Article / 并发与一致性
电影可以缓存,座位不行
二级缓存、逻辑过期与 SETNX 重建,以及哪些数据永远不该进缓存。
同一个系统里有两条读路径:电影与场次信息被反复浏览,座位的可售状态在每次选座时被反复判断。一个自然的想法是给两者都加缓存——但缓存从来不是「性能开关」,而是给一致性打的折价。要回答的问题不是「怎么加缓存」,而是「这份数据允许旧多久」。SeatFlow 的答案把两类数据推向了相反的方向:电影信息有两层缓存,座位状态一层都没有。
座位为什么不缓存
座位状态是每一次下单的判断依据:0 可以买、1 被别人锁定、2 已售。这份判断决定钱,任何层级的本地副本都会让「检查」与「写入」重新分离——而分离正是并发 bug 的源头。更关键的是,座位状态本来就住在 Redis(内存数据库)里:读一次是 HGET,读一场是 HGETALL,一次网络往返已经足够快。再套一层缓存,收益趋近于零,风险却会推翻座位锁建立的原子性。
所以座位读路径的原则是:座位状态只有 Redis 一个来源,没有副本。座位图接口把影厅模板(MySQL)与座位状态(Redis)拼在一起返回,状态永远实时。这条边界是文章标题的另一半:不是「座位不适合缓存」,而是「座位状态就是缓存本身,它已经是那个最快的东西了」。
电影与场次:值得缓存的理由
电影和场次是另一类数据:写频率低(上架、改简介、调价)、读频率高、容忍分钟级的陈旧。观众晚一分钟看到某部电影的新简介,没有任何业务后果;这类数据才是缓存的用武之地。但有一条例外必须单独对待:场次的开售状态决定下单准入。 场次从「待开售(0)」变为「售票中(1)」的那一刻起,用户就应该能下单;如果缓存让这个变化延迟了十分钟,业务上是不可接受的。所以失效时机的清单里,开售(open)是最关键的一项,后面会展开。
缓存的形态是两层:
- 本地 Caffeine:60 秒过期、上限 1000 条。挡住单实例内的重复读取,不产生网络往返。
- Redis:逻辑过期 10 分钟(含 0~60 秒随机抖动),物理 TTL 30 分钟。跨实例共享,也是重建的协调点。
- 再下一层才是 MySQL。
逻辑过期:用「多旧一会儿」换「不击穿」
如果 Redis 的键在到点时物理消失,下一个瞬间的所有并发请求会一起未命中,一起打向数据库——这就是缓存击穿,过期时刻越集中,冲击越大。SeatFlow 采用逻辑过期:值里携带过期时间戳,物理上键活得比逻辑上更久:
{"expireAt": 1789566000000, "data": {"id": 1, "title": "..."}}
读取逻辑是:本地命中直接返回;否则读 Redis,解析出信封,未过期就回填本地并返回;已过期则进入重建流程。到点的旧值依然可读、继续服务请求,只是由第一个拿到重建锁的请求负责刷新:
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)) { // SETNX,TTL 10 秒
try {
T value = loader.get(); // 赢家回源
write(key, value); // 写两层
return value;
} finally {
unlock(lockKey, token); // token 匹配才删除,避免误放
}
}
// 输家:最多轮询 25 × 20ms,仍未就绪则返回旧值
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());
}
三个工程细节:重建锁用 SETNX + token,释放时用一段小 Lua 比较 token 后才删除——直接 DEL 会误删另一个实例刚拿到的锁;输家最多等 500 毫秒,等不到就返回旧值,宁可多旧一会儿,不可打穿数据库,也不能报错;逻辑过期时间加 0~60 秒的随机抖动,避免一批缓存同一秒集体过期(雪崩)。
失效的时机清单
缓存一致性出问题,十有八九不是读路径的错,而是写路径漏了一次失效。SeatFlow 把失效范围限定在一处代码(CatalogCacheService.invalidate:删 Redis + 清本地),并让所有写路径调用它:
- 影片的修改、上下架;
- 场次的改期、删除;
- 场次开售。
开售这一项最值得展开。open 的动作是「初始化 Redis 座位状态,然后把场次状态从 0 改为 1」,而订单创建会校验场次必须处于售票中。如果开售之前有人浏览过这个场次(缓存里是状态 0),开售后缓存若未失效,下单入口会持续拒绝用户——最长十分钟,直到逻辑过期。这不是理论问题,是代码审查中明确点名的场景,对应的回归测试 openInvalidatesShowCache 会先把场次读进缓存、再执行开售、最后断言读到的是状态 1。这类「写路径漏失效」的 bug 的共同特征是:所有单链路测试都通过,只有把两条链路连起来才暴露。
sequenceDiagram
participant C as 客户端
participant L as Caffeine(本地)
participant R as Redis(逻辑过期)
participant D as MySQL
C->>L: 读电影/场次
L-->>C: 命中(60s 内)
L->>R: 未命中,读信封
alt 未到逻辑过期
R-->>C: 返回并回填本地
else 已过期
R->>R: SETNX 重建锁
alt 抢到锁(赢家)
R->>D: 回源
D-->>R: 写响应+本地
else 未抢到(输家)
R-->>C: 轮询后拿新值或返回旧值
end
end
写后删,而不是写后改
失效动作是「删除」而不是「更新缓存」:写路径改完数据库,直接把两层缓存删掉,下一次读自然重建。理由是值在 Redis 里是一层信封(带过期时间戳),写后改需要同时维护两份序列化逻辑;而写后删只依赖「下次读会回源」这一个假设。代价是更新后的第一次读会多一次数据库查询——在这个读多写少的场景里,这是划算的交换。
对事务内的写路径,失效注册在 afterCommit:事务回滚时缓存保持原样,不会留下「数据库没改、缓存却删了」或更糟的「缓存已更新、数据库回滚」的错位。
怎么测试缓存
缓存测试如果只断言「调用了缓存」,等于什么都没测。SeatFlow 的方法论是让缓存与数据库分叉,再断言谁赢了:
- 先通过服务读一次(缓存预热),然后绕过服务、直接用 JDBC 改数据库,再读一次——拿到的还是旧值,证明命中的是缓存而不是数据库;
- 通过服务执行更新,再读——拿到新值,证明失效奏效;
- 把 Redis 里的信封改成「已过期 + 旧内容」,再读——拿到数据库的新值,证明逻辑过期触发了重建;
- 预热场次后执行开售,再读——状态为 1,证明开售路径的失效没有被遗漏。
这四条用例不需要 mock 任何组件,全部是真实行为:分叉是真实的分叉,命中和重建是真实的命中与重建。
代价与边界
- 输家最多等 500ms:CI 高负载下赢家回源变慢时,输家会直接拿到旧值并打一条 warn。失败模式是「更旧」,不是「错误」——这是刻意的选择。
- 不做空值缓存:查询不存在的 ID 每次都会穿透到数据库(负缓存留作优化)。
- Redis 故障没有降级路径:缓存层不可用时读请求会失败,而不是回源数据库。对一个演示项目这是可接受的取舍,生产环境需要显式降级。
- 锁 key 前缀拼出了
lock:cache:cache:movie:1:功能正确,纯属不优雅,记录在案。 - 缓存对象共享引用:本地缓存和调用方持有同一个对象,调用方原地修改会污染缓存;当前约定只读,防御性拷贝留作加固项。
缓存策略的真正参数不是过期时间,而是业务对陈旧度的容忍曲线:座位的容忍度是零,连一分钟都不行;电影简介的容忍度是十分钟;场次开售状态夹在中间——它本身可以缓存,但它变化的那一刻必须立刻可见。