ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis分布式锁实战:秒杀抢单场景下的锁实现、队列削峰与避坑指南

Redis分布式锁实战:秒杀抢单场景下的锁实现、队列削峰与避坑指南 简介这份资源面向Java后端开发者与高并发场景学习者聚焦电商秒杀抢单中的库存超卖与并发控制难题提供一套基于Redis分布式锁的完整实现方案。压缩包共13个文件、约59KB以5个Java源码为核心配合properties配置、pom.xml依赖管理、mvnw构建脚本及README说明结构精简、开箱即用。内容围绕Redis加锁与原子操作展开涵盖库存扣减、请求队列削峰、SpringBoot集成Redis、抢单结果状态返回等关键环节并涉及锁自动释放、请求幂等与事务一致性等易踩坑点。已有4053人学习下载适合希望理解分布式锁落地细节、对照代码梳理秒杀链路的中级开发者可据此快速搭建可运行的抢单秒杀示例掌握高并发下的库存安全与公平性设计思路。1. 秒杀抢单场景下Redis 分布式锁到底锁住了什么618 大促那晚我盯着监控面板上跳动的库存数字心里其实没底。一个爆款商品库存 500 件活动开始 3 秒内涌进来 12 万次请求。如果不用分布式锁超卖几乎是必然的——多个服务实例同时读到库存还剩 1然后各自扣减最后数据库里库存变成负数。这不是理论推演是我在真实项目里踩过的坑。Redis 分布式锁要解决的核心问题很具体在分布式部署环境下让同一时刻只有一个线程能操作共享资源。秒杀抢单场景里这个共享资源就是库存。锁的本质是一个「占位标记」谁先抢到这个标记谁就有资格执行扣减逻辑执行完再把标记释放掉。听起来简单但落到代码里锁的粒度、超时时间、释放时机、异常处理每一个细节都可能让锁失效。这份资源适合正在做秒杀系统、抢单功能或者任何需要分布式互斥的 SpringBoot 项目。如果你还在用synchronized或者ReentrantLock扛并发单机确实没问题但一旦服务多实例部署本地锁就彻底失效了。Redis 分布式锁是跨实例的解决方案也是面试里绕不开的实战题。接下来我会把锁的实现、队列削峰、以及那些让我半夜爬起来改代码的坑一条条拆开讲。2. Redis 分布式锁的三种实现方式与选型对比2.1 从 SETNX 到 Redisson为什么我最终选了 Redisson最早实现分布式锁我用的是SETNX命令。逻辑很直接SETNX lock_key value返回 1 表示抢到锁返回 0 表示没抢到。但这里有个致命问题——如果抢到锁的服务突然宕机锁永远不会释放后面的请求全部阻塞。于是加过期时间用EXPIRE设置。但SETNX和EXPIRE是两条命令不是原子的如果设置完SETNX还没来得及EXPIRE就宕机死锁照样发生。后来 Redis 2.6.12 之后SET命令支持NX和EX参数一条命令搞定原子性SET lock_key unique_value NX EX 10这条命令的意思是如果lock_key不存在就设置它值为unique_value过期时间 10 秒。原子性有了但还有问题——释放锁的时候如果直接DEL lock_key可能误删别人的锁。比如 A 服务执行超时了锁自动过期B 服务抢到了锁这时候 A 执行完去删锁删的是 B 的锁。所以释放锁必须校验unique_value用 Lua 脚本保证「判断删除」的原子性if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这套逻辑手写没问题但实际项目里要考虑锁续期看门狗机制、可重入、公平锁、读写锁手写成本太高。Redisson 把这些都封装好了RLock接口用起来跟ReentrantLock几乎一样底层自动续期默认锁 30 秒每 10 秒续一次。我现在的项目里除非有特殊需求否则直接用 Redisson省下来的时间够我多排查两个线上问题。2.2 SpringBoot 集成 Redisson 的完整配置与代码先加依赖pom.xml里引入 Redisson 的 SpringBoot Starterdependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.5/version /dependency版本号根据你 SpringBoot 的版本选3.23.x 兼容 SpringBoot 2.7 和 3.x。然后写配置类单机模式、哨兵模式、集群模式的配置不一样秒杀场景如果 Redis 是单点直接配单机就行Configuration public class RedissonConfig { Value(${spring.redis.host}) private String host; Value(${spring.redis.port}) private int port; Value(${spring.redis.password}) private String password; Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); // 单机模式配置生产环境建议用集群或哨兵 config.useSingleServer() .setAddress(redis:// host : port) .setPassword(password) .setConnectionMinimumIdleSize(10) .setConnectionPoolSize(64) .setTimeout(3000) .setRetryAttempts(3); return Redisson.create(config); } }setConnectionMinimumIdleSize和setConnectionPoolSize这两个参数在秒杀场景下很关键。默认连接池太小高并发时大量线程阻塞在获取连接上日志里会看到Redis command timed out的报错。我一般把最小空闲连接设成 10最大连接池设成 64具体数值根据你的 QPS 压测结果调。setTimeout是命令执行超时时间3000 毫秒是保守值如果网络抖动频繁可以适当调大但别超过锁的过期时间。抢单接口的加锁逻辑RestController public class SeckillController { Autowired private RedissonClient redissonClient; Autowired private StockService stockService; PostMapping(/seckill) public Result seckill(RequestParam Long userId, RequestParam Long productId) { String lockKey seckill:lock: productId; RLock lock redissonClient.getLock(lockKey); try { // 尝试加锁最多等待 3 秒锁自动释放时间 10 秒 boolean acquired lock.tryLock(3, 10, TimeUnit.SECONDS); if (!acquired) { return Result.fail(当前抢购人数过多请稍后重试); } // 执行库存扣减 return stockService.deductStock(userId, productId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail(系统繁忙); } finally { // 只有当前线程持有锁才释放避免误删 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }tryLock的三个参数分别是等待时间、锁持有时间、时间单位。等待时间设 3 秒意味着如果 3 秒内没抢到锁直接返回失败不让请求堆积。锁持有时间设 10 秒是预估的业务执行时间上限。isHeldByCurrentThread()这个判断必须有否则在锁超时被其他线程抢走后当前线程执行unlock会抛IllegalMonitorStateException。2.3 锁粒度与库存扣减的原子性设计锁的粒度决定了系统的并发上限。我见过有人把锁加在整个商品维度上lockKey是seckill:lock:product所有商品共用一个锁QPS 直接掉到个位数。正确的做法是按商品 ID 加锁seckill:lock:1001和seckill:lock:1002互不影响。但即使锁粒度对了库存扣减本身也要保证原子性。常见做法是用 Redis 的DECR命令它本身是原子的public Result deductStock(Long userId, Long productId) { String stockKey seckill:stock: productId; // 先判断库存是否充足再扣减这两步在锁内执行 String stockStr redisTemplate.opsForValue().get(stockKey); if (stockStr null || Integer.parseInt(stockStr) 0) { return Result.fail(库存不足); } Long remaining redisTemplate.opsForValue().decrement(stockKey); if (remaining 0) { // 扣减后小于 0说明超卖了回滚 redisTemplate.opsForValue().increment(stockKey); return Result.fail(库存不足); } // 异步落库写入订单 orderService.createOrder(userId, productId); return Result.success(抢购成功); }这里有个细节decrement之后判断remaining 0如果小于 0 要回滚。但回滚操作本身也可能失败所以更稳妥的方式是用 Lua 脚本把「判断扣减」合成一个原子操作。不过有了分布式锁兜底这种极端情况概率很低我一般先用简单逻辑跑通压测时再根据实际表现决定要不要上 Lua。3. 队列削峰把 12 万请求挡在 Redis 之外3.1 为什么光有锁还不够锁解决了并发扣减的原子性问题但没解决流量冲击问题。12 万请求同时打到 Redis即使每个请求只是tryLock失败返回Redis 的网络 IO 和 CPU 也会被打满。我实测过单节点 Redis 在 8 万 QPS 左右开始出现明显的延迟抖动Redis command timed out的报错开始刷屏。队列削峰的核心思路是让请求先进入一个缓冲队列后端服务按照自己的处理能力从队列里消费。这样前端流量再大后端始终按固定速率处理Redis 的压力可控。常见的实现方式有两种用 Redis 的 List 结构做简易队列或者用 RabbitMQ、Kafka 这类专业消息队列。秒杀场景我倾向于用 Redis List因为少引入一个中间件运维成本低而且 Redis 本身就在链路里。3.2 用 Redis List 实现请求排队与异步处理流程分三步用户请求先入队然后立即返回「排队中」后端消费者从队列取出请求加锁执行扣减扣减结果写入另一个结果队列前端轮询查询。入队代码PostMapping(/seckill/queue) public Result seckillQueue(RequestParam Long userId, RequestParam Long productId) { String queueKey seckill:queue: productId; String requestId UUID.randomUUID().toString(); // 请求信息封装成 JSON 入队 SeckillRequest request new SeckillRequest(requestId, userId, productId); redisTemplate.opsForList().leftPush(queueKey, JSON.toJSONString(request)); // 立即返回让前端轮询结果 return Result.success(requestId); }消费者用定时任务或者独立线程从队列右端取出请求Component public class SeckillConsumer { Autowired private RedissonClient redissonClient; Autowired private StringRedisTemplate redisTemplate; Scheduled(fixedDelay 100) public void consume() { String queueKey seckill:queue:1001; // 每次取一条避免单次处理过多导致锁持有时间过长 String requestJson redisTemplate.opsForList().rightPop(queueKey); if (requestJson null) { return; } SeckillRequest request JSON.parseObject(requestJson, SeckillRequest.class); String lockKey seckill:lock: request.getProductId(); RLock lock redissonClient.getLock(lockKey); try { if (lock.tryLock(1, 5, TimeUnit.SECONDS)) { // 执行扣减逻辑 boolean success doDeduct(request); // 结果写入结果队列 String resultKey seckill:result: request.getRequestId(); redisTemplate.opsForValue().set(resultKey, success ? success : fail, 60, TimeUnit.SECONDS); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }fixedDelay 100表示上一次执行完毕后间隔 100 毫秒再执行下一次。这个值决定了消费速率100 毫秒一条理论上每秒处理 10 条。实际项目中要根据库存数量和活动时长调整比如 500 件库存、活动持续 10 秒那消费速率至少要 50 条/秒fixedDelay设成 20 毫秒左右。但注意别设太小否则消费者空转浪费 CPU。前端轮询结果GetMapping(/seckill/result) public Result queryResult(RequestParam String requestId) { String resultKey seckill:result: requestId; String result redisTemplate.opsForValue().get(resultKey); if (result null) { return Result.success(排队中); } return Result.success(result); }结果 key 设置了 60 秒过期防止内存里堆积大量无用的结果数据。前端轮询间隔建议 500 毫秒到 1 秒太频繁会给 Redis 增加不必要的读压力。3.3 队列长度控制与内存保护队列不能无限增长。如果入队速度远大于消费速度Redis 内存会被撑爆。我一般会在入队前检查队列长度Long queueSize redisTemplate.opsForList().size(queueKey); if (queueSize ! null queueSize 10000) { return Result.fail(当前排队人数过多请稍后重试); }10000 这个阈值根据你的 Redis 内存和业务容忍度定。每条请求 JSON 大概 200 字节10000 条就是 2MB 左右完全可控。另外给队列 key 设置过期时间也很重要活动结束后队列自动清理避免垃圾数据长期占用内存。4. 避坑指南那些让我半夜爬起来改代码的瞬间4.1 锁超时释放导致业务还没执行完现象日志里出现「库存扣减成功」但数据库里没有对应订单或者两个请求同时扣减成功导致超卖。原因锁的过期时间设得太短业务还没执行完锁就自动释放了另一个线程趁虚而入。比如锁设了 3 秒但数据库写入花了 5 秒。解决Redisson 的看门狗机制默认锁 30 秒每 10 秒续期一次只要业务线程还活着锁就不会过期。但前提是你用tryLock不传过期时间或者传了过期时间但业务能在过期前完成。我现在的习惯是锁过期时间设成业务预估最大耗时的 3 倍同时开启 Redisson 的看门狗。4.2 Redis 连接池耗尽引发雪崩现象活动开始后不久大量请求报Redis command timed out监控显示 Redis 连接数飙满。原因连接池配置太小高并发时线程都在等连接。默认的connectionPoolSize是 64但秒杀场景瞬时并发可能上千。解决调大连接池同时设置合理的超时时间。setConnectionPoolSize(200)、setConnectionMinimumIdleSize(50)、setTimeout(2000)。但别无限调大连接数太多会拖垮 Redis 服务端。压测找到瓶颈点按实际 QPS 的 1.5 倍配置。4.3 锁误删A 线程删了 B 线程的锁现象日志里出现IllegalMonitorStateException或者明明加了锁却出现并发问题。原因释放锁时没有校验持有者。A 线程业务超时锁自动过期B 线程抢到锁A 执行完unlock把 B 的锁删了。解决用 Redisson 的isHeldByCurrentThread()判断或者手写 Lua 脚本校验unique_value。我现在的代码模板里finally块第一行就是if (lock.isHeldByCurrentThread())这个习惯救了我好几次。4.4 库存扣减与订单落库不一致现象Redis 里库存扣了但订单表里没有记录或者反过来。原因扣减和落库是两个操作中间可能失败。比如扣减成功但数据库连接超时订单没写进去。解决用本地消息表或者事务消息保证最终一致性。简单做法是扣减成功后把订单信息写入 Redis 的另一个队列由独立消费者异步落库落库失败就重试。重试三次还失败就告警人工介入。别想着强一致秒杀场景最终一致就够了。4.5 热点 key 导致 Redis 单节点过载现象某个爆款商品的库存 key 访问量特别大Redis 集群里这个节点的 CPU 明显高于其他节点。原因所有请求都打同一个 keyRedis 集群按 key 哈希分片热点 key 全落一个节点。解决库存分片。把 500 件库存拆成 5 份每份 100 件key 分别是stock:1001:0到stock:1001:4。请求进来先随机选一个分片扣减失败再试其他分片。这样热点分散到 5 个 key 上单节点压力降到五分之一。5. 压测验证与锁性能调优的实战技巧压测是检验锁和队列设计的唯一标准。我一般用 JMeter 或者 wrk 模拟 1000 并发持续 30 秒观察几个核心指标Redis 的 QPS、锁等待时间、库存扣减成功率、订单落库延迟。先看 Redis 的监控数据。redis-cli --stat可以实时看到 QPS 和连接数。如果 QPS 超过 8 万且延迟开始抖动说明 Redis 单节点到瓶颈了考虑上集群或者增加分片。锁等待时间看 Redisson 的tryLock返回耗时如果大量请求等待超过 1 秒说明锁竞争太激烈要么减小锁粒度要么加队列削峰。库存扣减成功率要跟预期对比。500 件库存1000 并发理论上成功 500 次失败 500 次。如果成功次数明显少于 500说明锁超时或者队列消费太慢。如果成功次数超过 500那就是超卖了赶紧查锁的释放逻辑。订单落库延迟看数据库的写入耗时。如果落库延迟超过 1 秒考虑批量写入或者异步落库。我一般会在消费者里攒 50 条订单批量插入比单条插入快 5 倍以上。调优参数记录表参数初始值调优后说明connectionPoolSize64200按 QPS 1.5 倍配置lockWaitTime3s1s减少请求堆积lockLeaseTime10s30s配合看门狗consumerDelay100ms20ms按库存和时长计算queueMaxSize100005000保护 Redis 内存压测时还有个玄学问题JVM 的 GC 会导致锁续期延迟。如果看门狗续期线程被 GC 暂停超过锁过期时间锁还是会失效。解决办法是给 Redisson 的续期线程设置高优先级或者用-XX:UseG1GC减少 GC 停顿。这个坑我在生产环境遇到过日志里没有任何异常就是偶尔超卖查了两天才定位到 GC。从那以后我每次上线秒杀功能前都强制走一遍「压测 GC 日志分析 锁续期监控」的流程再也不敢跳过。希望这些经验能帮到你少走点弯路。本文还有配套的精品资源点击获取
返回列表