ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis高频面试八股:持久化、分布式锁与缓存一致性全解析

Redis高频面试八股:持久化、分布式锁与缓存一致性全解析 “每日八股”这个系列我写到第三篇了。写它的初衷很朴素Redis 算是后端岗位面试里性价比最高的一块提问频率高、追问深但市面上大多数八股清单只给你结论不给判断依据。这一篇我把四类高频题聚在一起——持久化机制、缓存穿透/击穿/雪崩、分布式锁、底层数据结构末尾又补了一块容易被跳过、但生产环境最容易摔跤的缓存一致性。准备面试的朋友可以拿来梳理思路已经在业务里的朋友也可以对照一下自己是不是真把 Redis 用明白了。1. 持久化别停在“RDB 和 AOF 的区别”要能讲清数据恢复链路1.1 面试官为什么总拿持久化开场面试里“Redis 持久化”被问到的频率极高一个重要原因是这个问题可以从两个维度考察。基础层面是 RDB 和 AOF 有什么区别深一层是 fork 子进程、写时复制、刷盘策略、数据恢复顺序这些才是区分“背过”和“用过”的分水岭。很多同学把 RDB 理解为“定期快照”把 AOF 理解为“追加日志”这样回答没错但太薄了。我见过不少候选人能流利背出save 900 1这种触发条件但被追问一句“主从全量同步时会不会触发 bgsave”当场就懵了。所以我的建议是准备持久化问题最好能从头到尾讲一条完整链路——Redis 突然宕机重启之后数据从哪来、为什么能恢复、会丢多少。这一节就按这条链路展开把这些逻辑弄清楚了八股自然不用死背。1.2 RDB 的两个隐藏坑fork 阻塞与写时复制RDB 本质是把内存中的全量数据以二进制快照形式写入磁盘。触发方式最常见的就是save m n配置比如save 900 1表示 900 秒内发生至少 1 次写操作就生成快照。但真正需要理解的是它背后的执行机制。save是同步生成快照会阻塞主线程生产环境基本不会用它日常触发的是bgsave由主进程 fork 一个子进程子进程负责写文件主进程继续服务客户端。这里有个非常容易忽略的坑——fork 本身要消耗时间而且 fork 期间主线程会短暂阻塞。阻塞时长取决于实例内存大小和系统压力。我用过一个内存接近 80G 的实例INFO stats里的latest_fork_usec常年停留在几百毫秒到上千毫秒高峰期极其难受。另一个坑是写时复制Copy On Writefork 出来的子进程和父进程共享内存页如果父进程在 bgsave 期间收到写请求涉及修改的内存页会先复制一份导致内存瞬时上涨。也就是说一个内存快满的实例bgsave 很可能把它推入 swap 甚至触发 OOM。这个坑在监控上很隐蔽。我后来总结的习惯是RDB 生成前关注used_memory_rss和系统可用内存而不是只看used_memory集群环境下让不同从节点错峰备份避免所有节点同时 bgsave。1.3 AOF 的刷盘策略与重写机制AOF 记录的是写操作命令相当于给 Redis 做一份操作日志。它的可靠性关键在于appendfsync配置配置项行为丢数据风险适用场景always每次写操作后立即 fsync几乎不丢对数据可靠性要求极高的场景everysec每秒 fsync 一次最多丢 1 秒左右生产环境默认推荐no交给操作系统决定刷盘可能丢多秒甚至分钟级追求极致性能、能忍受丢失提示everysec并不绝对意味着“最多丢一秒”。极端情况下比如主机断电操作系统写缓存没落盘实际丢失可能更多。如果业务对数据完整性极度敏感还是得用always。AOF 文件会随着持续写入越来越大所以有 AOF 重写机制。注意重写不是把旧文件压缩而是 fork 子进程根据当前内存中的数据重新生成一份只包含最少命令的 AOF 文件。触发条件是auto-aof-rewrite-percentage 100和auto-aof-rewrite-min-size 64mb意思是文件超过 64MB 并且较上次重写增长了 100% 才触发。重写期间新写入的命令会先进入重写缓冲区完成后追加到新文件再原子切换。理解了这套流程你就知道为什么可以通过调大阈值来降低磁盘 IO 频率。如果哪天 AOF 文件损坏了Redis 启动时会拒绝加载。这时候可以用redis-check-aof --fix修复文件再用修复后的文件启动这是线上救急的常用手段。1.4 混合持久化的加载优先级与生产配置Redis 4.0 之后提供了混合持久化能力。aof-use-rdb-preamble yes开启后AOF 文件开头部分直接是 RDB 格式的全量数据之后才是增量命令。好处非常实在纯 AOF 文件几 GB 时启动回放可能要几分钟混合持久化相当于先加载一个相对小的 RDB 段再补少量增量命令加载速度能快好几个量级同时保留了较低的丢数据风险。启动时的加载优先级值得记牢只要开启了 AOFRedis 首选 AOF 文件恢复数据如果 AOF 是混合格式先加载其中的 RDB 段再用后续命令进行回放。只有 AOF 不存在或损坏且无法修复时Redis 才会去加载 RDB 文件。很多同学以为 RDB 是默认加载源这是一个非常常见的误区。生产上的配置思路我一般分两种情况主从架构里承担核心数据的节点开启 AOF 且混合持久化同时保留 RDB 做冷备如果 Redis 纯做缓存、主要用于加速可以只开 RDB 甚至什么都不开把性能让出来。持久化配置没有统一标准取舍依据永远是“业务能不能承受那么多数据丢失”。2. 缓存穿透、击穿、雪崩从背概念升级到“治理链路”2.1 先分清三者别让面试官觉得你在背定义这三个词出现频率实在太高但很多人回答时会混着讲。先记住核心差异穿透查询的数据在缓存和数据库中都不存在每次请求都绕开缓存直达 DB最常见是恶意请求或空参攻击。击穿某个热点 key 在过期那一瞬间大量请求同时打到 DB像单点被击穿。雪崩大量 key 同时过期或 Redis 整体不可用导致 DB 请求量骤然放大。三者共性确实是“缓存失效导致后端压力变大”但治理思路完全不同。穿透要解决的是“空数据怎么挡在缓存门外”击穿要解决“单点 key 过期时如何让只有少量请求回源”雪崩要解决“批量失效怎么打散、系统整体怎么兜底”。方向清楚了细节才铺得开。2.2 穿透治理布隆过滤器与空值缓存的取舍穿透的标准应对方案之一是布隆过滤器。核心思路是在 Redis 前面再加一道过滤请求进来先查布隆过滤器过滤器说“不存在”就直接返回避免把压力引到 Redis 和 DB。布隆过滤器的原理是用多个哈希函数映射到 bit 数组查询时只要有一个 bit 为 0就说明数据一定不存在。它有两个参数需要提前定好预期的数据量 n 和可容忍的误判率 p。常见计算公式是bit 数组长度 m -(n * ln(p)) / (ln 2)^2哈希函数数量 k (m / n) * ln 2。举个例子预期 1000 万个 key误判率 1%算下来大约需要 11.4MB 的 bit 数组和 7 个哈希函数。误判本身问题不大最多放进来一次空查询DB 没有结果也不会产生脏数据。但布隆过滤器有个硬伤不支持删除。业务里如果大量删除 key就得考虑重建或者改用支持删除的变体比如计数布隆过滤器、布谷鸟过滤器。所以我会把空值缓存作为第二道兜底——直接把不存在的 key 记成 null给很短 TTL几十秒就够了。代码上基本就是一个SET key null EX 60能挡掉大部分重复空查询。不过空值缓存也有代价一是大量空 key 占内存需要监控 key 数量二是这些 null key 的 TTL 到期时会出现一个“穿透小高峰”。线上更稳的做法是布隆过滤器做第一道防线空值缓存做第二道两层配合。2.3 击穿治理互斥锁和逻辑过期的完整实现击穿的经典场景是微博热搜、商品详情这类高热度 key。缓存里这个 key 本来存在但正好在过期那一刻有大量请求进来第一波请求发现缓存为空后同时去 DBDB 很容易被打垮。互斥锁方案比较直观缓存失效时第一个发现问题的线程尝试SET lock_key NX PX 30000拿锁成功后才去 DB 查询并重建缓存其他线程拿不到锁要么直接返回旧值要么 sleep 一小会再去查缓存。Java 侧的示意代码大致是String key hot:product:1001; String lockKey lock:hot:product:1001; String requestId UUID.randomUUID().toString(); String value cache.get(key); if (value null) { Boolean locked redis.set(lockKey, requestId, NX, PX, 30000); if (Boolean.TRUE.equals(locked)) { try { // 再加一次缓存查询防止其他线程在等待期间已经重建 value cache.get(key); if (value null) { value db.query(key); cache.set(key, value, 60); } } finally { // Lua 脚本释放锁确认 requestId 是自己的 redis.eval(if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end, Arrays.asList(lockKey), Arrays.asList(requestId)); } } else { // 没抢到锁短暂休眠后读缓存 Thread.sleep(50); value cache.get(key); } } return value;互斥锁方案逻辑清晰但有两个问题一是热点 key 过期瞬间没抢到锁的请求如果全部 sleep 等待可能引发接口链路阻塞二是锁服务本身如果出问题比如 Redis 不可用缓存操作也会跟着报错。所以它更适合并发峰值可控、下游 DB 有相应限流兜底的场景。如果对实时一致性要求没那么高可以用逻辑过期方案。缓存 value 里直接存一个对象对象里带真正的 TTL 时间戳请求进来发现逻辑过期后尝试拿锁去重建拿不到锁的请求继续返回旧值。这样回源的请求只有少量重建线程其他请求零等待。代价是短暂的数据不一致以及重建失败时旧值会一直沿用。像活动页固定文案这种读多写少、允许几秒延迟的场景我非常推荐逻辑过期。2.4 雪崩治理随机过期时间只是开始后面还有三板斧雪崩的典型原因之一是批量写缓存时所有 key 被设置了同一个过期时间。最基础的做法是在 TTL 上加入随机值SET key1 value1 EX 3600 SET key2 value2 EX 3600 random(0, 300)这样做能把“同时过期”变成“先后过期”压力被摊开。但它只能缓解不能根治。如果业务在启动时批量预热 5 万个 key就算 TTL 各不相同热数据集中在同一批加载DB 也一样会承压。所以完整治理至少还要加三招本地缓存兜底在应用层再加一层 Caffeine 之类的本地缓存Redis 失效时至少还有本地副本顶着等待回源。请求限流熔断在 DB 访问层加入线程池、信号量或限流组件超出的流量直接返回默认值或排队等待。热点 key 拆分把一个高热度 key 拆成多个副本 key比如hot:1001:0到hot:1001:9靠随机前缀分散到不同分片。踩坑提醒一句只做随机过期时间而忽略本地缓存兜底雪崩时 Redis 大量 key 失效DB 照样会被打崩。我之前在电商活动项目里就见过这种半吊子方案后来加了本地缓存DB 峰值 QPS 直接降了一个数量级。3. 分布式锁setnx 只是表象边界条件才是深水区3.1 一个合格的分布式锁答案其实是四段式面试里的分布式锁题十个人有八个会脱口而出“用 setnx”。但只答到这里基本会被追问“能保证可重入吗”“锁过期了怎么办”“释放时会把别人的锁删掉吗”。这四个问题才是这道题的分水岭。一个能用于生产的 Redis 分布式锁至少满足四点互斥性同一时刻只有一个客户端能持有锁。防死锁必须设置过期时间防止持有者崩溃后锁永远不释放。防误删释放锁时要校验持有者身份避免删掉别人刚拿到的锁。释放原子性判断和删除必须在一个原子操作内完成。这就是我常说的“四段式”。把这些点全说出来面试官立刻知道你踩过坑而不是只背过命令。3.2 从 setnx 到 SET NX PX再到 Lua 释放最原始的写法是SETNX之后再单独执行EXPIRE。这个方案的问题很经典两步操作不原子如果SETNX成功后应用进程崩溃EXPIRE永远没机会执行锁就成了死锁。正确写法是一条命令完成写入和过期设置SET lock:order:1234 6f8a2c9d-3b1e-4f88-9a7c-1c2d3e4f5a6b NX PX 30000value 必须是全局唯一标识这样释放锁时才能确认“是我自己的锁”。释放锁也不能先 GET 再 DEL因为 GET 和 DEL 之间如果另一个客户端刚拿到锁你一个 DELETE 就把别人的锁删掉了。判断和删除必须绑定在同一个 Lua 脚本里if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个脚本几乎可以直接搬进代码库是我在多个项目里实际用过的方案。到这里一个“能用的锁”已经成立了有互斥、有过期时间、不会误删。接下来进阶问题才是拉开差距的地方。3.3 Redisson 看门狗续期续的不是锁是业务信心锁过期时间的设置有个两难设短了业务还没执行完锁就到期多个线程并发设长了持有者如果真崩溃其他客户端要等很久。Redisson 用“看门狗”机制缓解这个问题。Redisson 里如果创建锁时不指定leaseTime默认锁有效期为 30 秒同时有一个后台线程每 10 秒检查一次锁是否仍被持有如果业务还活着就自动续期到 30 秒。这样既不用怼一个超长过期时间也能降低“业务没执行完锁却到期”的概率。如果进程挂了看门狗线程自然停掉锁会在约 30 秒后被 Redis 回收。这里有两个容易踩的细节。第一看门狗只在你没有显式传leaseTime时生效一旦你写了lock.lock(10, TimeUnit.SECONDS)就不会续期10 秒后就过期。第二看门狗续期走的是后台线程如果把锁用到了异步线程里非常容易出现 ThreadLocal 身份信息拿不到的问题。我当时排查过一起“锁没到期却被续期”的诡异故障根因就是释放锁时校验身份失败后续一查锁对象被错用在了自己起的线程池里。3.4 RedLock 争议与主从切换丢锁的实际选型再升级一个层次主从架构中master 刚把锁写入内存主从异步复制还没完成master 挂了slave 顶上后锁丢失另一个客户端就可能拿到同一把锁。这就是主从切换丢锁问题。听起来 RedLock 能解决向 5 个独立 Redis 节点同时申请锁超过半数返回成功才算获锁。但分布式系统社区对 RedLock 的争议一直很大核心论点包括它依赖“节点宕机不通知”这类人为假设而真实场景中时钟跳跃、网络分区都可能导致锁失效它并没有真正绕过同步问题只是把单一故障变成了多数派故障。如果你面试时能说出 Martin Kleppmann 那篇文章和 Redis 作者回应的大致分歧绝对加分。实际选型上我的观点比较务实如果业务场景已经复杂到需要严格锁语义大概率不应该只靠 Redis 锁。可以用数据库唯一约束表或者用 ZooKeeper 临时顺序节点后者靠 session 心跳保活客户端崩溃后锁自动消失语义更接近正规分布式锁。Redis 锁真正适合“性能优先、允许极端场景下短暂并发”的业务比如秒杀库存防重、任务调度去重。几种方案对比方案可靠性性能实现复杂度适用场景Redis 单节点锁一般很高低性能优先、允许极端并发Redisson 分布式锁较高高中大多数微服务业务首选RedLock理论争议大中高仅在明确需要多数派容错时考虑ZooKeeper 临时节点高中中强一致、可靠性要求高的场景我的建议是默认 Redisson 就够了RedLock 如果不是业务方明确要求真不用刻意上。面试时把它当成“你知道争议点在哪”的加分话题来准备更合适。4. 数据类型底层结构面试官深挖到这里的概率很高4.1 redisObject每个 value 都有的“身份证”Redis 里每个 key 和 value 都是一个redisObject除了真正的数据指针ptr还保存着类型type、编码方式encoding、引用计数refcount等信息。这个结构的意义在于同一个命令在不同场景下可以用不同底层结构存储从而在内存占用和访问效率之间做权衡。面试时你不需要背出结构体每个字段但至少要知道为什么 Redis 要搞一个 encoding 字段。最直接的原因是内存太贵。比如一个哈希对象只有两个字段用完整哈希表存就很浪费用紧凑的 listpack 存可能只需要几十字节。这就是编码转换机制存在的价值。4.2 String 编码切换与 SDS 为什么节省内存String 类型最常被问因为它有三种编码intvalue 能被解析为 64 位整数时使用比如SET age 25直接存整数连字符串指针都省了。embstr字符串长度不超过 44 字节Redis 3.2 之后redisObject和 SDS 头在连续内存块里一次内存分配搞定。raw字符串超过 44 字节或者被APPEND等修改导致原有结构无法继续承载时切换为 raw 编码需要多次内存分配。这里有个容易追到的小细节对 int 编码的字符串执行APPEND它会直接变 raw因为长度变了、内存布局被打散。所以线上如果某个 string 经常被 append 修改编码大概率不在最优状态。这也是为什么 string 适合存小值、不适合频繁拼接。SDS简单动态字符串相比 C 字符串的优势核心是三点O(1) 获取长度、二进制安全中间可以包含 \0、通过预分配空间减少扩容次数。用一句话说就是“比 C 字符串更懂内存”。4.3 Hash、List、ZSet 的结构选择与转换时机这一块是面试深水区的经典。几个常用类型的编码切换大致是类型小型时编码转换条件大型时编码Hashlistpack旧版叫 ziplist元素数 128 或 单个 value 64 字节hashtableZSetlistpack元素数 128 或 成员长度 64 字节skiplist dictListquicklist由多个 listpack 节点组成节点大小和深度可控quicklistStringint / embstr长度 44 或不可解析为整数raw面试官问 ZSet几乎必问跳表。跳表本质上是一种多层链表通过概率性的索引层实现 O(log n) 的读写复杂度。和红黑树比跳表实现更简单范围查询也方便——要查一个范围直接用最底层链表顺序走就能拿到结果而红黑树需要额外维护前驱后继。Redis 选跳表而不是红黑树一个重要原因就是编码和维护成本低。理解编码转换时机对性能调优很有帮助。一个本来只有几十个小字段的 Hash如果后续慢慢变成几个大字段它可能已经悄然切到 hashtable此时再用HSET做写操作内存开销明显上升。排查时先看一眼OBJECT ENCODING key立刻确认。我踩过一次类似的坑某个 Hash 缓存越写越大最后单字段几十 KB内存增长异常才发现它早就从 listpack 切到了 hashtable后来改成大字段单独做 key内存才降下来。4.4 渐进式 rehash 与 big key 排查哈希表扩容时Redis 不会一次性把所有元素搬进新数组而是用渐进式 rehash保留旧表和新表两个哈希表每次读写操作顺便迁移一个桶直到旧表清空。好处很明显避免大表扩容瞬间阻塞主线程。触发扩容的条件一般是负载因子超过 1超过 5 会强制扩容。这个机制在生产排查里有实际意义当实例在跑 bgsave 时rehash 会被延后因为写时复制会让内存 copy 放大所以大实例上你会看到used_memory和used_memory_rss差值很大往往是 rehash 加写时复制双重作用的结果。big key 也要提一嘴。一个几 MB 甚至几十 MB 的 key删除时用DEL会阻塞主线程几百毫秒现在正确的做法是用UNLINK异步释放。排查可以用redis-cli --bigkeys扫描但注意高版本推荐用--scan配TYPE和DEBUG OBJECT精确定位避免全库扫描额外消耗。我处理线上一次 Redis 慢查询时最终定位到罪魁祸首就是一个存 JSON 字符串的 big key单 key 几百 KB直接拖慢了客户端批量响应。5. 缓存一致性八股里最少提及、生产中最常摔跤的地方5.1 先删缓存还是先更新 DB两种顺序都做不到“绝对”缓存一致性这个话题面试里没有三兄弟问得那么频繁但线上踩坑的人特别多。核心问题就一个缓存和数据库是两套存储写操作怎么编排才能让缓存尽量不出现旧数据。先删除缓存、再更新数据库并发读的时候读请求在写请求更新 DB 之前打进来发现缓存没数据于是把 DB 里的旧值写回缓存。这个旧值可能长期存在一致性被破坏得比较严重。先更新数据库、再删除缓存是很多团队的默认方案因为整体风险低一些。但它不是万无一失——如果删缓存这一步失败比如网络抖动、请求超时缓存里就一直是旧值。所以这个方案一定要在应用层把“删除缓存”做成可补偿的操作最常见的补偿是把删除操作投递到消息队列失败后重试。5.2 延时双删的边界与实际效果延时双删是对“先删缓存、再更 DB”方案的打补丁写完 DB 后等一段时间比如 500ms再删一次缓存。目的是覆盖“某个读请求在第一次删缓存之后、更新 DB 之前把旧值写回缓存”的窗口。能讲出延时双删的局限比能讲出这个方案本身更加分。它有两个硬伤等待时间是经验值。业务读写耗时不同延迟窗口不是固定的设长了影响读性能设短了又可能漏删。它是概率性解决问题不是可靠解决。如果那 500ms 里刚好有多个并发写读交织仍然可能留下旧缓存。所以延时双删适合“偶尔删缓存失败、且允许秒级不一致”的场景比如非交易类页面文案、用户资料列表。真要是每笔数据都不能出错那就不该用缓存方案了。5.3 binlog 订阅方案的最终一致实践更可靠的最终一致方案是把缓存更新任务从业务代码里解耦通过订阅数据库 binlog 来完成。MySQL 开启 binlog用 row 格式记录变更Canal 伪装成 MySQL 从节点解析 binlog 里每步增删改把变更事件推给消费者消费者负责删除或更新对应缓存。这个方案的好处很实在业务代码不用每个写操作都手动拼“删缓存”动作减少遗漏。删除失败可以天然重试因为 binlog 消息不会因为一次消费失败就消失。对缓存层不侵入新增缓存字段不用改业务代码。代价也明确需要额外部署和维护 Canal、消息队列缓存和 DB 的一致性被推到异步链路极端情况下会出现秒级到几十秒的延迟。我在物流类系统里用过这个方案业务能接受“状态更新慢几秒”但长期不更新造成的客诉完全无法接受。最终通过 Canal 解决了大部分问题还顺手用 binlog 事件做了历史变更审计算是个副产物。5.4 一致性没有银弹工程本质是权衡你会发现缓存一致性本质上不是技术问题而是权衡问题。真要强一致最直接的办法就是不用缓存所有读走 DB如果一定要用缓存就得接受一个约束不可能同时做到强一致、高性能、高可用。大多数业务系统最终都选了“最终一致”。工程目标不是把不一致概率降到零而是压到业务可接受的范围同时为异常准备补偿手段。比如过期时间本身就是一种补偿就算中间出了岔子TTL 一到缓存被清掉下次读就会自然从 DB 重新拿数据。我在设计缓存方案时脑子里始终带着优先级第一不能打垮 DB第二尽量避免长时间脏数据第三极端情况允许短暂不一致但不能丢数据。面试官顺着缓存一致性继续追问你把这套权衡讲出来比单纯背“延时双删”“Canal”这些名词要打动人得多。“每日八股”写到第三篇我最想说的其实是八股不是背概念是要给每个知识点找到对应的生产场景。Redis 的每个机制设计背后都有具体痛点常见面试问题基本是在帮你复盘那些线上事故。你工作中踩过的坑、调过的参数、看过的日志才是面试时真正能拿去讲的素材。下一篇我打算把主从复制、哨兵和 Cluster 集群的高频问题整理一下尤其是复制延迟、脑裂、选主这类容易出事故的点。如果面试题催得紧也欢迎在评论区告诉我你最想听哪个方向我尽量优先写。
返回列表