1. 为什么分布式锁是Java面试必问点?
去年帮团队面试中级Java开发时,我连续面了12个候选人,有9个在分布式锁问题上翻车。最典型的场景是:当被问到"你们的分布式锁怎么实现的",得到的回答往往是"用Redis的setnx命令"——这种回答在5年前可能还能过关,但现在只会暴露候选人对分布式系统理解的浅薄。
分布式锁之所以成为面试高频考点,本质上是因为它像一块试金石:
- 考察对并发编程的理解深度(从JVM锁到分布式锁的认知跃迁)
- 验证Redis/ZooKeeper等中间件的实战经验
- 评估解决分布式系统典型问题的思维体系
我见过最漂亮的回答来自一个有大厂背景的候选人:他先画出了CAP三角形,然后说:"根据我们的业务场景,我们选择了CP型的ZooKeeper锁而不是AP型的Redis锁,因为订单系统对一致性要求更高..." 这种回答立刻让面试官看到其知识体系的完整性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis分布式锁的演进之路
2.1 第一代:setnx + expire的致命缺陷
2015年我们团队第一次实现分布式锁时,代码简单到令人发指:
java复制Boolean result = jedis.setnx("lock_key", "1");
if(result) {
jedis.expire("lock_key", 30);
// 业务逻辑
jedis.del("lock_key");
}
这个方案有三个致命伤:
- setnx和expire不是原子操作,可能设置锁后还没设置过期时间就宕机
- 锁过期时间评估不准可能导致提前释放(A还没执行完,B就拿到了锁)
- 直接del可能误删其他线程的锁(A超时后B获取锁,A执行完又把B的锁删了)
2.2 第二代:Lua脚本解决原子性问题
Redis 2.6引入Lua脚本后,我们改进为:
lua复制if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
虽然解决了原子性问题,但锁误删和过期时间问题依然存在。我们曾因此导致订单重复创建——支付系统在锁超时后仍然执行了重复扣款。
2.3 第三代:Redlock算法的争议
Redis作者提出的Redlock算法要求:
- 获取当前毫秒级时间戳T1
- 依次向N个独立Redis节点请求加锁
- 当在(N/2 + 1)个节点上加锁成功,且总耗时小于锁有效期时才算成功
- 锁的实际有效时间 = 初始有效时间 - 获取锁总耗时
但分布式系统专家Martin Kleppmann撰文指出该算法存在时钟漂移风险。我们的实践是:在不需要绝对强一致性的场景(如秒杀库存扣减)使用Redlock,而在金融交易场景改用ZooKeeper。
3. Redisson分布式锁源码精析
3.1 看门狗机制揭秘
Redisson最精妙的设计在于看门狗(watchdog)——一个后台线程会每隔10秒(锁过期时间的1/3)检查客户端是否还持有锁,如果是则延长锁有效期。核心源码片段:
java复制private void renewExpiration() {
if (expirationRenewalMap.containsKey(getEntryName())) {
// 异步执行续期
RFuture<Boolean> future = commandExecutor.evalWriteAsync(
getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getName()),
internalLockLeaseTime, getLockName(threadId));
future.addListener((FutureListener<Boolean>) future -> {
if (future.isSuccess() && future.get()) {
// 递归调用实现持续续期
renewExpiration();
}
});
}
}
关键点:续期检查使用hexists而非get,确保只有锁持有者能续期。internalLockLeaseTime默认30秒,因此看门狗每10秒触发一次。
3.2 可重入锁实现原理
Redisson通过Redis的Hash结构实现可重入:
code复制HGETALL myLock
1) "b983c153-7421-469a-addf-6da0fbb75c17:1"
2) "2" # 重入次数
加锁时的Lua脚本:
lua复制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]);
3.3 解锁时的"信号量"设计
解锁时会发布Redis消息通知其他等待线程:
lua复制if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
其他线程通过subscribe监听这个channel,实现类似Java中wait/notify的机制。
4. ZooKeeper分布式锁的强一致性实现
4.1 临时顺序节点实战
ZooKeeper实现锁的核心在于临时顺序节点:
java复制public void lock() throws Exception {
// 创建临时顺序节点
currentPath = zk.create(rootPath + "/lock-",
null,
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL);
// 获取所有子节点并排序
List<String> children = zk.getChildren(rootPath, false);
Collections.sort(children);
// 判断当前节点是否是最小节点
int index = children.indexOf(currentPath.substring(rootPath.length() + 1));
if (index == 0) {
return; // 获取锁成功
} else {
// 监听前一个节点
String prevPath = rootPath + "/" + children.get(index - 1);
CountDownLatch latch = new CountDownLatch(1);
Stat stat = zk.exists(prevPath, event -> {
if (event.getType() == EventType.NodeDeleted) {
latch.countDown();
}
});
if (stat != null) {
latch.await();
}
}
}
4.2 羊群效应解决方案
原生方案存在"羊群效应"——当锁释放时,所有等待线程都会收到通知,导致大量无效的getChildren请求。改进方案是只监听前一个节点,形成等待队列。
我们曾用ZooKeeper锁实现分布式选主,关键优化点:
- 使用Curator框架的InterProcessMutex,它内置了重试机制
- 设置合理的sessionTimeout(建议10-30秒)
- 添加ConnectionStateListener处理连接断开事件
5. 分布式锁的黄金十二问
根据我参与的200+场面试,整理出最高频的分布式锁问题:
- Redis锁过期时间设置多久合适?(答:建议业务耗时的3-5倍,配合看门狗机制)
- 如何避免锁被其他线程释放?(答:value使用唯一标识,删除前先校验)
- Redis主从切换会导致锁失效吗?(答:会,这是Redis作为AP系统的固有缺陷)
- 为什么ZK更适合CP场景?(答:ZAB协议保证写请求全部落盘后才返回)
- 锁重入怎么实现?(答:Redis用hash结构记录线程ID和重入次数)
- 等待锁的线程如何避免忙等待?(答:Redisson用pub/sub,ZK用watch机制)
- 分布式锁一定比JVM锁慢吗?(答:通常慢1-2个数量级,但适用场景不同)
- 如何实现锁的公平性?(答:ZK顺序节点天然公平,Redis需要队列辅助)
- 锁释放失败怎么办?(答:设置合理的过期时间,最终一致性)
- 什么是惊群效应?如何解决?(答:大量线程被唤醒竞争,用队列串行化)
- 分布式锁用在哪些场景?(答:秒杀、订单处理、定时任务调度等)
- 除了Redis和ZK,还有哪些实现方式?(答:etcd、数据库乐观锁等)
6. 真实生产环境踩坑录
6.1 Redis锁导致的数据不一致
2020年我们电商大促时,库存服务出现超卖。复盘发现:
- 商品A库存100,100个请求同时抢锁
- Redis主节点写入锁成功,但还未同步到从节点时主节点宕机
- 哨兵选举新主节点后,部分请求在新主节点上又获取到锁
- 最终导致库存减了120次
解决方案:
- 改用Redisson红锁(RedLock),要求多数节点加锁成功
- 最终兜底方案:数据库唯一索引 + 乐观锁
6.2 ZK锁的sessionTimeout陷阱
我们的监控系统曾发生ZK锁死锁,原因是:
- 默认sessionTimeout是60秒
- GC停顿导致ZK服务端在40秒内没收到心跳
- 服务端主动断开连接,删除临时节点
- 但客户端恢复后继续执行业务逻辑
调整策略:
java复制CuratorFrameworkFactory.builder()
.connectString(zookeeperAddress)
.sessionTimeoutMs(30000) // 30秒
.connectionTimeoutMs(15000)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();
7. 性能优化实战技巧
7.1 Redis锁参数调优
在千万级QPS的秒杀系统中,我们这样优化Redisson:
java复制Config config = new Config();
config.useClusterServers()
.addNodeAddress("redis://127.0.0.1:7001")
.setPingConnectionInterval(5000)
.setLockWatchdogTimeout(30000); // 看门狗超时
// 针对高并发场景优化
config.setNettyThreads(32)
.setExecutor(Executors.newFixedThreadPool(64));
关键参数:
- lockWatchdogTimeout:看门狗检查间隔
- nettyThreads:网络IO线程数
- executor:业务线程池
7.2 ZK锁的预加载优化
我们发现ZK的getChildren操作在大规模并发下性能较差,采用两种优化:
- 预加载子节点列表并缓存
- 使用Curator的InterProcessSemaphoreMutex替代原生实现
优化后锁获取时间从平均120ms降到35ms。
8. 如何回答"分布式锁"面试题
当面试官问"说说你对分布式锁的理解"时,建议采用金字塔式回答:
- 先定义:"分布式锁是控制分布式系统互斥访问共享资源的机制"
- 说痛点:"解决JVM锁在分布式环境失效的问题"
- 讲实现:"主要有Redis、ZK、etcd三种主流方案"
- 对比差异:
- Redis:AP系统,性能高但可能不一致
- ZK:CP系统,可靠性高但性能较低
- 谈细节:"比如Redis的看门狗机制,ZK的临时顺序节点"
- 联业务:"我们电商系统用Redis锁处理秒杀,用ZK锁做任务调度"
最后可以反问:"您更想了解某个具体实现细节,还是应用场景?" 这样既展示知识体系,又掌握面试主动权。
