当前位置: 首页 > news >正文

架构师成长之路-缓存二

文章目录

  • 前言
  • 一、缓存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. 调用方查缓存,发现数据已过期(空);
  2. 调用方查数据库,拿到新数据(比如用户昵称“李四”);
  3. 把新数据写入缓存,设置1分钟过期;
  4. 返回数据给调用方。

优点:

  • 实现简单:只需要给缓存加个过期时间(比如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%的坑:

  1. Value存储优先序列化:除非极端追求性能,否则用JSON/ProtoBuf存,避免数据污染;
  2. 不滥用缓存:频繁修改、无热点、强一致性的场景,坚决不用缓存;
  3. 更新机制选对方案:简单场景用“被动更新(超时失效)”,复杂场景用“主动更新(先更DB再删缓存)”,必要时补“延时双删”。

下一篇我们就来拆解上一篇预告的“缓存三大致命坑”:穿透、击穿、雪崩——这三种情况会直接导致缓存失效,甚至系统瘫痪,看完你就能知道怎么提前预防。

http://www.dtcms.com/a/390857.html

相关文章:

  • 正点原子小智BOX0/BOX2 产品使用视频表情功能
  • 鸿蒙NEXT分布式文件系统:开启跨设备文件访问新时代
  • 【主机初始化工作】
  • Ubuntu20.04仿真 | iris四旋翼添加livox mid360激光雷达
  • Linux进程终止
  • Go如何重塑现代软件开发的技术基因
  • 设计模式(C++)详解—外观模式(2)
  • 【ubuntu24.04】apt update失败 过期的签名清理
  • Go 语言常用算法库教学与实践指南
  • 基于FPGA的智能垃圾分类装置
  • 168. Excel 表列名称【简单】
  • Ubuntu20.04 6步安装ROS-Noetic
  • 基于 MATLAB 的双边滤波去图像云雾处理
  • 将一台已连接无线网络的 Windows 电脑通过网络线共享网络给另一台电脑
  • 复习1——TCP/IP之常用协议
  • 讲清楚 PagedAttention
  • 多对多依赖;有向无环图l;拓扑排序;DFS回溯输出全路径简述
  • 【序列晋升】37 Spring Data LDAP 跳出传统数据访问框架,掌握目录服务开发新范式
  • Redis三种服务架构
  • GPT-5 高并发文生图视频 API 架构实战指南
  • LLM赋能网络安全:六大应用场景的深度解析与前沿突破
  • 分布式链路追踪-SkyWalking
  • 第五篇:范围-Based for循环:更简洁、更安全地遍历容器
  • 京准科技NTP网络校时服务器实现分布式系统精准协同
  • Node.js 简介与历史演进
  • MMLU:衡量大语言模型多任务理解能力的黄金基准
  • Java NIO/AIO 异步 IO 原理与性能优化实践指南
  • ReactJS + AppSync + DynamoDB 项目结构与组件示例
  • adm显卡下使用gpu尝试
  • dante 安装与使用