1. 分布式锁的核心价值与挑战
在分布式系统中,多个服务实例同时访问共享资源时,如何保证数据一致性是个经典难题。去年我们电商系统就遇到过这样的场景:大促期间库存扣减出现超卖,事后排查发现是多个节点同时执行了库存检查。这时候就需要分布式锁登场了——它就像跨机房的安全管理员,确保同一时刻只有一个服务能操作关键资源。
分布式锁与单机锁的本质区别在于,它要解决的是网络不可靠、时钟不同步等分布式环境特有的问题。一个好的分布式锁实现必须满足三个基本要求:
- 互斥性(任何时候只有一个客户端能持有锁)
- 避免死锁(持有者崩溃后锁最终能被释放)
- 容错性(部分节点宕机不影响整体可用性)
2. 基于Redis的分布式锁实现
2.1 基础SETNX方案
最直观的实现是利用Redis的SETNX命令:
bash复制SETNX lock_key unique_value
当返回1表示获取锁成功,0则表示已被占用。配合EXPIRE设置超时防止死锁:
bash复制EXPIRE lock_key 30
但这里有个致命缺陷——SETNX和EXPIRE不是原子操作。如果执行SETNX后客户端崩溃,锁就永远无法释放。Redis 2.6.12后提供了改进方案:
bash复制SET lock_key unique_value NX PX 30000
关键细节:unique_value必须使用客户端唯一标识(如UUID),否则可能出现误删其他客户端锁的情况。解锁时需要先GET验证value再DEL删除,这两个操作建议用Lua脚本保证原子性。
2.2 RedLock算法
当单Redis节点不可靠时,Redis作者提出了RedLock算法:
- 获取当前毫秒级时间戳T1
- 依次向N个独立节点请求锁(使用相同key和value)
- 计算获取锁耗时=T2-T1,当且仅当多数节点获取成功且耗时小于锁有效期时才认为成功
- 锁实际有效时间 = 初始有效时间 - 获取锁耗时
这个算法争议较大,Martin Kleppmann曾发文指出其时钟依赖问题。我们的经验是:在时钟同步良好的机房内可用,但跨地域部署时建议谨慎。
3. 基于ZooKeeper的分布式锁
3.1 临时顺序节点方案
ZooKeeper通过临时顺序节点实现更严谨的锁:
- 在/lock目录下创建临时顺序节点(如/lock/lock-000000001)
- 获取/lock下所有子节点,判断自己是否是最小序号
- 如果是则获取锁;否则监听前一个序号的删除事件
- 处理完成后主动删除节点释放锁
这个方案的优点是:
- 通过session机制自动清理崩溃节点的锁(临时节点特性)
- 通过watch机制实现阻塞等待,避免轮询
- 严格的先来后到顺序保证
3.2 实际应用中的优化
生产环境中我们做了两点改进:
- 采用Curator框架的InterProcessMutex,它已经封装了重试、连接恢复等逻辑
- 为每个锁设置独立的ZK路径,避免羊群效应(大量watch集中在单个节点)
典型配置示例:
java复制RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.newClient("zk.example.com:2181", retryPolicy);
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order_pay");
4. 基于数据库的分布式锁
4.1 乐观锁实现
适合冲突较少的场景,典型实现是在数据表中增加版本号字段:
sql复制UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = 1001 AND version = 123
通过影响行数判断是否成功,失败后需要重试业务逻辑。
4.2 悲观锁实现
通过数据库唯一索引或行锁实现:
sql复制-- 方法1:唯一索引
INSERT INTO distributed_lock(lock_name,owner,expire_time)
VALUES ('order_lock','service1',NOW()+INTERVAL 30 SECOND);
-- 方法2:SELECT FOR UPDATE
BEGIN;
SELECT * FROM distributed_lock WHERE lock_name='order_lock' FOR UPDATE;
-- 业务处理
COMMIT;
性能提示:数据库锁的性能瓶颈明显,建议配合本地缓存使用。我们曾在MySQL 5.7上测试,单机QPS约2000左右,而Redis可达10万+。
5. 三种方案的对比与选型建议
5.1 特性对比表
| 维度 | Redis | ZooKeeper | 数据库 |
|---|---|---|---|
| 性能 | 10万+ QPS | 1万+ QPS | 1千~2千 QPS |
| 一致性保证 | 依赖配置 | 强一致 | 依赖隔离级别 |
| 实现复杂度 | 中等 | 高 | 低 |
| 崩溃恢复 | 依赖持久化 | 自动处理 | 需手动清理 |
| 适用场景 | 高并发短时锁 | 强一致性场景 | 已有数据库的系统 |
5.2 选型决策树
- 是否需要强一致性?
- 是 → ZooKeeper
- 否 → 进入2
- 是否已有Redis集群?
- 是 → Redis
- 否 → 进入3
- 是否允许额外中间件?
- 是 → 根据QPS选择Redis或ZK
- 否 → 数据库方案
6. 生产环境中的实战经验
6.1 锁粒度控制
我们曾因锁粒度过粗导致性能问题:最初对整张订单表加锁,后来改为按订单ID哈希分片。经验法则是:
- 锁范围 = 最小竞争单元 + 业务容忍度
- 例如:订单系统按order_id%16分片锁
6.2 锁超时时间设置
这是个需要平衡的艺术:
- 太长 → 故障恢复慢
- 太短 → 业务未完成锁已释放
我们的计算公式:
code复制锁超时时间 = 平均业务处理时间 × 3 + 网络延迟缓冲
配合心跳续期机制更可靠(类似ZooKeeper的session续约)。
6.3 跨时区部署的时钟问题
在多地域部署时遇到过诡异问题:亚洲节点总是抢不到欧洲节点的锁。最终发现是NTP同步偏差导致,解决方案:
- 所有节点使用同一NTP服务器
- 在锁实现中加入时钟漂移容错:
code复制实际有效期 = 声明有效期 - 最大时钟偏差 - 网络延迟
7. 常见问题排查指南
7.1 Redis锁的误释放
现象:A客户端持有锁期间,锁被B客户端释放
排查步骤:
- 检查value是否全局唯一
- 验证解锁是否使用Lua脚本:
lua复制if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
7.2 ZooKeeper连接闪断
现象:频繁出现节点被意外删除
解决方案:
- 调整sessionTimeout(建议≥30s)
- 添加ConnectionStateListener处理状态变化
- 关键业务增加本地二级锁作为降级方案
7.3 数据库死锁
现象:高并发下出现死锁错误
优化方案:
- 为锁表添加索引(避免全表扫描)
- 统一获取锁的顺序(如按lock_name排序后加锁)
- 设置innodb_lock_wait_timeout(建议5~10s)