SeatFlow 选座页:座位按可选、锁定中、已售、已选四种状态着色,底部是已选座位和合计金额

影院选座与票务

SeatFlow

面向影院的在线选座票务系统,提供场次浏览、实时选座、锁座下单与模拟支付。

  • Java 21
  • Spring Boot
  • Redis
  • RocketMQ
04 / 05 · DOCUMENTATION

缓存与一致性补偿

区分目录缓存与交易库存,用五类对账把跨存储差异收敛。

两种 Redis 数据,两种职责

影片和场次详情是查询缓存,可以从 MySQL 重建;座位 Hash 则参与交易时的原子预占。不能因为它们都在 Redis,就把交易库存当成可随意丢弃的普通缓存。

目录详情采用 Caffeine 本地缓存和 Redis 远端缓存。源码设置本地 TTL 六十秒、逻辑过期约十分钟并附随机抖动、物理 TTL 三十分钟。逻辑过期意味着旧值仍在,可用于重建竞争期间的兜底。

数据丢失后能否直接重建过期或失效的含义
Caffeine 目录详情可以,从 Redis 或数据库重新获取本实例需要重新加载
Redis 目录详情可以,从数据库重建逻辑过期可暂用旧值;物理过期需回源
Redis 座位 Hash需要结合影厅模板和有效订单恢复涉及交易库存,不能只读模板全部置空
用户场次限购计数与当前有效购票占用有关不能随一次查询缓存失效而清零

两级缓存减少的是重复目录查询,并没有消除每次下单时对场次状态和座位归属的业务校验。交易正确性不能依赖目录缓存里恰好是最新值。

逻辑过期与互斥重建

未命中本地缓存时读取 Redis,物理未命中则回源数据库;远端值未逻辑过期就回填本地。逻辑过期后争抢带 token 的重建锁:持锁者同步加载并更新;其他请求最多短暂轮询,仍未得到新值则返回旧值。

seatflow/seatflow-service/src/main/java/com/seatflow/service/cache/CatalogCacheService.javaL126-150 @472bd43
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 更新;广播失效、统一版本号或事件通知属于后续改进,当前不能声称已具备实时一致的多节点缓存。

五类对账

seatflow/seatflow-service/src/main/java/com/seatflow/service/reconcile/ReconcileService.javaL78-106 @472bd43
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、数据库和消息的强事务。

后续若要缩小窗口,可以评估让下单与孤儿清理共享协调机制、记录预占身份及时间,或引入更明确的消息可靠投递方案。这些方法会增加锁竞争、状态字段和维护成本,应先根据残留差异的频率和持续时间判断收益;它们不是当前已经完成的实现。