Java 锁机制深度解析(系列六):分布式锁
一、什么是分布式锁
1.1 为什么需要分布式锁
在单机时代,synchronized和ReentrantLock可以很好地解决多线程竞争问题——因为它们工作在同一进程内,JVM 或 AQS 可以协调线程的访问顺序。
但在分布式系统中,情况发生了变化:
1.2 分布式锁的定义
分布式锁是一种跨多个服务实例(进程)的同步机制,用于协调对共享资源的互斥访问。它的核心特征与单机锁类似,但需要解决网络不确定性带来的额外挑战。
1.3 分布式锁需要满足的条件
必要条件:
① 互斥性:同一时刻只有一个客户端持有锁
② 防死锁:持有锁的客户端崩溃后,锁能自动释放
③ 可重入:同一客户端可重复获取同一把锁
④ 高可用:锁服务本身不能成为单点故障
⑤ 高性能:获取和释放锁的开销应尽可能低
进阶条件:
⑥ 公平性:按请求顺序获取锁(可选)
⑦ 容错性:部分节点故障不影响锁的正确性
⑧ 可续期:长时间任务可延长锁持有时间
⑨ 可监控:支持查看谁持有锁、等待队列等信息
二、分布式锁的实现方案
目前主流的分布式锁实现方案有三种:
| 方案 | 代表实现 | 一致性保障 | 性能 | 复杂度 |
|---|---|---|---|---|
| 基于数据库 | 数据库行锁 / 乐观锁 | 强一致 | 低 | 低 |
| 基于 Redis | SET NX / Redisson / RedLock | 最终一致 | 高 | 中 |
| 基于 ZooKeeper | Curator / 临时顺序节点 | 强一致 | 中 | 高 |
2.1 基于数据库的分布式锁
2.1.1 方案一:数据库行锁(悲观锁)
-- 方案:使用 SELECT ... FOR UPDATE 实现互斥-- 前提:需要有一张锁表CREATETABLEdistributed_lock(lock_keyVARCHAR(128)PRIMARYKEY,lock_valueVARCHAR(64)NOTNULLCOMMENT'锁持有者标识',expire_atDATETIMENOTNULLCOMMENT'锁过期时间',created_atDATETIMEDEFAULTCURRENT_TIMESTAMP);
@ServicepublicclassDatabaseDistributedLock{@AutowiredprivateJdbcTemplatejdbcTemplate;/** * 获取锁(基于数据库行锁) */@TransactionalpublicbooleantryLock(StringlockKey,StringrequestId,longexpireSeconds){try{// ⭐ FOR UPDATE 锁定行,其他事务必须等待// 如果记录不存在,INSERT 也会隐式加锁jdbcTemplate.queryForObject("SELECT lock_value FROM distributed_lock WHERE lock_key = ? FOR UPDATE",String.class,lockKey);// 检查是否已过期(防止客户端崩溃导致锁不释放)jdbcTemplate.update("DELETE FROM distributed_lock WHERE lock_key = ? AND expire_at < NOW()",lockKey);// 插入或更新锁记录intupdated=jdbcTemplate.update("INSERT INTO distributed_lock(lock_key, lock_value, expire_at) "+"VALUES(?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND)) "+"ON DUPLICATE KEY UPDATE lock_value = VALUES(lock_value), "+"expire_at = VALUES(expire_at)",lockKey,requestId,expireSeconds);returnupdated>0;}catch(Exceptione){// 主键冲突或行锁等待超时returnfalse;}}/** * 释放锁 */@Transactionalpublicbooleanunlock(StringlockKey,StringrequestId){intdeleted=jdbcTemplate.update("DELETE FROM distributed_lock WHERE lock_key = ? AND lock_value = ?",lockKey,requestId);returndeleted>0;}}优缺点分析:
| 维度 | 评价 |
|---|---|
| ✅ 优势 | 实现简单,无需引入额外中间件;数据强一致(ACID 事务保障) |
| ❌ 劣势 | 性能差(行锁 + IO);数据库连接成为瓶颈;存在单点故障风险 |
| 适用场景 | 低并发、对一致性要求极高的场景(如财务对账) |
2.1.2 方案二:乐观锁(版本号)
-- 不单独使用锁表,而是在业务表上加版本号字段ALTERTABLEinventoryADDCOLUMNversionINTDEFAULT0;
// 乐观锁更新(CAS 思想)@Update("UPDATE inventory SET stock = stock - #{count}, "+"version = version + 1 "+"WHERE id = #{goodsId} AND stock >= #{count} AND version = #{oldVersion}")intdeductStock(@Param("goodsId")LonggoodsId,@Param("count")intcount,@Param("oldVersion")intoldVersion);适用场景:更新冲突概率较低的简单场景,如配置更新。
2.2 基于 Redis 的分布式锁
2.2.1 基础方案:SET NX EX
/** * Redis 分布式锁的基础实现 * 核心命令:SET key value NX EX timeout */publicclassRedisDistributedLock{privatefinalStringRedisTemplateredisTemplate;/** * 获取锁 */publicbooleantryLock(Stringkey,StringrequestId,longexpireMs){returnBoolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key,requestId,expireMs,TimeUnit.MILLISECONDS));}/** * 释放锁(Lua 脚本保证原子性) */publicbooleanreleaseLock(Stringkey,StringrequestId){StringluaScript="if redis.call('GET', KEYS[1]) == ARGV[1] then "+" return redis.call('DEL', KEYS[1]) "+"else "+" return 0 "+"end";Longresult=redisTemplate.execute(newDefaultRedisScript<>(luaScript,Long.class),Collections.singletonList(key),requestId);returnLong.valueOf(1).equals(result);}}这个基础方案存在的问题:
问题1:锁超时自动释放?
t0: 线程A 获取锁(过期时间 10 秒) t1: 线程A 业务执行时间超过 10 秒 t2: 锁自动释放 t3: 线程B 获取锁(认为锁空闲) t4: 线程A 业务完成,释放锁 → 释放了线程B 的锁! t5: 线程C 获取锁 → 线程B 和线程C 同时持有锁!问题2:主从切换锁丢失?
线程A 获取锁成功(主节点) 主节点宕机,锁信息未同步到从节点 从节点升级为主节点,没有锁记录 线程B 获取锁成功 → 线程A 和线程B 同时持有锁!
2.2.2 进阶方案:RedLock 算法
RedLock 由 Redis 作者 Antirez 提出,用于解决 Redis 主从切换的锁丢失问题。
核心思想:在 N 个(通常 N=5)独立的 Redis 节点上同时获取锁,只要超过半数(N/2 + 1)节点获取成功,就算获取锁成功。
/** * RedLock 算法核心逻辑 */publicclassRedLock{privatestaticfinalintNODES_COUNT=5;privatestaticfinallongCLOCK_DRIFT_FACTOR=0.01;// 时钟漂移因子privatefinalList<RedisLock>nodes;/** * 获取 RedLock 锁 */publicbooleantryLock(Stringkey,StringrequestId,longleaseTime,longwaitTime)throwsInterruptedException{longstartTime=System.currentTimeMillis();intsuccessfulCount=0;// ⭐ 阶段1:逐个尝试所有节点for(RedisLocknode:nodes){// 每个节点的获取超时 = 总等待时间 / 节点数longnodeWaitTime=waitTime/NODES_COUNT;if(node.tryLock(key,requestId,leaseTime,nodeWaitTime)){successfulCount++;}}// ⭐ 阶段2:检查是否超过半数 + 是否超时longelapsedTime=System.currentTimeMillis()-startTime;longdrift=(long)(leaseTime*CLOCK_DRIFT_FACTOR)+2;// 时钟漂移补偿if(successfulCount>=NODES_COUNT/2+1&&elapsedTime<leaseTime-drift){// 获取成功,有效持有时间 = 原始租期 - 已消耗时间returntrue;}// ⭐ 阶段3:获取失败,释放所有节点上的锁for(RedisLocknode:nodes){node.releaseLock(key,requestId);}returnfalse;}}RedLock 的争议:
| 观点 | 代表人物 | 核心论据 |
|---|---|---|
| ✅ 支持 | Redis 作者 Antirez | 在工程实践中,时钟漂移可控,RedLock 足够安全 |
| ❌ 反对 | 分布式系统专家 Martin Kleppmann | RedLock 依赖时钟同步,本质上是" fencing token "机制才是正确方案 |
Martin 提出的替代方案:使用fencing token(单调递增的令牌):
// ⭐ fencing token 方案// 1. 锁服务分配一个单调递增的 token// 2. 每次写操作携带 token// 3. 资源端拒绝 token 小于当前最大值的请求// 示例:带 fencing token 的分布式锁publicclassFencingTokenLock{privatefinalRedisTemplateredisTemplate;privatefinalAtomicLongtokenGenerator=newAtomicLong(0);publicFencedLockacquireLock(Stringkey){longtoken=tokenGenerator.incrementAndGet();booleanlocked=redisTemplate.opsForValue().setIfAbsent(key,String.valueOf(token),30,TimeUnit.SECONDS);if(locked){returnnewFencedLock(key,token);}returnnull;}// 使用时,所有写操作都必须携带 tokenpublicvoidwriteData(FencedLocklock,Objectdata){// ⭐ 资源端校验:token 必须大于最后一次写入的 token// 即使锁已经超时释放,过期的请求也会被 token 机制拦截dataStore.writeWithToken(lock.getToken(),data);}}2.2.3 生产级方案:Redisson
Redisson 在基础 Redis 锁的基础上,增加了看门狗自动续期、可重入、订阅通知等机制,是生产环境中使用最广泛的方案(已在系列前篇中详细展开,此处不再重复)。
2.3 基于 ZooKeeper 的分布式锁
2.3.1 原理:临时顺序节点 + Watcher
ZooKeeper 利用其临时顺序节点的特性,天然适合实现分布式锁:
/locks/my_lock/ ← 持久节点(锁的根路径) ├── lock_0000000001 ← 临时顺序节点(请求最早) ├── lock_0000000002 ← 临时顺序节点 ├── lock_0000000003 ← 临时顺序节点(请求最晚)锁获取规则:
- 所有客户端在
/locks/my_lock/下创建临时顺序节点 - 创建完成后,检查自己的节点序号是否为最小
- 如果是 → 获取锁成功
- 如果不是 → 监听前一个节点的删除事件,进入等待
2.3.2 Curator 实现
publicclassZooKeeperDistributedLock{privatefinalCuratorFrameworkclient;publicZooKeeperDistributedLock(StringconnectString){this.client=CuratorFrameworkFactory.builder().connectString(connectString).sessionTimeoutMs(60000).connectionTimeoutMs(15000).retryPolicy(newExponentialBackoffRetry(1000,3)).build();this.client.start();}/** * 使用 Curator 的 InterProcessMutex 实现分布式锁 */publicvoidexecuteWithLock(StringlockPath,Runnabletask){InterProcessMutexlock=newInterProcessMutex(client,lockPath);try{// ⭐ 尝试获取锁(可设置超时)if(lock.acquire(10,TimeUnit.SECONDS)){try{task.run();}finally{lock.release();// 释放锁 → 删除临时节点}}else{thrownewRuntimeException("获取分布式锁超时");}}catch(Exceptione){Thread.currentThread().interrupt();thrownewRuntimeException(e);}}/** * 读写锁实现 */publicvoidexecuteWithReadLock(StringlockPath,Runnabletask){InterProcessReadWriteLockrwLock=newInterProcessReadWriteLock(client,lockPath);InterProcessLockreadLock=rwLock.readLock();// 使用方式同 InterProcessMutex}}2.3.3 ZK vs Redis 分布式锁对比
| 维度 | ZooKeeper | Redis |
|---|---|---|
| 一致性模型 | 强一致(ZAB 协议) | 最终一致(主从异步复制) |
| 锁自动释放 | 临时节点,会话断开即删除 | 通过过期时间实现 |
| 死锁防护 | ✅ 天然防死锁(会话超时) | ✅ 过期时间防护 |
| 性能 | 中(磁盘写 + 选举) | 高(内存操作) |
| 可重入 | ✅ Curator 支持 | ✅ Redisson 支持 |
| 公平性 | ✅ 天然公平(按节点序号) | ❌ 默认非公平 |
| 客户端复杂度 | 高(需管理会话) | 低 |
| 羊群效应 | 有(但 Curator 已优化为只监听前一个节点) | 无(不涉及 Watcher) |
三、三种方案的全面对比
| 对比维度 | 数据库方案 | Redis 方案 | ZooKeeper 方案 |
|---|---|---|---|
| 实现难度 | ⭐ 低 | ⭐⭐ 中 | ⭐⭐⭐ 高 |
| 性能 | ⭐ 低(毫秒级) | ⭐⭐⭐ 高(微秒级) | ⭐⭐ 中(毫秒级) |
| 可靠性 | ⭐⭐ 中(DB 故障风险) | ⭐⭐ 中(主从切换丢锁) | ⭐⭐⭐ 高(ZAB 强一致) |
| 可用性 | ⭐⭐ 中 | ⭐⭐⭐ 高(哨兵/集群) | ⭐⭐⭐ 高(集群选举) |
| 自动续期 | ❌ 需自行实现 | ✅ Redisson 看门狗 | ❌ 但会话超时自动释放 |
| 防死锁 | ✅ 过期删除 | ✅ 过期时间 | ✅ 临时节点 |
| 运维成本 | ⭐ 低(复用 DB) | ⭐⭐ 中(需维护 Redis) | ⭐⭐⭐ 高(需维护 ZK 集群) |
| 适用规模 | 小 | 中到大 | 中到大 |
| 典型延迟 | 1-10ms | <1ms | 1-5ms |
性能基准数据(粗略参考)
场景:10 个客户端同时竞争一把锁,每个客户端持有锁 100ms 数据库方案:约 200 TPS ← 数据库连接和行锁是瓶颈 Redis 方案:约 5000 TPS ← 内存操作,高吞吐 ZK 方案: 约 800 TPS ← ZAB 协议写入延迟四、方案选择决策树
需要分布式锁吗? ├── 可以接受偶尔的并发问题? │ └── → 乐观锁(数据库版本号 / CAS) │ ├── 并发量低(< 100 TPS)? │ ├── 已有 MySQL 且不想引入新中间件 → 数据库行锁 │ └── 对一致性要求极高 → ZooKeeper │ ├── 并发量中高(100-10000 TPS)? │ ├── 可以接受极端情况下的锁丢失 → Redis SET NX(基础方案) │ ├── 需要自动续期、可重入 → Redisson(推荐) │ └── 完全不能接受锁丢失 → ZooKeeper / RedLock │ └── 超高并发(> 10000 TPS)? ├── 是否可以优化为无锁?→ 重试 / 异步队列 ├── 是否可以接受近似互斥?→ Lua + 乐观锁 └── 必须精确互斥 → Redisson + 本地缓存降级五、生产选型建议
场景一:中小项目,已使用 Redis
推荐:Redisson(Redis 方案)
理由:
✅ 已引入 Redis,零额外成本
✅ 性能高,微秒级延迟
✅ 看门狗自动续期,无需担心任务超时
✅ 支持可重入、公平锁、读写锁
❌ 极端情况(主从切换)可能丢锁
适用:大多数业务场景(订单、支付、任务调度)
场景二:金融级场景,数据一致性优先
推荐:ZooKeeper / Curator
理由:
✅ 强一致保证,锁信息绝不丢失
✅ 临时节点自动释放,无超时窗口
✅ 天然公平排队
❌ 性能低于 Redis
❌ 运维复杂度高
适用:分布式事务协调、选主、配置同步
场景三:简单互斥,不想引入中间件
推荐:数据库行锁(SELECT … FOR UPDATE)
理由:
✅ 复用现有数据库,零额外成本
✅ 事务保障,数据强一致
❌ 性能差,不适合高并发
❌ 数据库连接池可能被锁请求耗尽
适用:后台管理任务、定时报表、低并发互斥
场景四:高并发 + 可接受最终一致
推荐:Redis + 本地锁双检
理由:
✅ Redis 高性能处理大部分请求
✅ 本地锁缓存去重,减少 Redis 压力
❌ 极端情况可能有短暂不一致
适用:秒杀、活动、热点数据防护
// 双检锁模式(本地 + 分布式)publicclassHybridLock{privatefinalReentrantLocklocalLock=newReentrantLock();privatefinalRLockredisLock;publicvoidexecute(Runnabletask){// ⭐ 第一层:本地锁(快速失败)if(!localLock.tryLock()){thrownewRuntimeException("系统繁忙");}try{// ⭐ 第二层:分布式锁if(!redisLock.tryLock()){thrownewRuntimeException("系统繁忙");}try{task.run();}finally{redisLock.unlock();}}finally{localLock.unlock();}}}六、分布式锁的常见陷阱
陷阱一:锁超时导致并发
// ❌ 问题:锁过期时间太短RLocklock=redisson.getLock("myLock");lock.lock(5,TimeUnit.SECONDS);// 5 秒后自动释放// 业务执行了 10 秒 → 5 秒时锁已释放,其他线程进入// ✅ 正确:使用看门狗自动续期lock.lock();// 不指定 leaseTime,启用看门狗// 或者设置足够长的租约时间lock.lock(60,TimeUnit.SECONDS);// 确保业务能在 60 秒内完成陷阱二:锁 Key 设计不当
// ❌ 问题:Key 粒度过粗@DistributedLock(key="'lock:order'")// 所有订单共享一把锁// → 所有订单处理串行化,性能极差// ❌ 问题:Key 粒度过细@DistributedLock(key="'lock:order:item:' + #itemId")// → 同一订单的不同商品可以同时处理,但可能逻辑上需要互斥// ✅ 正确:Key 粒度与业务逻辑匹配@DistributedLock(key="'lock:order:' + #orderId")// → 同一订单互斥,不同订单并行陷阱三:锁未正确释放
// ❌ 问题:未在 finally 中释放publicvoidwrong(){lock.lock();// 如果这里抛异常,锁永远不会释放!doSomething();lock.unlock();}// ✅ 正确publicvoidcorrect(){lock.lock();try{doSomething();}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}}}陷阱四:锁的可重入问题
// ❌ 问题:基础 Redis SET NX 不支持重入publicvoidmethodA(){redisLock.lock("lockKey");methodB();// methodB 也尝试获取同一把锁 → 死锁redisLock.unlock("lockKey");}publicvoidmethodB(){redisLock.lock("lockKey");// SET NX 返回 false → 一直等待// ...redisLock.unlock("lockKey");}// ✅ 解决:使用支持重入的 RedissonpublicvoidmethodA(){redissonLock.lock();methodB();// 重入成功redissonLock.unlock();}陷阱五:Redis 主从切换丢锁
时间线: t0: 线程A 向 Redis 主节点获取锁成功 t1: 主节点宕机,锁未同步到从节点 t2: 从节点升级为主节点(没有锁记录) t3: 线程B 获取同一把锁成功 t4: 线程A 和线程B 同时认为自己持有锁! 解决方案: ① 使用 RedLock(多节点独立 Redis) ② 使用 ZooKeeper(强一致) ③ 业务层做幂等兜底(fencing token)七、总结
核心要点回顾
| 知识点 | 一句话总结 |
|---|---|
| 分布式锁的本质 | 跨进程的互斥机制,解决单机锁无法协调多节点的问题 |
| 数据库方案 | 简单但性能差,适合低并发场景 |
| Redis 方案 | 高性能,但存在主从切换丢锁的风险 |
| ZooKeeper 方案 | 强一致,但性能低于 Redis,运维成本高 |
| RedLock | 通过多数派决策提高 Redis 锁的可靠性,但存在争议 |
| 看门狗 | 自动续期机制,解决任务超时导致锁释放的问题 |
| fencing token | 从资源端保证写入安全,即使锁已经超时 |
| 双检锁 | 本地锁 + 分布式锁结合,减少分布式锁压力 |
最终选型建议
有 Redis → Redisson(看门狗 + 可重入 + 丰富功能)
强一致 → ZooKeeper(临时节点 + Watcher 机制)
零成本 → 数据库(FOR UPDATE / 乐观锁)
高可靠 → RedLock(多数派,至少 3 节点)
极致性能 → Redis Lua(简单场景,自己实现)
没有银弹:分布式锁没有"完美"的方案。Redis 快但不绝对可靠,ZooKeeper 可靠但不快,数据库简单但不高性能。理解业务对一致性的真实需求,比选择"最好的"锁方案更重要。