分布式系统下的锁实现与最佳实践
📚 目录导读
- 什么是跨节点锁机制
- 为什么需要跨节点锁
- 常见的跨节点锁实现方案
- 基于Redis的跨节点锁编写步骤
- 基于ZooKeeper的跨节点锁示例
- 跨节点锁的常见问题与解决方案
- 总结与建议
什么是跨节点锁机制?
在分布式系统中,多个服务实例(节点)运行在不同的物理或虚拟机上,它们需要协调访问共享资源(如数据库记录、文件、缓存等),跨节点锁(Distributed Lock)就是一种跨机器、跨进程的互斥机制,确保在任意时刻只有一个节点能持有锁并执行关键操作。
问答:为什么不能直接用单机锁(如synchronized、ReentrantLock)?
答:单机锁仅在同一JVM或同一进程内有效,跨节点环境下,不同进程的内存空间隔离,锁状态无法共享,两个微服务实例同时修改用户余额,若只用本地锁,必然导致数据不一致。
为什么需要跨节点锁?
- 防止数据竞争:多个节点同时写入同一数据,避免覆盖或脏读。
- 保证幂等性:如订单去重处理,确保重复请求只生效一次。
- 控制资源并发:限制对有限资源(如数据库连接池中的特定连接)的访问。
- 实现分布式事务:在分布式协调中作为基础组件,如选举领导者。
常见的跨节点锁实现方案
| 方案 | 依赖中间件 | 特点 | 适用场景 |
|---|---|---|---|
| 基于Redis | Redis | 性能高、实现简单 | 高并发、对一致性要求适中 |
| 基于ZooKeeper | ZooKeeper | 强一致性、可靠性高 | 对高可用、强一致要求严格 |
| 基于数据库 | MySQL/PG | 实现简单、无需额外组件 | 小规模分布式系统 |
| 基于Etcd | Etcd | 类似ZooKeeper,更现代 | 云原生场景 |
本文重点讲解最成熟的两种方式:Redis和ZooKeeper。
基于Redis的跨节点锁编写步骤
Redis的分布式锁核心是使用SET NX PX命令,但还需考虑死锁、锁超时、节点故障等问题,经典的实现是Redlock算法(由Redis作者提出),以下为简化版编写步骤:
1 基本版:SET NX PX
String lockKey = "lock:user:123";
String lockValue = UUID.randomUUID().toString();
// 加锁:若key不存在则设置,且自动过期(防死锁)
Boolean locked = redisClient.set(lockKey, lockValue, SetOption.NX, SetOption.PX, 30000);
if (locked) {
try {
// 执行业务逻辑
} finally {
// 解锁:必须用Lua脚本保证原子性(防误删其他节点的锁)
String lua = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisClient.eval(lua, 1, lockKey, lockValue);
}
}
2 注意事项(防止踩坑)
- 锁值必须唯一(如UUID),防止释放了其他节点的锁。
- 解锁必须用Lua原子性,避免“检查-删除”非原子操作导致的误释放。
- 设置合理超时,超时时间需大于业务执行时长,防止锁自动释放后其他节点“捡漏”。
- 循环重试机制:若获取锁失败,应自旋重试,并设置最大重试次数。
3 Redlock算法(高可靠版)
当Redis采用主从集群时,若主节点宕机,锁可能丢失,Redlock要求向5个独立的Redis节点同时加锁,需多数节点成功才算加锁成功,虽然实现复杂,但能极大提升安全性。
问答:Redis锁的缺点是?
答:若Redis主节点在加锁后、未同步到从节点前宕机,则新主节点无该锁记录,其他节点可能加锁成功,导致“双主”问题,Redlock可缓解,但需更多资源。
基于ZooKeeper的跨节点锁示例
ZooKeeper利用临时顺序节点和Watcher机制实现锁,原理类似于“排队叫号”。
1 实现步骤
- 在锁路径下(如
/locks/user_123)创建临时顺序子节点(如lock-00000001)。 - 获取该路径下所有子节点,判断自己的顺序号是否为最小:
- 是:获得锁。
- 否:监听比它大的前一个节点(或监听比自己小的节点数量),等待释放。
- 释放锁时,删除自己创建的临时节点。
2 伪代码
ZooKeeper zk = new ZooKeeper("localhost:2181", 3000, null);
String lockPath = "/locks/resource";
String myNode = zk.create(lockPath + "/lock-", null,
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
List<String> children = zk.getChildren(lockPath, false);
Collections.sort(children);
int index = children.indexOf(myNode.substring(lockPath.length() + 1));
if (index == 0) {
// 获得锁
} else {
// 监听前一个节点
Stat stat = zk.exists(lockPath + "/" + children.get(index - 1), true);
if (stat != null) {
// 阻塞等待(Watcher回调唤醒)
synchronized (this) { wait(); }
}
}
// 释放锁:关闭或删除节点
zk.delete(myNode, -1);
3 优点与注意
- 临时节点:客户端断开后自动删除,避免死锁。
- 顺序节点+最小节点保证公平性(先请求先获得)。
- 羊群效应:大量节点监听同一节点,释放时惊群,优化方案:只监听前一个节点。
问答:ZooKeeper锁的缺点是?
答:性能低于Redis(创建节点、Watcher回调有开销);需维护ZK集群;且当节点数多时,Watcher风暴可能拖慢系统。
跨节点锁的常见问题与解决方案
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 死锁 | 节点崩溃后锁未释放 | 使用Redis的过期时间、ZK的临时节点 |
| 锁过期 | 业务未完成,锁自动释放 | 设置合适超时(建议比预期长50%);或使用“看门狗”续期(如Redisson) |
| 重入问题 | 同一节点重复获取锁导致死锁 | 实现可重入(记录线程+重入次数) |
| 锁被误删 | 节点A释放了节点B的锁 | 锁值用唯一标识(UUID)+原子化检查删除 |
| 时钟漂移 | 不同节点机器时间不一致 | 避免依赖绝对时间,用Redis的TTL相对时间或ZK的顺序号 |
| 性能瓶颈 | 高并发下锁服务成为热点 | 减少临界区范围;考虑分段锁或读写锁 |
总结与建议
🌟 最佳实践
- 根据场景选型:
- 追求高性能、弱一致性:Redis(SET NX PX + Lua)足以。
- 追求强一致性、高可靠性:ZooKeeper或Etcd。
- 始终使用唯一锁值,并采用原子操作加解锁。
- 优先使用成熟库:如Redisson(Redis)、Apache Curator(ZK),避免重复造轮子。
- 监控锁的持有时间:日志输出加锁时长,异常报警。
- 避免锁的滥用:能用无锁数据结构或乐观锁(如数据库CAS)优先。
💡 最后的问答
问:跨节点锁是否一定能100%保证一致性?
答:不能完全避免理论上的“脑裂”或“网络分区”,但通过冗余节点、多数派协议(如Redlock、ZK的Quorum)可将概率降至工程可接受范围。
您已掌握跨节点锁的核心原理与编写方法,实际开发中,请根据业务对一致性、性能的容忍度选择合适方案,并始终记得:没有银弹,只有权衡。
标签: 编写方法