缓存与一致性补偿
区分目录缓存与交易库存,用五类对账把跨存储差异收敛。
两种 Redis 数据,两种职责
影片和场次详情是查询缓存,可以从 MySQL 重建;座位 Hash 则参与交易时的原子预占。不能因为它们都在 Redis,就把交易库存当成可随意丢弃的普通缓存。
目录详情采用 Caffeine 本地缓存和 Redis 远端缓存。源码设置本地 TTL 六十秒、逻辑过期约十分钟并附随机抖动、物理 TTL 三十分钟。逻辑过期意味着旧值仍在,可用于重建竞争期间的兜底。
| 数据 | 丢失后能否直接重建 | 过期或失效的含义 |
|---|---|---|
| Caffeine 目录详情 | 可以,从 Redis 或数据库重新获取 | 本实例需要重新加载 |
| Redis 目录详情 | 可以,从数据库重建 | 逻辑过期可暂用旧值;物理过期需回源 |
| Redis 座位 Hash | 需要结合影厅模板和有效订单恢复 | 涉及交易库存,不能只读模板全部置空 |
| 用户场次限购计数 | 与当前有效购票占用有关 | 不能随一次查询缓存失效而清零 |
两级缓存减少的是重复目录查询,并没有消除每次下单时对场次状态和座位归属的业务校验。交易正确性不能依赖目录缓存里恰好是最新值。
逻辑过期与互斥重建
未命中本地缓存时读取 Redis,物理未命中则回源数据库;远端值未逻辑过期就回填本地。逻辑过期后争抢带 token 的重建锁:持锁者同步加载并更新;其他请求最多短暂轮询,仍未得到新值则返回旧值。
private <T> T rebuild(String key, Class<T> type, Envelope stale, Supplier<T> loader) {
String lockKey = LOCK_KEY_PREFIX + key;
String token = UUID.randomUUID().toString();
if (lock(lockKey, token)) {
try {
T value = loader.get();
write(key, value);
return value;
} finally {
unlock(lockKey, token);
}
}
for (int i = 0; i < WAIT_ATTEMPTS; i++) {
if (!sleep()) {
break;
}
Envelope current = parse(redis.opsForValue().get(key), type);
if (current != null && !current.expired()) {
localCache.put(key, current.data());
return type.cast(current.data());
}
}
log.warn("等待逻辑过期缓存重建超时, 返回旧值: {}", key);
return type.cast(stale.data());
}
解锁脚本只删除 token 匹配的锁,避免超时后的旧持有者删除新锁。修改影片或场次时删除对应远端缓存并使当前实例的本地缓存失效;这并不等于实现了多实例本地缓存的广播失效。
逻辑过期把“值是否存在”和“是否应该更新”分开。热门键过期时,竞争失败的请求可以短暂等待或返回旧值,避免所有请求同时回源;代价是允许一段旧数据可见时间。TTL 抖动分散一批键同时过期的时刻,却不能代替单个热点键的互斥重建。
如果扩展到多个应用实例,每个实例仍有自己的 Caffeine 副本。当前本地失效只影响处理修改请求的实例,其他实例会按各自 TTL 更新;广播失效、统一版本号或事件通知属于后续改进,当前不能声称已具备实时一致的多节点缓存。
五类对账
public ReconcileReport runOnce() {
int expiredCancelled = cancelExpiredOrders();
int paidSeatsFixed = fixPaidSeatStates();
int hashesRebuilt = rebuildMissingSeatHashes();
int orphanLocksReleased = releaseOrphanLocks();
int soldOrphansReleased = releaseSoldOrphans();
ReconcileReport report = new ReconcileReport(
expiredCancelled, paidSeatsFixed, hashesRebuilt, orphanLocksReleased, soldOrphansReleased);
recordFixed("expiredCancelled", expiredCancelled);
recordFixed("paidSeatsFixed", paidSeatsFixed);
recordFixed("hashesRebuilt", hashesRebuilt);
recordFixed("orphanLocksReleased", orphanLocksReleased);
recordFixed("sold-orphan", soldOrphansReleased);
if (expiredCancelled > 0
|| paidSeatsFixed > 0
|| hashesRebuilt > 0
|| orphanLocksReleased > 0
|| soldOrphansReleased > 0) {
log.info(
"reconcile finished: expiredCancelled={}, paidSeatsFixed={}, hashesRebuilt={},"
+ " orphanLocksReleased={}, soldOrphansReleased={}",
expiredCancelled,
paidSeatsFixed,
hashesRebuilt,
orphanLocksReleased,
soldOrphansReleased);
}
return report;
}
| 差异 | 修复方式 | 业务依据 |
|---|---|---|
| 过期待支付订单 | 复用取消与释放链路 | 数据库订单状态和截止时间 |
| 已支付但 Redis 未售出 | 把对应座位修复为 2 | 已支付订单的座位记录 |
| 售卖中场次缺少座位 Hash | 从影厅模板及有效订单重建 | 模板、待支付和已支付占用 |
| Redis 锁定但没有有效订单 | 按场次互斥,分批释放孤儿锁 | 有效订单占用集合 |
| Redis 已售但没有已支付订单 | 分批释放孤儿已售状态 | 已支付占用集合 |
对账按批次限制处理量,避免一次全量扫描占满资源。超时订单额外留一分钟缓冲;批次限制意味着修复可能需要多个调度周期,不能承诺一个周期扫完所有数据。
为什么五类修复不能互相替代
超时取消处理有订单但应结束的预占;孤儿锁清理处理没有有效订单却留下的预占。这两个集合不同,单纯扫描过期订单找不到“进程在写订单前退出”的残留。
缺少整个座位 Hash 时,需要从影厅模板恢复完整座位集合,再叠加待支付和已支付占用;只修复已支付座位,会漏掉尚未付款但合法存在的订单。反过来,仅重建缺失 Hash 也无法修复 Hash 仍存在但个别字段漂移的情况。
已支付修复与孤儿已售清理分别处理两个方向的差异:数据库确认有效付款而 Redis 未标售出,以及 Redis 已售却找不到相应已支付依据。一个负责补齐,另一个负责消除残留。
故障窗口与恢复依据
| 故障发生处 | 可能残留 | 恢复依据 |
|---|---|---|
| Redis 预占后、订单提交前退出 | 锁定座位无有效订单 | 有效占用集合与孤儿预占清理 |
| 数据库提交后、消息发送失败 | 待支付订单缺少正常到期唤醒 | 过期时间扫描 |
| 支付状态与座位更新之间异常 | 已支付但座位显示不一致 | 已支付订单及对应座位 |
| Redis 数据缺失 | 座位图无法正常展示 | 影厅模板与有效订单共同重建 |
恢复动作应从持久化业务状态推导,而不是根据哪一条日志最后打印成功推断。对账日志与修复计数用于解释执行过程,不能替代订单和有效占用记录。
对账仍然有竞态
下单先 Redis 预占再提交数据库,对账此时可能误把在途预占当成孤儿。按场次的对账互斥只协调参与同一把锁的对账操作,不会自动阻止未参与该锁的下单与支付。
项目接受这一短暂显示漂移:MySQL 唯一约束阻止重复持久化占用,已支付座位修复继续收敛 Redis。对外描述应是“最终一致与补偿”,不能声称跨 Redis、数据库和消息的强事务。
后续若要缩小窗口,可以评估让下单与孤儿清理共享协调机制、记录预占身份及时间,或引入更明确的消息可靠投递方案。这些方法会增加锁竞争、状态字段和维护成本,应先根据残留差异的频率和持续时间判断收益;它们不是当前已经完成的实现。
