分布式存储面试
分布式存储面试
缓存
【简单】什么是缓存?为什么需要缓存?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:缓存 / 缓存基础
💎 关键结论
缓存就是数据交换的缓冲区,把频繁访问的数据暂存在更快的存储介质上。本质是空间换时间:牺牲一点实时性,换取更快、更近的访问,命中率是核心度量指标。
⚡记忆卡片
- 口诀:缓冲区存热数据,空间换时间,快近命中高
- 关键词:缓冲区/空间换时间/更快更近/命中率
- 链路:频繁访问 → 数据暂存高速介质 → 就近快速读取 → 命中率提升 → 访问加速
📖 核心知识
缓存就是数据交换的缓冲区,用于将频繁访问的数据暂存在访问速度快的存储介质。
- 本质是空间换时间:牺牲一定的数据实时性,使得访问更快、更近:
- 将数据存储到读取速度更快的存储(设备);
- 将数据存储到离应用最近的位置;
- 将数据存储到离用户最近的位置。
- 缓存的形态:缓存是用于存储数据的硬件或软件的组成部分,以使得后续更快访问相应的数据。缓存中的数据可能是提前计算好的结果、数据的副本等。典型的应用场景:有 cpu cache、磁盘 cache 等。本文中提及的缓存主要是指互联网应用中所使用的缓存组件。
- 缓存命中率是缓存的重要度量指标,命中率越高越好。
缓存命中率 = 从缓存中读取次数 / 总读取次数🔬 扩展知识
【L3】
- 缓存普遍存在于计算机各层次:CPU Cache、磁盘 Cache、浏览器缓存、CDN、分布式缓存,越靠近访问方容量越小、速度越快,上层都是下层的缓存。
- 引入缓存即引入两个数据源,会天然带来一致性问题,需配合过期时间或主动失效手段,详见本文档『如何保证缓存与数据库的一致性?』。
【L4】
- 缓存的核心权衡是命中率 vs 一致性 vs 内存成本,三者难以兼得,设计时应以命中率为主、用 TTL 与失效机制兜底一致性。
⚠️ 常见误区
详情
常见误区:
- ❌ "缓存能提升数据实时性" → 纠正:缓存保存的是数据的副本或快照,必然牺牲一定实时性,换来的是访问速度而非新鲜度。
- ❌ "缓存命中率不重要,加机器就行" → 纠正:命中率是缓存的核心度量指标,命中率低意味着大量请求穿透到后端存储,缓存形同虚设,扩容无法解决回源压力。
🔀 发散问题
Q:缓存可以分为哪几类?
A:按部署位置分为客户端缓存(HTTP 缓存、浏览器缓存、APP 缓存)与服务端缓存(CDN、反向代理、数据库缓存、进程内缓存、分布式缓存),后端开发一般聚焦进程内缓存与分布式缓存。见本文档『缓存有哪些分类?』。
Q:什么情况下才值得引入缓存?
A:主要看 CPU 开销(昂贵且频繁的计算)与 IO 开销(数据库连接池繁忙)是否成为瓶颈,引入缓存会增加复杂度并牺牲实时性,需先权衡收益。见本文档『何时需要缓存?』。
Q:缓存会带来哪些副作用?
A:一是数据一致性问题(缓存与 DB 双份数据),二是系统复杂度上升(淘汰、失效、预热等机制),需要用 TTL、写路径失效等手段控制。
【简单】何时需要缓存?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:缓存 / 缓存基础
💎 关键结论
缓存不是免费的,会增加复杂度、牺牲实时性,只在计算或 IO 成为瓶颈时引入才值得。收益是读提速、横向扩展、降本,三者足以覆盖复杂度成本时就该上缓存。
⚡记忆卡片
- 口诀:先权衡后引入,CPU、IO 两看,提速扩展又省钱
- 关键词:CPU 开销/IO 开销/提速/扩展/降本
- 链路:计算或 IO 开销大 → 引入缓存分摊压力 → 读提速、可扩展、降成本 → 收益大于复杂度成本
📖 核心知识
引入缓存,会增加系统的复杂度,并牺牲一定的数据实时性。所以,引入缓存前,需要先权衡是否值得,考量点如下:
- CPU 开销 - 如果应用某个计算需要消耗大量 CPU,可以考虑缓存其计算结果。典型场景:复杂的、频繁调用的正则计算;分布式计算中间状态等。
- IO 开销 - 如果数据库连接池比较繁忙,可以考虑缓存其查询结果。
在数据层引入缓存,有以下几个好处:
- 提升数据读取速度。
- 提升系统扩展能力,通过扩展缓存,提升系统承载能力。
- 降低存储成本,Cache+DB 的方式可以承担原有需要多台 DB 才能承担的请求量,节省机器成本。

🔬 扩展知识
【L3】
- 缓存计算结果与缓存查询结果是两类不同场景:前者看重 CPU 节省(如正则、加解密、复杂聚合),后者看重 IO 节省(如 DB 查询),选型与失效策略各不相同。
【L4】
- 缓存是典型的"用复杂度换性能"的架构手段,评估是否引入时可用"收益(QPS 提升、DB 负载下降)- 成本(一致性维护、故障面扩大)"来量化决策。
🔀 发散问题
Q:缓存能放在系统的哪些位置?
A:从客户端到服务端均可:浏览器缓存、CDN、反向代理、进程内缓存、分布式缓存、数据库缓存,越靠近访问方加速效果越明显。见本文档『缓存有哪些分类?』。
Q:引入缓存后最需要关注的指标是什么?
A:缓存命中率。命中率持续偏低说明缓存内容与访问热点不匹配,需要调整淘汰策略、过期时间或增加内存。
【中等】缓存有哪些分类?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:缓存 / 缓存分类
💎 关键结论
缓存按部署位置分为客户端缓存与服务端缓存两大类。服务端常用 CDN、反向代理、数据库缓存、进程内缓存、分布式缓存,后端开发聚焦后两者,其余一般由运维或 DBA 维护。
⚡记忆卡片
- 口诀:客户端三类、服务端五类,后端只管进程内与分布式
- 关键词:客户端/服务端/CDN/反向代理/进程内/分布式
- 链路:请求进入 → 客户端缓存 → CDN → 反向代理 → 进程内缓存 → 分布式缓存 → 数据库
📖 核心知识
缓存从部署角度,可以分为客户端缓存和服务端缓存。
- 客户端缓存:
- Http 缓存:HTTP/1.1 中的
Cache-Control、HTTP/1 中的Expires - 浏览器缓存:HTML5 提供的 SessionStorage 和 LocalStorage、Cookie
- APP 缓存:Android、IOS
- Http 缓存:HTTP/1.1 中的
- 服务端缓存:
- CDN 缓存 - CDN 将数据缓存到离用户物理距离最近的服务器,使得用户可以就近获取请求内容。CDN 一般缓存静态资源文件(页面,脚本,图片,视频,文件等)。
- 反向代理缓存 - 反向代理(Reverse Proxy)方式是指以代理服务器来接受网络连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给客户端,此时代理服务器对外就表现为一个反向代理服务器。反向代理缓存一般针对的是静态资源,而将动态资源请求转发到应用服务器处理。
- 数据库缓存 - 数据库(如 Mysql)自身一般也有缓存,但因为命中率和更新频率问题,不推荐使用。
- 进程内缓存 - 缓存应用字典等常用数据。
- 分布式缓存 - 缓存数据库中的热点数据。
- 维护分工:CDN 缓存、反向代理缓存、数据库缓存一般由专职人员维护(运维、DBA);后端开发一般聚焦于进程内缓存、分布式缓存。
🔬 扩展知识
【L3】
- 请求命中的典型层次顺序为:浏览器缓存 → CDN → 反向代理 → 进程内缓存 → 分布式缓存 → 数据库,越靠前拦截的成本越低,多级缓存按此顺序逐级回源。
【L4】
- Mysql 查询缓存(Query Cache)在 MySQL 8.0 中已被彻底移除,印证了"命中率低、失效频繁"的缓存在工程上不划算的结论。
🔀 发散问题
Q:CDN 缓存和反向代理缓存有什么区别?
A:CDN 是把内容缓存到离用户最近的边缘节点,面向广域分发;反向代理缓存部署在应用服务器前端,面向单集群流量入口,两者都以缓存静态资源为主。分别见本文档『什么是 CDN?CDN 的工作原理是什么?』与『反向代理缓存的工作原理是什么?』。
Q:进程内缓存和分布式缓存如何配合?
A:进程内缓存(如 Caffeine)速度快但各节点独立、一致性弱,适合做一级缓存;分布式缓存(如 Redis)全局共享,适合做二级缓存,二者组成多级缓存架构。见本文档『多级缓存架构如何设计?』。
【中等】什么是 CDN?CDN 的工作原理是什么?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:缓存 / CDN
💎 关键结论
CDN 是把内容缓存到离用户更近节点的分布式网络,一般缓存静态资源。原理是就近调度加缓存回源:命中直接返回,未命中回源并缓存。既能加速又能扛攻击。
⚡记忆卡片
- 口诀:就近调度,命中即返,未命中回源再缓存
- 关键词:边缘节点/就近调度/缓存命中/回源/静态资源
- 链路:用户请求 → DNS 调度到最近节点 → 命中直接返回 → 未命中回源 → 缓存后再返回
📖 核心知识
CDN 是一种将内容缓存到离用户更近的节点的分布式网络系统。CDN 一般缓存静态资源文件(页面,脚本,图片,视频,文件等)。
国内网络异常复杂,跨运营商的网络访问会很慢。为了解决跨运营商或各地用户访问问题,可以在重要的城市,部署 CDN 应用。使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。

- CDN 原理:
- 就近调度:用户被导向最近的 CDN 节点
- 缓存+回源:节点有内容直接返回(缓存命中);节点无内容则向源站请求并缓存(缓存未命中)
- CDN 优点:
- 缓存加速:提升访问速度,尤其是含有大量图片和静态页面站点
- 带宽优化:自动生成服务器的远程 Mirror(镜像)cache 服务器,远程用户访问时从 cache 服务器上读取数据,减少远程访问的带宽、分担网络流量、减轻原站点 WEB 服务器负载等功能。
- 集群抗攻击 - 广泛分布的 CDN 节点加上节点之间的智能冗余机制,可以有效地预防黑客入侵以及降低各种 D.D.o.S 攻击对网站的影响,同时保证较好的服务质量。
- CDN 缺点与对策:
- 不适宜缓存动态资源。解决方案:主要缓存静态资源,动态资源建立多级缓存或准实时同步;
- 存在数据的一致性问题。解决方案(主要是在性能和数据一致性二者间寻找一个平衡):设置缓存失效时间(如 1 个小时,过期后同步数据);针对资源设置版本号。
🔬 扩展知识
【L3】
- CDN 的调度依赖 DNS 与 AnyCast 等技术将用户解析到最优边缘节点,节点之间通过智能冗余机制互为备份。
- 静态资源发布时采用"文件名带版本号/哈希指纹 + 长缓存"策略,可在不牺牲命中率的前提下实现秒级更新,绕开缓存一致性问题。
【L4】
- 边缘计算(如 Cloudflare Workers)把部分动态逻辑下沉到 CDN 边缘节点执行,是 CDN 从"内容缓存"向"计算缓存"的演进方向。
🔀 发散问题
Q:CDN 属于哪一类缓存?
A:属于服务端缓存,将数据缓存到离用户物理位置最近的服务器,一般缓存静态资源文件,由运维等专职人员维护。见本文档『缓存有哪些分类?』。
Q:CDN 缓存的动态资源怎么处理?
A:动态资源不适合在边缘长期缓存,常见做法是只对动态接口建立多级缓存或准实时同步,同时用短 TTL 控制一致性窗口。
📚 延伸阅读:浏览器缓存看这一篇就够了、5 分钟看懂系列:HTTP 缓存机制详解
【中等】反向代理缓存的工作原理是什么?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:缓存 / 反向代理
💎 关键结论
反向代理部署在应用服务器前端,既是流量入口也是缓存服务器。有缓存直接返回,无缓存回源取数并缓存后再返回,以此降低 WEB 服务器负载,主要缓存静态资源。
⚡记忆卡片
- 口诀:前端入口,命中直返,未命中回源再缓存
- 关键词:反向代理/流量入口/静态资源/回源/Nginx
- 链路:请求到达反向代理 → 命中缓存直接返回 → 未命中转发 WEB 服务器 → 取回数据本地缓存 → 返回客户端
📖 核心知识
反向代理服务器部署在应用服务器前端,作为流量入口。既是反向代理(转发请求),也是缓存服务器(缓存响应)。
反向代理(Reverse Proxy)方式是指以代理服务器来接受网络连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给客户端,此时代理服务器对外就表现为一个反向代理服务器。

- 部署位置:反向代理位于应用服务器同一网络,处理所有对 WEB 服务器的请求。
- 缓存原理:
- 如果用户请求的页面在代理服务器上有缓存的话,代理服务器直接将缓存内容发送给用户。
- 如果没有缓存则先向 WEB 服务器发出请求,取回数据,本地缓存后再发送给用户。
- 这种方式通过降低向 WEB 服务器的请求数,从而降低了 WEB 服务器的负载。
- 适用对象:反向代理缓存一般针对的是静态资源,而将动态资源请求转发到应用服务器处理。常用的缓存应用服务器有 Varnish,Ngnix,Squid。
🔬 扩展知识
【L3】
- Nginx 通过
proxy_cache指令族实现反向代理缓存,按响应头(如Cache-Control)决定缓存行为,并支持缓存目录分级与过期清理。 - 反向代理与 CDN 的差异在于部署位置:CDN 在全球边缘就近服务用户,反向代理在源站机房入口集中拦截回源流量。
【L4】
- 动静分离是反向代理缓存的前提:静态资源走代理缓存,动态请求转发应用服务器;若动态接口也需缓存,要额外解决失效与一致性问题。
🔀 发散问题
Q:反向代理缓存属于哪一类缓存?
A:属于服务端缓存,一般针对静态资源,动态资源请求转发到应用服务器处理,通常由运维专职维护。见本文档『缓存有哪些分类?』。
Q:反向代理缓存与 CDN 的关系是什么?
A:两者都是"命中直返、未命中回源"的缓存,但 CDN 部署在离用户近的边缘节点,反向代理部署在应用服务器前端;大型站点常组合使用,CDN 拦截广域流量,反向代理兜底回源流量。
【中等】缓存有哪些淘汰算法?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:缓存 / 淘汰算法
💎 关键结论
缓存资源快而昂贵,必须淘汰访问频率低或过期的数据。触发时机按空间、容量、TTL、TTI 设定;算法中 LRU 综合性能最好、适用通用场景,LFU 更适合长期稳定的热点。
⚡记忆卡片
- 口诀:空间容量时间触发,FIFO 先进先出,LRU 看时间,LFU 看频率
- 关键词:TTL/TTI/FIFO/LRU/LFU/MRU
- 链路:缓存资源有限 → 设定淘汰触发条件 → 按算法选择冷数据 → 淘汰释放空间 → 为热点腾位置
📖 核心知识
缓存算法设计思路:缓存一般存于访问速度较快的存储介质,快也就意味着资源昂贵并且有限。正所谓,好钢要用在刀刃上。因此,缓存要合理利用,需要设定一些机制,将一些访问频率偏低或过期的数据淘汰。
淘汰缓存首先要做的是,确定什么时候触发淘汰缓存,一般有以下几个思路:
- 基于空间 - 设置缓存空间大小。
- 基于容量 - 设置缓存存储记录数。
- 基于时间
- TTL(Time To Live,即存活期) - 缓存数据从创建到过期的时间。
- TTI(Time To Idle,即空闲期) - 缓存数据多久没被访问的时间。
主流缓存算法对比:接下来,就要确定如何淘汰缓存,常见的缓存淘汰算法有以下几个:
算法 淘汰策略 优点 缺点 适用场景 FIFO(先进先出) 淘汰最先进入的数据(队列结构) 实现简单 缓存命中率低(无视访问频率,可能淘汰热点数据) 数据访问无规律,或实现简单的缓存系统 LIFO(后进先出) 淘汰最后进入的数据(栈结构) 实现简单 缓存命中率低(无视访问频率,可能淘汰热点数据) 特殊场景(如回退操作,新数据价值低) MRU(最近最多使用) 淘汰最近最多使用的数据 适合访问局部性强的场景(如用户浏览信息流,看过内容不再看) 可能频繁淘汰缓存,降低命中率 数据访问模式具有“看后即弃”特性(如信息流推荐) LRU(最近最少使用) 淘汰最近最少被使用的数据(基于访问时间排序) 避免 FIFO 问题,对热点数据友好,综合性能好 临界区问题(热点数据在统计窗口末期无访问会被误淘汰) 通用场景(Web 缓存、数据库缓存等) LFU(最近最少频率使用) 淘汰使用频率最低的数据(额外记录访问频率) 解决 LRU 临界区问题,对长期热点数据保护更好 空间开销大(需记录频率);
频率衰减问题(旧热点难以淘汰)热点数据稳定,访问模式变化慢(如视频热门榜)
🔬 扩展知识
【L3】
- LRU 的经典实现是哈希表 + 双向链表,读写均摊 O(1);Redis 的 LRU/LFU 策略为节省内存采用抽样近似实现(
maxmemory-samples默认采样 5 个 key 择优淘汰)。 - LFU 需处理"频率衰减"问题(旧热点难以淘汰),Redis 4.0+ 的 LFU 引入对数计数器与衰减因子来缓解。
【L4】
- Caffeine 使用的 W-TinyLFU 结合了 LRU 的新鲜度与 LFU 的频率统计,通过准入窗口过滤低频新数据,综合命中率优于传统 LRU/LFU。
⚠️ 常见误区
详情
常见误区:
- ❌ "LRU 适合所有场景" → 纠正:LRU 存在临界区问题(热点数据在统计窗口末期无访问会被误淘汰),且有"看后即弃"型访问(如信息流)时 MRU 反而更合适。
- ❌ "LFU 记录频率就一定更优" → 纠正:LFU 空间开销大,且存在频率衰减问题,旧热点降温后仍因历史频率高而难以淘汰。
🔀 发散问题
Q:Redis 提供哪些淘汰策略?
A:Redis 提供 noeviction、allkeys/volatile 结合 lru、lfu、random、ttl 等策略,其中 allkeys-lfu(Redis 4.0+)基于访问频率淘汰,最适合保留热点数据。见本文档『如何保证 Redis 中始终是热点数据?』。
Q:为什么数据库 Buffer Pool 也用 LRU 类算法?
A:数据库页访问具有局部性,LRU 能把热页留在内存;但朴素 LRU 怕全表扫描污染,MySQL 将 LRU 链表分为 young/old 区加以防护。
📚 延伸阅读:Cache Replacement Policies - RR, FIFO, LIFO, & Optimal、Cache Replacement Policies - MRU, LRU, Pseudo-LRU, & LFU
【困难】缓存更新有哪些策略?⭐⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:15 min | 🏷 标签:缓存 / 缓存更新
💎 关键结论
缓存更新主流是 Cache Aside:先更新数据库、再删除缓存,用最终一致换高吞吐。所有策略都无法强一致,删除失败靠重试加 TTL 兜底,Write Behind 仅在能容忍丢数据时用。
⚡记忆卡片
- 口诀:旁路删缓存,穿透回源自加载,异步回写批量刷
- 关键词:Cache Aside/Read Through/Write Through/Write Behind/Refresh Ahead
- 链路:写更新 DB → 删除缓存 → 读未命中 → 回源 DB → 回填缓存 → 后续读命中
📖 核心知识

一般来说,系统如果不是严格要求缓存和数据库保持一致性的话,尽量不要将读请求和写请求串行化。串行化可以保证一定不会出现数据不一致的情况,但是它会导致系统的吞吐量大幅度下降。缓存更新的常见策略有以下几种:
- Cache Aside
- Wirte Through
- Read Though
- Wirte Behind
需要注意的是:以上几种缓存更新策略,都无法保证数据强一致。如果一定要保证强一致性,可以通过两阶段提交(2PC)或 Paxos 协议来实现。但是 2PC 太慢,而 Paxos 太复杂,所以如果不是非常重要的数据,不建议使用强一致性方案。
Cache Aside(旁路缓存):思路是:先更新数据库,再删除缓存。具体来说:
- 失效:尝试读缓存,如果不命中,则读数据库,然后更新缓存。
- 命中:尝试读缓存,命中则直接返回数据。
- 更新:先更新数据库,再删除缓存。

Read/Write Through(读写穿透):

Read Through 的思路是:查询时更新缓存。当缓存失效时,缓存服务自己进行加载。Write Through 的思路是:当数据更新时,缓存服务负责更新缓存。
Read/Write Through vs. Cache Aside:
- Cache Aside 模式中,应用需要维护两个数据源头:一个是缓存,一个是数据库。
- Read-Through 模式中,应用无需管理缓存和数据库,只需要将数据库的同步委托给缓存服务即可。
Write behind(异步回写):Write Behind 又叫 Write Back。Write Behind 的思路是:应用更新数据时,只更新缓存, 缓存服务每隔一段时间将缓存数据批量更新到数据库中,即延迟写入。这个设计的好处就是让提高 I/O 效率,因为异步,Write Behind 还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
Refresh Ahead(预刷新):
- 原理:缓存过期前主动刷新,避免用户等待
- 优点:减少延迟,体验更平滑
- 缺点:可能浪费资源(预刷新未访问的数据)
为什么 Cache Aside 采用先更新 DB、再删缓存
为什么不能先更新数据库,再更新缓存?
多个并发的写操作可能导致脏数据:当有多个并发的写请求时,无法保证更新数据库的顺序和更新缓存的顺序一致,从而导致数据库和缓存数据不一致的问题。

说明:如上图的场景中,两个写线程由于执行顺序,导致数据库中 val = 2,而缓存中 val = 1,数据不一致。
为什么不能先删缓存,再更新数据库?
存在并发读请求和写请求时,可能导致脏数据。

说明:如上图的场景中,读线程和写线程并行执行,导致数据库中 val = 2,而缓存中 val = 1,数据不一致。
先更新数据库,再删除缓存就没问题了吗?
存在并发读请求和写请求时,可能导致脏数据。

上图中问题发生的概率非常低:因为通常数据库更新操作比内存操作耗时多出几个数量级,最后一步回写缓存速度非常快,通常会在更新数据库之前完成。所以 Cache Aside 模式选择先更新数据库,再删除缓存,而不是先删缓存,再更新数据库。
不过,如果真的出现了这种场景,为了避免缓存中一直保留着脏数据,可以为缓存设置过期时间,过期后缓存自动失效。通常,业务系统中允许少量数据短时间出现不一致的情况。
策略权衡与选型边界
策略权衡与选型边界
| 策略 | 一致性 | 读延迟 | 写延迟 | 数据丢失风险 | 适用边界 |
|---|---|---|---|---|---|
| Cache Aside | 最终一致(秒级) | 命中低,未命中高 | 低 | 无 | 通用场景,读多写少,首选 |
| Read/Write Through | 最终一致 | 平滑(缓存层兜底回源) | 中 | 无 | 希望把回源/双写逻辑收敛到缓存层统一管理 |
| Write Behind | 弱一致(秒级~分钟级) | 低 | 极低(只写内存) | 有(缓存宕机丢未刷盘数据) | 写多读少、可容忍丢失(计数、日志、点赞数) |
| Refresh Ahead | 最终一致 | 极低(永不让用户等回源) | - | 无 | 可预测的热点数据 |
选型经验:90% 的业务用 Cache Aside + TTL 兜底即可;只有当写 QPS 远高于读、且 DB 成为写瓶颈时,才考虑 Write Behind——并且必须接受“缓存故障丢最近一个刷盘周期数据”的风险(如 30s 批量刷盘,最坏丢 30s 变更)。
方案失效场景:
- Cache Aside:删除缓存这一步失败(Redis 抖动、网络超时)时,脏数据会一直存活到 TTL 到期。所以生产上必须为删除操作加重试或 binlog 补偿,详见“缓存与数据库一致性”一节。
- Write Behind:缓存主节点宕机且无持久化时,刷盘队列中的数据全部丢失;批量合并写入还会把多次变更压成一次,无法回放中间状态。
- Refresh Ahead:热点判断失准时,会对冷数据做无效刷新,放大 DB 压力——刷新频率与热点命中率直接决定它是优化还是负担。
量化参考:Cache Aside 下,不一致窗口 ≈ TTL 或“删除失败到补偿成功”的耗时;工程上一般把 TTL 控制在 5~30 分钟,把删除重试控制在 3 次、总耗时 1s 内,即可把不一致影响压到业务无感。
🔬 扩展知识
【L3】
- 为什么是"删除缓存"而不是"更新缓存":删除把回填时机推迟到下一次读,天然避免并发写乱序覆盖(两个写并发更新缓存时,慢的写可能用旧值覆盖新值);且写多读少的数据若直接更新缓存属于浪费。只有"读远多于写、且值计算昂贵"的场景,才考虑写后主动更新缓存,并配合版本号防乱序。
- Redis Cluster 删除失败的概率量级:正常网络下 Redis 调用失败率在万分之一以下,但遇到主从切换、网络抖动时会突增到百分之几。补偿三板斧:删除失败立即重试 3 次 → 仍失败则投递 MQ 延迟重试 → 最终由 binlog 订阅兜底,三层至少一层生效即可收敛。
【L4】
- Write Behind 降低丢数据风险的手段是加 WAL(写前日志):每次写缓存前先追加持久化日志,刷盘成功后截断,故障重启重放 WAL;或缩短刷盘周期(如 30s → 5s)。代价是每次写多一次顺序 IO、DB 批量合并收益下降。若业务完全不能容忍丢失,说明根本不该选 Write Behind。
🏭 实战场景
详情
踩坑案例:大促前夜 Write Behind 丢数据导致资损
- 现象:某次大促前压测,库存扣减采用“Redis 扣减 + 每 30s 批量回写 DB”的 Write Behind 方案。压测高峰缓存集群一个主节点 OOM 宕机,随后出现大量“用户已付款但订单因库存不足被取消”的客诉,核对发现 DB 库存比 Redis 多出 4.2 万笔扣减量。
- 排查:主节点宕机时内存中待刷盘的增量队列全部丢失;主从切换后 Redis 侧库存已扣,DB 侧从未写入,两侧彻底分叉。
- 根因:Write Behind 把缓存当成了唯一事实来源,却没有任何持久化与对账兜底;刷盘周期 30s 就是最大数据丢失窗口。
- 修复:库存这类资损敏感数据改回“DB 乐观锁扣减为准 + Redis 做前置快速预扣”的两段式;确需 Write Behind 的场景(如浏览量计数)加上 AOF 持久化 + 每日全量对账任务。
场景题:大促开始前 10 分钟,运营批量修改了 20 万个商品价格(统一走“更新 DB + 更新缓存”),活动开始 1 分钟后大量用户投诉“下单价与详情页价格不一致”,同时 DB 慢查询告警。请从缓存更新策略角度复盘并给出改造方案。
分析:
- 应急处理:立即对涉及的 key 执行批量删除(而非批量更新),让读请求按 Cache Aside 惰性回填最新价;对下单链路临时强制以 DB 实时价为准,止损优先。
- 根因分析:其一,批量“更新缓存”在并发写/读下存在乱序覆盖,部分 key 被旧值回填;其二,20 万次写缓存在 1 分钟内集中打向 Redis 与 DB,批量任务未限速,引发 DB 慢查询;其三,运营工具绕过了业务的缓存维护逻辑,双写路径不统一。
- 长期方案:写路径统一收敛为“先更新 DB,再删除缓存 + 失败重试”;批量操作限速(如 2000 key/s)并错峰执行;对价格这类资损敏感字段,下单时不走缓存、直接查 DB 或用 binlog 订阅保证分钟级内收敛。
- 权衡:批量删除会让活动开始瞬间出现一波缓存未命中(回源压力),需提前评估 DB 承载能力并配合预热;若选择“更新缓存”,则必须引入版本号比较,只允许新版本覆盖旧版本,实现复杂度更高。
⚠️ 常见误区
详情
常见误区:
- ❌ "先更新数据库,再更新缓存最直观可靠" → 纠正:多个并发写请求时,无法保证更新数据库与更新缓存的顺序一致,慢的写可能用旧值覆盖新值,导致脏数据。
- ❌ "先删缓存再更新数据库,读不到旧值就安全" → 纠正:删缓存到更新 DB 完成之间存在窗口,并发读会把旧值回填缓存,脏数据持续到 TTL 到期。
- ❌ "Cache Aside 绝对一致" → 纠正:仍存在极低概率的并发脏读窗口,且删除缓存失败时脏数据会存活到 TTL,必须靠 TTL 与重试/binlog 补偿兜底。
🔀 发散问题
Q:缓存与数据库一致性还有哪些增强方案?
A:在 Cache Aside 基础上可叠加延迟双删、消息补偿、binlog 订阅(Canal)等手段,把不一致窗口压缩到业务可接受范围。见本文档『如何保证缓存与数据库的一致性?』。
Q:Write Behind 适合什么业务?
A:适合写多读少、且可容忍少量丢失的场景,如计数、日志、点赞数;资损敏感数据(库存、价格)不应使用,或必须加 WAL 持久化与对账兜底。
Q:为什么不建议读写请求串行化?
A:串行化可以保证不出现数据不一致,但会导致系统吞吐量大幅度下降;只有对强一致有刚性要求的极少数数据才值得这个代价。
📚 延伸阅读:分布式之数据库和缓存双写一致性方案解析
【困难】多级缓存架构如何设计?⭐
🎯 目标等级:L2 | ⏱ 建议用时:15 min | 🏷 标签:缓存 / 多级缓存
💎 关键结论
多级缓存通常两级就够:L1 进程内缓存加 L2 分布式缓存,过多分级只增复杂度。查询逐级回源逐级回填,更新靠删除加超时,L1 跨节点不一致用消息广播通知。
⚡记忆卡片
- 口诀:L1 进程内、L2 分布式,逐级查询逐级回填,L1 短 L2 长
- 关键词:L1/L2/回填/超时刷新/消息广播
- 链路:查询 L1 → 未命中查 L2 → 未命中查 DB → 结果回填 L2 → 回填 L1
📖 核心知识
一般来说,多级缓存架构使用二级缓存已可以满足大部分业务需求,过多的分级会增加系统的复杂度以及维护的成本。因此,多级缓存不是分级越多越好,需要根据实际情况进行权衡。
一个典型的二级缓存架构,可以使用进程内缓存(如: Caffeine/Google Guava/Ehcache/HashMap)作为一级缓存;使用分布式缓存(如:Redis/Memcached)作为二级缓存。
多级缓存查询:

多级缓存查询流程如下:
- 首先,查询 L1 缓存,如果缓存命中,直接返回结果;如果没有命中,执行下一步。
- 接下来,查询 L2 缓存,如果缓存命中,直接返回结果并回填 L1 缓存;如果没有命中,执行下一步。
- 最后,查询数据库,返回结果并依次回填 L2 缓存、L1 缓存。
多级缓存更新:
- 对于 L1 缓存,如果有数据更新,只能删除并更新所在机器上的缓存,其他机器只能通过超时机制来刷新缓存。超时设定可以有两种策略:
- 设置成写入后多少时间后过期
- 设置成写入后多少时间刷新
- 对于 L2 缓存,如果有数据更新,其他机器立马可见。但是,也必须要设置超时时间,其时间应该比 L1 缓存的有效时间长。
- 对于 L1 缓存,如果有数据更新,只能删除并更新所在机器上的缓存,其他机器只能通过超时机制来刷新缓存。超时设定可以有两种策略:
L1 不一致优化:为了解决进程内缓存不一致的问题,设计可以进一步优化:

通过消息队列的发布、订阅机制,可以通知其他应用节点对进程内缓存进行更新。使用这种方案,即使消息队列服务挂了或不可靠,由于先执行了数据库更新,但进程内缓存过期,刷新缓存时,也能保证数据的最终一致性。
🔬 扩展知识
【L3】
- L1 与 L2 的 TTL 搭配原则:L1 短(秒级,控制不一致窗口)、L2 长(保证命中率),绝不能 L1 比 L2 长,否则 L2 更新后 L1 长期持有脏数据。
- L1 无法被全局即时失效,广播消息丢失时只能依赖 TTL 收敛,因此需接受秒级不一致窗口。
【L4】
- Redis 也可借助 Pub/Sub 或 Keyspace Notifications 广播失效事件,但均不保证可靠投递,一致性兜底仍要依赖超时机制。
⚠️ 常见误区
详情
常见误区:
- ❌ "多级缓存层级越多性能越好" → 纠正:过多的分级会增加系统复杂度与维护成本,通常二级缓存即可满足大部分业务需求。
- ❌ "L1 缓存更新后其他节点立即可见" → 纠正:L1 是进程内缓存,只能删除并更新所在机器上的缓存,其他机器只能通过超时机制或消息广播刷新。
🔀 发散问题
Q:多级缓存如何应对缓存雪崩?
A:多级缓存是雪崩的事中防线:本地缓存吸收第一波回源流量,分布式缓存兜底,配合限流降级避免 DB 被打死。见本文档『什么是缓存雪崩?如何应对?』。
Q:L1 进程内缓存如何选型?
A:常用 Caffeine(W-TinyLFU 算法,命中率高)、Google Guava、Ehcache,极端简单场景可用 HashMap;核心考量是命中率、内存占用与并发安全。
Q:为什么 L2 的超时时间应比 L1 长?
A:L2 是全局共享层,承担更高命中率的职责;若 L2 先于 L1 过期,L1 可能长期持有脏数据且失去回源依据,分层就失去意义。
【中等】什么是缓存雪崩?如何应对?⭐⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:缓存 / 缓存雪崩
💎 关键结论
雪崩就是缓存不可用或大量 key 同时失效,请求全部压到数据库把系统拖垮。应对靠三层防线:事前高可用、事中多级缓存加限流降级、事后持久化恢复,外加 TTL 随机化防集中失效。
⚡记忆卡片
- 口诀:高可用防崩、多级缓存扛、限流降级保、持久化恢复、TTL 随机化
- 关键词:集中失效/高可用/多级缓存/限流降级/TTL 随机化
- 链路:大量 key 同时失效 → 请求直达 DB → DB 过载宕机 → 系统雪崩 → 多级缓存 + 限流降级兜底
📖 核心知识
“缓存雪崩”是指,缓存不可用或者大量缓存由于超时时间相同在同一时间段失效,大量请求直接访问数据库,数据库压力过大导致系统雪崩。
举例来说,对于系统 A,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机。缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。
解决缓存雪崩的主要手段:
- 增加缓存系统可用性(事前)。例如:部署 Redis Cluster(主从+哨兵),以实现 Redis 的高可用,避免全盘崩溃。
- 采用多级缓存方案(事中)。例如:本地缓存(Ehcache/Caffine/Guava Cache) + 分布式缓存(Redis/ Memcached)。
- 限流、降级、熔断方案(事中),避免被流量打死。如:使用 Hystrix 进行熔断、降级。
- 缓存如果支持持久化,可以在恢复工作后恢复数据(事后)。如:Redis 支持持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
上面的解决方案简单来说,就是多级缓存方案。系统收到一个查询请求,先查本地缓存,再查分布式缓存,最后查数据库,只要命中,立即返回。
辅助手段:
- 监控缓存,弹性扩容。
- 缓存的过期时间可以取个随机值。这么做是为避免缓存同时失效,使得数据库 IO 骤升。比如:以前是设置 10 分钟的超时时间,那每个 Key 都可以随机 8-13 分钟过期,尽量让不同 Key 的过期时间不同。
雪崩 / 穿透 / 击穿三兄弟辨析
雪崩 / 穿透 / 击穿三兄弟辨析
| 维度 | 缓存雪崩 | 缓存穿透 | 缓存击穿 |
|---|---|---|---|
| 定义 | 大量 key 同时失效,或缓存集群整体不可用 | 查询的数据在缓存和 DB 中都不存在 | 单个热点 key 失效瞬间被高并发访问 |
| 触发条件 | TTL 集中到期、缓存集群宕机、发布重启 | 恶意攻击/异常流量构造不存在的 key | 热点数据恰好到期,且无重建保护 |
| 流量特征 | 大面积、多 key 均匀落库 | 大量 key 无重复、DB 查不到 | 单个(少数)key 超高并发 |
| 核心应对 | 高可用 + 多级缓存 + TTL 随机化 + 限流降级 | 布隆过滤器 + 空值缓存 | 互斥锁重建 / 逻辑过期 + 热点探测 |
| 保护对象 | 整个 DB | DB 免受无效查询 | DB 免受单点洪峰 |
三者共同点:本质都是“请求绕过缓存直达 DB”,区别只在失效的 key 范围(整体 / 不存在的集合 / 单个热点)与请求性质,所以多级缓存 + 限流降级是三者共同的底座防线。
方案权衡与失效边界
方案权衡与失效边界:
- TTL 随机化:在基础 TTL 上加 ±10%~20% 随机偏移(如 30 分钟 ± 5 分钟),把集中失效削平成近似均匀分布。失效边界:如果数据是一次性批量写入的(如每日凌晨全量刷新 100 万 key),仅加随机偏移仍会在一个偏移窗口内集中到期,需要分批错峰写入。
- 多级缓存:本地缓存吸收第一波回源流量(如吸收 70%),但 L1 无法被全局即时失效,一致性弱于 L2,需接受秒级不一致窗口。
- 限流降级:DB 入口按承载能力限流(如单实例 3000 QPS),超限请求降级返回兜底数据。失效边界:限流阈值估错会误伤正常流量,需基于压测数据设定并动态调整。
量化参考:以“缓存平时拦截 95% 流量”为例,雪崩时落库流量瞬间放大 20 倍,而 DB 容量通常只按峰值的 1.5~2 倍预留,必然被击穿——这就是雪崩必须“事前预防”而非“事后抢救”的原因。
🔬 扩展知识
【L3】
- 缓存集群宕机恢复后不能立即放开全部流量:重启后缓存是冷的,全量流量会再次穿透到 DB 形成二次雪崩。正确做法是预热(从 RDB/持久化恢复或提前加载热点)+ 流量灰度递增(如 10% → 50% → 100%),观察命中率回升到 90% 以上再放量。
- TTL 随机化会引入数据新鲜度差异:同一批数据过期时间分散,业务上看到“新旧混杂”的时间窗口被拉长;对一致性敏感的 key 不应靠 TTL 保鲜,应走写路径主动删除。随机偏移一般控制在 ±20% 以内。
【L4】
- 雪崩防护需形成预案并定期演练“缓存全挂”场景,验证限流阈值与降级页是否真正生效,否则事中防线只是纸面方案。
🏭 实战场景
详情
踩坑案例:大促零点批量缓存同时过期打挂 DB
- 现象:某次大促,活动商品列表在零点前 30 分钟由预热脚本一次性写入缓存,TTL 统一 30 分钟。零点 30 分后 2 分钟内,120 万个 key 集中到期,DB QPS 从 3000 瞬间飙到 8 万,主库 CPU 100%,核心接口 P99 从 50ms 恶化到 10s+,活动页大面积超时,故障持续约 12 分钟。
- 排查:DB 慢查询日志显示全是同一批商品查询;比对 key 写入时间,发现预热脚本单线程串行写入,120 万 key 在 90 秒内写完,TTL 完全相同,到期时间自然集中。
- 根因:批量预热 + 固定 TTL,制造了一次人为的“定时雪崩”;且回源路径没有限流,DB 被直接打穿。
- 修复:TTL 加 ±15% 随机偏移;预热脚本分批限速(每批 5000 key,批间隔 1s);回源路径加信号量限流(单实例最多 200 并发回源);上线本地缓存兜底。改造后同类流量下 DB 峰值 QPS 稳定在 6000 以内。
场景题:凌晨 2 点告警:Redis 集群所在机房网络抖动 3 分钟,恢复后 DB QPS 瞬间从 2000 飙到 5 万,主库 CPU 打满开始拒绝连接,应用大面积超时。作为值班负责人,请给出应急处置与长期改造方案。
分析:
- 应急处理:第一步对 DB 入口限流(按压测水位的 80% 放行),超限请求降级返回兜底页;第二步确认 Redis 恢复后先灰度 20% 流量回缓存,命中率回升后再逐步放量;切忌直接全量放开造成二次冲击。
- 根因分析:缓存集群与 DB 同机房强耦合,无跨机房容灾;缓存不可用时没有降级链路,请求直达 DB;回源无限流保护,DB 被瞬间洪峰打穿后连接池耗尽,形成“DB 挂 → 更多请求堆积 → 更慢”的正反馈雪崩。
- 长期方案:① Redis 跨机房主从 + 自动故障转移;② 应用侧接入本地缓存(Caffeine,存热点,TTL 10s)作第二层防线;③ 回源信号量限流 + DB 熔断(错误率超 50% 自动降级);④ 预案化:定期演练“缓存全挂”场景,验证限流阈值与降级页。
- 权衡:多级缓存与限流降级会增加系统复杂度和不一致窗口(本地缓存秒级脏读);但对可用性要求高的核心链路,用秒级不一致换整体不崩,是明确的正收益。
⚠️ 常见误区
详情
常见误区:
- ❌ "雪崩只能事后抢救" → 纠正:雪崩时落库流量瞬间放大十倍量级,而 DB 容量通常只按峰值 1.5~2 倍预留,事后抢救基本来不及,必须事前预防(高可用 + 多级缓存 + TTL 随机化)。
- ❌ "TTL 随机化能解决所有集中失效" → 纠正:若数据是一次性批量写入的,仅加随机偏移仍会在偏移窗口内集中到期,需分批错峰写入。
🔀 发散问题
Q:多级缓存下,L1 和 L2 的 TTL 应该怎么搭配?
A:L1 短(5~30s,控制不一致窗口)、L2 长(分钟~小时,保证命中率),且 L1 失效事件可通过 MQ 广播给各节点即时清理;绝不能 L1 比 L2 长。见本文档『多级缓存架构如何设计?』。
Q:雪崩、穿透、击穿有何本质区别?
A:三者本质都是请求绕过缓存直达 DB,区别在失效的 key 范围:雪崩是整体或大批 key,穿透是根本不存在的 key,击穿是单个热点 key。
Q:缓存降级在雪崩中起什么作用?
A:当缓存故障或流量超载时,主动返回兜底数据或限流放行部分请求,保证核心业务可用,是事中防线的最后一环。见本文档『什么是缓存预热?什么是缓存降级?』。
【中等】什么是缓存穿透?如何应对?⭐⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:缓存 / 缓存穿透
💎 关键结论
穿透就是查不存在的数据,缓存和数据库都没有,每次请求都打到数据库。应对两招:空值缓存适合无效 key 有限的场景,布隆过滤器适合海量随机无效 key 的攻击场景。
⚡记忆卡片
- 口诀:数据不存在,查缓存查库都空,空值缓存加布隆过滤
- 关键词:空值缓存/布隆过滤器/恶意枚举/误判率
- 链路:查询不存在的 key → 缓存未命中 → 查 DB 仍无 → 请求持续打到 DB → 空值缓存或布隆过滤器拦截
📖 核心知识
“缓存穿透”是指,查询的数据在数据库中不存在,那么缓存中自然也不存在。所以,应用在缓存中查不到,则会去查询数据库,当这样的请求多了后,数据库的压力就会增大。
解决缓存穿透,一般有两种方法:
缓存空值:对于返回为 NULL 的依然缓存,对于抛出异常的返回不进行缓存。

采用这种手段的会增加我们缓存的维护成本,需要在插入缓存的时候删除这个空缓存,当然我们可以通过设置较短的超时时间来解决这个问题。
过滤不可能存在的数据:

制定一些规则过滤一些不可能存在的数据。可以使用布隆过滤器(针对二进制操作的数据结构,所以性能高),比如你的订单 ID 明显是在一个范围 1-1000,如果不是 1-1000 之内的数据那其实可以直接给过滤掉。
方案选择:针对于一些恶意攻击,攻击带过来的大量 key 是不存在的,那么采用第一种方案就会缓存大量不存在 key 的数据,此时采用第一种方案就不合适了,完全可以先用第二种方案过滤掉这些 key。针对这种 key 异常多、请求重复率比较低的数据,就没有必要进行缓存,使用第二种方案直接过滤掉;而对于空数据的 key 有限的,重复率比较高的,则可以采用第一种方式进行缓存。
空值缓存 vs 布隆过滤器
方案权衡:空值缓存 vs 布隆过滤器
| 维度 | 空值缓存 | 布隆过滤器 |
|---|---|---|
| 原理 | DB 返回 NULL 也写入缓存(如 NULL 标记 + 短 TTL) | 位数组 + k 个哈希函数,判断 key 是否“可能存在” |
| 准确性 | 准确(无误判) | 有误判(可能放行不存在的 key),但绝不漏放 |
| 内存成本 | 每个无效 key 占一个缓存条目,key 无限时会被打爆 | 固定位数组,1% 误判率约 10 bit/元素,1 亿元素约 120MB |
| 维护成本 | 数据新增时需主动删除空值标记 | 不支持删除(需用 Counting Bloom Filter 或定期重建) |
| 适用边界 | 无效 key 集合有限、重复率高 | 无效 key 海量、重复率低(恶意攻击场景) |
生产上常见组合拳:布隆过滤器前置拦截海量随机无效 key,漏过去的少量请求用短 TTL(30~60s)空值缓存兜底。
量化参考(布隆过滤器参数):最优哈希函数个数 k = (m/n) × ln2 ≈ 0.693 × m/n。1% 误判率下 m/n ≈ 9.6,即每元素约 10 bit,k ≈ 7;1000 万元素、1% 误判率约需 12MB 内存。注意:实际元素数超过预估容量 n 时,误判率会急剧上升(容量翻倍时误判率约从 1% 升到 4% 以上),必须定期按最新数据量重建。
失效场景:
- 空值缓存:攻击者用每次都不重复的随机 key 打过来,空值条目无限膨胀,可能反向打爆缓存内存——此时必须先上布隆过滤器或入口限流。
- 布隆过滤器:① 容量估计不足导致误判率飙升,放行大量无效请求;② 不支持删除,商品删除后布隆过滤器仍认为存在,退化为穿透到 DB 后才由空值缓存兜底;③ 布隆过滤器本身加载/重建期间存在空窗,需双缓冲热切换。
🔬 扩展知识
【L3】
- 布隆过滤器不支持删除的原因:多个元素映射到相同的位,把某元素对应的位置清零会误删其他元素。替代方案:Counting Bloom Filter(每位改计数器,内存膨胀 3~4 倍);或业务上接受误判,靠定期全量重建(如每天凌晨双缓冲热切换)来消化删除。
- 空值缓存与“数据新增”会冲突:若 key 在空值 TTL 内被写入 DB,而写路径没有同步删除空值标记,读请求会在 TTL 内持续返回“不存在”。所以写路径必须删除对应空值 key,且空值 TTL 要短(30~60s)以压缩冲突窗口。
【L4】
- 误判率并非越低越好:误判率每降低一个量级,每元素内存约翻倍(1% 约 10 bit,0.1% 约 15 bit);且误判只是多一次 DB 空查询,通常 1% 足够,把省下的内存用于扩大容量、覆盖更多数据更划算。
🏭 实战场景
详情
踩坑案例:爬虫拖垮商品库
- 现象:某电商平台晚高峰商品库 CPU 突增到 90%,接口 P99 从 30ms 恶化到 3s,但前端流量并未增长。
- 排查:慢查询日志显示大量
SELECT ... WHERE id = ?查询不存在的商品 ID,且 ID 完全无重复;接入层日志确认是竞对爬虫用随机 ID 枚举探测数据。 - 根因:商品缓存无穿透防护,不存在的 ID 每次直达 DB;团队曾讨论过空值缓存,但爬虫 ID 无重复,空值方案在这种攻击模式下形同虚设。
- 修复:接入层对单 IP 限速(100 QPS)+ 商品 ID 合法性校验(自增 ID 上限、雪花 ID 格式)+ 布隆过滤器(全量商品 ID,1 亿容量、1% 误判率、120MB);上线后 DB QPS 回落到 2000 正常水位,拦截 99% 以上无效请求。
场景题:营销活动期间,监控发现大量查询“不存在的优惠券 ID”的请求,DB 读 QPS 从 1000 涨到 9000,优惠券表 CPU 85%。进一步分析发现:请求 ID 基本不重复,但来源集中在少数几个 IP 段。请给出应对方案。
分析:
- 应急处理:先在网关对异常 IP 段限速/封禁(见效最快,分钟级);同时对优惠券查询结果加空值缓存(TTL 30s),减少重复 ID 的穿透。
- 根因分析:典型的缓存穿透 + 恶意枚举:攻击者用连续/随机 ID 探测有效优惠券,ID 不重复导致空值缓存命中率低,防护效果有限;优惠券 ID 又是自增的,天然可枚举。
- 长期方案:① 布隆过滤器前置(全量有效优惠券 ID,预留 2 倍容量防误判率飙升);② 优惠券 ID 改为不可枚举的加密 ID(如雪花 ID + 混淆),从根源上消灭枚举探测;③ 风控联动,对高频 404 的客户端自动加验证码/封禁。
- 权衡:布隆过滤器需要随优惠券新增/失效定期重建(双缓冲热切换),有运维成本;加密 ID 会牺牲 ID 可读性与排序能力。若预算有限,优先做网关风控 + 空值缓存,布隆过滤器作为第二道防线跟进。
⚠️ 常见误区
详情
常见误区:
- ❌ "缓存空值能应对所有穿透" → 纠正:面对每次都不重复的随机 key(恶意枚举),空值条目会无限膨胀甚至反向打爆缓存内存,必须先上布隆过滤器或入口限流。
- ❌ "布隆过滤器判断准确" → 纠正:布隆过滤器存在误判(可能放行不存在的 key),但绝不漏放;且容量超预估时误判率会急剧上升,需预留容量并定期重建。
🔀 发散问题
Q:穿透、击穿、雪崩三者如何区分?
A:穿透查的是不存在的数据,击穿是单个热点 key 失效瞬间的洪峰,雪崩是大批 key 同时失效或缓存整体不可用,三者保护对象的范围不同。见本文档『什么是缓存雪崩?如何应对?』。
Q:布隆过滤器还能用在哪些场景?
A:凡是"快速判断元素是否存在且允许少量误判"的场景都适用,如垃圾邮件黑名单、爬虫 URL 去重、HBase/Cassandra 的 SSTable 索引加速。
Q:为什么不直接用缓存异常返回来防穿透?
A:抛出异常的返回不应缓存(异常可能是临时故障),只有确定"不存在"(返回 NULL)才值得写入空值标记,否则会把临时错误固化为长期错误。
【中等】什么是缓存击穿?如何应对?⭐⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:缓存 / 缓存击穿
💎 关键结论
击穿就是热点 key 失效瞬间,高并发请求全部打到数据库。应对二选一:分布式锁重建保一致性,逻辑过期加异步重建保可用性;锁必须带超时,否则防护变死锁。
⚡记忆卡片
- 口诀:热点失效洪峰来,互斥锁重建保一致,逻辑过期保可用
- 关键词:热点 key/互斥锁/逻辑过期/异步重建/热点探测
- 链路:热点 key 到期 → 大量请求未命中 → 同时回源 DB → 互斥锁或逻辑过期拦截 → 单线程重建回填
📖 核心知识
“缓存击穿”是指,热点缓存数据失效瞬间,大量请求直接访问数据库。例如,某些 key 是热点数据,访问非常频繁。如果某个 key 失效的瞬间,大量的请求过来,缓存未命中,然后去数据库访问,此时数据库访问量会急剧增加。
为了避免这个问题,我们可以采取下面的两个手段:
- 分布式锁 - 锁住热点数据的 key,避免大量线程同时访问同一个 key。
- 定时异步刷新 - 可以对部分数据采取失效前自动刷新的策略,而不是到期自动淘汰。淘汰其实也是为了数据的时效性,所以采用自动刷新也可以。
互斥锁重建 vs 逻辑过期
方案权衡:互斥锁重建 vs 逻辑过期
| 维度 | 互斥锁重建 | 逻辑过期 |
|---|---|---|
| 实现 | 未命中时 SET key lock NX EX 10 抢锁,抢到的线程查 DB 回填,其他线程等待/重试 | key 不设物理 TTL,value 内嵌逻辑过期时间;发现逻辑过期则返回旧值,同时异步重建 |
| 一致性 | 强(等到的一定是新值) | 弱(短暂返回旧值) |
| 可用性 | 差:未抢到锁的请求阻塞等待,响应时间飙升 | 好:任何请求都不阻塞 |
| DB 压力 | 单次重建 | 单次重建(异步) |
| 适用边界 | 一致性敏感、可接受等待(如余额、配置) | 可用性优先的读多写少热点(如商品详情、首页榜单) |
失效场景:
- 互斥锁:① 锁超时时间小于重建耗时,锁提前过期后多个线程同时重建,甚至旧重建结果覆盖新值;② 持锁线程崩溃且未设超时,其他请求永久等待(死锁);③ 大量线程自旋等待抢锁,应用线程池被占满,故障从 DB 转移到应用层。
- 逻辑过期:① 异步重建失败且无重试,旧值永久存活,等于缓存永远不更新;② 重建期间数据变更,回填的是重建开始时刻的快照而非最新值,需接受这个窗口;③ 若 key 本身不是热点而误用逻辑过期,会一直占用内存不淘汰。
量化参考:热点 key 探测阈值一般取单 key 访问频次 > 5000 次/分钟(或集群 Top 1%);探测手段可用客户端滑动窗口统计 + redis-cli --hotkeys(需 LFU)+ 代理层频次统计三路交叉验证。对 QPS 8 万的热点 key,互斥锁方案下 DB 只承受 1 次回源,但等待线程的 P99 可能从 2ms 恶化到数百 ms;逻辑过期方案 P99 几乎不受影响,代价是秒级旧值窗口。
🔬 扩展知识
【L3】
- 逻辑过期方案中,异步重建由读到“逻辑已过期”数据的请求触发:该请求先返回旧值,同时用
SET rebuild_lock NX EX 10抢重建锁,抢到的才提交异步任务(查 DB → 回填 → 更新逻辑过期时间),抢不到的直接返回旧值。锁 TTL 要大于重建耗时,配合 watchdog 续期防止重建未完成锁先过期。 - 互斥锁方案中,没抢到锁的线程有三档选择:短暂休眠重试(如 sleep 50ms 后重查缓存);直接返回兜底/旧值;排队等待(最差,容易耗尽线程池)。生产上严禁无限自旋,必须设最大重试次数后降级。
【L4】
- 缓存预热和击穿防护的分工:预热解决“已知热点”(上线/重启前预加载,避免冷启动击穿);逻辑过期/互斥锁解决“未知或动态热点”(到期瞬间的重建风暴);再配合热点探测自动给突发热点 key 打上逻辑过期标记,三层组合才能覆盖全部击穿场景。
🏭 实战场景
详情
踩坑案例:热点 key 重建锁引发雪崩式超时
- 现象:某微博热搜话题引爆一个热点 key,到期瞬间 5 万 QPS 触发重建;随后应用线程池打满,接口大面积 3s 超时,持续 20 分钟才人工介入恢复。
- 排查:重建线程用
SETNX加锁后没有设置过期时间,而该线程在查 DB 时因 DB 超时抛异常退出,锁永远没释放;后续所有请求抢锁失败后无限自旋重试,线程池被耗尽。 - 根因:分布式锁缺少超时兜底 + 抢锁失败无降级策略,一个异常线程把击穿防护变成了死锁放大器。
- 修复:锁强制带过期时间(重建耗时 P99 × 2,如 10s)+ Redisson watchdog 自动续期;抢锁失败的请求最多重试 3 次后返回旧值/兜底数据;同时对该类 key 改用“逻辑过期 + 异步重建”方案。
场景题:秒杀活动零点开始,某爆款商品详情页 key 恰好在 23:59:50 到期,零点前累积的 10 万 QPS 在到期瞬间全部穿透到 DB,MySQL CPU 100%,活动页全部超时 30 秒,零点开抢黄金 30 秒基本报废。请复盘并给出改造方案。
分析:
- 应急处理:立即手工 SET 回填该 key(或直接延长 TTL)止血;对回源 DB 的查询开单点限流(如同一 key 每秒最多 10 次回源),防止再次打穿。
- 根因分析:爆款 key 与普通 key 一视同仁使用固定 TTL,恰好在大流量窗口到期;重建路径无任何保护,10 万 QPS 同时回源;活动前也没有对热点 key 做专项检查(TTL 到期时间 vs 流量高峰是否重叠)。
- 长期方案:① 热点 key 改“逻辑过期 + 异步重建”,物理上永不过期;② 接入热点探测,访问频次超 5000 次/分钟的 key 自动升级为受保护策略;③ 应用层 Caffeine 本地缓存热点(TTL 5s),把单 key 洪峰在 JVM 内消化;④ 大促前巡检:扫描所有热点 key 的到期时间,强制避开活动高峰时段。
- 权衡:逻辑过期会引入秒级旧值,秒杀场景下价格/库存等敏感字段不能直接用,需拆分为“展示信息走逻辑过期、敏感字段单独短 TTL + 写时删除”;本地缓存则需接受多节点短暂不一致。
⚠️ 常见误区
详情
常见误区:
- ❌ "用分布式锁防击穿万无一失" → 纠正:锁必须带过期时间且有降级策略,否则持锁线程崩溃会造成死锁,把击穿防护变成死锁放大器;未抢到锁的请求也必须有重试上限和兜底。
- ❌ "逻辑过期适合所有数据" → 纠正:逻辑过期返回旧值、一致性弱,只适合可用性优先的热点;价格、库存等敏感字段不能直接用,非热点 key 误用还会一直占用内存不淘汰。
🔀 发散问题
Q:击穿和雪崩的区别是什么?
A:击穿是单个(少数)热点 key 失效引发的单点洪峰,雪崩是大批 key 同时失效或缓存整体不可用引发的大面积落库,应对手段分别侧重重建保护与高可用 + 多级缓存。见本文档『什么是缓存雪崩?如何应对?』。
Q:热点 key 如何被探测出来?
A:一般取单 key 访问频次 > 5000 次/分钟(或集群 Top 1%)为阈值,用客户端滑动窗口统计、redis-cli --hotkeys(需 LFU)、代理层频次统计三路交叉验证。
Q:热点 key 还有哪些分散压力的手段?
A:可以把热点 key 拆成多个副本分散到不同节点,或在应用层用本地缓存吸收洪峰。见本文档『如何发现并解决热点 Key 问题?』。
【中等】如何保证 Redis 中始终是热点数据?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:缓存 / Redis
💎 关键结论
让 Redis 始终留住热点数据,靠五件套:选对淘汰策略(allkeys-lfu/lru)、读时缓存、差异化过期时间、预热、监控动态调整。热数据自动留下,冷数据自然淘汰。
⚡记忆卡片
- 口诀:淘汰策略留热点,读时缓存冷不进,预热监控调不停
- 关键词:allkeys-lfu/Cache-Aside/差异化 TTL/预热/监控
- 链路:访问命中热点 → LFU/LRU 统计频率 → 冷数据被淘汰 → 热点留存 → 命中率保持高位
📖 核心知识
淘汰策略 + 读时缓存 + 过期时间 + 预热 + 监控,让 Redis 自动保留热点数据,冷数据自然淘汰。
选择合适的内存淘汰策略:Redis 提供多种内存淘汰策略,其中最适合热点数据的是基于访问频率或最近访问时间的策略:
- allkeys-lru:从所有键中淘汰最近最少使用的数据。
- volatile-lru:从设置了过期时间的键中淘汰最近最少使用的数据。
- allkeys-lfu(Redis 4.0+):从所有键中淘汰最不经常使用的数据,更精确地统计访问频率。
- volatile-lfu:从设置了过期时间的键中淘汰最不经常使用的数据。
推荐使用 allkeys-lfu 或 allkeys-lru,让 Redis 自主淘汰冷数据,保留热点。
采用 Cache-Aside 模式(读时缓存):
- 查询流程:先读 Redis,命中则直接返回;未命中则查 MySQL,将结果写入 Redis(并设置合理的过期时间),再返回。
- 这种模式自然地将被访问的数据加入缓存,未被访问的数据不会进入 Redis。结合淘汰策略,长时间未被访问的数据会被逐步淘汰,留下的始终是最近/最常访问的数据。
差异化过期时间:
- 对于已知的热点类别(如爆款商品、热门用户),可以设置较长的过期时间(如 1 天)。
- 对于普通数据,设置较短的过期时间(如 30 分钟),让冷数据快速失效,减少内存占用。
- 过期时间应结合业务访问规律动态调整,避免集中失效导致缓存雪崩。
预加载(预热):
- 通过离线分析 MySQL 访问日志(如慢查询日志、业务统计),统计出高频访问的数据列表。
- 在系统低峰期或启动时,将这些数据提前加载到 Redis 中,并设置合适的过期时间。
- 预热可以确保核心热点从一开始就存在于缓存中,避免首次访问时的穿透。
监控与动态调整:
- 监控 Redis 的内存使用、命中率、淘汰键数量等指标。
- 如果命中率持续偏低,说明缓存内容与热点不匹配,可能需要调整淘汰策略、过期时间或增加内存。
- 可以记录被淘汰的 key,分析是否为误淘汰,从而优化策略。
🔬 扩展知识
【L3】
- Redis 的 LRU/LFU 均为近似实现(抽样评估),LFU 用对数计数器记录访问频率并支持衰减,对"长期稳定热点"的保护优于 LRU。
- 命中率偏低时先排查三类原因:淘汰策略与访问模式不匹配、TTL 过短导致频繁失效、内存不足导致高频淘汰。
【L4】
- 单分片热点(某个 key 所在节点被打满)不能靠淘汰策略解决,需要 key 拆分或本地缓存,见本文档『如何发现并解决热点 Key 问题?』。
🔀 发散问题
Q:allkeys-lfu 和 allkeys-lru 怎么选?
A:热点稳定、访问模式变化慢时选 allkeys-lfu(按频率);访问模式随时间漂移时选 allkeys-lru(按最近访问时间);Redis 4.0 以下只有 LRU 可选。
Q:缓存预热怎么做?
A:可在系统启动、定时任务或手动触发时提前加载热点数据,避免首次访问穿透。见本文档『什么是缓存预热?什么是缓存降级?』。
【困难】Redis 集群模式的工作原理是什么?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:15 min | 🏷 标签:分布式缓存 / Redis Cluster
💎 关键结论
Redis Cluster 用 16384 个虚拟槽分片、Gossip 协议通信、主从复制容灾,实现去中心化高可用。key 按 CRC16 取模定位槽,请求错发节点由 MOVED/ASK 重定向,故障时从节点自动接管。
⚡记忆卡片
- 口诀:一万六千槽,CRC16 取模,Gossip 互通,主从接管
- 关键词:16384 slot/CRC16/MOVED/ASK/Gossip/故障转移
- 链路:key 哈希取模定 slot → 请求路由到负责节点 → 错发则 MOVED/ASK 重定向 → 节点故障 Gossip 感知 → 从节点选举接管 slot
📖 核心知识
Redis Cluster 通过虚拟槽(slot)分区、Gossip 协议通信、主从复制容灾,实现去中心化的数据分片与高可用。
数据分片:16384 个虚拟槽:Redis Cluster 将整个键空间划分为 16384 个 slot,每个 key 通过 CRC16 计算后取模得到所属 slot:
slot = CRC16(key) mod 16384集群中的每个主节点负责一部分 slot(例如 3 主节点的集群,节点 A 负责 0-5460,节点 B 负责 5461-10922,节点 C 负责 10923-16383)。这种设计的好处是:
- slot 是数据迁移的最小单位,扩容/缩容时按 slot 迁移,业务无感。
- 节点与 slot 解耦,节点变化只需迁移 slot,不影响数据本身。
客户端路由:MOVED 与 ASK 重定向:
- 客户端可以连接集群中任意节点。
- 节点收到请求后,计算 key 的 slot,若属于自己的范围则直接处理;否则返回
MOVED错误,告知客户端正确的节点地址。 - 客户端会缓存 slot 与节点的映射关系,后续请求直接路由到目标节点。
- slot 正在迁移中时,源节点返回
ASK重定向(临时性,客户端不缓存),目标节点正常处理。
集群通信:Gossip 协议:集群节点间通过 Gossip 协议进行通信和故障检测:
- 每个节点定期向其他节点发送
PING消息,携带自己已知的集群信息。 - 收到
PING的节点回复PONG,并更新自己的集群视图。 - 通过 Gossip,最终所有节点都能感知到集群的完整拓扑和各节点状态。
- 每个节点定期向其他节点发送
故障转移:
- 节点之间通过 Gossip 检测存活状态,若超过半数主节点认为某主节点下线(
PFAIL→FAIL),则标记其下线。 - 该主节点的从节点发起选举(基于 Raft 变种),胜出的从节点升级为主节点,接管原主节点的 slot。
- 新主节点向集群广播自己的升级信息,集群恢复服务。
- 节点之间通过 Gossip 检测存活状态,若超过半数主节点认为某主节点下线(
集群的限制:
- 不支持跨 slot 的多键操作(除非使用 Hash Tag
{tag}强制相同 tag 的 key 落在同一 slot)。 - 不支持
SELECT切换数据库,集群模式下只能使用 db0。 - 事务和 Lua 脚本受限于单 slot,跨 slot 操作会报错。
- 客户端必须支持集群协议(如 Jedis Cluster、Lettuce、Redisson)。
- 不支持跨 slot 的多键操作(除非使用 Hash Tag
🔬 扩展知识
【L3】
- 槽位数量取 16384(2^14)而非 65536,是因为节点间心跳包需携带槽位图(bitmap),16384 bit 仅 2KB,且官方建议集群节点数不超过 1000,16384 个槽足够分配。
- Hash Tag
{tag}使带相同 tag 的 key 落在同一 slot,可支持多键操作与事务,但过度集中会形成热点 slot。
【L4】
- 故障转移需要多数主节点存活才能完成(标记 FAIL 需过半主节点同意),若主节点数不足半数,集群会进入不可写状态,这是可用性让位于一致性的设计取舍。
⚠️ 常见误区
详情
常见误区:
- ❌ "Redis Cluster 有中心节点负责路由" → 纠正:Redis Cluster 是去中心化架构,无代理无中心节点,客户端通过 MOVED/ASK 重定向与本地槽位缓存自行路由。
- ❌ "集群模式下多键操作和单机一样用" → 纠正:跨 slot 的多键操作、事务、Lua 脚本会报错,必须用 Hash Tag 把相关 key 强制落到同一 slot。
🔀 发散问题
Q:Redis Cluster 和哨兵模式有什么区别?
A:哨兵模式解决的是高可用(主从故障转移),不解决容量与吞吐;Cluster 在哨兵能力之上增加了数据分片,同时解决高可用与水平扩展。
Q:热点 key 会引发什么问题?
A:热点 key 集中在单个 slot 对应节点,会把该节点 CPU、带宽打满,其他节点空闲,甚至引发击穿。见本文档『如何发现并解决热点 Key 问题?』。
【困难】如何发现并解决热点 Key 问题?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:15 min | 🏷 标签:分布式缓存 / 热点 Key
💎 关键结论
热点 Key 是单个 Key 被高频访问,把所在节点 CPU、带宽打满,甚至引发击穿。解法四件套:发现(命令/代理统计)、拆分、本地缓存、限流,再配合永不过期加异步刷新。
⚡记忆卡片
- 口诀:发现拆分本地缓,限流兜底永不过期
- 关键词:热点探测/Key 拆分/本地缓存/限流/永不过期
- 链路:单 Key 高频访问 → 所在节点压力过载 → 拆分 + 本地缓存分散 → 限流兜底 → 节点压力均衡
📖 核心知识
热点 Key 指单个 Key 被高频访问,导致所在 Redis 节点 CPU、带宽打满,甚至引发缓存击穿。解决思路是发现 + 拆分 + 本地缓存 + 限流。
热点 Key 的危害:
- 单节点压力过大:Redis Cluster 下,热点 Key 所在的 slot 对应节点 CPU 飙升、网卡打满,其他节点却很空闲。
- 缓存击穿:热点 Key 失效瞬间,大量请求穿透到数据库。
- 集群雪崩:热点节点故障,流量转移到其他节点,引发连锁反应。
如何发现热点 Key:
方法 原理 优点 缺点 Redis 命令统计 redis-cli --hotkeys或OBJECT FREQ(LFU 模式下)官方原生支持 需开启 LFU 淘汰策略 MONITOR 命令 实时捕获所有命令,统计 key 频次 准确,能发现实时热点 性能开销大,不适合生产长期开启 代理层统计 在 Twemproxy/ShardingSphere 等代理层拦截并统计 key 访问频次 对业务透明,无侵入 需引入代理层 日志分析 应用层记录 key 访问日志,离线统计分析 灵活可控 有日志开销,实时性差 带宽监控 监控各节点网卡流量,异常飙升的节点可能存在热点 Key 简单快速 只能定位到节点,无法精确到 key 如何解决热点 Key:
- 拆分热点 Key(Key 分片):将一个热点 Key 拆分为多个子 Key,分散到不同节点。例如
hot_key→hot_key_1~hot_key_10,客户端随机访问其中一个。
- 拆分热点 Key(Key 分片):将一个热点 Key 拆分为多个子 Key,分散到不同节点。例如
热点 Key 拆分示例代码
/**
* 热点 Key 拆分:将一个 key 拆为多个副本,分散访问压力
*/
public String getHotKey(String key) {
int shard = ThreadLocalRandom.current().nextInt(10);
String shardedKey = key + ":" + shard;
String value = redisTemplate.opsForValue().get(shardedKey);
if (value == null) {
value = queryFromDatabase(key);
// 回填所有副本
for (int i = 0; i < 10; i++) {
redisTemplate.opsForValue().set(key + ":" + i, value, 300, TimeUnit.SECONDS);
}
}
return value;
}- 本地缓存(多级缓存):在应用层引入 Caffeine/Guava 本地缓存,热点数据缓存在 JVM 内存中,减少对 Redis 的访问。
- 限流保护:对热点 Key 的访问进行限流(如令牌桶),超过阈值的请求直接降级返回默认值或排队等待。
- 永不过期 + 异步刷新:热点 Key 不设置过期时间,由后台任务定时刷新,避免失效瞬间引发击穿。
🔬 扩展知识
【L3】
- Key 拆分后各副本可能短暂不一致(回填时间差),读多写少场景可接受;写频繁时需统一刷新入口或用版本号防乱序。
- 热点探测宜多路交叉验证:客户端滑动窗口统计 +
redis-cli --hotkeys(需 LFU)+ 代理层频次统计,单一手段容易漏报或误报。
【L4】
- Redis 7.x 的 hotkey 分析仍依赖 LFU 采样或 MONITOR,生产上更推荐代理层统计,避免 MONITOR 的线上性能开销。
⚠️ 常见误区
详情
常见误区:
- ❌ "热点 Key 靠扩容就能解决" → 纠正:热点 Key 落在单个 slot 单节点,加节点无法分散单 Key 的流量,必须拆分 Key 或用本地缓存吸收。
- ❌ "MONITOR 可以长期开着找热点" → 纠正:MONITOR 实时捕获所有命令,性能开销大,只适合短时排查,不适合生产长期开启。
🔀 发散问题
Q:热点 Key 失效瞬间怎么办?
A:这就是缓存击穿,用互斥锁重建或逻辑过期 + 异步重建保护,配合热点探测提前标记。见本文档『什么是缓存击穿?如何应对?』。
Q:热点 Key 和大 Key 是一回事吗?
A:不是。热点 Key 是访问频次高,大 Key 是 value 体积大;两者危害与治理手段不同,但可能同时出现在同一个 Key 上。
【困难】如何发现并解决大 Key 问题?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:15 min | 🏷 标签:分布式缓存 / 大 Key
💎 关键结论
大 Key 是 value 体积过大的 Key,会阻塞主线程、打满带宽、造成内存不均。治理三板斧:拆分、压缩、UNLINK 异步删除,再在写入层限制大小从源头预防。
⚡记忆卡片
- 口诀:拆分压缩 UNLINK,写入校验防新增
- 关键词:阻塞主线程/--bigkeys/MEMORY USAGE/UNLINK/lazyfree
- 链路:value 过大 → 读写删除耗时长 → 阻塞主线程、打满带宽 → 拆分/压缩/异步删除 → 恢复正常
📖 核心知识
大 Key 指 value 体积过大的 Key(如 string 超过 10KB、list/hash/set 元素超过 1 万),会引发阻塞、网络打满、内存不均等问题。
大 Key 的危害:
- 阻塞主线程:Redis 单线程模型下,操作大 Key(如
DEL一个 100MB 的 key)会阻塞主线程数秒,导致其他请求超时。 - 内存不均:Cluster 模式下,大 Key 所在节点内存远高于其他节点,引发不均衡。
- 网络带宽打满:读取大 Key 时单次响应数据量大,可能打满网卡。
- 删除引发抖动:大 Key 过期或主动删除时,释放内存耗时,造成服务抖动。
- 引发缓存雪崩:大 Key 失效瞬间,大量请求穿透到数据库。
- 阻塞主线程:Redis 单线程模型下,操作大 Key(如
如何发现大 Key:
方法 命令/工具 说明 redis-cli --bigkeys redis-cli --bigkeys离线扫描,统计各类型最大的 key MEMORY USAGE MEMORY USAGE key查询单个 key 占用的内存(字节) RDB 离线分析 rdb-tools、redis-rdb-tools离线分析 RDB 快照,不阻塞线上 代理层监控 ShardingSphere 等代理层 拦截大 value 的请求并告警 如何解决大 Key:
- 拆分大 Key:
- 大 Hash:按字段拆分为多个小 Hash,或按业务维度拆分。
- 大 List:按时间或范围拆分为多个 List(如
log:20240101、log:20240102)。 - 大 String:如果是 JSON,拆分为多个字段存储;如果是文件,改用对象存储(OSS)。
- 压缩 value:对 String 类型的 value 进行 GZIP/Snappy 压缩,减少内存占用和网络传输。
- 异步删除(UNLINK):删除大 Key 时使用
UNLINK而非DEL,UNLINK会异步释放内存,不阻塞主线程。
- 拆分大 Key:
异步删除大 Key 示例
# 使用 UNLINK 异步删除大 Key
UNLINK big_key_123
# Redis 4.0+ 还可以配置异步删除策略
# 配置文件中设置
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes- 设置合理的过期时间:为大 Key 设置过期时间,避免长期占用内存;但过期时间要错开,避免同时删除引发抖动。
- 避免大 Key 写入:在写入层做校验,限制单个 key 的 value 大小(如超过 1MB 拒绝写入并告警)。
🔬 扩展知识
【L3】
- Redis 4.0+ 的 lazyfree 机制(
UNLINK、lazyfree-lazy-*配置)把内存释放放到后台线程,是治理大 Key 删除抖动的关键特性。 - RDB 离线分析(rdb-tools)可以在不影响线上的情况下盘点全量大 Key,适合周期性巡检;SCAN 遍历则不宜在生产高峰期对大实例使用。
【L4】
- 大 Key 与热点 Key 可能叠加(又大又热),此时拆分需同时考虑体积分散与访问分散,必要时叠加本地缓存。
⚠️ 常见误区
详情
常见误区:
- ❌ "删除大 Key 用 DEL 和 UNLINK 一样" → 纠正:DEL 在主线程同步释放内存,删 100MB 的 key 可能阻塞数秒;UNLINK 异步释放,不阻塞主线程(Redis 4.0+)。
- ❌ "大 Key 只是浪费内存" → 纠正:大 Key 会阻塞单线程、打满网卡、造成 Cluster 内存不均,删除时还会引发抖动,危害是系统性的。
🔀 发散问题
Q:大 Key 失效瞬间会引发什么问题?
A:大 Key 失效瞬间大量请求穿透到数据库,类似击穿/雪崩,需要重建保护与回源限流。见本文档『什么是缓存击穿?如何应对?』。
Q:如何在写入前发现潜在大 Key?
A:在写入层校验 value 大小(如超 1MB 拒绝并告警),并通过代理层拦截大 value 请求,把问题拦在源头。
【中等】什么是缓存预热?什么是缓存降级?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:缓存 / 运维手段
💎 关键结论
预热是提前把热点数据加载进缓存,避免首次访问穿透;降级是缓存故障或压力过大时主动牺牲部分功能保核心可用。一个防冷启动,一个保命兜底。
⚡记忆卡片
- 口诀:预热提前装弹,降级断臂保命
- 关键词:预热/启动加载/定时刷新/读降级/写降级/限流降级
- 链路:上线/重启前预加载热点 → 首次访问命中缓存 → 故障时返回兜底数据 → 核心业务不中断
📖 核心知识
- 缓存预热:缓存预热是指系统上线或重启后,提前将热点数据加载到缓存,避免用户首次访问时全部穿透到数据库。预热的实现方式:
- 系统启动时预热:在应用启动的
@PostConstruct或CommandLineRunner中加载热点数据。 - 定时任务预热:通过定时任务(如 XXL-JOB)在低峰期刷新缓存。
- 手动触发预热:提供管理后台,运维人员手动触发预热接口。
- 系统启动时预热:在应用启动的
启动预热示例代码
@Component
public class CacheWarmUpRunner implements CommandLineRunner {
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void run(String... args) {
// 加载热门商品到缓存
List<Long> hotProductIds = productMapper.selectHotProductIds(1000);
for (Long id : hotProductIds) {
Product product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(
"product:" + id,
JSON.toJSONString(product),
1, TimeUnit.HOURS
);
}
}
}
}缓存降级:缓存降级是指在缓存不可用或压力过大时,主动牺牲部分功能或一致性,保证核心业务可用。常见降级策略:
降级策略 触发条件 实现方式 读降级 缓存故障 直接返回默认值/兜底数据,不查数据库 写降级 数据库压力大 先写缓存,异步写入数据库(Write Behind) 一致性降级 高并发场景 暂时不保证缓存与 DB 一致性,只保证最终一致 限流降级 流量超载 超过阈值的请求返回降级数据
熔断降级示例代码(Hystrix)
@HystrixCommand(
fallbackMethod = "getProductFallback",
commandProperties = {
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50")
}
)
public Product getProduct(Long id) {
// 正常流程:先查缓存,再查数据库
}
public Product getProductFallback(Long id) {
// 降级:返回兜底数据
Product fallback = new Product();
fallback.setId(id);
fallback.setName("商品信息加载中");
return fallback;
}🔬 扩展知识
【L3】
- 预热写入要注意分批限速与 TTL 随机化,否则批量预热 + 固定 TTL 会制造"定时雪崩"(集中到期)。
- 降级策略应预案化:提前定义兜底数据与降级开关,并定期演练,避免故障时临时决策。
【L4】
- 写降级(Write Behind)以丢失风险换吞吐,只适合计数、日志等可容忍丢失的数据,资损敏感数据禁用。详见本文档『缓存更新有哪些策略?』。
🔀 发散问题
Q:预热和击穿防护如何配合?
A:预热解决已知热点的冷启动问题,互斥锁/逻辑过期解决动态热点的到期重建风暴,两者加上热点探测才能覆盖全部击穿场景。见本文档『什么是缓存击穿?如何应对?』。
Q:降级在缓存雪崩中起什么作用?
A:雪崩时流量直达 DB,限流降级放行部分请求、其余返回兜底数据,避免系统被流量打死,是事中防线的最后一环。见本文档『什么是缓存雪崩?如何应对?』。
读写分离
【简单】什么是读写分离?为什么需要读写分离?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:数据库 / 读写分离
💎 关键结论
读写分离就是把写请求给主库、读请求分发给从库。好处是减少锁竞争、提升查询吞吐与可用性,代价是要处理主从同步延迟带来的一致性问题。
⚡记忆卡片
- 口诀:写走主库读走从,少锁竞争提吞吐
- 关键词:主库/从库/锁竞争/负载均衡/同步延迟
- 链路:写请求进主库 → binlog 同步从库 → 读请求分发多从 → 吞吐与可用性提升 → 需处理同步延迟
📖 核心知识
读写分离将数据库的读操作和写操作分离到不同的数据库实例。
- 读写分离作用:
- 有效减少锁竞争 - 主服务器只负责写,从服务器只负责读,能够有效的避免由数据更新导致的行锁竞争,使得整个系统的查询性能得到极大的改善。
- 提高查询吞吐量 - 通过一主多从的配置方式,可以将查询请求均匀的分散到多个数据副本,能够进一步的提升系统的处理能力。
- 提升数据库可用性 - 使用多主多从的方式,不但能够提升系统的吞吐量,还能够提升数据库的可用性,可以达到在任何一个数据库宕机,甚至磁盘物理损坏的情况下仍然不影响系统的正常运行。
- 读写分离工作原理:
- 写操作:所有写请求(增删改)只发送到主库
- 数据同步:主库通过复制机制将数据变更同步到从库
- 读操作:读请求分发到多个从库(负载均衡)
- 延迟处理:需处理主从同步延迟带来的数据不一致问题
🔬 扩展知识
【L3】
- 读写分离的收益依赖复制链路健康:主从延迟过大时读从库会读到旧数据,因此"写后立即读"场景通常强制读主库。
【L4】
- 读写分离是 CQRS(读写职责分离)思想的存储层体现:查询模型与命令模型可以在实例、缓存甚至表结构层面彻底分离。
🔀 发散问题
Q:读写分离具体怎么实现?
A:根据 SQL 语义分析把读写路由到不同实例,有代码封装与中间件两种方式。见本文档『如何实现读写分离?』。
Q:读写分离会遇到什么问题?
A:最典型的是主从复制延迟导致读从库读到旧数据,需要强制读主库、并行复制等手段应对。见本文档『如何应对主从复制延迟?』。
【中等】如何实现读写分离?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:数据库 / 读写分离
💎 关键结论
读写分离的实现是按 SQL 语义分析把读写路由到主从库。两种方式:代码封装简单灵活但扩展性差,中间件统一管理但多一层维护成本,复杂架构选中间件。
⚡记忆卡片
- 口诀:SQL 语义分读写,代码封装或中间件
- 关键词:SQL 路由/代码封装/中间件/ShardingSphere/Mycat
- 链路:应用发出 SQL → 语义分析判定读写 → 写路由主库、读路由从库 → 结果返回应用
📖 核心知识
读写分离的基本原理是:主服务器用来处理写操作以及实时性要求比较高的读操作,而从服务器用来处理读操作。
读写分离的实现是根据 SQL 语义分析,将读操作和写操作分别路由至主库与从库。
读写分离有两种实现方式:代码封装、中间件。以下是两种方案的对比:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 代码封装 | 业务层通过代理类路由读写请求(读走从库,写走主库)。 | 简单灵活,可定制化 - 适合业务特定需求 | 主从切换需修改配置并重启 - 多语言需重复开发 |
| 中间件 | 独立代理服务(如 MySQL-Proxy、ShardingSphere),客户端无感知。 | 屏蔽多语言差异,统一管理数据源 | 有额外维护成本,可能成为性能瓶颈 |
结论:代码封装适合简单架构,但扩展性差;中间件适合复杂架构,但需维护。
常见的读写分离中间件:
- MySQL-Proxy(官方)
- Atlas(360)
- ShardingSphere(Apache)
- Mycat
🔬 扩展知识
【L3】
- 代理型中间件(如 ShardingSphere-Proxy)独立部署、对客户端无感知;内嵌型(如 ShardingSphere-JDBC)以 jar 形式集成,无额外网络跳转,二者可按部署形态选型。
- 写后立即读场景需强制路由主库(如 ShardingSphere 的 HintManager),否则主从延迟会导致读到旧数据。
【L4】
- 中间件代理层本身可能成为性能瓶颈与单点,生产上需代理集群化部署并纳入监控。
🔀 发散问题
Q:写后立即读怎么保证读到最新数据?
A:把实时性要求高的读请求路由到主库,如 ShardingSphere 用 HintManager 强制读主。见本文档『如何应对主从复制延迟?』。
Q:读写分离和分库分表是什么关系?
A:读写分离解决读吞吐与可用性,分库分表解决存储与写入瓶颈;ShardingSphere 等中间件可同时提供两种能力。
【中等】MySQL 主从复制的原理是什么?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:数据库 / 主从复制
💎 关键结论
MySQL 主从复制基于 binlog:主库写变更日志,从库 IO 线程拉取写入 relay log,SQL 线程重放完成同步。三个线程流水协作,有异步、半同步、组复制三种模式。
⚡记忆卡片
- 口诀:主写 binlog,IO 拉取中继,SQL 线程重放
- 关键词:binlog/relay log/IO 线程/SQL 线程/GTID
- 链路:主库写操作 → 记录 binlog → 从库 IO 线程拉取 → 写入 relay log → SQL 线程重放 → 数据一致
📖 核心知识
MySQL 主从复制基于 binlog 日志,通过 IO 线程拉取 + SQL 线程重放实现,分为异步复制、半同步复制、组复制三种模式。
- 核心流程(三个线程协作):
主库 Master 从库 Slave
┌──────────────┐ ┌──────────────────┐
│ 写操作 │ │ IO Thread │
│ ↓ │ 2.拉取 binlog │ ↑ │
│ binlog 日志 │◄──────────────│ 写入 relay log │
│ │ 3.推送事件 │ ↓ │
└──────────────┘ │ SQL Thread │
│ 读取 relay log │
│ ↓ │
│ 重放 SQL │
│ ↓ │
│ 更新数据 │
└──────────────────┘主库写入 binlog:主库执行写操作后,将变更记录到二进制日志(
binlog)。binlog是 MySQL Server 层的日志,记录所有 DDL 和 DML 语句(除 SELECT)。从库 IO 线程拉取:从库的 IO 线程连接主库,请求从指定 binlog 位点(position)或 GTID 开始读取日志。
写入 relay log:主库将 binlog 事件发送给从库,从库 IO 线程将其写入中继日志(
relay log)。relay log的格式与binlog相同。SQL 线程重放:从库的 SQL 线程读取
relay log,解析并重放 SQL 语句,使从库数据与主库保持一致。三种复制模式对比:
模式 原理 数据安全性 性能 适用场景 异步复制(默认) 主库提交事务后立即返回,不等从库 ACK 低,主库故障可能丢数据 高 对数据安全性要求不高的场景 半同步复制 主库提交后等待至少一个从库 ACK 才返回 中,降低丢数据风险 中 对数据安全性有一定要求的场景 组复制(MGR) 基于 Paxos 协议的多数派写入 高,强一致 较低 对一致性要求极高的金融场景 binlog 的三种格式:
格式 说明 优点 缺点 STATEMENT 记录 SQL 语句 日志量小 函数(如 NOW()、UUID())可能导致主从不一致ROW(推荐) 记录每行数据的变更 精确,不会出现主从不一致 日志量大(尤其批量操作) MIXED 混合模式,默认用 STATEMENT,不安全时用 ROW 兼顾日志量与准确性 复杂度略高
🔬 扩展知识
【L3】
- GTID(全局事务标识)模式下每个事务有全局唯一编号,从库故障切换、主从搭建比基于位点(position)的方式更简单可靠。
- MySQL 8.0 引入了多线程 SQL 线程与 WRITESET 依赖跟踪,显著缓解单线程重放造成的复制延迟。
【L4】
- MGR(MySQL Group Replication)基于 Paxos 变种 XCom 协议实现多数派确认,与传统主从是不同量级的架构,运维复杂度也更高。
⚠️ 常见误区
详情
常见误区:
- ❌ "binlog 和 redo log 是一回事" → 纠正:binlog 是 MySQL Server 层的逻辑日志(记录 DDL/DML,用于复制与恢复),redo log 是 InnoDB 引擎层的物理日志(用于崩溃恢复),两者层次与用途不同。
- ❌ "STATEMENT 格式日志小所以最好" → 纠正:STATEMENT 对 NOW()、UUID() 等非确定性函数会导致主从不一致,生产上推荐 ROW 格式。
🔀 发散问题
Q:主从复制延迟怎么应对?
A:强制读主库、并行复制、半同步复制、缓存兜底等手段组合使用。见本文档『如何应对主从复制延迟?』。
Q:半同步复制和异步复制怎么选?
A:看数据安全要求:能容忍主库故障丢少量数据选异步(性能最高),否则选半同步,5.7+ 推荐无损半同步。见本文档『MySQL 半同步复制和异步复制的区别是什么?』。
【中等】如何应对主从复制延迟?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:数据库 / 主从复制
💎 关键结论
主从延迟是异步复制的固有问题,源于异步机制、大事务、单线程重放等。应对组合拳:写后立即读强制走主库,从库开并行复制,核心链路用半同步,缓存兜底。
⚡记忆卡片
- 口诀:强制读主保实时,并行复制降延迟,半同步防丢数
- 关键词:强制读主库/并行复制/半同步/缓存兜底/WRITESET
- 链路:主库提交 → binlog 传输重放耗时 → 从库数据滞后 → 强制读主/并行复制/半同步 → 延迟收敛
📖 核心知识
主从延迟是异步复制的固有问题,应对策略包括:强制读主库、并行复制、半同步复制、缓存兜底。
延迟产生的原因:
- 异步复制:主库提交不等从库,从库重放需要时间。
- 大事务:单个事务涉及大量数据变更,从库重放耗时长。
- 从库性能差:从库硬件配置低于主库,或负载过高(如承担报表查询)。
- 网络延迟:主从跨机房部署,网络往返延迟高。
- 单线程重放:MySQL 5.6 之前从库 SQL 线程是单线程,无法并行重放。
解决方案:
方案 原理 优点 缺点 强制读主库 写后立即读的场景,路由到主库 简单直接,数据强一致 主库压力增大 并行复制 从库多线程重放 binlog 显著降低延迟 需要 MySQL 5.7+ 半同步复制 主库等从库 ACK 才返回 降低丢数据风险 写性能下降 缓存兜底 写后先将数据写入缓存,读优先走缓存 减少读库压力 增加缓存维护成本 写后短暂休眠 写操作后短暂 sleep 再读 简单 不优雅,不可靠
强制读主库的典型实现(ShardingSphere)
// ShardingSphere 使用 HintManager 强制路由到主库
public Order createAndQuery(Long userId, String productCode) {
Order order = new Order();
order.setUserId(userId);
order.setProductCode(productCode);
orderMapper.insert(order);
// 写操作完成后立即读,强制走主库避免主从延迟
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.setMasterRouteOnly();
return orderMapper.selectById(order.getId());
}
}从库并行复制配置(MySQL 5.7+)
# 从库开启基于组提交的并行复制
slave_parallel_type = LOGICAL_CLOCK
slave_parallel_workers = 8
binlog_transaction_dependency_tracking = WRITESET🔬 扩展知识
【L3】
- 并行复制的演进:5.6 按库并行 → 5.7 LOGICAL_CLOCK 按组提交并行 → WRITESET 按事务冲突检测并行,并行度逐级提升。
- MySQL 8.0 将
slave_*参数更名为replica_*(如replica_parallel_workers),配置时需注意版本差异。
【L4】
- 监控主从延迟可对比主从
SHOW MASTER STATUS/SHOW REPLICA STATUS位点差,或写入心跳表测量秒级延迟,延迟突增往往是大事务或从库过载的信号。
⚠️ 常见误区
详情
常见误区:
- ❌ "写后 sleep 几百毫秒再读就能解决延迟" → 纠正:延迟受大事务、从库负载影响并不固定,sleep 既不可靠又拖慢接口,应强制读主库或走缓存兜底。
- ❌ "半同步复制能消除延迟" → 纠正:半同步降低的是丢数据风险(等 ACK),ACK 只表示从库收到日志而非重放完成,重放延迟仍可能存在。
🔀 发散问题
Q:延迟过大时业务层还有哪些兜底手段?
A:写后把数据写入缓存、读优先走缓存(Cache-Aside),用缓存吸收延迟窗口内的读请求;一致性敏感的读直接走主库。
Q:大事务为什么会放大延迟?
A:大事务在从库需要完整重放才能生效,重放期间后续事务全部排队;治理手段是拆分大事务、避免一次性百万行的批量变更。
【中等】MySQL 半同步复制和异步复制的区别是什么?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:数据库 / 主从复制
💎 关键结论
异步复制提交后立即返回,性能高但主库故障可能丢数据;半同步等至少一个从库 ACK 才返回,大幅降低丢数据风险,代价是写性能略降,超时还会自动降级为异步。
⚡记忆卡片
- 口诀:异步不等 ACK 快,半同步等 ACK 稳,超时降级回异步
- 关键词:ACK/丢数据风险/写性能/超时降级/AFTER_SYNC
- 链路:主库写 binlog → 异步直接返回/半同步等从库 ACK → ACK 达成才提交 → 数据安全提升 → 超时降级保可用
📖 核心知识
| 对比项 | 异步复制 | 半同步复制 |
|---|---|---|
| 主库提交行为 | 提交后立即返回,不等从库 | 等待至少一个从库 ACK 才返回 |
| 数据丢失风险 | 主库故障可能丢失未同步的数据 | 大幅降低(至少一个从库有数据) |
| 写性能 | 最高 | 略有下降(增加等待 ACK 时间) |
| 超时降级 | 无 | 超时后自动降级为异步复制 |
| 适用场景 | 对数据安全性要求不高 | 对数据安全性有一定要求 |
无损半同步(MySQL 5.7+)
MySQL 5.7 引入了 AFTER_SYNC(无损半同步)模式:
- 旧版(AFTER_COMMIT):主库先提交事务,再等从库 ACK。若主库在 ACK 后故障,已提交的事务可能丢失(从库还没收到)。
- 新版(AFTER_SYNC):主库先等从库 ACK,再提交事务。保证已提交的事务一定已在至少一个从库上存在。
# 主库配置无损半同步
rpl_semi_sync_master_wait_point = AFTER_SYNC🔬 扩展知识
【L3】
- 半同步的超时降级是可用性保护:
rpl_semi_sync_master_timeout(默认 1000ms)超时后自动退回异步,避免从库故障拖垮主库写入,代价是降级窗口内回到异步的丢数据风险。 - AFTER_SYNC 与 AFTER_COMMIT 的差别本质是"先提交还是先等 ACK",AFTER_SYNC 避免了"主库已提交但从库未收到"的空洞,是当前推荐配置。
【L4】
- 更强的一致性可用 MGR(组复制)多数派写入实现,但性能与运维成本更高,适合金融级场景。
🔀 发散问题
Q:半同步复制能消除主从延迟吗?
A:不能。ACK 只表示从库收到并写入 relay log,不代表重放完成,重放延迟仍需并行复制等手段应对。见本文档『如何应对主从复制延迟?』。
Q:三种复制模式怎么选型?
A:性能优先选异步,数据安全要求中等选半同步,金融级强一致选组复制(MGR)。见本文档『MySQL 主从复制的原理是什么?』。
分库分表
【简单】什么是分库分表?为什么需要分库分表?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:数据库 / 分库分表
💎 关键结论
分库分表是数据库水平拆分方案,解决单机的存储与性能瓶颈。分库分散到不同实例,分表分散到不同表,换来并发提升、磁盘降压和 SQL 提速,是千万级单表的常规解法。
⚡记忆卡片
- 口诀:分库分实例,分表分数据,并发磁盘 SQL 三突破
- 关键词:水平拆分/并发连接/磁盘容量/SQL 性能/千万行
- 链路:单机容量/并发到顶 → 数据拆分到多库多表 → 单机压力分散 → 吞吐与性能提升
📖 核心知识
什么是分库分表?
分库分表是一种数据库水平拆分方案,用于解决单机数据库的存储瓶颈和性能瓶颈问题。
- 分库:将数据分散到不同的数据库实例(如
DB1、DB2)。 - 分表:将数据分散到同一数据库的不同表(如
order_1、order_2)。
- 分库:将数据分散到不同的数据库实例(如
**为何要分库分表?**分库分表主要基于以下理由:
- 并发连接 - 一个健康的单库最好保持在每秒 1000 个并发左右,不要太大。
- 磁盘容量 - 磁盘容量占满,会导致服务器不可用。
- SQL 性能 - 单表数据量过大,会导致 SQL 执行效率低下。一般,单表超过 1000 万条数据,就可以考虑分表了。
# 分库分表前 分库分表后 并发支撑情况 MySQL 单机部署,扛不住高并发 MySQL 从单机到多机,能承受的并发增加了多倍 磁盘使用情况 MySQL 单机磁盘容量几乎撑满 拆分为多个库,数据库服务器磁盘使用率大大降低 SQL 执行性能 单表数据量太大,SQL 越跑越慢 单表数据量减少,SQL 执行效率明显提升
🔬 扩展知识
【L3】
- "单表千万行考虑分表"是经验阈值,实际还取决于行宽、索引数量与查询模式:宽表(字段多、索引多)可能几百万行就需要拆分。
【L4】
- 分库分表前应先穷尽单机优化(读写分离、索引优化、归档冷数据),因为拆分带来的分布式 ID、跨库事务等复杂度代价很高。见本文档『分库分表存在哪些问题?』。
🔀 发散问题
Q:垂直拆分和水平拆分有什么区别?
A:垂直拆分按业务/字段拆(专库专用),水平拆分按行拆(同结构多表);通常先垂直后水平。见本文档『垂直拆分和水平拆分有什么区别?』。
Q:分库和分表分别解决什么问题?
A:分库主要解决单机并发、连接数与磁盘容量瓶颈,分表主要解决单表数据量过大导致的 SQL 性能问题,两者常组合使用。
【困难】如何实现分库分表?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:15 min | 🏷 标签:数据库 / 分库分表
💎 关键结论
分库分表就是选好拆分键、用对路由算法、平滑迁移数据、改造查询。路由三种玩法:范围路由连续但热点,Hash 路由均匀但扩容麻烦,路由表灵活但多一次 IO。
⚡记忆卡片
- 口诀:范围连续易热点,取模均匀难扩容,路由表灵活多一跳
- 关键词:范围路由/Hash 取模/路由表/预分区/翻倍扩容
- 链路:选定拆分键 → 路由算法定分片 → 数据迁移就位 → 查询按规则定位分片
📖 核心知识
分库分表 = 选好拆分键 + 用对路由算法 + 平滑数据迁移 + 改造查询语句,核心是让数据均匀分布且查询能精准定位到分片。
数值范围路由:根据 ID、时间范围 这类具有排序性的字段来进行划分。例如:用户 Id 为 1-9999 的记录分到第一个库,10000-20000 的分到第二个库,以此类推。按这种策略划分出来的数据,具有数据连续性。
- 优点:数据迁移很简单。
- 缺点:容易产生热点问题,大量的流量都打在最新的数据上了。
Hash 路由:典型的 Hash 路由,如根据数值取模,当需要扩容时,一般以 2 的幂次方进行扩容(这样,扩容时迁移的数据量会小一些)。例如:用户 Id mod n,余数为 0 的记录放到第一个库,余数为 1 的放到第二个库,以此类推。
一般采用 预分区 的方式,提前根据 数据量 规划好 分区数,比如划分为
512或1024张表,保证可支撑未来一段时间的 数据容量,再根据 负载情况 将 表 迁移到其他 数据库 中。扩容时通常采用 翻倍扩容,避免 数据映射 全部被 打乱,导致 全量迁移 的情况。- 优点:数据离散分布,不存在热点问题。
- 缺点:数据迁移、扩容麻烦(之前的数据需要重新计算 hash 值重新分配到不同的库或表)。当 节点数量 变化时,如 扩容 或 收缩 节点,数据节点 映射关系 需要重新计算,会导致数据的 重新迁移。
路由表:这种策略,就是用一张独立的表记录路由信息。
- 优点:简单、灵活,尤其是在扩容、迁移时,只需要迁移指定的数据,然后修改路由表即可。
- 缺点:每次查询,必须先查路由表,增加了 IO 开销。并且,如果路由表本身太大,也会面临性能瓶颈,如果想对路由表再做分库分表,将出现死循环式的路由算法选择问题。

🔬 扩展知识
【L3】
- 一致性哈希可缓解 Hash 路由的扩容重分布问题:节点变化时只影响相邻区间的数据;但数据库分片场景更常用"预分区 + 翻倍扩容"的工程手段规避。
- ShardingSphere 等中间件已内置取模、范围、时间等分片算法,并支持自定义分片算法,可避免手写路由逻辑。
【L4】
- 预分区数量规划要预留足够冗余(如 512/1024 张表),一次规划支撑多年,避免频繁迁移;表数量过多也会带来元数据与运维开销,需权衡。
⚠️ 常见误区
详情
常见误区:
- ❌ "分表数定得少,后期再加分表就行" → 纠正:Hash 路由下节点数量变化会导致映射关系全部重算、数据重新迁移;应预分区一次规划到位,扩容采用翻倍方式。
- ❌ "范围路由扩容简单所以最优" → 纠正:范围路由的数据连续也意味着流量集中在最新区间,容易产生热点,适合时间序列类数据而非高并发写入场景。
🔀 发散问题
Q:拆分键怎么选?
A:要高基数、分布均匀、查询高频携带、不可变,如订单表选 user_id。见本文档『如何选择分片键(Sharding Key)?』。
Q:扩容时如何平滑迁移数据?
A:主流是双写迁移(新旧库双写 + 校验 + 灰度切换),小规模可用停机迁移。见本文档『如何实现迁库和扩容?』。
【困难】分库分表存在哪些问题?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:15 min | 🏷 标签:数据库 / 分库分表
💎 关键结论
分库分表换来吞吐,也带来四大难题:分布式 ID、分布式事务、跨节点 Join 和聚合、跨节点排序分页。本质都是单库局部能力失效后,必须用二次查询或中间件补齐全局能力。
⚡记忆卡片
- 口诀:ID 要全局,事务要跨库,Join 聚合二次查,分页归并再排序
- 关键词:分布式 ID/分布式事务/跨节点 Join/排序分页/二次查询
- 链路:数据分散多节点 → 单库能力失效 → ID/事务/Join/分页各自需新方案 → 复杂度上升
📖 核心知识
分库分表主要存在以下问题:
- 分布式 ID 问题
- 分布式事务问题
- 跨节点 Join 和聚合
- 跨节点的排序分页

分布式 ID 问题:一旦数据库被切分到多个物理结点上,我们将不能再依赖数据库自身的主键生成机制。一方面,某个分区数据库自生成的 ID 无法保证在全局上是唯一的;另一方面,应用程序在插入数据之前需要先获得 ID,以便进行 SQL 路由。
分布式 ID 的解决方案详见:分布式 ID
分布式事务问题:跨库事务也是分布式的数据库集群要面对的棘手事情。 合理采用分表,可以在降低单表数据量的情况下,尽量使用本地事务,善于使用同库不同表可有效避免分布式事务带来的麻烦。在不能避免跨库事务的场景,有些业务仍然需要保持事务的一致性。 而基于 XA 的分布式事务由于在并发度高的场景中性能无法满足需要,并未被互联网巨头大规模使用,他们大多采用最终一致性的柔性事务代替强一致事务。
分布式事务的解决方案详见:分布式事务
跨节点 Join 和聚合:分库分表后,无法直接跨节点
join、count、order by、group by以及聚合。针对这类问题,普遍做法是二次查询:- 在第一次查询时,获取各个节点上的结果。
- 在程序中将这些结果进行合并、筛选。
跨节点的排序分页:一般来讲,分页时需要按照指定字段进行排序。当排序字段就是分片字段的时候,我们通过分片规则可以比较容易定位到指定的分片,而当排序字段非分片字段的时候,情况就会变得比较复杂了。为了最终结果的准确性,我们需要在不同的分片节点中将数据进行排序并返回,并将不同分片返回的结果集进行汇总和再次排序,最后再返回给用户。如下图所示:

上面图中所描述的只是最简单的一种情况(取第一页数据),看起来对性能的影响并不大。但是,如果想取出第 10 页数据,情况又将变得复杂很多,如下图所示:

有些读者可能并不太理解,为什么不能像获取第一页数据那样简单处理(排序取出前 10 条再合并、排序)。其实并不难理解,因为各分片节点中的数据可能是随机的,为了排序的准确性,必须把所有分片节点的前 N 页数据都排序好后做合并,最后再进行整体的排序。很显然,这样的操作是比较消耗资源的,用户越往后翻页,系统性能将会越差。
那如何解决分库情况下的分页问题呢?有以下几种办法:
- 如果是在前台应用提供分页,则限定用户只能看前面 n 页,这个限制在业务上也是合理的,一般看后面的分页意义不大(如果一定要看,可以要求用户缩小范围重新查询)。
- 如果是后台批处理任务要求分批获取数据,则可以加大 page size,比如每次获取 5000 条记录,有效减少分页数(当然离线访问一般走备库,避免冲击主库)。
- 分库设计时,一般还有配套大数据平台汇总所有分库的记录,有些分页查询可以考虑走大数据平台。
🔬 扩展知识
【L3】
- 跨分片排序分页的优化方向:业务上限制翻页深度(禁止深分页)、加大 page size 减少分页数、用游标/上一页末尾 ID 代替 OFFSET 翻页。
- ShardingSphere 等中间件提供绑定表(binding table)机制,把关联表按相同规则分片,可在分片内直接 Join,避免笛卡尔积广播查询。
【L4】
- 复杂多维查询(后台管理、商家维度)常下沉到 Elasticsearch 或数据仓库,与在线分片库异构共存,以空间换查询能力。
⚠️ 常见误区
详情
常见误区:
- ❌ "分库后还能用数据库自增 ID" → 纠正:各分片自增 ID 会重复冲突,且应用需要先获得 ID 才能路由,必须引入全局唯一 ID 方案(雪花算法、号段模式等)。
- ❌ "跨库事务用 XA 就能解决" → 纠正:XA 在高并发场景性能无法满足需要,互联网大厂大多采用最终一致性的柔性事务(如本地消息表、事务消息)代替强一致事务。
🔀 发散问题
Q:分布式 ID 有哪些方案?
A:UUID、数据库自增、号段模式、雪花算法、Redis INCR、Leaf 等,需满足全局唯一、趋势递增、高可用。见本文档『常见的分布式 ID 方案有哪些?』。
Q:如何尽量避免跨库事务?
A:善于使用同库不同表:把需要事务关联的表绑定到同一分片键(如都按 user_id 分片),让事务尽量在单库内完成。
【困难】如何实现迁库和扩容?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:15 min | 🏷 标签:数据库 / 迁移扩容
💎 关键结论
迁库扩容三种方案:停机迁移最简单但代价高不推荐;双写迁移新旧库双写加校验灰度切换,是主流首选;主从替换利用现有从库直接升级,无需迁移数据、适合已有主从架构。
⚡记忆卡片
- 口诀:停机太粗暴,双写保平滑,主从替换白捡库
- 关键词:停机迁移/双写迁移/主从替换/数据校验/灰度切换
- 链路:双写新旧库 → 同步存量 + 校验修复 → 灰度切读 → 全量切换 → 清理旧库
📖 核心知识
停机迁移/扩容(不推荐):停机迁移/扩容是最暴力、最简单的迁移、扩容方案。

停机迁移/扩容流程:
- 预估停服时间,发布停服公告;停服,不允许数据访问。
- 编写临时的数据导入程序,从老数据库中读取数据。
- 将数据写入中间件。
- 中间件根据分片规则,将数据分发到分库(分表)中。
- 应用程序修改配置,重启。
停机迁移/扩容方案分析:
- 优点:简单、无数据一致性问题。
- 缺点:
- 停服时间长(数据量大时可能需数小时)。
- 风险高,失败后难以回滚。
结论:代价过高,不推荐使用。
双写迁移:
双写迁移方案核心思想:
- 新旧库同时写入,通过开关控制读写状态(只写旧库、只写新库、双写)。
- 逐步切换读请求到新库,确保数据一致性。
双写迁移方案关键步骤:
- 双写阶段:先写旧库,再写新库,以旧库结果为准。记录旧库成功但新库失败的日志,用于补偿。
- 数据校验:运行对比程序,检查新旧库数据差异并修复。
- 灰度切换读请求:逐步将读流量切至新库,观察稳定性。
- 最终切换:读写全部切至新库,清理旧库冗余数据。

双写迁移流程:
- 修改应用程序配置,将数据同时写入老数据库和中间件。这就是所谓的双写,同时写俩库,老库和新库。
- 编写临时程序,读取老数据库。
- 将数据写入中间件。如果数据不存在,直接写入;如果数据存在,比较时间戳,只允许新数据覆盖老数据。
- 导入数据后,有可能数据还是存在不一致,那么就对数据进行校验,比对新老库的每条数据。如果存在差异,针对差异数据,执行(3)。循环(3)、(4)步骤,直至数据完全一致。
- 修改应用程序配置,将数据只写入中间件。
- 中间件根据分片规则,将数据分发到分库(分表)中。
双写迁移方案分析:
- 优点:无需停服,业务影响小;可灰度验证,风险可控。
- 缺点:实现复杂,需处理双写一致性和补偿逻辑。
主从替换:生产环境的数据库,为了保证高可用,一般会采用主从架构。主库支持读写操作,从库支持读操作。

由于主从节点数据一致,所以将从库升级为主节点,并修改分片配置,将从节点作为分库之一,就实现了扩容。

主从替换方案流程:
- 解除主从关系,从库升级为主库。
- 应用程序,修改配置,读写通过中间件。
- 分库分表中间,修改分片配置。将数据按照新的规则分发。
- 编写临时程序,清理冗余数据。比如:原来是一个单库,数据量为 400 万。从节点升级为分库之一后,每个分库都有 400 万数据,其中 200 万是冗余数据。清理完后,进行数据校验。
- 为每个分库添加新的从库,保证高可用。
主从替换方案分析:
- 无需停机,无需全量数据迁移。
- 利用现有从库资源,节省成本。
三种方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 停机迁移 | 小规模数据,容忍停服 | 简单,无一致性问题 | 停服时间长,风险高 |
| 双写迁移 | 大规模数据,要求高可用 | 无停服,灰度可控 | 复杂,需补偿机制 |
| 主从替换 | 已有主从架构 | 无需迁移数据,快速扩容 | 依赖现有从库,清理冗余复杂 |
推荐选择:
- 优先双写迁移:适合大多数业务,平衡风险与复杂度。
- 主从升级:适合已有主从且数据量适中的场景。
- 避免停机迁移:除非数据量极小且可接受停服。
🔬 扩展知识
【L3】
- 双写迁移的成败在校验环节:全量比对 + 增量比对(按更新时间戳)循环执行直至完全一致,才可切读;校验程序应支持断点续比,避免每次全量扫描。
- 迁移前建议先做影子验证:新库接入读流量前,用旁路对比新旧库的查询结果,确认业务语义一致。
【L4】
- 基于 binlog 订阅(如 Canal)可实现存量 + 增量的自动化同步,替代手工编写同步程序,是当前大规模迁移的主流做法。
🏭 实战场景
详情
场景推演:双写迁移 2 亿行订单库拆 16 库(以下数字均为容量估算推演,非真实生产数据)
- 规模设定:单库 2 亿行订单、数据量约 120GB,写入峰值 3000 TPS,要求不停服拆分为 16 库。
- 耗时估算:存量同步按限速约 5000 行/秒读取,2 亿行约需 11 小时,可拆分到多个低峰时段执行;双写阶段新库写放大一倍,旧库侧写负载估算上升 30%~50%,需提前预留容量。
- 校验周期:全量校验按主键范围分批(如每批 10 万行)比对,一轮全量约 30 分钟,通常需要 2~3 轮“校验 → 修复差异 → 再校验”才能收敛到零差异。
- 关键卡点:① 双写失败若补偿不到位,新旧库差异会随时间线性增长,必须以差异日志 + 后台修复任务闭环;② 切读流量前必须满足“连续 2~3 轮校验零差异”的硬标准,否则影响面等于全部读流量;③ 切换开关要支持秒级回切旧库,切换后旧库至少保留一到两周观察期再下线。
⚠️ 常见误区
详情
常见误区:
- ❌ "双写迁移切完读流量就结束了" → 纠正:切换后仍需保留回滚能力(旧库继续保留一段时间),并持续监控新旧库差异;双写的一致性补偿日志也要保留至稳定后再清理。
- ❌ "主从替换后不用清理数据" → 纠正:从库升级为分库后包含全量数据,其中约一半是冗余数据,必须清理并校验,否则空间浪费且查询路由错误。
🔀 发散问题
Q:扩容时 Hash 路由为什么推荐翻倍扩容?
A:翻倍扩容时只有一半数据需要迁移,避免映射关系全部打乱导致全量迁移。见本文档『如何实现分库分表?』。
Q:双写阶段新库写失败怎么办?
A:记录旧库成功但新库失败的日志用于补偿,由后台任务重试修复;最终以数据校验收敛为准。
【中等】如何选择分片键(Sharding Key)?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:数据库 / 分库分表
💎 关键结论
分片键决定数据分布与查询路由,是分库分表设计的核心。好分片键要高基数、分布均匀、查询高频携带、不可变;选错了数据倾斜加多维查询难,返工代价极大。
⚡记忆卡片
- 口诀:高基数、够均匀、查询带、不可变
- 关键词:高基数/分布均匀/精确路由/不可变/异构索引
- 链路:选错分片键 → 数据倾斜或全分片扫描 → 热点与性能恶化 → 返工迁移代价高
📖 核心知识
分片键决定了数据如何分布和查询如何路由,是分库分表设计的核心。好的分片键应保证数据均匀分布、查询能精确路由、避免数据倾斜。
分片键选择原则:
原则 说明 高基数 分片键的取值范围要足够大,避免数据倾斜(如用 user_id而非gender)分布均匀 分片键的取值经过 hash 后能均匀分布到各分片,避免热点 查询带分片键 大部分查询条件都包含分片键,避免全分片扫描 不可变 分片键值一旦确定不再变化,避免数据迁移 单调递增(可选) 若用范围分片,分片键最好单调递增(如时间、自增 ID) 常见场景的分片键选择:
业务场景 推荐分片键 理由 电商订单 user_id用户查询自己的订单,精确路由;商家查询需走全分片或异构索引 用户中心 user_id绝大多数查询以用户为维度 社交消息 sender_id或conversation_id按发送者或会话维度查询 日志流水 create_time按时间范围查询为主,范围分片友好 支付交易 user_id或merchant_id根据主要查询维度选择,可能需要异构索引 分片键选择的坑:
- 避免使用会变化的字段:如手机号、状态字段,变更后需跨分片迁移数据。
- 避免使用低基数字段:如性别、状态,只有几个取值,必然数据倾斜。
- 多维度查询问题:若订单表以
user_id分片,商家查询自己的订单需要扫描全部分片。解决方案:- 异构索引:用
merchant_id建立另一套索引表(可以是 ES 或另一张分片表)。 - 双写双分片:同时按
user_id和merchant_id分片存储两份数据。 - 数仓/搜索引擎:复杂查询走 Elasticsearch 或数据仓库。
- 异构索引:用
🔬 扩展知识
【L3】
- 异构索引(如订单再按 merchant_id 存一份)本质是"存储换查询",需解决两份数据的一致性同步,常用 binlog 订阅异步维护。
【L4】
- 分片键一旦上线极难变更(涉及全量数据迁移),选型阶段应把未来 3~5 年的主要查询模式盘点清楚,宁可前期多花时间。
🔀 发散问题
Q:多维查询除了异构索引还有什么办法?
A:双写双分片存两份数据,或把复杂查询下沉到 Elasticsearch、数据仓库,各取所长。
Q:分片键和路由算法是什么关系?
A:分片键决定"按什么字段分",路由算法决定"怎么算出分片号"(取模/范围/查路由表),两者配合完成数据分布。见本文档『如何实现分库分表?』。
【简单】垂直拆分和水平拆分有什么区别?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:数据库 / 拆分方式
💎 关键结论
垂直拆分按业务或字段拆,解决单库与耦合问题;水平拆分按行拆,才能解决单表数据量过大。实践上先垂直后水平,两者互补而非替代。
⚡记忆卡片
- 口诀:垂直拆业务,水平拆行数据,先垂直后水平
- 关键词:垂直拆分/水平拆分/专库专用/分片键/跨库 JOIN
- 链路:系统膨胀 → 按业务垂直分库 → 大表再按行水平拆分 → 单表变小性能回升
📖 核心知识
| 对比项 | 垂直拆分 | 水平拆分 |
|---|---|---|
| 拆分维度 | 按业务或字段拆分到不同库/表 | 按行数据拆分到多个结构相同的表/库 |
| 拆分方式 | 专库专用(用户库、订单库);冷热字段分离 | 按分片键 hash/范围拆分 |
| 数据分布 | 每个库存储不同表/字段的数据 | 每个分片存储部分行的数据 |
| 能否解决单表数据量过大 | 不能(只是换了个库存) | 能(单表数据量减少) |
| 能否解决单库并发瓶颈 | 部分(分散到不同库) | 能(分散到多个库) |
| 跨库 JOIN | 频繁出现(业务关联表分散) | 不出现(同结构表) |
| 分布式事务 | 少(同业务在同一库) | 多(跨库操作) |
| 实施难度 | 低(业务梳理清晰即可) | 高(需引入中间件、分布式 ID 等) |
| 典型场景 | 业务边界清晰的系统 | 单表数据量超千万级的系统 |
实践建议:通常先垂直拆分(按业务分库),再对单表数据量过大的表进行水平拆分。
🔬 扩展知识
【L3】
- 垂直拆分还包括冷热字段分离:把大字段(如正文、图片 URL)拆到副表,主表保持窄行,提升扫描与缓存效率。
- 垂直拆分后的跨库 JOIN 问题,可通过应用层组装、冗余字段或搜索引擎解决,与水平拆分的跨分片查询治理思路相通。
【L4】
- 垂直拆分本质是 DDD 限界上下文在存储层的落地:库边界与业务边界对齐,为后续服务拆分与数据治理铺路。
⚠️ 常见误区
详情
常见误区:
- ❌ "垂直拆分能解决单表数据量过大" → 纠正:垂直拆分只是换了个库存,单表行数不变,只有水平拆分(按行拆)才能减少单表数据量。
- ❌ "水平拆分实施成本低,可以优先做" → 纠正:水平拆分需引入中间件、分布式 ID、处理跨库事务等,难度高;通常先垂直拆分把业务边界理清,再对大表水平拆分。
🔀 发散问题
Q:为什么通常先垂直后水平?
A:垂直拆分实施难度低(业务梳理清晰即可),能先解决耦合与单库压力;水平拆分复杂度高,只对真正超大的表使用,避免过度设计。
Q:水平拆分会带来哪些新问题?
A:分布式 ID、分布式事务、跨节点 Join 聚合、跨节点排序分页四大难题。见本文档『分库分表存在哪些问题?』。
缓存与数据库一致性
【困难】如何保证缓存与数据库的一致性?⭐⭐⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:15 min | 🏷 标签:缓存 / 一致性
💎 关键结论
主流方案是 Cache Aside:先更新 DB 再删缓存,配合 TTL 兜底实现最终一致。强一致代价极大,工程目标是压缩不一致窗口;真实风险主要来自删缓存失败而非极端时序。
⚡记忆卡片
- 口诀:先写库再删缓存,TTL 兜底保收敛,删失败要补偿
- 关键词:Cache Aside/延迟双删/binlog 订阅/TTL 兜底/最终一致
- 链路:写更新 DB → 删除缓存 → 删除失败则重试/MQ/binlog 补偿 → 读未命中回源 → 收敛到最新值
📖 核心知识
Cache Aside(先更新 DB,再删除缓存)是业界主流方案,辅以延迟双删、消息补偿、binlog 订阅等手段,可实现最终一致。
一致性层级与选型边界:
层级 定义 可达成的方案 代价 弱一致 容忍脏读,不承诺收敛时间 Write Behind、纯 TTL 低 最终一致 写入停止后,经过一个(可估的)窗口,所有读收敛到最新值 Cache Aside、延迟双删、binlog 订阅 低~中 强一致 任意时刻任意读都等于最新已提交值 不用缓存 / 2PC / 读写串行化 高(吞吐降一个数量级) 核心结论:只要引入缓存,DB 与缓存就是两个独立存储,变更无法原子完成,强一致只能靠串行化或 2PC,吞吐代价极大。所以工程上的正确目标是“最终一致 + 把不一致窗口压缩到业务可接受”:一般业务用 Cache Aside + TTL 兜底;资损敏感业务叠加延迟双删或 binlog 订阅;真正的强一致场景(如账务余额)直接绕过缓存读 DB。
方案一:先删缓存,再更新 DB(不推荐)。并发时序(写线程 W、读线程 R,DB 初始值 V1):
时间线 →
W:① 删除缓存
W:② 更新 DB 为 V2(进行中,尚未提交)
R:③ 读缓存未命中
R:④ 读 DB,读到旧值 V1(W 还没提交)
W:⑤ 更新 DB 完成,DB = V2
R:⑥ 把 V1 回填缓存
结果:DB = V2,缓存 = V1,不一致持续到 TTL 到期漏洞窗口 ≈ 整个 DB 更新耗时(毫秒到几十毫秒),窗口内任意一个读都会回填旧值,触发概率不低,所以这个顺序不作为主方案。
- 方案二:先更新 DB,再删除缓存(Cache Aside,主流方案)。极端时序漏洞推演(要求读请求恰好横跨整个写窗口):
时间线 →
R:① 读缓存未命中(key 恰好已过期/被删)
R:② 读 DB,读到旧值 V1(此时 W 还没写)
W:③ 更新 DB 为 V2
W:④ 删除缓存(缓存本来就是空的,删除是空操作)
R:⑤ 把 V1 回填缓存
结果:DB = V2,缓存 = V1,不一致持续到 TTL 到期触发条件极其苛刻:必须同时满足——① 读请求发起时缓存恰好未命中;② 读 DB(②~⑤)的总耗时超过写请求的 DB 更新耗时(③~④);③ 旧值回填恰好落在删除之后。而现实中缓存写入是内存级操作(约 1ms),DB 更新通常 5~50ms,要求一个读请求比一个写请求“跑得还慢”,概率量级在万分之一以下,且仅在该 key 恰好过期的一瞬间才可能成立——这就是 Cache Aside 仍是主流方案的原因。
对比可见:先删缓存方案的漏洞窗口是“整个 DB 更新时长”,先更新 DB 方案的漏洞要求“读比写慢”的逆序时序,前者概率高几个数量级。
更要警惕的真实风险:生产上不一致的主要来源不是上述极端时序,而是删除缓存失败(Redis 抖动、超时、应用崩溃),此时脏数据会存活到 TTL 到期。所以删除必须加重试/MQ 补偿,并用 TTL(如 5~30 分钟)作为最终兜底:TTL 把任何不一致都限制在一个有界窗口内,这是所有方案的安全网。
- 方案三:延迟双删:在“先删缓存 + 更新 DB”的基础上,增加一次延迟删除,专门消除方案一的旧值回填:
1. 删除缓存
2. 更新数据库
3. 延迟 N 毫秒(覆盖读线程“读 DB + 回填缓存”的耗时)
4. 再次删除缓存(干掉第 3 步期间可能回填的旧值)时序推演(N 取值正确时):
时间线 →
W:① 删除缓存
R:② 读缓存未命中
W:③ 更新 DB 为 V2
R:④ 读 DB(可能读到 V1 或 V2)
R:⑤ 回填缓存(可能是 V1)
W:⑥ 延迟 N 毫秒后再次删除缓存(V1 被干掉)
结果:下次读重新回源,收敛到 V2延迟双删示例代码
public void updateWithDoubleDelete(Long id, Object newValue) {
String key = "entity:" + id;
// 1. 先删除缓存
redisTemplate.delete(key);
// 2. 更新数据库
mapper.updateById(id, newValue);
// 3. 延迟一段时间后再次删除(生产上建议走延迟队列异步执行)
delayQueue.offer(new CacheDeleteTask(key), 300, TimeUnit.MILLISECONDS);
}延迟双删的参数设置依据(线上实践):
- 延迟量 N:必须大于一次“读未命中 → 查 DB → 回填缓存”的总耗时。经验公式:N = 读接口 P99 耗时 + 100~200ms 缓冲。例如读链路 P99 为 80ms,则 N 取 300ms;宁大勿小,N 偏大只是多删一次空缓存,偏小则兜不住脏回填。
- 执行方式:不要用业务线程
sleep(阻塞线程、进程重启丢任务),生产上用 RocketMQ 延迟消息、Redis ZSET 延迟队列或定时任务异步投递第二次删除。 - 失败兜底:第二次删除仍可能失败,需重试 3 次 + 死信告警,最终由 TTL/binlog 兜底。
延迟双删的失效边界:N 取值过小时退化为普通先删缓存;延迟窗口内若有多次写入,每次写各自触发双删,以最后一次为准,不会引入新错误,但删除请求量会放大,写极热场景需评估 Redis 压力。
- 方案四:订阅 binlog 异步更新缓存(Canal):
写请求 → 更新 DB(写 binlog)→ Canal 伪装从库拉取 binlog → 解析变更 → 投递 MQ → 消费者删除/更新缓存
↓(删除失败)
MQ 重试 → 死信告警Canal 消费者示例代码
// Canal 消费者监听 binlog 事件
@Component
@RocketMQMessageListener(topic = "canal-binlog", consumerGroup = "cache-sync")
public class CanalCacheListener implements RocketMQListener<CanalMessage> {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void onMessage(CanalMessage message) {
if ("UPDATE".equals(message.getEventType()) || "DELETE".equals(message.getEventType())) {
for (Map<String, String> row : message.getData()) {
String id = row.get("id");
String cacheKey = "entity:" + id;
redisTemplate.delete(cacheKey);
}
}
}
}优点:业务解耦(业务只管写 DB);可靠性高(binlog 是持久化日志,不丢变更,业务代码漏删缓存也能兜底);可重放(位点回拨即可重新同步)。
缺点与失效边界:① 引入 Canal + MQ 额外组件,运维复杂度高;② 一致性窗口 = binlog 产生 → Canal 拉取 → MQ 消费 的链路延迟,正常 100ms~1s,Canal 宕机或 MQ 积压时可能放大到分钟级;③ 消费必须幂等(重复删缓存本身幂等,但若改成“更新缓存”则必须带版本号);④ 多分片场景需按表/主键 hash 分区消费,保证同一行的变更有序,否则乱序消费会用旧值覆盖新值。
🔬 扩展知识
【L3】
- 版本号/时间戳能部分解决并发写不一致:缓存值携带数据版本,回填时比较版本,只允许高版本覆盖低版本,能挡住“旧值晚到覆盖新值”的乱序问题。代价是缓存结构和写路径都要改造,且删除失败场景仍需要 TTL/重试兜底,所以通常作为“更新缓存”类方案的配套,而不是替代“删除缓存”。
- 多级缓存(L1 进程内 + L2 Redis)的一致性做法:L1 无法被全局原子失效,标准做法是 L2 删除成功后通过 MQ 广播失效事件,各应用节点清理本地 L1;同时 L1 TTL 设短(5~30s)兜底。接受秒级不一致;若业务连秒级都接受不了,该数据就不应放进 L1。
【L4】
- 若业务必须强一致,三档方案:写后读走主库短窗口(如写后 3s 内的读绕过缓存或强制读 DB);读写加分布式锁串行化(吞吐下降明显);彻底不用缓存。真正的强一致必须让“变更 DB + 失效缓存”原子化(2PC),代价是吞吐降一个数量级,只有账务余额、库存扣减这类资损场景才值得,且更常见的做法是这些字段本来就不走缓存。
【L3】缓存一致性五种模式对比
| 模式 | 读流程 | 写流程 | 一致性 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 先读缓存,未命中读 DB 并回填 | 先写 DB,再删缓存 | 最终一致 | 通用场景(最常用) |
| Read Through | 缓存服务负责加载(应用无感) | 同 Cache Aside | 最终一致 | 缓存服务支持自动加载 |
| Write Through | 同 Cache Aside | 同步写缓存和 DB | 强一致 | 对一致性要求高 |
| Write Behind | 同 Cache Aside | 只写缓存,异步刷 DB | 弱一致 | 写多读少,容忍丢失 |
| Refresh Ahead | 缓存过期前主动刷新 | 同 Cache Aside | 最终一致 | 热点数据,避免过期抖动 |
五种模式的本质差异是“谁负责缓存与 DB 的同步”:Cache Aside 由应用负责,Read/Write Through 由缓存服务负责,Write Behind 把同步延迟到后台批量执行。模式选择与数据重要度、读写比、可容忍丢失程度强相关,同一系统内不同数据可以混用不同模式(如价格用 Cache Aside、浏览量用 Write Behind)。
选型建议:常规业务用 Cache Aside + TTL 兜底即可;高一致要求叠加延迟双删 + 消息重试;解耦高可靠选 binlog 订阅;金融强一致直接不用缓存。方案叠加有顺序:先 Cache Aside 打底,再延迟双删压缩回填窗口,最后 binlog 订阅兜底失败场景,逐层递进避免过度设计。
🏭 实战场景
详情
踩坑案例:删缓存失败导致的改价资损
- 现象:某电商一次商品改价后 10 分钟内,大量用户以旧价(更低)下单,资损数十万元。监控显示期间商品价格缓存命中率异常保持在 100%。
- 排查:改价走的是“先更新 DB 再删缓存”,代码中删缓存调用因 Redis 从节点短暂不可用而超时失败,业务只打了 warn 日志,无重试无补偿;旧价格缓存一直存活到 TTL(10 分钟)到期。
- 根因:把“删缓存”当成 fire-and-forget 操作,没有任何失败补偿链路;对资损敏感的价格字段也没有下单时的二次校验。
- 修复:删缓存失败 → 重试 3 次 → 失败投 MQ 延迟重试 → binlog 订阅做最终兜底;下单链路对价格做“缓存价 vs DB 价”校验,偏差超阈值拒绝下单并告警;把删缓存成功率纳入核心监控(告警阈值:成功率 < 99.9%)。
场景题:秒杀活动中,商品库存缓存在 Redis,扣减逻辑是“先 DECR 缓存,再异步写回 DB”。活动峰值后发现缓存与 DB 库存出现偏差,超卖了 300 单。请从缓存架构角度复盘并给出改造方案。
分析:
- 应急处理:立即冻结该商品下单,以 DB 实际库存为准盘点;超卖的 300 单按业务规则处理(退款补偿或协商发货);扣减路径临时切回 DB 乐观锁,牺牲性能止血。
- 根因分析:缓存 DECR 与 DB 扣减是两个非原子动作,异步写回存在延迟与失败可能;高并发下缓存侧扣减成功但 DB 侧未同步(消息丢失/消费失败),两侧库存逐渐分叉;更致命的是把“缓存库存”当成了事实来源,却没有对账机制发现偏差。
- 长期方案(二选一,按团队能力选型):
- 方案 A(以 Redis 为准):Lua 脚本原子校验并扣减缓存库存,活动期 Redis 是唯一事实来源;扣减成功后发 MQ 异步落库创建订单,消费保证幂等;活动结束后一次性对账核销。
- 方案 B(以 DB 为准):
UPDATE stock SET count = count - #{n} WHERE sku_id = #{skuId} AND count >= #{n}原子扣减,影响行数为 0 即售罄;缓存只放“是否还有货”的粗粒度标记;配合库存分段(拆 N 个子库存行)打散行锁热点。 - 无论哪种,都加准实时对账(每分钟比对 Redis 与 DB 库存,差值超阈值告警并自动暂停活动)。
- 权衡:方案 A 性能最高,但要解决 Redis 故障时的库存恢复(从 DB + 订单流水重建)与 MQ 幂等;方案 B 绝对可靠、无分叉,但单行锁是瓶颈,必须配合分段库存才能抗高 QPS。核心教训:扣减路径必须原子,异步只能用在扣减成功之后的落库环节。
⚠️ 常见误区
详情
常见误区:
- ❌ "先删缓存再更新 DB 也是可行方案" → 纠正:删缓存到更新 DB 完成之间的窗口内,并发读会把旧值回填缓存,漏洞窗口等于整个 DB 更新耗时,触发概率不低,不作为主方案。
- ❌ "Cache Aside 的不一致主要来自并发时序" → 纠正:极端时序触发概率万分之一以下,生产上不一致的主要来源是删除缓存失败(Redis 抖动、超时),必须靠重试、MQ 补偿与 TTL 兜底。
- ❌ "设了 TTL 就不用管删除失败" → 纠正:TTL 只是安全网,会把脏数据的存活时间拉长到分钟级;资损敏感字段不能只靠 TTL,必须加补偿链路与下单二次校验。
🔀 发散问题
Q:各一致性方案如何横向对比?
A:从一致性强度、性能、复杂度、适用场景四个维度对比。常规业务 Cache Aside 即可,高要求叠加延迟双删或 binlog 订阅。五种模式的读写流程对比见本文扩展知识【L3】。
Q:缓存更新还有哪些策略?
A:Read/Write Through、Write Behind、Refresh Ahead 等,与 Cache Aside 各有适用边界,且都无法保证强一致。见本文档『缓存更新有哪些策略?』。
Q:缓存与数据库双写一致性还有哪些资料?
A:可参考延伸阅读中的《分布式之数据库和缓存双写一致性方案解析》,从方案演进角度理解各策略的取舍。
📚 延伸阅读:分布式之数据库和缓存双写一致性方案解析