Redis 分布式锁:从入门到踩坑再到正确姿势
一个订单重复支付的惨案
“用户投诉:支付成功但订单状态没变。”
查了半小时日志,发现同一个订单在 200ms 内被两个支付回调同时处理,导致状态异常。进一步排查,发现是负载均衡下多个服务实例同时抢到了同一把"锁"。
实例 A: 查询订单状态 → 待支付 → 开始处理
实例 B: 查询订单状态 → 待支付 → 开始处理 ← 同一时刻!
实例 A: 更新状态为已支付
实例 B: 再次更新状态(覆盖了 A 的操作)
本地锁(synchronized / ReentrantLock)在分布式环境下彻底失效。你需要分布式锁。
一、分布式锁是什么
flowchart LR
subgraph "单机环境"
A1[线程1] --> L1[本地锁]
A2[线程2] --> L1
L1 --> R1[共享资源]
end
subgraph "分布式环境"
B1[实例1] --> DL[Redis 分布式锁]
B2[实例2] --> DL
B3[实例3] --> DL
DL --> R2[共享资源<br/>如订单状态]
end
核心诉求:多个进程 / 多台机器,同一时刻只有一个能操作共享资源。
二、Redis 分布式锁的演进
第一版:SETNX
// 伪代码:最简单的分布式锁
public boolean tryLock(String key) {
// SETNX: key 不存在时才设置成功
return redis.setnx(key, "1") == 1;
}
public void unlock(String key) {
redis.del(key);
}
问题:如果获取锁后程序崩溃,锁永远无法释放 → 死锁。
第二版:SETNX + EXPIRE
// 伪代码:加上过期时间
public boolean tryLock(String key, int expireSeconds) {
if (redis.setnx(key, "1") == 1) {
redis.expire(key, expireSeconds); // 设置过期时间
return true;
}
return false;
}
问题:SETNX 和 EXPIRE 是两条命令,不是原子操作。如果 SETNX 成功后、EXPIRE 执行前崩溃,依然死锁。
第三版:SET NX EX(正确姿势)
// 伪代码:原子设置值 + 过期时间
public boolean tryLock(String key, String value, int expireSeconds) {
// SET key value NX EX seconds
// NX: 不存在才设置
// EX: 设置过期时间(秒)
return "OK".equals(redis.set(key, value, "NX", "EX", expireSeconds));
}
一条命令,原子完成。这是 Redis 2.6.12 之后推荐的方式。
三、还有坑:锁误删问题
场景重现
sequenceDiagram
participant A as 实例A
participant R as Redis
participant B as 实例B
A->>R: SET lock:order:123 valueA NX EX 10
Note over A: 获取锁成功,开始处理
Note over A: 业务处理耗时 15s(超过锁过期时间)
Note over R: 锁自动过期
B->>R: SET lock:order:123 valueB NX EX 10
Note over B: 获取锁成功(A的锁已过期)
Note over A: 业务处理完成
A->>R: DEL lock:order:123
Note over R: 删除的是 B 的锁!
B->>B: 😭 我的锁被删了...
问题本质:实例 A 删除了实例 B 的锁。
解决方案:加锁时设置唯一标识,解锁时校验
// 伪代码:加锁时设置唯一值
public boolean tryLock(String key, int expireSeconds) {
String lockValue = UUID.randomUUID().toString();
boolean success = "OK".equals(redis.set(key, lockValue, "NX", "EX", expireSeconds));
if (success) {
// 保存 lockValue,解锁时要用
currentThreadLockValue.set(lockValue);
}
return success;
}
// 伪代码:解锁时校验是否是自己的锁
public void unlock(String key) {
String lockValue = currentThreadLockValue.get();
// 使用 Lua 脚本保证原子性
String luaScript = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""";
redis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(lockValue));
}
为什么用 Lua 脚本? GET + DEL 不是原子操作,中间可能被其他实例抢锁。Lua 脚本在 Redis 中原子执行。
四、还有坑:锁过期时间如何设置
问题
- 设置太短:业务还没处理完,锁就过期了
- 设置太长:服务崩溃后,锁要很久才能释放
解决方案:看门狗(Watchdog)
// 伪代码:自动续期机制
public boolean tryLockWithWatchdog(String key, int expireSeconds) {
String lockValue = UUID.randomUUID().toString();
boolean success = tryLock(key, lockValue, expireSeconds);
if (success) {
// 启动后台线程,定期续期
ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor();
watchdog.scheduleAtFixedRate(() -> {
// 每隔 expireSeconds/3 续期一次
redis.expire(key, expireSeconds);
}, expireSeconds / 3, expireSeconds / 3, TimeUnit.SECONDS);
// 保存 watchdog 引用,解锁时关闭
watchdogRef.set(watchdog);
}
return success;
}
原理:只要业务还在执行,看门狗就持续续期;业务结束或服务崩溃,看门狗停止,锁自动过期。
Redisson 已经实现了这个机制:
// Java: Redisson 示例
RLock lock = redisson.getLock("order:123");
lock.lock(); // 自动启动看门狗
try {
// 业务逻辑,即使执行很久也不会锁过期
processOrder();
} finally {
lock.unlock(); // 自动关闭看门狗
}
五、Redis 集群下的新问题:Redlock
主从切换导致锁丢失
sequenceDiagram
participant C as 客户端
participant M as Master
participant S as Slave
C->>M: SET lock:order NX EX 10
Note over M: 加锁成功
Note over M: 还未同步到 Slave
Note over M: Master 宕机!
M--xS: 同步中断
Note over S: Slave 升级为 Master
C->>S: SET lock:order NX EX 10
Note over S: 加锁成功(同一把锁被两次获取)
Redlock 算法
核心思想:向多个独立的 Redis 节点申请锁,多数派成功才算成功。
# 伪代码:Redlock 算法
def redlock(resource, ttl):
quorum = len(redis_nodes) // 2 + 1 # 多数派
acquired = 0
for node in redis_nodes:
if try_lock(node, resource, ttl):
acquired += 1
if acquired >= quorum:
return True # 获取锁成功
else:
# 释放已获取的锁
for node in redis_nodes:
unlock(node, resource)
return False
Redisson 实现:
// Java: Redisson Redlock
RLock lock1 = redisson1.getLock("order:123");
RLock lock2 = redisson2.getLock("order:123");
RLock lock3 = redisson3.getLock("order:123");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();
Redlock 的争议
反对派(Martin Kleppmann):
- 时钟同步问题可能导致锁安全性被破坏
- 系统复杂度大幅增加,收益不一定成正比
支持派(Redis 作者 antirez):
- 多数场景不需要 Redlock
- 使用 fencing token(递增序号)可以更简单地解决问题
实际建议:大多数场景下,单实例 Redis 锁 + 正确实现已经足够。
六、正确姿势总结
// Java: 推荐的分布式锁使用方式(Redisson)
public void processOrder(String orderId) {
String lockKey = "lock:order:" + orderId;
RLock lock = redisson.getLock(lockKey);
try {
// 尝试获取锁,最多等待 5 秒,锁自动过期时间 30 秒
boolean acquired = lock.tryLock(5, 30, TimeUnit.SECONDS);
if (!acquired) {
throw new BusinessException("系统繁忙,请稍后重试");
}
// 执行业务逻辑
doBusiness(orderId);
} finally {
// 只释放自己持有的锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
关键要点
| 要点 | 说明 |
|---|---|
| 原子加锁 | 使用 SET NX EX,一条命令完成 |
| 唯一标识 | 加锁时设置 UUID,解锁时校验 |
| Lua 解锁 | GET + DEL 必须原子执行 |
| 合理过期 | 根据业务时长设置,或使用看门狗自动续期 |
| 超时机制 | tryLock 设置等待时间,避免无限阻塞 |
| 异常处理 | finally 中确保释放锁,但要先判断是否持有 |
七、什么时候不该用 Redis 分布式锁
不是所有并发问题都需要分布式锁:
| 场景 | 更好的方案 |
|---|---|
| 数据库乐观更新 | 乐观锁(version 字段) |
| 幂等接口 | 唯一索引 / 去重表 |
| 简单计数 | Redis INCR(原子操作) |
| 消息顺序消费 | 单队列单消费者 |
分布式锁的成本:引入外部依赖、增加系统复杂度、潜在的性能瓶颈。能不用就不用,用了就要用对。
回到开头的问题
那次订单重复支付事故,最后是怎么解决的?
- 短期:在支付回调入口加分布式锁(Redis + Redisson)
- 中期:数据库订单状态字段加乐观锁(version)
- 长期:支付回调改为幂等设计,同一订单号只处理一次
三层防护,缺一不可。 分布式锁是盾牌,但不是唯一的防线。
思考:分布式锁的本质
分布式锁解决的核心问题是跨进程的互斥。但分布式系统的复杂性在于:
- 网络可能断
- 进程可能挂
- 时钟可能不同步
不存在完美的分布式锁,只有适合场景的取舍。
选择 Redis 分布式锁,意味着你接受:
- CP 系统中的 AP 特性(可用性优先,极端情况可能丢锁)
- 外部依赖带来的运维成本
- 正确实现的复杂度
如果业务对一致性要求极高(如金融交易),也许应该考虑 ZooKeeper / etcd 的分布式锁(CP 系统),或者直接用数据库事务,而不是 Redis。
选择的技术,要和业务风险相匹配。