1. 为什么我们需要分布式锁?
在单机应用时代,我们使用Java的synchronized关键字或者ReentrantLock就能解决多线程并发问题。但当系统演进到分布式架构时,这些单机锁机制就完全失效了。想象一下这样的场景:你的电商系统部署在3台服务器上,某个用户同时发起3个订单支付请求被负载均衡到不同服务器,如果不加控制,就会产生超卖问题。
分布式锁的核心诉求其实很简单:在分布式环境下,确保同一时刻只有一个客户端能执行关键代码段。但实现起来却面临诸多挑战:
- 网络不可靠:锁获取和释放都可能因为网络问题失败
- 时钟不同步:不同机器时间不一致可能导致锁过早失效
- 单点故障:锁服务本身不能成为系统瓶颈或单点
- 死锁风险:客户端崩溃后锁无法释放
提示:分布式锁必须满足四个基本特性:互斥性(同一时刻只有一个客户端持有锁)、无死锁(最终一定能获取锁)、容错性(大部分节点存活就能正常工作)、可重入性(同一个客户端可多次获取同一把锁)
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SETNX:最原始的Redis锁实现
2.1 SETNX命令的基本用法
SETNX(SET if Not eXists)是Redis提供的一个原子性操作命令,当且仅当key不存在时才能设置成功。这天然适合实现分布式锁:
bash复制SETNX lock_key unique_value
如果返回1表示获取锁成功,0则表示失败。释放锁也很简单:
bash复制DEL lock_key
看起来完美?实际上这种实现存在致命缺陷:
- 客户端崩溃导致死锁:如果客户端获取锁后崩溃,锁永远无法释放
- 非原子性释放:直接DEL可能误删其他客户端的锁
- 不具备可重入性:同一客户端无法重复获取锁
2.2 第一代改进:给锁加过期时间
为了解决死锁问题,我们引入过期时间:
bash复制SETNX lock_key unique_value
EXPIRE lock_key 30
但这又引入了新问题——两条命令不是原子的!如果在SETNX和EXPIRE之间客户端崩溃,依然会产生死锁。
2.3 第二代改进:原子性SET命令
Redis 2.6.12之后支持SET命令的扩展语法:
bash复制SET lock_key unique_value NX PX 30000
这个命令原子性地完成了SETNX和EXPIRE的操作,解决了死锁问题。但依然存在以下隐患:
- 锁误删:客户端A的锁可能被客户端B删除
- 超时不可靠:业务操作超过锁过期时间会导致并发问题
3. Lua脚本:解决锁释放的原子性问题
3.1 为什么需要Lua脚本?
考虑这样的场景:
- 客户端A获取锁,设置unique_value为"UUID_A"
- 客户端A执行时间超过锁过期时间,锁自动释放
- 客户端B获取锁,设置unique_value为"UUID_B"
- 客户端A完成任务,尝试释放锁
- 如果不校验unique_value,客户端A会误删客户端B的锁
3.2 安全的锁释放实现
使用Lua脚本可以原子性地完成"校验+删除"操作:
lua复制if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这样就能确保只有锁的持有者才能释放锁。但超时问题依然存在——如果业务执行时间超过锁过期时间,还是会导致并发问题。
4. Redisson:生产级的分布式锁实现
4.1 Redisson分布式锁的核心机制
Redisson的分布式锁实现(RLock)解决了上述所有问题,主要特性包括:
- 看门狗机制:后台线程定期续期,防止业务未完成锁已过期
- 可重入设计:同一线程可多次获取锁
- 公平锁支持:按照申请顺序获取锁
- 联锁和红锁:支持多Redis节点的分布式锁
基本用法示例:
java复制RLock lock = redisson.getLock("myLock");
try {
lock.lock();
// 业务代码
} finally {
lock.unlock();
}
4.2 看门狗原理剖析
Redisson的看门狗机制是这样工作的:
- 获取锁时默认设置30秒过期时间
- 启动一个定时任务,每10秒检查客户端是否还持有锁
- 如果持有则重置过期时间为30秒
- 客户端正常释放锁时会取消这个定时任务
这样只要客户端还"活着",锁就不会因为超时被释放。但如果客户端崩溃,锁最终还是会自动释放,避免了死锁。
4.3 Redisson锁的高级特性
4.3.1 公平锁
普通Redis分布式锁是非公平的——所有等待锁的客户端会同时争抢。Redisson提供了公平锁实现:
java复制RLock fairLock = redisson.getFairLock("fairLock");
公平锁内部使用Redis的List结构维护等待队列,按照申请顺序分配锁。
4.3.2 联锁(MultiLock)
需要对多个资源加锁时,可以使用联锁确保原子性:
java复制RLock lock1 = redisson.getLock("lock1");
RLock lock2 = redisson.getLock("lock2");
RLock multiLock = redisson.getMultiLock(lock1, lock2);
multiLock.lock();
try {
// 操作多个资源
} finally {
multiLock.unlock();
}
4.3.3 红锁(RedLock)
红锁算法是为了解决单Redis实例不可靠的问题,需要在多个独立的Redis实例上获取锁:
java复制RLock lock1 = redisson.getLock("lock1");
RLock lock2 = redisson.getLock("lock2");
RLock lock3 = redisson.getLock("lock3");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();
try {
// 关键业务
} finally {
redLock.unlock();
}
红锁的实现基于以下规则:
- 获取当前时间
- 依次尝试从多个独立Redis实例获取锁
- 计算获取锁花费的总时间
- 只有在多数节点上获取成功,并且总耗时小于锁有效时间,才算获取成功
- 如果获取失败,需要在所有节点上释放锁
5. 分布式锁的实践陷阱与优化建议
5.1 常见问题排查
5.1.1 连接数暴涨问题
Redisson底层使用Netty与Redis通信,如果出现类似错误:
code复制ERR max number of clients reached
可能原因包括:
- 未正确关闭Redisson客户端
- 锁获取过于频繁
- 网络问题导致连接泄漏
解决方案:
- 确保正确关闭Redisson实例
- 调整连接池配置
- 监控Redis连接数
5.1.2 锁续期失败
看门狗线程可能因为以下原因续期失败:
- 长时间GC暂停
- 网络分区
- Redis节点故障
建议:
- 监控锁续期日志
- 设置合理的锁超时时间
- 考虑使用红锁提高可用性
5.2 性能优化技巧
- 锁粒度控制:锁的key应该尽可能细粒度,减少竞争
- 锁超时设置:根据业务特点设置合理的超时时间
- 避免锁嵌套:尽量减少锁的嵌套层级
- 读写分离:对读多写少场景使用读写锁
5.3 最佳实践建议
- 总是使用try-finally确保锁释放
- 为锁设置合理的超时时间
- 考虑使用Redisson内置的锁实现
- 关键业务考虑使用红锁
- 监控锁等待时间和持有时间
6. 从SETNX到Redisson的架构思考
分布式锁的演进过程反映了分布式系统设计的几个核心原则:
- 从简单到复杂:先解决核心问题(互斥),再逐步完善(续期、可重入)
- 故障假设:始终考虑网络分区、节点故障等异常情况
- 性能与安全的平衡:在安全的前提下尽可能提高性能
- API友好性:将复杂性封装在底层,提供简单易用的接口
在实际项目中,建议:
- 中小型项目直接使用Redisson
- 超大规模系统可以考虑定制实现
- 关键业务必须进行充分的压力测试
