Go Redis Lua Kafka Canal Nginx

电商平台秒杀系统架构设计实战

1000万QPS双十一秒杀实战:动静分离+Lua原子扣减+五层漏斗拦截+库存防超卖,从瞬时洪峰到零超卖零崩溃的完整架构

一、项目概述

1.1 业务背景与流量特征

电商平台的秒杀活动是拉新促活的核心手段,但秒杀带来的流量特征与常规业务截然不同。双十一零点开抢的瞬间,流量会在毫秒级内从日常的几万QPS暴涨至千万级,并在5-30分钟内迅速回落。这种"瞬间洪峰"对系统的每个环节都构成极大压力——数据库连接池耗尽、缓存击穿、CDN回源风暴,任何一个环节的疏漏都可能引发系统性崩溃。

系统需要支撑的典型场景包括:

  • 限量抢购:1万件茅台/茅台/iPhone/演唱会门票,5分钟内售罄,数百万用户同时涌入
  • 整点秒杀:京东/天猫整点秒杀频道,准点开放,流量瞬间拉升
  • 阶梯团购:拼多多式成团玩法,达到人数阈值后发货
  • 新人专享:首单新用户限购一件,需要与老用户严格隔离

1.2 核心业务指标

1000万+ 峰值并发QPS
1万/s 库存扣减能力
100% 库存准确性(零超卖)
<100ms 用户等待时间
15分钟 支付窗口期

其中"零超卖"是秒杀系统的绝对红线——多卖出去的订单在支付时发现无货,要么退款补偿,要么引发客诉,一旦出现就是重大生产事故,直接影响平台信誉和财务损失。

1.3 核心矛盾分析

秒杀系统的本质矛盾是:超高并发读(商品详情页刷新)和严格一致性写(库存扣减)的流量差异,以及两者对系统资源的完全不同的压力特征。

维度 读请求 写请求(扣减)
流量规模 1000万QPS(详情页刷新) 10万QPS(实际有效下单)
一致性要求 最终一致即可(CDN缓存) 强一致性(原子扣减)
瓶颈位置 CDN/带宽 Redis/数据库
技术策略 尽可能缓存,层层拦截 单线程原子操作,零并发冲突

1.4 动静分离决策

秒杀商品的详情页,99%以上的内容是静态的——商品名称、图片、描述、秒杀规则。而真正需要实时获取的只有"当前库存数量"和"秒杀开始时间"两个动态字段。如果所有请求都打到后端服务器,即便是展示商品详情页,也足以把后端服务打爆。

因此,我们对商品详情页做了彻底的动静分离:

  • 静态内容(商品名称、图片、描述、秒杀规则):编译为静态HTML,上传到CDN,TTL设置为30秒。99%的用户请求由CDN直接响应,后端QPS接近0。
  • 动态接口(库存数量、秒杀开始时间):独立部署限流保护,不与商品详情页共享带宽,使用独立的Redis连接池。
  • HTML5推送:秒杀开始前5分钟,将最新的静态HTML推送到CDN所有边缘节点,避免冷启动。

1.5 为什么选择Redis做库存扣减

库存扣减的核心要求是:同一时刻只有一个请求能成功扣减同一件库存。这在Redis中天然成立——Redis采用单线程事件循环模型,所有命令在执行期间都是串行的,没有任何并发冲突。

相比之下,MySQL的行锁方案在10万QPS的扣减请求下,数据库连接池会在毫秒内耗尽,即使加上了连接池排队和超时机制,整体QPS也会从10万跌到几百,系统陷入不可用状态。

💡 Redis单线程模型的本质:Redis的"单线程"指的是命令执行阶段是单线程,但网络IO和协议解析是多路复用的(epoll/kqueue)。这意味着:所有库存扣减命令在Redis内部是严格串行的——即使10万个并发连接同时发来DECR命令,Redis也会一个一个地执行,保证原子性。没有任何分布式锁、没有任何乐观锁,Redis本身就是最强的分布式锁。

二、技术架构设计

2.1 五层漏斗拦截模型

整个秒杀系统的核心思想是"逐层拦截无效流量"——在每一层尽可能早地过滤掉无效请求,最大限度保护下游资源。每一层都根据当前系统的承载能力和业务规则,丢弃不符合条件的请求。

L1
CDN / 就近接入

拦截 ~50% 请求:静态资源(图片/CSS/JS)、未开始秒杀的商品详情页。CDN边缘节点直接返回缓存,无需回源。关键技术:CDN缓存策略、边缘计算(Edge Computing)判断秒杀状态。

L2
秒杀网关(Nginx/OpenResty + Lua限流)

拦截 ~45% 请求:超出限流阈值的请求直接丢弃;未登录请求直接返回;IP黑名单/风控标记请求拦截。关键技术:令牌桶算法、用户维度+IP维度+全局维度三重限流。

L3
秒杀服务(Redis Lua原子扣减)

拦截 ~4% 请求:库存耗尽、用户已购买、幂等Token重复。关键技术:Lua原子脚本、用户限购、幂等检查。1000万QPS → ~5万QPS。

L4
消息队列(Kafka异步下单)

削峰填谷:将扣减成功的下单消息投递到Kafka,订单消费者异步处理。关键技术:Exactly-Once语义、事务模式、幂等消费。

L5
订单履约(MySQL + 支付回调)

最终一致性:Kafka消费者创建订单,发送支付通知;支付回调更新订单状态,触发发货。关键技术:分库分表、分布式事务、补偿机制。

2.2 完整流量漏斗计算

以双十一iPhone 15 首发为例,展示五层漏斗的实际效果:

层级 拦截率 剩余QPS 说明
用户发起请求 1000万 双十一零点整点开抢
L1 CDN ~50% 500万 缓存命中+静态资源拦截
L2 网关限流 ~45% 50万 令牌桶+验证码+黑名单
L3 Redis扣减 ~95% 2.5万 库存耗尽+用户限购+幂等
L4 Kafka ~0% 2.5万 削峰缓冲,正常投递
L5 订单履约 ~0% 2.5万 全部成功下单(库存充足)

2.3 动静分离架构

用户浏览器 / App

秒杀按钮点击 → POST /api/seckill/order

CDN(静态HTML + 库存轮询接口)

商品详情页缓存(TTL=30s) | 库存数字独立接口(独立限流保护)

秒杀网关(Nginx/OpenResty)

Lua限流 | 验证码拦截 | IP/用户维度限流 | 路由到秒杀服务

Redis Cluster

库存扣减(Lua原子脚本) | 用户限购检查 | 幂等Token | 分桶Key打散热点

Kafka + 订单服务 + MySQL

消息异步下单 | 支付回调 | 库存回补

2.4 Canal实时同步MySQL库存到Redis

Redis库存是缓存,必须与MySQL主库存保持最终一致性。采用阿里开源的Canal监听MySQL binlog,实时同步库存变更:

  • 管理员加库存:MySQL UPDATE → Canal监听binlog → Redis同步更新
  • 秒杀结束后回补:未付款订单超时 → MySQL回滚库存 → Canal同步 → Redis恢复
  • Canal可靠性:Canal Server支持集群部署,通过ZooKeeper做Leader选举,避免单点

2.5 分桶库存设计

即使使用了五层漏斗,单个热点商品的库存Key仍然是Redis中最热的Key。在1000万QPS下,即使只有1%穿透到Redis,也是10万QPS对单Key的操作。采用分桶策略将单个热Key打散为多个Key:

// 分桶策略:将单Key库存拆分为N个桶,每个桶独立扣减
// 原:seckill:stock:goods_123 (单Key,QPS=100000)
// 分桶后:
// seckill:stock:goods_123:bucket_0  (库存 2000)
// seckill:stock:goods_123:bucket_1  (库存 2000)
// seckill:stock:goods_123:bucket_2  (库存 2000)
// ...
// seckill:stock:goods_123:bucket_4  (库存 2000)

// 扣减时:根据 userId 哈希选择分桶
bucketIndex := crc32.ChecksumIEEE([]byte(userId)) % bucketCount
stockKey := fmt.Sprintf("seckill:stock:%s:bucket_%d", goodsId, bucketIndex)

// 用户维度限购Key也需要对应分桶
orderKey := fmt.Sprintf("seckill:orders:%s:bucket_%d", goodsId, bucketIndex)

三、核心技术挑战与解决方案

挑战一:Redis单线程扣减如何绑定用户唯一身份,防止同一用户抢到多件?

这是秒杀系统中最容易被忽视但危害极大的问题。如果只做库存扣减(DECR),同一个用户可以用脚本同时发起100个并发请求,全部通过库存>0的检查,都执行扣减。如果库存只有1件,理论上可能卖出远超库存的数量。

✅ 解决方案:用户维度限购 + 幂等Token双重保障

第一重:Redis Set存储已购买用户ID(用户维度限购)—— 库存扣减Lua脚本中,在DECR库存之前,先执行 SISMEMBER seckill:orders:{goods_id} {user_id} 检查用户是否已购买。若返回1,说明该用户ID已经买过,直接返回-2(已购买)。这个检查在Redis单线程中完成,没有任何并发问题。

第二重:幂等Token防止重复提交—— 用户点击下单时,浏览器先生成唯一幂等Token(如 {user_id}:{goods_id}:{timestamp}:{random} 的SHA256),服务端先检查Redis中该Token是否已存在(EXISTS idempotent:{token})。若存在说明是重复提交,直接拒绝;若不存在则设置Token并继续扣减,TTL=600秒。

第三重:业务层面的账号体系绑定—— 无论用户从哪个端(PC/App/小程序)访问,最终都会汇聚到同一个登录账号体系(手机号或第三方UnionID)。限购的user_id必须使用账号体系中的全局唯一ID,而非设备ID或Cookie,确保跨端限购生效。

🔍 值得深挖:同一用户多端登录如何统一限购?

这个问题远比表面看起来复杂。PC端登录用的是账号密码,手机App用的是手机号+验证码,微信小程序用的是微信UnionID,这三个ID体系如何统一?

方案一:账号体系中心化—— 在用户中心维护一个 user_id ↔ union_id 的映射表。限购逻辑只认 user_id(账号体系主键),App和网页登录时将 union_id 映射到 user_id,微信小程序直接使用 union_id 作为 user_id。这样无论用户从哪个端进来,最终都路由到同一个账号ID。

方案二:Cookie/Token与用户ID绑定—— 使用 httpOnly + secure + sameSite=strict 的Cookie存储加密后的用户Session,前端JavaScript无法访问,只能在请求时自动携带。服务端解密后得到user_id,参与限购检查。关键安全点:Cookie签名使用HMAC-SHA256,防止伪造。

方案三:设备指纹 + 行为分析—— 对于未登录用户,使用设备指纹(UA、屏幕分辨率、Canvas指纹、WebGL指纹)做初步识别。但这只能作为辅助手段,因为设备指纹可以被篡改。

实战经验:我们采用的是方案一+方案二组合。账号中心提供统一的 resolveUserId(identity, channel) 接口,传入身份标识和渠道来源,返回标准化的 user_id。这层抽象也让我们能灵活应对渠道扩张(如抖音快手小程序登录)。

挑战二:Redis主从切换时的库存超扣问题

即使Redis是单线程的,主从复制带来的延迟仍然会引发超扣问题。当主节点已执行扣减并返回成功,但尚未同步到从节点时,主节点宕机——哨兵/Cluster自动将从节点提升为主节点。此时新主节点上没有刚才的扣减记录,如果再有请求进来,就会重复扣减。

✅ 解决方案:Redis Cluster + 读写分离策略 + RedLock

方案一:Redis Cluster + 写后读校验(推荐)—— 使用Redis Cluster模式,每个分片有多个副本。写入时等待主从都确认(WATI命令,指定N个副本确认后才返回),主从延迟控制在 <10ms。读请求优先读主节点,读取时校验库存数量,如果发现库存异常立即触发告警和人工介入。

方案二:RedLock分布式锁—— 在扣减前先获取分布式锁(RedLock算法,需要5个独立Redis节点中的3个以上节点确认才算获取成功)。获取锁后执行扣减,扣减完成后释放锁。这个方案可以保证主从切换时的数据一致性,但性能损耗约20-30%。

方案三:半同步复制(Semi-Sync)—— MySQL早已支持半同步复制,Redis 7.0后也引入了准同步复制(quorum ack)。写入时至少等待1个从节点确认才返回,即使主节点宕机,从节点的数据也不会丢失。

🔍 值得深挖:RedLock的「大多数节点确认」机制真的安全吗?

RedLock(Redis作者Antirez提出的分布式锁算法)的核心思想是:在5个独立的Redis节点上获取锁,只有超过3个节点成功获取才认为获取成功。这个设计看起来很安全,但Martin Kleppmann(分布式系统领域顶级专家)在其著名文章"How to do distributed locking"中对RedLock提出了严厉批评。

主要争议点:RedLock的5个节点使用本地时钟来计算TTL,如果某个节点的时钟发生了跳跃(系统管理员手动调整、NTP同步跳变),可能导致锁提前过期但尚未被释放,此时另一个客户端获取了同一个锁,两个客户端同时持有锁,造成数据破坏。

Antirez的回应与改进:Antirez随后发文反驳,指出Kleppmann的批评基于对RedLock实现的错误假设(RedLock使用的是一个安全的时间比较机制)。最终RedLock引入了fence token(递增序列号)机制来解决这个问题:每次获取锁时,服务端返回一个递增的fence token,客户端在访问共享资源时将token一并提交,资源服务检查token是否递增,如果发现token没有递增则拒绝访问。

实战建议:对于秒杀系统而言,RedLock的复杂度带来的收益有限。更实用的做法是:强依托Redis Cluster的副本确认机制(WAIT命令),在极端高可用场景下,配合业务层的库存回滚逻辑(支付超时释放、库存对账脚本)兜底。

挑战三:被丢弃请求的用户体验设计

在五层漏斗中,L1和L2层会丢弃大量请求。对于被丢弃的用户而言,如果直接显示"已售罄",会给用户带来极差的体验——明明商品刚开抢就显示没了,质疑平台是否有内幕。设计良好的用户体验需要在这两者之间找到平衡。

✅ 解决方案:验证码拦截 + 排队队列 + 友好提示

验证码拦截(峰值QPS从1000万→3万)—— 秒杀开始前5秒,在网关层弹出图形验证码或滑块验证码。用户输入正确后,才能进入下单流程。这个设计有两重效果:1)将并发请求打散到人类输入验证码的速度量级(每人大约3-5秒输入时间);2)有效拦截脚本自动抢购。

排队队列(Redis Sorted Set实现)—— 通过验证码后,用户进入排队队列。使用Redis Sorted Set存储(score=进入队列时间戳),保证FIFO顺序。每次扣减成功后,从队列中取出下一批用户。队列位置实时通过WebSocket推送,用户看到自己的排队进度。

友好提示文案—— 对于进入排队队列的用户,显示"当前排队第XXX位,预计等待X分钟";对于排队超时的用户,显示"排队已超时,请稍后再试";对于最终未能抢到的用户,显示"商品太抢手了,下次记得早点来哦~"

🔍 值得深挖:排队队列如何保证公平性?先到先得 vs 随机抽签

排队队列的核心问题是:谁应该排在前面?

先到先得(FIFO)—— 最直觉的方案,按进入队列的时间戳排序。优点:公平透明,用户能理解;缺点:脚本可以通过自动化脚本在秒杀开始瞬间立即进入队列,普通人永远抢不过机器。

随机抽签—— 秒杀开始后,所有通过验证码的用户进入候选池,系统从中随机抽取幸运用户。优点:彻底封堵脚本优势;缺点:用户体验差,不知道自己有没有机会,抱怨度高。

信用优先(混合方案)—— 参考淘宝/京东的做法:将用户分为不同等级(如新用户/普通用户/Plus会员),高等级用户的队列权重更高。同时,对历史有恶意抢购记录(买了不付款、频繁退货)的用户降低权重。配合验证码,兼顾公平与体验。

防刷策略细节:验证码弹出时机也有讲究——秒杀开始前3-5秒弹出最合适。太早会流失用户(等不及走了),太晚则脚本可以在验证码出现前就大量发起请求。验证码的答案也需要有足够复杂度(至少4位,包含数字+字母+干扰线),防止OCR识别。

挑战四:超时未支付库存释放的可靠性

用户抢购成功后有15分钟支付窗口,超时未支付则释放库存。但如果释放逻辑不可靠,会导致:1)库存未及时释放,商品实际有货但无法销售;2)重复释放(同一订单释放两次,库存回补两次);3)支付回调延迟到达时,与超时释放产生竞争条件。

✅ 解决方案:Redis Key过期 + Keyspace通知 + 兜底定时扫描

方案一:Redis Keyspace通知(主动通知模式)—— 为每个成功下单的订单创建一个Redis Key(order:timeout:{orderId}),TTL设置为15分钟。当Key过期时,Redis主动向订阅者推送过期事件。订单消费者订阅过期事件,收到后执行库存回补。

# Redis Keyspace通知配置(redis.conf)
notify-keyspace-events Ex
# E = Keyevent事件,x = 过期事件

# 订单消费者订阅过期事件
SUBSCRIBE __keyevent@0__:expired
# 收到消息:order:timeout:ORDER123
# 消费者处理:检查订单状态 → 回补库存

方案二:定时扫描兜底(被动兜底模式)—— 每分钟扫描15分钟前的所有未支付订单(通过MySQL查询 status=CREATED AND create_time < NOW()-15min),批量执行库存回补。

🔍 值得深挖:Redis Keyspace通知丢失了怎么办?

Redis Keyspace通知是一个"即发即忘"的机制——Redis不保证通知一定被客户端收到,如果客户端在通知发送时离线,通知就永久丢失了。在生产环境中,这个问题发生的概率不低:

问题场景:用户下单成功 → Redis Key创建 → Key过期 → 通知发送 → 但订单消费者恰好在重启,错过通知 → 库存永远无法释放

解法一:双保险设计—— Keyspace通知作为"主动通知",定时扫描作为"被动兜底"。两者同时运行,取并集处理。只要有一个能正常工作,就不会漏。

解法二:轮询而非过期—— 不依赖Key过期,而是为每个订单创建一个"期望过期时间"记录到MySQL(order.timeout_at = NOW() + 15min),后台服务每分钟轮询扫描所有 timeout_at < NOW()status = CREATED 的订单。Redis Key的TTL只作为"加速释放"的辅助手段。

解法三:支付平台回调延迟兜底—— 支付平台(如支付宝/微信)的回调也可能延迟。如果支付回调在超时释放之后到达,订单已经变成CLOSED状态,但支付成功。此时需要特殊处理:查询库存是否已被释放,若已释放则重新加库存并更新订单状态为PAID,同时发送补偿通知给运营。

解法四:幂等释放—— 为了防止重复释放(两个消费者同时处理同一订单的超时释放),在库存回补时使用Redis的DECRBY(而非SET),确保多次释放不会超出原始库存:redis.call('INCRBY', stockKey, qty),即使重复调用,库存也不会超量回补。

四、关键技术实现

4.1 Lua原子扣减脚本(核心)

这是整个秒杀系统的"心脏"。一个Lua脚本完成所有检查和扣减,保证原子性:

-- seckill_decrease_stock.lua
-- 秒杀库存扣减Lua脚本(原子操作,无并发问题)
-- KEYS[1] = 库存Key,如 "seckill:stock:goods_123:bucket_0"
-- KEYS[2] = 订单用户Set Key,如 "seckill:orders:goods_123:bucket_0"
-- ARGV[1] = 用户ID(用于限购检查)
-- ARGV[2] = 扣减数量(通常为1)
-- ARGV[3] = 幂等Token(防止重复提交)

local stockKey = KEYS[1]
local orderKey = KEYS[2]
local uid = ARGV[1]
local qty = tonumber(ARGV[2])
local idempotentToken = ARGV[3]

-- ============ 1. 幂等检查 ============
local idemKey = 'seckill:idem:' .. idempotentToken
if redis.call('EXISTS', idemKey) == 1 then
    return {-3, 0}  -- 幂等Token已存在,重复提交
end

-- ============ 2. 用户限购检查 ============
if redis.call('SISMEMBER', orderKey, uid) == 1 then
    return {-2, 0}  -- 用户已购买过
end

-- ============ 3. 库存检查 ============
local stock = tonumber(redis.call('GET', stockKey) or 0)
if stock < qty then
    return {-1, 0}  -- 库存不足
end

-- ============ 4. 原子扣减 ============
redis.call('DECRBY', stockKey, qty)

-- ============ 5. 记录购买用户 ============
redis.call('SADD', orderKey, uid)

-- ============ 6. 设置幂等Token ============
redis.call('SETEX', idemKey, 600, '1')  -- 10分钟有效期

-- ============ 7. 返回结果 ============
local remainingStock = redis.call('GET', stockKey)
return {0, remainingStock}  -- 成功,返回剩余库存

4.2 Nginx/OpenResty三层限流Lua脚本

在L2网关层,用OpenResty实现高性能限流:

-- seckill_limit.lua
-- 限流策略:用户维度 + IP维度 + 全局维度三重限流

local redis = require('resty.redis')
local red = redis:new()
red:set_timeout(1000)

-- 本地共享字典:缓存限流状态,减少Redis访问
local limit_dict = ngx.shared.seckill_limit

local user_id = ngx.var.user_id or ''
local remote_ip = ngx.var.remote_addr or ''

-- ============ 1. 用户维度限流 ============
-- 每个用户每分钟最多30次下单请求
local user_key = 'limit:user:' .. user_id
local user_count = limit_dict:get(user_key)
if user_count and tonumber(user_count) >= 30 then
    ngx.exit(ngx.HTTP_FORBIDDEN)  -- 超出限流,返回429
end
if user_count then
    limit_dict:incr(user_key, 1)
else
    limit_dict:set(user_key, 1, 60)  -- TTL 60秒
end

-- ============ 2. IP维度限流 ============
-- 每个IP每分钟最多200次(防爬虫/CC攻击)
local ip_key = 'limit:ip:' .. remote_ip
local ip_count = limit_dict:get(ip_key)
if ip_count and tonumber(ip_count) >= 200 then
    ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)  -- 超出限流
end
if ip_count then
    limit_dict:incr(ip_key, 1)
else
    limit_dict:set(ip_key, 1, 60)
end

-- ============ 3. 全局限流(令牌桶算法) ============
-- 每秒发放固定数量令牌,超出则拒绝
local token_key = 'limit:token:global'
local token_rate = 100000  -- 每秒10万令牌
local tokens = tonumber(red:get(token_key)) or token_rate

if tokens <= 0 then
    ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)  -- 令牌耗尽,服务不可用
end

-- 使用DECR保证原子性(Lua脚本或Redis MULTI/EXEC事务)
red:decr(token_key)
red:expire(token_key, 1)
red:set_keepalive(1000, 100)

4.3 Kafka可靠投递(事务模式)

Kafka消息投递使用Exactly-Once语义,保证库存扣减和消息投递的原子性:

// Kafka生产者:库存扣减成功后才发送消息(事务模式)
public class SeckillOrderProducer {

    private final KafkaTemplate kafkaTemplate;

    /**
     * 事务模式:库存扣减结果已知,消息投递必达
     * 如果消息发送失败,整个事务回滚,库存也回滚
     */
    public void sendOrderInTransaction(SeckillOrderMsg msg) {
        kafkaTemplate.executeInTransaction(operations -> {
            // 1. Redis扣减(已在Controller层完成)
            // 2. 发送订单消息
            //    - 消息key = orderId,保证同一订单的消息路由到同一Partition
            //    - 失败则事务回滚,触发库存回补
            operations.send("seckill-order",
                msg.getOrderId(),  // 分区key,同一订单的消息在同一Partition有序
                msg,
                new ProducerCallback(msg));
            return null;
        });
    }
}

// Kafka消费者:幂等处理
public class SeckillOrderConsumer {

    private final Map processedCache = Redis.getCache("order:idempotent");

    @KafkaListener(topics = "seckill-order", groupId = "seckill-order-consumer")
    public void consume(SeckillOrderMsg msg) {
        String idempotentKey = msg.getOrderId();

        // 幂等检查:已处理过的消息直接跳过
        if (processedCache.containsKey(idempotentKey)) {
            return;  // 重复消息,不处理
        }

        // 创建订单(MySQL INSERT)
        orderService.createOrder(msg);

        // 标记已处理(TTL=24h)
        processedCache.put(idempotentKey, "1", 24 * 3600);

        // 发送支付通知
        paymentService.sendPaymentNotice(msg);
    }
}

4.4 库存回补完整流程

// 库存回补服务(处理超时未支付)
@Service
public class StockRollbackService {

    @Autowired private RedisTemplate redisTemplate;
    @Autowired private OrderMapper orderMapper;

    /**
     * 定时任务:每分钟扫描超时未支付订单并回补库存
     */
    @Scheduled(cron = "0 * * * * ?")  // 每分钟执行
    public void rollbackTimeoutOrders() {
        // 1. 查询15分钟前创建的未支付订单
        List timeoutOrders = orderMapper.selectTimeoutOrders(
            LocalDateTime.now().minusMinutes(15), OrderStatus.CREATED);

        for (Order order : timeoutOrders) {
            try {
                // 2. 幂等回补:使用Lua脚本,保证原子性
                String script = """
                    local stockKey = KEYS[1]
                    local qty = tonumber(ARGV[1])
                    local orderKey = KEYS[2]
                    local uid = ARGV[2]
                    -- 加库存
                    redis.call('INCRBY', stockKey, qty)
                    -- 删除购买记录
                    redis.call('SREM', orderKey, uid)
                    return 1
                    """;
                redisTemplate.execute(
                    new DefaultRedisScript<>(script, Long.class),
                    Arrays.asList(
                        "seckill:stock:" + order.getGoodsId() + ":bucket_0",
                        "seckill:orders:" + order.getGoodsId() + ":bucket_0"
                    ),
                    String.valueOf(order.getQuantity()),
                    order.getUserId()
                );

                // 3. 更新订单状态为已取消
                orderMapper.updateStatus(order.getId(), OrderStatus.CANCELLED_TIMEOUT);

                // 4. 发送补偿通知
                smsService.send(order.getUserId(),
                    "您的订单" + order.getId() + "已超时取消,库存已释放");
            } catch (Exception e) {
                // 幂等保证:重复回补不会造成库存超量
                log.warn("回补订单失败: orderId={}, error={}", order.getId(), e.getMessage());
            }
        }
    }
}

五、性能指标与成果

5.1 双十一大促实战数据

1000万+ 峰值QPS(实际达到)
100% 库存准确性(零超卖)
0 主库击穿次数
1.2万/s 下单成功率
10分钟 万级商品售罄用时

系统上线后经历了双十一、618两次大促考验。双十一当天下午2点,iPhone 15 首发秒杀,10分钟内完成1万件销售,峰值QPS达到 873万,系统全程稳定运行。库存误差为 0(精确到个位),主库零击穿(Redis Cluster完整承接所有扣减请求),Kafka消息零丢失。

5.2 系统各层表现

层级 技术方案 峰值QPS 延迟P99 可用性
L1 CDN 阿里云CDN + 边缘缓存 800万+ 5ms 99.99%
L2 网关 OpenResty + Lua 50万 10ms 99.99%
L3 Redis Redis Cluster 16分片 10万 1ms 99.999%
L4 Kafka Kafka 12节点集群 5万 50ms 99.99%
L5 MySQL 分库分表(16库×16表) 2.5万 20ms 99.99%

5.3 业务价值

  • GMV提升:双十一当天秒杀频道GMV同比增长 42%,主要得益于系统稳定性和用户体验的提升。
  • 成本优化:相比之前使用Oracle数据库的方案,迁移到MySQL分库分表+Redis缓存后,数据库成本降低 75%。
  • 运营效率:系统支持运营随时配置新秒杀场次(商品+时间+库存),从配置到上线只需 5 分钟,无需开发介入。

六、架构经验总结

💡 经验一:漏斗模型是处理流量洪峰的第一性原理

秒杀系统的所有设计,本质上都是围绕"如何尽早丢弃无效请求"展开的。五层漏斗模型不是一开始就设计好的,而是在一次次大促中发现问题、逐层加固的结果。L1 CDN和L2网关是最容易被忽视但效果最显著的——它们拦截了99%以上的无效流量,让后端系统能在相对可控的规模下运行。架构设计的优先级永远是:能在前面拦截的,不要留给后面。

💡 经验二:Redis单线程模型是秒杀场景的"天选之人"

库存扣减的一致性问题是秒杀系统的核心难题,而Redis的单线程原子模型几乎完美地解决了这个问题——无需任何分布式锁、无需任何乐观锁、无需任何事务,Redis天然就是最强的分布式锁。在技术选型时,我们应该优先寻找"天然适合"业务特征的方案,而非用复杂的技术去弥补选型上的不足。

💡 经验三:幂等设计是一切分布式系统的基石

从库存扣减到订单创建,从Kafka消息消费到支付回调,幂等性贯穿了整个系统的每一个关键路径。幂等Token、唯一索引、去重表......这些看似"笨拙"的设计,实际上是系统在生产环境中稳定运行的保障。建议将幂等设计作为架构评审的强制checklist项,任何涉及状态变更的操作都必须有幂等方案。

💡 经验四:用户体验和系统容量需要协同设计

限流的最终目的是保护系统,但"粗暴限流"会严重伤害用户体验。验证码拦截、排队队列、公平抽签......这些看起来是"业务逻辑",实际上是系统容量的软调节器——它们在不改变硬件配置的情况下,有效地将系统压力控制在可承受范围内。建议将用户体验设计纳入技术架构的一部分,而非事后的"补救措施"。

💡 经验五:故障的代价永远大于过度设计的代价

秒杀系统的每一个设计决策,都应该以"最坏情况会发生什么"为出发点。Redis主从切换会导致超扣?那就上WAIT确认。Kafka消费者会重复处理?那就做幂等消费。支付回调会延迟到达?那就做超时回补+延迟补偿。系统的可靠性是通过一层又一层的"兜底"实现的,而非通过相信"这不会发生"。