秒杀这两个字写进简历很轻巧,真跑到线上往往是一地鸡毛。我经历过一次活动开场三秒的事故:监控大盘上 Redis 的 QPS 直接顶到天花板,库存 key 被扣成负数,数据库连接池排满,订单服务线程全部卡在等锁上,最后靠人工回滚数据收场。从那以后我对“Redis实战——秒杀业务优化”这件事的理解彻底变了:Redis 在秒杀链路里不是一块简单的缓存,它同时承担着流量闸门和库存账本两个角色,账本记错一个数,后面所有服务都得跟着擦屁股。这篇内容就是围绕这条链路,把我踩过的坑、验证过的方案、以及压在箱底的参数细节完整摊开。适合已经写过后端接口、但对高并发场景还停留在“加个缓存”认知的朋友,也适合正在准备大促的同行拿去对照自查。看完你至少能搞清楚三件事:库存怎么扣才不超卖,请求怎么拦才不把 Redis 打穿,扣减成功之后的一致性怎么兜。
1. 秒杀链路的真实瓶颈:不是QPS数字,而是无效请求的放大量
1.1 一次秒杀点击在后端到底经历了什么
很多人一上来就盯着“Redis 能扛多少 QPS”,这个方向从一开始就偏了。真实的秒杀链路远不止一次库存查询:请求先进网关做签名校验和黑名单过滤,再进业务网关做活动时间窗判断和用户资格校验,然后查活动配置、查商品信息、查限购记录,最后才是库存扣减和下单落库。一个 10 万库存、100 万人参与的活动,从概率上讲有 99% 的请求注定拿不到商品,但它们在前面的每一个环节都要消耗 CPU、连接和内存。这些请求我习惯叫它们“无效竞争”,它们不产生任何业务价值,却贡献了绝大部分系统压力。
所以秒杀优化的第一性原则不是让 Redis 更快,而是让尽可能多的注定失败的请求,在离 Redis 最远的地方就被挡掉。这个认知转变很关键。我见过不少实现,把限购判断、活动状态判断全放在库存扣减的 Lua 脚本里,脚本写得又长又重,单次执行耗时从 0.3ms 涨到 2ms,看起来只是两毫秒,但 Redis 执行 Lua 是单线程串行的,单 key 的扣减吞吐直接掉了一个数量级。把这些判断往前挪,脚本只做最核心的“判库存 + 扣减 + 记标记”,这才是划算的。
从请求生命周期看,一次秒杀点击大致会经过网关层、接入层、业务层、缓存层、存储层五个台阶。每个台阶的拦截成本差着数量级:网关层拦一次是纳秒到微秒级,Redis 拦一次是几百微秒,数据库拦一次可能就是毫秒到几十毫秒甚至锁等待超时。优化的本质,就是把该拦的请求尽量往便宜的台阶上推。
1.2 把库存塞进Redis,解决的到底是哪一个问题
先厘清一个常见误解:很多人以为库存放 Redis 是为了“扛并发”,其实它解决的是数据库行锁排队的问题。数据库扣库存的典型写法是UPDATE stock SET num = num - 1 WHERE id = ? AND num > 0,这条语句在行锁上加锁,一万个并发打过来,行锁变成串行队列,InnoDB 的锁等待超时和死锁检测会先把数据库拖垮。把扣减搬到 Redis,本质是把“串行的行锁等待”换成“内存里的原子操作”,速度快了不止一个量级。
但它没有解决另外两个问题:一是仍然存在无效请求,一百万请求里九十九万是来陪跑的,它们照样要打到 Redis;二是缓存与数据库的一致性,Redis 扣成功了,数据库落库失败怎么办,这个后面单独讲。所以“库存进 Redis”只是整条优化链路上的一环,绝不是终点。我一般会这么跟团队描述:Redis 是收费站的快速通道,但收费站本身也是瓶颈,你得先在收费站之前把不相干的车分流掉。
1.3 三层优化的职责边界与效果对比
我把秒杀优化拆成三层,每层的目标、手段和典型收益都不一样。这张表是我在几个项目里反复验证后总结的,可以直接当 checklist 用:
| 层次 | 核心目标 | 主要手段 | 典型收益 | 常见误区 |
|---|---|---|---|---|
| 拦截层 | 挡掉注定失败的请求 | 活动状态本地缓存、用户限购、令牌桶限流、验证码 | 打散 70%~95% 流量 | 只在网关限 IP,挡不住同一用户重放 |
| 扣减层 | 保证不超卖、不重复 | Lua 原子脚本、库存分片、用户标记 | 单分片稳定 3~5 万 TPS | 用 GET 再 DECR,竞态超卖 |
| 异步层 | 削峰、解耦下单 | Redis Stream 队列、消费组、幂等落库 | 数据库压力降一个量级 | 队列消息丢失或重复消费 |
三层的关系是漏斗,越往上越便宜。我见过只做扣减层不做拦截层的实现,活动开场 Redis CPU 直接打满,然后开始丢包、超时、重连风暴,最后库存是对的,但用户全是“系统繁忙”。这种系统“技术上正确,体验上失败”,很典型。
2. 库存扣减原子性:从DECR裸奔到Lua脚本收敛
2.1 一个DECR写错引发的负库存事故
先说反例。最早我写库存扣减是这么写的:先GET stock,判断大于 0,然后DECR stock。这段代码在低并发下永远正确,但一旦并发上来,两个请求同时读到库存 1,都判断通过,然后各自 DECR,库存变成 -1,超卖两份。这是最经典的检查与执行竞态,教科书级别的错误,但线上真的有大量代码这么写。
有人会说那我改成先DECR,返回小于 0 再INCR回补不就行了。这个写法在小流量下勉强能用,但有两个致命缺陷。第一,回补的INCR和下一次请求的DECR之间又有窗口,负库存会短暂暴露给其他读请求,如果你的商品详情页直接读这个 key,用户会看到负库存。第二,DECR和INCR是两次网络往返,Redis 客户端超时重试的情况下,可能出现 DECR 执行成功但客户端没收到响应、重试又扣一次的情况,库存凭空少了一份。
真正稳的做法只有一条路:把所有需要原子性的判断和修改放进一个 Lua 脚本。Redis 执行 Lua 脚本期间不会插入其他命令,脚本内部的“读-判-改”天然串行,这就把多次网络往返的原子性问题一次性解决了。这是 Redis 在秒杀场景里最核心的用法,没有之一。
2.2 库存扣减脚本的完整写法与逐段解释
下面这个脚本是我现在的主力版本,经历了几个大促,逻辑相对收敛:
-- KEYS[1] 库存key,KEYS[2] 用户购买集合key -- ARGV[1] 用户id,ARGV[2] 购买数量 local stockKey = KEYS[1] local userKey = KEYS[2] local userId = ARGV[1] local qty = tonumber(ARGV[2]) local stockStr = redis.call('GET', stockKey) if not stockStr then return -1 -- 活动库存未初始化 end local stock = tonumber(stockStr) if stock < qty then return 0 -- 库存不足 end if redis.call('SISMEMBER', userKey, userId) == 1 then return -2 -- 该用户已参与,重复请求 end redis.call('DECRBY', stockKey, qty) redis.call('SADD', userKey, userId) return 1 -- 扣减成功逐个说清楚设计意图。GET返回 nil 时返回 -1,是为了把“活动没初始化”和“库存不足”区分开,前者是配置问题需要告警,后者是正常业务结果,混在一起排查会很痛苦。用户标记用 Set 而不是 String,是因为 Set 的SISMEMBER是 O(1),而且天然去重,配合活动结束后的DEL或EXPIRE清理,内存占用可控。DECRBY而不是DECR,是为了支持一次买多件的场景,虽然秒杀通常限购 1 件,但脚本写通用一点不亏。
关键细节在于返回值的语义必须严格定义。调用方拿到 0 是“卖完了”,可以正常提示用户;拿到 -1 是“配置异常”,要打日志告警;拿到 -2 是“重复购买”,要么提示要么直接返回已下单的订单号。这四种返回值对应四种完全不同的后续处理,如果脚本里返回值和业务语义混乱,线上排查会非常难受。
2.3 脚本里的隐藏坑:数字截断、集群槽位与脚本缓存
Lua 脚本看起来简单,但有几个坑我踩过,值得单独说。
第一个是数据类型截断。Lua 里的数字是双精度浮点,redis.call返回的整数字符串经tonumber后如果带小数,传回 Redis 时会被截断。库存数量本身是整数没问题,但如果你的脚本里做了价格计算,务必在返回前用math.floor或string.format处理成整数或字符串,否则会出现“扣了 0.999 件”这种诡异结果。
第二个是集群模式下的槽位问题。Redis Cluster 要求脚本访问的所有 key 在同一个哈希槽里,否则直接报 CROSSSLOT 错误。解决办法是用 hash tag 把相关 key 绑在一起,比如库存 key 写成seckill:{act_1001}:stock,用户集合写成seckill:{act_1001}:users,花括号里的内容相同,两个 key 就会落在同一个槽。这个小括号非常关键,我见过有人部署到集群后整条链路报错,排查半天才发现是槽位问题。
第三个是脚本缓存与 EVALSHA。生产环境用EVAL每次传全量脚本文本,网络开销不小。正常做法是先SCRIPT LOAD拿 SHA1,之后用EVALSHA调用。但 Redis 重启或主从切换后脚本缓存会丢,客户端会收到NOSCRIPT错误。成熟的客户端库(比如 Jedis、Lettuce 配合 Spring 的DefaultRedisScript)都处理了这个回退逻辑,如果自己封装的客户端,一定要在捕获NOSCRIPT时自动降级为EVAL重新加载,否则主从一切换,秒杀链路就全挂。
private static final DefaultRedisScript<Long> STOCK_SCRIPT = new DefaultRedisScript<>(STOCK_LUA, Long.class); public int tryDeduct(String actId, String userId, int qty) { List<String> keys = Arrays.asList( "seckill:{" + actId + "}:stock", "seckill:{" + actId + "}:users"); Long r = redisTemplate.execute(STOCK_SCRIPT, keys, userId, String.valueOf(qty)); return r == null ? -99 : r.intValue(); }这段 Java 里的-99是兜底值,代表脚本执行异常或者序列化出问题。业务层必须把它当异常处理,绝对不能当成“扣减成功”往下走。我见过有实现直接r > 0判断成功,结果 null 被拆箱抛空指针,整个接口 500,教训很直接。
2.4 库存分片:把单key的吞吐天花板抬上去
即使脚本优化到极致,单 key 的扣减能力也是有上限的。Redis 单实例在普通云主机上大约能跑 8 万到 10 万次简单命令每秒,但 Lua 脚本因为有解析和判断逻辑,实测单个库存 key 的扣减上限大概在 3 万到 5 万 TPS 之间徘徊,具体取决于脚本长度和机器规格。当你预估峰值超过这个数,就得考虑库存分片。
分片的思路很直白:把 1000 件库存拆成 10 份,每份 100 件,对应 10 个 key,请求按用户 id 哈希或随机轮询到某个分片去扣。这样并发压力被摊到 10 个 key 上,理论吞吐提升接近十倍。路由方式我一般用userId.hashCode() % shardCount,好处是同一用户永远落在同一个分片,配合分片内的用户集合,去重逻辑天然成立。
分片带来的副作用是碎片库存。可能出现某个分片已经扣完,另一个分片还剩几十件,这时候用户明明来得不算晚却抢不到。解决办法是扣减失败时换一个分片重试,最多重试两到三次,代码上就是循环若干次路由。这样做会引入轻微的公平性问题,但秒杀本身就是先到先得加运气,用户感知不强。
还有一个细节:分片之后,总库存的展示要做聚合,不能让前端一个一个分片去查。我通常会在活动开始前把总库存写到另一个 key 里,扣减脚本里同时DECRBY总量 key,这样前端只读一个 key 就能拿到剩余总数。这个总量 key 是只读展示用,不参与扣减判断,即使有短暂偏差也不影响业务正确性,这点一定要区分清楚,别把展示库存当成扣减依据。
3. 进Redis之前就把人拦住:分层限流与用户维度去重
3.1 限流为什么必须分成三层来做
限流这个词太笼统,落到秒杀场景,至少要拆成三层,每层解决不同的问题。
第一层是入口层限流,按 IP 或设备指纹限,目的是挡住脚本刷单和压测工具。单 IP 每秒 10 次是个常见阈值,具体看业务。这层的实现位置在网关或 Nginx,用 Redis 计数器或者本地令牌桶都行,本地实现更快,但分布式部署下总量控制不准,需要配合 Redis 做全局配额。
第二层是活动层限流,控制整个活动在某段时间内进入核心链路的请求总量。这一层决定了 Redis 和下游数据库的负载上限,是最重要的一层。我的经验是把它设成“后端能稳定处理的最大 TPS 的 1.2 倍”,留一点缓冲给突发流量,超过就直接快速失败返回“活动太火爆”。
第三层是用户层限流,单用户在活动期间只能成功购买 N 件。这层严格说不叫限流,叫限购,但实现手段类似,都是靠计数器或标记。用户层限购是防黄牛的核心,必须在扣减脚本里做原子判断,单独用 Redis 命令做会出现“判断通过但没来得及写标记,下一个请求又通过”的窗口。
三层的顺序不能乱,一定是入口层最先,成本最低;活动层次之,保护后端;用户层最后,在扣减时原子完成。顺序颠倒的话,比如先查用户限购再限流,那等于把最贵的判断放在了最前面,白白消耗资源。
3.2 用Lua实现一个令牌桶限流器
滑动窗口用 ZSET 实现很直观,但有个毛病:每个请求都要往 ZSET 里写一条记录,活动开场那一秒,单个限流 key 上可能有几十万条记录,内存涨得飞快,清理也是负担。我更倾向令牌桶,状态只有“当前令牌数”和“上次刷新时间”两个字段,内存恒定。
-- KEYS[1] 限流key -- ARGV[1] 桶容量 ARGV[2] 每秒补充速率 ARGV[3] 当前时间戳(ms) ARGV[4] 本次需要令牌数 local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local need = tonumber(ARGV[4]) local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens = tonumber(data[1]) local last = tonumber(data[2]) if tokens == nil then tokens = capacity last = now end local delta = math.max(0, now - last) / 1000 * rate tokens = math.min(capacity, tokens + delta) local ttl = math.ceil(capacity / rate * 1000) + 1000 if tokens < need then redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now) redis.call('PEXPIRE', KEYS[1], ttl) return 0 end redis.call('HMSET', KEYS[1], 'tokens', tokens - need, 'ts', now) redis.call('PEXPIRE', KEYS[1], ttl) return 1参数怎么定?假设活动层希望控制在 2 万 TPS,那就是每秒补充 20000 个令牌,rate = 20000。桶容量我一般设为速率的 1 到 2 倍,比如 30000,这样允许短时间突发到 3 万,但长时间平均还是 2 万。容量设太大,瞬时压力照样会把后端打穿;设太小,正常用户稍微集中一点就被误伤。这个参数必须结合压测结果调,拍脑袋定下来的数字基本都要返工。
引用块提醒一下:
时间戳一定由客户端传入而不是在脚本里调 Redis 的
TIME命令。原因有两个:一是集群环境下TIME的行为在不同版本上有差异,二是客户端时间统一之后,多个限流 key 的刷新逻辑保持一致,排查问题时对时间轴更方便。当然前提是服务端做了 NTP 时间同步,这个属于基础设施基本要求。
3.3 单用户限购与重复请求的幂等处理
用户限购我前面说过,放在扣减脚本里用 Set 标记。这里补充一个容易被忽略的场景:用户扣减成功但订单创建失败。这时候用户标记已经写进去了,库存也扣了,但用户没拿到订单,如果直接允许重试,会因为SISMEMBER返回 1 而提示“重复购买”,用户会觉得系统在耍他。
正确做法是给标记加状态。我一般用 Hash 存userId -> orderId,扣减成功后先写一个占位值比如pending,订单落库成功后回写真实订单号。用户重试时先查这个 Hash,如果是pending说明上一次还在处理中,返回“处理中请稍候”;如果是真实订单号,直接返回订单详情。这样就消除了“扣了库存没订单”的尴尬状态。
另一个细节是占位标记一定要有 TTL。活动结束后这些标记没用了,不清理会一直占内存。我的做法是活动结束后延迟 24 小时统一过期,用EXPIRE而不是DEL,避免活动刚结束用户来查订单时标记已经没了。
EXPIRE seckill:{act_1001}:users 86400还有一类重复请求来自客户端重试。移动端网络抖动,用户点一次按钮可能发三次请求,如果不做幂等,三次都进扣减脚本,前两次扣成功第三次返回 -2,用户看到“重复购买”但其实只该买一件。这倒不会超卖,但体验很差。解决办法是在客户端带一个请求号,服务端用SETNX req:{requestId}做短时去重,TTL 设 5 秒足够覆盖重试窗口。
3.4 本地缓存在拦截链路里该放在哪一层
活动配置、商品基础信息这类读多写极少的数据,放在本地缓存(Caffeine 或者简单的 ConcurrentHashMap 加过期)比放 Redis 划算得多。一次本地缓存命中是纳秒级,一次 Redis 往返哪怕是同机房也要几百微秒,差距三个数量级。开场那一秒几十万请求同时查活动配置,全部打到 Redis,光这些读就能把 CPU 吃满。
本地缓存的代价是一致性。活动开始时间、结束时间这种一旦确定就不该变的数据,本地缓存 5 分钟完全没问题。但如果运营可能临时改活动状态(比如提前结束),就需要一个失效通道:运营后台改完数据库,往 Redis 的 Pub/Sub 发一条消息,所有业务节点订阅后清掉本地缓存。这个通道不要求强可靠,丢了最多多缓存几分钟,可以接受。
防缓存穿透的布隆过滤器,我的看法是在秒杀场景收益有限。秒杀的商品 id 是有限的、可枚举的,查询不存在的 id 本来就该在网关层用白名单挡掉,不需要布隆过滤器。布隆过滤器更适合商品详情页那种 id 空间巨大的场景。工具要用在对的地方,为了用而用反而增加复杂度。
4. 削峰填谷:用Redis做队列缓冲的几种姿势
4.1 同步下单链路为什么在大促必崩
库存扣减成功之后,如果直接同步走“创建订单 → 写订单表 → 扣数据库库存 → 发消息通知”这条链路,数据库立刻成为新的瓶颈。订单表插入涉及索引维护、主键生成、事务提交,单表写入能力在普通配置下大概每秒几千条,而且事务提交要刷 redo log,延迟波动很大。前端几十万请求涌进来,每个都同步等数据库,Tomcat 线程池几百个线程瞬间占满,后面的请求全部排队,接口超时率飙升。
异步化的思路是把“扣减成功”和“订单落库”解耦:Redis 扣减成功即给用户返回“抢购成功,订单生成中”,真正的落库交给后台消费者慢慢做。这样前端响应时间从几十毫秒降到几毫秒,数据库也只承受消费者可控速率的写入。这是秒杀架构里最标准的削峰手段,几乎没有替代方案。
关键点在于扣减成功和消息入队必须尽量靠近。理想情况下这两步在同一个 Lua 脚本或者同一个事务性操作里完成,否则会出现“库存扣了但消息没发出去”的丢单。这个一致性问题是异步方案最大的坑,后面单独讲。
4.2 List、Stream、Pub/Sub三种队列姿势的取舍
Redis 能当队列用的结构有好几种,选错了会很痛苦。我把它们的关键差异列出来:
| 方案 | 可靠性 | 消费组 | 消息回溯 | 内存表现 | 适用场景 |
|---|---|---|---|---|---|
| List(LPUSH+BRPOP) | 中,弹出即消失 | 不支持 | 不支持 | 随堆积线性增长 | 简单任务、允许少量丢失 |
| Stream | 高,支持 ACK | 支持 | 支持 | 可控,可裁剪 | 订单创建等核心链路 |
| Pub/Sub | 低,离线即丢 | 不支持 | 不支持 | 不堆积 | 配置推送、日志广播 |
Pub/Sub 绝对不能用来做订单队列,订阅者掉线期间的消息直接消失,秒杀场景下这就是丢单。List 的BRPOP在某些客户端实现下有消息丢失风险(弹出后处理失败无法回队),也不适合核心链路。Stream 是 Redis 5.0 之后的正解,有消费组、有 ACK、有 pending 列表,能追踪哪些消息没被确认,虽然比不上专业消息队列,但对秒杀这种短时高峰场景完全够用,而且省掉了引入新中间件的运维成本。
我选型时的判断标准很简单:这条消息丢了会不会导致用户投诉。会,就用 Stream;不会,List 足够。订单创建显然属于会投诉的那类。
4.3 Stream消费组的可靠消费模板
先把生产端和消费组初始化写清楚:
# 建组,$ 表示从最新消息开始消费,MKSTREAM 表示流不存在时自动创建 XGROUP CREATE seckill:stream:act_1001 order_group $ MKSTREAM # 生产消息 XADD seckill:stream:act_1001 * actId act_1001 userId u_88991 skuId 30021 qty 1消费端的关键是处理完才 ACK,以及处理 pending 里的僵尸消息:
// 伪代码,突出逻辑顺序 while (running) { List<MapRecord<String, Object, Object>> records = redis.opsForStream().read( Consumer.from("order_group", "consumer-01"), StreamReadOptions.empty().count(10).block(Duration.ofSeconds(2)), StreamOffset.create("seckill:stream:act_1001", ReadOffset.lastConsumed())); for (MapRecord<String, Object, Object> rec : records) { try { orderService.createFromMessage(rec.getValue()); // 幂等落库 redis.opsForStream().acknowledge("seckill:stream:act_1001", "order_group", rec.getId()); } catch (Exception e) { log.error("消费失败,留在pending待重试, id={}", rec.getId(), e); } } // 定期认领超时未确认的消息 reclaimPending(); }XAUTOCLAIM用来处理消费者崩溃后遗留的消息,这个命令从 Redis 6.2 开始提供,替代了之前要用XPENDING加XCLAIM两步完成的繁琐流程:
# 认领超过 60 秒未确认的消息,最多 10 条 XAUTOCLAIM seckill:stream:act_1001 order_group consumer-02 60000 0-0 COUNT 10min-idle-time设多少合适?取决于单条消息的正常处理耗时。订单落库一般几十毫秒,设 60 秒已经非常宽松,能覆盖 GC 停顿和短暂网络抖动。设太短会导致消息被重复认领、重复消费,虽然有幂等兜底,但白白浪费资源。我一般设 30 到 60 秒,并且要求消费逻辑本身必须幂等,这是底线。
4.4 消费端幂等:唯一索引加状态机的双保险
消息队列的语义是“至少一次”,重复消费是必然事件,不是异常。幂等的实现我一般用两层。
第一层是数据库唯一索引。订单表上给(act_id, user_id)建唯一索引,重复插入直接抛DuplicateKeyException,捕获后查出现有订单返回,业务上完全正确。这是最可靠的兜底,哪怕代码逻辑写错,唯一索引也不会让脏数据进去。
第二层是状态机校验。订单状态从“待支付”流转到“已支付”或者“已取消”,每次状态变更都要带上前置状态作为条件:UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'UNPAID'。影响行数为 0 说明状态已经被别的线程改过,直接返回。这样即使两个消费者同时处理同一条消息,也只有一个能成功推进状态。
这两层配合下来,重复消息最多是白跑一次查询,不会产生错误数据。切记不要在消费端用“先查再插”的写法做幂等,那只是把竞态窗口缩小了,没有消除,高并发下照样重复插入。
5. 扣减之后的一致性:预扣、对账与回补
5.1 库存扣了但订单没落库,这条缝怎么补
这是秒杀系统里最让人头疼的问题:Redis 扣减成功了,消息也发出去了,但消费者处理失败或者消息在某个环节丢了,结果就是库存少了、订单没有。用户来投诉说“我明明抢到了”,后台查不到订单,非常难解释。
我目前用的是一个“预扣 + 对账”的组合方案。Redis 扣减本质上就是预扣,它先占住库存名额;订单落库成功后,数据库那边再真正扣减并记录流向。中间如果出问题,靠定时对账发现并修复。具体链路是这样的:
- 请求进 Lua 脚本,扣 Redis 库存,写用户标记为
pending,同时把消息XADD进 Stream。 - 消费者拿到消息,创建订单(唯一索引保证幂等),成功后更新用户标记为真实订单号,ACK 消息。
- 定时任务每 5 分钟扫一遍超过 10 分钟仍是
pending的用户标记,认为这条链路失败了。 - 对这些失败记录,先查数据库确认订单确实不存在,然后把库存加回 Redis,把标记删掉,让用户可以重新抢。
第 3 步的“超过 10 分钟”是个经验值。消费正常在秒级完成,10 分钟足够覆盖任何合理的重试和延迟。设太短会误杀正在处理中的请求,设太长用户体验差。这个值要结合消息积压监控动态调整。
5.2 定时对账脚本怎么写才不会误伤
对账的核心是比较三个数:Redis 剩余库存、数据库已售数量、订单表实际订单数。理想状态下初始库存 = Redis 剩余 + 数据库已售,订单数也要和已售对得上。对不上就修,但修复动作必须极其谨慎,因为对账脚本本身出 bug 是整个链路的灾难。
我的做法是对账脚本默认只读不写。先跑全量比对,把差异记录到一张对账异常表里,人工或者下一轮定时任务再决定是否修复。修复动作分三种:Redis 库存偏多说明可能漏扣,直接告警不自动修;Redis 库存偏少说明多扣了,按差异量加回;订单数少于已售数说明有订单丢了,这种情况最严重,需要人工介入排查。
-- 对账差异查询示例,只读 SELECT a.act_id, a.init_stock, r.remain_stock, IFNULL(o.sold_count, 0) AS db_sold, a.init_stock - r.remain_stock - IFNULL(o.sold_count, 0) AS diff FROM act_stock a LEFT JOIN order_stat o ON a.act_id = o.act_id WHERE a.act_id = 'act_1001';异常的判定阈值也要设。比如差异在 5 件以内且活动还在进行中,可能只是异步落库的延迟,不告警;差异超过 5 件或者活动结束 1 小时后仍有差异,才触发告警。阈值拍太严,半夜被无效告警叫醒;太松,真出问题发现得晚。这个需要根据业务量级调,我一般从“单活动库存的千分之一”起步。
5.3 回补库存的三种触发场景与防重设计
回补库存有三种典型触发:用户下单后超时未支付、用户主动取消、支付后退款。三种场景都要回补,但回补的时机和幂等设计不一样。
超时未支付是最常见的。我用延时任务(或者定时扫表)找出超过 15 分钟仍是待支付的订单,先调支付网关确认真的没支付,再回补。这一步确认不能省,否则用户刚好在最后一秒支付成功,你回补了库存,商品就超卖了。确认支付状态和回补库存之间仍有窗口,所以订单状态更新要用条件更新防止并发:UPDATE orders SET status='CANCELLED' WHERE id=? AND status='UNPAID',影响行数为 1 才继续回补。
回补本身也要幂等。我的做法是在 Redis 里用SETNX refund:{orderId}做标记,TTL 设 7 天,只有第一次回补成功,重复调用直接跳过。虽然理论上状态机已经能保证只回补一次,但多层防护成本很低,值得加。
回补时的库存 key 和扣减时是同一个,脚本也类似,只是把DECRBY换成INCRBY,同时从用户集合里移除该用户:
-- KEYS[1] 库存key,KEYS[2] 用户集合key -- ARGV[1] 用户id,ARGV[2] 回补数量 redis.call('INCRBY', KEYS[1], ARGV[2]) redis.call('SREM', KEYS[2], ARGV[1]) return 1有一个细节很多人忽略:回补的库存不能超过活动初始库存。如果因为某种 bug 重复回补,库存会超过初始值,用户看到“库存越抢越多”。脚本里应该加上限判断,INCRBY之后如果超过初始值,直接回滚并告警。这种防御性判断看着多余,真出事的时候能救你一命。
6. 压测、热点与上线前的兜底清单
6.1 压测要盯的不是平均RT,而是这几个数
压测阶段最容易犯的错是看平均响应时间。平均值会把长尾全部吃掉,平均 20ms 看起来很美,P99 可能已经 800ms 了,而秒杀场景里 P99 才是真实用户的体感。我压测时固定盯这几个指标:P99 响应时间、错误率、Redis 单实例 CPU、Lua 脚本平均耗时、连接池使用率、网卡出入带宽。
Lua 脚本耗时尤其关键。Redis 是单线程的,脚本执行时间直接决定了扣减吞吐。SLOWLOG GET能抓到慢脚本,但更直接的办法是在脚本执行前后打点,或者用redis-cli --latency观察实例延迟。我的经验阈值是单次脚本执行不超过 1ms,超过就说明脚本太重,该拆或者该分片了。
压测流量的构造也要讲究。真实秒杀流量是瞬间尖峰,不是均匀的。用均匀压测跑出来的容量数据会高估系统能力,因为系统有缓存预热、JIT 编译、连接建立的启动开销。我一般用“3 秒内打到峰值,持续 10 秒,然后迅速回落”的波形,这样更接近真实场景,也能暴露瞬时扩容和连接风暴的问题。
6.2 热点Key与集群倾斜的处理
活动只有一个,库存 key 也只有一个,集群里所有扣减请求都打到同一台机器上,其他节点闲着,这就是典型的热点 key 问题。前面的库存分片能缓解一部分,但用户集合 key 如果也集中在一个槽,同样会热。分片方案要连用户标记一起分,userId哈希到同一个分片,保证判断和扣减在同一个 key 上完成。
还有个大 key 问题。用户集合如果一场活动一百万人参与,这个 Set 就是一百万个元素,SISMEMBER本身是 O(1) 没问题,但DEL或者EXPIRE这种操作可能阻塞很久,而且主从同步时这个大 key 的传播也会造成延迟。解决办法是给用户集合也分片,按userId哈希到 16 或者 32 个 Set 上,单个 Set 元素数控制在几万以内,过期清理时压力也分散了。
探测热点 key 可以用redis-cli --hotkeys,但它基于 LFU 统计,需要把maxmemory-policy设成allkeys-lfu才准确。生产环境如果用的是allkeys-lru,这个命令的结果参考价值有限。更可靠的办法是业务侧埋点,在执行扣减的客户端统计每个 key 的调用次数,数据最真实。
6.3 持久化与主从配置的取舍
秒杀场景下,Redis 的持久化配置要做取舍。AOF 的everysec是性能和数据安全的平衡点,最多丢一秒数据。如果设成always,每次写都刷盘,扣减吞吐直接砍半,秒杀根本扛不住。RDB 的 fork 操作在数据量大时会阻塞主线程几百毫秒,大促期间最好关闭自动触发,改到活动结束后手动执行。
主从切换是另一个风险点。哨兵切换大概需要十几秒,这期间写请求全部失败。集群模式下某个分片的主节点挂了,切主期间该分片不可用。秒杀的应对策略是快速失败 + 前端重试,而不是等待恢复。服务端检测到 Redis 写超时,直接返回“活动太火爆”,让用户重新点,比让请求挂在那里等 10 秒体验好得多。
有个配置特别容易被忽略:客户端的连接池大小和超时时间。压测时经常发现 Redis 服务端没事,客户端连接池满了。连接池大小要大于业务线程池的并发数,超时时间设短一点,比如 200ms,快速失败好过长时间阻塞。这两个参数不调,前面所有优化都白搭。
6.4 上线前的兜底自查清单
最后给一份我自己每次大促前都会过一遍的清单,按优先级排:
| 检查项 | 合格标准 | 不达标的后果 |
|---|---|---|
| 扣减脚本原子性 | 无 GET+DECR 组合,全部走 Lua | 超卖 |
| 脚本槽位 | 集群下所有 key 带相同 hash tag | CROSSSLOT 报错 |
| NOSCRIPT 兜底 | 捕获异常后自动 EVAL 重试 | 主从切换后全挂 |
| 限流参数 | 经压测验证,有缓冲余量 | 后端被打穿或误伤 |
| 用户限购 | 在扣减脚本内原子完成 | 黄牛批量下单 |
| 消息可靠性 | Stream + ACK + 僵尸消息认领 | 丢单 |
| 消费幂等 | 唯一索引 + 状态机双保险 | 重复订单 |
| 对账任务 | 定时跑,只读告警,人工确认修复 | 库存长期偏差 |
| 回补上限 | 不超过初始库存,有幂等标记 | 库存虚增 |
| 降级开关 | 一键关闭秒杀入口,保留查询 | 故障时无法止损 |
降级开关这个我要多说一句。很多团队做了很多优化,但没留一个“把入口关掉”的开关。真出问题时,最快最有效的处置往往就是关闭活动入口,让流量回落到正常水平,然后慢慢排查。这个开关要做到配置中心一键生效、所有节点秒级感知,比任何复杂的熔断策略都可靠。
我个人在实际操作中的体会是,秒杀系统的复杂度大部分不来自“怎么扣库存”,而来自“扣错了怎么发现、怎么修复”。写扣减逻辑一天能搞定,把对账、回补、降级这套兜底机制做扎实,往往要花上几倍的时间。但真到大促那天,救你的恰恰是这些看起来不酷的兜底代码。还有一个小心得:任何涉及库存数量的脚本,返回值语义先定义清楚再动手写,把每种返回码对应的业务动作列成表贴在代码注释里,后面接手的人会感谢你,你自己排查问题时也能少走很多弯路。