架构师成长之路-缓存二
文章目录
- 前言
- 一、缓存Value怎么存?先搞懂两种存储方式的利弊
- 1.1 序列化数据:通用但有额外成本
- 1.2 对象数据:高效但有“污染”风险
- 二、这些场景用缓存,反而会拖垮系统
- 2.1 频繁修改的数据:缓存成了“无用功”
- 2.2 没有“热点”的访问:内存不够用
- 2.3 强一致性要求的数据:用户会骂街
- 三、缓存宕机=系统瘫痪?可用性陷阱要避开
- 3.1 为什么缓存宕机会拖垮数据库?
- 3.2 怎么避免?2个实用方案
- 3.3 哪些数据适合预热?
- 四、缓存和数据库不同步?更新机制是关键
- 4.1 被动更新:最简单但有延迟
- 4.2 主动更新:保证一致性但复杂
- 方案1:先更新缓存,再更新数据库(直接排除)
- 方案2:先更新数据库,再更新缓存(不推荐)
- 方案3:先删除缓存,再更新数据库(慎用,需补方案)
- 方案4:先更新数据库,再删除缓存(推荐,Facebook都在用)
- 五、总结:缓存设计的3个核心原则
前言
上一篇我们聊了缓存的核心逻辑:怎么选缓存数据、怎么设计Key才不重复。但很多开发者会忽略一个问题——就算Key设计对了,Value存错格式、缓存和数据库数据不同步,照样会引发生产事故:比如用户看到的商品价格是旧的,或者缓存一宕机数据库直接被打垮。
今天我们就顺着上一篇的思路,继续拆解缓存的“后半段知识”:从Value的两种存储方式怎么选,到哪些场景绝对不能用缓存,再到最关键的“缓存更新机制”——毕竟,让缓存和数据库保持一致,才是缓存设计的终极考验。
一、缓存Value怎么存?先搞懂两种存储方式的利弊
缓存的核心是Key-Value,但Value的存储不是“随便塞数据”就行。根据业务数据的类型,Value主要分为序列化数据和对象数据两种,选对了能避免很多坑。
1.1 序列化数据:通用但有额外成本
什么是序列化数据?简单说,就是把Java对象、Python字典这些“结构化数据”,转成字符串(比如JSON)或二进制流再存到缓存里。比如你要存一个用户对象,会先把它序列化成{“id”:123,“name”:“张三”},再作为Value存到Redis。
优点:
- 通用性极强:几乎所有缓存(Redis、Memcached、浏览器缓存)都支持字符串/二进制存储,甚至能存图片、音频这些非结构化数据;
- 避免数据污染:每次读取都是从缓存拿到“字符串”,再反序列化成新的对象——就算你在代码里改了这个新对象,也不会影响缓存里的原始数据(因为是新的副本)。
缺点:
- 额外的计算开销:存的时候要序列化(对象→字符串),读的时候要反序列化(字符串→对象),会消耗一点CPU和时间;
- 格式转换麻烦:如果Value是复杂结构(比如嵌套的订单数据),序列化/反序列化时要注意格式兼容(比如JSON不支持Java的Date类型,需要额外处理)。
1.2 对象数据:高效但有“污染”风险
对象数据就是直接把内存中的对象作为Value存起来(比如Java里的User对象引用),不用转格式。这种方式在本地缓存(比如Caffeine)里很常见。
优点:
- 效率高:省去了序列化/反序列化的步骤,读写字段直接操作对象,速度更快。
缺点:
- 数据污染是致命问题:这是最坑的一点!因为你存的是“对象引用”,不是副本——如果A业务拿到这个对象后修改了字段(比如把user.name改成“李四”),B业务再读缓存时,拿到的就是被改后的“脏数据”。
举个例子:
Java里存了一个User(id=123, name=“张三”)到本地缓存,A业务读取后把name改成“李四”(此时操作的是同一个对象引用),B业务再读缓存,看到的就是“李四”,但数据库里还是“张三”——数据一致性直接崩了。
结论:优先选序列化存储
除非是极端追求性能的本地缓存场景(且能保证没人修改对象),否则优先用序列化方式存Value(比如JSON、ProtoBuf),虽然多了一点转换成本,但能避免数据污染这个“定时炸弹”。
二、这些场景用缓存,反而会拖垮系统
上一篇我们说过,缓存的核心是“选对数据”。但有些场景下,用缓存不仅没收益,还会成为系统累赘。这3种场景一定要避开:
2.1 频繁修改的数据:缓存成了“无用功”
如果数据更新频率比读取还高(比如用户的实时转账记录、秒杀活动的库存每秒变10次),缓存就失去了意义——你刚把数据存到缓存,还没等有人读,数据就过期了,只能反复更新缓存,白白浪费CPU和内存。
判断标准:数据的读写比要大于2:1(即更新1次,至少有2次读取),缓存才划算。比如新浪微博的热门微博,更新1次能被读上百万次,这种场景用缓存才值;而实时转账记录,读写比可能只有0.5:1,完全没必要用。
2.2 没有“热点”的访问:内存不够用
缓存本质是“用有限的内存存高频数据”,核心依赖“28定律”——80%的访问集中在20%的热点数据上(比如电商平台的爆款商品)。但如果你的数据没有热点,每个数据的访问次数都差不多(比如每个用户的历史订单,平均每个订单只被查1次),就意味着要把所有数据都放进缓存才能有收益,但内存根本装不下(内存比硬盘贵10倍以上)。
这种场景下,不如直接查数据库(或用数据库索引优化),强行用缓存只会导致“内存满了不停淘汰数据,查询还是走数据库”,反而增加复杂度。
2.3 强一致性要求的数据:用户会骂街
缓存和数据库本质是“两个存储”,数据同步一定会有延迟(哪怕是毫秒级)。如果你的业务不能容忍任何延迟(比如商品价格、用户余额),用缓存就会出大问题:
- 比如商家把商品价格从100元改成50元,但缓存里还是100元,用户按100元付款——这会直接引发客诉;
- 但如果是商品图片、简介这类数据,就算用户晚10分钟看到新图,基本不会有影响。
结论:强一致性业务(钱、价格)优先保证数据库正确,别依赖缓存;弱一致性业务(图片、简介)再用缓存提升性能。
三、缓存宕机=系统瘫痪?可用性陷阱要避开
很多人觉得“缓存只是锦上添花,宕了大不了查数据库”,但实际生产中,缓存一旦宕机,数据库很可能跟着崩——这就是“缓存雪崩”的前兆。
3.1 为什么缓存宕机会拖垮数据库?
大型系统中,缓存会承担80%以上的读请求(比如Redis每秒处理10万次读,数据库只处理2万次)。数据库长期处于“低负载”状态,已经适应了这种节奏。如果缓存突然宕机,所有读请求会瞬间压到数据库上(从2万次/秒涨到10万次/秒),数据库直接扛不住就会宕机,进而导致整个系统瘫痪。
这种情况被称为“缓存雪崩前兆”——不是缓存本身的问题,而是缓存成为“流量挡箭牌”后,数据库失去了抗压能力。
3.2 怎么避免?2个实用方案
-
方案1:缓存集群+数据分片
别把所有缓存数据存在一台服务器上,而是用集群(比如Redis Cluster),把数据分到多台机器:比如1号机存用户数据,2号机存商品数据,3号机存订单数据。就算1号机宕机,也只有用户相关的读请求会压到数据库,其他业务不受影响,数据库压力可控。 -
方案2:缓存预热,避免“冷启动”
新启动的缓存是空的,所有请求都会走数据库(相当于“缓存宕机”的瞬间)。解决办法是“缓存预热”——在缓存启动时,提前把热点数据加载进去(比如电商大促前,把TOP1000的爆款商品数据提前写入Redis),避免缓存启动初期数据库被打垮。
3.3 哪些数据适合预热?
- 静态数据:比如全国城市列表、系统规则配置(几乎不会变);
- 已知热点数据:比如大促前的爆款商品、活动页面数据。
四、缓存和数据库不同步?更新机制是关键
这是缓存设计中最核心的问题:当数据库数据改了,缓存怎么更?比如用户改了昵称,数据库里是“李四”,缓存里还是“张三”,用户看到的就是旧数据。
所有缓存更新机制,本质上就两种:被动更新和主动更新。
4.1 被动更新:最简单但有延迟
被动更新也叫“超时失效”,逻辑很简单:给缓存设置一个过期时间(比如1分钟),在过期前,缓存一直返回旧数据;过期后,缓存自动失效,下一次查询会从数据库加载新数据,再重新写入缓存。
原理时序图(用文字简化):
- 调用方查缓存,发现数据已过期(空);
- 调用方查数据库,拿到新数据(比如用户昵称“李四”);
- 把新数据写入缓存,设置1分钟过期;
- 返回数据给调用方。
优点:
- 实现简单:只需要给缓存加个过期时间(比如Redis的EXPIRE命令),不用额外写代码。
缺点:
- 数据不一致:过期时间内,缓存是旧数据,数据库是新数据(比如用户改了昵称,要等1分钟后才能在缓存看到新的)。
适用场景: - 读多写少、能容忍延迟的业务:比如商品关注人数(多10个人关注,晚12小时更新也没关系)、文章阅读量。
4.2 主动更新:保证一致性但复杂
主动更新就是“数据库数据一改,马上同步更新缓存”,核心是解决被动更新的延迟问题。但主动更新不是“直接改缓存”这么简单,里面藏着很多坑。
根据“更新缓存”和“更新数据库”的顺序、操作方式,主动更新可分为4种方案,我们逐一拆解:
方案1:先更新缓存,再更新数据库(直接排除)
- 逻辑:改缓存→改数据库。
- 问题:如果改完缓存后,数据库更新失败(比如网络波动、SQL错误),缓存里是新数据,数据库是旧数据——数据永久不一致,而且很难恢复(缓存没过期的话,一直返回错数据)。
结论:生产环境绝对不能用。
方案2:先更新数据库,再更新缓存(不推荐)
- 逻辑:改数据库→改缓存。
- 问题1:线程安全隐患
比如两个线程同时更新:
线程A:把数据库从0改成1,改完后被阻塞;
线程B:把数据库从0改成2,改完后更新缓存为2;
线程A恢复,把缓存更新为1——最终缓存是1,数据库是2,不一致。 - 问题2:业务计算浪费
如果更新缓存需要复杂计算(比如统计用户的订单总数,要查3张表),每次改数据库都要重新计算一次,万一计算完还没人读缓存,就白浪费CPU了。
结论:除非是单线程、无复杂计算的简单场景,否则不推荐。
方案3:先删除缓存,再更新数据库(慎用,需补方案)
-
逻辑:删缓存→改数据库。
这种方案比前两种好,但有个致命漏洞:读操作比写操作快。 -
漏洞演示:
线程A:删除缓存(用户昵称缓存被清);
线程A:开始更新数据库(改昵称“张三”→“李四”),但写操作慢,被卡住;
线程B:查用户昵称,发现缓存空,去数据库查(此时数据库还是“张三”);
线程B:把“张三”写入缓存;
线程A:终于更新完数据库(变成“李四”);
最终结果:缓存是“张三”,数据库是“李四”,不一致。 -
解决方案:延时双删
在“改完数据库”后,等一会儿再删一次缓存,把线程B写入的旧数据清掉:
删缓存→改数据库;
休眠一段时间(比如500毫秒,比读操作耗时久);
再次删缓存。
这样一来,就算线程B写入了旧数据,第二次删除也会把它清掉,下一次查询就会加载数据库的新数据。
注意:
- 休眠时间要大于“读操作从数据库查数据+写入缓存”的总耗时(比如读耗时100ms,休眠500ms足够);
- 第二次删除可以用异步线程(比如扔到消息队列),避免阻塞写请求,提升吞吐量。
方案4:先更新数据库,再删除缓存(推荐,Facebook都在用)
-
逻辑:改数据库→删缓存(也叫“Cache-Aside策略”),这是目前生产环境最常用的方案。
-
为什么推荐?
它把“更新缓存”变成了“删除缓存”——下次查询时发现缓存空,自然会去数据库加载新数据,避免了“更新缓存”的各种问题。 -
有没有漏洞?
有,但概率极低。需要同时满足4个条件才会出问题:
读操作发现缓存空,去查数据库;
此时刚好有个写操作在执行;
读操作查数据库的耗时,比写操作“改数据库+删缓存”的总耗时还久;
读操作查到旧数据后,写入缓存,而写操作已经删过缓存了。
举个例子:
线程A:查缓存空,去数据库查旧数据(耗时1秒);
线程B:改数据库为新数据→删缓存(耗时200ms);
线程A:1秒后查到旧数据,写入缓存;
最终:缓存是旧数据,数据库是新数据。
但这种情况的概率有多低?写操作通常比读操作慢(改数据库要写磁盘,读数据库可能走索引),所以“读耗时>写耗时”的情况极少,再加上其他3个条件,实际生产中几乎遇不到。
如何彻底规避?
- 给缓存加个短期过期时间(比如10秒):就算出现不一致,10秒后缓存自动失效,数据会恢复一致;
- 配合“延时双删”:写操作删完缓存后,过一会儿再删一次,把可能的旧数据清掉。
- 结论:优先用“先更数据库再删缓存”,简单、可靠,Facebook在论文中也推荐这种方案。
五、总结:缓存设计的3个核心原则
看到这里,你应该能明白:缓存不是“存数据”这么简单,而是一套“选数据、存数据、更数据”的完整逻辑。最后总结3个原则,帮你避开90%的坑:
- Value存储优先序列化:除非极端追求性能,否则用JSON/ProtoBuf存,避免数据污染;
- 不滥用缓存:频繁修改、无热点、强一致性的场景,坚决不用缓存;
- 更新机制选对方案:简单场景用“被动更新(超时失效)”,复杂场景用“主动更新(先更DB再删缓存)”,必要时补“延时双删”。
下一篇我们就来拆解上一篇预告的“缓存三大致命坑”:穿透、击穿、雪崩——这三种情况会直接导致缓存失效,甚至系统瘫痪,看完你就能知道怎么提前预防。