
很多团队第一次意识到“代码里的锁不管用了”往往发生在服务从单机部署切到多实例部署之后。单体时代写synchronized很顺手ReentrantLock也很好用但一旦服务同时跑在三台机器上同一个订单的两个请求可能分别落到不同实例每台机器都觉得自己没遇到并发冲突最后就会出现重复发券、重复扣库存、重复创建支付单这类问题。分布式锁就是为了在多个独立节点之间同步访问共享资源而引入的互斥机制。提SpringBoot Redis来做分布式锁几乎是目前一线团队最容易达成的共识。SpringBoot 已经是 Java 后端的事实标准骨架Redis 在多数项目里也早就承担着缓存、计数器、分布式会话这些职责在现有基础设施上加一个锁服务不需要申请新的存储资源也不需要引入额外维护成本。这篇文章我会把从原理选型到代码实现、再到真实踩坑点的完整过程讲清楚适合两类人看一类是项目里已经因为本地锁失效吃过亏想快速把分布式锁补上的开发者另一类是刚接触分布式锁想搞清楚 Redis 锁的边界和注意事项的读者。1. 整体设计思路与方案选型1.1 为什么优先选 Redis 而不是其他中间件分布式锁本质上解决的是“跨节点的互斥”问题有资格做这件事的系统其实不少。最简单的方案是继续用 MySQL靠select ... for update做悲观锁或者用版本号做乐观锁好处是基本不用学新东西坏处是数据库在高并发争抢锁时并发能力偏弱而且行级锁一个没控制好就容易拖垮主库性能。ZooKeeper 基于临时顺序节点配合 watch 机制实现锁可靠性和公平性都不错但代价是要维护好 ZooKeeper 集群本身对很多业务团队来说运维成本不太友好。etcd 的 lease 和事务机制同样能实现分布式锁强一致性好只是等于在技术栈里硬塞了一个新组件。Redis 的优势不在于理论上的最强可靠性而在于它通常已经部署好、已经有人运维、业务系统接入成本极低。Redis 里的SET命令配合NX和PX参数天然就能表示“在 key 不存在时才写入并设置过期时间”这就是一把分布式锁最原始的雏形。性能方面单次操作在微秒到毫秒级只要锁的访问频率可控完全不会成为系统瓶颈。所以在选型阶段如果项目里已经有 Redis并且并发互斥场景不是金融级别我会第一个推荐用 Redis 实现。当然也不是所有场景都适合。如果业务涉及资金账务、核心状态流转这类极端强一致的场景Redis 锁在极端情况下比如主从切换确实存在丢失锁的可能那就要考虑 ZooKeeper 或数据库并且必须配套对账和补偿机制。方案选型没有绝对的对错只有风险接受度的问题。1.2 主流分布式锁方案横评可以把几种常见方案放在一张表里看看心里会更清楚它们的定位方案实现方式可靠性性能和成本适用场景MySQL悲观锁for update/ 乐观锁版本号强一致数据库压力大实现简单低频操作、已有数据库且不想引入新组件ZooKeeper临时顺序节点 watch强一致需要额外维护 ZK 集群性能适中强一致性要求较高的分布式协调场景etcdlease 租约 事务强一致引入新组件运维成本偏高已有 etcd 环境的基础设施团队RedisSET NX PX/ Lua / Redisson多数场景够用极端场景可能丢锁性能高使用成本低高并发、能接受极小概率丢失锁的互斥场景从这张表能看出来Redis 的核心竞争力是“高性价比”。很多业务团队已经积累了 Redis 运维经验再引入一个重组件只是为了锁性价比确实不高。但选 Redis 不代表忽略可靠性而是要在方案设计时清楚它的边界在哪里并提前做好业务兜底。1.3 分布式锁必须满足的三个硬性指标做锁不能只停留在“能跑通”的层面站在使用者的角度一个靠谱的分布式锁至少要满足三件事。第一是互斥性任意时刻只有一个客户端能持有锁其他客户端不能同时进入临界区。这是锁的基本盘做不到互斥后面全免谈。第二是防死锁持有锁的客户端如果崩溃、服务宕机、线程被杀掉锁必须能在一段时间后自动释放否则整个系统会被一个异常节点拖死。这也是 Redis 锁必须有PX过期时间的原因。第三是防误删加锁时如果只存一个固定字符串线程 A 因为业务超时导致锁被自动释放线程 B 随后拿到了锁A 在 finally 里执行DEL时就会把 B 的锁误删掉。所以解锁必须校验“这把锁是不是我加的”通过唯一标识来确认归属。这三个指标听起来很基础但实现里的很多细节都是为了守住它们。2. 核心细节解析原子性、释放与过期时间2.1 加锁必须是一个原子动作网上很多老文章给的 Redis 加锁代码是先用SETNX写入 key再调用EXPIRE设置过期时间。单独看每条命令都没问题但如果把这两条命令拆开执行90% 的分布式锁事故都出在这两步之间。比如执行完SETNX之后业务线程因为 GC 停顿、网络抖动、进程崩溃等原因挂了EXPIRE根本没执行Redis 里就留下了一个永远不过期的 key这把锁就再也拿不到了这就是典型的死锁。正确做法是使用 Redis 2.6.12 之后提供的原子命令SET dist:lock:order:9527 6f8b2c1e NX PX 30000这个命令把“无则写入”和“设置过期时间”合并到了同一次运维中中间没有任何空档。后面在 SpringBoot 代码里我们可以直接用StringRedisTemplate.execute()执行 Lua 脚本来完成同样的效果也不只依赖某一个 Redis 版本的命令语法以后想加其他校验逻辑也更好扩展。2.2 解锁必须校验 owner且比对和删除必须原子如果加锁只是SETNX解锁时只是DEL会引入一个很隐蔽的问题。假设线程 A 加锁后业务执行时间过长锁提前过期了线程 B 加锁成功。A 忙完准备释放锁此时如果直接DEL删掉的就是 B 的锁。这个瞬间线程 C 也可以用同样的方式再拿到锁并进入临界区互斥性直接失效。解决办法是加锁时持有一个全局唯一的随机值通常用 UUID 生成解锁前先比对 Redis 里的 value 是不是自己存入的那个只有完全一致才执行删除。但这里还有一个隐藏问题“比对”和“删除”中间不能有间隔否则和加锁一样会出现竞态窗口。所以解锁必须用 Lua 脚本把GET和DEL合并在 Redis 服务端一次原子执行if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本看着简单却是防误删的关键。Redis 执行 Lua 脚本时不会插入其他命令所以 get 和 del 之间不可能被其他线程的请求打断。2.3 过期时间太短和太长都会出事Redis 分布式锁最终依赖过期时间兜底而怎么定这个时间是最容易纠结的点。设太短业务没跑完锁就自动释放A 还没结束B 已经进来锁形同虚设设太长一旦持有者真的宕机锁会长时间占住资源其它请求全部阻塞或失败。常规做法是先通过日志或压测统计临界区代码的最坏耗时再把过期时间定在估算值的 3 到 5 倍给足余量。比如一个接口正常耗时 200ms 左右但数据库偶尔慢查询会到 2 秒那把锁的过期时间设在 5~10 秒比较稳妥而不是盲目设成 30 秒。如果业务里有长耗时任务还需要“看门狗”机制做自动续期这个细节我在第四部分展开。提示过期时间本质上只是兜底方案。不要指望一个过期值让锁覆盖所有异常场景还应该通过熔断、超时控制、优雅停机等手段防止业务无限期占用锁。2.4 拿不到锁不应该立刻失败分布式锁的调用模式不是一棍子打死很多场景下拿不到锁就立刻抛异常并不合适。比如一个库存扣减接口同时来了 100 个请求只有一个能拿到锁其余 99 个如果全部返回“失败”用户体验会非常差。更好的做法是设置一个有界的等待时间在锁释放后的短时间内让线程重新尝试获取。常见实现是拿不到锁就让线程休眠一小段时间比如 50ms再继续 tryLock直到超过预设的最大等待时间。这里要注意自旋频率不能太高。如果 200 个线程同时自旋每隔 5ms 就打一次 RedisRedis 上会形成新的热点得不偿失。我习惯把重试间隔设为 50~100ms最大等待时间控制在 2~3 秒以内压测数据也证实这样对 Redis 的压力最小。超过等待时间后仍然拿不到锁再走降级逻辑比如返回“操作繁忙请稍后重试”或记录 MQ 消息做异步补偿。2.5 可重入到底要不要实现Java 里的synchronized和ReentrantLock都是可重入的同一个线程嵌套调用不会把自己锁住。但 Redis 锁默认不具备这个能力如果业务中有一个方法内部调用了另一个带锁的方法第二次tryLock会直接失败。实现可重入的思路是加锁时 value 结构改成“线程标识 重入次数”同一线程再次加锁时只要确认 owner 是自己就把计数加 1解锁时减 1减到 0 才真正删除 key。虽然代码能实现但我的经验是分布式锁的可重入要慎用。可重入会在代码里隐藏锁边界开发者很容易忘记锁的实际范围最后锁越套越深排查问题成本直线上升。宁可把锁粒度拆细让嵌套调用变成顺序调用或者把锁放到最外层统一管理。3. 实操过程SpringBoot 集成与完整代码实现3.1 项目准备与 Maven 依赖创建一个标准的 SpringBoot 项目然后在pom.xml里引入 Redis 相关依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependencySpringBoot 2.x 默认使用 Lettuce 作为 Redis 客户端配合 commons-pool2 可以开启连接池。如果你用的是 SpringBoot 3.x注意配置前缀从spring.redis.*变成了spring.data.redis.*细节不一样但整体原理一致。3.2 Redis 连接配置在application.yml里做如下配置spring: data: redis: host: 127.0.0.1 port: 6379 password: timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 0连接池的max-active一般不需要配得特别大16 到 32 之间足够。锁操作本身很快真正的瓶颈通常在业务临界区而不是 Redis 连接数。timeout建议设置 3 秒避免 Redis 故障时业务线程长时间阻塞在连接等待上。3.3 核心工具类实现接下来是最核心的锁工具类我封装了四个基础能力尝试加锁、阻塞加锁、释放锁、续期。package com.example.distlock; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; import java.util.Collections; import java.util.UUID; import java.util.concurrent.TimeUnit; Component public class RedisDistributedLock { private static final String LOCK_PREFIX dist:lock:; /** * 加锁 Lua 脚本原子执行 set key value NX PX * KEYS[1] 为锁keyARGV[1] 为客户端唯一标识ARGV[2] 为过期时间毫秒值 */ private static final String LOCK_SCRIPT if redis.call(set, KEYS[1], ARGV[1], NX, PX, ARGV[2]) then return 1 else return 0 end; /** * 解锁 Lua 脚本确认 value 匹配后删除 */ private static final String UNLOCK_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; private final StringRedisTemplate redisTemplate; private final DefaultRedisScriptLong lockScript; private final DefaultRedisScriptLong unlockScript; public RedisDistributedLock(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; this.lockScript new DefaultRedisScript(LOCK_SCRIPT, Long.class); this.unlockScript new DefaultRedisScript(UNLOCK_SCRIPT, Long.class); } /** * 尝试加锁立即返回。成功返回唯一标识失败返回 null。 */ public String tryLock(String key, long expireMs) { String token UUID.randomUUID().toString(); Long result redisTemplate.execute( lockScript, Collections.singletonList(LOCK_PREFIX key), token, String.valueOf(expireMs) ); if (result ! null result 1L) { return token; } return null; } /** * 阻塞加锁在最大等待时间内不断尝试 */ public boolean lock(String key, long expireMs, long tryWaitMs) throws InterruptedException { long start System.currentTimeMillis(); while (System.currentTimeMillis() - start tryWaitMs) { String token tryLock(key, expireMs); if (token ! null) { ThreadLocalHolder.setToken(key, token); return true; } Thread.sleep(50); } return false; } /** * 释放锁 */ public void unlock(String key, String token) { if (token null) { return; } redisTemplate.execute( unlockScript, Collections.singletonList(LOCK_PREFIX key), token ); } /** * 续期仅当 value 匹配时延长过期时间 */ public boolean renew(String key, String token, long expireMs) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) else return -1 end; Long result redisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(LOCK_PREFIX key), token, String.valueOf(expireMs) ); return result ! null result 0; } }这段代码里有几个关键点值得说明。tryLock成功后必须把 token 留在外层变量里后面unlock要用它做归属校验不建议把 token 存到 Redis 和业务数据无关的 ThreadLocal 里除非你做了非常严谨的清理逻辑。lock方法里的Thread.sleep(50)是我实测下来兼顾响应速度和 Redis 压力的折中值不建议低于 20ms。另外用StringRedisTemplate而不是RedisTemplateString, Object是为了避开 JDK 序列化器带来的各种隐性 bug这个小细节后面会专门讲。3.4 业务代码调用模板在业务方法中使用分布式锁时我推荐把锁调用抽象成一个固定模板避免每个开发写出来的释放逻辑都不一样。public boolean markOrderFinished(String orderId) { String lockKey order:finish: orderId; long expireMs 5000L; long waitMs 2000L; String token null; try { token lockService.tryLock(lockKey, expireMs); if (token null) { // 拿不到锁说明已有其他线程在处理该订单 log.warn(订单 {} 处理中请勿重复操作, orderId); return false; } // 模拟业务处理更新订单状态、记录操作日志等 doMarkFinished(orderId); return true; } finally { if (token ! null) { lockService.unlock(lockKey, token); } } }注意finally里只是释放自己持有的锁如果这里在中间断网或者返回前线程被 killRedis 的过期时间会兜底自动释放。异常路径下释放逻辑同样要执行但一旦出现网络异常unlock本身也可能失败这时只能靠过期时间保证锁最终可用。3.5 看门狗自动续期的实现思路如果临界区代码里有长耗时操作固定过期时间就不够用了。思路是启动一个守护线程每隔一段时间检查锁是否还在自己手里如果在就把过期时间往后推。public void lockWithRenewal(String key, String token, long expireMs) { ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(r - { Thread t new Thread(r, redis-lock-renewal); t.setDaemon(true); return t; }); scheduler.scheduleAtFixedRate(() - { boolean success lockService.renew(key, token, expireMs); if (!success) { // 锁已丢失或已被其他线程持有停止续期 scheduler.shutdown(); } }, expireMs / 3, expireMs / 3, TimeUnit.MILLISECONDS); }看门狗的续期间隔一般是过期时间的三分之一。比如锁过期时间 10 秒就每隔 3 秒续一次。这个方案比把过期时间盲目调到 30 秒更优雅因为锁在正常情况下会被及时释放不会长时间占用 Redis key但如果持有者真的挂了守护线程也会随之消亡过期时间仍然能兜底。3.6 快速验证方法与并发测试工具类写完以后建议先用一个简单的并发测试验证锁是否真的生效。比如编写一个接口内部执行Thread.sleep(100)模拟耗时操作然后用压测工具或 JUnit 并发模拟器发起 50 个并发请求观察日志中进入临界区的线程列表。验证时可以通过redis-cli观察锁 key 的写入与释放redis-cli --scan --pattern dist:lock:*锁生效时会看到dist:lock:order:finish:9527这样的 key 短暂出现业务结束后unlock将其删除。如果出现一个线程的锁还没释放、另一个线程也打印了“进入临界区”的日志说明锁的互斥性没生效优先检查 token 校验和 Lua 脚本是否执行正确。4. 常见问题与排查技巧实录4.1 业务还没执行完锁自己先过期了现象业务代码本身很慢或者数据库出现慢 SQL导致临界区执行时间超过锁的过期时间锁被 Redis 自动释放第二个线程提前进来。原因过期时间设置不合理或者业务耗时的估算严重偏离实际。锁的过期时间本质上是“最大兜底时间”不能指望它正好等于业务执行时间。解法统计接口最坏耗时将过期时间设为最坏耗时的 3~5 倍如果有稳定的长耗时操作直接升级为看门狗续期方案。还有一个很容易被忽略的点加锁后不要在临界区里调用 RPC 远程接口因为远程调用是不可控的可能等很久这个问题我踩过好几次。4.2 Redis 主从切换时锁丢失现象请求 A 在 master 节点上写入锁 key但这条数据还没来得及同步到 slave 节点时master 发生宕机slave 被提升为新的 master此时锁 key 不存在其他请求就能拿到锁导致互斥失效。原因Redis 的主从复制默认是异步的锁数据存在丢失窗口。Redis 官方文档也明确提到了这个风险。解法如果系统完全无法接受这种极端情况就要选择 ZooKeeper 或 etcd 这类强一致性的协调组件。Redis 锁在多数业务场景下是可以接受的因为这类“重复处理”问题通常可以通过幂等设计、唯一约束、对账任务等机制兜住。所谓 RedLock 算法本身也有争议不建议盲目引入它并不能彻底解决所有分区场景下的锁丢失问题反而增加了复杂度。4.3 Lua 脚本在 Spring Data Redis 里的序列化坑现象执行 Lua 脚本时报ClassCastException或返回结果类型不对最典型的是DefaultRedisScript传Long.class却返回了LinkedHashMap。原因如果使用RedisTemplateString, Object并且配置了 JSON 序列化器Redis 中的 value 会被反序列化成复杂对象。执行 Lua 脚本时传入的 key 和参数也会被序列化导致比对逻辑出错。解法在分布式锁场景下统一使用StringRedisTemplate。它的 key 和 value 默认都是 String配合 Lua 脚本传参和返回数字都非常稳定。我在实际项目中还遇到过因为序列化器不同导致同一个 key 在不同代码块里无法互相识别的案例所以这个问题必须从源头规避。4.4 自旋等待把业务线程池打满现象高并发期间大量线程阻塞在lock()方法里Tomcat 线程池快速耗尽系统出现大面积请求超时。原因等待时间过长或者重试间隔太短。核心问题不是锁本身而是锁竞争时没有做好流量控制。解法给lock()设置一个合理的最大等待时间比如 2 秒超过后快速失败自旋间隔保持在 50ms~100ms避免线程风暴更稳妥的做法是提前在网关或入口层做限流让真正进入业务的请求数量可控。记住分布式锁解决的是并发修改问题不是流量削峰问题。4.5 锁与事务一起使用时顺序颠倒现象在Transactional方法内部加锁方法结束立即释放锁但此时事务还没提交。下一个拿到锁的线程查到的仍是旧数据。原因事务的提交发生在方法返回之前如果锁在事务提交前释放就存在一个“锁已释放但数据未生效”的窗口。解法把锁放在事务外层。正确的调用链路是外层方法先加锁然后进入事务方法事务方法正常提交并返回后外层 finally 再解锁。如果项目里已有大量在事务内加锁的代码改造时把锁逻辑上提到 Service 层的外层包装方法里即可。4.6 常见问题速查表现象可能原因处理建议拿不到锁业务全部失败过期时间过长 / 持有者崩溃设置合理过期时间增加看门狗续期锁变量丢了解锁报空指针token 未保存或作用域不对在 try 之前声明finally 里判空释放锁被别的线程误删解锁没有校验随机标识使用 Lua 脚本 get del 原子比对执行 Lua 报类型异常RedisTemplate 序列化器不一致统一使用 StringRedisTemplate锁释放后数据还没更新事务提交晚于锁释放锁在外层事务在内层提交后再释放高并发时请求大量超时自旋等待过长或线程池打满设置有界等待时间入口限流最后分享一点个人体会我实际把这个方案落地到项目中后最强烈的感受是锁本身并不复杂真正需要反复打磨的是业务对锁边界和失败处理的理解。Redis 分布式锁不是银弹而是一个性价比很高的基础设施。它帮你挡住了 99% 的重复提交和并发覆盖问题但剩下的 1%必须靠业务幂等、数据对账、补偿任务来兜底。如果刚准备在 SpringBoot 项目里做分布式锁建议用这篇文章里的方案先跑通主流程把过期时间、token、Lua 脚本这几个细节写对再根据可靠性需求决定要不要引入更重的中间件。最后再分享一个小习惯每写一段加锁后的业务代码都问自己一句“如果从这里宕机锁会不会自动释放后面的补偿逻辑是什么”。这个问题想明白了锁相关的问题基本不会留下大隐患。
拿不准这条消息跟你有没有关系?
工种不同、批次不同,要求可能差很多。打电话把你的情况说清楚,我们按信阳、平顶山本地的口径给你捋一遍。