臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、💡 一句话理解

核心结论

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,而是这项业务最终仍以数据库中的一条账号记录为准:

  1. 一个 HTTP 请求只进入一个 Pod。
  2. AccountFacadeImpl.updateAccount 使用 withTransaction(),数据库操作放在事务中执行。
  3. AccountGatewayImpl.update 根据 account_id 更新账号记录,数据库负责实际写入时的并发控制。
  4. 更新后清除并重新加载账号详情缓存,让后续查询读取新数据。

项目虽然引入了 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
参数作用
NXKey 不存在时才写入,保证只有一个客户端成功
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
end

Redis 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-cli

4.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 锁不是绝对强一致,也不能代替业务幂等。

记忆句:一条命令加锁,唯一标识认主人,过期时间防死锁,比较成功才能删。

七、📚 官方资料

7.1 Redis 官方文档