news 2026/10/27 13:54:31

Redis分布式锁解决秒杀超卖:从SETNX到Redisson实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis分布式锁解决秒杀超卖:从SETNX到Redisson实战

1. 从一次压测翻车说起:单机锁在秒杀场景里的真实边界

前段时间在一个本地生活点评类的实战项目里负责优惠券秒杀模块,需求本身不复杂:一批限量券,用户抢购,一人限一单,库存不能超卖。第一版代码写得很顺,synchronized把下单逻辑一包,本地跑几千并发数据完全正常,库存扣到 0 就停,订单数和库存数对得上,看着像模像样。

然后把它丢到两台机器上做模拟压测,问题立刻出来了:库存 100 张,最后生成了 137 笔订单。这个数字很刺眼,因为它说明锁压根没起作用。后来把日志打出来一看,两台机器各自的 JVM 里都在跑自己的synchronized代码块,它们互不认识,锁的只是各自进程内的一个对象头,跨进程毫无约束力。

这就是本文要聊的核心:在优惠券秒杀这类高并发场景里,单机锁为什么会失效,以及如何用 Redis 分布式锁把「一人一单 + 库存扣减」这道闸门真正守住。内容会从方案选型一路讲到手写实现、Redisson 落地、锁与事务的顺序陷阱、压测验证和排查清单,代码都是可以直接照着改的。适合已经写过秒杀基础逻辑、正在被并发问题折磨的同学,也适合刚接触分布式锁、想搞清楚「为什么这么设计」的读者。不管你现在的项目是单体还是集群,只要涉及「多实例共享一份资源」的竞争,这套思路都能平移过去。

1.1 单体锁失效的根因:锁的作用域和资源的作用域不匹配

很多人第一次遇到超卖,第一反应是「锁没加对」,于是拼命调整synchronized的位置,从方法级挪到代码块级,从this换成Xxx.class,结果还是一样。问题的关键不在锁的写法,在于锁的作用域和它要保护的资源不在同一个层级上。

synchronized和ReentrantLock的本质,是在 JVM 堆里维护一个对象头或者 AQS 状态位。这个状态位只存在于当前进程的内存空间里。部署两台机器,就有两份独立的内存、两个独立的锁状态。A 机器上的线程拿到锁,只代表「A 进程内部没人跟我抢」,B 机器上的线程完全感知不到,它照样品尝自己的锁,然后快乐地读库存、减库存、写订单。两个进程同时读到库存 1,各自减成 0,各自生成一笔订单,超卖就这么产生了。

所以这类问题的正确描述不是「锁失效」,而是「锁用错了层级」。当被保护的资源是数据库里同一行库存记录、Redis 里同一个计数器、文件系统里同一个文件时,锁就必须放在一个所有实例都能访问到的公共位置。这个公共位置可以是数据库(行锁、唯一索引)、可以是 ZooKeeper(临时顺序节点)、也可以是 Redis(SETNX)。这也是分布式锁这个概念存在的全部理由。

还有一个容易被忽略的点:即便只有一台机器,如果你的服务用了多线程容器加连接池,synchronized也只能锁住同一个 JVM 里的线程,一旦将来做水平扩容、灰度发布时新旧实例并行,问题会突然爆发。我见过不少项目在单机阶段跑了大半年没事,扩容当天下午开始出对账差异。所以只要业务形态存在「未来可能多实例」的预期,锁的设计就应该按分布式来考虑,而不是等出事再补。

1.2 超卖现场还原:三个并发窗口分别在哪

把下单逻辑拆开看,超卖不是一个点,而是三个时间窗口叠加的结果。

第一个窗口是查询与判断之间。线程 A 查库存,读到 1,判断「有货」;此时线程 B 也查,也读到 1,也判断「有货」。两个线程都通过了校验,但库存只有一份。这个窗口的存在,是因为「读」和「判断」不是原子的。

第二个窗口是判断与扣减之间。线程 A 判断完有货,准备执行update stock = stock - 1,这时如果 SQL 写成update ... set stock = #{stock} where id = ?,用的是应用层读到的旧值,那 B 的扣减会把 A 的结果覆盖掉。正确写法应该是set stock = stock - 1 where id = ? and stock > 0,让数据库自己做原子递减并带上条件。

第三个窗口是扣减与建单之间。库存扣成功了,订单还没插进去,进程挂了或者抛异常,库存白扣。这个窗口要靠事务来兜。

分布式锁能解决的主要是第一个和第三个窗口里「同一用户重复提交」的部分,第二个窗口本质上是数据库原子性问题,靠 SQL 写法解决更靠谱。这个区分很重要,因为不少人以为加了分布式锁就万事大吉,结果 SQL 还是set stock = #{stock},照样出问题。我在项目里最终的组合是:Redisson 分布式锁管住「一人一单」的判断与创建,数据库的stock > 0条件更新管住库存扣减,订单表的唯一索引做最后兜底。三层各管一段,谁都不越界。

2. 分布式锁的方案选型:为什么最后把票投给 Redis

确定要上分布式锁之后,选型这一步其实没花太久,但在团队内部还是争论过一轮。候选方案无非三个:数据库、ZooKeeper、Redis。每个都能用,区别在于你更在意什么。

2.1 三种实现路径的取舍逻辑

数据库方案最朴素:建一张锁表,靠唯一索引或者select ... for update实现互斥。优点是零新增组件,运维成本最低;缺点是性能天花板很明显,行锁的持有时间和数据库连接绑定,秒杀这种瞬时高并发场景下,连接池会先被打爆,而且for update在 RR 隔离级别下还有间隙锁带来的额外开销。它适合并发量不高、但对「绝对不能出错」要求极高的后台任务,比如对账批处理。

ZooKeeper 方案靠临时顺序节点实现,优点是强一致、有会话心跳天然支持故障自动释放,客户端挂了节点自动消失,不用操心锁过期。缺点是性能不如 Redis(写操作要走 ZAB 协议过半确认),而且引入 ZK 意味着多一套要维护的中间件,中小项目一般不愿意为了一个秒杀功能背上这个包袱。它更适合已经有 ZK 集群、且对一致性要求高于吞吐量的场景。

Redis 方案的优势在于快,单机十万级 QPS 很轻松,而且大部分项目本来就有 Redis 做缓存,复用现成基础设施,几乎零额外成本。缺点是它在主从架构下是异步复制的,极端情况下存在锁丢失的理论风险,需要额外机制来兜底。但在秒杀这种「允许极小概率失败、但不允许系统被打挂」的场景里,这个取舍是划算的。

选型对比可以看这张表:

方案互斥可靠性单机吞吐故障自动释放额外运维成本适用场景
数据库唯一索引/行锁高低(千级)靠连接断开无低并发后台任务、对账
ZooKeeper 临时节点很高中(万级)会话超时自动删高已有 ZK、强一致诉求
Redis SETNX较高高(十万级)靠过期时间低秒杀、缓存击穿保护

最后选 Redis,理由很实在:项目里 Redis 已经在跑,压测下来单节点扛住几千并发毫无压力,剩下的可靠性问题可以通过「锁 + 唯一索引兜底」来补,没必要为了理论上的完美去堆一套新组件。这个决策逻辑我觉得比结论本身更值得记:技术选型不是选最强的,是选在你能接受的故障代价下、最省事的那一个。

2.2 一把合格的分布式锁必须满足的五个条件

选定 Redis 之后,还要明确「什么样才算一把合格的锁」。我把它总结成五条,后面手写实现和 Redisson 的对比,基本都是围绕这五条展开的。

第一,互斥性。任意时刻只能有一个客户端持有锁。这是底线,做不到就不用谈了。Redis 的SET key value NX就是干这个的。

第二,防死锁。持锁的客户端崩溃了,锁必须能自动释放,否则整个业务永久卡死。手段是给锁加过期时间。

第三,防误删。A 加的锁,不能被 B 删掉。这要求锁的 value 带唯一标识(比如 UUID + 线程 ID),释放时先比对再删除。

第四,可重入。同一个线程在持锁期间再次请求同一把锁,应该直接放行而不是自己把自己锁死。业务代码里方法嵌套调用很常见,没有可重入性会非常难受。

第五,释放的原子性。「比对 value」和「删除 key」这两步必须原子完成,否则比对通过、删除之前锁刚好过期并被别人抢到,你还是删了别人的锁。手段是 Lua 脚本。

前三条是及格线,后两条是从「能用」到「好用」的分水岭。手写方案通常能覆盖前三条,第四条和第五条要额外花功夫,这也是后来转向 Redisson 的直接原因。

2.3 锁粒度设计:锁住用户,而不是锁住整个活动

动手写代码之前还有一个设计决策容易被跳过:锁的 key 到底怎么设计。

最粗暴的做法是一把大锁,key 叫lock:seckill,所有用户抢所有券都排队。这样确实不会超卖,但等于把整个秒杀活动串行化了,QPS 直接掉到个位数,压测时接口响应时间能从几十毫秒飙到几秒,体验极差。

合理的做法是缩小锁的粒度,只锁真正会冲突的资源。在「一人一单」这个需求里,会冲突的是什么?是同一个用户的重复提交。不同用户之间其实不冲突。所以 key 应该设计成lock:order:{userId},每个用户一把锁,互不干扰。

如果业务是「同一张券的库存扣减」,那冲突点是券,key 就应该是lock:stock:{voucherId}。但这时候要注意,如果某张爆款券有几万人同时抢,锁的竞争依然很激烈,这时候更好的做法是把库存分成若干段(比如 100 张拆成 10 段各 10 张),key 带上分段号,把竞争打散。

提示:锁粒度不是越细越好。key 太细会导致 Redis 里 key 数量爆炸,而且每个 key 都要设置过期时间,内存和清理成本都上来了。我的经验是:先按业务冲突维度拆(用户、商品、订单号),拆完再看热点,热点再考虑分段。

我们项目里最终用的是lock:order:{userId},因为「一人一单」的判断逻辑需要读订单表再决定是否插入,这个读-判-写的过程必须原子,锁用户维度刚好覆盖。库存扣减则交给 SQL 的条件更新,两件事分开处理,锁的持有时间也短。

3. 手写 Redis 分布式锁:从 SETNX 到 Lua 原子释放

虽然最后用的是 Redisson,但我强烈建议每个人都手写一遍。不手写一次,你永远不知道 Redisson 帮你挡了多少坑,出问题时也就无从排查。

3.1 加锁:为什么必须是 SET NX EX 一条命令

最早的写法是两步:

// 错误示范:两条命令,中间可能宕机 Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, value); if (Boolean.TRUE.equals(ok)) { redisTemplate.expire(key, 10, TimeUnit.SECONDS); }

这段代码的问题在于非原子。如果setIfAbsent成功之后、expire执行之前,服务被 kill 或者 Redis 连接断开,这个 key 就永久存在了,后续所有请求都拿不到锁,业务直接死锁。而且这种 bug 很隐蔽,本地测试永远复现不了。

正确写法是用一条命令把「不存在才设置」和「设置过期时间」合并:

String key = "lock:order:" + userId; String value = UUID.randomUUID() + ":" + Thread.currentThread().getId(); Boolean ok = redisTemplate.opsForValue() .setIfAbsent(key, value, Duration.ofSeconds(10)); if (Boolean.FALSE.equals(ok)) { // 获取失败,可以重试或者直接返回 return Result.fail("请勿重复提交"); }

底层对应 Redis 命令SET lock:order:123 uuid:88 NX EX 10,NX保证不存在才写入,EX保证带过期时间,一条命令原子完成。这是手写方案里唯一没有争议的部分。

value 里带上 UUID 和线程 ID,是为了后面释放时能识别「这是不是我加的锁」。只用 UUID 也行,但加上线程 ID 在多线程排查日志时更直观。

3.2 释放锁:不带 Lua 的删除等于埋雷

加锁成功后,业务代码跑完要释放锁。最直觉的写法是redisTemplate.delete(key),但这行代码有两个致命问题。

第一,会删掉别人的锁。假设线程 A 加锁,过期时间 10 秒,但业务执行了 12 秒(GC 停顿、慢 SQL、网络抖动都可能),第 10 秒锁自动过期,线程 B 抢到了锁。第 12 秒 A 执行完,一句delete把 B 的锁删了,C 立刻又能进来。三个线程同时在跑,锁形同虚设。

第二,比对和删除不是原子的。就算你先get出来比对 value 是对的,再执行delete,这两步之间锁也可能过期并被别人抢走,你删的还是别人的。

所以释放必须用 Lua 脚本,把「判断 value 是否匹配」和「删除」打包成一次 Redis 原子操作:

-- unlock.lua if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

Java 侧用DefaultRedisScript加载并执行:

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/unlock.lua")); UNLOCK_SCRIPT.setResultType(Long.class); } public void unlock(String key, String value) { redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key), value); }

Redis 执行 Lua 脚本时是单线程串行的,脚本内部不会被其他命令打断,原子性就有了保证。这个套路不只用于释放锁,秒杀的库存预扣、一人一单的 Redis 预校验,都可以用同样的方式实现。

3.3 手写方案的四个硬伤与真实踩坑记录

Lua 释放加上去之后,一个「能用」的分布式锁就有了。但真到生产环境,问题才刚开始。

硬伤一:不可重入。同一个线程如果在下单方法里又调用了一个也被同一把锁保护的方法,第二次setIfAbsent必然失败,因为它发现 key 已经存在了,而它不知道这是自己加的。表现出来就是「自己把自己锁死」,日志里全是获取锁失败,但业务逻辑明明只跑了一遍。

硬伤二:不可重试。拿不到锁就直接返回失败,用户体验很差。秒杀场景下瞬时冲突多,用户点一下提示「请勿重复提交」,再点还是失败,很挫败。理想行为是等待一小段时间再试,超过阈值才失败。

硬伤三:超时释放的时机难以确定。设 10 秒,业务跑了 12 秒,锁提前释放;设 60 秒,持锁进程崩溃后,其他线程要干等 60 秒。而且这个时间跟你用什么数据库、网络抖不抖动强相关,很难拍一个放之四海皆准的值。

硬伤四:主从架构下的锁丢失。Redis 主从是异步复制的,A 在主节点加锁成功,主节点还没把这条数据同步给从节点就挂了,哨兵把从节点提上来,B 在新主节点上又能加锁成功,于是 A 和 B 同时持锁。这个问题的严重程度取决于业务,对账类业务要命,秒杀类业务其实还好(因为还有唯一索引兜底)。

注意:硬伤四经常被人拿来说「Redis 分布式锁不可靠」,其实更准确的说法是「单节点 Redis 无法提供强一致」。要彻底解决得上 RedLock 多点加锁,但 RedLock 本身的性能损耗和争议也不小,绝大多数业务场景下用「锁 + 兜底约束」的思路更实际。

我当时踩得最惨的一个坑,是在锁过期后线程还在跑。压测时把过期时间设成了 5 秒,结果某个请求触发了全表扫描,跑了 8 秒,锁提前释放,第二个请求进来读到没有订单,又插了一笔。最后是数据库唯一索引把它拦住了,抛了个DuplicateKeyException,但我一开始没处理这个异常,直接 500 报给了前端。后来加了异常捕获,返回「您已参与过本次活动」,才算圆过去。

4. Redisson 落地:可重入、可重试和看门狗的完整拆解

手写方案的四个硬伤,本质上不是「写不出来」,而是「写出来容易出 bug、维护成本高」。可重入要维护计数器,重试要搞订阅通知,续期要开定时任务,这几件事凑一块就是个小框架了。既然成熟方案已经把这些都做了,没必要重复造轮子。

4.1 依赖引入与客户端配置

引入依赖:

<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.23.5</version> </dependency>

配置类里声明客户端。这里有几个参数值得说清楚,不是抄上去就完事:

@Configuration public class RedissonConfig { @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setDatabase(0) .setConnectionMinimumIdleSize(10) .setConnectionPoolSize(64) .setConnectTimeout(3000) .setTimeout(3000) .setRetryAttempts(3) .setRetryInterval(1500); // 看门狗超时时间,单位毫秒,默认 30000 config.setLockWatchdogTimeout(30000); return Redisson.create(config); } }

connectionPoolSize要按实际并发估算。简单算法:业务里同时持锁的线程数上限约等于连接数上限,如果秒杀峰值同时有 200 个线程在抢锁,池子至少给到 200 以上,否则线程会卡在拿连接而不是拿锁上,排查起来非常迷惑。我一般按「压测峰值 QPS × 平均持锁时长(秒)× 1.5」来估,留 50% 余量。

lockWatchdogTimeout是看门狗的基准时间,默认 30 秒,后面会详细讲它怎么影响行为。

4.2 可重入的实现原理:一个 Hash 结构搞定计数

Redisson 的锁不是一个简单的 String,而是一个 Hash 结构。key 是锁名,field 是「客户端 UUID:线程 ID」,value 是重入次数。

第一次加锁时,执行一段 Lua:如果 key 不存在,hincrby把 field 设为 1 并设置过期时间;如果 key 存在且 field 是自己,hincrby加 1,重入计数变成 2。释放时反过来hincrby -1,减到 0 才真正del这个 key。

这个设计很巧妙,它用一次 Hash 操作同时解决了「互斥」和「重入计数」两个问题。相比之下,手写方案要实现重入,得额外维护一个 Map 记住每个线程的加锁次数,还得考虑这个 Map 本身的线程安全问题,复杂度一下就上去了。

理解了这个结构,很多现象就解释得通了。比如你用redis-cli去看锁的 key,会发现类型是hash而不是string,这不是版本差异,是设计使然。再比如,如果代码里lock()了两次只unlock()一次,key 会残留(计数为 1),直到过期时间到了才消失,这个残留就是「忘记释放」的典型症状。

4.3 看门狗续期:参数怎么算,什么时候该关掉

看门狗是 Redisson 最容易被误解的机制,得掰开说。

它什么时候生效?只有调用lock()或者tryLock(waitTime, unit)这种不指定 leaseTime的方法时,看门狗才会启动。如果你写了tryLock(1, 10, TimeUnit.SECONDS),明确告诉了它锁只持有 10 秒,那看门狗就不管了,到点就释放,哪怕业务没跑完。

它怎么续期?默认lockWatchdogTimeout是 30000 毫秒,看门狗会每隔30000 / 3 = 10000毫秒检查一次,如果业务还在跑,就把过期时间重置回 30 秒。注意是「重置」,不是「累加」,所以锁的过期时间永远在 30 秒以内滚动,不会无限增长。

参数怎么定?这里有个反直觉的点:lockWatchdogTimeout不是设成「业务最长耗时」,而是设成「你能容忍的故障恢复时间」。假设设成 30 秒,持锁进程突然断电,其他线程最多等 30 秒就能拿到锁;设成 5 秒,故障恢复快,但要求续期间隔只有 1.67 秒,GC 稍微停一下就可能导致续期失败、锁提前释放。

我的经验值:业务耗时稳定在毫秒级的,直接用带 leaseTime 的tryLock,把看门狗关掉,省掉后台定时任务的开销,同时避免续期逻辑本身出问题。业务耗时不确定、可能几分钟的(比如批量导出、复杂计算),才用看门狗。秒杀下单这种场景,正常 50 毫秒内就跑完了,我一般写tryLock(1, 5, TimeUnit.SECONDS),1 秒等锁、5 秒持有,既能容忍短时排队,又不会因为进程挂掉而长时间锁死。

提示:leaseTime设得太短是常见事故源。如果业务实际耗时 800 毫秒,你设了 500 毫秒,锁会在业务中途释放,其他线程进来读到未提交的数据,出现「明明加了锁还是重复下单」。宁可多设一点,反正有唯一索引兜底。

4.4 秒杀下单的代码改造:从 synchronized 到 RLock

原来的下单逻辑大概是这样的:

@Transactional public Result createOrder(Long voucherId) { Long userId = UserHolder.getUser().getId(); // 1. 查是否已下单 int count = orderMapper.countByUserAndVoucher(userId, voucherId); if (count > 0) { return Result.fail("一人限一单"); } // 2. 扣库存 int rows = stockMapper.deduct(voucherId); if (rows == 0) { return Result.fail("库存不足"); } // 3. 创建订单 orderMapper.insert(new VoucherOrder(userId, voucherId)); return Result.ok(); }

改造后:

public Result seckillVoucher(Long voucherId) { Long userId = UserHolder.getUser().getId(); RLock lock = redissonClient.getLock("lock:order:" + userId); boolean locked = false; try { locked = lock.tryLock(1, 5, TimeUnit.SECONDS); if (!locked) { return Result.fail("系统繁忙,请稍后再试"); } // 通过代理对象调用,保证事务生效 IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createOrder(voucherId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail("请求被中断"); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } }

几个细节必须强调。lock.isHeldByCurrentThread()这个判断不能省,因为如果tryLock抛异常或者超时返回 false,unlock会抛IllegalMonitorStateException,把原始异常盖掉,排查时一脸懵。AopContext.currentProxy()是为了解决自调用导致@Transactional失效的问题,需要启动类上加@EnableAspectJAutoProxy(exposeProxy = true)。

还有一个更隐蔽的点:锁的 key 用什么维度。上面用的是 userId,看起来合理,但要注意如果同一个用户同时抢两张不同的券,这两次请求会互相阻塞。如果业务允许,把 voucherId 也带上:lock:order:{userId}:{voucherId},粒度更细。反之,如果业务是「用户全局只能有一笔待支付订单」,那就必须只用 userId。这个取舍取决于业务约束,不是技术问题。

5. 锁与事务的顺序:一个能让所有努力白费的细节

代码写完跑通,压测数据也漂亮,很多人就收工了。但这里藏着一个非常容易翻车的点:锁和事务的边界谁包谁。

5.1 先锁后事务和先事务后锁,差别到底在哪

先说结论:锁必须包在事务外面,也就是「先加锁,再开事务,事务提交后再释放锁」。

如果反过来,把锁加在@Transactional方法内部,会发生什么?事务方法执行到最后一行,方法返回,Spring 开始提交事务。但锁的unlock写在finally里,它执行的时候,事务可能还没提交完成。此时另一个线程拿到锁,去查订单表,查不到刚插入的那条(因为还没提交),于是判定「没下过单」,又插了一笔。等两个事务都提交完,数据库里躺着两条订单。

这个窗口有多长?取决于数据库的提交耗时,通常是几毫秒到几十毫秒,但在高并发下足够让几十个请求挤进来。压测时如果 QPS 高,这个 bug 几乎必然出现,而且很难复现——因为它依赖时序。

正确的结构是这样:

// 外层:不带事务,只负责加锁 public Result seckillVoucher(Long voucherId) { RLock lock = ...; lock.tryLock(...); try { // 内层:通过代理调用带事务的方法 return proxy.createOrder(voucherId); } finally { lock.unlock(); // 此时内层事务已提交 } }

proxy.createOrder执行完毕返回时,事务已经提交(或者回滚),锁再释放,就不存在「锁释放了但数据还看不见」的窗口。

还有一个变种问题:如果非要把锁写在同一方法里,可以用TransactionSynchronizationManager.registerSynchronization注册一个回调,在afterCompletion里释放锁。但这样代码可读性差很多,我一般不建议,除非有特殊约束。

5.2 用 AOP 加注解封装:别让业务代码里到处是 try-finally

每个方法都手写tryLock/unlock样板代码,很快就没人愿意看了。抽一个注解是常规操作:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DistributedLock { String key(); // SpEL 表达式,如 "'order:' + #userId" long waitTime() default 1; long leaseTime() default 5; TimeUnit unit() default TimeUnit.SECONDS; }

切面里解析 SpEL 拿到实际 key,执行加锁逻辑,环绕proceed(),finally 释放。这样业务方法上只加一行@DistributedLock(key = "'order:' + #userId")就行。

这里有个坑要提醒:切面的执行顺序。如果@DistributedLock和@Transactional加在同一个方法上,两者的优先级决定了谁在外层。Spring 的事务切面默认Ordered.LOWEST_PRECEDENCE,你的锁切面如果不显式设@Order,很可能排到事务里面去,又回到了「锁在事务内」的老问题。解决办法是给锁切面加@Order(Ordered.HIGHEST_PRECEDENCE),让它先于事务执行。

注意:注解方式虽然优雅,但 SpEL 解析有性能开销,每次调用都要走一遍表达式引擎。秒杀这种高频路径上,我实测过,简单表达式的解析耗时在几十微秒级别,跟 Redis 网络往返(通常 1 毫秒以上)比可以忽略。但如果 key 表达式里嵌了复杂方法调用,就另说了,建议尽量用简单变量拼接。

6. 压测验证与问题排查:数据不会骗人

代码改完,最忌讳的就是「看着没问题就上线」。分布式锁这种东西,不出问题的时候看起来特别正常,一出问题就是数据错乱。必须用压测把问题逼出来。

6.1 压测方案设计与结果对照

测试环境:两个应用实例(模拟集群),Redis 单节点,MySQL 单机。目标券库存 100 张,参与用户 500 个,每个用户模拟重复点击 10 次,总请求量 5000。

用压测工具配置线程组,500 个线程,每个线程循环 10 次,请求头里带上不同的用户标识。观察三个指标:最终订单数、库存剩余、接口异常率。

对比三组实现:

实现方式订单数库存剩余超卖/重复平均响应
无锁(基线)1370超卖 3745 ms
synchronized 单机锁1370超卖 37(跨实例无效)48 ms
手写 Redis 锁(无 Lua)1030重复 3 单62 ms
Redisson + 唯一索引兜底1000078 ms

数据很说明问题。synchronized 那一行和基线完全一样,因为它跨实例就是失效的;手写方案虽然大部分时候是对的,但仍有 3 单重复,追查日志发现都是业务耗时超过了 leaseTime 导致锁提前释放;Redisson 方案加上唯一索引之后,最终 100 单,一单不多一单不少。

响应时间从 45ms 涨到 78ms,这个代价换来的是数据正确,在秒杀场景下完全值得。如果嫌慢,可以从两个方向优化:一是把锁的粒度再拆细,二是把「查订单-判断-插入」整体挪到 Lua 脚本或者 MQ 消费端,锁只做前置拦截。

压测时还有个细节要留意:一定要用真实的多实例部署,不能在 IDE 里起两个 main 方法就完事,因为容器环境下的网络延迟、连接池行为跟本地差很多。我第一次压测就是单机跑的,数据完美,部署到测试环境立刻原形毕露。

6.2 常见问题速查表

现象可能原因排查方法解决
加了锁仍重复下单锁在事务内部释放看 unlock 与事务提交的先后锁包在事务外层,或用 afterCompletion
报 IllegalMonitorStateException未持锁就调用 unlock检查 tryLock 返回值finally 里加 isHeldByCurrentThread 判断
锁一直释放不掉unlock 未执行/线程异常redis-cli看 key 的 TTL确认 finally 覆盖,设置合理过期
大量请求超时失败waitTime 太短或锁粒度过粗看等待队列长度拆细 key,或适当加大 waitTime
业务中途锁失效leaseTime 小于业务耗时比对日志时间戳加大 leaseTime 或改用看门狗
主从切换后出现双持锁异步复制丢数据观察切换时间点唯一索引兜底,或接受该风险
锁的 key 类型是 hashRedisson 可重入设计type命令确认正常现象,不是 bug

这张表里最值得展开的是第一行。我在两个不同项目里都遇到过「明明加了分布式锁还是重复下单」,最后定位都是事务边界问题。排查方法很简单:在unlock之前和事务提交之后各打一行日志,带上时间戳和订单号,跑一次并发就能看出顺序对不对。

第二行也很常见。tryLock返回 false 说明压根没拿到锁,这时候调unlock就会抛异常,而且这个异常会覆盖掉try块里真正的业务异常,导致日志里全是「未持锁」而看不到根因。养成写if (locked && lock.isHeldByCurrentThread())的习惯,能省很多排查时间。

6.3 几条压箱底的实操心得

第一条:唯一索引是最后一道防线,不是可选项。分布式锁再完善,也可能因为 Redis 抖动、主从切换、代码 bug 出现漏网之鱼。订单表上加一个(user_id, voucher_id)的唯一索引,捕获DuplicateKeyException返回友好提示,成本几乎为零,收益是「数据绝对不会错」。我现在的习惯是,只要涉及「一人一单」这类业务约束,唯一索引必须先建好,锁是性能优化,索引是正确性保证。

第二条:锁的 key 一定要打日志。出问题时第一件事就是看「谁持有了锁、持有了多久、谁在等」。日志里打上 key、线程名、获取时间、释放时间,配合redis-cli的TTL和HGETALL,基本能定位 90% 的问题。别嫌日志多,秒杀链路就那么几个方法,打全了也就多几行。

第三条:别在锁里做远程调用。见过有人在持锁期间调第三方接口、发短信、写日志到远端,一次调用几百毫秒,锁的持有时间被拉长十倍,吞吐直接崩。原则是:锁内只做最核心的判断和写库,其他所有副作用全部挪到锁外,或者异步化。

第四条:压测要压到「锁冲突」的那一段。如果压测用的用户 ID 都是随机的,每个请求的锁 key 都不一样,根本没有竞争,压出来数据再漂亮也没意义。必须构造热点:让 500 个请求集中在 10 个用户身上,才能压出真实的锁竞争和等待行为。

7. 分布式锁的能力边界:什么时候该换思路

分布式锁用顺手之后,很容易产生「什么问题都上锁」的惯性。但这东西是有代价的:一次加锁至少一次 Redis 往返,释放又一次,高并发下这些 RTT 会累加成实实在在的延迟。所以判断「什么时候不该用锁」,跟会用锁一样重要。

7.1 性能账要算清楚

假设单次 Redis 往返 1 毫秒,加锁 + 释放就是 2 毫秒,加上业务本身的数据库操作 20 毫秒,看起来占比不高。但如果你的接口目标是 50 毫秒响应、单机 2000 QPS,那这 2 毫秒乘以并发量,对 Redis 连接池和网络带宽都是压力。更麻烦的是锁竞争导致的排队:热门券的 key 集中,等待时间会随着并发线性增长,尾延迟(P99)会非常难看。

衡量标准很简单:如果这段逻辑能用一条 Redis 原子命令或者一条带条件的 SQL 解决,就绝不加锁。比如「库存扣减」用update stock = stock - 1 where stock > 0,天然原子,不需要锁;「一人一单」的前置拦截可以用setIfAbsent往 Redis 写一个order:user:{userId}:{voucherId}的标记,写成功才继续,也不需要锁。锁真正不可替代的场景,是「读-判断-写」这个序列必须整体互斥,且中间状态需要落库。

7.2 更轻的替代路径

路径一:Lua 脚本合并判断与扣减。把「查库存、判重、扣库存」全塞进一段 Lua,在 Redis 里原子执行,返回成功与否,应用层只负责落库。这样完全不需要分布式锁,性能还高一个量级。缺点是 Redis 里的库存和数据库可能不一致,需要额外的对账或异步补偿机制。

路径二:消息队列削峰。请求进来先丢进队列,消费者单线程或者按用户 ID 哈希分区消费,天然串行,不需要锁。这是大促场景的主流做法,代价是响应从同步变成异步,前端要轮询结果。

路径三:分段 + 本地锁。把库存拆成 N 段,每段对应一个独立的 key,请求按用户 ID 哈希到某一段,段内用本地锁或者 Redis 锁。这样锁的竞争被分散到 N 个桶里,吞吐提升 N 倍。实现上比单一锁复杂,但效果立竿见影。

我们项目最后没有走到这几条路,因为流量规模还没到那个量级,Redisson 方案扛得住。但心里要清楚:分布式锁是「够用就好」的工具,不是终点。当压测显示锁竞争成为瓶颈时,就该考虑上面这些更彻底的方案了。

我在实际项目里踩过的最深的一个坑,倒不是技术细节,而是心态:第一版用 synchronized 跑通之后,觉得「并发问题不过如此」,直到集群部署那天被打脸。后来每次上并发相关的功能,我都会强迫自己做两件事:一是把服务真的部署成多实例再测,二是把唯一索引、幂等校验这些「兜底」先写好再谈优化。技术方案可以慢慢迭代,数据错了是真的要人肉对账的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 13:54:23

深入解析SELECT查询阻塞:数据库锁机制与并发控制

1. 为什么你的SELECT查询会阻塞&#xff1f; 这个问题困扰过不少刚接触数据库开发的工程师。明明只是执行一个简单的SELECT查询&#xff0c;为什么有时候会卡住不动&#xff1f;我十年前第一次遇到这个问题时&#xff0c;花了整整两天时间才搞明白背后的机制。 SELECT查询阻塞…

作者头像 李华
网站建设 2026/10/27 13:53:43

GBase 8c分布式集群运维实战:故障排查与优化

1. GBase 8c分布式集群运维实战概述GBase 8c作为一款企业级分布式数据库集群系统&#xff0c;其运维复杂度远高于传统单机数据库。在实际生产环境中&#xff0c;我曾处理过一个典型案例&#xff1a;某金融客户的核心交易系统在业务高峰期突然出现大面积查询超时&#xff0c;通过…

作者头像 李华
网站建设 2026/10/27 13:53:37

数据库DDL语句详解:从创建到优化实践

1. 为什么需要学习DDL语句&#xff1f; 在数据库的世界里&#xff0c;DDL&#xff08;Data Definition Language&#xff09;就像建筑师的蓝图。想象一下&#xff0c;你要建造一栋房子&#xff0c;如果没有先设计好房间布局、门窗位置&#xff0c;直接就开始砌墙会是什么结果&a…

作者头像 李华
网站建设 2026/10/27 13:52:31

CLIP模型微调实战:HyperCLIP技术与跨模态优化

1. 从HyperCLIP微调看CLIP模型调优的本质 CLIP模型作为跨模态学习的里程碑式成果&#xff0c;其"图文对齐"的核心思想早已深入人心。但当我们真正将其应用于具体业务场景时&#xff0c;预训练模型的"通用性"反而可能成为掣肘——就像给所有病人开同一种药&…

作者头像 李华
网站建设 2026/10/27 13:52:29

32位CEF 3.2357老工程嵌入实战:文件布局、初始化与NPAPI替代

简介&#xff1a;面向仍在使用 CEF 嵌入浏览器内核的 C 桌面客户端开发者、旧项目维护人员以及需要研究历史版本兼容性的技术团队&#xff0c;这份 Windows 32 位官方二进制包保留了 CEF 3.2357 分支&#xff0c;属于官方已经下架、仍支持 NPAPI 的最高版本&#xff0c;可用于兼…

作者头像 李华
网站建设 2026/10/27 13:51:13

ponytail浏览器插件实战:网页素材收集、整理与导出全指南

很多人第一次听到“ponytail”这个浏览器插件&#xff0c;都会冒出一个疑问&#xff1a;这名字跟代码、写作有什么关系&#xff1f;我第一次看到那个“马尾辫”图标时也愣了一下&#xff0c;顺手装来试了试&#xff0c;结果一用就是两个多月没卸。它本质上是个网页素材收集整理…

作者头像 李华