瞿才应
← 回到主页的项目列表

个人项目 · 高并发下单

Mini 秒杀高并发下单系统

我只做秒杀下单这一条主链路。库存不能超卖,同一用户不能重复下单,瞬时流量不能打穿数据库。Redis 预扣后先持久化本地消息,接纳成功才返回排队中,MySQL 订单事务提交才算下单成功;性能看同机对照,异常路径用暂停和强杀进程来验证。

  • Java 17
  • Spring Boot 3
  • Redis Lua
  • RabbitMQ
  • MySQL
  • k6
results下单主链路
超卖 / 重复下单
0
各轮压测的数据库订单事实
售罄尾部 p99
12ms
百分之九十九分位,基线 98 ms;同机单实例
故障与负载场景
9/9
同机三个 JVM,也就是三个 Java 应用进程
单实例入口稳定档
4,000/s
两组独立对照各到此档,不是落库速率
01

先把下单成功的边界定清楚

不能把“缓存扣成功”当成“订单成功”。Redis、消息队列和 MySQL 各有自己的提交时刻,中间可能丢响应、重复投递,也可能进程突然停住。入口要快,但每条异常路径都得能回到数据库事实。

资格 token 是下单前领到的一次性凭证。Redis Lua 把库存判断和扣减放进一次执行,中间不留给其他请求插进来的空隙;幂等表示重复请求也不多产生一笔业务结果。

下单主链路

领资格校验活动和限流,发一次性 token 与隐藏下单路径
原子预扣验路径与 token、用户幂等挡重复,Lua 扣库存并登记在途请求
先持久化本地消息表写下发送意图,异常时还有重试依据
排队削峰投 RabbitMQ 消息队列,接口返回排队中
事务落库抢占消息、插订单、条件扣库存、完成消息一起提交
看链路核对表
环节责任失败时的处理
资格与幂等限制入口流量、挡重复请求业务拒绝,不承诺下单成功
Redis 预扣扣减与在途登记一起完成不确定时保留登记
本地消息表保存可恢复的发送意图核对真实写入,不直接退库存
RabbitMQ削峰、投递与重投允许重复,消费端幂等收敛
MySQL 事务订单、库存、消息的最终事实一起提交或一起回滚

数据库是最后防线

扣库存必须满足 available_stock > 0,订单联合唯一索引挡重复。消费者抢占消息也在订单事务里,进程中途退出会一起回滚,不留下单独提交的“消费中”。

缓存和确认都排在提交之后

事务提交后才写 Redis 订单状态,再向 RabbitMQ 确认消费完成。缓存写失败、确认响应丢失,都不能把已提交的成功单改成失败。用户查询优先读 MySQL。

Redis 不可用时我选拒单,不让流量绕过资格、幂等和库存校验直接打 MySQL。恢复时也不是能连上就放量,任一商品核对不过,整个实例继续拒单。

02

售罄后的慢请求少了

售罄尾部 p99 从 98 ms 降到 12 ms。p99 是响应耗时的百分之九十九分位,用来看少数慢请求。这里统计领资格和下单两类 HTTP 请求,不是订单最终完成的耗时。

售罄尾部的整体 HTTP p99

同一台 Apple M1(8 核、16 GiB)、单实例;每秒 300 次迭代,持续 30 秒,初始库存 100;每版本三轮,取逐指标中位数。主场景关闭限流和防刷。

改造前改造后
看数据表
指标基线完整优化版
整体 HTTP p9998.27 ms12.04 ms
最终成功订单100100
用户重复订单组00

原来由 Java 一个个试库存分桶,快卖完时要反复往返 Redis。改成一次 Lua 扫完分桶,再用本地售罄标记快速拒绝,减少尾部的无效工作。完整优化版还包含高频日志异步化等改动。

这是基线对完整优化版,不是只开单次 Lua 的单点实验。卖光后很多迭代在领资格时结束,不再下单。不能把 12 ms 当作库存充足时的接口延迟,也不能把整体收益全归给 Lua。

03

两次提高入口稳定档,瓶颈各不相同

入口稳定档是恒定加压时还能稳定承接的最高到达率。容量测试每档 60 秒,每次迭代是新用户先领资格、再下单;系统错误率或压测端丢弃超过 1% 就停,最终对账也必须通过。它不是 HTTP QPS,更不是持续落库能力。

Linux 开发容器上,削减入口同步 SQL

单实例,数据库连接总预算保持 40;同机新旧版本交错。只比较这组的低磁盘卡顿窗口。

改造前改造后
看数据表
项目改造前改造后
入口稳定档3,000 /s4,000 /s
热活动成功迭代的入口同步 SQL5 次2 次,另有活动回源
数据库连接总预算4040

独占云服务器上,扩大 channel 缓存

channel 是消息连接里的逻辑通道。单实例,固定 40 条数据库连接,中间件数据放内存盘;同一个包只改缓存参数,交错三轮。

改造前改造后
看数据表
项目改造前改造后
入口稳定档3,500 /s4,000 /s
发布端 channel 缓存25256
各轮最高通过档3,500 /s,三轮一致4,000 /s,三轮一致

两张图来自不同环境,不横比绝对吞吐,也不串起来算累计提升。入口 SQL 那组的磁盘卡顿分组是看过数据后定的,结论限于低卡顿窗口,失败档和缺采样的档都保留在报告里。

入口等连接,先减少对连接的需求

线程栈显示大量入口线程停在数据库借连接上。我没有扩池,而是缓存活动元数据、让首条消息直接以“发送中”插入,省掉两次活动读取和一次单独的状态更新。热活动命中时,入口同步 SQL 从 5 次降到 2 次,不含消费者和后台任务。

Redis 未命中时查询历史订单这一步仍保留。缓存没记录不等于新用户,可能是旧订单的缓存过期或丢失;为了吞吐删掉回源,可能给老用户再发资格、多占库存。

消融里,省掉状态更新的收益更大。我的理解是,它省了一次独立写事务提交,比省主键读更划算;提交耗时没单独检验。

Redis 变快后,真正堵住的是消息发布

channel 是 RabbitMQ 连接里的逻辑通道。并发发布时,缓存不足就反复新建、关闭带发送确认的通道。我先试过关库存分桶,Redis CPU 降了,入口稳定档却没动;按入口分阶段耗时核对,两组数据库阶段的耗时接近,差距出在首次发消息。

缓存从 25 调到 256 后,同一组交错压测的稳定档从 3,500 /s 提到 4,000 /s。参数改完还重跑了三 JVM 九个故障场景,仍全部通过。

04

进程停在中间,谁还有权改库存

多实例最容易出错的地方,是旧进程醒来继续写。锁过期、读到的库存快照变旧,或者预扣成功但消息没落库,都不能靠“异常就加回库存”处理。

旧快照不能直接写回

租约是临时写权限。在 Redis 写回的同一个 Lua 里,重新核对租约持有者、库存版本、在途登记为空。读数据库之后只要有新预扣,版本就变了,旧快照作废。

写库结果不明,先抢唯一键

在途登记记住尚未落库的预扣。恢复者用同一个请求号插“已取消”墓碑,也就是取消记录,和真实消息争唯一键。只有墓碑先写成,才退库存;真实消息已提交就不退。退库存和删登记一起执行,最多退一次。

迟到确认不能改新状态

发送按消息行认领,每次换新 token 并设租约。回调要同时匹配 token、状态和数据库时间,旧回调改不动新一轮。旧线程仍可能再发一次消息,重复靠消费端幂等收敛。

安全不等于立即修好

持续有在途请求或版本变化,对账可能一直跳过。我选晚修、不修错。协调元数据全部丢失时,也不凭 MySQL 在线盲目重建,入口继续拒单,先停写核对。

同机三个 JVM 的九个故障与负载场景

三个应用进程共用 MySQL、Redis 和 RabbitMQ。故障点命中后才发暂停或强杀信号;每组停掉全部 JVM,再做最终跨系统核对。独占云服务器上的复测全部通过。

场景实际验证的结果判定
负载中修复只改坏 Redis 总量,负载期间受保护写回生效,停流后库存守恒通过
有效租约、过时快照旧修复者暂停后有新下单,醒来写回被库存版本校验拒绝通过
旧租约持有者醒来写回被租约校验拒绝,也删不掉接任者的锁通过
预扣后暂停墓碑取消,库存只退一次;旧 INSERT 撞唯一键,同用户新请求仍能成单通过
预扣后强杀其他节点清算孤儿预扣,库存只退一次,重启后可正常接纳通过
消息提交后强杀读到已提交消息,不退库存;其他节点补发,最终成单通过
发送认领交错新旧 token 不同,迟到确认改动 0 行,最终只有一单通过
负载中消费者丢失暂停、强杀再重启,积压排空后已持久化接纳都有成功订单通过
稀缺库存竞争410 个用户抢 41 件,41 单成功、369 次售罄、库存余 0通过
看最终核对项
核对项九组的最终结果
库存守恒总量 = 可用 + 已售,已售 = 成功订单
业务键与订单号重复0
在途登记与期限集合清空
消息终态与订单事实关联一致,没有悬挂消息
消息队列排空
Redis 与 MySQL 剩余库存一致

这是进程信号下的同机正确性验证,不是多机集群性能、网络分区或主从切换测试。夹具为了让交错可控,回收期限和扫描周期比常规配置短,不能把它们当生产恢复时限。

05

拐点移动了,后端仍会积压

入口能接住请求,不代表订单能按同样速率持续完成。下面只看 channel 缓存这一组,随着到达率接近拐点,尾延迟快速上涨。

到达率上升时,整体 HTTP p99 怎么变

独占云服务器,单 JVM,固定 40 条数据库连接,中间件数据放内存盘。横轴是迭代到达率,纵轴是毫秒;每点取该档三轮 p99 的中位数,失败档也保留。

改造前,缓存 25改造后,缓存 256
看三轮原始值与通过数
缓存到达率 /s首轮 p99 ms第二轮第三轮中位数通过数
252,00021.2528.4422.0322.033/3
253,00083.1969.13109.2183.193/3
253,500276.66263.74506.59276.663/3
254,0001,539.532,194.131,548.741,548.740/3
2562,00026.1817.4720.1320.133/3
2563,00063.0172.4278.7972.423/3
2563,500129.34178.62173.08173.083/3
2564,000290.94345.35457.37345.353/3

缓存 256 时,4,000 /s 三轮都通过,但压测窗口内每秒只落库 1,951 到 2,024 单,队列峰值有 10.3 到 10.7 万条。停流后排空才能收齐,不能把入口稳定档写成持续 TPS。

协议的往返压回去了,CPU 代价还在

把接纳时的读登记、清登记和排队状态投影合成一次 Lua 后,成功下单热路径的 Redis 往返由 5 次回到 3 次,和协议改造前相同。独占云服务器同档对照里,Redis CPU 仍是改造前的 1.6 到 1.9 倍。正确性校验并不免费。

同机多开 JVM 没有变快

调大 channel 缓存之前的另一组独立对照里,同样的 40 条连接,单 JVM 用满,对比三 JVM 拆成 14 / 13 / 13。单 JVM 三轮稳定档都是 3,500 /s,三 JVM 是 3,000、3,000、2,000 /s。池子拆小、消费者更多、JVM 多占 CPU,增加了竞争。

这也是我分别测入口、负载期落库、队列峰值和停流排空的原因。只盯一个接口延迟,可能只是把压力从入口移到了后面。

06

能说什么,不能说什么

能说

  • 各轮压测的数据库订单没有超卖、没有用户重复订单;优化版的业务失败有持久化记录。
  • 同机对照里,完整优化版降低售罄尾延迟;两组独立改造提高了各自的入口稳定档。
  • 现行协调协议在同机三 JVM 的九个故障与负载场景下通过最终核对。

不能说

  • 零超卖不等于入口永远恰好受理库存数。历史轮次有多受理后被 MySQL 拒绝的订单,也有基线消息没有完整闭环。
  • 不承诺消息只发一次。重复物理投递允许发生,业务结果靠唯一键和事务收敛。
  • 短窗口稳定档不是生产容量。独占云服务器的中间件数据放内存盘,不能代表真实云盘的持久化落库能力。
  • 没有线上用户,也没有多主机性能、网络分区、Redis 或 MySQL 主从切换的实测结论。

要继续部署,先补这些边界

  • Redis现行协议只支持单主,协调元数据不能丢失或被驱逐。Redis Cluster 和“MQ 失败就同步下单”旁路会拒绝启动。
  • 升级和管理协议升级要停掉所有旧进程再一起切换;已有库存不能在线覆盖,终态消息不能原地重放,取消墓碑还没有自动清理协议。
  • 生产化支付、商品中心、真实风控和跨主机容灾没做。我会先按真实磁盘、下游余量和消费者并发重新压测,不用同机数据推算扩容倍数。
07

术语速查

p99
响应耗时的百分之九十九分位。用来看尾部慢请求,不是平均耗时,也不自动代表最终落库耗时。
幂等
同一个业务请求重复执行,也不多产生一笔订单或多扣一份库存。入口去重和数据库唯一约束一起兜底。
Redis Lua
在 Redis 内一次执行库存校验和扣减,不让其他请求穿插。脚本报错不会自动回滚已执行的写,所以预检放在写入之前。
本地消息表
MySQL 中保存消息发送意图和状态的表。投递失败后仍有持久化记录可重试,不把 MQ 当订单事实源。
租约
有有效期的临时权限。旧进程醒来时必须由被写的资源重新核对,不能只看它曾经拿到过锁。
在途登记
记录已经 Redis 预扣、但尚未明确交给数据库消息的请求。恢复者用它找出悬空的库存责任。
取消墓碑
同请求号的一条取消记录,和真实消息争数据库唯一键。先取消成功,才确定迟到写入不能再把这笔请求受理。
channel
RabbitMQ 连接内的逻辑通道。缓存太小,并发发送会不断新建、关闭通道,拖慢首次发布。
入口稳定档
按固定窗口阶梯加压,错误、丢弃和最终对账都满足规则的最高到达率。表示入口短时承载,不表示无限期持续完成订单。
→

其他项目