Redis面试常问的相关问题

87461 字
437 分钟
Redis面试常问的相关问题
  1. Redis 有哪些核心数据结构?各自的使用场景是什么?
  2. Redis 的 String 类型底层是如何实现的?int、embstr、raw 三种编码的区别?
  3. HashMap 和 Redis 的 Hash 结构底层实现有什么不同?Redis Hash 的扩容机制?
  4. Redis 的 List 类型底层是 ziplist 还是 linkedlist?转换条件是什么?
  5. Redis 的 Set 和 Sorted Set 底层实现分别是什么?Sorted Set 如何实现排序?
  6. Redis 有哪几种持久化方式?RDB 和 AOF 的原理、优缺点及适用场景?
  7. Redis 混合持久化的原理是什么?相比单独的 RDB/AOF 有什么优势?
  8. [Redis 的过期键删除策略有哪些?为什么不采用定时删除?](#####一、Redis 过期键的三种核心删除策略)#
  9. Redis 的内存淘汰策略有哪些?volatile-lru 和 allkeys-lru 的区别?
  10. 什么是缓存穿透?如何解决?布隆过滤器的原理及优缺点?
  11. 什么是缓存击穿?如何解决?热点 key 永不过期的注意事项?
  12. 什么是缓存雪崩?如何解决?过期时间随机化的实现思路?
  13. [Redis 如何实现分布式锁?需要注意哪些问题(原子性、死锁、误删)?](#####Redis 如何实现分布式锁?需要注意哪些问题(原子性、死锁、误删)?)#
  14. Redis 分布式锁和 ZooKeeper 分布式锁的区别及适用场景?
  15. [Redis 主从复制的原理是什么?数据同步的流程(全量同步 + 增量同步)?](#####Redis 主从复制的核心原理)#
  16. Redis 哨兵模式的作用?哨兵如何实现主节点故障转移?
  17. Redis Cluster 集群的哈希槽机制?16384 个哈希槽的设计原因?
  18. Redis Cluster 如何实现高可用?主节点宕机后从节点如何接管?
  19. Redis 为什么快?(从内存、IO 模型、数据结构、单线程等角度)
  20. Redis 的单线程模型为什么能支撑高并发?单线程的瓶颈是什么?#
  21. Redis 的事务机制?为什么说 Redis 事务是 “弱事务”?
  22. Redis 的 Pipeline 原理?相比单条命令执行有什么优势?
  23. Redis 的 Bitmap 如何实现?适用场景(如统计活跃用户)?
  24. Redis 的 HyperLogLog 原理?为什么能以极小内存统计基数?
  25. Redis 的 Geo 数据结构原理?如何实现 “附近的人” 功能?
  26. Redis 主从同步时的数据延迟问题如何解决?
  27. Redis 中的 BigKey 会带来哪些问题?如何发现和处理 BigKey?
  28. Redis 的键过期后为什么还会占用内存?
  29. Redis 如何做内存优化?(数据结构、编码、过期策略等)
  30. Redis 与 Memcached 的区别?各自的适用场景?
  31. Redis 的持久化文件损坏如何恢复?
  32. Redis 集群脑裂问题如何产生?如何解决?
  33. Redis 的发布订阅模式原理?有什么缺点?
  34. Redis 的 Lua 脚本作用?为什么能保证原子性?
  35. Redis 缓存与数据库双写不一致如何解决?(延时双删、分布式事务等)#
Redis 主从复制的核心原理#

Redis 主从复制(Master-Slave Replication)的核心目标是:让从节点(Slave)的数据完全镜像主节点(Master)的数据,主节点负责处理写请求,从节点负责处理读请求,实现读写分离和故障备份。

核心设计原则#

  1. 异步复制:主节点处理完写请求后,异步将数据同步给从节点(不阻塞主节点的写操作);
  2. 从节点只读:从节点默认禁止写操作(可通过 slave-read-only no 关闭,但不推荐);
  3. 多从架构:一个主节点可挂载多个从节点,从节点也可作为其他从节点的主节点(级联复制);
  4. 数据一致性:主从之间通过 “复制偏移量”“运行 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,旧版本用slaveof
masterauth 123456 # 主节点有密码时配置
# 方式2:运行时命令
127.0.0.1:6379> replicaof 192.168.1.100 6379
127.0.0.1:6379> config set masterauth 123456

场景 1:全量同步(Full Resync)#

触发时机

  • 从节点首次连接主节点;
  • 从节点失联后重新连接,主节点的复制积压缓冲区中没有对应的偏移量数据;
  • 从节点发送的 PSYNC 命令中,主节点的 runid 不匹配(如主节点重启后 runid 变化)。

全量同步完整流程(10 个步骤,附流程图):

从节点发送PSYNC ? -1 命令

主节点接收命令,判断需全量同步

主节点执行BGSAVE生成RDB文件

主节点将新写命令写入复制积压缓冲区

主节点发送FULLRESYNC + runid + offset给从节点

从节点保存runid和offset,清空旧数据

主节点发送RDB文件给从节点

从节点接收并加载RDB文件(阻塞读请求)

主节点发送复制积压缓冲区中的写命令给从节点

从节点执行这些命令,同步offset到主节点水平

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 命令

主节点接收命令,判断需全量同步

主节点执行BGSAVE生成RDB文件

主节点将新写命令写入复制积压缓冲区

主节点发送FULLRESYNC + runid + offset给从节点

从节点保存runid和offset,清空旧数据

主节点发送RDB文件给从节点

从节点接收并加载RDB文件(阻塞读请求)

主节点发送复制积压缓冲区中的写命令给从节点

从节点执行这些命令,同步offset到主节点水平

步骤拆解(通俗易懂版)

  1. 从节点发起同步请求:从节点连接主节点后,发送 PSYNC ? -1 命令(? 表示未知主节点 runid,-1 表示偏移量为 - 1,请求全量同步);
  2. 主节点准备全量数据:主节点收到请求后,执行 BGSAVE 命令(后台生成 RDB 文件,不阻塞主节点写操作);
  3. 主节点缓存新写命令:生成 RDB 期间,主节点将新收到的写命令写入 “复制积压缓冲区”(避免这部分数据丢失);
  4. 主节点发送同步指令:主节点向从节点发送 FULLRESYNC {runid} {offset} 指令,告知从节点 “需要全量同步,我的 runid 是 XXX,当前偏移量是 XXX”;
  5. 从节点准备接收数据:从节点保存主节点的 runid 和 offset,清空自己的旧数据(避免数据冲突);
  6. 主节点传输 RDB 文件:主节点将生成的 RDB 文件发送给从节点;
  7. 从节点加载 RDB 文件:从节点接收 RDB 文件后,加载到内存(此阶段从节点阻塞,无法处理读请求);
  8. 主节点同步增量命令:RDB 传输完成后,主节点将 “复制积压缓冲区” 中的写命令发送给从节点;
  9. 从节点执行增量命令:从节点执行这些命令,将自己的 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[发全量同步]

步骤拆解

  1. 从节点发起增量请求:从节点重新连接主节点后,发送 PSYNC {主节点runid} {自己当前的offset} 命令,告知主节点 “我之前同步到了这个偏移量,请求继续同步”;
  2. 主节点校验信息:主节点检查 runid 是否匹配(确认是同一个主节点),并检查从节点的 offset 是否在 “复制积压缓冲区” 的范围内;
  3. 主节点发送增量命令:校验通过后,主节点从复制积压缓冲区中,读取从节点 offset 之后的所有写命令,发送给从节点;
  4. 从节点执行命令:从节点执行这些写命令,将自己的 offset 更新到主节点的水平,完成增量同步;
  5. 校验失败兜底:若 runid 不匹配(如主节点重启)或 offset 超出缓冲区范围,主节点会触发全量同步。

三、关键细节与实战注意事项#

1. 复制积压缓冲区的核心作用#

  • 是主节点维护的固定长度环形缓冲区(默认 1MB,可通过 repl-backlog-size 配置);
  • 仅存储最近的写命令,用于增量同步,缓冲区越大,支持的从节点失联时间越长;
  • 实战建议:根据业务写 QPS 调整大小(如写 QPS=10 万 /s,需支持 10 秒失联,则配置 repl-backlog-size 1GB)。

2. 异步复制的 “数据延迟” 问题#

  • 主节点写操作完成后,异步将命令发送给从节点,因此从节点数据会比主节点 “慢一拍”(延迟通常在毫秒级);

  • 实战优化:

    • 减少主从节点的网络延迟(同机房部署);
    • 避免主节点执行大命令(如 KEYS *),导致同步阻塞;
    • 通过 info replication 查看 slave_repl_offsetmaster_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 模式分片 + 主从 + 自动故障转移分片内主从同步高并发、大数据量、水平扩展

总结#

  1. 主从复制核心原理:主节点异步同步数据到从节点,通过 runid 识别主节点身份,通过 offset 保证数据同步一致性,复制积压缓冲区优化增量同步效率;

  2. 数据同步流程

    • 全量同步:初次连接 / 异常恢复时,主节点生成 RDB 文件 + 传输缓冲区命令,从节点加载 RDB 并执行命令;
    • 增量同步:短暂失联后,主节点从复制积压缓冲区发送失联期间的命令,从节点执行完成同步;
  3. 关键注意事项

    • 复制积压缓冲区大小需适配业务场景,避免增量同步降级为全量同步;
    • 关注主节点 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,可配置)执行一次 “过期键扫描”:

    1. 随机选取一定数量(如 20 个)的带过期时间的键;
    2. 检查这些键是否过期,删除已过期的;
    3. 若过期键占比超过 25%,则重复步骤 1-2(避免单次扫描耗时过久);
    4. 单次扫描有时间上限(如 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 没有单独采用某一种策略,而是结合了惰性删除 + 定期删除,既保证性能,又避免内存泄漏:

混合策略的执行流程#

Redis 运行中

客户端访问键

键是否过期?

惰性删除:删除该键,返回nil

返回键值

后台线程定期扫描(每100ms)

随机选取20个带过期时间的键

键是否过期?

删除过期键

跳过

过期键占比>25%?

结束本次扫描

内存达到maxmemory阈值

触发内存淘汰策略

淘汰部分键(含过期键)

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 运行中

客户端访问键

键是否过期?

惰性删除:删除该键,返回nil

返回键值

后台线程定期扫描(每100ms)

随机选取20个带过期时间的键

键是否过期?

删除过期键

跳过

过期键占比>25%?

结束本次扫描

内存达到maxmemory阈值

触发内存淘汰策略

淘汰部分键(含过期键)

补充:内存淘汰策略(最终兜底)#

即使有了惰性 + 定期删除,若过期键仍未被清理且 Redis 内存达到 maxmemory 阈值,Redis 会触发内存淘汰策略(如 volatile-lru:淘汰最近最少使用的过期键),进一步保证内存不溢出。

四、过期键删除的额外注意事项#

  1. 过期键的持久化影响

    • RDB:生成 RDB 文件时,会跳过过期键;加载 RDB 文件时,主库会跳过过期键,从库会保留(主从同步后再删除)。
    • AOF:过期键被删除时,会往 AOF 文件写入 DEL key 命令;AOF 重写时,会跳过过期键。
  2. 主从同步的过期键处理

    过期键的删除操作由主库执行,主库删除后会向从库发送

    DEL key

    命令,从库被动删除(从库不主动执行过期扫描),保证主从数据一致。

  3. 过期时间的精度

    Redis 的过期时间精度是毫秒级,但定期删除的扫描间隔是 100ms,因此过期键的实际删除时间可能有 ±100ms 的误差,属于正常现象。

总结#

  1. 核心删除策略:Redis 有定时删除、惰性删除、定期删除三种理论策略,实际采用 “惰性删除 + 定期删除” 的混合策略;

  2. 不采用定时删除的原因

    • 大量定时器占用 CPU 资源,导致性能下降;
    • 高并发下易出现性能抖动;
    • 实时性收益远低于成本,无实际必要;
  3. 混合策略的优势

    惰性删除保证 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 30
OK
# 释放锁
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:最终版(解决误删 + 自动续期)#

核心优化点:#
  1. 锁值唯一化:用 UUID + 客户端 ID 作为锁值,释放锁时先校验值是否为自己的,再删除(避免误删);
  2. Lua 脚本释放锁:校验值 + 删除锁是原子操作(避免校验后锁过期,删除别人的锁);
  3. 自动续期(看门狗):业务执行超时前,自动延长锁的过期时间(避免锁提前释放)。
完整 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;
@Component
public 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. 使用示例(扣库存场景)#

@Service
public 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. 死锁问题#

  • 问题场景:客户端获取锁后宕机 / 网络中断,未释放锁,其他客户端无法获取;

  • 解决方案

    1. 为锁 Key 设置合理的过期时间(如 30 秒),即使未手动释放,也会自动过期;
    2. 看门狗机制:业务执行超时前,自动续期锁的过期时间(避免锁提前释放)。

3. 误删锁问题#

  • 问题场景:客户端 A 的锁因超时释放,客户端 B 获取锁,A 业务执行完成后执行 DEL,误删 B 的锁;

  • 解决方案

    1. 锁值唯一化:用 UUID + 客户端 ID 作为锁值,而非固定值;
    2. 原子释放锁:通过 Lua 脚本先校验锁值是否为自己的,再删除(避免 “校验 + 删除” 非原子)。

4. 锁粒度问题#

  • 问题场景:使用全局锁(如 lock:all),所有业务竞争同一把锁,导致并发性能极低;
  • 解决方案:按业务维度细化锁粒度(如商品 ID、订单 ID),如 lock:stock:1001(仅锁定 1001 号商品的库存)。

5. Redis 集群脑裂问题#

  • 问题场景:Redis 主从集群脑裂,主节点宕机后,从节点升级为主节点,但原主节点仍在处理请求,导致多个客户端获取到锁;

  • 解决方案

    1. 使用 Redis Redlock 算法(多实例部署,需过半节点获取锁才算成功);
    2. 配置 Redis 主从的 min-replicas-to-write 参数,减少脑裂概率。

6. 重入性问题#

  • 问题场景:同一客户端获取锁后,再次请求获取锁时失败(非重入锁);

  • 解决方案

    1. 锁值存储 “客户端 ID + 重入次数”,获取锁时若为自己的锁,重入次数 + 1;
    2. 释放锁时重入次数 - 1,仅当次数为 0 时删除锁 Key。

五、Redis 分布式锁 vs Zookeeper 分布式锁#

特性Redis 分布式锁Zookeeper 分布式锁
性能高(内存操作,无磁盘 IO)中(需创建临时节点,有网络开销)
可靠性中(集群脑裂可能导致多锁)高(临时节点 + Watcher 机制)
实现复杂度低(命令 / 脚本简单)中(需处理 Watcher 事件)
重入性需自定义实现原生支持
阻塞获取锁需自旋实现原生支持(Watcher 通知)
适用场景高并发、短持有时间(如库存扣减)低并发、长持有时间(如分布式任务调度)

总结#

  1. Redis 分布式锁核心实现

    • 原子获取锁:SET lock_key 唯一值 NX EX ttl
    • 原子释放锁:Lua 脚本校验锁值 + 删除;
    • 防死锁:过期时间 + 看门狗续期;
    • 防误删:锁值唯一化。
  2. 核心注意事项

    • 必须保证操作原子性(避免多命令拆分);
    • 锁 Key 需设置过期时间,避免死锁;
    • 释放锁前校验锁值,避免误删;
    • 细化锁粒度,提升并发性能。
  3. 选型建议

    • 高并发短锁场景选 Redis(性能优先);
    • 高可靠长锁场景选 Zookeeper(一致性优先)。

关键点:Redis 分布式锁的核心是 “原子性” 和 “容错性”—— 所有操作必须保证原子,同时通过过期时间、看门狗、唯一锁值等机制,应对宕机、超时、并发等异常场景。

一、先理解:缓存与数据库双写不一致的核心成因#

缓存和数据库双写的核心矛盾是:两个独立存储的写操作无法原子化,常见不一致场景有 2 类:

场景 1:更新操作顺序错误(并发更新)#

假设存在两个请求同时更新同一数据:

  • 请求 A:更新数据库 → 删除缓存(正常流程)

  • 请求 B:查询缓存(未命中)→ 查询数据库(旧值)→ 写入缓存

  • 时序异常:A 更新数据库 → B 查询数据库(旧值)→ A 删除缓存 → B 写入缓存(旧值)

    → 最终缓存是旧值,数据库是新值,不一致。

场景 2:删除 / 更新缓存失败(网络 / 宕机)#

  • 请求 A:更新数据库成功 → 删除缓存时网络超时 / Redis 宕机 → 缓存未删除

    → 后续查询会读取缓存中的旧值,不一致。

场景 3:并发读写#

  • 请求 A:读取缓存(未命中)→ 查询数据库(旧值)

  • 请求 B:更新数据库(新值)→ 删除缓存

  • 时序异常:A 查询数据库(旧值)→ B 更新数据库 + 删除缓存 → A 写入缓存(旧值)

    → 缓存存旧值,数据库存新值。

二、核心解决方案(按落地难度 / 适用场景分类)#

方案 1:延时双删(最简单,适配大部分场景)#

原理#

更新数据库后,先删除一次缓存,等待一段时间(如 500ms),再删除一次缓存 —— 目的是覆盖 “并发读写导致的旧值写入缓存” 场景。

完整流程#

收到更新请求

更新数据库

第一次删除缓存

延时N毫秒(如500ms)

第二次删除缓存

操作完成

graph TD
A[收到更新请求] --> B[更新数据库]
B --> C[第一次删除缓存]
C --> D[延时N毫秒(如500ms)]
D --> E[第二次删除缓存]
E --> F[操作完成]

收到更新请求

更新数据库

第一次删除缓存

延时N毫秒(如500ms)

第二次删除缓存

操作完成

实操代码(Java 示例)#
@Service
public 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

收到更新请求

B

实操代码(分布式锁 + 更新缓存)#
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),异步更新 / 删除缓存,完全解耦业务代码和缓存操作。

完整流程#

查看代码

改/删

业务服务更新数据库

MySQL写入binlog

Canal 监听binlog并解析

判断操作类型(增/删/改)

操作类型

删除对应缓存

写入缓存

操作完成

graph TD
A[业务服务更新数据库] --> B[MySQL写入binlog]
B --> C[Canal 监听binlog并解析]
C --> D[判断操作类型(增/删/改)]
D --> E{操作类型}
E -- 改/删 --> F[删除对应缓存]
E -- 增 --> G[写入缓存]
F --> H[操作完成]
G --> H

改/删

业务服务更新数据库

MySQL写入binlog

Canal 监听binlog并解析

判断操作类型(增/删/改)

操作类型

删除对应缓存

写入缓存

操作完成

关键细节#
  • 无业务侵入:无需修改业务代码,仅通过中间件监听 binlog;
  • 最终一致性:binlog 解析和缓存更新是异步的,存在短暂不一致,但最终会一致;
  • 适用场景:大规模微服务、业务代码不想耦合缓存逻辑的场景。

方案 4:分布式事务(极致一致性,适配金融级场景)#

原理#

通过分布式事务(如 Seata TCC/2PC)保证 “数据库更新” 和 “缓存更新 / 删除” 的原子性 —— 要么都成功,要么都回滚。

流程(以 TCC 为例)#
  1. Try 阶段:锁定数据库数据 + 标记缓存待更新;
  2. Confirm 阶段:提交数据库更新 + 删除 / 更新缓存;
  3. Cancel 阶段:回滚数据库更新 + 恢复缓存旧值。
关键细节#
  • 实现复杂:需改造业务代码,开发成本高;
  • 性能损耗:分布式事务会增加网络开销,降低吞吐量;
  • 适用场景:金融级场景(如转账、支付),一致性要求极高。

三、各方案对比与选型建议#

方案一致性级别实现难度性能适用场景
延时双删最终一致(短暂不一致)极低读多写少、非核心数据(商品详情)
缓存更新 + 分布式锁强一致高并发写、核心数据(库存、订单)
binlog 监听最终一致大规模微服务、低侵入需求
分布式事务强一致金融级、极致一致性(支付、转账)

四、通用保障措施(无论选哪种方案都要加)#

1. 缓存设置过期时间(兜底)#

所有缓存都必须设置过期时间(如 1 小时),即使出现不一致,过期后也会自动失效,重新从数据库加载最新值。

2. 读写分离场景的特殊处理#

若数据库是读写分离架构,需注意:

  • 更新操作走主库,查询操作若缓存未命中,需查主库(避免从库同步延迟导致的旧值);
  • 或等待从库同步完成后再写入缓存。

3. 避免缓存更新,优先删除缓存#

“更新缓存” 易出现并发问题,建议优先选择 “删除缓存”—— 查询时缓存未命中,再从数据库加载最新值写入,减少不一致概率。

总结#

  1. 核心矛盾:缓存与数据库双写不一致的本质是 “两个独立存储的写操作无法原子化”,并发读写 / 操作失败是主要诱因;

  2. 方案选型

    • 简单场景选「延时双删」,成本最低;
    • 高并发写选「分布式锁 + 缓存更新」,兼顾一致性和性能;
    • 大规模微服务选「binlog 监听」,低侵入;
    • 金融级场景选「分布式事务」,极致一致;
  3. 通用兜底:缓存设置过期时间、优先删除缓存而非更新,是避免长期不一致的关键。

关键点:没有 “银弹” 方案,需根据业务的一致性要求、并发量、开发成本选择合适的方案,大部分业务场景下,延时双删 + 过期时间已足够。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
Redis面试常问的相关问题
https://www.liuguang.top/posts/redis/article-20260501-redis相关面试/
作者
LiuGuang
发布于
2026-05-01
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
LiuGuang
Hello, I'm LiuGuang.
公告
没啥官方话术,热烈欢迎到访!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
6
分类
5
标签
8
总字数
946,977
运行时长
0
最后活动
0 天前

目录