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·后端
小满zs1 小时前
Go语言第十一章(协程)
后端·go
星星落进兜里2 小时前
Redis 内存缓存,面试补充
数据库·redis·面试
小程故事多_802 小时前
企业AI对话记忆架构实战,告别单一Redis存储,搭建长短效协同的记忆体系
人工智能·redis·架构
newerp4 小时前
Golang 函数调用的瞬间:栈帧、拷贝栈与逃逸分析
后端·程序员·go
newerp4 小时前
Golang 网络轮询器 netpoll:把阻塞 IO 变成事件通知
后端·程序员·go
合橱瑰4 小时前
从“假智能”到“真闭环”:Go 向量推理引擎的零硬编码调优实践
后端·go
名字还没想好☜4 小时前
Go 的 TCP 粘包与拆包:用长度前缀协议 + bufio 正确读消息
后端·tcp/ip·golang·go·php
盟道科技5 小时前
Redis+Lua 实现滑动窗口限流:从固定窗口踩坑到生产可用的完整方案
redis·junit·lua
Zenova EdgeOS5 小时前
Go 工业边缘 Protobuf 实战:从 proto 到 Marshal 的完整落地
物联网·go·边缘计算·序列化·protobuf·工业边缘