这是《Redis 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 分布式锁是 Redis 最常用也最容易出错的能力。本章从基础 SET NX 到 Redisson,再到 Redlock、故障切换与业务幂等,把正确性边界讲清楚。
11.1 为什么需要分布式锁
多个服务实例同时修改同一资源:
实例A: 读库存=1
实例B: 读库存=1
实例A: 扣减成功
实例B: 扣减成功 -> 超卖
单机锁只能保护一个 JVM,分布式锁让多个进程互斥:
实例A -> 尝试 SET lock NX 成功 -> 进入临界区
实例B -> SET 失败 -> 等待/返回
实例A -> 释放 -> 实例B 可进入
11.2 最基础的正确写法
SET lock:order:1001 owner-uuid NX PX 30000
必须满足:
- NX:不存在才设置,保证互斥;
- PX:设置过期,防止持有者崩溃导致死锁;
- value 唯一:标识持有者,释放时只能释放自己的锁。
错误写法:
SETNX lock:order value
EXPIRE lock:order 30
两步之间进程崩溃,锁可能永远存在。
11.3 释放锁:不能直接 DEL
直接 DEL 可能删掉别人的锁:
A 获取锁 -> GC/网络延迟超过 TTL
锁过期 -> B 获取锁
A 恢复 -> DEL lock -> 删除了 B 的锁
C 又能获取 -> B/C 同时进入
正确释放必须校验 value:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
Java 实现:
String token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue()
.setIfAbsent(key, token, Duration.ofSeconds(30));
if (Boolean.TRUE.equals(ok)) {
try {
doBusiness();
} finally {
release(key, token);
}
}
11.4 锁超时与续期
TTL 是保险丝,不是业务时长。设置过短会“锁过期但业务还在执行”;过长会导致持有者崩溃后长时间不可用。
方案:
- 预估业务最大耗时,TTL = 最大耗时 + 缓冲;
- 使用 Redisson 看门狗自动续期;
- 记录锁获取、释放、业务耗时指标;
- 关键流程设置总超时。
看门狗只能解决进程活着但执行慢的情况,不能解决代码死循环,因此要配合最大执行时间。
11.5 Redisson 锁实现要点
RLock lock = redisson.getLock("lock:order:" + orderId);
if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) {
return Result.busy();
}
try {
payOrder(orderId);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
Redisson 底层:
- 使用 Lua 保证加锁/解锁原子;
- Hash 结构记录持有者与重入次数;
- 无 leaseTime 时后台续期;
- 支持 waiting list 与公平锁。
11.6 锁粒度设计
| 场景 | 建议 key |
|---|---|
| 订单支付 | lock:order:{orderId} |
| 用户资料更新 | lock:user:{userId} |
| SKU 库存扣减 | 常直接 Lua,不一定加锁 |
| 全局配置刷新 | lock:config:global |
| 定时任务防重 | lock:job:{jobName}:{yyyyMMddHHmm} |
原则:
- 粒度尽量小,只锁真正冲突的资源;
- 不要用
lock:global保护所有业务; - 同一资源 key 规则统一;
- 锁名字要带业务语义,便于监控和排查。
11.7 主从切换下的锁风险
常见异步复制流程:
1. 客户端A 在 Master 获取锁
2. Master 崩溃,锁还没同步到 Replica
3. Replica 提升为新 Master
4. 客户端B 在新 Master 获取同一把锁
5. A/B 同时持有
这不是代码 bug,而是异步系统的一致性边界。可能的应对:
- 业务幂等:即使锁失效,重复执行也不造成资金/库存错误;
- 数据库乐观锁/唯一约束:把最终正确性放在事实源;
- 等待复制:降低丢失窗口,但牺牲可用性;
- 强一致协调服务:etcd/ZooKeeper/Consul,代价是吞吐与运维复杂度;
- Redlock:多实例多数派,争议较多,需谨慎评估。
生产系统最重要的是 1 和 2:锁用于减少冲突,不承担最终正确性。
11.8 Redlock 简述
Redlock 流程:
1. 获取当前时间
2. 依次向 N 个独立 Redis 实例加锁
3. 多数实例成功且总耗时小于锁有效期,则视为成功
4. 有效期 = 初始TTL - 获取耗时
5. 失败则向所有实例释放
它试图降低单实例故障导致锁失效的概率,但存在争议:
- 时钟跳变可能影响有效性判断;
- 长时间 GC/进程暂停仍可能在过期后继续执行;
- 部署要求 N 个独立实例,非一主多从;
- 不能消除“锁过期后业务继续执行”的古老问题。
建议:
- 一般业务使用单实例 Redis 锁 + 业务幂等即可;
- 对正确性极其敏感的流程使用数据库约束/乐观锁,或 etcd/ZooKeeper; Redlock 只在你清楚其假设和代价时使用。
11.9 锁与数据库乐观锁配合
可靠订单支付示例:
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE order_id = ? AND status = 'PENDING' AND version = ?;
流程:
1. 尝试 Redis 锁,减少并发请求
2. 锁内读取订单
3. 数据库条件更新,只有 PENDING 才支付成功
4. 释放锁
即使 Redis 锁异常失效,数据库条件更新也会保证只有一个请求成功。这是生产系统的推荐形态。
11.10 分布式锁常见错误
| 错误 | 后果 |
|---|---|
| 忘记 TTL | 宕机后死锁 |
| SETNX 后再 EXPIRE | 中间崩溃造成永久锁 |
| value 固定字符串 | 释放别人的锁 |
| DEL 释放 | 同上 |
| 业务耗时大于 TTL | 多实例同时执行 |
| finally 中盲目 unlock | IllegalMonitorStateException |
| 锁粒度过大 | 并发度下降 |
| 只靠锁保证正确性 | 主从切换后可能出错 |
11.11 可观测性
指标:
lock_acquire_total;lock_acquire_failure_total;lock_wait_duration;lock_hold_duration;lock_timeout_while_running;lock_release_failure_total。
日志字段:
lock_key, owner_id, wait_ms, hold_ms, result, trace_id
出现 lock_timeout_while_running 必须告警,它意味着业务执行时间超过预期或锁被异常过期。
本章小结
- 正确基础锁:
SET key uniqueValue NX PX ttl; - 释放必须用 Lua 校验 value 后删除;
- 锁要有 TTL、唯一持有者、超时与看门狗;
- 异步复制故障切换下,Redis 锁可能失效;
- 生产正确性由数据库约束/乐观锁/业务幂等兜底,锁用于降低竞争。
思考题
- 为什么 value 必须是每个持有者唯一的随机值?
- 业务执行 5 秒但锁 TTL 3 秒,会发生什么?给出两种修复方案。
- 如果锁只能提高效率而不能保证绝对互斥,系统最终靠什么保证正确?