秒杀功能上线前两天,我盯着一份活动预案反复改,心里最没底的就是三个地方:库存到底会不会被扣成负数、同一个账号能不能绕过限制抢到多单、以及那把挂在商品上的 Redis 分布式锁到底靠不靠得住。做过这类促销场景的人应该都有类似体会——压测环境下一切正常,真到了开抢那一秒,各种边界情况会同时涌上来。这篇内容就围绕库存超卖、一人一单、Redis 分布式锁这三件事,把我自己踩过的坑、验证过的写法、以及线上排查用的思路完整梳理一遍。不管你是刚接触 Redis 想搞明明白白扣库存的逻辑,还是已经写过一版秒杀但总觉得哪里不踏实,下面这些代码结构和取舍理由应该都能直接拿去对照。
1. 场景剖析:三个问题为什么总是一起出现
1.1 一次促销活动暴露出来的三类症状
先描述一个很典型的现场。活动开始十秒,商品详情页显示"还剩 3 件",后台库存表里查出来是 -7;紧接着客服反馈,同一个账号在订单列表里躺着四条记录,属于同一件商品;再往下翻日志,发现商品锁的 key 从活动开始就没被释放过,后续所有请求全部超时。这三件事看起来是三个 bug,实际上它们是同一个根因的三种表现形式。
库存变负,意味着"判断有没有库存"和"减少库存"这两个动作之间存在时间缝隙,多个请求在这条缝隙里同时看到了"还有货"这个结论。一个人抢到多单,同理,是"查这个人有没有下过单"和"记录这个人已经下单"之间有空隙。而锁没被释放,往往是因为加锁和设置过期时间被拆成了两条命令,中间进程挂掉或者网络抖动,锁就成了永久残留。
理解这一点非常重要,因为它直接决定了后面所有方案的设计方向:凡是需要"先判断后修改"的逻辑,只要判断和修改不是一次原子操作,就一定会出问题。Redis 这层能提供的最大价值,就是把这两步压成一步。
1.2 从时序角度看清楚超卖是怎么发生的
拿最简单的两个人并发抢一件库存为 1 的商品来推演,这里不谈 Redis,先看数据库版本的应用层逻辑:
| 时刻 | 线程 A | 线程 B | 数据库中库存 |
|---|---|---|---|
| T1 | 查询库存,得到 1 | - | 1 |
| T2 | - | 查询库存,得到 1 | 1 |
| T3 | 判断 1 > 0,准备扣减 | - | 1 |
| T4 | - | 判断 1 > 0,准备扣减 | 1 |
| T5 | 执行扣减,库存变 0 | - | 0 |
| T6 | - | 执行扣减,库存变 -1 | -1 |
问题出在 T1 和 T2 都读到了同一个快照。如果扣减写成update stock = stock - 1 where id = ?,两条 SQL 在数据库里确实是串行执行的,但业务层早已根据读到的旧值做出了"可以下单"的判断,数据库只是忠实地执行了减法,它并不知道应用层已经做出了错误决策。
所以防超卖的核心思路只有一条:把"读库存"和"减库存"合并成一个不会被其他请求插入的原子动作。数据库层面可以用带条件的更新语句实现,Redis 层面则用 Lua 脚本实现。两者的原理是一样的,只是 Redis 把这件事做得更快、更扛量。
1.3 为什么单靠数据库挡不住大促流量
有人会问,既然update ... where stock > 0本身就是原子的,为什么还要引入 Redis?答案在并发量和连接数上。一件热门商品的所有下单请求会集中打在同一行数据上,InnoDB 的行锁会把它们排成一条队列,每一条都要经历加锁、更新、写 redo log、提交的过程。几百 QPS 尚可,上万 QPS 时连接池会迅速被打满,后续请求开始排队等连接,接口响应时间从几十毫秒涨到几秒,前端超时重试又会带来新一轮流量,形成雪崩。
Redis 单机扛几万 QPS 的简单读写没什么压力,而且它的命令执行是单线程的,天然不存在多个命令同时交错执行的问题。把库存预扣减放在 Redis 里做,等于把绝大部分无效请求挡在最前面——库存已经为 0 的时候,请求在 Redis 这一层就被拒绝了,根本走不到数据库。这就是常说的分层拦截思路。
1.4 方案选型的整体结构
我把实际用的结构整理成下面这张表,方便对照:
| 层次 | 承担职责 | 技术要求 | 兜底能力 |
|---|---|---|---|
| 前端与网关 | 限流、按钮置灰、验证码 | 响应快 | 弱,可被绕过 |
| Redis 预扣减 | 库存判断 + 扣减 + 一人一单 | 原子、低延迟 | 中,可能丢数据 |
| 消息队列 | 削峰、异步化下单 | 可靠投递 | 中 |
| 数据库 | 最终扣减、唯一约束 | 事务、一致性 | 强 |
| 对账任务 | 定期校验、修复偏差 | 可重跑 | 最终兜底 |
这个结构里有两条防线是真正硬的:Redis 的 Lua 原子脚本负责挡住绝大部分流量,数据库的唯一索引和条件更新负责兜住最后一道。中间的消息队列解决的是"Redis 扣成功了但订单还没落库"这个时间窗口里的体验问题。
有一点需要提前说清楚:Redis 的预扣减不是记账,它只是"占位"。真正决定这单成不成立的是数据库那一次事务提交。所以 Redis 扣减成功不等于下单成功,回补逻辑必须写完整,否则库存会被慢慢吃掉。
2. 库存超卖:从根因到可落地的扣减方案
2.1 数据库侧的两道防线与它们各自的代价
先说数据库侧,因为它是最终一致性的落点,绕不开。最直接的写法是条件更新:
update tb_seckill_goods set stock = stock - 1 where id = #{goodsId} and stock > 0;执行完拿返回值affectedRows,等于 1 说明扣减成功,等于 0 说明库存已经没了。这条 SQL 在 InnoDB 下会对目标行加排他锁,判断和扣减在同一个语句里完成,不会出现读到旧值的问题。这是最省事、最可靠的一招,我在很多项目里都把它作为最终防线保留着。
另一种写法是乐观锁版本号,先查出 stock 和 version,然后update ... set stock = stock - 1, version = version + 1 where id = ? and version = #{oldVersion}。它的好处是不用长时间持有行锁,适合读多写少的场景;缺点是冲突高的时候失败率会很高,需要业务层做重试,重试又带来了新的并发压力。在秒杀这种极端写热点下,乐观锁的重试成本反而比条件更新更高。
至于select ... for update的悲观锁,我基本不推荐用在秒杀主链路上。它会把行锁持有到事务结束,事务里但凡有一点点外部调用或者慢查询,锁的持有时间就会拉长,连接池迅速堆积。更麻烦的是多个商品交叉下单时容易出现死锁,排查起来非常费劲。
2.2 Redis 预扣减:Lua 为什么必须是唯一选择
Redis 提供了DECR命令,看起来正好适合扣库存。但单独用DECR有个明显问题:它不判断库存是否已经小于等于 0,库存减到负数它照样执行。于是你会看到"还剩 -3 件"这种尴尬数据。
那就先GET再判断再DECR?这就回到了前面说的时序问题。Redis 虽然是单线程处理命令,但两条命令之间是可以插入其他客户端请求的。A 客户端 GET 到 1,还没来得及 DECR,B 客户端把库存减到了 0,A 再执行 DECR 就变成了 -1。
Lua 脚本的价值就在这里。Redis 在执行一个 Lua 脚本期间,会把整个脚本当作一个整体,中间不会插入任何其他客户端的命令。这就相当于给这段逻辑加了一把天然的、无成本的锁。库存判断、扣减、一人一单标记,全都可以塞进同一个脚本里。
-- KEYS[1] = 库存 key,例如 seckill:stock:{1001} -- 返回值:1 扣减成功;0 库存不足;-1 库存未初始化 local stock = redis.call('GET', KEYS[1]) if stock == false then return -1 end if tonumber(stock) <= 0 then return 0 end redis.call('DECR', KEYS[1]) return 1这段脚本很短,但有几个细节值得单独拎出来说。第一,GET返回的是字符串,必须用tonumber转换后再比较,直接拿字符串和数字比在某些 Lua 版本下会报错。第二,库存 key 不存在时返回 -1 而不是 0,是为了区分"活动没开始"和"卖光了"这两种状态,前端展示的文案完全不一样。第三,脚本里绝对不要写KEYS *这类命令,也不要写循环遍历大量 key 的逻辑,Lua 执行期间整个 Redis 是被阻塞的,脚本跑太久会拖垮所有业务。
2.3 库存预热与 key 的过期策略
Redis 里的库存不是凭空出现的,必须在活动开始前从数据库同步过去。我通常的做法是活动配置审核通过后触发一次预热,把库存数量写入seckill:stock:{goodsId},同时给这个 key 设一个比活动结束时间晚几小时的过期时间。
设置过期时间的理由有两个。一是防止活动长期挂着导致内存里堆积大量无用的库存 key;二是防止上一次活动的脏数据影响下一场。但过期时间不能设得太紧,如果设成活动结束就过期,而对账任务还没跑完,就会出现"库存 key 没了,对账查不到数据"的尴尬局面。我的经验是留出至少两小时缓冲,对账任务在这个窗口内完成。
预热还有一个容易忽略的点:预热动作本身要幂等。活动配置可能被反复修改,如果每次修改都执行INCR,库存会越加越多。正确的做法是直接用SET覆盖写入,而不是增量累加。
2.4 扣减成功之后:异步下单与失败回补
Redis 扣减成功只代表"占到了名额",订单还没有真正创建。这里我一般用消息队列把后续流程异步化,消息体里带上用户 ID、商品 ID、活动 ID 和一个请求流水号。
下游消费者拿到消息后做的事情是:开启数据库事务,先执行update ... where stock > 0,再插入订单记录,最后提交。如果数据库扣减返回 0(说明 Redis 和数据库的库存数据出现了偏差),或者插入订单时因为唯一索引冲突失败,就需要回补 Redis。
回补的逻辑要特别小心,必须是"先补偿再确认"的顺序。具体来说,先在 Redis 里把用户从集合中移除、把库存加回去,然后再把消息标记为消费失败或者进入重试队列。如果顺序搞反了,先标记成功再回补,中间进程挂掉就会造成永久性的库存丢失。
public void rollback(Long activityId, Long userId) { String stockKey = "seckill:stock:{" + activityId + "}"; String userKey = "seckill:users:{" + activityId + "}"; // 用 Lua 保证移除用户和加回库存是一个原子动作 redisTemplate.execute(ROLLBACK_SCRIPT, Arrays.asList(stockKey, userKey), userId.toString()); }对应的回补脚本:
-- KEYS[1] 库存 key,KEYS[2] 用户集合 key,ARGV[1] 用户 ID -- 只有确实在集合中才回补,防止重复回补导致库存虚增 local removed = redis.call('SREM', KEYS[2], ARGV[1]) if removed == 1 then redis.call('INCR', KEYS[1]) return 1 end return 0这个脚本用SREM的返回值来判断是否真的移除成功,只有移除成功才加库存。这一步非常关键,因为消息队列在极端情况下可能重复投递同一条消息,如果没有这个判断,库存会被重复加回去,反而变成"超卖的反面"——明明卖完了却还有货,导致后续下单全部失败。
2.5 库存分桶:应对极端热点的进阶手段
单个商品的库存 key 在极端情况下也会成为热点,所有请求都打在这一个 key 上。Redis 单线程处理命令,虽然单条命令很快,但几十万 QPS 集中在一个 key 上仍然会有排队。解决办法是库存分桶,把总库存拆成若干个桶,比如 1000 件拆成 10 个桶,每个桶 100 件。
请求进来后先随机选一个桶(或者按用户 ID 哈希取模),在桶内执行扣减,桶空了就换下一个桶。这样热点被分散到 10 个 key 上,压力立刻降下来。代价是实现复杂度上升:需要维护桶的状态、需要遍历查找可用桶、对账时要把所有桶的数量加起来。
我的建议是,日常活动库存几千件的量级完全不需要分桶,直接单 key 就够了。只有当单品库存上万且预估 QPS 极高的时候,才考虑上分桶,而且分桶逻辑一定要封装成独立的服务方法,不要散落在业务代码里,否则后面维护会很痛苦。
3. 一人一单:Set 结构比想象中更合适
3.1 单独用 setnx 撑不住的三个理由
"一人一单"这个需求,很多人的第一反应是用setnx,给每个用户建一个 key,形如order:1001:user:8888。这个思路在小规模场景下能用,但放到促销场景里就有问题。
第一个问题是内存开销。一百万个用户就是一百万个 key,每个 key 除了值本身,还要承担 Redis 内部的对象头、过期字典等额外开销,实际占用可能是纯数据的几倍。而且这么多小 key 的过期清理本身也是一种负担。
第二个问题是操作不是原子的。真正需要保证的是"判断用户有没有下过单"和"记录用户已下单"这两步不可分割。setnx单独用确实能实现这一点,但它没法跟库存扣减合并成一个原子操作。如果先setnx再扣库存,扣库存失败时用户就被锁死了,明明没买到却再也不能买;如果先扣库存再setnx,setnx失败时库存就白扣了。这两个顺序都有漏洞。
第三个问题是查询和统计不方便。运营想看"这场活动有多少人参与",用一堆独立 key 只能靠SCAN慢慢数,这在生产环境里是禁忌操作。而用 Set 结构,一个SCARD命令就能拿到结果。
3.2 Set 结构的原子语义与内存估算
一个活动对应一个 Set,成员是用户 ID。SADD命令有一个非常适合这个场景的特性:当成员不存在时返回 1,已存在时返回 0。这个返回值天然就是"是否首次下单"的判断依据。
内存方面,Redis 的 Set 在成员数量较少且都是整数时,会用 intset 这种紧凑编码,每个元素只占几个字节。成员数量超过阈值(默认 512)后会转成 hashtable,开销会明显上升,但即使按 hashtable 估算,一百万个用户 ID 大概也就是几十兆的量级,比一百万个独立 key 要省得多。
| 方案 | 存储开销 | 原子性 | 统计能力 | 清理成本 |
|---|---|---|---|---|
| 每用户一个 String key | 高,对象头占比大 | 单 key 内可以 | 差,需要遍历 | 高,依赖逐个过期 |
| 每活动一个 Set | 低,紧凑编码 | 单 key 内可以 | 好,SCARD 直接取 | 低,整 key 过期 |
| 每活动一个 Hash | 中 | 单 key 内可以 | 好,HLEN 直接取 | 低 |
| 数据库唯一索引 | 磁盘存储 | 强 | 一般,需要聚合查询 | 随数据增长 |
实际选择上,如果只是判断"有没有下过单",Set 足够了。如果还要记录下单时间、订单号这些附加信息,可以用 Hash,field 是用户 ID,value 是订单信息。我大多数时候用 Set,因为附加信息在订单表里本来就有,没必要在 Redis 里重复存一份。
3.3 把库存与限购合成一个 Lua 脚本
前面两个小节分别解决了库存和限购,但真正关键的是把它们合并。合并之后,整个"能不能买"的决策就变成了一个不可分割的整体,不存在中间状态。
-- KEYS[1] 库存 key,KEYS[2] 用户集合 key -- ARGV[1] 用户 ID -- 返回值:1 抢购成功;0 库存不足;2 重复下单;-1 活动未初始化 local userId = ARGV[1] local stock = redis.call('GET', KEYS[1]) if stock == false then return -1 end if tonumber(stock) <= 0 then return 0 end if redis.call('SISMEMBER', KEYS[2], userId) == 1 then return 2 end redis.call('DECR', KEYS[1]) redis.call('SADD', KEYS[2], userId) return 1这段脚本的判断顺序是有讲究的。先判断库存是否存在,再判断库存是否充足,最后才判断用户是否重复。为什么把重复判断放在库存判断之后?因为在绝大多数促销场景里,库存售罄是更常见的结果,先判断库存可以让脚本在售罄后更早返回,少执行一条SISMEMBER,虽然单次差异微乎其微,但在几十万 QPS 下累积起来是可观的。
也有人会问,为什么不用SADD的返回值直接判断,而是先SISMEMBER再SADD?因为如果先SADD再判断库存,库存在SADD之后发现不足,就必须回滚移除用户,多了一步操作且容易出错。先查后写的顺序更清晰。
Java 侧调用这段脚本:
private static final DefaultRedisScript<Long> SECKILL_SCRIPT; static { SECKILL_SCRIPT = new DefaultRedisScript<>(); SECKILL_SCRIPT.setScriptSource( new ResourceScriptSource(new ClassPathResource("lua/seckill.lua"))); SECKILL_SCRIPT.setResultType(Long.class); } public long trySeckill(Long activityId, Long userId) { String stockKey = "seckill:stock:{" + activityId + "}"; String userKey = "seckill:users:{" + activityId + "}"; Long result = redisTemplate.execute(SECKILL_SCRIPT, Arrays.asList(stockKey, userKey), userId.toString()); return result == null ? -1 : result; }注意 key 的命名里带了花括号,这是 Redis Cluster 的 hash tag 语法。在集群模式下,Lua 脚本涉及的所有 key 必须落在同一个哈希槽,否则会直接报错。用{activityId}把两个 key 绑定到同一个槽,是分布式部署时必须要做的事情,很多在单机环境跑得好好的代码,一上集群就报CROSSSLOT错误,原因就在这里。
3.4 集合的过期清理与冷热数据分离
用户集合的 key 一定要设置过期时间。活动结束后这个集合就没有用了,如果不清理,随着活动一场场累积,内存会被慢慢吃掉。过期时间同样建议设在活动结束后留出几小时的缓冲,等对账任务跑完再让它消失。
还有一种情况需要处理:活动结束后,运营想统计"这场活动有多少人参与",但 key 已经过期了。我的做法是在活动结束后的对账任务里,把SCARD的结果和关键统计信息落库,之后就算 key 没了也不影响运营报表。这些统计数据本来也不该长期放在 Redis 里。
另外提一个容易忽视的细节:如果活动允许"取消订单后释放限购名额",那么用户取消订单时需要从集合里SREM掉。这个操作必须和库存回补放在同一个 Lua 脚本里执行,否则又会出现中间状态。而且要注意判断SREM的返回值,只有真的移除了才执行加库存,避免重复取消导致库存虚增。
4. Redis 分布式锁:什么时候真的需要它
4.1 先说结论:能不用就不用
写到这里必须先泼一盆冷水。前面讲的库存扣减和一人一单,用 Lua 脚本已经完全解决了,完全不需要分布式锁。这是一个很重要的判断——很多人在做秒杀的时候,上来就给商品加一把分布式锁,结果把好好的并发改成串行,QPS 掉了两个数量级,还引入了锁超时、锁误删、主从切换丢锁等一堆新问题。
分布式锁的适用场景是有明确边界的:需要跨多个 key 或多个服务做一串操作,且这些操作没法用一次 Lua 脚本表达;或者需要调用外部服务,且必须保证同一时刻只有一个请求在调用。比如"修改活动配置 + 刷新本地缓存 + 通知其他节点",这种涉及多步协调的操作,才轮得到锁上场。
判断标准很简单:如果你的逻辑能写成一段 Lua 脚本,就不要用锁。Lua 是乐观的、无等待的,失败就返回失败;锁是悲观的、排队的,会带来等待和超时。在秒杀这种追求吞吐的场景里,前者永远优于后者。
4.2 setnx 与 expire 分开写的历史教训
早期很多人写锁是这样的:
Boolean ok = redisTemplate.opsForValue().setIfAbsent(lockKey, "1"); if (Boolean.TRUE.equals(ok)) { redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS); try { doBusiness(); } finally { redisTemplate.delete(lockKey); } }这段代码有两个致命问题。第一,setIfAbsent和expire是两条命令,如果在这两条命令之间服务重启或者网络中断,锁就永久留在那里了,后续所有请求全部阻塞。第二,delete是不校验持有者的,如果业务执行时间超过了 30 秒,锁自动过期被别人拿走,原来的线程执行完finally里的 delete,删掉的是别人的锁。接着第三个线程又能拿到锁,形成连环误删。
正确的基础写法只有一条命令:
SET lock:activity:1001 "request-uuid-abc" NX EX 30NX表示 key 不存在才设置,EX 30表示 30 秒后自动过期,两条语义在一条命令里完成,Redis 保证原子性。这一步是所有分布式锁的地基,没有它后面全都白谈。
4.3 释放锁:值校验加 Lua 才是完整闭环
释放锁的关键在于"只删自己的锁"。做法是在加锁时写入一个唯一的标识(比如 UUID 加上线程 ID),释放时先比对标识再删除。比对和删除是两步,同样需要用 Lua 保证原子性:
-- KEYS[1] 锁 key,ARGV[1] 加锁时写入的唯一标识 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0如果不比对标识直接删,就会出现前面说的误删场景。如果比对了但没有用 Lua 包起来,两个命令之间仍然可能被其他请求插入,导致"判断时锁还是自己的,删除时已经被别人拿走了"这种极小概率但确实存在的漏洞。生产环境的 bug 不看概率,只看会不会发生。
4.4 锁续期、可重入与成熟组件的取舍
即使有了上面这套写法,还有一个绕不过去的矛盾:业务执行时间不确定,锁的过期时间却是固定的。设短了,业务没跑完锁就没了,等于没锁;设长了,万一持有者真的挂了,其他请求要等很久。
解决办法是给锁加"续期"机制,也就是常说的看门狗。基本原理是加锁成功后启动一个后台任务,每隔一段时间(通常是过期时间的三分之一)检查一次,如果当前线程还在执行业务,就把锁的过期时间重新设置为完整时长。续期同样必须走 Lua 值校验,不能盲目EXPIRE,否则又回到了误删别人的锁的老问题。
如果是同一线程重复加锁,还需要可重入。实现方式是把这个锁的 value 从简单字符串改成 Hash,field 是持有者标识,value 是重入次数。加锁时如果发现持有者是自己,就把次数加一;释放时次数减一,减到 0 才真正删除 key。
这些逻辑自己写并不难,但坑非常多,尤其是续期线程的生命周期管理、异常情况下的清理、处理锁超时后的业务回滚。所以我的实际做法是:能用成熟组件就用成熟组件。Redisson 的RLock已经把可重入、续期、异步释放这些细节都封装好了,接口用起来跟本地锁差不多:
RLock lock = redissonClient.getLock("lock:activity:" + activityId); boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException("操作太频繁,请稍后再试"); } try { doBusiness(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }tryLock的第一个参数是等待时间,第二个是锁的持有时间。注意unlock之前一定要判断isHeldByCurrentThread,否则在没拿到锁的情况下释放会抛异常,把真正的业务异常盖掉,排查起来特别难受。
4.5 锁粒度与超时时间的估算方法
锁的粒度决定了性能。最粗的是全局一把锁,所有商品共享,等于把整个系统串行化了,绝对不能这么写。合理的粒度是按商品或者按活动加锁,lock:activity:{activityId}这样,不同活动之间互不影响。
超时时间怎么定?我的做法是先压测拿到业务逻辑的 P99 耗时,然后把锁的过期时间设为 P99 的三倍左右,同时把tryLock的等待时间设得短一些(比如 1 到 3 秒),让拿不到锁的请求快速失败而不是排队。这是因为在促销场景里,用户等三秒没反应就会刷新页面重新请求,排队反而制造更多流量。
还有一个容易忽略的问题需要单独提醒:Redis 的主从复制是异步的。主节点写入锁之后,如果还没同步到从节点就发生故障切换,新选出的主节点上是没有这把锁的,于是两个客户端同时认为自己持有了锁。这是 Redis 分布式锁在架构层面的固有缺陷,任何基于单实例的锁实现都绕不开。
应对办法有两个方向。一是接受这个风险,但在最终落库的地方加唯一约束兜底,也就是让数据库成为最后一道防线。二是引入 fencing token,每次加锁时分配一个单调递增的序号,客户端写数据库时携带该序号,数据库只接受序号更大的写入,从而把过期的锁持有者挡在外面。前一种方式成本低、够用,后一种更严格但改造成本高。对于绝大多数业务场景,我会选择第一种,把锁当优化手段而不是正确性保证。
5. 线上排查实录:那些文档里不会写的细节
5.1 常见问题速查表
把实际遇到过的问题整理成了下面这张表,出问题时按现象对号入座会比较快:
| 现象 | 可能原因 | 排查方式 | 处置方式 |
|---|---|---|---|
| 库存出现负数 | 扣减逻辑没用 Lua 或脚本写错 | GET库存 key 看当前值,检查脚本是否包含比较判断 | 修正脚本,用回补脚本修复数据 |
| 库存扣了但没生成订单 | 消息丢失或消费失败未回补 | 查消息队列堆积情况、消费日志 | 补发消息,或手工按流水号回补 |
| 同一用户下了多单 | 集合写入失败、key 被误删 | SISMEMBER检查用户是否在集合中 | 检查是否有清理任务误删 key |
| 用户被误判为重复下单 | 回补没清理集合,或缓存脏数据 | 对比集合成员和数据库订单表 | 清理集合中对应成员 |
| 脚本报 CROSSSLOT 错误 | 集群模式下 key 不在同一槽 | 检查 key 命名是否带 hash tag | 统一加{activityId}前缀 |
| 锁一直不释放 | 加锁后异常没走 finally,或过期时间没设 | TTL查看锁的剩余时间 | 手工删除该 key,补上异常处理 |
| 锁被误删导致并发进入 | 释放时没校验持有者 | 检查释放脚本 | 改为值校验加 Lua |
| 本地测试正常线上报错 | 序列化方式不一致 | 检查 RedisTemplate 的序列化配置 | 统一使用 String 序列化 |
5.2 序列化配置不一致引发的诡异问题
这个问题我踩过不止一次,值得单独说。RedisTemplate 默认用的是 JDK 序列化,写进去的值带一堆二进制前缀。如果加锁时用了默认的 RedisTemplate,释放锁时用了 StringRedisTemplate,两边算出来的 key 字节序列是不一样的,结果就是锁明明在,却怎么也删不掉。
更隐蔽的是,这个问题在开发环境可能表现正常,因为恰好两个地方用的是同一个模板,到了线上某次代码合并后引入了新的模板,问题才暴露。我的做法是在项目初期就统一强制使用StringRedisTemplate做所有 key-value 操作,值统一用 JSON 或者纯字符串,并且在代码规范里写清楚"禁止使用默认 RedisTemplate 操作锁和计数器"。如果确实需要存对象,配置一个独立的、明确指定序列化器的模板,命名上区分开。
5.3 大 key 与慢查询的排查思路
用户集合随着活动进行会不断变大,如果活动规模很大,单个 Set 可能有几十万成员。这时候SMEMBERS这样的全量读取命令会阻塞 Redis,绝对不能在生产环境用。判断是不是大 key,可以用MEMORY USAGE key看内存占用,或者STRLEN、SCARD看元素数量。
清理大 key 也要小心,直接DEL一个几十万元素的集合会造成明显的卡顿。正确的做法是用UNLINK异步删除,或者在活动结束后直接依赖过期时间让它自然淘汰。如果必须立刻清理,可以分批SREM,每次删一批,中间留出间隔。
慢查询的排查用SLOWLOG GET 10,能看到执行时间超过阈值的命令。我一般的经验是,Lua 脚本执行时间超过 10 毫秒就要警惕,超过 50 毫秒基本可以认定脚本里写了不该写的东西,比如循环遍历或者全量读取。
5.4 数据库唯一索引:最硬的那道底线
前面所有的 Redis 方案都有失效的可能,可能因为数据被误删、可能因为主从切换、可能因为代码 bug。所以数据库这一层必须有一道最后的防线。
对一人一单来说,最可靠的做法是在订单表上加唯一索引:
alter table tb_seckill_order add unique key uk_activity_user (activity_id, user_id);这样即使 Redis 那层完全失效,同一个人对同一活动的第二次插入也会被数据库拒绝。应用层捕获唯一键冲突异常,转成"您已参与过本场活动"的友好提示即可。
对库存来说,条件更新where stock > 0就是防线。即使 Redis 里的库存数据完全错了,数据库也不会让库存变成负数。这两道防线加起来,才能说系统在极端情况下也是安全的。
我见过一些团队只做 Redis 层不做数据库约束,觉得 Redis 已经挡住了流量,数据库不会出问题。这种想法在小活动里可能侥幸没事,但只要出现一次数据异常,排查和修复的成本远远超过当初加索引的几秒钟。
5.5 压测时要重点观察的几个指标
最后分享一点压测经验。很多人压测只看 QPS 和平均响应时间,这两个指标在秒杀场景里参考价值有限,因为流量是瞬间聚集的,平均值会被大量快速失败的请求拉低。
我重点看三个指标。一是 Redis 的instantaneous_ops_per_sec和latency,判断 Redis 是否成为瓶颈。二是应用的线程池活跃数和队列长度,判断请求是否在应用层堆积。三是数据库的连接池等待时间和慢查询数量,判断压力有没有穿透到最后一层。
还有一个容易被忽略的点:压测要从完整的链路压,包括消息队列消费和数据库写入,不能只压 Redis 那一层。我见过只压 Redis 时 QPS 很漂亮,加上异步消费后整个链路立刻堵住的案例,原因是消费端处理速度跟不上生产端,消息堆积到队列上限后开始丢弃。
库存扣减、一人一单、分布式锁这三件事,说到底都是在跟"并发下的中间状态"较劲。把判断和修改压成一次原子操作,把不安全的锁换成带校验的写法,再在数据库留一道谁都绕不过去的底线,基本就能睡个安稳觉了。真正踩过坑之后会发现,最难的不是写出能跑的代码,而是想清楚每一个失败分支该怎么收场。