1. Redis分布式锁的演进背景
在分布式系统中,多个服务实例对共享资源的并发访问是一个经典问题。我最早接触这个问题是在2016年做一个电商秒杀系统时,当时遇到了严重的超卖问题。传统单机环境下的synchronized或ReentrantLock在分布式场景下完全失效,这促使我开始研究分布式锁的实现方案。
Redis因其高性能和丰富的数据结构,成为实现分布式锁的首选。但早期的实现方式存在诸多缺陷,我见证了整个技术方案的迭代过程:
- 2014年左右:简单使用SETNX命令,但存在死锁风险
- 2015年:引入EXPIRE设置过期时间,但两个命令非原子操作
- 2016年:开始普及Lua脚本方案,解决原子性问题
- 2017年后:Redis 2.6.12+版本支持SET扩展语法,进一步简化实现
2. 基础实现方案与缺陷分析
2.1 SETNX的原始实现
最早的分布式锁实现非常直接:
Boolean result = jedis.setnx("lock_key", "1"); if(result == 1) { // 获取锁成功 }这个方案存在明显问题:如果获取锁的服务崩溃,锁将永远无法释放。我在生产环境就遇到过因此导致的系统死锁,最终只能手动删除Redis键来恢复。
2.2 引入过期时间的改进版
为解决死锁问题,开发者们开始增加过期时间设置:
jedis.setnx("lock_key", "1"); jedis.expire("lock_key", 30);但这个方案在极端情况下仍存在问题:
- SETNX成功但EXPIRE失败(如服务崩溃)
- 非原子操作导致竞态条件
- 误删其他服务的锁(未做value校验)
3. Lua脚本的原子性解决方案
3.1 Lua脚本的优势
Redis 2.6开始支持Lua脚本,这为分布式锁提供了完美的解决方案:
- 原子性:整个脚本作为一个命令执行
- 减少网络开销:多个命令一次发送
- 高复用性:脚本可重复使用
3.2 完整Lua实现方案
3.2.1 加锁脚本
-- KEYS[1]: 锁key -- ARGV[1]: 唯一标识(如UUID) -- ARGV[2]: 过期时间(秒) if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then redis.call('expire', KEYS[1], ARGV[2]) return 1 else return 0 end3.2.2 解锁脚本
-- KEYS[1]: 锁key -- ARGV[1]: 预期值 if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end3.3 Java客户端实现示例
public class RedisDistributedLock { private final JedisPool jedisPool; private final String lockKey; private final String lockValue; private final int expireTime; // 加锁脚本 private static final String LOCK_SCRIPT = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then " + "redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end"; // 解锁脚本 private static final String UNLOCK_SCRIPT = "if redis.call('get',KEYS[1]) == ARGV[1] then " + "return redis.call('del',KEYS[1]) else return 0 end"; public boolean lock() { try(Jedis jedis = jedisPool.getResource()) { Object result = jedis.eval(LOCK_SCRIPT, Collections.singletonList(lockKey), Arrays.asList(lockValue, String.valueOf(expireTime))); return "1".equals(result.toString()); } } public boolean unlock() { try(Jedis jedis = jedisPool.getResource()) { Object result = jedis.eval(UNLOCK_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); return "1".equals(result.toString()); } } }4. Redis 2.6.12+的优化方案
Redis 2.6.12版本后,SET命令增加了扩展语法,可以替代Lua脚本:
SET lock_key unique_value NX EX 30这个命令等价于之前的Lua脚本,但更加简洁。在Java中的实现:
public boolean tryLock(Jedis jedis, String key, String value, int expireSeconds) { String result = jedis.set(key, value, "NX", "EX", expireSeconds); return "OK".equals(result); }5. 生产环境中的注意事项
5.1 锁续期问题
当业务执行时间超过锁过期时间时,需要考虑锁续期。我推荐使用Redisson的看门狗机制,它会在后台线程定期检查并延长锁有效期。
5.2 集群环境下的特殊考量
在Redis集群模式下,需要注意:
- 所有key必须在同一个hash slot(可以使用hash tag)
- 主从切换可能导致锁失效(考虑Redlock算法)
5.3 性能优化建议
- 锁粒度要尽可能细(按业务ID而非全局锁)
- 超时时间设置合理(通常500ms-3s)
- 避免在锁内执行耗时操作
6. 常见问题排查
6.1 锁无法释放
可能原因:
- 未正确捕获异常导致未执行unlock
- 锁value不匹配(使用随机值而非固定值)
- Redis连接中断
解决方案:
- 确保unlock在finally块中执行
- 使用UUID作为锁value
- 增加重试机制
6.2 锁竞争激烈
现象:系统响应变慢,大量获取锁超时
优化方案:
- 引入分段锁(如库存分段)
- 增加随机退避时间
- 考虑改用本地锁+分布式锁的混合方案
7. 其他实现方案对比
除了Redis方案,常见的分布式锁实现还有:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Zookeeper | 可靠性高,有现成API | 性能较差,依赖ZK集群 | 强一致性要求场景 |
| 数据库 | 实现简单 | 性能差,有死锁风险 | 低并发场景 |
| etcd | 可靠性高 | 学习成本高 | Kubernetes环境 |
在大多数互联网应用中,Redis方案因其性能和易用性仍是首选。
8. 最佳实践建议
根据我在多个项目中的实践经验,总结以下建议:
- 始终设置合理的过期时间(即使你认为业务会很快完成)
- 锁value必须全局唯一(推荐UUID)
- 获取锁和释放锁的代码要放在同一方法层级
- 考虑使用成熟的客户端(如Redisson)
- 重要业务增加锁获取失败的监控告警
对于Java项目,我强烈推荐使用Redisson客户端,它提供了丰富的分布式对象和服务,包括:
- 可重入锁
- 公平锁
- 联锁
- 红锁(RedLock)
- 读写锁
这些实现都经过了充分的生产验证,比自己实现更可靠。