ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Redisson分布式锁实战:从原理到秒杀防超卖

SpringBoot+Redisson分布式锁实战:从原理到秒杀防超卖 在 SpringBoot 项目里做并发控制但凡踩过多次扣减超发、重复执行这类问题基本都会走到分布式锁这条路。我之前负责的秒杀活动单体应用一切正常拆成三个节点之后线上直接超卖排查到半夜才发现问题不在业务代码而在锁本身synchronized 和 ReentrantLock 都是 JVM 级别的锁每个节点只认自己进程里的线程多实例部署时完全管不住别的节点。后来接入了 Redisson把分布式锁这一块彻底夯实了这才算把并发问题真正收口。这篇文章我想结合自己真实落地项目的经历把 Redisson 这套东西从头到尾捋一遍。包括它到底是什么、分布式锁解决什么问题、可重入锁和读写锁的原理、SpringBoot 里怎么接入和封装以及线上排查时踩过的那些坑。准备接手相似项目的同学或者正在看分布式锁面试题、准备自己写一套锁方案的可以直接照着用。先提醒一句这个框架的官方拼写是 Redisson不是 Redission搜索和引入依赖的时候别搞错。1. 分布式锁解决的核心问题1.1 单机锁在多节点部署时为什么会失效很多人一开始容易想不明白我在方法上加个 synchronized 不就能防并发了吗为什么要绕这么大一圈做分布式锁道理其实很简单synchronized、ReentrantLock 这些锁的底层机制是 JVM 的 monitor 对象锁的信息只存在于当前 Java 进程的内存里。进程A的线程拿到了锁进程B的线程根本看不到这把锁的存在。举个我实际遇到的例子商品库存表里数量剩 10 件三个服务节点同时接到两个用户的购买请求。每个节点都执行synchronized (lockObject)来保护扣库存逻辑结果是 node1 扣了 1 件变成 9node2 扣了 1 件变成 9node3 扣了 1 件还是变成 9。三个节点同时读到初始值 10各自写回自己的结果最终库存可能是 9但实际卖出了 3 件。这就是经典的超卖。用生活场景类比单机锁相当于每家店自己设了一个排队护栏只能拦住自己店里的顾客拦不住从别的店门进来的顾客。所以只要服务是多实例部署或者同一个服务部署了多个副本JVM 内建的并发控制就天然失效。这个时候必须把锁放到一个所有节点都能访问到的公共组件上去让所有节点都遵守同一套规则。Redis 就是最常见的这个公共组件而 Redisson 则是把“用 Redis 实现分布式锁”这件事做得最完整的一套封装。1.2 哪些业务场景真正需要分布式锁不是所有并发问题都要上分布式锁但以下几个场景基本绕不开。我把实际工作中高频出现的场景整理出来方便对照。场景核心问题锁的作用电商秒杀、限时抢购库存扣减并发高多节点同时写库导致超卖对商品库存维度加锁串行化扣减动作分布式定时任务多个节点同时执行同一个 Job重复发放奖励/重复对账用锁实现“只有一个节点真正执行”接口防重提交用户双击、重复点击导致订单重复创建按用户业务单据维度加锁缓存重建缓存过期瞬间大量请求打到数据库造成缓存击穿只让一个线程去查库重建缓存分布式事务补偿多个服务同时修改同一份账户余额锁住账户资源避免数据覆盖这里要注意的是锁的粒度设计。我见过不少同学把所有业务都锁在一个全局 key 上比如lock:global这样虽然不会出错但并发度基本归零秒杀直接变成串行排队性能上完全不可接受。正确做法是按资源的维度拆锁比如product:stock:1001、order:user:8888让不同资源的操作互不干扰同一资源的操作才互相竞争。1.3 一把合格的分布式锁必须满足的条件业内讨论分布式锁绕不开几个基础要求。我在面试中也经常被问到这里展开说下自己的理解。第一是互斥性。任何时刻只有一个客户端能持有锁这是分布式锁存在的根本前提也是最容易验证的一点。第二是安全性也就是锁必须有自动释放机制。如果持锁的线程崩溃了、机器宕机了锁要能在一段时间后自动消失否则其他线程永远等不到锁整个系统就死锁了。第三是可用性加锁和解锁要足够快引入的延迟不能影响正常业务。如果为了一个锁操作增加几十毫秒耗时大部分场景是接受不了的。第四是高可用Redis 如果挂了锁服务不能跟着彻底瘫痪否则所有依赖锁的业务都会出问题。这四个要求里互斥性靠 Redis 的原子操作保证安全性靠过期时间可用性靠网络和连接池优化高可用靠部署架构。Redisson 把这四件事都封装好了这也是我选择它的核心原因。2. Redisson凭什么值得选2.1 Redisson到底是什么Redisson 是一个基于 Redis 的 Java 客户端中间件但它不是简单的 Jedis、Lettuce 那种基础客户端。Redisson 把 Redis 封装成了一个个开箱即用的分布式数据结构除了分布式锁之外还有分布式集合、分布式队列、分布式计数器和原子长整型等甚至还有分布式的 BloomFilter 和延迟队列功能相当全。在分布式锁这一块Redisson 提供的不只是最基础的加锁和解锁还有可重入锁、公平锁、读写锁、红锁、联锁、信号量等。这也意味着大多数分布式锁业务需求不用自己从零写逻辑直接调 API 就可以了。而且 Redisson 底层通过 Lua 脚本保证多条 Redis 命令的原子性省掉了自己组合 SETNX、EXPIRE 命令时的很多心智负担。我最初接触 Redisson 时用一个词评价它省心。很多细枝末节它都替你考虑好了比如自动续期、线程标识、释放时的安全校验。你要是自己写一套八成会在某个细节上出问题。2.2 为什么不要自己用SETNX手写分布式锁网上很多分布式锁教程会教用SETNXEXPIRE实现看起来很简单真实落地到处都是坑。我给大家罗列一下手写方案常见的几个问题第一个问题是命令的非原子性。SETNX成功之后EXPIRE还没来得及执行进程就崩了锁没有过期时间直接变成死锁。虽然可以用 Redis 的SET key value EX seconds NX一条命令搞定但后面还有更多问题。第二个问题是锁误删。线程A的锁过期了线程B重新获取了同一把锁A 的业务执行完后调用DEL把 B 的锁删掉了后续线程C又能进来互斥性完全失效。第三个问题是不可重入。同一个线程在嵌套方法里想再次获取同一把锁直接把自己阻塞死了。第四个问题是不可续期业务执行时间超过锁的过期时间锁提前释放另一个线程趁虚而入。这些问题不是靠打补丁就能完美解决的。Redisson 使用 Lua 脚本一次性完成“检查锁是否存在、判断当前线程、累加重入次数并续期”等操作整个流程原子化执行然后通过看门狗机制自动续期通过 hash 记录线程标识来解决误删和重入。对比下来自己写方案表面上是“轻量”实际上是把很多风险埋到了线上。维度手写 SETNX 方案Redisson加锁原子性需要小心组合命令Lua 脚本保证过期续期需要额外定时任务内置看门狗自动续期可重入需要自行维护计数基于 Hash 天然支持误删保护需要自己存储线程标识内置 UUID线程ID 校验读写锁/公平锁基本没有开箱即用2.3 SpringBoot 项目里如何快速接入接入方式不复杂但这里有个版本经验想分享。网络上很多教程推荐redisson-spring-boot-starter这个依赖它确实能自动装配但我实际用下来发现它对 SpringBoot 版本比较敏感不同版本下配置项的写法不一样容易踩兼容性的坑。我在项目里更倾向于手动配置一个RedissonClient的 Bean代码量不大反而更稳定可控。先引入核心依赖我使用的是 3.x 版本dependency groupIdorg.redisson/groupId artifactIdredisson/artifactId version3.17.7/version /dependency然后写一个配置类负责创建RedissonClient。以最常见的单机 Redis 为例Configuration public class RedissonConfig { Value(${spring.data.redis.host:127.0.0.1}) private String host; Value(${spring.data.redis.port:6379}) private int port; Value(${spring.data.redis.password:}) private String password; Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis:// host : port) .setPassword(password.isEmpty() ? null : password) .setDatabase(0); config.setLockWatchdogTimeout(30000); return Redisson.create(config); } }destroyMethod shutdown这个细节很重要Spring 容器关闭时能自动释放 Redisson 的连接池资源避免应用停机时连接残留。另外setLockWatchdogTimeout设置的是看门狗的超时时间默认就是 30000 毫秒如果你不确定自己的业务情况先用默认值问题不大后面我会详细讲。如果项目用的是哨兵模式和集群模式只需要改Config里的配置方式Redisson 本身对这些部署形态支持得很完整。集群模式下自动感知主从切换对锁的可用性提升也比较明显。3. 可重入锁原理拆解3.1 可重入锁的业务含义可重入锁这个词听起来高级理解起来其实很生活化。你把家门钥匙理解为一把锁进门的时候需要用钥匙进门之后你打开卧室的门不需要再掏钥匙证明一次身份。也就是说同一个线程已经持有一把锁的前提下它可以再次获取同一把锁不会被自己阻塞。在业务代码里这种情况非常常见。方法A加了Transactional内部调用了方法B方法B也尝试获取同一把分布式锁或者同一个服务里两层方法都走同一个加锁模板。如果锁不可重入第二次加锁直接把自己挡在门外死锁随之而来。Redisson 的可重入能力可以说是分布式锁的标配不能处理重入的分布式锁在复杂业务里基本不可用。3.2 基于Hash结构的锁计数实现Redisson 的可重入锁之所以能记录“哪个线程持有锁”、“重入了多少次”是因为它在 Redis 里存的是一个 Hash 结构而不是简单的字符串键值对。锁的 key 就是业务方传入的锁名称hash 的 field 是UUID:线程IDhash 的 value 是重入次数。这个设计非常巧妙UUID 保证不同客户端持有锁时标识不同线程 ID 区分同一客户端内部的线程重入次数则让同一线程的多次加锁能够正确嵌套。贴一段简化版加锁 Lua 脚本帮助理解-- KEYS[1] 为锁名称 -- ARGV[1] 为线程标识UUID:线程ID -- ARGV[2] 为锁过期时间 if (redis.call(exists, KEYS[1]) 0) then redis.call(hset, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return nil end if (redis.call(hexists, KEYS[1], ARGV[1]) 1) then redis.call(hincrby, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return nil end return redis.call(pttl, KEYS[1])这段逻辑分三种情况讨论锁不存在时直接创建锁并记数 1锁存在且 field 是当前线程时把重入次数加 1同时刷新过期时间锁存在但 field 不是当前线程直接返回剩余过期时间表示加锁失败。整个过程用 Lua 脚本一步完成Redis 单线程执行脚本的特性保证了这一步不会被其他指令穿插。解锁是加锁的逆过程核心逻辑是先把重入次数减 1只有减到 0 才真正删除锁 key。如果没减到 0说明外层还有加锁锁要继续保留。这个“只有最后一次解锁才删除 key”的机制就是可重入锁能够嵌套使用的根本原因。3.3 看门狗自动续期机制如果你调用lock()方法时不指定租约时间leaseTimeRedisson 会启动一个看门狗机制。默认锁的超时时间是 30 秒看门狗每 10 秒检查一次锁是否仍然持有如果还在持有就把锁的超时时间重新刷回 30 秒说白了就是动态续期。这个设计解决了一个非常实际的问题加锁的同时设置一个固定过期时间比如 10 秒但业务在锁内跑了 30 秒锁在业务结束前就自动释放了另一个线程进来重复执行业务就乱了。而有了看门狗只要业务线程还活着锁就不会因为超时被释放线程崩溃之后看门狗也会跟着消失锁最多在 30 秒后自动释放不会死锁。但是有一条值得注意如果你在调用时显式传入了 leaseTime比如lock(10, TimeUnit.SECONDS)看门狗就会停止工作锁严格按照 10 秒过期。这意味着你需要对业务耗时有一个比较准确的预估否则锁会被提前释放。我的建议是能预估耗时的场景尽量传 leaseTime作为兜底保障无法预估的复杂链路就用不带参数的lock()让它自动续期同时设置一个合理的看门狗超时时间防止业务线程卡死却一直续期。4. 读锁与写锁的实现和场景4.1 读写锁的互斥矩阵与直观理解读写锁是分布式锁家族里容易被低估的一个角色。它把锁分为读锁和写锁两种遵循的规则可以总结成一句话读读共享、写写互斥、读写互斥、写读互斥。翻译成业务语言就是多个线程可以同时拿读锁但写锁和任何其他锁都不能同时共存。生活化的类比是高铁候车厅的大门普通乘客进站可以多个人同时刷身份证进去这叫共享读锁但工作人员要维护闸机谢绝乘客进入这叫独占写锁。读锁的存在意义是提升并发度如果所有读操作都用互斥锁系统的吞吐量会被严重浪费。写锁的价值则是保证写入强一致写的时候绝对不允许任何读操作读到中间数据。4.2 Redisson 读锁和写锁的底层逻辑Redisson 的读写锁 API 封装在RReadWriteLock里用法非常直接RReadWriteLock rwLock redissonClient.getReadWriteLock(config:lock); RLock readLock rwLock.readLock(); RLock writeLock rwLock.writeLock();读锁和写锁的加锁过程底层都走了 Lua 脚本只是判断逻辑不同。写锁加锁时会检查锁 key 里是否存在读锁模式只要存在就等待读锁加锁时则会检查是否存在写锁模式存在就等待。两种锁分别有自己的计数器读锁是一个计数器累加的共享锁写锁则是基于可重入锁实现的排他锁。我在源码里让读锁和写锁共用同一个锁名称但内部用模式字段区分当前锁的状态。写锁存在时读锁会一直阻塞等待写锁释放读锁存在时写锁也会等所有读锁释放完毕才拿到锁。这样既保证了读的高并发又守住了写入的独占性真的是解决读多写少类场景的利器。4.3 读写锁的适用场景与避坑我在项目中用读写锁处理过一个商品配置缓存更新的需求。用户请求商品详情时读取配置信息并发量很大全是读操作运营后台偶尔修改配置触发缓存重建。理论上读操作根本不冲突如果全部用互斥锁一次缓存更新就会把大量读请求全部挡住。改成读写锁之后读请求之间完全并行只有真正更新缓存时才短暂介入排他锁线上接口时延下降非常明显。不过读写锁也有一个经典坑锁的升级问题。比如先拿到读锁然后在业务逻辑中尝试获取写锁这个操作会形成读锁等待写锁、写锁等待读锁释放的循环等待直接造成死锁。Redisson 不支持读锁升级为写锁口语点说你不能拿着一把共享钥匙突然想着把所有人拦在外面。遇到这种需求要么一开始就用写锁要么把读写锁拆成两个步骤设计。另一个要注意的是锁粒度切分。如果是同一条数据且读多写少读写锁的收益很大但如果是毫无关联的一批数据各自用独立 key 加锁比全局读写锁更合适。锁的范围越小系统并发能力越强。5. 实战落地从工具类到秒杀扣库存5.1 封装一个可复用的分布式锁模板直接把redissonClient.getLock()的代码散落在各个 Service 里会让代码显得很乱加锁、判空、释放的逻辑重复三遍后就没人愿意维护了。我在项目里习惯封装一个轻量的工具模板把公共逻辑抽出来。Component public class RedisLockTemplate { Resource private RedissonClient redissonClient; public T T execute(String lockKey, long waitTime, long leaseTime, TimeUnit unit, SupplierT action) { RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(waitTime, leaseTime, unit); if (!locked) { throw new RuntimeException(系统繁忙稍后重试); } return action.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(获取锁被中断, e); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这个模板把加锁、获取锁失败的处理、锁释放全部收敛到一个方法里。业务代码需要做的只是传入锁 key 和业务逻辑可读性和复用性都好了很多。isHeldByCurrentThread()这个判断很多人容易忽略它保证了只有当前线程持有的锁才会被释放结合 hash 里的线程标识能有效防止误删其他线程的锁。5.2 秒杀扣库存的完整实现用一个具体的扣库存例子来演示。这里我简化了 Mapper 部分重点展示分布式锁怎么嵌入业务逻辑public boolean deductStock(Long productId, Integer count) { String lockKey product:stock: productId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { log.warn(获取锁超时productId{}, productId); return false; } Integer stock stockMapper.selectStock(productId); if (stock null || stock count) { log.warn(库存不足productId{}, stock{}, productId, stock); return false; } int rows stockMapper.decreaseStock(productId, count); return rows 0; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }这里我传入了等待时间 3 秒和租约时间 10 秒。为什么这么设等待时间是允许业务线程在锁冲突时最多等多久3 秒对于用户点击一次秒杀足够租约时间是锁的最大存活时限10 秒对于一个“查库存更新库存”的数据库操作绰绰有余同时防止数据库抖动时锁被长时间占用。虽然 Redisson 有看门狗但在明确知道业务耗时很短时显式设置租约时间反而更可靠。还有一个细节是查库存和扣库存必须都在锁内执行。如果把selectStock放在锁外查锁内再扣库存判断的依据就是过期的数据锁等于白加。这个顺序错位是很多线上事故的真正来源。5.3 事务、锁与异常处理的执行顺序Spring 的事务和分布式锁一起使用时有一个容易被忽视的先后顺序问题。常见写法是在 Service 方法上加Transactional方法内部先加锁然后执行数据库更新最后在 finally 里解锁。问题在于事务的提交动作发生在方法返回之后也就是说锁释放的时候事务可能还没提交其他线程拿到锁后读到的是未提交的数据。举个实际例子线程A在事务里把库存从 10 扣到 9然后释放锁此时事务还没提交线程B立刻拿到锁查库存时发现还是 10随后执行扣减又扣了一个 1数据库层面可能出现不可重复读和丢失更新的风险。解决思路有几个。第一种是不要在方法末尾依赖事务自动提交而是把事务边界往前提确保提交完成后才释放锁。第二种是拆分两层外层方法负责加锁和解锁内层真正带事务的业务逻辑在锁内执行这样事务提交会在解锁之前完成实际测试下来更稳。第三种是用编程式事务TransactionTemplate把事务提交动作显式控制在锁内逻辑最清晰。从经验来看分布式锁和事务结合最容易出事的地方就在提交与解锁的边界。宁可多写几行代码把顺序控制清楚也不要因为图省事留下数据不一致的隐患。6. 线上踩过的坑与排查技巧6.1 锁丢失与主从切换的抉择分布式锁有一个绕不开的隐患Redis 主从架构下的锁丢失。想象一个场景线程A在主节点上拿到了锁写操作还没同步到从节点主节点突然宕机从节点被提升为新的主节点。此时线程B去新主节点加锁发现锁不存在也成功拿到了同一把锁。两个线程同时持有锁分布式锁的互斥性瞬间失效。业界有个方案叫 RedLock基于多个独立 Redis 节点投票确认来避免单点下的锁丢失但其正确性一直存在争议。我的看法是大部分业务场景用不到 RedLock 这种级别的方案。普通秒杀、任务防重场景业务本身可以通过数据库乐观锁、幂等表做兜底分布式锁只是第一道防线。如果真到了并发写入且数据极度敏感的场合更稳妥的选择是对接 ZooKeeper 或 etcd 这类强一致性协调组件用 Redis 做分布式锁时保持合理的过期时间同时接受极小概率下的锁丢失并做好业务兜底。线上排查锁异常可以先看 Redis 里锁 key 是否存在、TTL 是多少、hash 里有多少 field。HGETALL product:stock:1001一下就能看出锁被哪个线程持有重入了几次。这套检查命令在事故发生时非常管用。6.2 锁误删和死锁的防范锁误删这个问题手写 SETNX 方案非常容易出现Redisson 虽然内置了线程标识但使用姿势不对同样会翻车。最常见的是在 finally 里直接调用lock.unlock()不做任何判断。如果当前线程根本没有持有锁却执行了 unlock 方法Redisson 会抛出 IllegalMonitorStateException影响主流程。我的习惯是每次释放锁都判断isHeldByCurrentThread()并且把获取锁的结果单独用变量记下来。在工具类里统一处理释放逻辑不让业务代码直接碰 lock 对象这样能最大限度避免释放错误。另外业务代码里加锁后出现异常一定要走 finally 释放锁。如果忘记释放锁只能靠过期时间自动清理而这个空窗期内所有相关线程都会卡住表现为接口大量超时。死锁还有一个容易被忽略的成因多个锁的获取顺序不一致。线程A先拿锁1再拿锁2线程B先拿锁2再拿锁1两个线程互相等待对方释放锁形成交叉死锁。Redisson 的锁跟 JVM 锁一样没有强制顺序需要开发者在设计时约定全局加锁顺序或者用tryLock设置等待超时避免无限阻塞。6.3 压测与性能调优建议分布式锁引入之后性能调优也是必须做的一步。Redisson 的连接池默认参数在低并发下够用但高并发压测时连接池配置往往成为瓶颈。单机模式下可以适当调大connectionMinimumIdleSize和connectionPoolSize同时关闭一些不必要的模块以减少连接占用。从业务侧看缩小锁内代码的执行时间是提升并发能力的直接手段。锁内只保留真正需要串行的操作比如查库存、扣库存其他如日志上报、消息通知、风控校验全部移出锁外。锁的粒度也要精细能用商品级就尽量不要用商家级能用订单级就尽量不要用用户级。一次压测中我把锁 key 从商家维度改成商品维度QPS 直接翻了近一倍。压测工具我常用 JMeter 配合 Redis 监控一起跑。压测过程中重点盯两块一个是 Redis 的慢查询日志一个是锁等待失败的日志量。如果大量请求在 tryLock 阶段返回失败说明锁冲突严重需要进一步缩小锁粒度或者优化业务结构。7. 关于分布式锁我最后想说的几件事分布式锁不是银弹这是我最想强调的一点。能用数据库乐观锁解决的并发问题不要引入分布式锁能用幂等设计避免的重复提交也不要先想着加锁。分布式锁本身会带来额外的 Redis 依赖和运维成本使用不当还可能引起新的数据不一致。我个人在实际操作中的体会是Redisson 最大的价值不是那几行加锁 API而是它把“获取锁、判重、续期、安全释放”这条链路用工程化的方式打磨完整了。你不需要懂 Lua 脚本的细节也能正常使用但理解了可重入和看门狗原理之后遇到锁超时、锁失效的问题时才能精准定位。建议每一个准备在 SpringBoot 项目中使用分布式锁的人都先把这篇里的原理部分吃透再动手写代码。最后再分享一个技巧线上一定要把锁 key 的命名规范定好统一前缀加业务模块加资源 ID后续排查问题时能少走非常多的弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进