这个系列写到第八篇,今天想认真聊一个后端开发绕不开、但又特别容易在流量面前翻车的知识点——缓存。做全栈偏后端必备知识点扫盲这几年,我发现一个现象:很多同学对缓存的认知停留在“Redis存一下数据,查询快一点”的层面,可真到线上数据不一致、缓存穿透把数据库打穿、大促前压测发现一清缓存服务就告警的时候,才发现这里的细节远比自己想象的多。这篇文章打算按照我个人的项目经验,把缓存从收益边界、选型、异常场景到一致性方案完整捋一遍。适合工作过一段时间、用过Redis但还没系统梳理过缓存体系的后端同学参考,也适合准备系统设计面试的人把它当一份复习提纲来读。
1. 先从“缓存到底解决什么问题”说起:延迟与吞吐的权衡
1.1 为什么你的服务慢,往往不是代码慢,而是IO慢
一个后端请求从进来到最后返回,真正花在计算上的时间通常很少,大头都在等IO。数据库查询、外部接口调用、文件读写,每一个环节都是毫秒甚至几十毫秒级别的延迟。数据库自己虽然也有缓冲池,但在高并发场景下,大量重复查询依然会形成巨大的压力。我压测过一个模拟项目:单条用户信息查询在数据库里的耗时大概是5毫秒,每秒几百次请求时数据库就开始出现慢查询。加了一层缓存之后,热点查询的耗时就降到了0.5毫秒以内,数据库的压力瞬间小了一个量级。
这个对比很直观:缓存的本质就是“用昂贵的内存换取廉价的IO”,把重复计算和重复查询的结果临时保存下来,让下一次请求直接拿答案。理解了这一点,你就能明白为什么缓存不是万能的,它只对特定模式的数据有效。
1.2 缓存的收益有边界:读多写少、热点集中、时效可控才值得上
不是所有数据都适合丢进缓存,这点很多人容易忽略。适合缓存的数据有三个特征:第一,读多写少,比如用户资料、商品详情、系统配置,读的频率远高于写;第二,热点集中,也就是大部分请求都落在少量数据上,二八法则非常明显;第三,时效性要求可控,短暂的不一致不会造成严重后果。
反过来,库存、余额这类强一致数据就不适合简单粗暴地加缓存。我自己的习惯是在设计缓存之前先做一个简单的“缓存收益率”判断:预估一下查询QPS、单次查询耗时、重复查询比例。如果重复查询比例超过30%,这个数据就值得缓存。这个方法不严谨,但非常好用,能帮你快速过滤掉一批不值得加缓存的场景。
1.3 缓存失效瞬间的“体验崩盘”,才是设计缓存真正要面对的
缓存能扛住正常流量,但真正考验设计的是失效瞬间。如果没有任何保护,缓存miss之后所有请求会同时打到数据库,短时间内连接池被打满、慢查询堆积,服务很容易整体雪崩。我参与过好几次压测,预热状态下命中率99%,压测数据非常漂亮;一旦清空缓存做冷启动测试,数据库马上告警。所以请记住:缓存设计的第一原则不是“命中后有多快”,而是“失效瞬间数据库能不能扛得住”。后面要讲的穿透、击穿、雪崩,本质上都是围绕这个失效瞬间展开的。
2. 本地缓存还是Redis:各有各的坑,选型不能凭感觉
2.1 本地缓存:快是真的快,但“各自为政”也是真的麻烦
本地缓存指的是放在进程内的缓存,Java生态里常用的有Caffeine、Guava Cache,Python里也有functools.lru_cache这类内置工具。它的最大优势是极快,零网络开销,查询就是一次内存访问。但多实例部署时问题就来了:每个实例各有一份数据,数据更新的通知无法跨实例传递,很容易出现各实例状态不一致。
更危险的是热点场景:如果某个key在某一个实例上失效,这个实例的请求会直接回源数据库,而其他实例不受影响,但数据库可能被这一个实例的回源流量打挂。所以我的使用原则很明确:本地缓存只放两类数据。一类是几乎不变、各实例短暂不一致也完全能接受的配置类数据;另一类是访问频率极高、允许短暂过期的临时结果。同时容量要严格控制,启动时就要设置好最大条数和淘汰策略,不然JVM堆内存分分钟被吃掉。
2.2 Redis:分布式缓存的绝对主角,但开销比你想的大
Redis能成为分布式缓存的主角,靠的是共享存储、丰富的数据结构、成熟的过期淘汰策略。但你也要清楚它的成本:每一次查询都有网络RTT,一个get可能就消耗0.1到0.5毫秒,批量操作必须用pipeline或者mget合并,而不是在循环里一个个get。
还有一个非常隐蔽的容量问题:Redis内存比磁盘贵得多,key和value的编码方式直接决定成本。一个Java对象转成JSON后动辄几百字节,如果里面有几十个字段,缓存几百万元素就吃掉几十GB内存。同样的数据用hash结构存,或者把字段压缩成短键名,体积能缩小一半以上。我估算容量的公式是:单条数据实际大小乘期望条目数,再乘一个1.2到1.3的冗余系数,留给过期策略和临时数据的波动。这个公式很粗糙,但能让你在申请资源的时候心里有数。
2.3 两级缓存架构怎么搭才合理:本地挡热点,Redis挡回源
高并发项目里我经常用“本地缓存+Redis”的两级架构,逻辑是:请求先查本地缓存,miss了再查Redis,Redis也miss才回源数据库。回源成功后,先写Redis,再写本地缓存。
这里有一个关键细节:两级缓存的过期时间要拉开差距。本地缓存时间必须明显短于Redis,比如本地5秒、Redis 10分钟。这样做的原因是本地缓存无法收到其他节点的更新通知,时间长了数据新鲜度会变差。另一个细节是本地缓存不能对所有key一视同仁地缓存,成本太高,应该用热点统计或者白名单机制,只让大热点key进入本地缓存。我在代码里通常会做一个简单工具类,内部维护一个访问计数,超过阈值才把key升级到本地缓存。
// 两级缓存读取示例 public Object get(String key) { Object value = localCache.getIfPresent(key); if (value != null) { return value; } value = redis.get(key); if (value != null) { localCache.put(key, value, LOCAL_TTL); return value; } value = loadFromDb(key); redis.set(key, value, REDIS_TTL); localCache.put(key, value, LOCAL_TTL); return value; }3. 缓存穿透、击穿、雪崩:背下名词没用,要能对症处理
3.1 缓存穿透:查一个根本不存在的数据,缓存永远帮不了你
缓存穿透最常见的场景是请求一条数据库中不存在的记录。因为缓存里没有,所以每次请求都会穿过缓存直达数据库,哪怕是完全无效的ID,也会消耗数据库连接。攻击者可以轻易用大量随机ID绕过缓存,把数据库打满。
解决穿透有两个常用方案。第一是缓存空值:查询结果为null时也写缓存,设置一个短过期时间,比如60秒。这个方案实现简单,可以解决绝大多数穿透问题,但要注意空值膨胀,如果非法ID非常多,缓存里会堆满无意义的空key,所以空值过期时间要短,同时要做总量上限控制。第二是布隆过滤器:在缓存前加一道过滤器,判断ID是否存在,不存在直接拒绝。这个方案要求ID集合相对稳定,如果ID是随机生成的分布式ID,布隆过滤器的误判率和内存消耗也值得权衡。我的习惯是两者结合:普通业务用空值缓存,核心入口加布隆过滤器做前置拦截。
3.2 缓存击穿:单个热点key失效瞬间,流量全部压在数据库上
击穿和穿透的区别在于“点”:穿透说的是数据本身不存在,击穿说的是一个访问量极高的热点key突然过期,缓存失效的瞬间,所有请求同时回源数据库,数据库连接池在被击穿的几十毫秒内被打满。
解决击穿有两个主流思路。第一个是互斥锁重建:发现缓存miss后,不是直接查数据库,而是先获取一个分布式锁,拿到锁的线程才去查数据库并回填缓存,其他线程等待锁释放后直接读缓存。这个方案代码清晰,但会增加一次锁的等待时间。第二个是逻辑过期:缓存不设置物理过期时间,而是把过期时间作为字段存在value里,读请求永远能读到缓存,发现逻辑过期后由异步线程去重建缓存。这个方案读请求体验最好,但实现复杂度明显更高。
我的经验是:对访问量非常集中的大热点key,比如大促商品详情,用逻辑过期更稳;对普通热点key,互斥锁已经完全够用。不要一上来就上最复杂的方案,先评估这个key的QPS到底有多高,再决定。
# 互斥锁重建缓存示例 def get_with_lock(key): value = redis.get(key) if value is not None: return value if redis.set(f"lock:{key}", "1", nx=True, ex=1): try: value = load_from_db(key) redis.set(key, value, ex=300) return value finally: redis.delete(f"lock:{key}") time.sleep(0.02) return redis.get(key)3.3 缓存雪崩:大量key同时失效,数据库被“面”级打穿
雪崩和击穿的区别在“面”与“点”:击穿是一个热点key失效,雪崩是大量key在同一时刻集体失效。典型场景是给所有key设置了相同的过期时间,比如统一10分钟,时间一到,成千上万个key一起过期,数据库要承受整个缓存层的回源压力。
解决雪崩主要有三种手段。第一是过期时间加随机抖动,这是最常用也最有效的办法,基础时间加上一个随机偏移量,把失效时间打散。第二是多级缓存,即使Redis里的key大面积失效,本地缓存还能先吸收一部分流量,给数据库争取重建时间。第三是熔断降级,当数据库压力超过阈值时,直接返回默认值或者旧缓存,不要死扛。
我会在封装缓存工具类时直接把抖动逻辑内置,所有的key默认都经过统一的过期时间配置,这样开发同学就不容易在某处写死一个相同的过期时间。
3.4 加锁和降级的参数不是玄学,压测数据说了算
互斥锁的超时时间、重试等待、降级阈值,这些参数在面试里常被问,但很多人回答得都很虚。我的参考值是这样来的:锁超时一般500毫秒到1秒,重试等待20到50毫秒,最多重试3次。如果数据库查询本身就很快,锁的意义就很小;如果回源查询要200毫秒,锁的价值就非常大。
降级阈值要用数据库的承受能力反推。假设数据库连接池有200个连接,单条查询耗时10毫秒,那么数据库能安全承受的QPS大约是20000,我们就应该在QPS到16000左右开始触发降级,而不是等数据库已经被打满才介入。这些参数必须在压测环境里验证,不能拍脑袋写一个数字就上线。
4. 缓存与数据库一致性:别再迷信“删缓存就好了”
4.1 先更新数据库还是先删缓存,这个顺序决定了你踩不踩坑
做缓存更新时最常见的操作是“先更新数据库,再删除缓存”。这里存在一个极小的竞态窗口:线程A更新数据库成功但还没删缓存时,线程B读到了旧缓存并返回,随后线程A才把缓存删掉。这个窗口非常短,对大多数业务来说可以接受。
另一种做法是“先删缓存,再更新数据库”,问题就大多了。删除缓存之后到数据库更新完成之前,其他线程会读到数据库的旧值并写回缓存,脏数据可能长期存在,很难自动恢复。所以我的默认实践非常明确:先更新数据库,后删缓存。并且要保证删除这一步足够可靠——如果删缓存失败,必须有重试机制,否则下次查询会把旧值重新回填,脏数据就赖着不走了。
4.2 延迟双删:理论完美,实际操作很难受
延迟双删的思路是:更新数据库后先删一次缓存,然后等一小段时间,比如500毫秒,再删一次。目的是清掉第一次删除后到数据库更新完成前,并发线程写回缓存里的旧值。听起来很严谨,但实际用起来很别扭。
问题在于延迟时间很难定。太短了,并发的写回还没结束,第二次删除删了寂寞;太长了,接口响应时间被拖长,或者你得把这步放到异步线程里去。就算延迟时间设得合适,它也只在特定并发窗口内有效,不能根治不一致。所以我的结论是:延迟双删只能降低出问题的概率,不能根除问题。真正对一致性要求高的场景,我不会依赖它,而是用下面这种更解耦的做法。
4.3 更务实的方案:订阅数据库日志,让缓存更新变成异步任务
订阅数据库binlog实现缓存同步,是当前比较成熟的做法。核心思路是用类似Canal的组件订阅数据库变更日志,解析出变更的主键,然后异步去更新或者删除缓存。这样业务代码里不需要手动管理缓存一致性,更新数据库和删缓存被天然解耦成两个阶段,脏数据的窗口被大幅压缩。
这个方案的代价是引入了一条新的数据管道,需要处理数据延迟、消息重复、消费失败等问题。我的落地经验是:binlog方案适合一致性要求较高、变更频繁的核心链路,比如用户资料、订单状态字段;对于低频更新、可容忍短暂不一致的页面,用“先更新库再删缓存”的简单方案已经足够。架构上不要过度设计,每引入一个组件都要想清楚它到底解决了什么痛。
4.4 一致性分级:先想清楚你接受多大程度的“脏读”
踩过几次一致性坑之后,我总结出一个原则:先给数据做一致性分级,再决定用什么方案。对用户可见的核心数据,比如支付状态、账户余额,尽量做到秒级最终一致;对列表页、聚合页、非关键配置,短暂不一致通常不会引起投诉。
架构评审时,我会反复追问几个问题:这个数据读错了会影响钱吗?会影响用户体验吗?会影响多少用户?如果答案都是“影响很小”,那就不需要花高成本去追求强一致。缓存本来就是一种取舍,搞清楚自己愿意接受多少不一致,很多方案选型都会变得简单。
5. 缓存实战:监控指标、容量评估与上线前的自查清单
5.1 命中率不是唯一指标,要拆开看
缓存命中率是最常见的监控项,一般从Redis的INFO stats或者代理层指标里取。但只看整体命中率会掩盖很多问题:某个核心key命中率掉到0,但其他key命中率都在90%以上,整体数字依然好看,线上已经出事故了。
因此我要求给重要业务单独设置key维度的命中率告警,或者按前缀分组统计。除了命中率,还要关注淘汰数和慢命令:如果evicted_keys数量不断增加,说明内存容量不够或者过期策略不合理;如果出现大量慢查询命令,就要排查是不是有同学用O(N)命令处理了大数据集。这些指标组合起来,才能比较真实地反映缓存健康状况。
5.2 容量评估和过期时间怎么定,有没有可量化的方法
容量评估要结合业务量级,公式大概是:内存占用等于key平均大小乘key数量,加上value平均大小乘value数量,集群的话再乘副本数,最后加上20%到30%的冗余。我见过很多兄弟申请Redis资源时完全靠感觉,结果大促前才突然发现内存要到上限,非常被动。
过期时间的设定要综合考虑数据新鲜度与回源压力。对基本不变的数据,可以设到24小时;对价格、库存这类高频变化的数据,要么设几秒,要么干脆不缓存。这里有个容易忽略的点:所有key必须带业务前缀和环境前缀,比如order:pay:prod:这种格式。别小看这个习惯,将来清理数据、灰度上线、按维度排查问题,前缀能帮你省下无数时间。
5.3 上线前可以照着过一遍的自查清单
最后分享一个我每次接入缓存都会过一遍的检查清单,每一行都对应过一次线上事故教训:
- 是否存在缓存穿透风险?查询不存在的key时会怎么处理?
- 有没有热点key?热点key失效瞬间,数据库回源压力能扛住吗?
- 所有key的过期时间是否做了随机化处理?
- 删除缓存失败怎么办?有没有重试或者订阅日志兜底?
- 缓存容量是否做过量化估算?内存上限和淘汰策略是什么?
- 是否配置了命中率、内存、慢查询指标的告警?
这份清单看起来简单,但真要把每一项都做到位,其实需要不少准备工作。你可以在项目上线前对着过一遍,能提前发现很多隐患。
缓存这个组件,用的时候很爽,出问题的时候确实很惨。它不像业务代码那样可以靠修逻辑来补救,一旦在流量面前失守,数据库被击穿就是系统性风险。我个人的体会是,做缓存设计时不要追求“最好的方案”,而要把“失效瞬间的兜底”和“数据不一致的容忍边界”想清楚,再根据业务场景选择那个足够简单、足够可靠的方案。希望这篇文章能帮你少走一些弯路,至少在下一次被问到“缓存和数据库怎么保持一致性”的时候,你能给出自己的判断,而不是背一个标准答案。