打造能落地的高并发仿12306售票

时间:2026-10-17 05:11:34来源:力不从心网 作者:焦点

12306 的打造地难点从来不是“高并发”三个字,而是高并“余票扣减 + 订单创建 + 支付回调”这条链路在几十万人同时点“提交订单”时还能保证不超卖。我见过一个真实案例:某小团队照搬电商秒杀方案,发仿把库存扣减放 Redis,售票结果春运压测时发现同一张票被卖了三次——原因是打造地 Redis 扣减成功但 MySQL 落库失败,没有对账补偿。高并

所以仿 12306 的发仿第一步,是售票把“票”当成有向区间的库存,而不是打造地普通商品。G1234 次列车,高并北京南到上海虹桥,发仿中间停 5 站。售票一张票的打造地本质是“某段区间的占用”。你买北京南到济南西,高并他买济南西到上海虹桥,发仿这两张票可以同时卖,因为区间不重叠。但如果你买北京南到上海虹桥,整条线路的库存都得扣。

我自己的做法是:用“位图 + 原子操作”来管理区间库存。每趟车每个座位类型维护一个 bitset,长度等于区间段数。比如 6 个站就有 5 段,bitset 为 11111 表示全空。买北京南到济南西,就是把第 0 段置 0。用 Redis 的 BITFIELD 命令做原子置位,返回旧值判断是否冲突。这个方案比“每段一个计数器”省内存,也比 Lua 脚本简单。

BITFIELD seat:1001:1A SET u1 0 0 GET u1 0

但 Redis 只做“预扣”。真正落库前,还得靠数据库唯一索引兜底。订单表里加一个 seat_segment 字段,存“车次+座位+区间段”的哈希,建唯一索引。Redis 预扣成功但 MySQL 插入冲突时,直接返回“票已售完”,同时异步回滚 Redis 位图。这个异步回滚用消息队列做,别在请求线程里同步重试,否则高峰期线程池瞬间打满。

订单创建环节,我坚持“先落库再返回”。很多人为了压测数据好看,把订单写进 Redis 就返回成功,结果用户支付完发现订单不存在。12306 这种场景,用户容忍度极低。我的做法是:MySQL 分库分表,按车次日期哈希,单表控制在 2000 万行以内。插入订单用 INSERT IGNORE,配合唯一索引,冲突直接忽略。插入成功后发一条延迟消息,30 分钟未支付就关单并回补库存。

支付回调是另一个坑。微信/支付宝的回调可能重复,也可能乱序。我的处理是:回调表加 out_trade_no 唯一索引,先插入回调记录,插入成功才处理业务。处理时用状态机:待支付 -> 已支付 -> 已出票,每个状态变更都带版本号,用 UPDATE ... WHERE status = 旧状态 做乐观锁。这样即使回调重复,第二次更新影响行数为 0,直接返回成功。

压测时别只看 QPS。我关注三个指标:Redis 位图冲突率、MySQL 唯一索引冲突率、订单状态机回滚率。冲突率超过 5% 说明预扣逻辑有问题,回滚率超过 1% 说明支付回调有重复。有一次我把 Redis 位图 key 的过期时间设成 10 分钟,结果用户选座后犹豫了 12 分钟才提交,位图已过期,库存被重复扣减。后来改成“位图不过期,靠延迟消息回补”,问题消失。

最后说个反直觉的点:别用分布式锁。我试过用 Redisson 锁座位,QPS 直接掉到 800。后来改成“位图原子操作 + 唯一索引兜底”,单节点 Redis 扛住 3 万 QPS 预扣,MySQL 分 16 库后写入也没压力。仿 12306 的核心不是堆中间件,是把“区间库存”这个模型吃透,然后用最笨的数据库约束兜底。

相关内容
推荐内容