刚接手一个交易类系统的时候,印象最深的一次故障,是数据库连接数被打满,整个服务集体超时。上游接口响应从几十毫秒一路涨到几秒,最终牵一发动全身。事后复盘的时候发现,其实根子上的问题很简单:缓存部署得太单薄了,所有请求在缓存未命中之后全都穿透到数据库。那套系统的缓存只有一个Redis集群,本地进程内缓存完全没有用起来,预热更是不存在的。那次事故之后,我花了很长时间重新梳理整个系统的缓存架构,把多级缓存和缓存预热系统地落到了生产环境。这篇文章就把这套设计思路、落地过程、以及我踩过的坑,尽量完整地写出来,希望对正在做架构设计或者性能优化的你有点参考价值。
1. 多级缓存的底层逻辑:一次请求的完整链路
1.1 从数据库扛不住说起
很多系统最开始的形态很简单,服务直接查数据库,逻辑清晰,开发也快。可一旦流量上来,数据库的压力会先于所有指标暴露出来。连接池数量有限,慢查询会占住连接,数据库CPU飙升,最终请求排队甚至超时。引入缓存看起来是标准答案,但把所有缓存都放在一个Redis集群里,相当于把所有赌注压在同一个篮子里。
单个Redis的好处是集中管理、容量大、过期策略统一,坏处也明显:每次缓存查询都是一次网络RPC,响应时间在0.1到0.5毫秒级别,可一旦Redis出现网络抖动或者热key集中,服务立刻跟着遭殃。数据库本身的访问耗时普遍在几毫秒到几十毫秒,这一层加一层的耗时叠加,最终用户感受到的接口时延很难看。
1.2 多级缓存的本质是“远近搭配,层层拦截”
业界常说的多级缓存,并不是把同一个数据复制到多个地方就完事,而是让不同距离、不同速度、不同容量的存储各司其职。一个典型的模型是三层:浏览器或客户端侧的本地缓存、应用进程内的本地缓存、以Redis为代表的分布式缓存。再往下才是数据库本身。
进程内缓存离代码最近,查询耗时可以忽略不计,通常都在微秒级。但它的容量受限于JVM堆或者进程内存,而且每个节点各自维护一份,更新时很容易出现节点间的不一致。Redis缓存离服务有网络开销,但容量大,可以跨节点共享。浏览器缓存离用户最近,命中后连请求都不会发到后端。
这三层从性能上呈数量级递减,从容量和一致性维护成本上看又呈递增趋势。多级缓存的真正设计目标,是用最廉价的机制挡住大部分流量,让昂贵的后端只处理不得不处理的那部分请求。
1.3 各级缓存的职责划分
我给系统中的各级缓存做了明确的职责划分,这在后面写代码、设计更新策略的时候会非常有用。
浏览器侧主要用来缓存静态资源和极少变化的公共数据,比如图片、静态页面片段。这里用HTTP缓存头控制即可,业务代码不用过多介入。
进程内缓存主要放两类内容:一类是变化频率极低、几乎是静态配置的数据,比如商品类目、城市列表;另一类是访问量极高、同时可以容忍短暂不一致的数据,比如热点新闻、排行榜。进程内缓存的特点是快,但容量有限,适合温热点数据,不适合全量。
Redis这一层作为主缓存层,承担了大部分后端逻辑的数据承载,同时因为它是集中式的,数据更新后可以快速同步到所有节点。那些需要强一致、又没法容忍本地节点缓存漂移的数据,应该优先放到这一层。
数据库永远是兜底,但兜底不意味着随意压榨。我后面会讲到预热和缓存空值标记,目的就是让数据库暴露在真实流量下的概率尽可能低。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多级缓存的整体设计思路与核心选型
2.1 按数据特征决定缓存层级
在动手搭建之前,有一件事必须先做:把业务数据按特征分类。我一般会分成三类。
第一类是静态配置类数据,变化周期以天甚至以周计。这类数据适合全部放进程内缓存,比如启动时加载,之后定时刷新。即使不做主动刷新,靠过期时间也能撑住。
第二类是热点类数据,总量很大,但某一小部分key的访问频率极高。这类数据必须多级缓存配合,热key用进程内缓存拦截,防止Redis被单点尖峰打爆;Redis中存放全量热点子集,回源时只查有限范围。
第三类是强一致类数据,比如支付订单状态、库存扣减。这类数据原则上不推荐用进程内缓存,因为本地缓存天然存在节点间不一致的问题,不同节点读到不同数据会引发业务逻辑错误。合理做法是把这类数据放在Redis,并配合数据库事务与版本控制。
2.2 缓存粒度与Value大小的权衡
缓存的单位到底是单条记录、列表还是聚合对象,这个选择直接决定了命中率和一致性维护的复杂度。
细粒度缓存,比如缓存一条订单记录,优点是数据变更时精确更新,缺点是如果需要列表数据,得先查询一批id再逐条查缓存,甚至还得回源拼接,网络往返次数多。
粗粒度缓存,比如缓存一个用户订单列表的整体对象,优点是一次读操作就能拿到完整数据,缺点是任一子项变更都需要重建整个列表缓存,频繁更新时成本高。
我的经验是列表加摘要混合使用。列表数据只缓存一个轻量级的id序列,业务字段回源时再逐条查询细粒度缓存。这样既保证了列表不实时拼装的灵活性,又不至于因为列表整体缓存大幅降低命中率。
2.3 缓存键设计:一个被低估的环节
缓存键设计不当,会造成大量无效的缓存空间和命中率下降。我常用的键格式是:业务域:对象类型:唯一标识:关联维度,比如 user:profile:1001:basic。
一个特别容易踩的坑是键里拼接了大量无关维度,导致同一个对象的不同属性散落到大量键里。宁可一个业务对象建多个键,也不要一个键里塞满所有查询条件。条件一变键就变化,缓存命中率几乎为零。
另一方面,键名不要过长。Redis的key如果存几万个超长字符串,内存浪费非常明显。我用过一种方式:将业务语义保留在key中,但会把会变化的部分,比如时间戳、批次号,放在value里或者用单独的映射表维护。
2.4 选型必看:进程内缓存用Caffeine还是Guava
在没有其他约束的情况下,我优先推荐Caffeine。Guava Cache在早年非常流行,功能也很完善,但Caffeine在性能和内存管理上更激进,尤其在高并发读场景下,它的命中率和吞吐量更占优。
Caffeine支持按时间和按大小两种过期策略,还支持异步加载和手动刷新。实际项目中我会把它和Spring Cache整合,再用注解驱动。需要注意的是,进程内缓存的对象一定要做深拷贝或者用不可变对象,不然调用方不小心改了缓存对象里的属性,会污染整份缓存数据。
3. 多级缓存核心细节解析与实操要点
3.1 流量穿透问题:先查本地还是先查Redis
多级缓存的核心请求路径通常是:先查进程内缓存,未命中查Redis,仍未命中才查数据库,查完后依次回填各级缓存。这个顺序很好理解,能最大程度用低延迟的缓存拦住请求。
不过这里有一个习惯性问题:很多团队会把“先查Redis再查本地”作为默认顺序,原因是觉得本地缓存命中率低、数据容易不一致。我建议反过来重新思考。进程内缓存访问成本是微秒量级,Redis是零点几毫秒量级,数据库是几毫秒甚至几十毫秒量级。如果热点数据占比高,先查本地缓存能省掉大量跨网络请求。
实际生产中我自己的策略是:在热key比例明确偏高时才启用进程内缓存,并把它的过期时间设置得比Redis短,比如本地5秒、Redis 30秒。这样即使出现更新延迟,最坏情况下本地缓存在5秒后也能重新拉取新数据。
3.2 缓存穿透、击穿与雪崩的三重防线
这三个词基本是缓存面试题标配,但想真正防得住,细节都在代码里。
缓存穿透指的是查询一个并不存在的数据,缓存和数据库都没有,于是请求每次都打进数据库。最常见的处理方式是把空值也缓存起来,设置一个较短的过期时间,比如30秒到1分钟。注意,如果后续新增了真实数据,需要主动删除空值缓存,防止长时间查不到新数据。另一种思路是请求前加布隆过滤器,先判断key是否存在,但实现和维护成本更高,适合数据量极大的场景。
缓存击穿指的是某个热点key突然过期,同时大量请求同时来,结果全部穿透到数据库。解决手段是互斥锁重建缓存。进程内可以用锁,Redis可以用分布式锁。简单来说,拿到锁的线程去数据库查并回填缓存,其他线程短暂阻塞后重新查缓存。
缓存雪崩则是一大片key在同一时间过期。处理时尽量避免过期时间完全一致,可以给过期时间加上随机抖动,比如几秒钟的随机偏移。同时,多级缓存也能减缓雪崩的杀伤力,Redis中的key过期后,进程内缓存还能扛一波。
3.3 数据一致性:多级更新还是先更新再删除
多级缓存最令人头疼的问题就是数据一致。常用的方案有两种:Cache Aside(旁路缓存)和Read Through/Write Through(读写穿透)。
Cache Aside的原则是:读操作先查缓存,未命中则读数据库并回填;写操作更新数据库,然后删除对应的缓存。删除而不是更新缓存,是因为更新缓存需要付出额外计算,而且并发写时很容易产生旧值覆盖新值的场景。
多级缓存下的删除操作要格外细心。你删掉了Redis中的缓存,但进程内缓存里还留着旧值,如果接口透出了这个旧值,用户依然会觉得数据没刷新。所以我在更新数据时,会执行一级缓存删除、二级缓存删除的流程,同时配合本地缓存的短暂过期策略。
3.4 预热前必须做的事:摸清数据访问规律
预热不是把数据库数据一股脑塞进缓存。那样只会把低频数据占满内存,真正的高频数据反而被挤出降级。所以预热前要做数据访问统计。
最简单的方式是记录一段时间内每个业务key的访问次数,统计出Top N的热点清单。可以用日志分析,也可以在业务入口埋点,把key和访问频次写入一个集中统计服务。对交易类系统来说,通常Top 1%的key可能就承担了80%以上的查询流量。预热就针对这部分来做。
对于新增业务模块,没有历史访问数据,可以根据业务预期来预热。比如说今天要上一个热门活动,预热名单可以根据活动商品ID列表和用户分群来预置。两种方式结合,能够覆盖大多数场景。
4. 缓存预热的方法论与实操手册
4.1 预热触发时机:启动、定时、事件
按照触发时机,预热一般分三类。
应用启动时预热,适用于静态配置类数据。这种数据量不大,直接随应用启动异步加载填满本地缓存即可。
定时任务预热,适用于有周期性规律的数据。比如每天早上九点前把昨日热榜内容更新到缓存,或者每半小时刷一次运营位配置。这类预热可以用Spring的@Scheduled或者分布式任务调度中心来实现。
事件驱动预热,适用于业务变化有明确感知的场景。比如运营发布了一个新商品,希望在商品上架前就把详情页缓存好。此时可以在发布接口中发送消息,由消费者线程把新商品写入缓存。
4.2 手动预热 vs 自动预热:什么阶段用哪种
如果系统规模还不大,手动预热往往更方便。写一个内部管理接口,调用触发后循环加载指定数据到缓存。好处是灵活可控,缺点是运维操作容易遗漏,且无法自动应对突发流量。
自动预热更适合持续运行且访问规律稳定的系统。核心是维护一份热点key清单,动态计算并持续刷新缓存。一个常见做法是每间隔一段时间,从统计服务拿最新的热点Top N,然后比较缓存中的实际数据,缺失的顺序补写,过期的重新加载,如此循环。
我现在的项目中是手动加自动结合:所有静态数据启动自动加载,运营位数据由事件触发预热,高并发活动场景下再由运营手动触发一次性预热接口兜底。
4.3 预热的实现细节:并发控制与失败补偿
预热最怕的不是不执行,而是并发执行。多个实例同时启动,各自独立执行预热逻辑,很可能把后端数据库在启动瞬间打挂。解决办法有两个:一是加分布式锁,每个预热任务整体加锁,只有一个实例执行;二是把预热任务拆成一个个小批次,用限流器控制每秒写入缓存的数量。
另一个细节是预热失败后的补偿。缓存预热过程中,可能因为数据库抖动、Redis访问超时等原因导致部分数据没有成功写入。这里我通常会在预热任务结束时做一次校验:比较预期加载数量和实际加载数量,如果不一致,针对失败的分片重新处理。
下面给一个简单的预热代码示意,这个不是生产直接可用的代码,但核心思路很清晰:
java复制public class CacheWarmupService {
private final CacheManager cacheManager;
private final HotKeyService hotKeyService;
public void warmupHotKeys(String bizName, int topN, int batchSize) {
List<String> hotKeys = hotKeyService.getTopHotKeys(bizName, topN);
// 分批处理,避免一次性把数据库和Redis打满
for (List<String> keys : partition(hotKeys, batchSize)) {
keys.parallelStream().forEach(this::loadAndSetCache);
// 后续可加限流控制,这里忽略
}
}
private void loadAndSetCache(String key) {
Object value = loadFromDB(key);
if (value != null) {
cacheManager.set(buildRedisKey(key), value, Duration.ofMinutes(30));
}
}
}
4.4 预热数据量级控制:如何确定多少条算够
这个没有标准答案,但有一个经验公式可以判断大致量级:预热的总体数据量应当尽可能覆盖预估的读请求量。假设系统预计每秒有10万次读请求,命中率目标99%,那么理论上需要兜底的未命中请求是每秒1000次。与其让这1000次请求都打到数据库,不如把它们落到缓存。这个过程其实就是在反复衡量热度分布曲线。
热key的头部效应非常明显。通常我们会把前几百个或者前几千个key称为核心热区,这块数据必须全部预热。再往下是长尾区,数据量可能很大,但命中率逐步走低。我的建议是核心热区全量预热,长尾区域靠自然回源逐步填充,不加干预。
5. 实战:一个高并发商品详情页的缓存落地
5.1 业务场景与性能目标
这里用一个商品详情页作为例子。详情页的典型特征是读多写少,商品基础信息、库存、价格、销量变动不大,但流量往往集中在几个头部商品上。双十一大促时,几个爆款商品的详情页访问量能占到整体90%以上。
性能目标设定为:单机QPS 5000,接口平均响应时间小于100毫秒,数据库峰值QPS不超过日常基线。在这样的目标下,单靠Redis是不可能同时满足性能和数据库压力要求的,必须利用进程内缓存。
5.2 具体缓存层级划分
商品详情页的数据我拆成了三类:
基础信息(标题、图片、描述)基本不变,放在进程内缓存,过期时间10分钟,更新采用主动通知。
价格和库存变化较快,放在Redis,过期时间1分钟,写入时同步删除Redis缓存。
用户维度的个性化信息(收藏状态、优惠券)走用户中心缓存,不在商品详情页多级缓存内单独做。
实际请求链路是:先查本地Caffeine,未命中则查Redis,仍未命中则查数据库,查询成功后依次回写。库存变化时,先更新数据库,再删除Redis中的库存缓存,同时删掉本地缓存的库存字段。由于本地缓存过期时间短,最坏情况下10秒内能恢复一致。
5.3 预热脚本的完整实现
商品详情页的预热我用了定时任务加手工触发两种方式。定时任务每小时执行一次,从日志分析服务中拉取过去一小时内访问频率最高的500个商品ID,然后对每个商品ID生成缓存键,批量加载数据库并写入Redis。在写入Redis的同时,本地缓存不预写,因为本地缓存依赖于当前节点的内存分布,预热写在Redis层可以让任何节点都受益。
这个过程中的关键点是控制批量大小。500个商品拆成10个批次,每批50个,串行提交,避免预热线程把数据库连接池占满。每个商品读取的SQL都走主键索引,单次查询耗时极低。
5.4 实际的性能数据变化
上线多级缓存和预热机制后,我记录了三个指标的变化。
数据库QPS从高峰期的每秒3000多,降到了日均不到200。商品详情页的平均响应时间从约180毫秒降到了约35毫秒。Redis的QPS虽然还是很高,但进程内缓存的命中率达到了70%,这意味着有七成请求根本不会发送到Redis服务器。
这套数据的说服力在于,它不是靠加大机器配置堆出来的,而是靠合理的缓存分层把流量层层拦截。多一次预热动作,数据库就少承受一次真实业务冲击。
6. 常见问题与排查技巧实录
6.1 缓存和数据库数据不一致:如何快速定位
生产环境里最怕的就是用户看到脏数据。排查思路一般是三步走:先看缓存过期时间是否设置合理,再看写入路径是不是没有先更新数据库再删缓存,最后用日志追踪某一条数据在缓存中的写入时间和来源。
我踩过最典型的一个坑是,先删缓存再更新数据库。如果数据库更新失败,缓存又被删了,下一请求就会拿到旧数据并重新填充,导致旧值长时间存在。正确的顺序必须是先更新数据库,后删缓存。如果要进一步降低窗口期,可以用延迟双删,更新数据库后先删一次缓存,隔几百毫秒再删一次,避免并发请求期间重建旧数据。
6.2 命中率突然下降:先查键设计再查数据分布
如果是上线新代码后命中率下降,优先怀疑键设计;如果是大促开始前命中率下降,优先检查预热逻辑是否覆盖了新的热点数据。
我遇到过一次情况:运营活动中新增了一批商品ID,但预热清单还是按旧ID生成的,结果流量一来,所有新商品全部回源数据库。排查时看监控曲线就能知道,某个时间点开始数据库QPS直线上升,命中率同步下降,再比对线上最新商品ID集合,立刻就能确认是预热数据没跟上。
6.3 进程内缓存导致的内存溢出与对象污染
Caffeine这类本地缓存用起来容易,但内存溢出是隐藏风险。设置最大条数时,不能只考虑缓存容量,还要考虑对象的大小。如果你缓存的是一个包含大量嵌套结构的复杂对象,几百个key就可能占掉几个GB堆内存。
解决方式是严格控制放入本地缓存的对象大小,并对写入对象做序列化大小预估。我的习惯是:本地缓存中每个value尽量控制在几KB以内,超过这个量级的对象只缓存轻量摘要字段,完整数据放到Redis。
对象污染则是另一个高发问题。业务代码拿到的缓存对象如果直接做setter操作,会改变缓存里的真实对象。由于本地缓存不通过Redis,所有节点共享同一份内存,这种污染是致命的。处理办法是设计缓存对象时使用不可变类,或者返回时做防御性拷贝。
6.4 缓存预热常见的失效场景
预热失效场景很多,我列三个最常见的。
第一,预热数据量过大,执行时间过长,导致预热还没跑完缓存已经开始过期,头部数据始终没被加载。解决办法是缩短预热周期,把大任务拆小。
第二,预热任务和业务高峰期重叠。定时任务如果没有避开流量高峰,预热线程会和业务线程争抢数据库连接和带宽。解决办法是让定时预热在低峰期执行,比如凌晨,或根据实时流量调度。
第三,多实例同时预热导致负载不均。虽然最终缓存都在Redis中,但预热时每个实例都去数据库加载一遍,不仅浪费,还会放大压力。使用分布式锁确保单一实例执行预热,能从根本上消除这个问题。
6.5 排查清单:我遇到问题时的固定动作
当缓存环节出现性能或者一致性问题时,我习惯按下面这个清单逐步排查,能省去大量定位时间。
先确认缓存命中率指标是否正常,如果命中率正常,性能瓶颈大概率在其他环节;再检查Redis慢日志,确认是否存在大key和热key;然后确认数据库慢查询次数,如果回源量上涨,多半是缓存键没有命中;最后检查本地缓存配置,包括最大条数、过期时间、是否被业务误操作污染。
这套排查顺序的核心原则是从整体指标入手,再逐级缩小范围。不要一上来就翻服务器日志或者抓线程栈,那样效率很低,也很容易被表象带偏。
7. 一些个人经验和建议
多级缓存和缓存预热并不是高深的理论,而是解决高并发读场景下数据库压力的实用手段。真正让它发挥效果的,是对业务数据特征的深入理解,以及对每个细节的严格把控。如果你即将在一个新系统中引入这套方案,我的建议是先别急着写代码,先把所有数据的读写比例、更新频率、热点分布拉出来看一看。
从实际项目经验来看,有几件事值得反复强调。第一,缓存未命中的回源防护一定要做好,缓存穿透和击穿的处理代码必须在首次上线时就带上,而不是等问题出现后再补。第二,预热任务要有监控和告警,最好能记录每次预热任务的完成时间、加载数量、耗时分布,出现异常时能第一时间感知。第三,多级缓存的一致性越弱,业务容忍度就越要高,所以设计阶段就要明确哪些数据可以短暂不一致,哪些数据必须强一致,并写入团队的技术规范。
最后分享一个我自己习惯的小技巧:在向缓存写入数据时,顺手记录数据来源和写入时间戳。平时看不出来,一旦排查线上数据不一致问题时,这份元信息能帮你快速定位是预热任务写入的,还是用户实时回源写入的,以及数据到底在哪个时间点开始偏离预期。有了这些细节,维护一套多级缓存体系会轻松很多。
