一、💡 一句话理解
核心结论
Redis 分布式锁就是用一个所有服务实例都能访问的 Key 表示“资源已被占用”。面试时重点讲清楚:加锁要原子、锁要有过期时间、每次加锁要有唯一标识、解锁要先校验标识再删除。
二、🧭 理论:它是什么
2.1 为什么普通 Java 锁不够
synchronized 和 ReentrantLock 只能协调同一个 JVM 中的线程。服务部署了多个实例后,每个实例都有自己的 JVM 和锁对象,无法阻止其他实例同时处理同一份共享资源。
如果对锁对象、临界区、互斥和可重入还不熟悉,可以先阅读 锁与线程同步。
实例 A ─┐
实例 B ─┼─→ 共同访问订单、库存、定时任务等共享资源
实例 C ─┘分布式锁把锁状态放到 Redis,让所有实例竞争同一个 Key。成功创建 Key 的实例获得锁,其他实例创建失败。
2.1.1 图例:两个 Pod 怎么抢同一把锁
假设两个修改请求分别进入 Pod 3 和 Pod 11,而且它们都要执行账号 1001 的同一段跨系统操作。两个 Pod 会向 Redis 创建同一个锁 Key:lock:account:1001。
可以把 Redis 想成一块所有 Pod 都看得见的“占用牌”:
Redis 中没有 lock:account:1001
→ 谁先成功放上占用牌,谁获得锁
Redis 中已有 lock:account:1001
→ 说明别人正在使用,后来的 Pod 获取失败
持有者完成业务并删除 lock:account:1001
→ 占用牌被拿走,其他 Pod 才能再次竞争这里最关键的是:Pod 之间不需要直接通信,它们都通过 Redis 看到同一把锁。NX 保证 Key 已存在时不能重复创建,所以同一时刻只有一个 Pod 能成功。
对应关系
lock:account:1001是锁的名字,表示“账号 1001 正被处理”;pod3-token是本次持有者的唯一凭证;PX 30000表示 30 秒后自动释放,防止 Pod 宕机后锁一直存在。
2.2 常见使用场景
- 防止多个实例重复执行同一个定时任务。
- 防止同一订单被并发处理。
- 缓存击穿时,只允许一个请求重建热点缓存。
- 对共享资源做短时间的互斥操作。
2.3 多个 Pod 不代表必须使用分布式锁
假设系统部署了 16 个 Pod。一个请求经过负载均衡后,只会由其中一个 Pod 处理,不会被 16 个 Pod 同时执行。因此,Pod 数量本身不是使用分布式锁的理由。
真正需要判断的是:同一份共享资源是否可能同时收到多个相互冲突的请求,以及数据库等现有机制能否保证正确性。
以修改账号 1001 为例:
请求 A:修改昵称 → Pod 3
请求 B:修改手机号 → Pod 11如果两个请求只更新各自的字段,直接交给数据库即可:
UPDATE account SET nickname = '小明' WHERE id = 1001;
UPDATE account SET phone = '13800000000' WHERE id = 1001;数据库会处理行级并发,两个请求也没有必须互斥的完整业务流程,所以不需要 Redis 分布式锁。
如果担心两个请求都先读取旧数据,再整行覆盖造成“丢失更新”,通常优先使用:
- 只更新真正变化的字段。
- 数据库事务和行锁。
version字段实现乐观锁。- 唯一索引或条件更新保证业务规则。
例如使用乐观锁:
UPDATE account
SET nickname = '小明',
version = version + 1
WHERE id = 1001
AND version = 5;影响行数为 0,表示数据已经被其他请求修改,应重新读取或提示冲突。这比额外引入 Redis 锁更直接。
只有当“修改账号”变成一段不能仅靠单条 SQL 或单库事务保护的流程时,才可能考虑 Redis 分布式锁。例如:
修改账号等级
→ 查询当前等级
→ 调用外部权益系统发放权益
→ 写入另一套系统
→ 更新本地账号状态如果请求 A 和请求 B 被不同 Pod 同时执行,可能重复发放权益。又无法把整个跨系统流程放进同一个数据库事务,此时可以用 lock:account:1001 让同一账号的整段流程串行执行。
判断标准
**一条请求进入哪个 Pod 并不重要;重要的是多个 Pod 是否会同时操作同一资源。**如果数据库条件更新、事务、唯一约束或业务幂等已经能解决,就不需要 Redis 锁;只有必须让一段跨进程流程互斥执行时,才考虑它。
2.4 结合 aboss-sso-group 理解
aboss-sso-group 的普通账号修改调用链是:
POST /platform/api/aboss/sso/group/account/update
→ AccountController.updateAccount
→ AccountFacadeImpl.updateAccount
→ AccountUpdateExe.execute
→ AbsAccountChangeCmdExe.update
→ AccountGatewayImpl.update
→ UPDATE group_sso_account WHERE account_id = ?
→ 发送账号变更 MQ、刷新账号详情缓存这条链路没有使用 Redis 分布式锁,原因并不是系统只有一个 Pod,而是这项业务最终仍以数据库中的一条账号记录为准:
- 一个 HTTP 请求只进入一个 Pod。
AccountFacadeImpl.updateAccount使用withTransaction(),数据库操作放在事务中执行。AccountGatewayImpl.update根据account_id更新账号记录,数据库负责实际写入时的并发控制。- 更新后清除并重新加载账号详情缓存,让后续查询读取新数据。
项目虽然引入了 Redis,但目前看到的相关用途主要是:
- 保存和读取账号详情缓存。
- 账号更新后清除、刷新缓存。
- 保存失效 Token 等临时数据。
代码中没有找到 RLock、Redisson、setIfAbsent 或 SET NX 这类分布式锁实现。
AccountUpdateLockExe不是分布式锁这个类名里的
Lock指“锁定账号”。它修改的是账号表中的locked业务字段,用来表示账号能否登录;它不是用来阻止多个 Pod 并发执行代码的锁。
因此,这个项目可以这样理解:
Redis 缓存:让账号查询更快
Redis 失效 Token:辅助登录态失效
数据库事务:保证一次账号数据库操作成功或回滚
数据库行级并发控制:处理同时写同一账号记录
Redis 分布式锁:当前普通账号修改链路没有使用不过,当前账号实体中没有看到 version 乐观锁字段。如果两个管理员几乎同时修改同一账号的同一个字段,最终通常是后提交的值覆盖先提交的值,也就是“最后写入者生效”。如果业务可以接受这种结果,就不需要额外处理;如果不能接受,优先考虑增加版本号和条件更新,而不是马上增加 Redis 分布式锁。
项目中 AbsAccountChangeCmdExe.update 还有一个“先查询账号是否存在,再决定新增或更新”的流程。如果同一账号被同时创建,优先依靠账号主键或唯一索引阻止重复数据,并正确处理重复键异常。数据库唯一约束比 Redis 锁更接近最终数据,通常也更可靠。
分布式锁不等于接口幂等
锁只能减少同一时间的并发执行;请求重试、锁过期或进程故障后,业务仍可能再次执行。支付、扣款等重要操作还需要唯一业务号、状态机或数据库唯一约束保证幂等。
三、⚙️ 理论:它是怎么工作的
3.1 加锁:一条命令同时完成互斥和过期
SET lock:account:1001 550e8400-e29b-41d4-a716-446655440000 NX PX 30000| 参数 | 作用 |
|---|---|
NX | Key 不存在时才写入,保证只有一个客户端成功 |
PX 30000 | 设置 30 秒过期时间,客户端宕机后锁也能自动释放 |
| UUID | 标识本次加锁者,防止误删其他客户端后来获得的锁 |
必须使用一条 SET ... NX PX ... 命令。先 SETNX、再单独 EXPIRE 不是原子操作:如果进程在两条命令之间宕机,就可能留下永不过期的死锁。
3.2 解锁:校验唯一标识后再删除
不能直接执行 DEL lock:account:1001。假设客户端 A 的业务执行太久,锁已经过期;客户端 B 随后获得同名锁。此时 A 直接 DEL,会错误删除 B 的锁。
Redis 8.4 之前通常使用 Lua 脚本,把“比较标识”和“删除”作为一个原子操作执行:
-- 只有锁里的值仍等于自己的唯一标识时,才删除锁。
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
endRedis 8.4 新增了 DELEX key IFEQ value,可以直接完成“值相等才删除”。面试中使用 Lua 回答兼容性更广,但可以补充这个版本变化。
3.3 锁过期了,但业务还没执行完怎么办
锁的有效期必须覆盖正常业务耗时。业务耗时不稳定时,常用成熟客户端的自动续期机制:持锁线程仍在工作时定期延长 TTL,结束后停止续期。Redisson 中常把这类机制称为 watchdog(看门狗)。
自动续期也不是绝对安全。进程长时间停顿、网络分区或 Redis 时钟变化,都可能导致旧持有者以为自己还有锁,而其他客户端已经获得新锁。正确性要求很高时,还要考虑 fencing token(单调递增的令牌),由下游资源拒绝旧令牌的写入。
3.4 Redis 主从切换为什么可能破坏互斥
Redis 主从复制通常是异步的:
客户端 A 在主节点加锁成功
↓ 锁还没复制到从节点
主节点宕机,从节点升级为新主节点
↓
客户端 B 在新主节点也加锁成功于是 A、B 可能同时认为自己持有锁。Redis 官方给出的 Redlock 算法会向多个相互独立的 Redis 主节点加锁,并要求在有效时间内获得多数节点成功;但它也依赖时钟和执行时间等假设。普通业务常使用单 Redis 实例或成熟客户端,在可用性和一致性之间取舍;资金、交易等强一致场景不能只因为“用了 Redis 锁”就认为绝对安全。
3.5 面试常见错误
| 错误做法 | 问题 | 正确思路 |
|---|---|---|
SETNX 后再执行 EXPIRE | 两条命令之间宕机会产生死锁 | 使用 SET key value NX PX ttl |
| 所有请求都写固定 value | 无法判断锁属于谁 | 每次加锁生成唯一标识 |
解锁时直接 DEL | 可能删除别人的锁 | Lua 或 DELEX ... IFEQ ... 校验后删除 |
| TTL 随便写得很短 | 业务未完成锁就失效 | 按耗时设置 TTL,必要时自动续期 |
| 获取失败后立即死循环 | Redis 压力升高,客户端互相争抢 | 有限次数重试,并加入随机退避 |
| 把锁当成幂等方案 | 重试后业务仍可能重复执行 | 配合唯一约束、业务状态和幂等号 |
四、🚀 实践:从加锁到验证
4.1 前置准备
下面使用 Redis 7+ 演示兼容性较好的 SET 与 Lua 方案。没有本地 Redis 时,可以启动一个临时实例:
docker run --name redis-lock-demo -p 6379:6379 -d redis:7-alpine
docker exec -it redis-lock-demo redis-cli4.2 可以拿来干什么
以“同一账号的跨系统变更流程只能被一个实例处理”为例,锁 Key 可以设计成:
lock:account:{accountId}账号 1001 对应 lock:account:1001。不同账号使用不同 Key,可以并行处理;同一账号才互斥。不要只用一个全局 Key,否则所有账号都会排队。
这里锁住的不是普通的昵称或手机号更新,而是“读取账号状态、调用外部系统、发放权益、更新本地状态”这一整段跨系统流程。普通字段更新仍应优先使用数据库能力。
核心流程:
生成唯一标识
→ SET key value NX PX ttl
→ 成功:执行受保护业务
→ finally:校验标识并删除锁
→ 失败:快速返回,或有限重试4.3 完整实践:命令、验证和结果
先由客户端 A 获取锁:
SET lock:account:1001 client-a-uuid NX PX 30000预期返回 OK。查看锁和剩余时间:
GET lock:account:1001
PTTL lock:account:1001客户端 B 再次加锁:
SET lock:account:1001 client-b-uuid NX PX 30000预期返回 (nil),说明同一时间只有客户端 A 成功。
客户端 B 尝试释放 A 的锁:
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:account:1001 client-b-uuid预期返回 0,锁仍然存在。最后由客户端 A 释放:
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:account:1001 client-a-uuid预期返回 1。再次执行 GET lock:account:1001,结果为 (nil)。
Java 项目怎么做
理解原理后,生产项目优先使用 Redisson 等经过验证的客户端能力,不建议每个项目都手写重试、续期和异常处理。无论使用什么库,面试时都要能解释它底层仍然围绕“原子加锁、TTL、唯一标识、安全解锁”工作。
4.4 关键报错排查
- 加锁一直失败:先用
GET、PTTL检查旧锁及其剩余时间。 - 锁不自动过期:检查是否把加锁和设置 TTL 拆成了两条命令。
- 偶发并发执行:检查业务耗时是否超过 TTL,以及是否发生主从切换或长时间停顿。
- 误删锁:检查 value 是否每次唯一,解锁是否执行了原子校验。
- Redis 压力升高:检查失败重试是否没有次数限制和随机退避。
五、🗣️ 面试回答模板
5.1 如何用 Redis 实现分布式锁
我会使用
SET key 唯一值 NX PX 过期时间原子加锁。NX保证 Key 不存在时才能成功,TTL 防止客户端宕机造成死锁,唯一值用于标识锁的持有者。解锁时不能直接DEL,要用 Lua 脚本先比较 value,再删除,避免误删别人后来获得的锁。业务可能超过 TTL 时,需要合理设置过期时间或使用成熟客户端的自动续期机制。最后还要说明 Redis 主从异步复制、锁过期和网络分区都可能影响安全性,所以重要业务仍需配合幂等、唯一约束,强一致场景要选择更合适的协调方案。
5.2 为什么不能用 SETNX 加锁后再设置过期时间
因为这是两条命令。如果
SETNX成功后进程宕机,EXPIRE没有执行,锁就不会自动释放。应使用一条SET key value NX PX ttl命令原子完成。
5.3 为什么解锁需要 Lua 脚本
因为“先 GET 判断、再 DEL 删除”也是两条命令,中间锁可能过期并被别人重新获得。Lua 在 Redis 内原子执行比较和删除,可以避免误删别人的锁。
5.4 系统有很多 Pod,为什么不一定需要分布式锁
一个请求只会被一个 Pod 处理,多 Pod 本身不等于并发冲突。普通账号字段更新通常可以通过数据库行锁、条件更新、乐观锁和唯一约束保证正确性。只有多个 Pod 可能同时操作同一账号,而且必须互斥的是一段无法放进单库事务的跨系统流程时,才考虑 Redis 分布式锁。
六、📌 总结
- 加锁:
SET key uniqueValue NX PX ttl。 - 防死锁:必须设置合理 TTL。
- 防误删:value 必须唯一,解锁时原子地“比较后删除”。
- 防提前过期:估算业务耗时,必要时使用自动续期。
- 多 Pod 不是加锁理由;现有数据库和幂等机制能解决时,不用 Redis 锁。
aboss-sso-group的普通账号修改依赖数据库事务和账号表更新,Redis 主要用于缓存和失效 Token。- Redis 锁不是绝对强一致,也不能代替业务幂等。
记忆句:一条命令加锁,唯一标识认主人,过期时间防死锁,比较成功才能删。