12306 春运抢票:数据一致性怎么保证

🥰个人主页:会编程的土豆(欢迎来访)

💎作者简介:后端学习者

❄️个人专栏:数据结构与算法数据库leetcode

那些你一个人走过的夜路,终将化作照亮未 来的光


一、题目

12306 作为超高并发系统,在春节抢票场景下,数据一致性怎么保证?


二、新人常见答法:上手就用锁

刚接触高并发的人,第一反应往往是:

抢票 = 并发扣库存 → 必须上锁。

做法类似:

  1. 用分布式锁锁住(Java 里常提 Redisson;Go 里一般用 go-redis 做 SET key uuid NX EX
  2. 抢锁的本质:抢一个大家都能看到的 Redis key,谁先 SET NX 成功谁持有锁
  3. 扣完库存,再同步写 MySQL,事务一包

看起来很完美。

压测结果

TPS 往往直接掉到三位数。

为什么?

因为锁会把并行强行变成串行

  • 一节车厢上千个座位
  • 每个座位本来可以独立扣减、并行处理
  • 若锁的是整趟车(或锁粒度很粗、锁持有很久)
  • 所有请求都卡在抢同一把锁上

Redis 自己执行命令很快;但外面先用一把粗锁把人排成单列,内部再快也被架空

这很像 MySQL 里用了表锁,而不是只锁冲突的那一行。


三、进阶一点:TCC / Saga

思路:

  • Try:预占座位
  • Confirm:确认出票
  • Cancel:失败释放

逻辑没问题,偏强一致 / 准实时一致。

但放到春运秒杀:

  • 一个 Try 往往要写至少两条记录
  • 还要事务协调器维护全局状态
  • 链路从几毫秒变成几十甚至上百毫秒

锁和资源多持有一丁点时间 ,后面排队请求就多堆一截。

强一致方案在这种量级下,吞吐容易断崖下跌。

根源是:协调太重、路径太长、持有太久。


四、老手心法

放弃热路径上的强一致,拥抱最终一致,用补偿兜底。


五、车票怎么存:席位复用

以 G7852 为例,经停多个站,切成多个区间(站与站之间):

复制代码
广州南 → 韶关 → 郴州 → 衡阳东 → 长沙南
  区间1    区间2    区间3     区间4

同一个座位可以像乐高一样拆开卖:

  • 张三买前两段(广州南→郴州)
  • 李四买后两段(郴州→长沙南)

区间不重叠 → 不冲突 → 多赚一份。这就是席位复用


六、热路径:Redis Bitmap + Lua 原子扣座(Go:go-redis)

怎么判断某座位某区间是否可卖?

Bitmap(位图)

  • Key 示例:G7852:20260214:车厢号:座位号
  • Value:二进制位图,每一位代表一个区间
    • 1 = 已占用
    • 0 = 空闲

例如 1100:前两段卖了,后两段空着。

查「广州南到郴州(前两段)能不能卖」:

  1. 构造掩码(mask),例如关注前两段:1100
  2. 位图与掩码做按位与(AND)
  3. 结果为 0 → 无重叠,可卖
  4. 0 → 有重叠,不能卖

为何要 Lua?

「查位图 + 改位图扣减」必须原子完成,否则两人同时看到空闲会一起扣成功。

  • Java 口播里常说 Jedis eval
  • Go 里用 go-redis 执行 Lua 脚本(Eval / Script.Run

前端请求过来:

  1. Lua 一把完成 Bitmap 校验与扣减
  2. 生成订单号直接返回
  3. 热路径几乎全在 Redis 完成

注意:这里互斥是细粒度的(这个座位的这些位),不是锁整趟车。


七、订单不落同步写 MySQL:丢进消息队列

扣座成功后:

  • 不要同步死等写 MySQL
  • 把订单信息发给 RocketMQ(或等价 MQ;Go 用对应的 Go 客户端)
  • 也可以用「本地消息表 + 定时补偿」保证消息可靠投递

消费者拿到消息后大致:

  1. 先在数据库「扣减记录表」插一条(便于幂等与对账)
  2. 再创建完整订单
  3. 回写 Redis 一个「落库成功」标识

前端拿着订单号轮询该标识,查到了再跳转支付。

主路径顺序可以记为:

复制代码
Redis 占座 → 发 MQ →(异步)写 MySQL

八、还没完:会出烂账的坑

坑 1:Redis 主从切换

  • 主节点扣了 Bitmap
  • 还没同步到从节点就挂了
  • 从节点提主后,库存像「倒退」→ 可能超卖

坑 2:消息队列

  • 半路丢消息
  • 消费失败
  • 落库异常

任一环断裂,都可能变成:

Redis 票扣了,库里却没有订单。


九、填坑:对账补偿

既然不能保证永不丢,就保证能发现丢了,并能修

双账本

Redis 侧:

每次扣减成功,Lua 顺手写一条 Hash 记录,例如:

  • Key:某次列车某天的扣减账本
  • Field:订单号
  • Value:JSON(座位号、区间、状态、时间戳等)

(Java 口播里的 StringRedisTemplate / RMap;Go 里就是 go-redis 的 HSET 等。

MySQL 侧:

消费者落地时写一张结构镜像的扣减记录表。

两边都有账,对账才有依据。

定时对账

  • Go 里用定时任务 / worker(对应 Java 的 @Scheduled
  • 例如每分钟扫一次 3~10 分钟前 的窗口

为何不扫「刚刚」的单?

要给 MQ 消费留缓冲。新单可能还在队列里,此时查不到就回滚,会误伤

三种结果

情况 处理
两边都有且状态一致 通过
Redis 有、数据库没有 多半消息丢了或消费失败 → 执行回滚 Lua:把 Bitmap 对应位翻回 0,删掉 Hash 记录,还库存(原子,避免二次超卖)
两边都有但状态不一致 例如 Redis 仍「锁定中」,DB 已是「已售」→ 以数据库为准同步 Redis;反过来 DB 未售而 Redis 仍锁着 → 回滚 Bitmap

原则:

数据库是账本(真相),Redis 是投影;投影错了按账本修正。

像月末银行流水勾兑:日记账允许偶尔错漏,但对账机制能把账调平。


相关推荐
零域码客1 小时前
Redis 双刃剑:缓存与分布式锁的本质区别
redis·分布式·缓存·并发控制·后端架构
Escalating_xu1 小时前
【Linux】线程概念与控制:从地址空间、LWP 到 pthread_create、join、detach 与 NPTL
java·linux·redis
InfinitePlus2 小时前
docker redis部署
redis·docker·容器
程序员爱钓鱼3 小时前
Go 编程实战:数组 Array——固定长度的数据集合
后端·面试·go
ltl11 小时前
Redis 紧凑编码:listpack、quicklist 与 intset
redis
野生技术架构师1 天前
Redis 和 MySQL 如何保证数据一致性?先更新数据库还是先删缓存,延迟双删、MQ、Canal 一次讲透
数据库·redis·缓存
Devin~Y1 天前
从内容社区到AI智能客服:Spring Boot + Spring Cloud + Spring AI 全栈实战面试拆解
java·spring boot·redis·elasticsearch·spring cloud·kafka·mybatis
2602_959960921 天前
电商大厂Java面试:从Spring Boot、JPA、微服务到Redis、Kafka、Spring Security与监控,谢飞机的爆笑三轮问答
java·jvm·spring boot·redis·面试题
愈努力俞幸运1 天前
token,context window,top_p,temperature,skill,Harness Engineering
数据库·redis·缓存