
1. 缓存为什么无处不在先从原理和层次说起缓存这个词几乎每个后端项目都会碰到。Redis、本地缓存、浏览器缓存、甚至显卡驱动里的着色器缓存归根到底都在解决同一个问题把数据放到离使用者更近的地方让高频访问不必每次都穿透到最慢的那一层。但缓存也是最容易埋雷的一层——缓存穿透、缓存击穿、缓存雪崩、缓存一致性这些词在面试里被问烂了在线上也照样踩坑。这篇文章我把这些年做缓存的经验完整梳理一遍从底层原理、经典故障到框架源码、客户端缓存再到线上治理尽量做到既有深度又能直接照着落地。1.1 缓存的本质空间换时间数据往“近处”放计算机系统里存在一条明显的存储“价格阶梯”CPU寄存器极快但极小内存快但有限SSD容量大但慢一个数量级网络存储再慢一些。数据从最底层取一次可能要几十毫秒但从内存取只要几十纳秒这里差着几个数量级。缓存做的事情很朴素把热数据复制到更接近使用者的高速存储里用额外的空间成本换取访问延迟的大幅下降这就是“空间换时间”。生活里最典型的例子是做饭。调料不是每次做菜都去楼下超市买而是提前买好放灶台边常用菜谱贴在冰箱门上而不是每次翻书。缓存就是这套逻辑基于局部性原理——一段数据被访问过短时间内大概率还会被访问时间局部性相邻的数据也可能被连续访问空间局部性。理解这个本质有个好处做技术选型时你不会再纠结“要不要加缓存”而是会先想清楚数据的热度分布是什么样访问延迟的瓶颈到底在哪。我在项目里见过不少团队接口慢就盲目套Redis结果缓存命中率不到50%多了一层网络开销反而更慢。缓存加错地方比不加还糟。1.2 一套完整系统里的缓存层次从CPU到浏览器真实的业务系统里缓存从来不是只有一层。我习惯把一个请求从发出到返回数据沿途会经过的缓存位置列一遍CPU缓存L1/L2/L3操作系统和硬件替你处理的写代码时几乎感知不到但循环遍历的顺序会影响命中率这就是为什么遍历二维数组时“按行访问”比“按列访问”快。进程内本地缓存比如Caffeine、Guava Cache、Go的bigcache。速度最快零网络开销但只能单机使用多实例部署时数据不共享存在一致性问题。分布式缓存以Redis为代表多实例共享容量可扩展但每次读写都有一次网络RTT约0.1到1毫秒。操作系统页缓存Page Cache你读文件时Linux内核会自动把磁盘内容缓存到内存里很多数据库“读磁盘很快”的假象其实是页缓存在起作用。客户端缓存浏览器HTTP缓存、安卓App里的本地缓存离用户最近能直接省掉一次网络请求。CDN缓存把静态资源推送到离用户最近的边缘节点本质也是缓存。这层结构给了我一个很重要的选型意识缓存要逐级往上放。请求先打本地缓存本地没有再看RedisRedis没有才查数据库最后回填各层。很多高并发项目只盯着Redis忽略了本地缓存相当于每次请求都要跨网络明明是缓存架构却跑出了数据库直连的延迟。2. 高并发缓存三大故障穿透、击穿、雪崩的治理实操这三兄弟是整个缓存领域最经典的“坑”名字长得很像但成因和解决方案完全不同。我分别拆开讲每个都附上我在线上真实用过、验证过有效的处理方式。2.1 缓存穿透总是在打数据库里不存在的数据所谓穿透就是请求的key在缓存里和数据库里都不存在。比如恶意攻击者遍历一些不存在的用户ID每次请求都绕过缓存直达数据库DB被拖垮。最典型的现象是Redis里的key一查一个空数据库的QPS却高得离谱。我常用的防御手段有三层按顺序做第一层是接口参数校验。对入参做基本合法性检查比如ID格式、范围、枚举值无效请求直接返回不进入缓存查询。这能过滤掉大量无脑扫描和数据异常问题。第二层是空值缓存。当数据库查询结果为空时也把这个空结果缓存起来设置一个较短的过期时间比如30到60秒这样同样的非法请求短期内不会再打到数据库。不过空值缓存要特别注意一是TTL不能太长否则用户新增数据后读不到二是空值本身也要key唯一防止被恶意构造大量不同的空key打爆内存所以通常还要配合第一层校验。第三层是布隆过滤器。在缓存前面加一个Bloom Filter把所有存在的ID预加载到布隆过滤器里。请求来时先判断ID是否存在不存在就直接拦截。布隆过滤器的特点是判断“存在”可能有误判但判断“不存在”是100%准确的恰好适合拦截穿透。注意布隆过滤器有误判率bit数组长度和哈希函数次数要按数据量算好否则误判率会高到影响正常请求。我习惯用guava的BloomFilter或者Redis的BF模块初始化时按预估数据量的10倍容量设置。实际项目中我通常会三层一起上参数校验挡掉低级攻击布隆过滤器拦截大部分不存在的key剩下的漏网之鱼由空值缓存兜底。2.2 缓存击穿热点key过期瞬间的并发高峰击穿和穿透只差一个字场景完全不同穿透打的是“不存在的key”击穿打的是“一个非常热门的key”比如爆款商品、热搜话题。当这个热点key恰好过期的那一瞬间大量并发请求同时发现缓存没数据于是一窝蜂砸向数据库DB瞬间被打穿。解决击穿的核心思路是只让一个请求真正去重建缓存其他请求等着复用结果。我常用两种方案各有取舍一种是互斥锁。用Redis的SETNX命令抢锁抢到锁的请求才允许查数据库、重建缓存、写入Redis并释放锁没抢到锁的请求先sleep几十毫秒再重试从缓存里读。实现简单效果直接但有个问题如果重建缓存时间较长请求会一直阻塞等待体验略差。这里要注意锁必须设置过期时间防止拿锁的线程崩了导致死锁锁过期时间要大于缓存重建预估时间否则会出现多个请求同时重建。我在生产环境还会给锁的value存一个唯一标识释放时校验是不是自己持有的锁避免误删别人的锁。另一种是逻辑过期。缓存value里除了真实数据再存一个逻辑过期时间。读取时判断逻辑时间是否已过期如果未过期就直接返回旧数据如果已过期则同样抢锁重建但没抢到锁的请求不等待直接返回旧数据。这个方案的出发点就是“牺牲一点点一致性换更平滑的响应”适合能容忍短暂旧数据的场景比如文章详情、促销活动页。我个人比较推荐高并发读多写少的场景用逻辑过期实测下来对用户体验更友好。方案一致性额外复杂度适用场景互斥锁强低对数据时效要求高的场景逻辑过期弱短时间内旧数据中高并发、读多写少、能容忍短暂延迟2.3 缓存雪崩一次过期的连锁反应雪崩是大面积key同时失效或者Redis本身挂了导致大量请求瞬间落到数据库。它与击穿的区别在于范围击穿是“一个key”雪崩是“一批key”。最常见的原因是大量key设置了相同的过期时间。比如某功能上线时批量把商品信息都设成24小时后过期结果第二天同一秒内几万个key一起失效数据库直接被一波流量拍死。治这个病很简单过期时间加随机值。我在项目里通常这么写固定TTL加随机0到300秒的偏移把同时过期的可能性降到最低。另一个原因是Redis整体不可用。这时所有请求都会穿透到数据库解决办法是在架构上做高可用和兜底。一是Redis主从加哨兵或者Cluster集群保证单点故障自动切换二是在应用层加本地缓存兜底即便Redis挂了本地缓存还能扛住一部分流量三是通过Sentinel或Hystrix做限流降级在极端情况下直接返回降级数据保护数据库不被打挂。多说一句很多人以为雪崩只有Redis挂了才叫雪崩其实我线上遇到最多的还是“过期时间设计不合理”。排查时先看Redis监控有没有断点再看key的TTL分布如果是后者修复成本很低但影响面可能很广。3. 缓存一致性双写乱序下还能拿到正确数据吗缓存用得越久越会明白一个道理缓存本身不难难的是让缓存里的数据和数据库里的数据保持一致。很多团队上线了缓存也设置了过期时间但总会出现“用户改了资料App里却还是旧头像”之类的投诉这就是缓存一致性问题。3.1 不一致是怎么产生的先看最常见的读写流程用户读取时先查缓存未命中再查数据库然后回填缓存用户更新时先写数据库再更新缓存或者删除缓存。问题就出在“并发”上。举个很典型的场景。请求A读到数据库里的旧值准备写回缓存与此同时请求B修改了数据库为新值并把缓存删掉了。但A这时候已经拿着旧值随手覆盖了缓存。结果缓存里躺着旧数据数据库里是新的用户下一次读到的就是旧数据。每次我看到有人质疑“为什么非要删缓存而不更新缓存”都要重新解释一遍更新缓存的问题在于你怎么知道这次写入的数据一定比正在回填的数据新在并发环境下两个线程回填的顺序可能和数据库写入顺序不一致更新缓存等于把不确定的结果覆盖到了缓存里。所以业界更倾向于删除缓存让下一次读取时用最新数据重新回填这是“被动重构”的思路——宁可多查一次库也不让脏数据长期存在。3.2 主流缓存更新策略对比业界常说的几种缓存策略我按实际使用频率排个序Cache Aside旁路缓存是最常用的一种。读的时候先查缓存没命中就查库再回填写的时候先更新数据库再删除缓存。逻辑简单适合大多数业务。但它有两个隐患一是“更新DB后删除缓存”这个操作可能失败比如删除时Redis超时导致缓存长期是旧的需要靠TTL兜底二是先删缓存再更新DB的顺序如果反了会引发读写竞争。这两个坑下面第三小节细说。Read/Write Through是把缓存当作主存储的代理应用读写都走缓存组件由缓存组件负责同步数据库。优点是应用侧代码简单缺点是需要引入一个承担代理职责的中间层自研成本高在轻量项目里有点重。Write Behind / Write Back是写操作先落缓存异步批量刷到数据库。性能最高但存在数据丢失风险——如果缓存还没刷盘就坏了数据就没了。一般用在写多读少的场景比如点赞数、浏览量的异步落库。拿这三个对比我的选择倾向很简单大多数后端业务用Cache Aside就够不要为了炫技引入复杂的代理层如果遇到写并发极高的场景再用Write Behind但一定要接受“可能丢最近一小段时间数据”的代价。3.3 我常用的组合拳延迟双删加binlog订阅在我自己负责的项目里缓存一致性做过好几轮改造最终沉淀下来的组合方案是延迟双删 异步删除兜底。先说延迟双删。更新数据库后先删一次缓存等几百毫秒我一般取200到500ms具体取决于DB主从延迟和回填耗时再删一次缓存。第二次删除的目的是清掉“第一次删除后到更新完成前”这段窗口期内可能被读请求回填的旧数据。有人觉得多删一次很浪费但实际这个操作的成本极低换来的是并发安全很划算。但延迟双删不能解决“删除动作本身失败”的问题所以我在生产环境会再加一道保险通过监听数据库binlog用Canal之类的组件拿到变更事件后异步删除对应的缓存key。这个方案的好处是业务代码里不用再手动调删除缓存的方法更新DB后缓存删除由binlog消费者负责即使第一次删缓存失败binlog这条链路也能最终补救。这套组合拳虽然不能做到线性一致性但能把脏数据的窗口压缩到很窄配合TTL过期兜底基本能满足绝大多数业务。如果业务真要求强一致比如余额这类敏感数据就别用缓存了直接走数据库主库查询或者引入分布式事务框架那已经不是“删缓存”能解决的问题。4. 框架自带的缓存Spring三级缓存与MyBatis缓存源码拆解初学Spring时很多人听到“三级缓存”会误以为是Redis那套缓存体系其实它解决的是Spring容器内部Bean的循环依赖问题完全不在一个层面。但既然做后端这个机制值得搞透面试常考排起难查的依赖问题也有大用。4.1 Spring三级缓存为什么需要三级而不是二级所谓三级缓存是指Spring单例Bean容器里的三个Map一级缓存singletonObjects存放已经完成初始化可以正式对外使用的单例Bean。二级缓存earlySingletonObjects存放已经实例化但还没完成属性填充和初始化的“早期Bean”。三级缓存singletonFactories存放Bean对象的工厂方法拿到这个工厂可以生成Bean的早期引用。为什么需要三级得从循环依赖场景说起。假设A依赖BB依赖A两个类通过setter注入互相引用。Spring实例化A后发现需要注入B于是去创建BB创建过程中又发现需要A但A还没创建完。如果没有缓存机制这里就死循环了。Spring的做法是A实例化后即使还没初始化完也先把一个能生成A早期引用的工厂放到三级缓存里。B创建时从这个工厂拿到A的早期引用此时A可能还没完全初始化但对象内存已经分配出来了于是B可以顺利完成依赖注入和初始化。B完成后Spring再回过头来继续初始化A最后把完整的A放进一级缓存。有人会问既然二级缓存也能存早期引用为什么还非要三级这就涉及到AOP代理了。如果A需要被代理比如加了Transactional那么最终放入一级缓存的应该是代理对象而不是原始对象。三级缓存里放的是ObjectFactory可以在需要早期引用时动态决定返回原始对象还是代理对象如果只有二级缓存提前生成的早期Bean就无法在后期替换成代理对象AOP和循环依赖就冲突了。所以三级缓存不是设计上的堆叠而是为了兼顾“提前暴露引用”和“延迟生成代理”这两个需求。我踩过的一个坑是如果循环依赖涉及构造器注入三级缓存也解决不了因为构造器注入要求对象实例化时必须传入依赖此时早期引用还没准备好。所以设计Bean时尽量用setter注入或者干脆别设计循环依赖。4.2 MyBatis缓存两兄弟一级缓存与二级缓存的坑MyBatis内部也有缓存但这两个缓存的坑比它们带来的收益更值得关注。一级缓存是SqlSession级别的默认开启。同一个SqlSession里执行相同的SQL第二次会直接命中缓存不再查库。乍看很美好但实际使用中有一个隐形坑在Spring管理下每个Mapper方法通常都会新开或复用一个SqlSession如果事务边界没控制好一级缓存根本帮不上忙更麻烦的是同一个SqlSession里先查后改MyBatis会自动清掉查询缓存不会脏但有时候你会以为缓存生效了实际没生效导致性能误判。二级缓存是namespace级别的默认关闭。开启后跨SqlSession的相同查询会走缓存但会在多表关联场景下产生脏数据。举个例子A表的数据被缓存了另一个namespace里更新了B表但A表的查询结果和B表有关联MyBatis不会感知到这种关联于是你查到的可能是更新前的旧数据。这个坑非常隐蔽线上出现过好几次“数据怎么不变了”的排查最后都定位到二级缓存上。我的建议简单粗暴分布式环境或者多表复杂查询的场景直接关闭MyBatis二级缓存统一由Redis做缓存层。本地一级缓存留着也无妨但不要指望它是可靠的性能优化手段顶多算个意外惊喜。5. 客户端与本地缓存的实操场景浏览器、安卓、GPU、应用目录后端之外缓存的应用其实遍布日常开发。这些场景本身不复杂但处理不好很容易让用户感知到卡顿这里挑几个热搜里高频的场景整理一下我的实操经验。5.1 PythonSelenium怎么清浏览器缓存最彻底自动化测试里最烦的问题之一脚本跑出来的页面效果和手动打开的不一致十有八九是浏览器缓存和Cookie在作怪。用Python和Selenium清理缓存我试过三种方式按彻底程度排序第一种最省事的方式是开启无痕模式。启动Chrome时加一个参数from selenium import webdriver from selenium.webdriver.chrome.options import Options options Options() options.add_argument(--incognito) driver webdriver.Chrome(optionsoptions)这种方式每个测试会话都是全新环境最适合跑测试但缺点是每次启动都要重新加载稍微慢一点。第二种只清除Cookie。用driver.delete_all_cookies()。注意Cookie只影响登录态图片、CSS、JS这些静态资源的缓存它管不了所以如果你改了前端代码却看不见变化删Cookie没用。第三种真正连资源缓存一起清掉要通过Chrome DevTools ProtocolCDP实现driver.execute_cdp_cmd(Network.enable, {}) driver.execute_cdp_cmd(Network.clearBrowserCache, {}) driver.execute_cdp_cmd(Network.clearBrowserCookies, {})先启用Network域再清缓存和Cookie实测最干净。我日常写爬虫和自动化回归脚本都会在用例开始前执行这一段确保每个用例的起点一致。还有个细节execute_cdp_cmd在某些版本的Chrome驱动上需要先等待页面加载完再执行否则会报“Session not created”之类的错踩过一次建议在driver.get()之后统一清理。5.2 安卓RTSP流缓存和WorkBuddy缓存目录修改安卓端处理RTSP流和普通的网络视频缓存逻辑不一样。RTSP实时流的本质是直播或者监控画面讲究低延迟通常不应该把整段视频缓存到本地文件而是在播放器里用一块环形缓冲区临时存最近几秒的数据应对网络抖动。这块缓冲区是内存里的循环队列写满就覆盖最旧的数据保证播放器随时能取到数据。如果本地写文件缓存一方面延迟会增加另一方面无限增长的文件很快占满存储。如果做的是回放流RTSP服务器存了录像则可以按时间段切片缓存到本地比如只缓存最近10分钟的录像文件再配合播放器的seek逻辑。另外热搜里提到的WorkBuddy缓存目录这类知识管理/协作工具一般会把附件、图片、语音转写等资源缓存到系统用户目录下比如Windows上常见的是%LOCALAPPDATA%\WorkBuddy\Cache。如果系统盘空间吃紧可以把整个缓存目录迁移到其他盘。我先说一个前提先在应用设置里找有没有存储位置/缓存目录的选项很多新版本都自带迁移功能比手动操作安全得多。如果应用没有提供再手动处理步骤是关闭WorkBuddy应用确保没有进程占用缓存目录。把原缓存目录整个移动到目标位置比如D:\WorkBuddyCache。在管理员命令行里建立目录联接mklink /J C:\Users\你的用户名\AppData\Local\WorkBuddy\Cache D:\WorkBuddyCache这个命令会创建一个指向新目录的联接应用访问旧路径时实际读写的是新路径整个迁移对应用是透明的。注意一定要在应用关闭状态下操作否则文件占用会导致移动失败而且不要用普通复制后删除的方式没有目录联接的话应用重新写入的大量文件依然会落在C盘。5.3 着色器缓存与SSBO写缓存GPU侧也有缓存学问NVIDIA控制面板里的“着色器缓存大小”是个容易被忽视的选项。它的原理是把显卡编译好的着色器程序缓存到本地磁盘这样下次启动游戏时不必重新编译。着色器编译是很费时的如果这个缓存被禁用或者设得太小你会发现同一款游戏第一次启动特别卡第二次就流畅很多但缓存如果设置得过大会占用大量系统盘空间。NVIDIA控制面板通常在“管理3D设置”的全局设置里有“关闭、驱动器默认、16GB、32GB、64GB”几个选项。我的经验是SSD空间充裕的机器给到16GB就够了再大收益不明显还会让缓存目录膨胀。WebGL/OpenGL开发里还有个SSBOShader Storage Buffer Object它也是缓存思想的一种——显式地在GPU显存里开辟一块可读写的缓冲区CPU可以把一批数据一次性写入GPU着色器再从缓冲区里读取。相比每次绘制都单独传uniform变量SSBO更适合批量、动态的数据比如粒子系统的位置和速度、骨骼动画矩阵。我去年做WebGL粒子效果时大量粒子的参数如果每帧一个个传CPU和GPU之间的数据传输会成为瓶颈后来改成SSBO配合glMapBufferRange一次性映射写入const buffer gl.createBuffer(); gl.bindBuffer(gl.SHADER_STORAGE_BUFFER, buffer); gl.bufferData(gl.SHADER_STORAGE_BUFFER, dataBytes, gl.DYNAMIC_DRAW); gl.bindBufferBase(gl.SHADER_STORAGE_BUFFER, 0, buffer); // 更新数据时映射写入 const mapped gl.mapBufferRange(gl.SHADER_STORAGE_BUFFER, 0, dataBytes.length, gl.MAP_WRITE_BIT | gl.MAP_INVALIDATE_BUFFER_BIT);注意这里有两个关键细节一是要用MAP_WRITE_BIT声明写入权限配合MAP_INVALIDATE_BUFFER_BIT告诉驱动旧内容可以丢弃二是不要在一帧渲染正在读取缓冲区时去映射写入否则会造成同步等待性能反而下降我一般用双缓冲两个SSBO交替使用规避。6. 线上缓存治理与排查常用命令、工具和速查表光会写缓存还不够线上缓存系统出问题时的排查手段才是最考验功力的地方。我参与过好几次Redis缓存故障的应急响应这里把最常用的排查工具和流程记录下来。6.1 缓存治理三板斧命中率、大key、热key先说命中率。连上Redis后执行info stats命令会看到keyspace_hits和keyspace_misses两个计数器命中率就是hits / (hits misses)。如果命中率长期低于80%说明缓存设计可能有问题——要么key过期太快要么数据访问不是热点模式很多请求在查根本没人用的数据。这套数值还能通过监控工具Prometheus redis_exporter做成曲线方便看趋势。再说大key。Redis的redis-cli --bigkeys能扫描出占用空间最大的key但注意这个命令在线上执行时会有性能影响建议加上-i 0.1参数让每扫描100个key停顿0.1秒降低阻塞风险。大key的危害我在生产环境见识过一个几MB的字符串key每次读取都会造成Redis阻塞客户端超时连片。遇到大key处理方式是拆分成多个小key或者换用hash结构而不是简单加大内存。最后是热key。定位热key在早期只能靠业务日志猜测现在可以在redis.conf里设置maxmemory-policy allkeys-lfu开启LFU淘汰策略然后用redis-cli --hotkeys命令扫描访问频率最高的key。定位到热key后应对手段一般是加本地缓存扛一部分流量或者把key做多副本分片减轻单点压力。6.2 缓存问题排查速查表下面的表格是我整理的一份排查速查表基本覆盖了线上高频问题现象可能原因排查方法解决方案缓存命中率低key过期太快、内存淘汰频繁、访问分散看info stats和evicted_keys调整TTL、换LRU/LFU策略、引入本地缓存数据不一致更新策略不对、删除缓存失败对比DB和缓存、看binlog消费日志延迟双删、binlog订阅、缩短TTL兜底Redis内存暴涨大key、value过大、碎片率高执行--bigkeys、memory doctor拆分大key、升级数据结构、开启碎片整理请求变慢热key阻塞、慢查询、网络抖动SLOWLOG GET、客户端超时监控热key分片、本地缓存、检查slowlog缓存穿透非法请求大量查不存在的key看misses曲线和DB慢查询参数校验、空值缓存、布隆过滤器缓存雪崩key过期集中、Redis宕机看TTL分布、Redis状态TTL加随机值、集群高可用、多级缓存这张表不用背重点是建立排查思路先看缓存层本身命中率、慢日志、内存再看数据层数据库QPS、慢查询最后再回到业务层热点、key分布层层收窄范围。6.3 一套可落地的治理流程事前、事中、事后踩过多次缓存故障后我总结了一套适合自己的治理流程现在基本上每个项目上线缓存前都会走一遍事前是把设计做扎实。缓存key要统一命名规范比如业务:场景:主ID:维度方便排查和切流过期时间一律加随机偏移上线前预估容量算出需要多少内存、命中率预期是多少同时把监控指标先埋好命中率、大key、慢查询、Redis内存一样不能少。事中是故障发生时的控制手段。限流和降级开关必须提前准备好比如通过Sentinel对热点接口做并发限流超阈值直接返回兜底数据本地缓存作为Redis故障时的二道保险能保住核心读接口不挂。事后是对故障的复盘沉淀。每次故障的日志、监控截图、处理过程都要整理成文档把新问题补充进速查表同时做一次压测验证修复效果。如果连事后复盘都没有同样的故障大概率会换个姿势再来一遍。这套流程看起来繁琐但做过一次完整闭环之后后续新业务上线会顺畅很多因为很多坑在事前设计阶段就避开了。最后分享一个日常小技巧排查缓存问题时别只盯着Redis指标还要看数据库的QPS曲线和慢查询日志很多时候缓存故障的根源在数据层——比如SQL本身太慢导致缓存重建耗时过长间接拖垮了整个链路。把“缓存层、数据层、业务层”三层指标放在同一张监控图上对比定位问题的速度会快得多。