Redis 分布式锁与任务状态

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 或状态机记录下来,并在当前任务完成后基于最新数据补跑或重新调度。

相关推荐
踏月的造梦星球2 小时前
DMHS同步原理与DM8到DM8基础搭建
数据库·oracle
Hardworking6662 小时前
第6章 数据工程
运维·服务器·数据库·数据工程
Elastic 中国社区官方博客3 小时前
4 个英伟达人工智能任务,1 个 Elasticsearch 接口:嵌入、聊天、completion 和重排序
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai
半夜修仙3 小时前
RabbitMQ的推模式和拉模式
java·分布式·中间件·rabbitmq·github·java-rabbitmq
G.O.G.O.G4 小时前
LeetCode SQL 从入门到精通(MySQL)05(下)
数据库·sql·oracle
其实防守也摸鱼4 小时前
运维--安全与数据库
网络·数据库·学习·安全·安全威胁分析·数据库架构·软件安全
不能只会打代码4 小时前
Day 002 — Python 工程化 & FastAPI & 数据库速通
数据库·redis·python·docker·fastapi·测试
Rsun045514 小时前
MySQL 中什么是回表?
数据库·mysql
buligbulig5 小时前
Hadoop环境安装和集群创建
大数据·hadoop·分布式