Redis分布式锁实战:优惠券秒杀超卖、锁粒度与事务顺序

发布时间:2026/10/27 21:36:13

Redis分布式锁实战:优惠券秒杀超卖、锁粒度与事务顺序 秒杀优惠券这个功能我在不同量级的项目里前后写过三遍每一遍栽的坑都不一样。第一次是在本地单机压测所有用例全绿库存数字漂亮得很部署到两台机器之后库存直接跑成负数。第二次学乖了引入分布式锁超卖确实没了但冒出一个更诡异的现象用户明明抢到了页面却提示已抢完。第三次才把这套东西真正捋顺也才明白优惠券秒杀这条链路上分布式锁只是其中一环把它当成万能药反而会踩更深的坑。这篇文章就把分布式锁这条线完整拆一遍为什么单体应用里一个synchronized能解决的事上了集群就得写这么多代码手写的 Redis 锁究竟差在哪几口气Redisson 替你补了什么、又埋了什么以及最容易被忽略的——锁和事务的先后顺序、锁的粒度选择这两件事比选哪个锁实现更决定成败。1. 从一人一单在集群下失效说起1.1 synchronized 的护城河只有一条 JVM先把场景摆清楚。优惠券秒杀的典型业务约束是一人一单同一个用户对同一张券只能下一单成功。单机时代这个约束实现起来很轻松把校验和下单逻辑用synchronized包一层或者锁在用户 ID 的字符串常量上问题就解决了。原因在于synchronized的互斥范围是当前 JVM 内的对象监视器只要所有线程都在同一个进程里监视器就是同一个互斥成立。但这个前提一旦被打破整个方案就崩了。项目从一台机器扩到两台前面挂了个负载均衡同一个用户的两次请求可能分别落到 A 机器和 B 机器上。这两次请求各自在自己进程里竞争各自的对象锁谁都锁不住谁两个线程同时通过了是否已下单的校验于是两条订单记录、两次库存扣减。这就是超卖的经典成因跟代码写得好不好没关系是锁的作用域和业务的作用域不匹配。判断要不要用分布式锁只需要问一句互斥的临界区是否可能被多个进程同时进入。只要答案可能是是本地锁就一律不算数。很多人在这一步会尝试一个更聪明的办法把库存校验和扣减写成一条 SQLupdate voucher set stock stock - 1 where id ? and stock 0靠数据库行锁保证原子性。这个思路本身没错行锁确实是跨进程的但它在秒杀场景下有两个硬伤一是热点行会被大量并发请求堵在同一把行锁上连接池迅速耗尽二是它只能保证库存不超卖管不住一人一单这种业务约束——你没法用一条 update 同时表达用户没买过和库存够。所以数据库方案通常只作为最后的兜底而不是主链路。1.2 把锁搬到进程之外一个合格分布式锁的三个硬指标既然锁要在进程之外共享那就得找一个所有实例都能访问的公共组件来存放锁状态。常见的选择是 Redis、ZooKeeper、数据库本项目用的是 Redis因为秒杀链路上 Redis 本来就在不需要额外引入组件而且它的写入性能足够扛住秒杀流量。但能存状态不等于是一把好锁。一个能在生产环境用的分布式锁至少要同时满足三个条件缺一个都会在某个特定时刻翻车指标含义不满足时的后果互斥同一时刻只有一个客户端能持有锁直接失去锁的意义等于没加防死锁持有者异常退出后锁能自动释放锁永久占用业务全线阻塞比超卖更严重可识别持有者只有加锁的那个客户端才能解锁误删别人的锁互斥被破坏且难以复现第三个指标经常被忽略但它是手写方案最容易出事的地方。举个具体例子线程 A 加锁成功执行到一半因为 Full GC 卡住了 12 秒锁的过期时间只有 10 秒锁自然过期线程 B 趁机加锁成功开始执行这时候 A 恢复过来执行完 finally 里的删除逻辑——删掉的是 B 的锁。B 还在临界区里但锁已经没了第三个线程 C 也能进来。整个互斥机制土崩瓦解而且这种问题在压测里几乎看不出来只会在线上偶发。除了这三个还有一个工程上的隐性要求加锁失败要有合理的等待策略。秒杀场景下大量请求同时涌进来如果加锁失败就直接返回系统繁忙用户体验会很差通常需要短暂重试或者阻塞等待一小段时间。这个能力手写起来不难但很容易写出一个不断自旋把 Redis 打满的版本。2. 手写 Redis 分布式锁一场必须经历的三轮演进我建议每个做秒杀的人都亲手把这条演进路线走一遍哪怕最后用的是 Redisson。因为你不摔这几跤就永远不知道 Redisson 那些看起来啰嗦的代码是在防什么。2.1 第一版setnx 加 del两个动作三种死法最直觉的实现是两个命令SETNX加锁DEL解锁。代码大概长这样public boolean tryLock(String key) { // 存在则不加不存在则写入返回是否写入成功 Boolean ok redisTemplate.opsForValue().setIfAbsent(key, 1); return Boolean.TRUE.equals(ok); } public void unlock(String key) { redisTemplate.delete(key); }这段代码在演示环境能跑通但它有致命伤加锁之后没有过期时间。一旦持有锁的进程在finally之前挂掉进程被 kill、机器宕机、甚至只是抛出Error没被捕获这把 key 就永远留在 Redis 里所有后续请求全部加锁失败业务彻底停摆。这种故障比超卖可怕得多因为超卖只是少部分数据错死锁是整个功能不可用。于是我加了过期时间这是第二版public boolean tryLock(String key, long timeoutSec) { Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, 1, timeoutSec, TimeUnit.SECONDS); return Boolean.TRUE.equals(ok); }注意这里必须用setIfAbsent(key, value, timeout, unit)这个重载而不是先setIfAbsent再expire。前者在 Redis 里是一条SET key value NX EX timeout命令原子执行后者是两条命令中间如果进程崩了锁又变成永久的了。这个细节看起来小但它决定了你是不是把上一版的坑真正填上了。2.2 第二版过期时间带来的误删比死锁更隐蔽加了过期时间之后新的问题浮现出来线程 A 执行业务耗时超过了锁的过期时间锁自动释放线程 B 加锁成功A 执行完后照常调unlock把 B 的锁删了。这就是前面说的误删别人的锁。解决思路很直接加锁的时候写入一个能标识自己身份的值UUID 线程 ID解锁前先比对是我的才删。但这里有个陷阱——比对和删除是两条命令中间依然有窗口。比对时锁还是我的比对完、删除前锁刚好过期并被别人拿走那我还是删了别人的。这类检查后再操作的竞态在并发场景下永远不能靠两条命令拼必须靠服务端原子执行。于是就有了第三版用 Lua 脚本把比对和删除打包成一次原子操作-- unlock.lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endJava 侧这么调private static final DefaultRedisScriptLong UNLOCK_SCRIPT new DefaultRedisScript( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end, Long.class); public void unlock(String key, String token) { redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key), token); }为什么 Redis 能保证这段脚本的原子性因为 Redis 执行命令是单线程的指命令执行阶段一段 Lua 脚本在被加载后会作为一个整体执行中间不会插入其他客户端的命令。所以读到的是我写的值和删除这两步之间外人插不进来。这是 Redis 做分布式锁的技术底座也是为什么几乎所有成熟实现最终都落到 Lua 上。一个容易被忽略的点Lua 脚本里返回 0 表示锁已经不是你的了业务上应该记录一条告警日志。这条日志出现的频率就是你的锁超时配置是否合理的直接指标。我在项目里把它做成一个计数指标超过阈值就说明锁的timeout给短了需要调大或者改用续期方案。2.3 第三版锁过期与业务耗时之间的那道算术题到第三版互斥、防死锁、可识别持有者这三个指标基本都满足了。但还剩下一个绕不开的矛盾锁的过期时间到底给多少。给短了业务没跑完锁就没了互斥失效给长了进程崩溃后其他请求要白等到过期才能进来可用性下降。这是一个典型的两难因为你在加锁的那一刻无法预知业务要跑多久。有人会说按压测 P99 的 3 倍来给但秒杀这种场景抖动是常态某一次数据库慢查询就能把耗时从 50 毫秒拉到 3 秒。工程上的解法有两条路第一条是给业务加一个硬性的执行预算。比如锁超时 10 秒那就在业务代码里设定所有操作必须在 8 秒内完成超时就主动放弃并回滚。这要求下游调用都设超时实际上是个好习惯但实现成本不低。第二条是引入看门狗续期起一个后台任务在锁快过期时自动续命只要持有锁的进程还活着锁就不会过期进程一死后台任务随进程消失锁在自然过期后释放。这条路把预知耗时变成探测存活明显更合理但也引入了新的复杂度——续期任务本身的调度、续期失败的处理、进程假死还在但线程卡住怎么办。这些正是 Redisson 帮你封装掉的部分也是它值得引入的原因。2.4 手写版本的最后一公里阻塞等待与可重入还有两个能力是我在自研锁里做到最后才发现必须补上的。第一个是阻塞等待。原始版本加锁失败直接返回 false前端弹手速慢了。但在优惠券场景用户点击之后被拒绝的体验很差而且真实并发中锁的持有时间往往只有几十毫秒只要稍等一下大概率能拿到。所以需要一个带超时的自旋每隔 50 毫秒试一次最多等 3 秒。这里的关键是自旋间隔不能太小否则几十个线程同时自旋会把 Redis 的 QPS 打满反而拖慢了持锁线程的释放速度形成负反馈。第二个是可重入。听起来秒杀场景用不上但实际代码里很常见加锁的服务方法调用了另一个同样加了锁的方法如果锁不可重入自己就把自己锁死了。手写可重入需要用一个 Hash 结构存{持有者标识: 重入次数}加锁时判断是不是自己是自己就把计数加一解锁时计数减一减到零才真正删除。这套逻辑用 Lua 写出来大概二十行本身不难但一旦开始写你就等于在重新实现一个 Redisson。所以我的建议很明确手写版本写到第三版UUID Lua就够了用来理解原理真正上生产直接上 Redisson。3. 换到 Redisson 之后那些必须搞懂的内部机制引入 Redisson 之后加锁代码会短到让人心虚RLock lock redissonClient.getLock(lock:order: userId); boolean locked lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { return Result.fail(系统繁忙请稍后再试); } try { // 校验一人一单、扣减库存、创建订单 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }代码短但底下的机制不能不懂否则参数会配错、异常会吞掉。3.1 加锁用的为什么是 Hash 而不是 StringRedisson 在 Redis 里存的结构是这样的key: lock:order:1001 value: { 3f2a...-1: 1 }外层 key 是锁名内层是一个 Hashfield 是客户端 ID 线程 ID拼成的唯一标识value 是重入次数。加锁时执行一段 Lua锁不存在就创建并设重入次数为 1锁存在且 field 是自己就把次数加一否则返回当前锁的剩余存活时间。解锁同理次数减到 0 才del。这个结构解释了两件事。第一判断锁归属的维度是实例 线程不是用户。同一个 JVM 里两个线程去抢一把锁是互斥的不同 JVM 的两个线程标识不同也互斥。第二重入是靠计数实现的所以unlock()必须和lock()成对出现多调一次会抛IllegalMonitorStateException。我在早期代码里就犯过在 catch 块里也调了一次 unlock 的错误结果异常路径下报错信息完全对不上根因排查了半天。3.2 看门狗默认不生效这点最容易踩Redisson 的看门狗watchdog逻辑是加锁成功后起一个定时任务每隔leaseTime / 3默认 30 秒锁10 秒续一次检查一次如果锁还持有就重置过期时间为 30 秒。但有个前提条件经常被忽略只有调用不带leaseTime参数的lock()或tryLock(waitTime, unit)时看门狗才生效。一旦你像上面那样写成tryLock(3, TimeUnit.SECONDS)也就是显式指定了租期看门狗直接不启动锁到点就过期不管业务跑没跑完。// 看门狗生效不指定 leaseTime默认 30 秒自动续期 lock.lock(); lock.tryLock(3, TimeUnit.SECONDS); // 看门狗不生效显式指定 10 秒租期到点必过期 lock.tryLock(3, 10, TimeUnit.SECONDS);这个差异在文档里写着但很多人是照着某篇博客抄的参数抄完根本不知道自己把看门狗关了。我的做法是内部服务调用、链路耗时可控几百毫秒的场景显式指定租期短一点没关系避免续期任务带来额外开销涉及外部接口、耗时不可控的场景用不指定租期的版本让看门狗兜底。顺带提醒一个副作用看门狗续期是每个锁一个定时任务。如果业务里有大量细粒度锁比如按用户 ID 加锁高峰期会同时存在几千个定时任务虽然开销不大但也不是零。锁用完及时释放、避免在循环里大量创建锁对象是必要的好习惯。3.3 tryLock 的参数怎么定才不用背锅tryLock(waitTime, leaseTime, unit)这三个参数我见过太多配错的案例参数含义配错的典型后果waitTime最多等多久拿不到就放弃配太大如 30 秒请求堆积线程池打满leaseTime锁的自动过期时间配太小业务没跑完锁就没了互斥失效unit时间单位用错单位是低级但高频的错误waitTime的取值应该和用户能忍受的等待时间对齐。秒杀按钮点下去用户最多愿意等一两秒所以 2 到 3 秒是合理区间。配成 30 秒意味着大量请求卡在等锁上Tomcat 线程被占满整个服务对无关请求也不响应了——这就是典型的锁把服务拖死。leaseTime的判断标准是业务正常耗时 合理余量。我的习惯是先压测出 P99然后乘以 3。比如下单链路 P99 是 200 毫秒那我给 1 秒。留 5 倍余量也行但不要留到几十秒因为一旦进程崩溃其他请求就得干等这么久。还有一个细节tryLock抛InterruptedException方法签名上是要处理的。有些人图省事直接catch (Exception e) {}吞掉然后继续往下走业务——这会在一把没拿到的锁上执行业务逻辑比不加锁还危险。正确做法是捕获后恢复中断标记并立即失败返回boolean locked false; try { locked lock.tryLock(3, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail(请求被中断); }4. 锁明明加对了为什么还会超卖这是我在项目里花时间最多的一段。把 Redisson 接进去、参数也调对了、压测也没问题但做数据核对的时候还是能查出极少数订单存在库存为负或者超卖一张的情况。查了很久根因都不在锁上。4.1 锁和事务的释放顺序那个著名的先放锁后提交问题代码大概是这样Transactional public Result seckill(Long voucherId) { RLock lock redissonClient.getLock(lock:order: UserHolder.getUserId()); if (!lock.tryLock()) return Result.fail(系统繁忙); try { // 1. 校验一人一单 // 2. 判断库存 // 3. 扣库存、创建订单 return Result.ok(); } finally { lock.unlock(); } }看起来没毛病锁在方法内加在 finally 释放。但Transactional的本质是 AOP 代理事务的提交发生在方法返回之后、代理层收尾时。也就是说执行顺序是业务方法体执行完finally里的lock.unlock()执行锁释放代理层提交事务第 2 步和第 3 步之间有一个窗口虽然很短但足够让另一个线程拿到锁、读到尚未提交的旧库存数据库默认隔离级别下别的事务看不到未提交的修改校验通过然后也去扣库存。两个事务都提交后库存被扣了两次超卖发生。这个问题在低并发下几乎不复现因为它依赖那个几毫秒的窗口属于典型的压测压不出来、线上偶发。修法有三种按推荐程度排序第一种把锁提到事务外层。在 Controller 或者一个不带事务的外层服务方法里加锁锁住之后调用带事务的内层方法内层返回即代表事务已提交再释放锁public Result seckill(Long voucherId) { RLock lock redissonClient.getLock(lock:order: UserHolder.getUserId()); if (!lock.tryLock()) return Result.fail(系统繁忙); try { // 调用事务方法方法返回时事务已提交 return orderService.createOrder(voucherId); } finally { lock.unlock(); } }第二种用编程式事务把commit显式放在锁内TransactionStatus status txManager.getTransaction(def); try { // 业务逻辑 txManager.commit(status); } catch (Exception e) { txManager.rollback(status); } finally { lock.unlock(); }第三种把库存扣减改成原子 SQL 兜底update ... where stock 0即使并发进来也不会扣成负数。这是最省事的兜底手段我一般会同时加上作为防线的最后一层。4.2 锁的粒度锁用户还是锁券这道选择题决定吞吐量另一个常见误区是锁的粒度选错。很多示例代码锁的是优惠券 IDRLock lock redissonClient.getLock(lock:voucher: voucherId);这个粒度下一人一单确实不会出错因为同一张券的所有请求都串行化了。但代价是同一张券完全失去并发能力一秒只能处理 N/单次耗时的请求量剩下的全在排队或者被拒绝。热门券本来就是要被大量用户抢的用这把锁等于自己给自己设了个瓶颈。更合理的组合是按业务约束来一人一单那就锁用户 券的维度不同用户之间互不影响库存扣减的原子性交给 Redis 预扣或者数据库的原子 update 保证不需要用锁去串行化整张券。String key lock:seckill: voucherId : userId;这样不同用户的请求可以完全并行同一用户的重复请求被挡住正好对应业务语义。库存安全由另一条独立防线负责。这是我踩了锁整张券导致压测 QPS 只有几百的坑之后才想明白的——锁管的是业务规则不是数据一致性这两件事该用不同手段解决。如果确实需要控制总的并发扣减速度还有分段锁的思路把一张券的库存拆成若干个桶每个桶一把锁请求按用户哈希落到某个桶上。这把锁的竞争范围就降到 1/N吞吐量提升 N 倍。但分段锁会带来某个桶空了但总库存还有的碎片问题需要用额外逻辑做调剂实现复杂度比 Redisson 高一个数量级。我的经验是只有单券 QPS 需要上万时才值得上分段锁普通秒杀场景用 Redisson 的细粒度锁 Redis 预扣库存足够。4.3 库存预扣与异步下单锁之外的真正解法说到最后秒杀场景的正确姿势其实是把抢和下单拆开抢的环节在 Redis 里完成用 Lua 脚本一次性做三件事判断用户是否已抢过SISMEMBER、判断库存是否大于零、扣减库存并记录用户SADDDECR。整个脚本原子执行天然互斥根本不需要分布式锁。下单的环节异步化脚本执行成功后把消息丢进队列由消费者慢慢落库。-- seckill.lua local stock tonumber(redis.call(get, KEYS[1])) if stock nil or stock 0 then return -1 end if redis.call(sismember, KEYS[2], ARGV[1]) 1 then return -2 end redis.call(incrby, KEYS[1], -1) redis.call(sadd, KEYS[2], ARGV[1]) return 0这个方案的好处是彻底消除了锁竞争——所有请求都在 Redis 里被串行处理Redis 单线程模型互斥是天然的既不需要加锁也不需要续期延迟还低。而且库存和用户去重放在同一个脚本里不会出现扣了库存但没记录用户的半成品状态。那分布式锁还有没有用有。它在这个架构里退到了第二道防线异步消费者落库时如果担心消息重复投递导致重复下单可以在消费者侧对用户 券加锁做幂等或者更简单——给订单表建(user_id, voucher_id)唯一索引让数据库来做最后一道拦截。这两者都是低频、短临界区的场景用 Redisson 正合适。我在项目里的最终形态是Redis Lua 预扣做第一道闸消息队列削峰消费端 Redisson 锁 唯一索引兜底。分布式锁没有消失但它从主逻辑降级成了补偿逻辑这才是我认为合理的定位。5. 怎么证明这把锁真的生效了写完代码就跑是分布式锁最大的敌人。因为它的失效往往是概率性的本地开发环境几乎不可能复现。所以验证这件事必须专门设计。5.1 单机测试为什么一定会骗你最典型的自欺欺人是本地起一个实例用 50 个线程并发调接口结果库存正确、订单数正确于是认为锁没问题。但这个测试根本没有测到锁的价值——单实例、单 JVM 的情况下synchronized也能过你的 Redisson 锁和它没有区别。要测出问题最低要求是同一个应用起两个端口比如 8081 和 8082然后用压测工具把请求同时打到两个端口上。不用 nginx 也行直接在压测脚本里轮流请求两个地址。这样才能真正让两个独立进程去抢同一把 Redis 锁把主从、网络、时钟这些问题暴露出来。我的压测脚本大致是这么组织的# 用 ab 交替打两个实例 ab -n 2000 -c 100 http://127.0.0.1:8081/api/seckill/1 ab -n 2000 -c 100 http://127.0.0.1:8082/api/seckill/1两个命令要同时跑而不是一前一后。前一后是顺序执行并发度根本不够。真要做严格测试用 JMeter 的线程组配两个 HTTP 请求默认值或者干脆写个多线程程序随机选端口发请求。5.2 用数据核对代替看起来没超卖压测跑完不要只看接口返回的成功率那玩意儿会骗人。必须做数据核对我一般查三样东西-- 1. 库存是否被扣成负数 SELECT stock FROM voucher WHERE id 1; -- 2. 是否出现同一用户的重复订单 SELECT user_id, COUNT(*) FROM voucher_order WHERE voucher_id 1 GROUP BY user_id HAVING COUNT(*) 1; -- 3. 订单总数是否等于初始库存减去剩余库存 SELECT COUNT(*) FROM voucher_order WHERE voucher_id 1;这三条查询分别是超卖检查一人一单检查数量守恒检查。任何一条不通过就说明这一层的防护有漏洞。我建议把这三条做成测试用例每次改完锁相关代码都跑一遍而不是靠肉眼判断。除了数据还有一个更有用的观测指标解锁时 Lua 脚本返回 0 的次数。这个数字代表我去解锁的时候锁已经不属于我了也就是锁超时被抢走了。它大于零说明leaseTime给短了或者业务里有慢操作需要优化。我在生产环境把这条做成日志告警一旦出现就去看对应链路的耗时。5.3 主动制造故障那些只能靠演练发现的坑还有一类问题正常压测永远碰不到必须主动制造故障才能验证手动 kill 进程。在锁持有期间kill -9掉应用看锁是否能在过期后自动释放新请求是否能正常进来。这一条能验证你究竟有没有把过期时间设上。Redis 主从切换。如果用的是主从架构在主节点写入锁之后立刻让主节点下线观察从节点晋升后锁是否存在。Redis 的主从复制是异步的锁在切换瞬间有丢失的可能——这就是所谓的锁可靠性问题后面会细说。Redis 短暂不可用。把 Redis 停 5 秒再启动观察应用的表现是熔断降级还是全量请求卡在tryLock上把线程池耗光。这个演练很值因为 Redis 抖动在云环境里并不罕见。6. 选型收口什么时候该用 Redisson什么时候不该6.1 Redis 主从下的锁丢失概率与影响要算清楚先说清楚一个事实基于 Redis 主从非 RedLock、非集群多数派的分布式锁在故障切换的瞬间存在锁丢失的可能。原因是写入主节点的锁命令通过异步复制同步到从节点如果主节点在复制完成前宕机从节点晋升后就没有这把锁另一个客户端可以再次加锁成功。概率有多大取决于主从复制延迟和故障频率。在正常的同机房部署下延迟通常在毫秒级主节点恰好在这个窗口内宕机概率并不高。但不高不等于不会是否需要为此买单取决于业务对超卖的容忍度业务场景超卖影响建议方案优惠券、秒杀券有限量多卖几张需要补偿或退款Redisson 数据库唯一索引兜底积分、余额扣减资金损失不可接受数据库乐观锁 对账慎用纯 Redis 锁防止重复提交、重复任务多跑一次幂等可解Redisson 足够缓存重建、定时任务选主重复执行浪费资源Redisson 足够这张表的结论其实很明确分布式锁解决的是并发互斥不解决数据绝对正确。凡是涉及钱的最后一道防线一定要落在数据库的唯一约束或者对账上锁只是在前面挡住绝大部分无效并发降低数据库压力。指望一把 Redis 锁扛住资金安全是把工程问题当成了信仰问题。至于 RedLock向多个独立 Redis 实例加锁多数成功才算成功它确实能降低单点故障导致的锁丢失概率但代价是每次加锁要访问 N 个实例延迟和复杂度都上升而且社区里对它的争论一直没停。我的态度是普通业务用单实例或主从的 Redisson 就够真有极端要求用 ZooKeeper 或数据库行锁更省心不必在 RedLock 上纠缠。6.2 一段可以直接拿去用的模板代码最后把我自己在项目里用的模板贴出来包含了几处容易漏的细节public Result seckillVoucher(Long voucherId) { Long userId UserHolder.getUserId(); String lockKey lock:seckill: voucherId : userId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { // waitTime 3s 与前端超时对齐leaseTime 不指定交给看门狗续期 locked lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { return Result.fail(当前参与人数过多请稍后再试); } // 事务方法调用返回即表示已提交 return voucherOrderService.createVoucherOrder(voucherId, userId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail(请求被中断请重试); } catch (Exception e) { log.error(秒杀异常 voucherId{} userId{}, voucherId, userId, e); return Result.fail(系统异常); } finally { // 关键判断当前线程是否持有锁避免误放别人的锁 if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }几处细节值得说一下。第一lockKey里带上了userId粒度精确到人 券不同用户互不影响。第二isHeldByCurrentThread()这个判断不能省——如果tryLock抛异常或者没拿到锁直接unlock会抛IllegalMonitorStateException把真实的异常信息盖掉排查时非常痛苦。第三锁在外、事务在内这是前面说的顺序问题的正确写法。第四异常分类处理InterruptedException单独分支是因为它需要恢复中断标记不属于普通业务异常。6.3 一个我至今觉得值得的小优化最后分享一个不太起眼但收益挺明显的改动。最早的实现里每个请求进来都无条件去tryLock哪怕是已经下过单的用户。结果就是同一个用户疯狂重复点击每次都去 Redis 抢一把锁白白消耗了 Redis 的 QPS。后来我在加锁之前加了一层本地缓存判断用 Caffeine 缓存最近成功下单的userId voucherId组合存活 5 秒。命中就直接返回您已抢过不进 Redis。这一层的命中率在真实流量下比我预想的高得多——秒杀期间用户的重复点击是非常集中的行为往往几十次请求里只有一次是真的要去抢。加完之后Redis 的 QPS 下降了一大截而业务正确性没有受任何影响因为缓存只是快速拒绝没命中还是会老老实实走锁。这个优化也说明一个道理分布式锁的成本不只是那一次 Redis 命令还有它带来的排队、续期、异常处理。能在这把锁之前挡掉一部分无效请求比优化锁本身的实现收益更大。6.4 收个尾我踩过最深的一个坑如果只能留一条经验我会留这条别把锁当成并发控制的终点。我最早的做法是给所有涉及库存和订单的方法都套上一层 Redisson 锁以为万无一失结果压测时 QPS 只有三位数锁竞争让整条链路排队最后不得不把并发相关逻辑全部重构成Redis Lua 预扣 异步落库 消费端幂等。锁的位置越靠前、临界区越大系统的吞吐就越差。真正合理的结构是把能原子化的操作交给 Redis 脚本或数据库的一条 SQL把分布式锁压缩到必须跨进程互斥、且临界区极短的那一小段上。剩下的正确性交给唯一索引、状态机和对账去保证。这条思路调整完之后同样的机器数量秒杀接口的 QPS 涨了一个量级且再没出现过库存为负的数据。
延伸阅读

更多相关文章

2026/10/27 21:36:13

text-to-cad技术本质:从设计意图到STEP/DXF/URDF的工程语义映射

1. 这不是“让AI画CAD图”——而是重建设计意图到几何表达的桥梁最近在几个工业软件开发者群里,几乎每天都有人问:“text-to-cad到底能不能用?我输入‘一个带M6螺纹孔的铝制支架,长120mm宽80mm厚15mm,底部有4个Φ8安装…

2026/10/27 21:36:13

Skills:AI时代可复用、可编排的最小能力单元

1. 项目概述:从“skills”这个词看懂当前开发者生态的真实脉搏最近在多个技术社区、内部分享会和一线团队的站会上,反复听到一个词被高频提起——skills。它不再只是简历里那行轻描淡写的“熟悉 Python/React/Docker”,而是突然变成一个具象的…

2026/10/27 21:31:10

金蝶KIS V8.1兼容性配置与数据迁移实战指南

简介:金蝶KIS标准版迷你版V8.1是一款专为小型企业设计的轻量级财务信息化软件,聚焦财务管理、进销存与固定资产等核心业务场景,兼顾操作简易性与财税合规性,适合无专职IT人员、预算有限但需规范账务处理的初创及小微企业使用。资源…

2026/10/27 22:26:40

OpenClaw插件系统在水产养殖中的技术实践

1. OpenClaw插件系统概述OpenClaw人人养虾插件系统是一个面向水产养殖领域的模块化扩展框架。这套系统最初由国内某水产科技团队开发,旨在为中小型养殖户提供灵活可定制的数字化管理工具。我在实际部署过程中发现,其插件架构设计非常贴合养殖场景中的碎片…

2026/10/27 22:26:40

OpenCode:60K星开源AI编码代理的部署与实战指南

1. OpenCode项目概述:60K星开源AI编码代理的核心价值OpenCode作为当前GitHub上最热门的AI辅助编程项目之一,已经获得超过6万星的开发者认可。这个开源项目本质上是一个基于大语言模型的智能编码代理系统,能够深度理解开发者的编程意图&#x…

2026/10/27 22:26:40

大模型行业转型指南:方向选择与学习路径

1. 大模型行业转型全景图2023年被称为"大模型元年",全球AI人才需求激增287%(LinkedIn数据)。但我在帮助37位转行者的过程中发现,约68%的初学者会陷入"学完基础理论却找不到切入点"的困境。这个领域真正的门槛…

2026/10/27 22:26:40

桥梁损伤检测数据集与YOLOv8实战指南

1. 桥梁损伤检测数据集概述 这个包含4000张已标注图片的数据集,是专门为桥梁结构健康监测领域打造的目标检测训练资源。作为基础设施维护的关键环节,桥梁表面损伤的自动化识别一直存在样本不足的痛点。我们团队通过三年实地采集,覆盖裂缝、剥…

2026/10/27 22:26:40

Playwright端到端测试:从脚本录制到业务旅程建模

1. 项目概述:为什么“e2e”这个词最近在工程师圈里高频刷屏?最近两周,我在三个不同行业的技术群里都看到有人发截图问:“这个 e2e 是啥?Playwright 跑不起来,报错说 profile1 not found,是不是环…

2026/10/27 22:21:40

C语言冒泡排序:边界推导、优化与泛型实现

冒泡排序在C语言里的地位有点微妙:面试常考、教材必讲、实际项目里却几乎轮不到它上场。可真到要手写的时候,越界、少跑一趟、交换写反、循环变量作用域搞错,这些问题一个接一个冒出来。我带新人做代码审查,见过最多的不是算法写不…

2026/10/27 14:24:03

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/27 9:34:54

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/27 0:59:21

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/27 0:02:28

WorkBuddy_新建任务与工作模式19_“模式选错”的典型代价

“模式选错”的典型代价 摘要 在软件工程中,设计模式、架构模式、并发模式乃至团队协作模式的选择,往往在项目早期就决定了后续的开发效率和维护成本。本文通过多个真实场景,剖析“模式选错”带来的典型代价——包括返工成本、技术债务累积、…

2026/10/27 0:02:28

WorkBuddy_新建任务与工作模式20_新手推荐路径

新手推荐路径 摘要 在 AI 辅助开发工具快速普及的今天,新手面对最多的困惑往往不是"工具够不够强",而是"我该从哪一步开始用"。本文提出一条经过实践验证的三阶段学习路径:先从 Ask(问答式交互)起…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑