Redis面试常问的相关问题
- Redis 有哪些核心数据结构?各自的使用场景是什么?
- Redis 的 String 类型底层是如何实现的?int、embstr、raw 三种编码的区别?
- HashMap 和 Redis 的 Hash 结构底层实现有什么不同?Redis Hash 的扩容机制?
- Redis 的 List 类型底层是 ziplist 还是 linkedlist?转换条件是什么?
- Redis 的 Set 和 Sorted Set 底层实现分别是什么?Sorted Set 如何实现排序?
- Redis 有哪几种持久化方式?RDB 和 AOF 的原理、优缺点及适用场景?
- Redis 混合持久化的原理是什么?相比单独的 RDB/AOF 有什么优势?
-
[Redis 的过期键删除策略有哪些?为什么不采用定时删除?](#####一、Redis 过期键的三种核心删除策略)
- Redis 的内存淘汰策略有哪些?volatile-lru 和 allkeys-lru 的区别?
- 什么是缓存穿透?如何解决?布隆过滤器的原理及优缺点?
- 什么是缓存击穿?如何解决?热点 key 永不过期的注意事项?
- 什么是缓存雪崩?如何解决?过期时间随机化的实现思路?
-
[Redis 如何实现分布式锁?需要注意哪些问题(原子性、死锁、误删)?](#####Redis 如何实现分布式锁?需要注意哪些问题(原子性、死锁、误删)?)
- Redis 分布式锁和 ZooKeeper 分布式锁的区别及适用场景?
-
[Redis 主从复制的原理是什么?数据同步的流程(全量同步 + 增量同步)?](#####Redis 主从复制的核心原理)
- Redis 哨兵模式的作用?哨兵如何实现主节点故障转移?
- Redis Cluster 集群的哈希槽机制?16384 个哈希槽的设计原因?
- Redis Cluster 如何实现高可用?主节点宕机后从节点如何接管?
- Redis 为什么快?(从内存、IO 模型、数据结构、单线程等角度)
-
Redis 的单线程模型为什么能支撑高并发?单线程的瓶颈是什么?
- Redis 的事务机制?为什么说 Redis 事务是 “弱事务”?
- Redis 的 Pipeline 原理?相比单条命令执行有什么优势?
- Redis 的 Bitmap 如何实现?适用场景(如统计活跃用户)?
- Redis 的 HyperLogLog 原理?为什么能以极小内存统计基数?
- Redis 的 Geo 数据结构原理?如何实现 “附近的人” 功能?
- Redis 主从同步时的数据延迟问题如何解决?
- Redis 中的 BigKey 会带来哪些问题?如何发现和处理 BigKey?
- Redis 的键过期后为什么还会占用内存?
- Redis 如何做内存优化?(数据结构、编码、过期策略等)
- Redis 与 Memcached 的区别?各自的适用场景?
- Redis 的持久化文件损坏如何恢复?
- Redis 集群脑裂问题如何产生?如何解决?
- Redis 的发布订阅模式原理?有什么缺点?
- Redis 的 Lua 脚本作用?为什么能保证原子性?
-
Redis 缓存与数据库双写不一致如何解决?(延时双删、分布式事务等)
Redis 主从复制的核心原理
Redis 主从复制(Master-Slave Replication)的核心目标是:让从节点(Slave)的数据完全镜像主节点(Master)的数据,主节点负责处理写请求,从节点负责处理读请求,实现读写分离和故障备份。
核心设计原则
- 异步复制:主节点处理完写请求后,异步将数据同步给从节点(不阻塞主节点的写操作);
- 从节点只读:从节点默认禁止写操作(可通过
slave-read-only no关闭,但不推荐); - 多从架构:一个主节点可挂载多个从节点,从节点也可作为其他从节点的主节点(级联复制);
- 数据一致性:主从之间通过 “复制偏移量”“运行 ID”“复制积压缓冲区” 保证同步的准确性。
核心概念(理解流程的关键)
| 概念 | 作用 |
|---|---|
| 运行 ID(runid) | 每个 Redis 节点启动时生成的唯一 ID,主节点的 runid 用于从节点识别主节点身份 |
| 复制偏移量(offset) | 主节点每次向从节点同步数据,都会记录已发送的字节数(主节点:master_repl_offset;从节点:slave_repl_offset),用于判断数据是否同步一致 |
| 复制积压缓冲区(backlog) | 主节点维护的一个固定长度的环形缓冲区,存储最近同步的写命令,用于增量同步 |
| PSYNC 命令 | Redis 2.8+ 新增的同步命令,支持全量同步(FULLRESYNC)和增量同步(CONTINUE) |
| RDB 文件 | 主节点生成的内存快照文件,全量同步时用于向从节点传输完整数据 |
二、Redis 主从复制的完整数据同步流程
Redis 主从同步分为两个核心场景:全量同步(初次同步 / 异常恢复) 和 增量同步(正常运行时),PSYNC 命令是连接这两个场景的核心。
前置条件:从节点连接主节点
从节点通过配置 / 命令指定主节点地址,建立连接:
# 方式1:配置文件(redis.conf)replicaof 192.168.1.100 6379 # Redis 5.0+ 用replicaof,旧版本用slaveofmasterauth 123456 # 主节点有密码时配置
# 方式2:运行时命令127.0.0.1:6379> replicaof 192.168.1.100 6379127.0.0.1:6379> config set masterauth 123456场景 1:全量同步(Full Resync)
触发时机:
- 从节点首次连接主节点;
- 从节点失联后重新连接,主节点的复制积压缓冲区中没有对应的偏移量数据;
- 从节点发送的 PSYNC 命令中,主节点的 runid 不匹配(如主节点重启后 runid 变化)。
全量同步完整流程(10 个步骤,附流程图):
graph TD A[从节点发送PSYNC ? -1 命令] --> B[主节点接收命令,判断需全量同步] B --> C[主节点执行BGSAVE生成RDB文件] C --> D[主节点将新写命令写入复制积压缓冲区] D --> E[主节点发送FULLRESYNC + runid + offset给从节点] E --> F[从节点保存runid和offset,清空旧数据] F --> G[主节点发送RDB文件给从节点] G --> H[从节点接收并加载RDB文件(阻塞读请求)] H --> I[主节点发送复制积压缓冲区中的写命令给从节点] I --> J[从节点执行这些命令,同步offset到主节点水平]步骤拆解(通俗易懂版):
- 从节点发起同步请求:从节点连接主节点后,发送
PSYNC ? -1命令(?表示未知主节点 runid,-1表示偏移量为 - 1,请求全量同步); - 主节点准备全量数据:主节点收到请求后,执行
BGSAVE命令(后台生成 RDB 文件,不阻塞主节点写操作); - 主节点缓存新写命令:生成 RDB 期间,主节点将新收到的写命令写入 “复制积压缓冲区”(避免这部分数据丢失);
- 主节点发送同步指令:主节点向从节点发送
FULLRESYNC {runid} {offset}指令,告知从节点 “需要全量同步,我的 runid 是 XXX,当前偏移量是 XXX”; - 从节点准备接收数据:从节点保存主节点的 runid 和 offset,清空自己的旧数据(避免数据冲突);
- 主节点传输 RDB 文件:主节点将生成的 RDB 文件发送给从节点;
- 从节点加载 RDB 文件:从节点接收 RDB 文件后,加载到内存(此阶段从节点阻塞,无法处理读请求);
- 主节点同步增量命令:RDB 传输完成后,主节点将 “复制积压缓冲区” 中的写命令发送给从节点;
- 从节点执行增量命令:从节点执行这些命令,将自己的 offset 同步到主节点的水平,完成全量同步。
场景 2:增量同步(Partial Resync)
触发时机:
- 从节点短暂失联(如网络抖动)后重新连接主节点;
- 主节点的复制积压缓冲区中,仍保存着从节点失联期间的写命令;
- 从节点发送的 PSYNC 命令中,主节点的 runid 匹配,且偏移量在复制积压缓冲区范围内。
增量同步完整流程:
graph TD A[从节点发送PSYNC {runid} {offset}命令] --> B[主节点校验runid和offset] B --> C{校验通过?} C -- 是 --> D[主节点从复制积压缓冲区中,读取offset之后的写命令] D --> E[主节点发送CONTINUE指令+这些写命令给从节点] E --> F[从节点执行命令,更新自己的offset] C -- 否 --> G[发全量同步]步骤拆解:
- 从节点发起增量请求:从节点重新连接主节点后,发送
PSYNC {主节点runid} {自己当前的offset}命令,告知主节点 “我之前同步到了这个偏移量,请求继续同步”; - 主节点校验信息:主节点检查 runid 是否匹配(确认是同一个主节点),并检查从节点的 offset 是否在 “复制积压缓冲区” 的范围内;
- 主节点发送增量命令:校验通过后,主节点从复制积压缓冲区中,读取从节点 offset 之后的所有写命令,发送给从节点;
- 从节点执行命令:从节点执行这些写命令,将自己的 offset 更新到主节点的水平,完成增量同步;
- 校验失败兜底:若 runid 不匹配(如主节点重启)或 offset 超出缓冲区范围,主节点会触发全量同步。
三、关键细节与实战注意事项
1. 复制积压缓冲区的核心作用
- 是主节点维护的固定长度环形缓冲区(默认 1MB,可通过
repl-backlog-size配置); - 仅存储最近的写命令,用于增量同步,缓冲区越大,支持的从节点失联时间越长;
- 实战建议:根据业务写 QPS 调整大小(如写 QPS=10 万 /s,需支持 10 秒失联,则配置
repl-backlog-size 1GB)。
2. 异步复制的 “数据延迟” 问题
-
主节点写操作完成后,异步将命令发送给从节点,因此从节点数据会比主节点 “慢一拍”(延迟通常在毫秒级);
-
实战优化:
- 减少主从节点的网络延迟(同机房部署);
- 避免主节点执行大命令(如
KEYS *),导致同步阻塞; - 通过
info replication查看slave_repl_offset和master_repl_offset,监控同步延迟。
3. 主节点 BGSAVE 阻塞问题
-
全量同步时,主节点执行
BGSAVE生成 RDB 文件,若数据量过大(如 10GB),BGSAVE会消耗大量 CPU / 磁盘 IO,可能导致主节点短暂卡顿; -
实战优化:
- 主节点配置
rdbcompression yes(压缩 RDB 文件,减少传输时间); - 避开业务高峰期进行首次全量同步;
- 级联复制(从节点挂载到从节点上,减少主节点的同步压力)。
- 主节点配置
4. 从节点加载 RDB 的阻塞问题
-
从节点加载 RDB 文件时会阻塞所有读请求,若 RDB 文件过大,阻塞时间会很长;
-
实战优化:
- 从节点配置
repl-disable-tcp-nodelay no(开启 TCP_NODELAY,减少同步延迟); - 从节点使用固态硬盘(SSD),提升 RDB 加载速度;
- 分批启动从节点,避免多个从节点同时请求全量同步,压垮主节点。
- 从节点配置
5. 主从切换的注意事项
- 主节点宕机后,从节点可通过
replicaof no one升级为主节点; - 新主节点的 runid 会变化,其他从节点重新连接时会触发全量同步,需提前配置足够大的复制积压缓冲区。
四、Redis 主从复制 vs 哨兵模式 vs Cluster 模式
| 模式 | 核心能力 | 同步方式 | 适用场景 |
|---|---|---|---|
| 主从复制 | 读写分离、数据备份 | 全量 + 增量同步 | 低并发、简单高可用需求 |
| 哨兵模式 | 主从复制 + 自动故障转移 | 基于主从复制的同步 | 中并发、自动容灾需求 |
| Cluster 模式 | 分片 + 主从 + 自动故障转移 | 分片内主从同步 | 高并发、大数据量、水平扩展 |
总结
-
主从复制核心原理:主节点异步同步数据到从节点,通过 runid 识别主节点身份,通过 offset 保证数据同步一致性,复制积压缓冲区优化增量同步效率;
-
数据同步流程
- 全量同步:初次连接 / 异常恢复时,主节点生成 RDB 文件 + 传输缓冲区命令,从节点加载 RDB 并执行命令;
- 增量同步:短暂失联后,主节点从复制积压缓冲区发送失联期间的命令,从节点执行完成同步;
-
关键注意事项
- 复制积压缓冲区大小需适配业务场景,避免增量同步降级为全量同步;
- 关注主节点 BGSAVE 和从节点加载 RDB 的阻塞问题;
- 监控同步延迟,保证读写分离场景下的数据一致性。
关键点:Redis 主从复制的核心是 “异步、高效、容错”—— 全量同步解决 “初始数据一致” 问题,增量同步解决 “运行时数据一致” 问题,而复制积压缓冲区则是连接两者的关键,实战中需重点优化缓冲区大小、网络延迟、磁盘 IO 等环节。
一、Redis 过期键的三种核心删除策略
Redis 中设置过期时间的键(如 SET key val EX 60),核心有三种理论删除策略,每种策略的设计思路和优缺点截然不同:
| 策略类型 | 核心原理 | 优点 | 缺点 |
|---|---|---|---|
| 定时删除 | 在设置键过期时间的同时,创建定时器(timer),到期后立即删除该键 | 过期键立即清理,内存干净 | 1. 大量定时器占用 CPU;2. 高并发下性能差 |
| 惰性删除 | 不主动删除过期键,仅在访问该键时检查是否过期,过期则删除并返回 null | 节省 CPU,仅在必要时删除 | 过期键长期占用内存,可能导致内存泄漏 |
| 定期删除 | 每隔一段时间(如 100ms),主动扫描部分过期键并删除,控制扫描频率和时长 | 平衡 CPU 和内存消耗 | 过期键可能延迟删除,仍有少量内存浪费 |
1. 定时删除(Timed Deletion)
-
执行逻辑
当你给键设置
EX 60时,Redis 会创建一个定时器,60 秒后定时器触发,Redis 立即删除该键。
-
核心问题
若 Redis 中有 10 万个过期键,就需要维护 10 万个定时器,定时器的创建、管理会占用大量 CPU 资源;高并发场景下,CPU 会被定时器调度占满,导致 Redis 处理业务请求的能力大幅下降。
2. 惰性删除(Lazy Deletion)
-
执行逻辑
过期键不会被主动删除,只有当你执行
GET key时,Redis 才会检查该键是否过期:
- 未过期:正常返回值;
- 已过期:删除该键,返回
(nil)。
-
核心优势
完全不消耗 CPU 资源在 “提前删除” 上,仅在用户访问时做检查,是 “按需处理” 的思路。
-
核心问题
若一个过期键长期不被访问,会一直占用内存(比如设置了
EX 60的键,60 天后才被访问),极端情况下会导致 Redis 内存耗尽。
3. 定期删除(Periodic Deletion)
-
执行逻辑
Redis 会启动一个后台线程,每隔固定时间(默认 100ms,可配置)执行一次 “过期键扫描”:
- 随机选取一定数量(如 20 个)的带过期时间的键;
- 检查这些键是否过期,删除已过期的;
- 若过期键占比超过 25%,则重复步骤 1-2(避免单次扫描耗时过久);
- 单次扫描有时间上限(如 25ms),避免阻塞主线程。
-
核心优势
既不会像定时删除那样消耗大量 CPU,也不会像惰性删除那样导致内存泄漏,是 “折中方案”。
-
核心问题
键的删除存在延迟(比如 100ms 后才被扫描到),但延迟可控,且不会长期占用内存。
二、为什么 Redis 不单独采用定时删除?
Redis 作为高性能的内存数据库,CPU 资源是核心瓶颈,定时删除的设计完全违背了 Redis 的性能优先原则,具体原因有三:
1. 定时器的 CPU 开销不可接受
- Redis 是单线程(主线程处理业务请求)+ 多后台线程(处理慢操作)架构,定时删除的定时器若由主线程管理,会阻塞业务请求;若由后台线程管理,大量定时器的调度、触发会占用后台线程的全部资源。
- 举例:若有 10 万个过期键,每个定时器的创建、销毁、触发都需要 CPU 计算,Redis 的 QPS 会从 10 万 + 骤降,失去高性能优势。
2. 高并发场景下的性能抖动
- 定时删除的定时器触发是 “突发式” 的:比如某一时刻有 1 万个定时器同时触发,Redis 会集中删除这 1 万个键,导致该时间段内业务请求响应超时,出现性能抖动。
- 而定期删除是 “匀速扫描”,单次扫描有时间上限,能保证 Redis 的响应延迟稳定。
3. 无实际必要(混合策略已足够)
- Redis 的核心诉求是 “高性能”,而非 “绝对实时的过期键删除”—— 即使过期键延迟几秒删除,只要内存不泄漏,对大部分业务无影响。
- 定时删除的 “实时性” 收益,远低于其 CPU 开销的成本,属于 “过度设计”。
三、Redis 实际的过期键删除策略:混合策略(惰性删除 + 定期删除)
Redis 没有单独采用某一种策略,而是结合了惰性删除 + 定期删除,既保证性能,又避免内存泄漏:
混合策略的执行流程
graph TD A[Redis 运行中] --> B[客户端访问键] B --> C{键是否过期?} C -- 是 --> D[惰性删除:删除该键,返回nil] C -- 否 --> E[返回键值]
A --> F[后台线程定期扫描(每100ms)] F --> G[随机选取20个带过期时间的键] G --> H{键是否过期?} H -- 是 --> I[删除过期键] H -- 否 --> J[跳过] G --> K{过期键占比>25%?} K -- 是 --> G K -- 否 --> L[结束本次扫描]
A --> M[内存达到maxmemory阈值] M --> N[触发内存淘汰策略] N --> O[淘汰部分键(含过期键)]补充:内存淘汰策略(最终兜底)
即使有了惰性 + 定期删除,若过期键仍未被清理且 Redis 内存达到 maxmemory 阈值,Redis 会触发内存淘汰策略(如 volatile-lru:淘汰最近最少使用的过期键),进一步保证内存不溢出。
四、过期键删除的额外注意事项
-
过期键的持久化影响
- RDB:生成 RDB 文件时,会跳过过期键;加载 RDB 文件时,主库会跳过过期键,从库会保留(主从同步后再删除)。
- AOF:过期键被删除时,会往 AOF 文件写入
DEL key命令;AOF 重写时,会跳过过期键。
-
主从同步的过期键处理
过期键的删除操作由主库执行,主库删除后会向从库发送
DEL key命令,从库被动删除(从库不主动执行过期扫描),保证主从数据一致。
-
过期时间的精度
Redis 的过期时间精度是毫秒级,但定期删除的扫描间隔是 100ms,因此过期键的实际删除时间可能有 ±100ms 的误差,属于正常现象。
总结
-
核心删除策略:Redis 有定时删除、惰性删除、定期删除三种理论策略,实际采用 “惰性删除 + 定期删除” 的混合策略;
-
不采用定时删除的原因
- 大量定时器占用 CPU 资源,导致性能下降;
- 高并发下易出现性能抖动;
- 实时性收益远低于成本,无实际必要;
-
混合策略的优势
惰性删除保证 CPU 不被浪费,定期删除避免内存泄漏,内存淘汰策略作为最终兜底,平衡了性能和内存管理。
关键点:Redis 设计过期键删除策略的核心原则是 “性能优先,兼顾内存”—— 宁可让过期键延迟删除,也不允许 CPU 资源被过度消耗,这是 Redis 高性能的核心设计思路之一。
Redis 如何实现分布式锁?需要注意哪些问题(原子性、死锁、误删)?
你想知道 Redis 实现分布式锁的核心方法,以及在实现过程中需要解决的原子性、死锁、误删等关键问题 —— 分布式锁是解决分布式系统中资源竞争的核心方案,Redis 因其高性能和易用性成为最主流的实现方式,我会从核心实现原理、关键问题解决方案、完整代码示例、注意事项四个维度,讲透 Redis 分布式锁的落地细节。
一、Redis 分布式锁的核心实现原理
分布式锁的核心要求是:在分布式集群中,同一时刻只有一个客户端能获取到锁。Redis 实现分布式锁的核心是利用 SETNX(SET if Not Exists)命令的原子性 —— 只有当锁 Key 不存在时,才能设置成功(获取锁),否则设置失败(锁已被占用)。
基础命令解析
| 命令 | 作用 |
|---|---|
SETNX lock_key val | 原子操作:若 lock_key 不存在,设置值并返回 1(获取锁成功);否则返回 0(失败) |
DEL lock_key | 删除锁 Key(释放锁) |
EXPIRE lock_key ttl | 为锁 Key 设置过期时间(避免死锁) |
二、Redis 分布式锁的实现步骤(从基础到完善)
步骤 1:基础版实现(存在死锁 / 误删问题)
# 客户端A获取锁(成功)127.0.0.1:6379> SETNX order_lock 1(integer) 1
# 客户端B获取锁(失败,锁已被占用)127.0.0.1:6379> SETNX order_lock 1(integer) 0
# 客户端A释放锁127.0.0.1:6379> DEL order_lock(integer) 1问题:
- 死锁:若客户端 A 获取锁后宕机,未执行
DEL,锁 Key 会永久存在,其他客户端无法获取; - 无过期时间:即使未宕机,业务执行超时也会导致锁无法释放。
步骤 2:优化版(添加过期时间,解决死锁)
Redis 2.6.12+ 支持 SET 命令整合 NX(等价 SETNX)和 EX(过期时间),原子性设置锁 + 过期时间(解决基础版 “SETNX+EXPIRE” 非原子的问题):
# 原子操作:设置锁Key,值为1,过期时间30秒,仅当Key不存在时设置127.0.0.1:6379> SET order_lock 1 NX EX 30OK
# 释放锁127.0.0.1:6379> DEL order_lock(integer) 1核心改进:
- 原子性:
SET key val NX EX ttl是单条命令,避免 “SETNX 成功但 EXPIRE 失败” 导致的死锁; - 过期时间:30 秒后锁自动释放,即使客户端宕机也不会永久占用锁。
仍存在问题:
- 误删锁:客户端 A 的锁因超时自动释放,客户端 B 获取锁,此时 A 业务执行完成,执行
DEL会误删客户端 B 的锁; - 过期时间难设置:过期时间太短,业务未执行完锁就释放;太长,锁释放延迟。
步骤 3:最终版(解决误删 + 自动续期)
核心优化点:
- 锁值唯一化:用 UUID + 客户端 ID 作为锁值,释放锁时先校验值是否为自己的,再删除(避免误删);
- Lua 脚本释放锁:校验值 + 删除锁是原子操作(避免校验后锁过期,删除别人的锁);
- 自动续期(看门狗):业务执行超时前,自动延长锁的过期时间(避免锁提前释放)。
完整 Lua 释放锁脚本(原子校验 + 删除)
-- 入参:KEYS[1]=锁Key,ARGV[1]=客户端持有的锁值if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) -- 校验通过,删除锁else return 0 -- 校验失败,不删除end三、Java 代码实现(完整可落地)
1. 核心工具类(解决原子性、死锁、误删)
import org.springframework.data.redis.core.RedisTemplate;import org.springframework.data.redis.core.script.DefaultRedisScript;import org.springframework.stereotype.Component;
import javax.annotation.Resource;import java.util.Collections;import java.util.UUID;import java.util.concurrent.TimeUnit;import java.util.concurrent.locks.Condition;import java.util.concurrent.locks.Lock;
@Componentpublic class RedisDistributedLock implements Lock { @Resource private RedisTemplate<String, Object> redisTemplate;
// 锁Key前缀 private static final String LOCK_PREFIX = "dist_lock:"; // 锁默认过期时间(秒) private static final long DEFAULT_TTL = 30; // 客户端唯一标识(避免误删) private final String lockValue; // 当前锁Key private String lockKey;
public RedisDistributedLock() { // 生成唯一值(UUID+线程ID) this.lockValue = UUID.randomUUID().toString() + "-" + Thread.currentThread().getId(); }
/** * 获取锁(阻塞式,直到获取成功) */ @Override public void lock() { while (!tryLock()) { // 自旋等待,可优化为阻塞(如Thread.sleep(100)) Thread.yield(); } }
/** * 尝试获取锁(非阻塞) * @return true-成功,false-失败 */ @Override public boolean tryLock() { return tryLock(DEFAULT_TTL, TimeUnit.SECONDS); }
/** * 尝试获取锁(带超时时间) */ @Override public boolean tryLock(long time, TimeUnit unit) { long ttl = unit.toSeconds(time); // 原子操作:设置锁Key + 唯一值 + 过期时间 Boolean success = redisTemplate.opsForValue().setIfAbsent( lockKey, lockValue, ttl, TimeUnit.SECONDS ); // 开启看门狗(自动续期) if (Boolean.TRUE.equals(success)) { startWatchDog(ttl); } return Boolean.TRUE.equals(success); }
/** * 释放锁(原子校验+删除) */ @Override public void unlock() { // Lua脚本:校验锁值是否为当前客户端的,是则删除 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); redisScript.setScriptText(script); redisScript.setResultType(Long.class);
// 执行脚本 Long result = redisTemplate.execute( redisScript, Collections.singletonList(lockKey), lockValue );
// 释放看门狗 stopWatchDog(); }
/** * 看门狗:每隔10秒续期一次,延长锁过期时间 */ private void startWatchDog(long ttl) { // 用线程池执行续期任务(避免创建过多线程) new Thread(() -> { while (true) { try { // 续期间隔:过期时间的1/3(如30秒→10秒) Thread.sleep(ttl * 1000 / 3); // 原子续期:仅当锁存在且值匹配时,重置过期时间 String renewScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end"; DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText(renewScript); script.setResultType(Long.class);
Long result = redisTemplate.execute( script, Collections.singletonList(lockKey), lockValue, String.valueOf(ttl) );
// 续期失败(锁已释放/过期),退出循环 if (result == 0) { break; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }).start(); }
private void stopWatchDog() { // 实际开发中需用线程池管理续期线程,此处简化为标记退出 }
// 以下为Lock接口默认实现,无需关注 @Override public void lockInterruptibly() throws InterruptedException {} @Override public Condition newCondition() { return null; }}2. 使用示例(扣库存场景)
@Servicepublic class StockService { @Resource private StockMapper stockMapper; @Resource private RedisDistributedLock redisLock;
public void deductStock(Long productId) { // 设置锁Key(按商品ID粒度,避免全局锁) redisLock.setLockKey("dist_lock:stock:" + productId);
try { // 获取锁 redisLock.lock();
// 执行业务:扣库存 Stock stock = stockMapper.selectById(productId); if (stock.getNum() > 0) { stockMapper.deductNum(productId); System.out.println("库存扣减成功,剩余:" + (stock.getNum() - 1)); } else { System.out.println("库存不足"); } } finally { // 释放锁(必须在finally中,避免业务异常导致锁未释放) redisLock.unlock(); } }}四、实现分布式锁需注意的核心问题
1. 原子性问题(最核心)
- 问题场景:基础版 “SETNX + EXPIRE” 是两条命令,若 SETNX 成功后,EXPIRE 执行前客户端宕机,锁 Key 无过期时间,导致死锁;
- 解决方案:使用
SET key val NX EX ttl原子命令,或 Lua 脚本保证多操作原子性(如释放锁时 “校验值 + 删除”)。
2. 死锁问题
-
问题场景:客户端获取锁后宕机 / 网络中断,未释放锁,其他客户端无法获取;
-
解决方案
- 为锁 Key 设置合理的过期时间(如 30 秒),即使未手动释放,也会自动过期;
- 看门狗机制:业务执行超时前,自动续期锁的过期时间(避免锁提前释放)。
3. 误删锁问题
-
问题场景:客户端 A 的锁因超时释放,客户端 B 获取锁,A 业务执行完成后执行 DEL,误删 B 的锁;
-
解决方案
- 锁值唯一化:用 UUID + 客户端 ID 作为锁值,而非固定值;
- 原子释放锁:通过 Lua 脚本先校验锁值是否为自己的,再删除(避免 “校验 + 删除” 非原子)。
4. 锁粒度问题
- 问题场景:使用全局锁(如
lock:all),所有业务竞争同一把锁,导致并发性能极低; - 解决方案:按业务维度细化锁粒度(如商品 ID、订单 ID),如
lock:stock:1001(仅锁定 1001 号商品的库存)。
5. Redis 集群脑裂问题
-
问题场景:Redis 主从集群脑裂,主节点宕机后,从节点升级为主节点,但原主节点仍在处理请求,导致多个客户端获取到锁;
-
解决方案
- 使用 Redis Redlock 算法(多实例部署,需过半节点获取锁才算成功);
- 配置 Redis 主从的
min-replicas-to-write参数,减少脑裂概率。
6. 重入性问题
-
问题场景:同一客户端获取锁后,再次请求获取锁时失败(非重入锁);
-
解决方案
- 锁值存储 “客户端 ID + 重入次数”,获取锁时若为自己的锁,重入次数 + 1;
- 释放锁时重入次数 - 1,仅当次数为 0 时删除锁 Key。
五、Redis 分布式锁 vs Zookeeper 分布式锁
| 特性 | Redis 分布式锁 | Zookeeper 分布式锁 |
|---|---|---|
| 性能 | 高(内存操作,无磁盘 IO) | 中(需创建临时节点,有网络开销) |
| 可靠性 | 中(集群脑裂可能导致多锁) | 高(临时节点 + Watcher 机制) |
| 实现复杂度 | 低(命令 / 脚本简单) | 中(需处理 Watcher 事件) |
| 重入性 | 需自定义实现 | 原生支持 |
| 阻塞获取锁 | 需自旋实现 | 原生支持(Watcher 通知) |
| 适用场景 | 高并发、短持有时间(如库存扣减) | 低并发、长持有时间(如分布式任务调度) |
总结
-
Redis 分布式锁核心实现
- 原子获取锁:
SET lock_key 唯一值 NX EX ttl; - 原子释放锁:Lua 脚本校验锁值 + 删除;
- 防死锁:过期时间 + 看门狗续期;
- 防误删:锁值唯一化。
- 原子获取锁:
-
核心注意事项
- 必须保证操作原子性(避免多命令拆分);
- 锁 Key 需设置过期时间,避免死锁;
- 释放锁前校验锁值,避免误删;
- 细化锁粒度,提升并发性能。
-
选型建议
- 高并发短锁场景选 Redis(性能优先);
- 高可靠长锁场景选 Zookeeper(一致性优先)。
关键点:Redis 分布式锁的核心是 “原子性” 和 “容错性”—— 所有操作必须保证原子,同时通过过期时间、看门狗、唯一锁值等机制,应对宕机、超时、并发等异常场景。
一、先理解:缓存与数据库双写不一致的核心成因
缓存和数据库双写的核心矛盾是:两个独立存储的写操作无法原子化,常见不一致场景有 2 类:
场景 1:更新操作顺序错误(并发更新)
假设存在两个请求同时更新同一数据:
-
请求 A:更新数据库 → 删除缓存(正常流程)
-
请求 B:查询缓存(未命中)→ 查询数据库(旧值)→ 写入缓存
-
时序异常:A 更新数据库 → B 查询数据库(旧值)→ A 删除缓存 → B 写入缓存(旧值)
→ 最终缓存是旧值,数据库是新值,不一致。
场景 2:删除 / 更新缓存失败(网络 / 宕机)
-
请求 A:更新数据库成功 → 删除缓存时网络超时 / Redis 宕机 → 缓存未删除
→ 后续查询会读取缓存中的旧值,不一致。
场景 3:并发读写
-
请求 A:读取缓存(未命中)→ 查询数据库(旧值)
-
请求 B:更新数据库(新值)→ 删除缓存
-
时序异常:A 查询数据库(旧值)→ B 更新数据库 + 删除缓存 → A 写入缓存(旧值)
→ 缓存存旧值,数据库存新值。
二、核心解决方案(按落地难度 / 适用场景分类)
方案 1:延时双删(最简单,适配大部分场景)
原理
更新数据库后,先删除一次缓存,等待一段时间(如 500ms),再删除一次缓存 —— 目的是覆盖 “并发读写导致的旧值写入缓存” 场景。
完整流程
graph TD A[收到更新请求] --> B[更新数据库] B --> C[第一次删除缓存] C --> D[延时N毫秒(如500ms)] D --> E[第二次删除缓存] E --> F[操作完成]实操代码(Java 示例)
@Servicepublic class UserService { @Resource private UserMapper userMapper; @Resource private StringRedisTemplate redisTemplate; // 延时时间(根据业务QPS调整,建议500ms~1s) private static final long DELAY_TIME = 500;
public void updateUser(User user) { // 1. 更新数据库 userMapper.updateById(user); String cacheKey = "user:" + user.getId();
// 2. 第一次删除缓存 redisTemplate.delete(cacheKey);
// 3. 延时后第二次删除(异步执行,避免阻塞主线程) CompletableFuture.runAsync(() -> { try { Thread.sleep(DELAY_TIME); redisTemplate.delete(cacheKey); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); }}关键细节
- 延时时间:需大于 “一次查询缓存 + 查询数据库 + 写入缓存” 的耗时(一般 500ms 足够);
- 异步执行:第二次删除需异步(如线程池 / 消息队列),避免阻塞业务流程;
- 适用场景:读多写少、一致性要求不极致(允许短暂不一致)的场景(如商品详情、用户信息)。
方案 2:缓存更新 + 分布式锁(强一致性,适配高并发写)
原理
通过分布式锁(Redis/Zookeeper)保证 “更新数据库 + 更新缓存” 的原子性,同时避免并发读写导致的不一致。
完整流程
graph TD A[收到更新请求] --> B[获取分布式锁(key=user:{id})] B --> C{获取锁成功?} C -- 否 --> D[重试/返回失败] C -- 是 --> E[更新数据库] E --> F[更新缓存(设置过期时间)] F --> G[释放分布式锁] G --> H[操作完成]
I[收到查询请求] --> J[查询缓存(命中则返回)] J --> K{缓存命中?} K -- 是 --> L[返回数据] K -- 否 --> M[获取分布式锁] M --> N{获取锁成功?} N -- 否 --> O[重试查询缓存/返回降级数据] N -- 是 --> P[查询数据库] P --> Q[写入缓存(设置过期时间)] Q --> R[释放锁] R --> L实操代码(分布式锁 + 更新缓存)
public void updateUserWithLock(User user) { String lockKey = "lock:user:" + user.getId(); String cacheKey = "user:" + user.getId(); // 1. 获取分布式锁(超时时间30s,避免死锁) Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 2. 更新数据库 userMapper.updateById(user); // 3. 更新缓存(设置过期时间,兜底) redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 1, TimeUnit.HOURS); } finally { // 4. 释放锁 redisTemplate.delete(lockKey); } } else { // 获取锁失败,重试(或抛出异常) throw new RuntimeException("更新频繁,请稍后再试"); }}关键细节
- 锁粒度:按数据 ID 加锁(如 user:1001),避免全局锁影响性能;
- 锁超时:必须设置锁超时,防止服务宕机导致死锁;
- 适用场景:写并发高、一致性要求高的场景(如订单状态、库存)。
方案 3:基于 binlog 的缓存更新(最终一致性,无侵入)
原理
通过监听数据库 binlog(如 Canal 监听 MySQL binlog),异步更新 / 删除缓存,完全解耦业务代码和缓存操作。
完整流程
查看代码
graph TD A[业务服务更新数据库] --> B[MySQL写入binlog] B --> C[Canal 监听binlog并解析] C --> D[判断操作类型(增/删/改)] D --> E{操作类型} E -- 改/删 --> F[删除对应缓存] E -- 增 --> G[写入缓存] F --> H[操作完成] G --> H关键细节
- 无业务侵入:无需修改业务代码,仅通过中间件监听 binlog;
- 最终一致性:binlog 解析和缓存更新是异步的,存在短暂不一致,但最终会一致;
- 适用场景:大规模微服务、业务代码不想耦合缓存逻辑的场景。
方案 4:分布式事务(极致一致性,适配金融级场景)
原理
通过分布式事务(如 Seata TCC/2PC)保证 “数据库更新” 和 “缓存更新 / 删除” 的原子性 —— 要么都成功,要么都回滚。
流程(以 TCC 为例)
- Try 阶段:锁定数据库数据 + 标记缓存待更新;
- Confirm 阶段:提交数据库更新 + 删除 / 更新缓存;
- Cancel 阶段:回滚数据库更新 + 恢复缓存旧值。
关键细节
- 实现复杂:需改造业务代码,开发成本高;
- 性能损耗:分布式事务会增加网络开销,降低吞吐量;
- 适用场景:金融级场景(如转账、支付),一致性要求极高。
三、各方案对比与选型建议
| 方案 | 一致性级别 | 实现难度 | 性能 | 适用场景 |
|---|---|---|---|---|
| 延时双删 | 最终一致(短暂不一致) | 极低 | 高 | 读多写少、非核心数据(商品详情) |
| 缓存更新 + 分布式锁 | 强一致 | 中 | 中 | 高并发写、核心数据(库存、订单) |
| binlog 监听 | 最终一致 | 中 | 高 | 大规模微服务、低侵入需求 |
| 分布式事务 | 强一致 | 高 | 低 | 金融级、极致一致性(支付、转账) |
四、通用保障措施(无论选哪种方案都要加)
1. 缓存设置过期时间(兜底)
所有缓存都必须设置过期时间(如 1 小时),即使出现不一致,过期后也会自动失效,重新从数据库加载最新值。
2. 读写分离场景的特殊处理
若数据库是读写分离架构,需注意:
- 更新操作走主库,查询操作若缓存未命中,需查主库(避免从库同步延迟导致的旧值);
- 或等待从库同步完成后再写入缓存。
3. 避免缓存更新,优先删除缓存
“更新缓存” 易出现并发问题,建议优先选择 “删除缓存”—— 查询时缓存未命中,再从数据库加载最新值写入,减少不一致概率。
总结
-
核心矛盾:缓存与数据库双写不一致的本质是 “两个独立存储的写操作无法原子化”,并发读写 / 操作失败是主要诱因;
-
方案选型
:
- 简单场景选「延时双删」,成本最低;
- 高并发写选「分布式锁 + 缓存更新」,兼顾一致性和性能;
- 大规模微服务选「binlog 监听」,低侵入;
- 金融级场景选「分布式事务」,极致一致;
-
通用兜底:缓存设置过期时间、优先删除缓存而非更新,是避免长期不一致的关键。
关键点:没有 “银弹” 方案,需根据业务的一致性要求、并发量、开发成本选择合适的方案,大部分业务场景下,延时双删 + 过期时间已足够。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!