架构师必备:分布式锁方案选型

📅 2026/7/28 14:38:08
架构师必备:分布式锁方案选型
当同时操作共享资源时需要做并发控制。在单机上用synchronized或ReentrantLock能解决的互斥问题到了多机部署的分布式环境就行不通了因为它们只在单个JVM进程内生效跨多机就需要分布式锁了。先说结论最常用的分布式锁方案是Redis锁、DB乐观锁、或DB逻辑锁。高并发、能容忍极端情况下短暂的弱一致选Redis锁并发不高、强一致要求高选DB乐观锁、或DB逻辑锁不推荐DB行锁、ZK锁。下面展开介绍。一把合格的分布式锁要满足什么先对齐一下标准后续每种方案的好坏都可以对照互斥最基本的要求任意时刻只能有一个客户端持有同一把锁防死锁持有锁的客户端如果宕机了锁能自动释放防止其它客户端永远加锁失败可重入同一个客户端在持锁期间能再次拿到这把锁避免自己阻塞自己性能加锁、解锁开销要小吞吐要高悲观锁和乐观锁悲观锁觉得并发冲突大概率会发生所以操作前先加锁把资源独占起来操作完再释放。典型实现DB行锁select ... for update、DB逻辑锁通过新增lock_status状态字段实现、Redis的set nx px命令优点严格互斥不用像乐观锁那样反复重试缺点持锁期间其它线程只能等、有死锁风险、吞吐被锁的粒度和持有时间卡住适用场景适合写冲突频繁的场景乐观锁觉得冲突很少所以一开始不加锁等更新时再判断中途是否被其它线程修改了。典型实现version字段、CAS先比较再修改优点无锁实现开销较低缺点并发冲突较多时需要不断重试可能有ABA问题但用递增的version能解决仅CAS比较值则不行适用场景适合读多写少的场景如果写并发很高但误用了乐观锁的话会导致线程不停地重试最终线程池耗尽的严重问题两个并发更新请求的时序图如下上图展示了乐观锁的流程业务入口服务本身无存储、数据都在下游存储服务上查询时一并拿到业务数据、系统version回写时带上这个version做校验。这样能解决一个典型问题两个并发请求因网络抖动乱序到达存储服务先到的请求反而后处理导致旧数据覆盖新数据。有了乐观锁校验version不匹配的更新会失败从而避免旧覆盖新。主流方案选型1. DB行锁不推荐原理 借助InnoDB的排他锁在事务里执行select ... for update对查到的行加行锁事务提交或回滚后释放。别的事务再尝试上DB行锁时会阻塞住。实现细节必须在数据库事务里用否则for update一执行完就释放了起不到持锁的作用where条件必须命中主键或唯一索引否则会锁表、或者产生大段间隙锁数据库事务内不要做耗时操作比如调外部RPC接口设置合理的锁等待超时innodb_lock_wait_timeout别让被阻塞的请求一直干等最佳场景低并发、强一致、已有DB不想引入新中间件 比如公司内部系统的定时任务多个实例都可能触发该定时任务需要保证同一时间只有一个实例在跑。2. DB乐观锁原理 不靠数据库自带的行锁而是在已有业务表里新增一个version字段依靠版本号、乐观锁来实现。实现细节先查询得到版本号v1然后更新时再判断version是否为版本号v1即update ... where biz_id? and version?最后判断影响行数如果大于0则说明修改成功如果是0则说明中途被别的线程修改了需要重试-- 通用更新带上version条件影响行数为0则说明被并发修改需重试 UPDATE biz_record SET name #{name}, status #{status}, version version 1 WHERE biz_id #{bizId} AND version #{version};对应的Java示例// 业务代码查询版本号 - 带version更新 - 判断影响行数失败则重试 public boolean updateBiz(String bizId, String name, int status) { int maxRetry 3; for (int i 0; i maxRetry; i) { // 1. 先查询得到版本号v1 Biz biz bizMapper.selectByBizId(bizId); // 2. 更新时带上version条件即 where biz_id? and versionv1 int affected bizMapper.updateByBizId(bizId, name, status, biz.getVersion()); // 3. 影响行数大于0说明成功为0说明被别的线程改过version变了重试 if (affected 0) { return true; } } throw new BizException(并发冲突更新失败); }最佳场景中等并发、读多写少、能接受DB的性能上限 比如库存扣减、余额扣减、业务状态流转并发冲突不算特别频繁乐观锁足够。又比如核心链路上减少不必要的外部依赖不用Redis锁、只用DB乐观锁毕竟DB的稳定性要高于Redis。3. DB逻辑锁原理 不靠数据库自带的行锁而是在业务表里新增lock_status状态字段用一条带条件的UPDATE抢占锁抢到就置为占用状态释放再置回空闲状态。靠DB写操作的行级一致性保证同一时刻只有一个客户端抢成功是悲观锁。实现细节加锁update ... set lock_status1 where biz_id? and (lock_status0 or lock_expire_atnow())影响行数大于0即加锁成功解锁时带上lock_owner校验防止误删其它线程上的锁防死锁持有者宕机没正常释放状态会一直占用所以要加lock_expire_at过期时间靠定时任务清理过期锁、或持有者续期-- 加锁状态空闲、或已过期才能抢到同时记下持有者和过期时间 UPDATE biz_record SET lock_status 1, lock_owner #{owner}, lock_expire_at #{expireAt} WHERE biz_id #{bizId} AND (lock_status 0 OR lock_expire_at NOW()); -- 解锁带上 lock_owner 校验防止误删别人的锁 UPDATE biz_record SET lock_status 0, lock_owner NULL, lock_expire_at NULL WHERE biz_id #{bizId} AND lock_owner #{owner}; -- 兜底定时任务清理持有者宕机导致的过期锁 UPDATE biz_record SET lock_status 0, lock_owner NULL WHERE lock_status 1 AND lock_expire_at NOW();对应的Java示例// 加锁影响行数大于0即成功 public boolean tryLock(String bizId, String owner, long leaseMs) { Date expireAt new Date(System.currentTimeMillis() leaseMs); return bizMapper.acquireLock(bizId, owner, expireAt) 0; } // 释放锁带上 owner 校验影响行数大于0即释放成功 public boolean unlock(String bizId, String owner) { return bizMapper.releaseLock(bizId, owner) 0; }最佳场景中等并发、需要持久化锁状态 比如跨多个服务、多个步骤的长流程需要持锁一段时间、且锁状态要落库重启不丢、可审计追溯。DB逻辑锁持锁不绑事务、状态可持久化比DB行锁灵活。4. Redis锁单节点redis锁原理 依靠Redis单线程串行执行的特点用SET key value NX PX timeout一条命令原子完成“键不存在才设置 设置过期时间”。设置成功就算拿到锁过期时间一到自动释放、防死锁。实现细节value必须是唯一标识比如UUID释放锁时先比对再删防止误删其它线程上的锁释放锁需要保证原子性比对value、与删除是两步要用lua脚本包成原子操作锁续期业务执行时长不可控过期时间设短了业务没跑完锁就释放了设长了宕机后锁仍然占用。Redisson用看门狗机制解决后台线程每隔一段时间自动检查、并续期锁的TTL直到业务跑完、并主动释放锁可重入用Hash结构记下持有者和重入次数同一客户端就能多次获取举例手写Redis锁// 加锁NX保证互斥PX设置过期时间value用唯一标识防误删 public boolean tryLock(String lockKey, String requestId, long expireMs) { return OK.equals(jedis.set(lockKey, requestId, NX, PX, expireMs)); } // 解锁用lua脚本保证“比对删除”的原子性 public boolean unlock(String lockKey, String requestId) { String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; return jedis.eval(lua, Collections.singletonList(lockKey), Collections.singletonList(requestId)).equals(1L); }生产环境考虑直接用Redisson框架可重入、锁自动续期、lua脚本都封装好了RLock lock redisson.getLock(distributedLock: bizId); try { // 尝试加锁最多等待10s锁自动续期 if (lock.tryLock(10, TimeUnit.SECONDS)) { doBusiness(); } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }最佳场景高并发写场景、能容忍极端情况下短暂的弱一致 比如秒杀场景防重复提交、消息消费的幂等处理。并发大要求加锁解锁快能接受Redis主从切换那一瞬间极小概率的锁失效必须要做兜底的业务幂等校验。多节点RedLock锁不推荐Redis的高可用是靠主从异步复制来实现的。有个经典问题客户端A在master加锁成功锁还未同步到slave此时master宕机了slave升成新master客户端B加锁也能成功。于是A、B同时持锁产生并发冲突。针对这个Redis作者提出了RedLock向n个一般5个互相独立的Redis节点申请锁多数n/21成功才算加锁成功少数节点挂了不影响。 但这个方案引入了复杂度、牺牲了性能在GC暂停或网络延迟下仍然有边界问题就不推荐了。单节点Redis锁配合业务幂等兜底已经能解决核心问题了兼顾了性能和正确性。5. ZK锁不推荐原理 靠ZK的临时顺序节点。客户端在/lock路径下创建一个临时顺序节点/lock/seq-00000001然后取出/lock下所有子节点排个序看自己是不是序号最小的是最小的拿到锁不是最小的就监听前一个节点的删除事件前一个节点一删自己被唤醒再判断一次释放锁就是删掉自己创建的节点。客户端宕机导致session失效ZK会自动删掉它建的临时节点锁跟着释放。最佳场景对一致性要求极高、能接受性能和运维代价 比如集群选主节点宁可慢也不能错。在业务场景中就不用考虑了更适合用于集群运维场景比如Hadoop、Kafka那套大数据生态。总结方案一致性性能实现复杂度防死锁最适合的场景DB行锁悲观锁不推荐强一致低低需配置超时低并发强一致、已有 DB内部系统的定时任务DB乐观锁乐观锁强一致中高低无死锁中等并发、读多写少库存扣减、余额扣减、业务状态流转等DB逻辑锁悲观锁强一致中中靠过期兜底中等并发、需持久化锁状态Redis锁悲观锁弱一致高中靠过期续期高并发写场景、可容忍极端弱一致秒杀、幂等ZK锁不推荐强一致低高自动释放集群选主不要在业务场景使用对照上面的表格就能快速找到合适的方案。优先考虑DB乐观锁、DB逻辑锁、Redis锁不推荐DB行锁、ZK锁。