1. Redis 为什么能做分布式锁
Redis 虽然常用于缓存,但本质上是一个共享的、高性能的内存键值数据库。多个服务实例都可以访问同一个 Redis,因此可以用 Redis 中的某个 key 表示一把公共锁。
Redis 适合实现分布式锁的原因:
- 多个服务实例能共享访问同一个 Redis。
SET NX是原子操作。- key 可以设置过期时间,避免死锁。
- Redis 性能高,抢锁和释放锁成本低。
- Lua 脚本可以保证"判断 + 删除"的原子性。
2. 分布式锁解决什么问题
分布式锁用于多个服务实例之间控制同一资源的并发访问。
例如:
text
OrderService-1
OrderService-2
OrderService-3
这些实例可能同时处理同一个订单、同一个任务、同一张优惠券或同一份库存。如果没有锁,可能出现重复处理或并发冲突。
分布式锁的目标是:
text
同一时间只允许一个执行者操作同一份资源
3. Redis 分布式锁的基本命令
常见加锁命令:
text
SET lock:order:1001 random-token NX PX 30000
含义:
| 参数 | 含义 |
|---|---|
| lock:order:1001 | 锁的 key,表示订单 1001 的锁 |
| random-token | 锁的 value,用来标识"是谁加的锁" |
| NX | 只有 key 不存在时才设置成功,避免重复加锁 |
| PX 30000 | 30 秒后自动过期,防止死锁 |
多个实例同时执行这条命令时,Redis 保证只有一个实例能设置成功。
4. 为什么要设置 TTL
如果服务 A 拿到锁后宕机:
text
服务 A:拿到锁
服务 A:宕机,没来得及释放锁
如果锁没有过期时间,其他服务会一直拿不到锁,形成死锁。
因此必须设置 TTL:
text
PX 30000
表示 30 秒后锁自动释放。
5. 为什么要 random-token
random-token 是这把锁的持有者标识,也可以理解为"谁加了这把锁"的身份凭证。它通常是一个随机字符串,例如 UUID、随机数,或者"机器 ID + 进程 ID + 线程 ID + UUID"的组合。
例如:
text
SET lock:order:1001 9f2c8a4e-4d6d-4c57-96fa-8b7f9d3d1234 NX PX 30000
这里的 9f2c8a4e-4d6d-4c57-96fa-8b7f9d3d1234 就是锁的 token。
释放锁不能简单地执行:
text
DEL lock:order:1001
因为可能误删别人的锁。
典型问题:
text
1. A 拿到锁,value = token-A,TTL 30 秒
2. A 执行太慢,30 秒后锁自动过期
3. B 拿到新的锁,value = token-B
4. A 执行完,直接 DEL lock key
此时 A 删除的已经不是自己的锁,而是 B 的锁。
所以释放锁时必须先判断 Redis 中保存的 value 是否还是自己的 token:
lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
也就是说,只有 GET lock:order:1001 的结果仍然等于当前执行者持有的 random-token,才允许删除锁。
random-token 的核心作用是:
text
确保只能释放自己加的锁,避免误删其他执行者后来获得的锁。
生产中通常用 Lua 脚本保证"判断 token + 删除 key"是原子的。
6. A 执行完为什么不能停止 B
如果 A 的锁已经过期,Redis 已经认为 A 不再持有锁。B 在锁过期后拿到锁,从 Redis 视角看是合法的。
所以问题不在于"是否应该停止 B",而在于:
text
A 的任务执行时间超过了锁 TTL,导致锁提前失效
A 执行完后不应该删除 B 的锁,更不应该通过删除锁来"停止 B"。这样会导致锁保护彻底失效,甚至让 C 也进入。
7. C 为什么可能会抢锁
错误时间线:
text
1. A 拿锁,开始执行任务
2. A 太慢,锁过期
3. B 拿到锁,也开始执行同一个任务
4. A 执行完,误删了 B 的锁
5. C 来了,发现没有锁,于是也拿到锁
这里 C 不是因为任务应该再执行,而是因为 A 误删了 B 的锁,导致系统误以为当前没人执行。
8. 怎么避免锁提前过期
8.1 设置合理 TTL
如果任务通常最多执行 10 秒,锁 TTL 可以设置为 30 秒或 60 秒。
适合:
- 任务耗时稳定
- 执行时间上限明确
缺点:
- TTL 太长,服务宕机后恢复慢。
- TTL 太短,任务还没完成锁就过期。
8.2 自动续约 Watchdog
任务执行期间,由后台线程或定时器持续续约:
text
A 拿锁,TTL 30 秒
A 每隔 10 秒续约一次
只要 A 还活着,就延长 TTL
A 执行完成后释放锁
适合:
- 长任务
- 任务耗时不确定
- 不能接受执行中锁过期
很多 Redis 锁库,例如 Redisson,都提供类似机制。
9. 任务状态为什么很重要
分布式锁只解决:
text
谁现在能执行
任务状态解决:
text
这个任务还该不该执行
锁并不等于任务完成状态。任务是否已经完成,应该由数据库中的业务状态判断。
例如:
text
task_status = pending / processing / success / failed
如果 C 抢到了锁,但发现任务已经完成,就应该直接退出。
10. 任务状态判断必须是原子的
错误做法:
text
1. 查询任务状态是不是 pending
2. 如果是 pending,就开始执行
3. 执行完改成 success
并发下可能出现:
text
A 查询:pending
B 查询:pending
A 开始执行
B 也开始执行
因为"查询"和"改状态"不是一个原子操作。
正确做法是数据库原子更新:
sql
UPDATE task
SET status = 'processing'
WHERE id = 1001 AND status = 'pending';
然后看影响行数:
text
影响行数 = 1:抢占成功,可以执行
影响行数 = 0:别人已经抢了或任务已完成,退出
11. 有任务状态还需要分布式锁吗
不一定。
如果任务天然可以通过数据库状态机控制,并且所有执行者都必须先原子更新状态,那么很多场景可以不需要 Redis 分布式锁。
但是 Redis 分布式锁仍然有价值:
- 减少无效并发进入数据库。
- 保护外部资源,例如设备、文件、第三方 API。
- 防止重复启动长任务。
- 多实例定时任务防重入。
- 跨多个资源做前置互斥。
12. 加锁后又来了新的数据更新任务怎么办
如果某条数据正在被 Redis 锁保护执行更新任务,此时同一条数据又来了新的更新任务,不建议直接丢弃,也不建议让新任务长时间阻塞等待。
更稳妥的做法是:
text
加锁串行执行 + 标记 pending/dirty + 当前任务结束后补跑一次
核心原因是:锁只表示"当前有人在处理",不表示"后来的数据变化已经被处理"。如果新任务获取锁失败后直接返回,就可能导致后续数据更新丢失,出现缓存、索引、聚合结果或异步任务状态不一致。
推荐流程:
text
收到数据更新任务
↓
尝试获取 lock:data:{id}
↓
获取成功?
├─ 是:读取最新数据并执行更新逻辑
│ ↓
│ 执行完成前检查 pending:data:{id}
│ ↓
│ 有 pending:删除 pending 标记,再基于最新数据补跑一轮
│ 无 pending:释放锁,结束
│
└─ 否:设置 pending:data:{id} = 1,然后返回"任务已合并/稍后处理"
也可以把 pending 标记替换成重新投递 MQ 或延迟队列,但不要仅仅因为抢锁失败就丢弃任务。
13. pending/dirty 标记适合什么任务
pending/dirty 标记适合"只关心最终状态"的更新任务,例如:
- 刷新缓存。
- 同步搜索索引。
- 重建商品快照。
- 重新计算聚合数据。
- 同步外部展示状态。
这类任务的特点是:短时间内同一条数据更新多次,通常不需要执行同样多次,只需要最后基于最新数据执行一次或少数几次。
因此任务处理时要注意:
text
不要完全依赖任务入参中的旧数据,而应该根据 dataId 重新查询最新数据。
必要时还应该加版本号或更新时间判断:
text
version / updatedAt
避免旧任务后写覆盖新结果。
14. 哪些任务不能只用 pending 合并
如果每一次变化都不能丢,就不能只用 pending/dirty 合并。
典型场景:
- 账户流水。
- 积分变更。
- 订单状态流转。
- 需要逐条审计的业务事件。
这类任务应该使用消息队列、数据库状态机、唯一索引、幂等键、版本号或顺序消费来保证每个事件都被正确处理。
pending/dirty 更像是"合并最终状态更新",不是"保存每一次业务事件"。
15. 推荐流程
text
1. 尝试获取 Redis 分布式锁
2. 拿不到锁 -> 标记 pending/dirty,或重新投递 MQ/延迟队列
3. 拿到锁 -> 用数据库原子更新抢占任务,或读取最新数据执行业务
4. 执行过程中若有新更新 -> 由其他请求设置 pending/dirty
5. 当前执行完成 -> 检查 pending/dirty
6. 如果存在 pending/dirty -> 清除标记后基于最新数据再执行一轮
7. 如果不存在 pending/dirty -> 正常结束
8. 释放锁时校验 token
16. 锁、状态、幂等的分工
| 机制 | 解决的问题 |
|---|---|
| 分布式锁 | 谁现在能进入临界区 |
| pending/dirty 标记 | 锁期间是否又发生了新的更新 |
| 任务状态 | 任务是否还应该执行 |
| 数据库事务 | 状态变更的原子性 |
| 幂等设计 | 即使重复请求也不重复产生副作用 |
| 唯一索引 | 最终防止重复写入 |
关键业务不能只靠 Redis 锁。Redis 锁通常是第一道并发保护,pending/dirty 标记用于承接锁期间的新更新,数据库状态机、事务、唯一索引和幂等才是最终正确性的兜底。
17. 一句话总结
Redis 能做分布式锁,是因为它支持共享访问、原子写入和 TTL。但分布式锁只解决"谁现在执行",不能表示"后续更新已经处理";如果锁期间同一数据又有新更新,应通过 pending/dirty、MQ 或状态机记录下来,并在当前任务完成后基于最新数据补跑或重新调度。