🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
✨那些你一个人走过的夜路,终将化作照亮未 来的光



一、题目
12306 作为超高并发系统,在春节抢票场景下,数据一致性怎么保证?
二、新人常见答法:上手就用锁
刚接触高并发的人,第一反应往往是:
抢票 = 并发扣库存 → 必须上锁。
做法类似:
- 用分布式锁锁住(Java 里常提 Redisson;Go 里一般用 go-redis 做
SET key uuid NX EX) - 抢锁的本质:抢一个大家都能看到的 Redis key,谁先
SET NX成功谁持有锁 - 扣完库存,再同步写 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:前两段卖了,后两段空着。
查「广州南到郴州(前两段)能不能卖」:
- 构造掩码(mask),例如关注前两段:
1100 - 位图与掩码做按位与(AND)
- 结果为
0→ 无重叠,可卖 - 非
0→ 有重叠,不能卖
为何要 Lua?
「查位图 + 改位图扣减」必须原子完成,否则两人同时看到空闲会一起扣成功。
- Java 口播里常说 Jedis
eval - Go 里用 go-redis 执行 Lua 脚本(
Eval/Script.Run)
前端请求过来:
- Lua 一把完成 Bitmap 校验与扣减
- 生成订单号直接返回
- 热路径几乎全在 Redis 完成
注意:这里互斥是细粒度的(这个座位的这些位),不是锁整趟车。
七、订单不落同步写 MySQL:丢进消息队列
扣座成功后:
- 不要同步死等写 MySQL
- 把订单信息发给 RocketMQ(或等价 MQ;Go 用对应的 Go 客户端)
- 也可以用「本地消息表 + 定时补偿」保证消息可靠投递
消费者拿到消息后大致:
- 先在数据库「扣减记录表」插一条(便于幂等与对账)
- 再创建完整订单
- 回写 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 是投影;投影错了按账本修正。
像月末银行流水勾兑:日记账允许偶尔错漏,但对账机制能把账调平。