ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis分布式锁+异步队列解决秒杀超卖实战

Redis分布式锁+异步队列解决秒杀超卖实战

简介:面向高并发电商秒杀场景的Redis分布式锁实战示例,适合具备Spring Boot与Redis基础、希望理解抢单限流与库存一致性实现的Java开发者。内容围绕分布式锁加锁与释放、库存原子扣减、重复抢单与超卖防控、请求排队削峰及抢单结果状态返回等关键环节展开,可对照代码梳理锁自动释放、幂等与事务一致性等易错点。压缩包共13个文件,约59KB,以5个Java源码为主体,配合2个properties配置、pom.xml依赖说明、README文档及Maven Wrapper脚本等,结构紧凑,便于直接导入运行和二次改造。已有4057人学习下载,适合作为秒杀抢单原型参考或课程设计、面试复盘的基础工程。

1. 从一次超卖事故说起:Redis 分布式锁加队列撑起的秒杀骨架

秒杀抢单的代码写起来不难,难的是并发压到两三千的时候不出洋相。我最早那版直接拿数据库 update 扣库存,本地跑得挺好,一上压测就出现负库存,日志里全是同一用户重复下单——典型的超卖翻车。后来换成 Redis 分布式锁加异步队列这套骨架:Redis 扛住瞬时入口流量,锁保证同一商品同一时刻只有一个请求进扣减逻辑,队列把下单落库这类慢活挪到后台消费。项目用 Spring Boot 搭,模块拆得比较干净,适合正在做秒杀、抢单、优惠券领取这类高并发场景的开发者,也适合想把分布式锁真正写对一次的人。

2. 链路拆解:一次抢单请求从 Spring Boot 入口到 Redis 队列的完整走向

先把整体链路说清楚,不然读代码的时候容易迷路。这套东西本质上做两件事:把「谁能抢到」这个判断从数据库挪到 Redis 内存里做,再把「抢到之后写订单」这个慢动作异步化。前者靠分布式锁和 Lua 脚本保证原子性,后者靠队列削峰。

2.1 为什么是 Redis 锁加队列,而不是数据库行锁

数据库行锁的思路是select ... for update把商品行锁住,扣完再放开。单机小流量没问题,一上量就原形毕露:所有请求排队等同一行记录,InnoDB 的行锁等待直接打满连接池,后面进来的请求连数据库都连不上,超时告警一片。热点行锁竞争这件事,加机器是解决不了的,因为大家抢的还是同一行。

Redis 这边的优势在于它是单线程处理命令的,天然串行,一次SET NX判断加锁的成本在微秒级,几千并发压过来也就是排队进内存。但要注意,锁只能解决「同一时刻只有一个线程进临界区」,解决不了「库存扣减本身要原子」,所以真正的扣减还得用 Lua 把「读库存 + 判断 + 扣减」打包成一次执行。队列的作用是另一层:抢到资格之后写订单、扣数据库库存、发消息这些动作耗时几十毫秒,如果同步做,接口 RT 会很难看,放到队列里异步消费,接口只需要保证入队成功就能返回。

我一般的分层是这样的:接入层做参数校验和用户维度限流,服务层做 Redis 原子扣减和入队,消费层做落库和补偿。三层各管一段,出问题的时候日志能直接定位到是哪一段翻的车。

2.2 模块划分与目录结构

项目按职责拆成几个 Maven 模块,这样锁组件和队列组件可以单独抽出来复用到别的业务里:

seckill-redis/ ├── seckill-api # 对外接口:Controller、参数校验、限流切面 ├── seckill-service # 业务编排:抢单主流程、库存预热、订单查询 ├── seckill-lock # 锁组件:加锁模板、Lua 解锁、续期封装 ├── seckill-queue # 队列层:入队、消费线程池、重试队列 ├── seckill-common # 通用返回体、业务异常、Redis Key 常量 └── seckill-admin # 活动配置、库存预热入口、监控面板
模块主要职责关键类(示例命名)
seckill-api接收请求、限流、统一异常处理SeckillController、RateLimitAspect
seckill-service抢单编排、库存预热、订单落库SeckillService、StockWarmUpJob
seckill-lock加锁/解锁/续期RedisLockClient、LockRenewTask
seckill-queue入队、消费、失败重试SeckillQueueProducer、OrderConsumer

这个拆法有个好处:seckill-lock不依赖任何业务类,只吃StringRedisTemplate,你把它拎到别的项目里改个 Key 前缀就能用。seckill-queue同理,消费逻辑用接口回调注入,业务方实现OrderHandler就行。

2.3 一次请求的完整时序

把链路按顺序拆成八步,后面每一章基本都在展开其中某一步:

  1. 请求打到SeckillController,先按 userId 做一层本地限流,挡掉明显的脚本刷单。
  2. 服务层拼出两个 Redis Key:库存 Keyseckill:stock:{activityId}和用户占位 Keyseckill:user:{activityId}:{userId}。
  3. 用SETNX写用户占位,写成功说明这个用户本次没来过,写失败直接返回「请勿重复提交」。
  4. 执行库存扣减 Lua 脚本,一次调用完成「读库存 → 判断 → 扣减」,返回扣减后的余量。
  5. 扣减失败(库存不足或活动未预热)就把用户占位删掉,避免用户被白白锁两小时。
  6. 扣减成功则把activityId:userId推进 Redis List 队列,接口立刻返回「排队中」。
  7. 消费端从队列右端RPOP,交给业务 handler 落库,靠数据库唯一索引兜底幂等。
  8. 落库失败进重试队列,重试超过阈值写死信表,人工介入。

整条链路里,真正耗时的只有第 7 步,而它被挪到了异步线程里,接口侧只剩下三次 Redis 往返,RT 基本能压到个位数毫秒。

2.4 基础配置与入口代码

Spring Boot 这边主要调 Redis 连接池参数,默认的 8 个连接在秒杀入口会直接排队:

spring: redis: host: 127.0.0.1 port: 6379 timeout: 2000ms lettuce: pool: max-active: 200 # 入口连接数要给够,默认 8 会直接把请求堵在获取连接上 max-idle: 50 min-idle: 20 max-wait: 500ms

max-active是按「接口 QPS × 单次请求 Redis 往返次数」估的,我这边单机 800 QPS、每请求三次往返,给到 200 留了余量。max-wait一定要设,不设的话连接池满了线程会无限等,线程池跟着一起爆。

入口类保持薄,只做校验和转发:

@RestController @RequestMapping("/seckill") public class SeckillController { private final SeckillService seckillService; public SeckillController(SeckillService seckillService) { this.seckillService = seckillService; } /** * @param activityId 活动 id,对应 Redis 库存 key 的后缀 * @param userId 用户 id,参与用户占位去重 * @return 排队中的受理结果,不代表最终抢到 */ @PostMapping("/order") public Result<String> grab(@RequestParam Long activityId, @RequestParam Long userId) { // 参数校验、限流都在切面里做,这里只转发 return Result.ok(seckillService.grab(activityId, userId)); } }

注意返回语义:这里返回「排队中」而不是「抢单成功」,因为真正的落库在异步侧。前端拿到受理结果后轮询订单状态接口,这样接口侧不会被数据库拖慢。

3. 分布式锁核心实现:SETNX 加锁、Lua 解锁与续期三件套

锁这块是最容易写出「看着对、跑起来错」的地方。核心就三件事:加锁要原子、解锁要认主、过期要续上。下面按这三件事拆。

3.1 加锁:一条 SET 命令里的三个参数

public class RedisLockClient { private static final String PREFIX = "seckill:lock:"; private final StringRedisTemplate redisTemplate; public RedisLockClient(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } /** * @param bizKey 业务 key,例如 goods:1001,方法内部会拼前缀 * @param requestId 本次请求唯一标识,解锁时用来校验锁的归属 * @param expireMs 锁过期时间(毫秒),必须大于临界区最长耗时 * @return true 表示加锁成功 */ public boolean tryLock(String bizKey, String requestId, long expireMs) { Boolean ok = redisTemplate.opsForValue() .setIfAbsent(PREFIX + bizKey, requestId, Duration.ofMillis(expireMs)); return Boolean.TRUE.equals(ok); } }

setIfAbsent带过期时间,底层发的是一条SET key value NX PX 3000,原子完成「不存在才写」和「设过期」。很多人习惯写setnx再补一条expire,这两条命令之间如果进程挂了,锁就永久留在 Redis 里,后面所有请求全卡死,这种事故我见过不止一次。

requestId用UUID.randomUUID().toString(),它是解锁时证明「这把锁是我加的」的唯一凭证。expireMs的取值不是拍脑袋,我一般按「临界区 P99 耗时 × 3」估,比如扣减逻辑 P99 是 200ms,就设 600ms 到 1s,既不会因为业务稍慢就提前失效,也不会让异常情况下锁卡太久。

3.2 解锁:Lua 脚本与唯一 value 的配合

直接del会出问题:线程 A 加的锁因为超时自动释放了,线程 B 拿到锁,这时候 A 执行完del把 B 的锁删了。所以解锁必须「先比对 value 再删」,而且比对和删除要在一个原子操作里完成。

-- unlock.lua -- KEYS[1]:锁的完整 key -- ARGV[1]:加锁时写入的 requestId local val = redis.call('get', KEYS[1]) if val == ARGV[1] then return redis.call('del', KEYS[1]) end return 0

Java 侧通过RedisScript调用,脚本内容可以在启动时加载一次缓存起来,别每次请求都拼字符串:

private static final RedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>( "local v = redis.call('get', KEYS[1]) " + "if v == ARGV[1] then return redis.call('del', KEYS[1]) end " + "return 0", Long.class); /** * @return true 表示确实删掉了自己加的锁,false 表示锁已不属于自己(多半是已过期) */ public boolean unlock(String bizKey, String requestId) { Long r = redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(PREFIX + bizKey), requestId); return r != null && r > 0; }

返回值别丢,false是个重要信号:说明临界区跑超时了,锁在业务还没结束时就过期了。生产上我会在这里打一条 warn 日志并上报监控,这条日志一多,就说明过期时间设短了。

3.3 续期与 Redisson 的取舍

自己写的锁有个绕不过去的坎:业务耗时不确定,过期时间设多少都可能不够。Redisson 的看门狗机制就是干这个的——加锁时把leaseTime传成-1,它会在后台每 10 秒(默认过期时间的三分之一)检查一次,线程还持有锁就续到 30 秒。

@Bean public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setConnectionMinimumIdleSize(20) .setConnectionPoolSize(200); // leaseTime 传 -1 才会启用看门狗,传具体数值则到期直接释放不续期 return Redisson.create(config); }

用 Redisson 的时候有个坑要提前说:lock.lock(10, TimeUnit.SECONDS)这种写法是「自定义租期」,看门狗直接失效,10 秒一到锁自动释放,业务没跑完也没人管。想要续期就必须lock.lock()或tryLock(waitTime, -1, unit)。

至于选哪个,我的判断标准是:临界区耗时可控(比如就是一次库存扣减)用自研的tryLock加 Lua 解锁,代码透明好排查;临界区里有外部调用、耗时说不准,用 Redisson 的看门狗更稳。别在同一套业务里混着用两种锁,Key 前缀一对不上,等于没加锁。

3.4 库存扣减:把判断和扣减塞进一次执行

锁解决了并发进入,但库存扣减本身还得原子。如果先get再decr,中间这段时间足够另一个线程插进来,照样超卖。

-- stock.lua -- KEYS[1]:库存 key,例如 seckill:stock:1001 -- ARGV[1]:本次购买数量 -- 返回 >=0:扣减后的剩余库存;-1:活动库存未预热;-2:库存不足 local stock = tonumber(redis.call('get', KEYS[1])) if stock == nil then return -1 end if stock < tonumber(ARGV[1]) then return -2 end redis.call('decrby', KEYS[1], tonumber(ARGV[1])) return stock - tonumber(ARGV[1])

返回值我特意设计成三种语义,调用方能直接区分「活动没开始」和「卖完了」,前端提示也不一样。用-1和-2而不是抛异常,是因为 Lua 里抛错会带上堆栈信息,日志噪音大,返回码更干净。

Java 侧调用:

private static final RedisScript<Long> STOCK_SCRIPT = new DefaultRedisScript<>(STOCK_LUA, Long.class); /** * @param activityId 活动 id * @param count 购买数量,秒杀场景通常固定 1 * @return 剩余库存,负数代表失败原因 */ public long deduct(Long activityId, int count) { Long left = redisTemplate.execute(STOCK_SCRIPT, Collections.singletonList("seckill:stock:" + activityId), String.valueOf(count)); return left == null ? -1L : left; }

有个细节:ARGV传进去的都是字符串,Lua 里一定要tonumber,不然stock < ARGV[1]会变成字符串比较,"9" < "10"结果是 false,库存判断直接反了。这个坑我第一次写的时候踩得结结实实,压测时库存扣成负数才发现。

4. 队列削峰落地:入队校验、消费幂等与库存回滚的配合

Redis 侧扣减成功只代表「有资格」,真正的订单还在数据库里。这一章讲队列这一段怎么把资格变成订单,以及中间失败了怎么收场。

4.1 入队前的三道校验

抢单主流程按「占位 → 扣减 → 入队」串起来,顺序不能乱:

public String grab(Long activityId, Long userId) { String stockKey = "seckill:stock:" + activityId; String userKey = "seckill:user:" + activityId + ":" + userId; // 1. 用户维度占位,SETNX 成功才有资格继续,过期时间覆盖整个活动周期 Boolean first = redisTemplate.opsForValue() .setIfAbsent(userKey, "1", Duration.ofHours(2)); if (!Boolean.TRUE.equals(first)) { throw new BizException("请勿重复提交"); } // 2. 原子扣减库存,-1 未预热 / -2 售罄 long left = stockService.deduct(activityId, 1); if (left < 0) { redisTemplate.delete(userKey); // 扣减失败必须放开占位,否则用户被白锁两小时 throw new BizException(left == -1 ? "活动未开始" : "已售罄"); } // 3. 入队,value 用冒号拼接,消费端按分隔符拆 redisTemplate.opsForList().leftPush("seckill:queue", activityId + ":" + userId); return "排队中"; }

第 2 步失败后删占位这行代码看着不起眼,但漏了就是血泪事故:用户手一抖点了两次,第二次被占位挡住返回「重复提交」,可实际上他第一次根本没抢到,库存也被别人抢光了,用户只能干瞪眼。占位和扣减必须同生共死。

入队用leftPush、消费用rightPop,形成先进先出。别两边都用同一端,那就成栈了,先来的请求反而排在后面,用户体验很怪。

4.2 消费端的幂等设计

队列这东西只能保证「至少消费一次」,重复投递是常态,所以消费逻辑必须幂等。最省事的办法是数据库唯一索引兜底,别指望应用层判断:

CREATE TABLE `seckill_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `activity_id` bigint NOT NULL, `user_id` bigint NOT NULL, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀订单表';

uk_activity_user这个唯一索引是整个幂等方案的基石。消费端重复处理同一条消息时,第二次 insert 直接抛DuplicateKeyException,捕获后当成功处理就行,不用查一次再插一次(先查后插在并发下根本不可靠)。

消费循环长这样:

@Scheduled(fixedDelay = 10) public void consume() { // 一次拉一条,阻塞 1 秒,避免空转;量大时改成批量 + 线程池 String payload = redisTemplate.opsForList() .rightPop("seckill:queue", Duration.ofSeconds(1)); if (payload == null) { return; } String[] p = payload.split(":"); Long activityId = Long.valueOf(p[0]); Long userId = Long.valueOf(p[1]); try { orderService.createOrder(activityId, userId); // 内部靠唯一索引兜底 } catch (DuplicateKeyException e) { log.warn("重复消费,直接忽略 payload={}", payload); } catch (Exception e) { // 非幂等冲突一律进重试队列,别在这里吞掉 redisTemplate.opsForList().leftPush("seckill:queue:retry", payload); log.error("消费失败 payload={}", payload, e); } }

这里用@Scheduled只是单机演示,生产上我会起一个固定大小的线程池,每个线程跑自己的rightPop循环,线程数按「消费 QPS ÷ 单条耗时」估。但线程别开太多,消费端压力最终会传导到数据库,开 50 个线程打 20 个连接的池子,等于自己给自己制造排队。

4.3 库存预热、回滚与补偿

活动开始前必须把库存预热进 Redis,不然 Lua 里get拿到 nil,所有请求都返回「活动未开始」。预热脚本我一般写成管理后台的一个按钮,运营点一下从数据库读库存灌进去:

public void warmUp(Long activityId) { Integer stock = activityMapper.selectStockById(activityId); if (stock == null || stock <= 0) { throw new BizException("活动库存未配置"); } // set 而不是 incr,保证重复点按钮不会把库存叠加 redisTemplate.opsForValue().set("seckill:stock:" + activityId, String.valueOf(stock)); log.info("活动 {} 库存预热完成,数量 {}", activityId, stock); }

用set不用incr,就是为了幂等——运营手抖点两次也不会把 100 件变成 200 件。

消费端落库失败要回滚库存,用incrby加回去。但回滚本身也可能失败(比如 Redis 正好抖动),所以我加了一个对账任务:每 5 分钟扫一遍seckill:queue:retry,超过 3 次重试的写进死信表,同时按「Redis 剩余库存 + 已生成订单数」和「数据库总库存」比对,对不上就告警。这套对账逻辑平时不干活,真出事的时候是唯一的后悔药。

5. 避坑与排查:五个把我按在地上摩擦的线上问题

下面这五条都是我自己或者身边人真实踩过的,按「现象 → 原因 → 解决」写,遇到类似的直接对号入座。

5.1 锁在 finally 里被提前释放,并发照样超卖

现象:明明加了锁,压测还是出现超卖,日志里能看到两个线程几乎同时进扣减逻辑。

原因:解锁代码写在了try块里而不是finally,或者临界区里调用了别的方法,那个方法内部又加了一次同名的锁(不可重入),第二次setIfAbsent失败直接抛出,finally里的unlock把外层锁给删了。

解决:解锁一律放finally,并且requestId必须一路透传,不能中途重新生成。如果临界区里可能重入,换成 Redisson 的可重入锁,别自己拿SETNX硬扛。

5.2 锁过期时间比业务耗时短,锁形同虚设

现象:unlock返回 false 的 warn 日志越来越多,同时超卖偶发。

原因:过期时间设了 500ms,但库存扣减后面还跟了一次 RPC 调用,P99 直接干到 800ms,锁在业务跑完之前就自动释放了,第二个线程进来自如地改了库存。

解决:临界区里不要放 RPC 和 IO,只留纯内存操作。确实躲不开就上 Redisson 看门狗,或者把过期时间按 P99×3 重设。上线前我一般会统计一次临界区耗时分布,这个数不看清楚就设过期时间,纯属赌运气。

5.3 主从切换把锁切没了

现象:集群环境下低概率超卖,重启或主节点故障后尤其明显,单机复现不了。

原因:Redis 主从是异步复制,锁写在主节点还没同步到从节点时主挂了,从节点升主,锁就凭空消失,另一个线程立刻拿到同一把锁。

解决:这是 Redis 分布式锁的固有限制,换 Redlock 也只能降低概率不能消除。我的做法是双保险:Redis 锁负责挡流量,数据库层再加一道update stock set num = num - 1 where id = ? and num >= 1的 CAS 兜底,最后还有唯一索引和库存对账。任何分布式锁都别当成唯一防线。

5.4 序列化不一致,Lua 比对永远不相等

现象:锁加上了就再也删不掉,只能等过期,unlock一直返回 false。

原因:加锁用的是StringRedisTemplate(value 是裸字符串),解锁脚本里ARGV[1]用的却是RedisTemplate默认的 JDK 序列化,实际传进去的是带类型头的字节流,跟get出来的字符串永远对不上。

解决:一个项目里锁相关的读写必须用同一套序列化,我统一用StringRedisTemplate,Key 和 Value 都是纯字符串。混用RedisTemplate和StringRedisTemplate是这类玄学问题的头号来源,看代码的时候先查这个。

5.5 BRPOP 之后进程挂掉,消息凭空蒸发

现象:Redis 库存扣了,用户也收到「排队中」,但数据库里就是没有订单,也没有任何错误日志。

原因:消费端用rightPop把消息取出来之后,落库之前进程被重启或 OOM 干掉,消息已经从队列里删掉了,没有第二次机会。

解决:换成RPOPLPUSH(或LMOVE)把消息原子地从主队列挪到 processing 队列,落库成功再LREM删掉;服务启动时先扫 processing 队列里超过 5 分钟没处理的消息,重新投回主队列。这个改造只多几行代码,但把「至少一次」真正落实了。

5.6 排查顺序:先看 Redis 再看应用

真出事的时候别乱翻日志,我固定按这个顺序走:先redis-cli看库存 Key 还在不在、值是多少、TTL 是不是正常;再看seckill:queue和seckill:queue:retry的长度,长度暴涨说明消费端卡住了;然后看应用侧连接池活跃数和max-wait超时日志,连接池满了会伪装成各种业务超时;最后才去翻业务日志。这个顺序能把定位时间从半小时压到三五分钟。

6. 二次开发:把写死的秒杀逻辑改成可配置组件

这套代码直接拿去用,最难受的是所有 Key 前缀、过期时间、线程数都写死在代码里,换个业务就得改一遍源码。二次开发我建议先做两件事:把参数外置、把业务回调抽成接口。

6.1 参数外置与回调抽象

新建一个SeckillProperties,用@ConfigurationProperties绑定配置:

@Data @ConfigurationProperties(prefix = "seckill") public class SeckillProperties { /** 锁 key 前缀,多业务共用一套 Redis 时靠它隔离 */ private String lockPrefix = "seckill:lock:"; /** 锁过期时间,毫秒 */ private long lockExpireMs = 1000L; /** 用户占位过期时间,秒,要覆盖整个活动周期 */ private long userHoldSeconds = 7200L; /** 消费线程数 */ private int consumerThreads = 8; }

然后定义一个OrderHandler接口,消费端只负责拆消息,具体落库交给实现类:

public interface OrderHandler { /** 返回 true 表示处理成功,false 或抛异常会进重试队列 */ boolean handle(Long activityId, Long userId); }

这么改完,抢优惠券、抢名额、抢预约号这些场景都能复用同一套骨架,只要写一个新的OrderHandler实现,配置里换个前缀就行。并发限流那块也可以顺手抽成注解,用 AOP 按userId或activityId做维度限流。

6.2 改造后的验证方法

改完别急着上生产,本地起两个实例压一轮,重点验证三件事:

验证项方法通过标准
不超卖库存设 100,2000 并发打订单数 = 100,无负库存
不重复同一 userId 并发 50 次只成功 1 次,其余返回重复提交
消息不丢消费中途 kill 进程再重启最终订单数与扣减数一致

压测我用一段 bash 循环加 curl 就够了,不用上重型工具,重点是并发要真并发:

# 2000 个请求分 50 批打,每批 40 并发,模拟瞬时冲击 for round in $(seq 1 50); do for i in $(seq 1 40); do curl -s -X POST "http://127.0.0.1:8080/seckill/order?activityId=1001&userId=$((round * 40 + i))" & done wait done

跑完对着 Redis 里的库存、数据库里的订单数、processing 队列长度三个数对一遍,三个数对得上才算过关。

顺带说个改造时的取舍:不要为了通用把锁组件抽得过度抽象,我见过有人把加锁做成注解加 SpEL 表达式,结果排查问题时连 Key 长什么样都要算半天。参数外置到配置类就够了,再往上抽象收益很低。从那以后我每次上线秒杀活动之前,都会先跑一遍上面那张表的三项验证,哪怕只是改了个过期时间也一样——这个习惯帮我拦下过两次本来要发到线上的配置错误。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进