一次高并发读接口的缓存链路设计:从 Redis 到本地缓存,再到热点保护

高并发系统里,读接口往往是最先被打满的。

特别是活动页、资源详情页、热门商户页这类接口,平时访问量不一定夸张,但一到活动开始、首页推荐、用户集中访问时,流量会突然堆上来。

如果每个请求都直接查数据库,数据库很快就会变成整条链路里最脆弱的一环。

这次优化的场景就是一个典型高频读链路。业务上主要是查询活动资源和商户资源,数据本身变化不算特别频繁,但访问量在活动期间非常集中。最开始只依赖数据库兜底,后来逐步加了 Redis 分布式缓存、本地缓存,以及针对穿透、雪崩、击穿的保护机制。

这篇文章主要记录一下这条缓存链路是怎么设计的,以及为什么不是简单“加一层 Redis”就结束。


一、为什么只用数据库不够

活动资源查询这类接口,有两个特点。

第一,读多写少。

资源创建、修改、上下架并不是高频操作,但查询非常频繁。尤其是活动入口曝光后,同一个资源可能被大量用户反复访问。

第二,热点明显。

并不是所有资源都会被平均访问。往往是少数热门资源承担了大部分流量。

如果每次查询都走数据库,链路大概是:

用户请求
  ↓
业务接口
  ↓
数据库查询
  ↓
返回结果

在低并发下没问题。但流量一上来,数据库会承受大量重复查询。

这类重复查询其实没有必要。对于读多写少的数据,缓存应该成为主路径,数据库更多作为兜底数据源。


二、第一层优化:Redis 分布式缓存

最容易想到的方案是加 Redis。

查询链路变成:

用户请求
  ↓
查询 Redis
  ↓
命中:直接返回
  ↓
未命中:查询数据库
  ↓
写入 Redis
  ↓
返回结果

这种模式也就是常见的 Cache-Aside。

它的好处是比较直观:

缓存命中时不访问数据库
数据更新时由业务系统控制缓存删除或重建
缓存和数据库之间保持最终一致

对于活动资源这类读多写少场景,Cache-Aside 比较合适。

不过 Redis 也不是银弹。

当流量继续增大时,每次请求虽然不查数据库了,但仍然需要访问 Redis。单次网络开销很小,但在热点接口里,累计开销仍然明显。

如果同一个热点资源在某一台应用实例上被频繁访问,把它每次都从 Redis 拿出来,也不算最优。

所以后面又加了一层本地缓存。


三、第二层优化:本地缓存 + Redis 二级缓存

优化后的读取链路变成:

用户请求
  ↓
本地缓存
  ↓
Redis 分布式缓存
  ↓
数据库

其中本地缓存放在应用 JVM 内部,Redis 作为多实例共享缓存。

这两层缓存的职责不一样。

本地缓存适合承接当前实例上的热点请求,减少 Redis 网络访问。

Redis 则负责多实例之间共享缓存结果,避免每个实例都直接打数据库。

完整链路可以理解为:

请求进来
  ↓
先查本地缓存
  ↓
本地命中:直接返回
  ↓
本地未命中:查 Redis
  ↓
Redis 命中:写入本地缓存并返回
  ↓
Redis 未命中:查数据库
  ↓
结果写入 Redis
  ↓
结果写入本地缓存
  ↓
返回结果

这样热点资源在同一个应用实例上被多次访问时,可以直接从内存返回。

本地压测时,这种二级缓存对吞吐提升比较明显。以某个活动资源查询接口为例:

数据库直查:QPS 约 3600
Redis 缓存:QPS 约 8000
本地缓存 + Redis:QPS 约 12500

相对数据库直查,整体吞吐提升到了约 3.5 倍。

当然,这个数字只代表当时本地压测环境和指定接口下的结果,不能简单泛化成所有场景。但它至少说明:在热点读场景里,本地缓存确实能减少 Redis 网络访问,并进一步提升吞吐。


四、本地缓存带来的新问题:一致性

本地缓存的性能很好,但它也带来一个问题:多实例之间不共享。

假设系统部署了三台应用实例:

实例 A:本地缓存了一份资源详情
实例 B:本地缓存了一份资源详情
实例 C:本地缓存了一份资源详情

如果后台修改了这条资源,只删除 Redis 缓存是不够的。

因为实例 A、B、C 的本地缓存里仍然可能保留旧值。

所以本地缓存适合用在以下场景:

读多写少
允许短时间最终一致
数据更新频率低
业务能接受短暂旧值

为了降低不一致影响,一般会配合:

本地缓存 TTL
更新时删除 Redis
必要时广播删除本地缓存

如果业务对一致性要求更高,可以通过消息队列或发布订阅,把缓存删除事件广播到所有实例,让每个实例删除自己的本地缓存。

在这次设计里,本地缓存主要用于热点读接口,允许短时间最终一致,因此先采用 TTL + Cache-Aside 的方式控制复杂度。


五、缓存穿透:非法请求不能一路打到数据库

缓存解决了正常热点请求的问题,但还有一类请求更危险:不存在的数据。

例如有人不断请求随机资源 ID:

id = 100000001
id = 238912733
id = 999991231

这些 ID 在系统里根本不存在。

如果没有保护,请求链路会变成:

查本地缓存:没有
查 Redis:没有
查数据库:没有
返回失败

如果攻击方持续构造随机 ID,每个 ID 都是第一次出现,那么普通缓存很难挡住。

缓存空值为什么不够

一种常见做法是缓存空值。

也就是数据库查不到时,在 Redis 里写一个空对象,设置较短 TTL。

这对“同一个非法 ID 被反复请求”有效。

但如果非法 ID 是随机生成的,每次都不一样,那么每个 ID 第一次请求仍然会打到数据库。

而且大量空值 key 还会占用 Redis 空间。

所以缓存空值只能解决一部分穿透问题,不适合大量随机非法 ID 场景。


六、用布谷鸟过滤器做前置拦截

为了在访问 Redis 和数据库之前先挡掉非法 ID,可以在链路前面增加过滤器。

请求进来后,先判断这个 ID 是否“可能存在”。

用户请求
  ↓
过滤器判断 ID 是否可能存在
  ↓
不存在:直接返回
  ↓
可能存在:继续走缓存链路

这里可以使用布谷鸟过滤器。

它的作用不是百分百判断一个 ID 一定存在,而是判断:

这个 ID 一定不存在
或者这个 ID 可能存在

也就是说,它允许少量误判为“可能存在”,但不会把真实存在的数据判断成不存在。

对于缓存穿透来说,这已经足够。

如果过滤器判断某个 ID 不存在,就可以直接拦截,请求不会继续访问 Redis 和数据库。

在随机非法 ID 压测里,加过滤器之后,数据库无效查询可以降到 0。相比缓存空值,过滤器更适合防大量随机非法请求。

不过过滤器也要维护。

当新增资源时,要把新 ID 加入过滤器;当资源删除或下架时,也要考虑过滤器是否需要支持删除或定期重建。布谷鸟过滤器相比布隆过滤器的一个优势就是支持删除,更适合动态数据场景。


七、缓存雪崩:不要让大量 key 同时过期

另一个常见问题是缓存雪崩。

假设系统里有一批资源缓存,它们在同一时间写入 Redis,并设置了相同的过期时间。

例如都设置 30 分钟:

resource:1  30min
resource:2  30min
resource:3  30min
...

如果这些 key 在同一时刻大量过期,就会出现:

大量请求同时发现缓存失效
  ↓
同时回源数据库
  ↓
数据库压力瞬间上升

解决方式很简单:TTL 随机化。

比如基础过期时间是 30 分钟,可以在此基础上增加一个随机偏移:

30min + random(0, 10min)

这样缓存不会集中在同一时间失效。

这种方式不能提升单个 key 的命中率,但能把过期时间打散,避免大量 key 同时回源。

缓存雪崩很多时候不是业务逻辑问题,而是过期时间设计太整齐。


八、热 Key 击穿:热点不能在过期瞬间集体回源

缓存击穿和雪崩不一样。

雪崩是大量 key 同时失效。

击穿是某一个热点 key 失效。

比如活动开始后,某个热门资源被大量用户访问。

正常情况下,这个 key 在缓存里,请求都能快速返回。

但如果它突然过期,瞬间可能有大量请求同时发现缓存 miss,然后一起打到数据库。

这就是热 Key 击穿。

解决它有两种常见方式:互斥锁和逻辑过期。


九、互斥锁:只允许一个线程重建缓存

互斥锁的思路是:

缓存未命中
  ↓
尝试获取分布式锁
  ↓
拿到锁:查数据库并重建缓存
  ↓
没拿到锁:等待或稍后重试

这样可以保证同一时间只有一个线程回源数据库,其他请求不会一起打穿。

它的优点是数据相对新。

缺点是没有拿到锁的请求可能需要等待,热点高峰时响应时间会受到影响。

对于一些必须返回最新数据的场景,互斥锁比较合适。


十、逻辑过期:先返回旧值,再异步重建

逻辑过期的思路和 Redis 物理过期不一样。

Redis key 本身不设置很短的物理过期,或者设置较长过期;真正的过期时间放在缓存 value 里。

缓存结构大概是:

{
  "data": {},
  "expireTime": "2026-07-17 12:00:00"
}

请求进来后:

读取缓存
  ↓
判断逻辑过期时间
  ↓
未过期:直接返回
  ↓
已过期:尝试获取锁
  ↓
拿到锁:异步重建缓存
  ↓
没拿到锁:直接返回旧值

这样热点 key 即使逻辑过期,也不会让所有请求一起打到数据库。

用户仍然能拿到旧值,系统在后台慢慢重建缓存。

逻辑过期牺牲的是一小段时间内的数据新鲜度,换来的是高并发下的可用性和稳定性。

对于活动资源详情、商户详情这类读多写少数据,这个取舍通常是可以接受的。


十一、逻辑过期里的锁要控制什么

逻辑过期不是简单返回旧值就行。

它还需要控制缓存重建次数。

否则每个请求都发现逻辑过期,然后都去异步重建,数据库还是会被打穿。

所以要配合分布式锁:

发现逻辑过期
  ↓
尝试获取重建锁
  ↓
只有拿到锁的线程负责重建
  ↓
其他线程直接返回旧值

在压测中,无论使用互斥锁还是逻辑过期,热点 key 的缓存重建次数都能稳定控制为 1 次。

这说明重建控制是有效的。

不过这两种方案的取舍不同:

互斥锁:更关注数据新鲜度,请求可能等待
逻辑过期:更关注可用性,请求先返回旧值

在高并发读场景里,我更倾向于对热点数据使用逻辑过期,因为它对用户响应更友好,也更能保护数据库。


十二、缓存链路的完整顺序

最终这条读链路大致是:

用户请求
  ↓
参数校验
  ↓
过滤器判断 ID 是否可能存在
  ↓
不存在:直接返回
  ↓
查本地缓存
  ↓
本地命中:返回
  ↓
查 Redis
  ↓
Redis 命中:写入本地缓存,返回
  ↓
Redis 未命中:查数据库
  ↓
数据库命中:写入 Redis 和本地缓存
  ↓
数据库未命中:按策略缓存空值或直接返回

如果是热点逻辑过期 key,则链路稍微不同:

读取 Redis 缓存
  ↓
判断逻辑过期时间
  ↓
未过期:返回数据
  ↓
已过期:尝试获取重建锁
  ↓
获取成功:异步重建缓存
  ↓
获取失败:返回旧值

这里的关键是:正常请求、非法请求、热点过期请求,不应该走完全一样的处理路径。

不同风险要用不同手段处理。


十三、缓存更新怎么处理

读缓存设计完以后,还要考虑写操作。

这类读多写少数据,通常采用 Cache-Aside:

更新数据库
  ↓
删除缓存

而不是直接更新缓存。

原因是缓存里可能存的是复杂聚合结果,直接更新容易遗漏字段或造成不一致。

删除缓存后,下一次查询再回源数据库并重建缓存。

如果有本地缓存,还需要考虑本地缓存删除问题。

简单方案是:

删除 Redis
等待本地缓存自然过期

更严格的方案是:

删除 Redis
发送缓存失效消息
所有实例删除本地缓存

如何选,要看业务对一致性的要求。

对于活动资源和商户详情这类数据,如果允许短时间旧值,TTL 自然过期可以接受;如果涉及价格、库存、状态,就不能依赖本地缓存自然过期,需要更严格的失效机制。


十四、压测结果怎么看

压测不是为了证明“用了缓存一定快”,而是验证设计有没有解决具体问题。

这次主要看了三类结果。

1. 正常热点查询吞吐

数据库直查作为基线,Redis 缓存提升明显,本地缓存 + Redis 进一步减少网络访问,QPS 提升到约 3.5 倍。

2. 穿透场景下的数据库保护

随机非法 ID 压测中,直接查库会产生大量无效数据库查询。缓存空值只能缓解重复非法 ID,对随机非法 ID 效果有限。

加入过滤器后,无效数据库查询降为 0。

3. 热 Key 重建次数

热点 key 过期时,重点不是单纯看 QPS,而是看数据库有没有被重复重建打穿。

压测结果里,缓存重建次数稳定控制为 1,说明分布式锁控制住了重建并发。

这类压测结果要结合场景看。

QPS 提升是一方面,更重要的是数据库有没有被保护住。


十五、这套方案的边界

缓存方案一定有边界。

1. 本地缓存不是强一致

多实例下,本地缓存天然有不一致窗口。

所以它适合读多写少、允许短暂旧值的数据,不适合强一致字段。

2. 过滤器需要维护

新增资源、删除资源、全量重建,都要考虑过滤器同步。

如果过滤器数据长期不维护,拦截效果会下降,甚至影响正常请求。

3. 逻辑过期会返回旧值

逻辑过期本质是用旧数据换可用性。

如果业务不能接受旧值,就不能使用这种方案,或者只能在部分字段上使用。

4. 缓存层级越多,治理成本越高

本地缓存、Redis、数据库三层链路性能更好,但更新、失效、监控也更复杂。

所以缓存不是越多越好,应该只给真正高频、热点明显、读多写少的接口做多级缓存。


十六、后续还可以怎么优化

这条链路后续还可以继续做几件事。

1. 本地缓存失效广播

如果数据更新频率变高,可以通过消息队列或发布订阅,把失效事件广播给所有应用实例,统一删除本地缓存。

2. 热点 key 监控

可以统计 Redis key 访问频率,自动识别热点 key。对于热点 key,提前使用逻辑过期或后台刷新策略。

3. 缓存预热

活动开始前,可以提前把热门资源写入 Redis 和本地缓存,避免活动开始瞬间大量请求同时回源。

4. 降级策略

当 Redis 异常时,需要决定接口是直接失败、查数据库,还是返回本地缓存旧值。

不同接口应该有不同降级策略,不能简单统一处理。

5. 缓存指标监控

至少要关注:

本地缓存命中率
Redis 命中率
数据库回源次数
过滤器拦截次数
空值缓存数量
热点 key 重建次数
缓存重建耗时

没有这些指标,缓存链路很难长期维护。


十七、总结

这次缓存优化给我的感觉是,高并发读接口的核心不是“加 Redis”这么简单。

Redis 解决的是数据库重复查询问题,但解决不了所有问题。

当流量继续上来,还要考虑:

Redis 网络访问是否还能继续优化
非法 ID 会不会穿透到数据库
大量 key 会不会同时过期
热点 key 过期时会不会打穿数据库
多实例本地缓存会不会读到旧值
缓存更新和删除是否可控

最终这条链路形成了一个分层结构:

过滤器:挡掉明显不存在的请求
本地缓存:承接单实例热点请求
Redis:承接多实例共享缓存
数据库:作为最终数据源
互斥锁 / 逻辑过期:控制热点重建
TTL 随机化:打散过期时间

真正有价值的地方,不是用了多少缓存组件,而是每一层都解决了一个明确的问题。

对高并发读链路来说,缓存设计要同时考虑性能、可用性、一致性和可恢复性。只追求命中率,或者只看 QPS,都不够。

系统最终要做到的是:热点请求尽量不进数据库,非法请求尽量挡在前面,缓存过期时也不要让数据库承受瞬时压力。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐