☰
秒杀高并发实战:库存超卖、一人一单与Redis分布式锁
2026/10/26 6:16:57 网站建设 项目流程

秒杀功能上线前两天,我盯着一份活动预案反复改,心里最没底的就是三个地方:库存到底会不会被扣成负数、同一个账号能不能绕过限制抢到多单、以及那把挂在商品上的 Redis 分布式锁到底靠不靠得住。做过这类促销场景的人应该都有类似体会——压测环境下一切正常,真到了开抢那一秒,各种边界情况会同时涌上来。这篇内容就围绕库存超卖、一人一单、Redis 分布式锁这三件事,把我自己踩过的坑、验证过的写法、以及线上排查用的思路完整梳理一遍。不管你是刚接触 Redis 想搞明明白白扣库存的逻辑,还是已经写过一版秒杀但总觉得哪里不踏实,下面这些代码结构和取舍理由应该都能直接拿去对照。

1. 场景剖析:三个问题为什么总是一起出现

1.1 一次促销活动暴露出来的三类症状

先描述一个很典型的现场。活动开始十秒,商品详情页显示"还剩 3 件",后台库存表里查出来是 -7;紧接着客服反馈,同一个账号在订单列表里躺着四条记录,属于同一件商品;再往下翻日志,发现商品锁的 key 从活动开始就没被释放过,后续所有请求全部超时。这三件事看起来是三个 bug,实际上它们是同一个根因的三种表现形式。

库存变负,意味着"判断有没有库存"和"减少库存"这两个动作之间存在时间缝隙,多个请求在这条缝隙里同时看到了"还有货"这个结论。一个人抢到多单,同理,是"查这个人有没有下过单"和"记录这个人已经下单"之间有空隙。而锁没被释放,往往是因为加锁和设置过期时间被拆成了两条命令,中间进程挂掉或者网络抖动,锁就成了永久残留。

理解这一点非常重要,因为它直接决定了后面所有方案的设计方向:凡是需要"先判断后修改"的逻辑,只要判断和修改不是一次原子操作,就一定会出问题。Redis 这层能提供的最大价值,就是把这两步压成一步。

1.2 从时序角度看清楚超卖是怎么发生的

拿最简单的两个人并发抢一件库存为 1 的商品来推演,这里不谈 Redis,先看数据库版本的应用层逻辑:

时刻线程 A线程 B数据库中库存
T1查询库存,得到 1-1
T2-查询库存,得到 11
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 30

NX表示 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 很漂亮,加上异步消费后整个链路立刻堵住的案例,原因是消费端处理速度跟不上生产端,消息堆积到队列上限后开始丢弃。

库存扣减、一人一单、分布式锁这三件事,说到底都是在跟"并发下的中间状态"较劲。把判断和修改压成一次原子操作,把不安全的锁换成带校验的写法,再在数据库留一道谁都绕不过去的底线,基本就能睡个安稳觉了。真正踩过坑之后会发现,最难的不是写出能跑的代码,而是想清楚每一个失败分支该怎么收场。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询