面试这东西,最怕的不是你不会,而是你知道个大概,但一问到细节就露馅。Redis作为缓存层的绝对主力,面试官几乎必问缓存一致性,而大Key治理则是从"会用Redis"到"真正用过Redis"的分水岭。这篇是Redis面试突击的第三篇,咱们把这两个硬骨头一次性啃透:先说清楚面试官到底在考你什么,再给出一套能落地、能讲明白的实战方案。别急着背答案,先跟着思路走一遍,你会发现这两类问题其实是有套路的。
1. 缓存一致性问题:先搞懂面试官在问什么
1.1 三个一致性概念先分清
很多人一上来就背"延迟双删",结果面试官换个问法就卡壳。其实缓存一致性这个问题的核心,是搞清楚"一致"到底指什么。
- 强一致:任何时刻读到的都是最新写入的数据,相当于只有一个数据源。
- 最终一致:允许一段时间内读到旧值,但最终所有副本都会收敛到新值。
- 因果一致:有因果关系的事件按顺序被看到,无因果关系的可以乱序。
Redis+MySQL这种架构,能在业务层做到强一致吗?理论上可以,用分布式锁或事务,但代价是性能和可用性双降。我几乎没见过哪个线上业务为了缓存强一致这么干的,大家追求的都是最终一致性。所以面试官问"怎么保证一致性",你第一句话应该是"严格强一致不现实,我们的目标是最终一致",这个定位先摆正,后面所有方案都在这个前提下展开。
1.2 不一致是怎么发生的:根源分析
先想明白一件事:缓存和数据库是两个独立的存储系统,各自维护一份数据,所有不一致问题的根源都是这两个系统的写入时序不一致。
举个最常见的场景:
- 请求A更新数据库,把某个用户的昵称从"张三"改成"李四"。
- 请求B读取缓存,拿到的是修改前的"张三"。
- 缓存里的数据是旧的,数据库是新的,不一致产生了。
这个窗口期有多长,取决于你用什么更新策略。如果先更新数据库再更新缓存,两个并发写请求的到达顺序不一样,也会导致旧值覆盖新值。更麻烦的是,如果更新缓存失败了,数据库和缓存就直接永久不一致了。
所以面试时别单纯说"删缓存啊",你还要能讲清楚:删除缓存和更新数据库这两个动作的先后顺序,以及失败补偿怎么做。这才是面试官想听的深度。
1.3 四种主流缓存更新策略对比
业界常见的缓存策略就那几种,我直接列表对比,面试时你可以先抛一张这样的表,展示你知识面的系统性。
| 策略 | 核心逻辑 | 一致性保证 | 适用场景 |
|---|---|---|---|
| Cache Aside(旁路缓存) | 读miss回源DB,写操作更新DB并删除缓存 | 最终一致 | 绝大多数业务系统 |
| Write Through(直写) | 写操作先写DB再同步写缓存,事务保证 | 强一致 | 写频率低、对一致性要求高的场景 |
| Write Back(回写) | 先写缓存,异步批量落DB | 最终一致,存在丢数据风险 | 写入量极高的日志、计数场景 |
| Read Through | 缓存组件自动回源DB,对业务透明 | 最终一致 | 希望缓存策略封装的场景 |
这里面面试最高频的就是Cache Aside,因为它最符合实际业务。它读的时候先读缓存,miss了再去数据库查,查到后回填缓存;写的时候先更新数据库,然后删缓存。为什么是删缓存而不是更新缓存?因为更新缓存有并发覆盖的风险,而且缓存里的value可能经过二次加工,更新成本高。删掉缓存让下次读来自动回填,是最省事的做法。
1.4 Cache Aside的并发隐患与取舍
Cache Aside看着简单,但并发下有两个经典坑,面试官一定会往这上面引。
第一个坑:先删缓存,再更新数据库。
假设线程A删掉了缓存,还没更新数据库;线程B这时来读,发现缓存miss,就去数据库读。如果B读到的是旧值(A还没更新),B会把旧值回填到缓存。然后A才更新数据库,但缓存里已经是B回填的旧值了——缓存和数据库不一致,而且这个旧值可能长期存续。
第二个坑:先更新数据库,再删缓存。
这个顺序相对安全,但依然有极小概率出问题:线程A更新了数据库但删除缓存失败,或者删除的瞬间有线程B正在读并回填了旧值。这个窗口期极短,但理论上是存在的。
所以主流方案是先更新数据库,再删除缓存,同时配合补偿机制。这也是延迟双删能成为面试明星的原因——它就是专门针对这个极小概率窗口设计的。但你先别急着上延迟双删,因为它的延迟参数怎么定、失败怎么办,才是真正的考点。
2. 最终一致性方案:延迟双删与异步补偿
2.1 延迟双删的原理与伪代码
延迟双删的核心思路很简单:既然第一次删除缓存后,可能有一个读请求正好把旧值回填进去,那我过一段时间再删一次,把那个旧值二次清掉。
public void updateData(String key, Object newValue) { // 第一次删除缓存 redisTemplate.delete(key); // 更新数据库 updateDatabase(newValue); // 延迟一段时间后再次删除缓存,确保回填的旧值被清掉 Thread.sleep(DELAY_MILLIS); redisTemplate.delete(key); }这段代码面试时写出来,基本能过第一关。但紧接着面试官就会追问:"这个sleep一定会执行吗?延迟多久合适?"
这就是第二个问题:delayed delete不是银弹。如果你的应用部署在多台机器上,每一台都执行这段逻辑,那实际延迟是叠加的,很难精确控制。而且Thread.sleep会阻塞当前线程,在高并发下等于自己给自己制造超时。
2.2 延迟时间怎么定:两个约束条件
延迟时间不是拍脑袋定的,它必须满足一个核心条件:延迟时间要大于"读请求从缓存miss到回填旧值完成"的完整耗时。
这个耗时由两部分组成:
- 网络往返时间(应用服务器到缓存、应用服务器到数据库)
- 数据库查询耗时
更严谨的做法是用业务的P99耗时来估算。比如你的接口P99是200ms,那延迟时间至少得留500ms到1s的余量。定得太短,等于没删;定得太长,缓存空窗期变长,数据库压力变大。
我见过一个实际案例,某团队把延迟设成3秒,结果每次更新后3秒内所有读都直接打数据库,QPS高峰期DB直接扛不住。后来他们把延迟调到800ms,同时用P99监控验证,才把问题解决。
2.3 重试机制与可靠消息最终一致
延迟双删还有一个致命问题:如果第二次删除又失败了呢。第二次删除失败,缓存里还是旧值,一致性照样被破坏。
所以严谨的方案里,删除操作要有重试机制。我建议的做法是:
- 删除缓存失败时,把key和操作标识封装成消息发送到消息队列。
- 专门有一个消费者任务消费消息,查询数据库当前值,执行删除操作。
- 删除成功才确认消息,失败则重试,重试次数超过阈值就告警。
这个方案的好处是:把"删除缓存"从同步链路里解耦出来,不再依赖业务线程的sleep,而是靠消息队列的可靠投递来保证最终一定会删除。哪怕某一次删除失败了,重试机制兜底,最多是延迟兜底,而不是永远不一致。
2.4 基于binlog订阅的异步刷新方案
比延迟双删更优雅的方案,是订阅数据库的binlog,解析出数据变更事件,然后异步删除对应缓存。业界有现成的中间件能做这个事,原理就是伪装成一个从库,接收binlog事件流。
这个方案的好处很明显:
- 与业务代码完全解耦,不需要在每个更新的地方手动删缓存。
- 可靠性高,binlog是MySQL自身机制,不会漏事件。
- 天然支持重试,binlog消费失败可以被消息队列重新投递。
缺点是引入了一套新的基础设施,小团队未必有这个投入。但面试时你能把这个方案讲出来,并且说清楚它的优缺点,面试官会认为你是有全局视野的人,而不只是背了一个延迟双删的答案。
3. 缓存三大痛点:穿透、击穿、雪崩
3.1 穿透:布隆过滤器与空值缓存
缓存穿透说的是:查询一个根本不存在的数据,缓存里没有,数据库里也没有,请求直接打到DB上。如果是恶意攻击,一直用不存在的ID循环打,DB会被打挂。
两个常规解法:
一是布隆过滤器。提前把合法ID加进布隆过滤器,请求来了先判断ID在不在过滤器里,不在就直接返回,不查DB。注意布隆过滤器的特点是"一定不在的不在,可能在的也可能不在"——它可能有误判,但没有漏判,所以适合拦截非法ID。
二是空值缓存。如果查询结果为空,也把空值缓存起来,设置一个较短的过期时间,比如30到60秒。这样同一个ID的重复请求会被缓存扛住。
我自己的偏好是两者结合:入口层用布隆过滤器做粗筛,缓存层用空值缓存做兜底。布隆过滤器挡掉绝大多数恶意穿透,空值缓存挡掉那些"合法但暂时没有数据"的请求。
3.2 击穿:互斥锁与逻辑过期
击穿和穿透只差一个字,但问题完全不同。击穿是某个热点key的缓存过期瞬间,大量请求同时打到DB。
比如一个爆款商品的信息,缓存一旦过期,十万个请求同时涌向数据库,这种瞬时流量DB根本扛不住。
最常见的解法是互斥锁:当缓存miss时,不是所有请求都去查DB,而是先尝试获取锁,只有拿到锁的线程才去查DB并回填缓存,其他线程要么等待,要么直接返回旧缓存(如果有的话)。
public Object getData(String key) { Object value = redisTemplate.get(key); if (value != null) { return value; } // 尝试获取锁 boolean lock = redisTemplate.setIfAbsent("lock:" + key, "1", 30, TimeUnit.SECONDS); if (lock) { try { // 拿到锁的线程去查DB value = queryFromDb(key); redisTemplate.set(key, value, 10, TimeUnit.MINUTES); } finally { redisTemplate.delete("lock:" + key); } } else { // 没拿到锁,短暂等待后重试 Thread.sleep(50); return getData(key); } return value; }另一种思路是逻辑过期:缓存不设物理过期时间,而是把过期时间写入value里。读的时候发现逻辑过期,就用后台线程去更新缓存,读请求仍然返回旧值。这种方式不会阻塞请求,适合读多写少的场景,但会短暂读到旧数据,需要业务能接受。
3.3 雪崩:过期时间随机化与多级兜底
雪崩是大量key在同一时间过期,导致缓存层大面积失效,所有流量直接灌到DB。它和击穿的区别在于,击穿是一个key的锅,雪崩是一批key的锅。
产生雪崩的典型原因有两种:一是设置了相同的过期时间(比如统一在凌晨缓存重建,全部key同时过期);二是Redis实例宕机。
针对第一种,解法是过期时间加随机值。比如基础过期时间10分钟,再额外加一个0到5分钟的随机值,让key的到期时间错开,避免集体失效。
针对Redis宕机这种极端情况,更靠的是多级缓存。本地缓存(如JVM的缓存)兜底一层,就算Redis全挂了,本地缓存还能扛一部分读流量。再配合限流降级,把写流量挡在DB前面,给Redis恢复争取时间。
面试时把这三个"X穿"放在一起对比着讲,能体现你对缓存问题的系统性理解。
4. 大Key治理:从发现到拆解全流程
4.1 大Key判定标准:别等线上出事了再定义
大Key没有一个绝对统一的阈值,不同团队标准不一样,但业内比较通用的参考线是:
- String类型的value超过10KB。
- Hash、List、Set、ZSet类型的元素数量超过5000个。
- 单个key的总大小超过10MB,或者数据量超过Redis实例可用内存的10%。
你要注意,大Key的危害跟具体业务有关,不能只看体积。一个10KB的String,如果每次读取都被全量传输,在高QPS下照样把带宽打满;一个5000个元素的list,如果每次都要遍历,Redis单线程处理下来就得几十毫秒。
所以大Key治理的第一步其实是建立"这个key的访问频率×单次操作成本"的评估意识。低频的大Key可以观察,高频的大Key必须处理。
4.2 大Key的危害链路:阻塞问题最致命
大Key的表现,总结起来主要是四个方向的问题:
- 阻塞Redis:Redis是单线程模型,处理一个超大key的读写操作会占用事件循环,直接导致后续所有命令排队等待。我见过一个hash有上百万字段的情况,一次HGETALL直接让Redis卡了好几秒,业务全线超时。
- 网络拥塞:每次读写大Key都要在网络上传输大量数据,带宽成了瓶颈,同实例的其他key也跟着倒霉。
- 内存不均与数据倾斜:在集群模式下,大Key所在的分片内存压力远高于其他分片,明明整体内存还够,某一个节点却先OOM了。
- 删除阻塞:删除一个巨大的key本身就是一个重操作,DEL命令会同步删除所有底层数据结构,这个删除操作可能会阻塞Redis几秒甚至更久。
4.3 大Key是怎么产生的
大多数大Key不是设计出来的,是业务迭代里失控产生的。常见的来源有:
- 把某个对象的所有详情序列化成一个String塞进缓存,json越来越大。
- 用List当天消息队列,消费者挂了没人消费,List无限增长。
- 日活用户数据、在线用户列表这种集合,往一个Set/Hash里一直加,没有清理逻辑。
- 缓存没有设置过期时间,或者过期时间太长,数据只增不减。
这是生产事故里最高频的几个来源,治理也得从业务侧和缓存侧两头下手。
4.4 发现大Key的三个手段
不扫描不知道,一扫描吓一跳。发现大Key比较有效的三个手段是:
# 1. 使用 redis-cli 内置的大key扫描 redis-cli --bigkeys # 2. 使用 SCAN 命令自写脚本,统计 key 的数量和体积 redis-cli --scan --pattern "*" | while read key; do TYPE=$(redis-cli type "$key") if [ "$TYPE" = "string" ]; then SIZE=$(redis-cli strlen "$key") else SIZE=$(redis-cli llen/hlen/scard/zcard "$key") fi echo "$key $TYPE $SIZE" done--bigkeys用起来方便,但它只统计每个类型里最大的那个key,不能给出全局的大Key列表。要完整的分析,建议用RDB分析工具,直接解析Redis的持久化文件,能精确列出所有大Key的体积和分布。线上环境用SCAN没问题,KEY命令千万别用,会阻塞Redis。
我习惯的做法是:定期跑一次RDB分析,生成大Key报表,同时上线一个监控脚本,每次出现超过阈值的写入就告警,把问题扼杀在早期。
4.5 大Key拆分与删除实战
发现大Key之后怎么办?拆、压、删三步走。
先看能不能拆。Hash类型的大Key最容易被拆,假设一个hash存的是用户维度的数据,可以按user_id的hash取模,拆成N个小hash:
user:info:0、user:info:1、user:info:2 ... 按照 hash(userId) % N 路由List类型的大Key,按时间分桶,比如按天拆分:feed:list:20250101,存档和消费分开,避免一个list无限增长。
String类型的大Key,如果是JSON结构,建议直接改成Hash类型,按字段存取,还省内存;如果一定要保持String,可以考虑压缩value(gzip或snappy),可以显著降体积。
再看能不能删。如果是历史垃圾数据,直接清掉最省事。但删除大Key要用对命令:
# 会阻塞Redis的删除 DEL my-big-key # 异步删除,不阻塞主线程(Redis 4.0+) UNLINK my-big-keyRedis 4.0以下没有UNLINK,只能自己写脚本分批删。比如Hash大Key用HSCAN分批取出部分field再HDEL,List就用LTRIM逐步截断。这个细节实战里非常重要,千万别小看。
4.6 热Key的处理心得
热Key和大Key经常一起出现,但它们是两个维度的问题。大Key是体积大了,热Key是访问频率高了。一个String可能只有1KB,但每秒被访问几十万次,一样能把Redis的CPU打满。
处理热Key的思路:
- 加本地缓存:把热Key的值缓存一份到应用本地,读请求优先走本地,Redis只做兜底。收益最直接,但要注意本地缓存的一致性更新。
- 热Key复制:把热Key复制成多份,比如增加后缀
hotkey_01、hotkey_02,读请求随机访问,把压力分散到多个key上。 - 读写分离:在集群模式下,把热Key请求引导到只读节点,减轻主节点压力。
我自己遇到过一个真实案例:一个排行榜key,每次热点赛事期间QPS暴增,这个key成了一台Redis节点的独占压力源。最后就是靠热Key复制加本地缓存两个手段结合,才把单点的CPU压下来。面试中能讲出这种真实处理过程,远比背几个名词有价值。
5. 面试避坑:别把实战经验聊成八股文
5.1 高频追问清单提前演练
这部分是给马上要面试的同学准备的,我整理了几个最容易被追问的问题,你可以拿来自测:
- 为什么先更新数据库再删缓存,而不是先删缓存再更新数据库?
- 延迟双删的延迟时间你设多少?依据是什么?
- 删除缓存失败怎么办?
- 怎么发现大Key?线上能用KEYS命令吗?
- 删大Key用DEL还是UNLINK?为什么?
- 缓存和数据库之间到底能不能做到强一致?你采用了什么方案?
这些问题每一个都能往深处挖,上面的内容你要是都理解了,基本都能答上来。
5.2 怎么组织答案:结论先行加分层展开
面试时回答问题,最忌讳的是想到哪说到哪。我的建议是掌握"结论先行,分层展开"的话术结构:
先说结论(30秒内):"我们采的是Cache Aside模式,先更新数据库再删缓存,删除失败就投递MQ重试,底层还接了一个binlog订阅的异步兜底。"
然后分层展开:
- 前提:我们接受最终一致,窗口期通过监控控制。
- 方案:主流程怎么走,异常补偿怎么走。
- 代价:引入的延迟、成本、复杂度是什么。
- 效果:实际压测或线上验证的结论。
这样一套下来,面试官既看到你的知识面,又看到你的工程判断力。最后被问到"有没有更好的方案",你可以说大方向是往分层缓存+异步刷新演进,把一致性问题和性能问题解耦。
5.3 加分项:主动讲踩坑经历
面试时间二十分钟,你如果能主动讲一个真实踩坑经历,会非常加分。比如:
"我接手过一个业务,有个key是存用户完整信息json的,日积月累长到了20MB。有次发布,这个key过期,同时有大量用户查询,每个请求都去DB全量查一次,结果DB瞬间打满,整个业务雪崩。后来我们把json拆成了hash存储,按字段读取,才彻底解决。"
这种故事比任何设计模式都有说服力,因为它证明了你是真正在线上解决过问题的人,而不是看了几篇博客就来面试的。
有一个比较关键的建议:面试前最好在自己本地的Redis环境里把延迟双删和大Key拆分自己动手实现一遍。哪怕是简单的伪代码,亲手跑一次和只看不练,在面试时的表达深度是完全不一样的。这个系列后续我还会聊Redis的持久化、内存淘汰、分布式锁,都是实战导向的踩坑总结为主,跟着一篇一篇啃下来,面试基本不会虚。