先讲个我实际经历过的场景。凌晨零点刚过数据批处理任务触发我负责的系统有四个应用实例同时收到调度命令。如果代码不做任何控制同一批订单会被四个节点各跑一遍结果是报表重复发送、订单状态被覆盖、库存扣成负数。最后查问题的时候大家先怀疑代码写重了但代码里明明只有一个定时任务。真正的原因是分布式环境下同一个任务在每个节点上都会执行一次。解决这个问题最顺手的方式就是用 Redisson 的分布式锁把任务锁住确保同一时间只有一个节点真正执行。这篇文章会把 Redisson 锁实现分布式任务的完整链路讲清楚为什么需要分布式锁、Redisson 内部帮你做了什么、代码怎么落、不同任务场景怎么选锁粒度以及我踩过的一些坑。如果你是后端开发正在处理多实例部署下的定时任务、批处理任务或者消息消费防重下面的内容可以直接参考。1. 先理清分布式任务为什么需要一把分布式锁1.1 一个定时任务四个实例四个重复的“债”很多团队一开始是单机部署一个 Spring Boot 应用跑一个Scheduled定时任务一切正常。后来为了高可用部署变成了两台、四台甚至更多实例前面再挂负载均衡。这时候问题就来了定时任务不是负载均衡调度的而是每个节点各自触发。也就是说一个 cron 表达式到了时间点所有节点会一起执行同一个方法。我见过最典型的翻车现场是订单超时自动关闭。单机时代一条 SQL 更新所有超时订单没问题。上了多实例以后四个节点同时执行这个 SQL虽然最终效果也差不多但中间态会乱订单状态被反复更新日志里全是重复操作甚至有的节点先查到了订单还没超时就把状态改了另一个节点又把它改回去。最怕的是如果任务里涉及对外发送通知、调用支付退款接口重复执行就是重复扣款、重复发短信这个“债”几行日志根本还不清。所以要意识到分布式任务的核心问题是“多个执行者同时抢同一份工作”。代码本身写得再正确也无法避免多实例并发执行导致的不一致。你需要一个机制让所有节点对“谁先执行”达成共识。1.2 分布式锁解决的是哪一类问题分布式锁解决的就是互斥问题。用一个通俗的类比公司只有一个会议室谁先拿到钥匙谁进去开会开完再还钥匙。其他人在外面等着或者本轮放弃。在分布式任务里Redis 就是那个钥匙架Redisson 就是帮你管理钥匙的服务哪台节点拿到钥匙哪台执行任务。但要注意分布式锁并不是万能的。它解决的是“互斥”不解决“幂等”。举个例子两个节点同时抢同一把锁抢到锁的节点执行任务把订单状态从“待支付”改成“已关闭”。如果任务执行到一半节点宕机了锁自动释放另一个节点拿到锁继续执行它查到的订单状态可能已经是“已关闭”这时候就需要业务状态机去兜底。我后面会专门讲这个组合。1.3 对比数据库唯一约束、ZK 临时节点和 Redis 锁选型的时候我见过不少团队纠结到底用数据库锁、ZooKeeper 锁还是 Redis 锁我自己的判断标准比较简单数据库唯一约束适合单条数据的防重比如订单号唯一、任务批次唯一。但它和业务表耦合遇到高并发频繁插入会有明显压力。ZooKeeper 临时节点适合对一致性要求极高的场景比如分布式协调、选主。缺点是部署和维护成本高Java 客户端 API 相对笨重。Redis 锁胜在性能高、实现简单、Redis 本来就是大多数团队已有的基础设施。Redisson 锁相当于把 Redis 锁的细节封装好还带看门狗续期代码侵入小适合标准 Java 服务。我用一个表格大致对比一下方案优点缺点适用场景数据库唯一约束简单可靠和业务强相关吞吐有限耦合业务表单业务防重任务批次去重ZooKeeper 锁强一致没有锁过期问题运维成本高API 复杂分布式选主、强一致协调手写 Redis SETNX实现快性能好过期时间、释放逻辑都要自己做临时应急非核心链路Redisson RLock封装完善支持续期和重入引入第三方库Redis 挂了影响业务大多数分布式任务互斥场景如果你们项目已经用了 Redis我建议优先考虑 Redisson。没必要为了分布式锁单独引一套 ZooKeeper也不要为了省依赖去手写山寨锁后面会看到手写锁的坑远比你想象的多。2. Redisson 分布式锁的核心机制我建议你至少懂这些2.1 加锁不是简单的 SET NX很多入门文章教你的 Redis 分布式锁是这样的执行SET key value NX PX 30000设置一个 30 秒过期时间谁设置成功谁就拿到锁。这套逻辑在单点 Redis 下能跑但有几个隐患第一锁没有重入能力同一线程想再次获得锁会被自己挡住第二删除锁的时候要判断 value 是不是自己设的否则可能把别人的锁误删第三过期时间设短了任务没跑完锁没了设长了锁一直占着。Redisson 的底层实现远比这完整。它用 Lua 脚本保证加锁和续期操作的原子性锁的数据结构也不是简单字符串而是一个 Hash。简单理解是这样-- 加锁如果锁不存在创建锁并设置线程标识和过期时间 if (redis.call(exists, KEYS[1]) 0) then redis.call(hincrby, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; -- 如果锁存在但当前线程持有重入次数加一 if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(hincrby, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; -- 否则返回剩余过期时间 return redis.call(pttl, KEYS[1]);这个 Hash 的外层 key 是锁名称内部 field 是 UUID 线程 IDvalue 是重入次数。为什么要这么设计因为这样可以精确区分“哪台机器的哪个线程持有了锁”释放锁的时候也只能由持有者本人释放其他线程无法误删。重入次数则让同一线程在嵌套代码里反复加锁而不会死锁。理解这一层以后排查锁相关问题时你会非常感谢 Redisson 的设计。2.2 看门狗续期让你的锁不会中途消失手写 Redis 锁最怕一件事任务执行时间超过锁的过期时间锁提前释放另一个节点冲进来重复执行。Redisson 解决这个问题靠的是看门狗机制。默认情况下你把锁的时间设为 30 秒Redisson 会启动一个后台定时任务每隔 10 秒检查一次只要锁还在当前线程手里就把过期时间重新续成 30 秒。只要持有锁的线程没死锁就相当于“无限期”地陪着任务跑完。但这里有个关键点我见过很多同事搞混如果你在调用tryLock时手动指定了 leaseTime看门狗就不会生效。Redisson 的逻辑是leaseTime 为 -1 时启用看门狗一旦你给了具体值它就认为“你不需要自动续期”到期直接释放。所以如果任务逻辑非常长了要么你估算好时间设一个足够大的 leaseTime要么干脆不传这个参数让看门狗盯着。我自己写任务脚本时习惯把任务本身拆小配合看门狗使用而不是完全依赖看门狗兜底。2.3 可重入、公平锁、读写锁按需选择Redisson 的RLock默认是可重入锁这也是绝大多数任务场景够用的锁类型。同时它还提供了公平锁FairLock、读写锁ReadWriteLock、红锁RedissonRedLock这些扩展。我实际用下来分布式任务里很少需要红锁。红锁要求在多台独立 Redis 节点上同时加锁只有超过半数成功才算持有锁主要是为了防止主从切换时锁丢失。但它的代价是部署复杂、性能低而且大部分业务场景根本到不了那种一致性级别。如果只是定时任务互斥、消息防重普通可重入锁配合合理设计已经足够。读写锁偶尔有用。比如一个任务同时做“刷新配置”和“读取配置”允许多个节点并发读但不允许读的时候有人写。这种场景里用 Redisson 的读写锁能减少锁冲突。但说实话我接触的大多数任务系统里读写冲突没那么频繁用普通锁更容易维护。3. 用 Redisson 锁实现分布式任务完整代码实践3.1 依赖引入与 Redisson 客户端配置如果项目是 Spring Boot最简单的方式是引入redisson-spring-boot-starter。我常用的版本是 3.x稳定性和文档都比较成熟。dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.5/version /dependency配置上可以直接使用 Spring Boot 的 Redis 配置也可以单独定义一个RedissonClientBean。我倾向于显式定义因为可以更清楚地控制连接池大小和超时参数。Configuration public class RedissonConfig { Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionMinimumIdleSize(10) .setConnectionPoolSize(50); return Redisson.create(config); } }生产环境如果用的是 Redis 集群改用config.useClusterServers()即可。我这里用单机示例是为了方便本地调试集群部署下代码逻辑没有任何区别。3.2 定时任务加锁的核心写法下面这段代码是我常用的模板。整体思路每个定时方法对应一个固定 lockKey多个节点同时触发时只有抢到锁的节点执行其他节点提前返回。Service public class BatchJobService { private static final Logger log LoggerFactory.getLogger(BatchJobService.class); private final RedissonClient redissonClient; public BatchJobService(RedissonClient redissonClient) { this.redissonClient redissonClient; } Scheduled(cron 0 0 0 * * ?) public void scanExpiredOrders() { String lockKey job:scan:expired-orders; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(5, TimeUnit.SECONDS); if (!locked) { log.info(订单扫描任务已被其他节点执行当前节点跳过); return; } doScanExpiredOrders(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(订单扫描任务获取锁被中断, e); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } } private void doScanExpiredOrders() { // 批量查询超时订单执行状态流转 } }这里有几个细节要特别注意。第一个我使用的是tryLock(5, TimeUnit.SECONDS)意思是等待 5 秒拿不到就放弃。如果直接用lock()方法线程会一直阻塞等锁节点一多其他节点可能全部挂在等待锁的路上任务调度会异常。第二个finally里必须判断locked lock.isHeldByCurrentThread()。因为tryLock可能失败而失败时当前线程没有持有锁直接调用unlock()会抛出IllegalMonitorStateException。第三个catch InterruptedException后要恢复线程中断状态这是 Java 并发编程的基本素养避免吞掉中断信号。3.3 锁、状态机与任务进度三者要联动拿到锁只是第一步业务上还得防重复。我遇到过这样的事一个订单状态流转任务节点 A 拿到锁后执行到一半数据库连接超时代码抛出异常锁释放。节点 B 立刻拿到锁重新执行任务。如果任务内部没有“先查状态再更新”的逻辑B 节点可能把 A 节点刚处理过的订单又处理一遍。正确的做法是锁负责互斥业务状态机负责幂等。例如if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 加锁成功后先查一次任务状态 Order order orderMapper.selectById(orderId); if (order.getStatus() ! OrderStatus.WAIT_CLOSE) { // 已经被其他节点处理过直接返回 return; } // 更新状态标记为处理中 orderMapper.updateStatus(orderId, OrderStatus.CLOSING); // 执行真正的关闭逻辑 doCloseOrder(orderId); } finally { lock.unlock(); } }用锁挡住并发入口用订单状态挡住重复执行这两者结合才能真正让任务安全。特别是任务里要调用外部接口的情况下没有状态机兜底外部接口可能会收到大量重复请求。3.4 减小锁竞争本地预留判断在某些高并发场景所有节点任务同时到达大家都去抢同一把锁Redis 的压力会瞬间变大。虽然 Redisson 内部已经做了优化但我在实践中发现一个更简单有效的办法在抢锁之前先做一个本地判断只有满足条件的节点才参与抢锁。比如任务执行前把一个“当前任务是否正在运行”的标记写进本地内存或者先查一次任务的状态表如果发现任务已经处于“处理中”连抢锁都不必了。这个技巧不是替代分布式锁而是减少无效的锁竞争。用生活话说会议室门口先看一眼灯亮不亮亮着就先不拿钥匙了灯灭了我再去拿钥匙开门。当然本地判断得接受一个事实每个节点的本地状态不共享所以它只能降低 Redis 请求量不能替代锁的正确性。真正保证互斥的一定是最后的加锁判断。4. 三种典型分布式任务场景锁的用法完全不同4.1 定时批量扫描任务锁要短任务要能续跑最常见的是定时扫描任务比如每 5 分钟扫描一次超时未支付的订单或者每小时清点一次失败消息。这类任务的特点是数据量大、执行时间可能波动、失败后要能重跑。我用的是最直接的全局锁一个任务一把锁抢到锁的节点执行全量扫描。这种场景下锁的粒度要“短平快”。如果任务执行要 2 分钟就不要设 10 分钟的锁时间执行完了立刻释放其他节点才有机会处理后续的新任务。另外如果扫描本身很大我会把扫描拆成多页每处理一页更新一次游标万一中途失败下次任务可以从上次游标继续。锁只负责“同一时间只有一个人在跑”进度管理交给数据库记录。4.2 消息驱动任务防重复消费但不能拖垮吞吐消息队列场景和定时任务不太一样。比如订单支付成功后会发一条 MQ 消息多个消费者实例都会收到这条消息如果不加控制每个实例都会去执行“更新订单状态、扣库存、发通知”这一串操作。这里可以用分布式锁防重复消费但锁的粒度必须按业务主键来。如果消费者代码里用一把全局锁处理所有消息那吞吐量会直线下降因为所有消息都被串行处理了。正确的做法是按订单号、用户 ID 或消息 ID 加锁String lockKey job:consume:order: orderId; RLock lock redissonClient.getLock(lockKey); if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // 消费消息业务逻辑 } finally { lock.unlock(); } }这样不同订单的消息可以并行处理只有同一个订单的消息才会互斥。Redis 锁本身就是为高频短操作设计的按业务维度拆锁是发挥它性能优势的关键。所以我反复强调锁 key 千万别全写死成一个字符串除非你的业务确实要求所有任务全局排队。4.3 人工触发重任务按业务维度拆细锁管理后台常常有这种按钮重新生成某个用户的报表、手动执行某笔订单的退款、重跑某个商户的对账。用户手一抖可能点两下或者运维人员同时在不同页面触发同一个操作任务就会重复执行。这类任务如果用全局锁会出现一个商户的重任务把整个后台任务通道堵死其他商户的任务全部排队。后来我改成按用户维度加锁job:report:userId同一个用户的报表任务互斥不同用户完全并行。这样既防了重复点击又不会让无关任务互相影响。如果任务还会拆分子任务我建议再加一层任务批次 ID 的锁。例如用户 A 的重跑任务可能拆成 10 个子任务每个子任务可以用job:report:userId:batchId:subtask加锁粒度更细可回收性也更好。5. 实操中的常见问题与排查思路5.1 锁拿到了任务怎么没执行有段时间我收到一个诡异反馈某个定时任务偶尔不执行但不执行也没关系因为别的节点执行了。后来发现问题不在锁而在tryLock的等待时间设得太短。如果当前节点的任务调度时间到了但另一个节点还在处理上一轮任务锁还没释放当前节点等待 5 秒后直接放弃返回看起来就像“任务没执行”。这其实是预期行为。如果你确实希望任务一定执行可以把 waitTime 调大一点或者接受“本轮跳过下一轮再处理”。我一般会加监控如果连续 N 次都没抢到锁说明任务执行时间可能已经超过调度周期需要人工介入。排查的时候先看日志里有没有already running on another node类似的关键字再看任务耗时基本能定位。5.2 锁瞬间失效任务还是重复跑了重复执行是最难查的问题。有一次我们一个对账任务突然重复发起退款查了半天发现 Redis 发生过主从切换。当时加锁是在旧主节点上完成的主节点挂掉后从节点还没来得及同步锁数据就被提升为新主节点另一个节点在新主节点上重新加锁成功于是两把锁同时生效。这是 Redis 主从架构下分布式锁的天然缺陷。另一个常见原因是手写了锁的过期时间但没有续期。任务执行超过锁的过期时间后锁自动释放其他节点就能再抢锁。排查思路先看监控里 Redis 的锁 key 是否存在再看任务执行日志的时间戳是否重叠最后确认锁是否用的 Redisson 的看门狗模式。如果是集群部署且对一致性要求极高就只能考虑 RedLock 或 ZooKeeper 锁不过常规任务系统里遇到主从切换的概率不高保持监控告警基本够用。5.3 锁竞争严重任务耗时暴涨锁竞争导致的性能问题最直接的罪魁祸首是锁粒度太粗。比如我就是全局锁所有业务任务都抢同一把锁并发一高其他节点全在等待。排查时先看 Redis 的redis.call(pttl)相关调用量或者直接看任务方法平均执行时间和 P99 耗时。解决方案我基本按三步走。第一步确认锁 key 是否需要按业务维度拆分比如按用户、订单、商户拆。第二步本地缓存或状态判断过滤掉明显不需要抢锁的任务。第三步检查是不是tryLock等待时间太长导致大量线程堆积。锁是手段不是目的目的是让系统不重复执行如果锁本身成了瓶颈就要重新设计粒度。5.4 大事务 分布式锁经典组合坑这是个很隐蔽的坑。有的同事会把数据库事务和 Redisson 锁写在同一个方法里锁还没释放事务先提交了或者锁释放了事务还没提交。比如Transactional public void processOrder(Long orderId) { RLock lock redissonClient.getLock(job:order: orderId); lock.lock(); try { // 业务操作 } finally { lock.unlock(); } }问题在于Transactional的事务提交发生在方法返回之后而finally里的unlock()在方法返回之前执行。也就是说锁释放了但事务可能还没提交其他节点马上抢到锁读到的还是旧数据。我自己踩过这个坑后现在的做法是把锁逻辑放到事务外层或者手动控制事务边界确保事务提交完成再释放锁。更简单的理解是你不希望锁住了“更新操作”但没锁住“更新结果的可见性”。6. 如果有人问起分布式锁这几个问题值得提前备好6.1 Redisson 相比手写 Redis 锁到底多做了什么这个问题几乎每次都会被问到。手写SET NX锁最多做到“原子加锁 过期时间”但后续的续期、释放判断、重入逻辑都要自己写。Redisson 把这些都做成了标准能力Lua 脚本实现原子操作、Hash 结构记录持有者、看门狗自动续期、可重入计数。一句话总结手写锁是在用 Redis 能力模拟锁Redisson 是在用成熟的分布式锁模型封装 Redis。两者差距在并发量小的时候不明显一旦任务执行时间长、节点数量多Redisson 的稳定性和可维护性会好非常多。6.2 主从切换为什么会让锁丢失回答这个问题建议先讲现象再讲原理。主从架构下锁数据写在主节点从节点通过异步复制同步数据。如果主节点刚完成加锁就宕机从节点此时还没有锁数据但它会被提升为新主节点其他线程就能在新主节点上加锁成功。本质上是“加锁成功”这个事实没有同步到新主节点锁的一致性被打破了。RedLock 的思路是在多个独立节点上加锁超过半数成功才算成功但实际部署成本高多数业务不需要为了极小概率的主从切换付出这么大的代价。6.3 ZooKeeper 锁与 Redisson 锁怎么选这是个经典的选型问题。ZooKeeper 用临时顺序节点实现锁锁是强一致的只要客户端会话还在锁就存在客户端宕机会话结束锁自动消失不存在锁忘记释放的问题。Redis 锁性能更高部署简单但存在主从切换等极端场景下的锁丢失可能。我一般这样回答如果系统追求极致的可用性和吞吐Redis 锁优先如果业务对一致性要求极高且可以接受额外的运维成本ZooKeeper 锁更合适。普通互联网业务里Redis 锁是主流选择。6.4 面试问到“分布式锁使用场景”怎么答不要只背“防止重复执行”这种泛泛答案。我建议从三个方向展开第一幂等场景比如消息重复消费、定时任务多节点重复调度用锁保证同一数据同一时间只有一个执行者。第二资源抢占场景比如分布式环境下的唯一订单号生成、多节点抢单、任务调度选主。第三分布式事务中的状态机流转比如订单状态从待支付到已关闭只能用锁保证状态流转的原子性。回答时带上真实场景比如“我在做订单超时关闭任务时发现多实例会同时修改订单状态”比单纯背概念让人信服得多。7. 我踩过坑之后的几点实在体会最后分享几个自己实际踩出来的经验。第一个不要以为有了分布式锁任务就一定幂等。锁只能保证同一时间只有一个节点在跑但它保证不了业务状态更新会不会覆盖别人数据。我在做任务时会先用数据库状态或者版本号兜底锁解决互斥状态机解决幂等。第二个锁的 key 一定要稳定最好带上业务前缀千万别把时间戳放进 key 里否则每个节点锁的都不是同一把锁整段代码等于白写。第三个看门狗听起来很美好但也不是万能。如果任务里调用了外部系统阻塞时间远超锁时间最好给任务设一个合理的 leaseTime 并配套告警而不是完全依赖自动续期。第四个体会是关于监控的。分布式锁代码能不能正常工作最终还是要靠日志和监控来说话。我会在每个任务的加锁、解锁、放弃锁三个位置都打上日志并配上“抢锁失败次数”和“任务执行耗时”的监控指标。这样线上出了重复执行或者任务不执行的问题至少能快速定位到是锁的问题还是业务的问题。分布式锁只是一个工具把它用对、看得见才能让你的分布式任务真正稳定跑起来。