尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Redis分布式锁+延迟队列实现秒杀抢单稳态架构

Redis分布式锁+延迟队列实现秒杀抢单稳态架构 简介本资源是一套基于SpringBoot的Redis分布式锁实现秒杀抢单系统的完整工程实践面向Java后端开发者及高并发场景学习者聚焦电商级抢单公平性、库存防超卖与分布式一致性等核心难题。压缩包共13个文件含5个核心Java类涵盖RedisLock工具、秒杀控制器、库存扣减服务等、2个配置properties文件连接池与Redis参数、1个可执行jar包便于快速验证辅以pom.xml依赖管理、README.md说明文档及mvnw构建脚本结构清晰、开箱即用整体仅59KB轻量易学。已有4054人学习下载读者可直接复用分布式锁加锁逻辑、库存原子扣减方案、请求幂等处理思路并通过本地启动快速验证高并发下锁竞争与库存归零响应机制是理解Redis在真实秒杀场景中落地的关键参考样本。1. 抢单接口扛不住并发别急着加机器——Redis 分布式锁 队列才是秒杀抢单的稳态解法某电商模拟项目X上线前压测3000 QPS 下订单服务直接雪崩库存超卖、重复下单、数据库连接池打满。团队第一反应是扩容数据库、加 Redis 缓存层、上消息队列……结果发现问题根子不在吞吐瓶颈而在「同一商品被多个请求同时判定为‘有库存’并扣减」——这是典型的分布式环境下竞态条件race condition。真正卡脖子的是那个看似简单的「判断库存 0 → 扣减 → 创建订单」三步原子操作。用本地锁跨 JVM 失效用数据库行锁高并发下锁冲突严重、响应毛刺明显用 ZooKeeper引入新组件、运维成本陡增。而 Redis 分布式锁 延迟队列组合恰恰在轻量、可靠、可落地三者间找到了平衡点它不追求强一致性下的绝对零超卖那需要 TCC 或 Saga而是用可接受的极低超卖率实测 0.002%换来了毫秒级响应和线性水平扩展能力。本文面向已掌握 Spring Boot 基础、正面临真实秒杀场景压力的后端开发者不讲 CAP 理论推导只拆解一个能跑通、能压测、能上线的最小可行实现——从 Lua 脚本锁的原子性保障到 Redisson 的自动续期陷阱再到 RabbitMQ 延迟队列如何规避死信堆积每一步都附带我在线上翻车后重写的验证逻辑。2. 锁不是随便 set 就完事Redis 分布式锁的原子性、可重入与自动续期三重校验2.1 为什么 SETNX EXPIRE 是危险组合Lua 脚本才是唯一答案很多初学者会写这样的伪代码// 危险非原子操作 Boolean isLocked redisTemplate.opsForValue().setIfAbsent(lock:goods:1001, client-123); if (isLocked) { redisTemplate.expire(lock:goods:1001, 10, TimeUnit.SECONDS); // 执行业务... }这段代码在高并发下必然出问题setIfAbsent成功后若 JVM 在expire前发生 GC 暂停或网络抖动锁就变成永不过期的“幽灵锁”整个系统卡死。真正的原子性必须由 Redis 服务端保证。正确做法是使用 Lua 脚本一次性完成「设置 key 设置过期时间 校验 value 是否匹配」-- acquire_lock.lua local key KEYS[1] local value ARGV[1] local expireTime tonumber(ARGV[2]) -- 如果 key 不存在设置并返回 1如果存在返回 0 if redis.call(SET, key, value, NX, EX, expireTime) OK then return 1 else return 0 end提示NXNot eXists和EXEXpire seconds必须在同一命令中使用这是 Redis 2.6.12 版本才支持的原子语义。低于此版本必须用EVAL脚本封装。调用该脚本的 Java 代码需严格校验返回值// Spring Data Redis 调用 Lua 脚本 DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(luaScriptContent); // 加载上面的 Lua 字符串 script.setResultType(Long.class); ListString keys Arrays.asList(lock:goods:1001); Object[] args new Object[]{client-123, 10}; // value expire seconds Long result redisTemplate.execute(script, keys, args); if (result ! null result 1L) { // 获取锁成功执行业务逻辑 } else { // 获取锁失败走降级或排队逻辑 }参数说明keys是 Redis 中的 key 列表此处仅一个args是传入 Lua 脚本的参数数组。注意args中的过期时间单位是秒且必须是整数Luatonumber()会截断小数。2.2 可重入锁怎么实现value 不是随机字符串而是「客户端ID:重入次数」单纯用 UUID 作为锁 value会导致同一线程多次调用时无法识别自己持有的锁从而破坏可重入性。真实生产环境要求同一个线程/请求在未释放锁前可多次进入临界区。解决方案是将 value 设计为结构化字符串client-123:1 // 第一次获取 client-123:2 // 同一 client 第二次重入对应 Lua 释放脚本需支持「先查当前 value → 若匹配则递减重入计数 → 计数为0才真删除」-- release_lock.lua local key KEYS[1] local value ARGV[1] local currentValue redis.call(GET, key) if not currentValue then return 0 -- 锁已不存在 end local clientId, reentryCount string.match(currentValue, (.-):(.)) if clientId ~ string.sub(value, 1, #clientId) then return -1 -- 非本客户端持有拒绝释放 end local count tonumber(reentryCount) if count 1 then -- 重入计数减一更新 value redis.call(SET, key, clientId .. : .. (count - 1)) return 1 else -- 计数归零彻底删除 redis.call(DEL, key) return 1 endJava 层需维护线程局部变量记录重入次数private static final ThreadLocalMapString, Integer REENTRANT_COUNTS ThreadLocal.withInitial(HashMap::new); public boolean tryLock(String lockKey, int expireSeconds) { String clientId getClientId(); String lockValue clientId : (REENTRANT_COUNTS.get().getOrDefault(lockKey, 0) 1); Long result redisTemplate.execute(acquireScript, Arrays.asList(lockKey), lockValue, String.valueOf(expireSeconds)); if (result 1L) { REENTRANT_COUNTS.get().merge(lockKey, 1, Integer::sum); return true; } return false; }关键细节REENTRANT_COUNTS必须是ThreadLocal确保每个请求线程独立计数getClientId()应基于请求唯一标识如 traceId 或 UUID而非简单用Thread.currentThread().getId()否则异步线程池中会丢失上下文。2.3 自动续期Watchdog不是银弹Redisson 的 leaseTime 陷阱与手动心跳方案很多团队直接引入 Redisson依赖其RLock.lock(10, TimeUnit.SECONDS)的自动续期能力。但线上踩坑发现当业务逻辑执行时间波动大如 DB 查询偶发慢 SQL、或网络分区导致客户端心跳包丢失时Watchdog 可能误判锁失效并提前释放。更致命的是Redisson 默认leaseTime30s而业务处理若超过 30s锁就被其他节点抢走造成数据错乱。血泪经验生产环境必须显式控制续期节奏。我们弃用 Redisson 的自动模式改用手动心跳// 启动守护线程每 3 秒检查并续期一次续期时间设为 10s留足缓冲 ScheduledExecutorService watchdog Executors.newSingleThreadScheduledExecutor(); String lockKey lock:goods:1001; String lockValue getClientId() :1; watchdog.scheduleAtFixedRate(() - { try { // 仅当本客户端仍持有锁时才续期 String current (String) redisTemplate.opsForValue().get(lockKey); if (current ! null current.startsWith(getClientId())) { redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS); } } catch (Exception e) { log.warn(Failed to renew lock {}, lockKey, e); } }, 0, 3, TimeUnit.SECONDS); // 业务执行完毕后关闭守护线程并释放锁 try { // 执行扣库存、生成订单等耗时操作 doBusinessLogic(); } finally { watchdog.shutdown(); releaseLock(lockKey, lockValue); // 调用 2.2 的 release_lock.lua }参数设计依据续期间隔3s必须远小于锁过期时间10s确保至少有 3 次心跳机会shutdown()必须在finally块中执行防止业务异常导致心跳泄漏。3. 锁只是第一道闸门为什么必须叠加延迟队列秒杀流量削峰的底层逻辑3.1 单靠锁为什么还是挡不住超卖——锁粒度与数据库事务的错位即使分布式锁完全正确仍可能超卖。原因在于锁保护的是「检查库存 扣减」这一段逻辑但扣减操作本身如UPDATE inventory SET stock stock - 1 WHERE id 1001 AND stock 0在数据库层面仍是独立事务。当大量请求几乎同时通过 Redis 锁因网络时延微小差异它们会瞬间涌入数据库而 MySQL 的行锁在高并发下会出现锁等待队列部分请求在等待时库存已被前置请求扣光但因未真正执行WHERE stock 0判断最终仍可能成功更新——这就是经典的「ABA 问题」在数据库层的变体。根本解法不是更细粒度的锁而是让请求排队。把「瞬时洪峰」转化成「匀速流水线」让数据库永远只面对可控的并发量。这就引出了第二道防线延迟队列。3.2 RabbitMQ 延迟队列实战不依赖插件用死信 TTL 实现精准 500ms 延迟RabbitMQ 官方不原生支持延迟消息但可通过「死信交换器DLX 消息 TTL」组合实现。关键在于延迟时间必须精确到毫秒级且不能因队列积压导致延迟漂移。我们定义两个队列seckill.order.queue主队列接收所有抢单请求TTL 设为 500msseckill.order.dlx.queue死信队列绑定到 DLX接收过期消息。配置代码Spring Boot AutoConfigConfiguration public class RabbitMQConfig { public static final String SECKILL_ORDER_QUEUE seckill.order.queue; public static final String SECKILL_ORDER_DLX_QUEUE seckill.order.dlx.queue; public static final String SECKILL_DLX_EXCHANGE seckill.dlx.exchange; Bean public Queue seckillOrderQueue() { MapString, Object args new HashMap(); args.put(x-dead-letter-exchange, SECKILL_DLX_EXCHANGE); // 死信转发到 DLX args.put(x-dead-letter-routing-key, seckill.order.dlx); // 转发时的 routing key args.put(x-message-ttl, 500); // 关键TTL 500ms非永久 return QueueBuilder.durable(SECKILL_ORDER_QUEUE).withArguments(args).build(); } Bean public Queue seckillOrderDlxQueue() { return QueueBuilder.durable(SECKILL_ORDER_DLX_QUEUE).build(); } Bean public DirectExchange dlxExchange() { return new DirectExchange(SECKILL_DLX_EXCHANGE); } Bean public Binding dlxBinding() { return BindingBuilder.bind(seckillOrderDlxQueue()) .to(dlxExchange()).with(seckill.order.dlx); } }发送消息时不设置消息级别 TTL只依赖队列级别 TTL避免单条消息覆盖队列配置// Controller 层收到抢单请求后立即发往延迟队列 rabbitTemplate.convertAndSend(SECKILL_ORDER_QUEUE, JSON.toJSONString(new SeckillRequest(userId, goodsId)));注意x-message-ttl设在队列上意味着所有进入该队列的消息统一 500ms 过期若设在消息上MessageProperties.setExpiration()则每条消息需单独计算增加复杂度且易出错。3.3 消费端如何防重复消费幂等性校验必须落在数据库唯一索引上延迟队列消费端RabbitListener拿到消息后仍需做两件事快速幂等判断用 Redis 缓存已处理的userId:goodsId组合过期时间设为 24 小时最终一致性落库插入订单表时依赖数据库唯一索引强制拦截重复。订单表建表语句关键片段CREATE TABLE order_info ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, goods_id bigint NOT NULL, status tinyint DEFAULT 0, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_goods (user_id,goods_id) -- 核心防同一用户重复下单同一商品 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;消费逻辑RabbitListener(queues SECKILL_ORDER_DLX_QUEUE) public void handleSeckillOrder(String message) { SeckillRequest req JSON.parseObject(message, SeckillRequest.class); // 1. Redis 幂等缓存快速失败 String cacheKey seckill:done: req.getUserId() : req.getGoodsId(); Boolean exists redisTemplate.hasKey(cacheKey); if (Boolean.TRUE.equals(exists)) { log.info(Duplicate order request ignored: {}, cacheKey); return; } // 2. 尝试插入订单唯一索引兜底 try { orderMapper.insertSelective(new OrderInfo(req.getUserId(), req.getGoodsId())); redisTemplate.opsForValue().set(cacheKey, 1, 24, TimeUnit.HOURS); // 发送成功通知... } catch (DuplicateKeyException e) { // 唯一索引冲突说明已下单忽略 log.warn(Order already exists for user {} goods {}, req.getUserId(), req.getGoodsId()); } }为什么不用 Redis 计数器替代数据库唯一索引因为 Redis 是缓存可能丢失而数据库唯一索引是最终一致性保障是不可绕过的底线。4. 避坑线上真实踩过的 5 个分布式锁 队列组合陷阱4.1 现象锁释放失败大量请求阻塞在tryLock()CPU 持续 90%原因release_lock.lua中string.match使用了贪婪匹配(.-):(.)当value为client-123:1:extra因日志打印误拼接时匹配出错导致脚本异常退出锁未被清理。解决Lua 脚本增加防御性判断对currentValue做格式校验Java 层releaseLock()方法增加重试机制最多 3 次指数退避。4.2 现象延迟队列消息批量堆积延迟从 500ms 漂移到 5s原因消费者线程池核心线程数固定为 1而 RabbitMQ 预取prefetch值默认为 250导致单个消费者一次拉取过多消息处理不过来后续消息被迫排队等待。解决将spring.rabbitmq.listener.simple.prefetch从默认 250 改为 1并动态调整消费者线程池大小根据队列长度自适应扩容spring: rabbitmq: listener: simple: prefetch: 1 concurrency: 5 max-concurrency: 504.3 现象Redis 主从切换后部分锁被「假释放」出现超卖原因Redis 主从异步复制客户端 A 在主节点 set 锁成功主节点还没同步到从节点就宕机从节点升主此时客户端 B 在新主节点上成功 set 同名锁形成双锁。解决放弃主从架构改用 Redis Cluster 模式Redlock 已被作者废弃Cluster 的WAIT命令可强制多数节点确认或在业务层增加「二次校验」扣减库存后再查一次 DB 库存是否仍 ≥0。4.4 现象RabbitListener消费失败后消息无限重回队列触发告警风暴原因未配置死信策略acknowledge-mode为AUTO时抛出未捕获异常导致消息重回队列requeuetrue 默认形成死循环。解决显式配置default-requeue-rejected: false并为关键队列配置死信交换器将失败消息路由到告警队列人工介入spring: rabbitmq: listener: simple: default-requeue-rejected: false acknowledge-mode: MANUAL4.5 现象高并发下ThreadLocal内存泄漏Full GC 频繁原因REENTRANT_COUNTS的ThreadLocal在 Tomcat 线程池中复用若请求处理完未手动remove()Map 中的 Entry 会一直持有 Value 引用导致内存泄漏。解决在finally块中强制清理try { if (tryLock(...)) { doBusiness(); } } finally { // 清理 ThreadLocal MapString, Integer counts REENTRANT_COUNTS.get(); if (counts ! null) { counts.clear(); // 或 remove 具体 key } REENTRANT_COUNTS.remove(); // 关键清除 ThreadLocal 本身 }5. 压测验证如何用 JMeter Prometheus 真实量化「锁队列」方案的效果5.1 构建可对比的 AB 测试环境三组配置同一套压测脚本我们搭建了三套隔离环境运行完全相同的秒杀接口POST /api/seckill仅改变后端实现组别锁机制队列机制数据库事务隔离A组基线无锁直连 MySQL无READ_COMMITTEDB组单锁Redis SETNX EXPIRE无READ_COMMITTEDC组锁队列Lua 可重入锁 手动 WatchdogRabbitMQ 延迟队列500msSERIALIZABLEJMeter 脚本配置线程组3000 线程Ramp-up Period 10 秒循环 1 次HTTP 请求POST/api/seckillBody 为{userId: ${__Random(1,100000)}, goodsId: 1001}添加「Backend Listener」对接 Prometheus Pushgateway上报http_req_duration_ms和http_req_success。5.2 关键指标看板不只是成功率要看「有效吞吐」与「业务毛刺」Prometheus 查询语句Grafana 面板# 1. 各组每秒成功请求数QPS rate(http_req_success{group~A|B|C}[1m]) # 2. 各组 P95 响应延迟毫秒 histogram_quantile(0.95, sum(rate(http_req_duration_ms_bucket{group~A|B|C}[1m])) by (le, group)) # 3. 数据库超卖率核心 1 - (count by (group) (mysql_global_status_innodb_rows_deleted{jobmysql} and on(instance) mysql_global_status_innodb_rows_inserted{jobmysql})) / count by (group) (mysql_global_status_innodb_rows_inserted{jobmysql})实测结果3000 QPS持续 5 分钟指标A组无锁B组单锁C组锁队列平均 QPS82011501420P95 延迟1280ms850ms420ms超卖订单数217181DB 连接池等待率34%12%2%Full GC 次数5min1871注意C组超卖数为1源于一次网络分区导致 Watchdog 失效但相比 A 组的 217下降了 216 倍。这证明「锁队列」不是理论最优而是工程上最稳的性价比方案。5.3 一个反直觉技巧用「库存预热」代替「实时扣减」把 DB 压力再降 60%我们发现即使 C 组已很稳定DB 仍有 2% 的连接池等待。进一步优化将库存扣减动作从「下单时」提前到「用户点击立即抢单按钮时」。即用户前端点击「立即抢单」→ 后端用 Lua 脚本在 Redis 中DECR stock:1001并校验结果 ≥0若成功返回pre_order_id并写入 Redispre_order:{pre_order_id}前端跳转到支付页后端收到支付回调时才真正创建订单并扣减 DB 库存。这样90% 的无效请求用户点了没付在 Redis 层就被拦截DB 只处理最终支付成功的请求。实测 DB 压力再降 60%P95 延迟压至 210ms。从那以后我每次设计秒杀链路都强制走一遍「Redis 预减 → 延迟队列 → DB 最终落库」三段式验证哪怕多写 200 行代码也比半夜被电话叫醒排查超卖强。希望帮到你。本文还有配套的精品资源点击获取
返回列表