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

发布时间:2026/10/25 14:51:04
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、RateLimitAspectseckill-service抢单编排、库存预热、订单落库SeckillService、StockWarmUpJobseckill-lock加锁/解锁/续期RedisLockClient、LockRenewTaskseckill-queue入队、消费、失败重试SeckillQueueProducer、OrderConsumer这个拆法有个好处seckill-lock不依赖任何业务类只吃StringRedisTemplate你把它拎到别的项目里改个 Key 前缀就能用。seckill-queue同理消费逻辑用接口回调注入业务方实现OrderHandler就行。2.3 一次请求的完整时序把链路按顺序拆成八步后面每一章基本都在展开其中某一步请求打到SeckillController先按 userId 做一层本地限流挡掉明显的脚本刷单。服务层拼出两个 Redis Key库存 Keyseckill:stock:{activityId}和用户占位 Keyseckill:user:{activityId}:{userId}。用SETNX写用户占位写成功说明这个用户本次没来过写失败直接返回「请勿重复提交」。执行库存扣减 Lua 脚本一次调用完成「读库存 → 判断 → 扣减」返回扣减后的余量。扣减失败库存不足或活动未预热就把用户占位删掉避免用户被白白锁两小时。扣减成功则把activityId:userId推进 Redis List 队列接口立刻返回「排队中」。消费端从队列右端RPOP交给业务 handler 落库靠数据库唯一索引兜底幂等。落库失败进重试队列重试超过阈值写死信表人工介入。整条链路里真正耗时的只有第 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: 500msmax-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 ResultString 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 0Java 侧通过RedisScript调用脚本内容可以在启动时加载一次缓存起来别每次请求都拼字符串private static final RedisScriptLong 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 RedisScriptLong 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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。原因加锁用的是StringRedisTemplatevalue 是裸字符串解锁脚本里ARGV[1]用的却是RedisTemplate默认的 JDK 序列化实际传进去的是带类型头的字节流跟get出来的字符串永远对不上。解决一个项目里锁相关的读写必须用同一套序列化我统一用StringRedisTemplateKey 和 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 改造后的验证方法改完别急着上生产本地起两个实例压一轮重点验证三件事验证项方法通过标准不超卖库存设 1002000 并发打订单数 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?activityId1001userId$((round * 40 i)) done wait done跑完对着 Redis 里的库存、数据库里的订单数、processing 队列长度三个数对一遍三个数对得上才算过关。顺带说个改造时的取舍不要为了通用把锁组件抽得过度抽象我见过有人把加锁做成注解加 SpEL 表达式结果排查问题时连 Key 长什么样都要算半天。参数外置到配置类就够了再往上抽象收益很低。从那以后我每次上线秒杀活动之前都会先跑一遍上面那张表的三项验证哪怕只是改了个过期时间也一样——这个习惯帮我拦下过两次本来要发到线上的配置错误。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询