简介:围绕Redis分布式锁实现抢单秒杀场景的SpringBoot示例代码包,面向有Java与Spring基础、希望理解高并发库存控制与请求削峰的中级开发者。内容以分布式锁加锁与释放、库存原子扣减、队列缓冲请求、抢单结果状态返回为主线,兼及限流与FIFO处理、锁自动释放、重复抢单幂等和事务一致性等常见难点,可用于搭建本地实验环境或作为课程设计参考。压缩包13个文件、约59KB,包含5个Java源文件、2个properties配置、1个依赖jar,以及工程构建与说明文档等,结构紧凑,便于导入IDE阅读调试。已有4057人学习,读者可据此观察锁过期、库存归零、抢购失败等分支的处理方式,并理解状态码与业务返回值的关系,从而梳理从加锁、扣库存到返回结果的完整实现思路,也可据此复现超卖防护与失败回滚。
1. 从一次超卖事故说起:redis分布式锁在抢单秒杀里到底锁住了什么
抢单秒杀活动的库存数字,是redis分布式锁唯一要守住的东西。凌晨两点被叫起来看线上事故,一百件库存的商品卖出了三百多单,运营在群里刷屏问怎么回事;事后复盘发现不是库存没扣,是扣的时候两个请求同时读到了同一个余量。这个项目要解决的正是这一幕:Spring Boot 起服务,活动开始前把库存预热进 Redis,抢单请求进来先抢锁、再校验资格、再原子扣减、最后把订单丢进队列异步落库,保证一百件库存只生成一百笔订单。
它适合两类人:一类是想把分布式锁从「背八股」变成「能写对」的后端,锁的加锁命令、释放脚本、续期机制都在代码里;另一类是手里有个秒杀需求要落地,需要一份能跑起来的骨架,改掉 key 前缀和业务字段就能接进自己的系统。下面按「架构怎么分 → 锁怎么写 → 库存怎么扣 → 队列怎么消费 → 哪里会翻车 → 怎么改造压测」的顺序拆。
2. 架构拆解:一次抢单请求要穿过限流、锁、Lua 与队列四道关
动手写代码之前,先要把这条链路上每一层负责什么定下来。秒杀和普通下单最大的区别是瞬时并发量——活动开始那一秒可能是平时一小时的流量,如果所有请求都直接打到数据库,行锁会立刻把连接池吃干净,后面排队的请求全部超时,页面显示「系统繁忙」,用户反复点击,流量再翻一倍,形成雪崩。所以这类系统一定是分层的:越靠前的层越轻、越快、越早把无效流量挡掉,越靠后的层越重、越慢、只处理真正有效的请求。
2.1 选型:数据库行锁、Redis 锁、队列各自该站哪个位置
常见做法是把这三样当成互相替代的方案,其实它们是串联的。数据库行锁的作用是兜底,保证最终写库那一刻不出错,它的代价是持有连接、阻塞其他事务,撑不住高并发;Redis 锁的作用是在内存里把「同一件商品的库存判断」串行化,单机十万级 QPS 的量级,一条SET NX PX就能完成互斥,成本极低;队列的作用是削峰,把「扣减成功」和「真正生成订单」这两件事拆开,让请求线程快速返回、让消费线程按自己的能力匀速落库。
我在这个项目里的分工是这样的:限流在本地做,用令牌桶把明显超量的请求挡在业务逻辑之外;去重和资格校验放在 Redis,用SETNX或者集合结构防止一个用户重复提交;互斥交给分布式锁或者 Lua 脚本;落库交给队列。四层各管一件事,任何一层出问题都不会直接击穿数据库。
2.2 请求链路:从进入到返回经过哪几层
一个完整的抢单请求,走完大概是下面这几个步骤。把这些步骤先在纸上画清楚,代码写起来会顺很多,也才知道每一层失败时该返回什么给前端。
| 步骤 | 执行位置 | 主要动作 | 失败时的处理 |
|---|---|---|---|
| 1 | 过滤器 / 拦截器 | 参数校验、活动时间校验、本地令牌桶限流 | 直接返回「活动未开始」或「请求过于频繁」 |
| 2 | 业务层 | Redis 去重,判断该用户是否已参与 | 返回「每人限购一件」 |
| 3 | 业务层 | 按activityId + skuId抢分布式锁 | 抢锁失败快速返回「排队中,请稍后重试」 |
| 4 | Redis Lua | 校验库存并原子扣减、写入已购集合 | 返回「已售罄」 |
| 5 | 业务层 | 生成订单号,写入队列 | 扣减回滚,返回「下单失败」 |
| 6 | 消费端 | 拉取队列、幂等落库、更新订单状态 | 重试,超过次数进死信表 |
注意第 5 步:扣减成功但入队失败,库存必须加回去,否则就是「少卖」。这类补偿逻辑在秒杀里比正常路径更重要,因为异常路径才是事故的高发区。
2.3 工程结构与依赖:一个能跑的最小骨架
这个项目的结构不复杂,按职责切成 controller、service、lock、queue、consumer 几块,好处是锁和队列都能单独替换。依赖上只需要 Redis 客户端和 Web,如果不想额外引入消息中间件,队列直接用 Redis 的 List 结构就够用。
<!-- pom.xml 关键依赖 --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 提供 StringRedisTemplate,执行 Lua 脚本用它 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 可选:需要看门狗续期、可重入锁时再引 --> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.x</version> </dependency> </dependencies>spring-boot-starter-data-redis默认用 Lettuce 做连接工厂,它是共享连接的异步客户端,适合秒杀这种高并发读写的场景,但要注意连接池不是越大越好,后面避坑章节会讲。Redisson 是可选项:如果锁的持有时间不确定(业务里有远程调用、有慢 SQL),用它的看门狗比手写续期稳;如果业务执行时间是确定的几百毫秒,手写SET NX PX反而更轻、更可控。
目录划分大致如下,方便对照后面的代码:
| 目录 / 文件 | 职责 |
|---|---|
controller/SeckillController | 接收抢单请求,返回排队结果 |
service/SeckillService | 抢锁、执行 Lua、生成订单号、入队 |
service/StockWarmUpService | 活动前把数据库库存预热进 Redis |
lock/RedisLockHelper | 加锁、释放锁的封装,含 Lua 释放脚本 |
queue/OrderQueue | Redis List 的入队、出队封装 |
consumer/OrderConsumer | 消费队列、幂等落库、失败回滚 |
resources/lua/seckill.lua | 库存判断与扣减的原子脚本 |
2.4 配置项先定死:锁超时、等待时间、库存 key
配置项在秒杀里不是随便填的,锁超时时间和业务最大执行时间之间的关系直接决定会不会超卖。我一般会先估一个业务执行时间的 P99,然后把锁超时设成它的 2 到 3 倍,并开启续期作为保险。
# application.yml seckill: lock: # 锁的持有时间,单位毫秒,要大于业务 P99 执行时间的 2 倍 lease-time: 3000 # 抢锁的最长等待时间,秒杀场景一般设得很短,抢不到就快速失败 wait-time: 200 key-prefix: "seckill:lock:" stock: # 库存 key 前缀,最终形态 seckill:stock:{activityId}:{skuId} key-prefix: "seckill:stock:" # 已购用户集合前缀,用于一人一单 user-key-prefix: "seckill:users:" queue: order-key: "seckill:order:queue" # 消费线程数,按落库能力调,不是越大越好 consumer-threads: 4 spring: redis: host: 127.0.0.1 port: 6379 lettuce: pool: max-active: 200 max-wait: 200mswait-time设成 200 毫秒是个有意的取舍:秒杀请求等不起,锁被占用说明前面有人正在处理同一件商品,与其让线程挂在那里占资源,不如直接告诉前端「排队中」,让它稍后查结果。consumer-threads也不要拍脑袋调大,消费端最终要写数据库,线程数超过数据库连接池承受能力只会把压力转移成连接等待。
3. 分布式锁实现:SET NX PX、Lua 释放、看门狗续期三段式
锁这块是整个项目的核心,也是最容易写出 bug 的地方。我见过太多代码用setnx加expire两条命令,中间服务一重启,锁就永远留在 Redis 里,只能靠人工删 key 救场。正确的写法必须满足三个条件:加锁是原子的、释放是原子的、超时是可控的。
3.1 加锁:SET NX PX 一行命令里的三个参数
Redis 的SET key value NX PX milliseconds一条命令同时完成「不存在才设置」和「设置过期时间」,Spring Data Redis 里对应setIfAbsent的重载方法。value 不能随便写个1,必须是一个能唯一标识当前持有者的字符串,通常用UUID + 线程 id,这是后面释放锁时判断「这把锁是不是我加的」的依据。
@Component public class RedisLockHelper { private final StringRedisTemplate redisTemplate; public RedisLockHelper(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } /** * 尝试加锁 * @param lockKey 锁的 key,形如 seckill:lock:1001:2001 * @param requestId 持有者标识,释放锁时用它比对 * @param leaseMs 锁的过期时间,毫秒 * @return true 表示加锁成功 */ public boolean tryLock(String lockKey, String requestId, long leaseMs) { // setIfAbsent + 过期时间,底层等价于 SET key value NX PX leaseMs Boolean ok = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMillis(leaseMs)); return Boolean.TRUE.equals(ok); } /** 生成持有者标识,线程级别唯一 */ public String buildRequestId() { return UUID.randomUUID().toString().replace("-", "") + ":" + Thread.currentThread().getId(); } }第一个参数lockKey决定锁的粒度,这个后面单独讲。第二个参数requestId是安全底线,没有它就无法区分「我的锁」和「别人的锁」。第三个参数leaseMs是最容易踩坑的地方:设短了业务没跑完锁就自动释放,别的请求进来直接超卖;设长了服务宕机后锁要等很久才释放,期间这件商品谁都抢不了。我的习惯是先量业务耗时再定值,绝不拍脑袋写个 30 秒。
3.2 释放锁:为什么必须用 Lua 比对 value
释放锁看起来只要del一下,实际上藏着两个陷阱。一是「删错锁」:线程 A 的锁因为超时自动释放了,线程 B 加锁成功,这时候 A 执行完业务去del,删掉的是 B 的锁。二是「判断和删除不原子」:先get判断再del,两步之间锁可能刚好过期并被别人加上,判断通过但删的是新锁。解决办法是把「比对 value」和「删除 key」写进同一段 Lua,Redis 执行脚本是单线程的,中间不会插入其他命令。
-- resources/lua/unlock.lua -- KEYS[1] 锁的 key -- ARGV[1] 当前持有者的 requestId -- 返回 1 表示删除成功,0 表示锁不属于当前持有者 if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 endJava 侧加载脚本并执行,注意DefaultRedisScript要指定返回类型,否则反序列化会拿到奇怪的字节数组:
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(); static { UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/unlock.lua")); UNLOCK_SCRIPT.setResultType(Long.class); } public boolean unlock(String lockKey, String requestId) { Long result = redisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(lockKey), // KEYS[1] requestId); // ARGV[1] return result != null && result == 1L; }脚本里的KEYS和ARGV是 Redis 规定的两个数组,KEYS放 key,ARGV放其他参数。之所以强调「key 要通过 KEYS 传」,是因为集群模式下 Redis 需要根据 key 计算槽位,写死在脚本文本里会导致路由错误。返回值的含义也要约定清楚:1是正常释放,0说明锁已经不属于自己(大概率是超时后被别人拿走了),这时候要打日志告警,因为这往往意味着业务执行时间超过了锁的租期。
3.3 锁续期:看门狗和「业务超时」这对矛盾
手写锁的痛点是租期难定:定短了会超卖,定长了宕机后恢复慢。Redisson 的看门狗解决的就是这个问题——加锁时不指定leaseTime,它会给锁一个默认 30 秒的过期时间,然后起一个后台任务,每隔三分之一租期检查一次,只要当前线程还持有锁就续到 30 秒,直到显式unlock或者服务下线。
@Resource private RedissonClient redissonClient; public void seckillWithRedisson(String lockKey, Runnable business) { RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { // leaseTime 传 -1 才会触发看门狗,传具体值则不会续期 locked = lock.tryLock(200, -1, TimeUnit.MILLISECONDS); if (!locked) { throw new IllegalStateException("抢锁失败,稍后重试"); } business.run(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 必须判断锁是否还被当前线程持有,否则会抛 IllegalMonitorStateException if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } }这里的坑在于leaseTime传了具体数值,看门狗就不生效了,锁到点自动释放,续期逻辑压根不会启动。另一个坑是unlock没加isHeldByCurrentThread判断,业务抛异常或者抢锁失败时走到 finally,Redisson 会直接抛异常把原始异常盖掉,排查时看到的堆栈和真实问题毫无关系。
看门狗也不是万能的:它依赖 JVM 活着,服务被强杀或者发生长时间 Full GC,续期线程同样会停,锁照样会过期。所以真正稳的方案是「锁 + 最终校验」双保险,锁只管把并发收敛,落库前再用数据库的唯一索引或者乐观锁版本号确认一次,别把正确性全押在锁上。
3.4 锁粒度:别拿一把全局锁锁住整个活动
锁的 key 设计直接决定吞吐量。写成seckill:lock一把全局锁,所有活动、所有商品、所有用户全部串行,QPS 会被压到个位数,压测时能看到请求排成长队。正确的做法是把锁拆到「活动 + 商品」这一级,不同 SKU 之间互不影响:
| key 形态 | 粒度 | 适用场景 |
|---|---|---|
seckill:lock:{activityId}:{skuId} | 单个活动下的单个商品 | 绝大多数秒杀,推荐 |
seckill:lock:{activityId}:{skuId}:{slot} | 再拆成 N 个槽位 | 单品热点极高,单 key 打满时 |
seckill:lock | 全局 | 只适合压测或极小流量 |
如果单个 SKU 的并发高到一把锁扛不住,可以把它拆成若干槽位,每个槽位存一份库存(比如 100 件拆成 10 份各 10 件),请求按用户 id 取模落到某个槽位,锁粒度跟着槽位走。代价是可能出现「某个槽位卖完了但别的槽位还有货」,用户体验上表现为「明明还有库存却提示售罄」,要不要接受这个代价取决于业务,不是纯技术决策。
4. 库存扣减与队列削峰:Lua 原子脚本加异步落单的完整闭环
锁解决的是「同一时刻只有一个请求在处理这件商品」,但光有锁还不够——如果锁住的业务里藏着「读库存、判断、减库存」三次 Redis 往返,中间依然可能被其他路径改写。真正干净的做法是把判断和扣减塞进一段 Lua,让它们在一次 Redis 调用里完成。
4.1 库存预热:活动开始前把数据库数字灌进 Redis
秒杀不能等请求来了再查数据库,库存必须在活动开始前预热到 Redis,key 按「活动 + 商品」拆分,value 就是剩余件数。预热一般在活动配置生效时触发,或者由定时任务在开始前几分钟跑一遍,同时给 key 设置一个比活动结束时间略长的过期时间,避免活动结束后残留一堆垃圾数据。
public void warmUp(Long activityId, Long skuId, int stock) { String stockKey = stockKeyPrefix + activityId + ":" + skuId; String userKey = userKeyPrefix + activityId + ":" + skuId; // 库存写入,注意这里直接覆盖,重复预热不会叠加 redisTemplate.opsForValue().set(stockKey, String.valueOf(stock)); // 清空该商品的已购集合,防止上一轮活动的数据串味 redisTemplate.delete(userKey); // 活动结束后自动清理,时间按活动实际时长加缓冲 redisTemplate.expire(stockKey, Duration.ofHours(2)); redisTemplate.expire(userKey, Duration.ofHours(2)); }重复预热不会叠加这点很关键:如果预热方法里写成increment,运维手抖点两次,库存就凭空多出一倍。另外已购集合必须跟着清,否则用户「上一场买过」的记录会让他在新一场里被判定为重复下单。
4.2 Lua 原子扣减:判断和扣减不能分两步
库存判断、一人一单校验、扣减这三件事必须在同一个脚本里完成。脚本里如果库存不足要返回明确的错误码,方便前端区分「售罄」和「你已买过」,别统一返回一个false让业务层去猜。
-- resources/lua/seckill.lua -- KEYS[1] 库存 key:seckill:stock:{activityId}:{skuId} -- KEYS[2] 已购集合 key:seckill:users:{activityId}:{skuId} -- ARGV[1] 用户 id -- 返回:1 抢单成功 0 已售罄 2 重复下单 -1 库存未初始化 local stock = tonumber(redis.call('get', KEYS[1])) if stock == nil then return -1 end if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then return 2 end if stock <= 0 then return 0 end redis.call('decr', KEYS[1]) redis.call('sadd', KEYS[2], ARGV[1]) return 1Java 侧执行并处理返回码。注意两个 key 要一起传进KEYS,集群模式下它们必须落在同一个槽位,稳妥的做法是用{activityId}这种 hash tag 让相关 key 绑到一起。
private static final DefaultRedisScript<Long> SECKILL_SCRIPT; static { SECKILL_SCRIPT = new DefaultRedisScript<>(); SECKILL_SCRIPT.setLocation(new ClassPathResource("lua/seckill.lua")); SECKILL_SCRIPT.setResultType(Long.class); } public int tryDeduct(Long activityId, Long skuId, Long userId) { String stockKey = stockPrefix + activityId + ":" + skuId; String userKey = userPrefix + activityId + ":" + skuId; Long code = redisTemplate.execute( SECKILL_SCRIPT, Arrays.asList(stockKey, userKey), // KEYS[1]、KEYS[2] String.valueOf(userId)); // ARGV[1] return code == null ? -1 : code.intValue(); }返回码要和前端约定好,0提示「已抢完」,2提示「您已参与过」,-1是配置问题,应该走告警而不是直接展示给用户。这段脚本执行时间在毫秒以内,不会成为瓶颈,真正的瓶颈在后面的落库。
4.3 入队与削峰:先建订单号,再慢慢落库
扣减成功之后不要在请求线程里直接写数据库,那样前面所有的削峰努力都白费了。正确顺序是:生成全局唯一的订单号,把订单信息序列化后LPUSH进队列,然后立刻返回「排队中」,让前端拿着订单号去轮询结果。
public String submit(Long activityId, Long skuId, Long userId) { int code = tryDeduct(activityId, skuId, userId); if (code != 1) { throw new BizException(code == 0 ? "已售罄" : "您已参与过本场活动"); } // 订单号用 Redis 自增生成,保证全局唯一且有序 String orderId = "SK" + activityId + redisTemplate.opsForValue() .increment("seckill:order:seq:" + activityId); SeckillOrder order = new SeckillOrder(orderId, activityId, skuId, userId, System.currentTimeMillis()); try { // 入队失败要回滚库存,否则就是少卖 orderQueue.push(order); } catch (Exception e) { rollback(activityId, skuId, userId); throw new BizException("下单失败,请重试"); } return orderId; } private void rollback(Long activityId, Long skuId, Long userId) { String stockKey = stockPrefix + activityId + ":" + skuId; String userKey = userPrefix + activityId + ":" + skuId; redisTemplate.opsForValue().increment(stockKey); redisTemplate.opsForSet().remove(userKey, String.valueOf(userId)); }队列用 Redis 的 List 就够了:LPUSH进、BRPOP出,天然是 FIFO,还带阻塞等待,消费端不用写轮询。订单号用INCR生成,比 UUID 好排查,出问题时看一眼订单号就知道属于哪场活动。回滚顺序要注意——先加库存再删用户标记,反过来的话中间有一个窗口期用户能重复抢。
4.4 消费端:幂等落单与失败回滚
消费端是整个链路里唯一碰数据库的地方,也是最需要幂等的地方。Redis 的 List 不保证「只消费一次」,消费线程崩溃重启、BRPOP之后业务没执行完就宕机,都会导致同一条消息被处理两次。幂等靠数据库唯一索引兜底最可靠,Redis 的SETNX只能作为前置过滤,不能当唯一依据。
@Component public class OrderConsumer { @Resource private OrderQueue orderQueue; @Resource private OrderMapper orderMapper; @PostConstruct public void start() { for (int i = 0; i < consumerThreads; i++) { Thread t = new Thread(this::loop, "order-consumer-" + i); t.setDaemon(true); t.start(); } } private void loop() { while (!Thread.currentThread().isInterrupted()) { SeckillOrder order = orderQueue.take(); // 内部是 BRPOP,阻塞 1 秒 if (order == null) { continue; } try { // 数据库对 (activity_id, user_id) 建唯一索引,重复插入直接抛异常 orderMapper.insertIgnore(order); } catch (Exception e) { // 落库失败:库存加回去,用户标记移除,交给人工或重试队列 log.error("落单失败 orderId={}", order.getOrderId(), e); rollback(order); } } } }insertIgnore依赖数据库的唯一索引,这是幂等的最后一道闸门,比任何应用层判断都可靠。落库失败时要把 Redis 里的库存和用户标记都还原,否则这笔库存就永久消失了。生产上还会加一层重试队列,把失败消息转存到另一个 List 或者死信表,由定时任务补偿,而不是在消费循环里原地重试——原地重试很容易把线程堵死。
5. 避坑与排查:五个真实翻车现场的现象、原因和补救
这套东西写出来能跑通不难,难的是高并发下不出岔子。下面这几条都是我在秒杀场景里真遇到或者见别人踩过的,按「现象 → 原因 → 解决」列出来,对着排查能省不少时间。
5.1 现象:库存变成负数,订单数超过库存数
原因基本只有一个:锁在业务执行完之前就过期了,第二个请求拿到锁进来,读到的还是扣减前的库存。日志里的特征很明显——同一件商品的两条扣减记录时间间隔小于锁的租期。
解决分两步。第一步把leaseTime调到业务 P99 的 2 到 3 倍,或者改用看门狗续期;第二步在 Redis 扣减之外加数据库兜底,用UPDATE stock SET num = num - 1 WHERE sku_id = ? AND num > 0配合影响行数判断,就算 Redis 层出了问题,数据库也不会写成负数。别指望单靠锁就百分百安全,锁是性能优化,不是正确性保证。
5.2 现象:日志里出现IllegalMonitorStateException,或者锁莫名被删
前者的原因是unlock之前没判断isHeldByCurrentThread,线程压根没持有锁就去释放。后者是手写锁时释放逻辑没比对requestId,把别人的锁删了,表现为「A 明明还在跑,B 却已经拿到锁」。
解决:手写锁一律用 Lua 比对 value 再删,Redisson 一律加isHeldByCurrentThread判断。另外requestId一定要线程级别唯一,见过有人用固定的常量字符串当 value,等于没做校验。
5.3 现象:主从切换后,同一件商品被卖出两份
这是 Redis 主从异步复制的固有问题,不是代码 bug。锁在主节点加成功,还没同步到从节点主节点就挂了,从节点升主之后锁就丢了。秒杀这种场景一旦丢锁,后果就是超卖。
解决思路有三条:一是接受它,用数据库唯一索引兜底,把损失控制在一两单而不是灾难性超卖;二是对一致性要求极高的活动,改用 Redlock 或者基于共识算法的协调服务,代价是延迟上升;三是把「锁 + 库存扣减」合并成一次 Lua 执行,让关键判断尽量短,缩短暴露窗口。
5.4 现象:用户收到两条一模一样的下单成功通知
队列消息重复消费,消费端没做幂等。BRPOP取出消息后消费线程崩溃,消息其实已经出队但业务没提交,或者重试逻辑把同一条消息又塞回队列,都会触发这个问题。
解决:数据库对(activity_id, user_id)建唯一索引,插入用insert ignore或者捕获唯一键冲突异常;同时给每条订单消息带一个业务唯一 id,消费前用 RedisSETNX做一次前置过滤,把绝大部分重复挡在前面。记住顺序:Redis 过滤是优化,数据库唯一索引才是最终保证。
5.5 现象:压测时请求大量超时,但 Redis CPU 和数据库 CPU 都不高
这种「哪儿都不忙但就是慢」的情况最玄学,八成是连接池或者线程池卡住了。Lettuce 默认共享一个连接,如果业务里有阻塞式调用,所有请求会排队;consumer-threads调太大把数据库连接池占满,消费线程全在等连接,队列越积越长。
排查顺序:先看lettuce.pool.max-wait是不是设得太小导致获取连接直接超时,再看max-active和实际并发是否匹配,最后看消费端线程数和数据库连接池上限的关系。我的经验值是消费线程数与数据库连接池上限保持在 1:2 以内,别让消费端自己把自己堵死。
6. 二次开发:把锁粒度和队列消费速率调到能扛住压测的位置
拿到这套代码之后,大多数人第一件想改的事是「把商品 id 换成自己的业务字段」,这很简单;真正决定它能不能上生产的,是锁粒度和消费速率这两个参数。我一般在预发环境先跑一轮全链路压测,用并发请求打满库存,然后看三个数字:Redis 扣减数、队列长度峰值、数据库订单数。三个数字必须能对上,对不上就说明中间有丢单或者超卖。
# 2000 个请求、200 并发打同一个 sku,观察 HTTP 状态码分布 seq 1 2000 | xargs -P 200 -I{} curl -s -o /dev/null -w "%{http_code}\n" \ -X POST http://127.0.0.1:8080/api/seckill/order \ -H 'Content-Type: application/json' \ -d '{"activityId":1001,"skuId":2001,"userId":{}}' | sort | uniq -c-P 200控制并发度,userId用序号占位保证每个请求是不同用户,跑完把成功的数量和各返回码数量统计出来。如果成功数正好等于库存数,说明主链路没问题;如果成功数小于库存数,去查是不是有请求在抢锁阶段被拒绝得太狠,适当把wait-time放宽一点;如果大于库存数,立刻停手查锁的租期和 Lua 脚本。
常见改造方向我列在下面,按投入产出排序:
| 改造点 | 做法 | 验证方式 |
|---|---|---|
| 锁粒度变细 | 从activityId:skuId拆到槽位级别,每槽独立库存 | 压测看同一 SKU 的 QPS 是否线性上升 |
| 队列换中间件 | List 换成消息队列,获得重试、死信、削峰三重能力 | 消费端宕机重启后消息是否还在 |
| 库存预热自动化 | 活动配置生效时发事件触发预热,替代定时任务 | 改活动库存后 Redis 值是否同步更新 |
| 结果查询接口 | 按订单号查状态,替代前端轮询库存 | 前端能区分「排队中」「成功」「失败」 |
最后说个习惯。早些年我做秒杀,压测只看 QPS 曲线好不好看,结果上线当天库存对不上,运营手工补了半天单。从那以后我每次上线秒杀活动前,都强制走一遍「预热 → 压测 → 对账」的流程:先用脚本把库存和已购集合清空重灌,再打满并发,最后拿数据库订单数和活动库存做减法,两个数字对不上就不发版。听着笨,但真能挡住大部分事故。希望帮到你。
本文还有配套的精品资源,点击获取