行业资讯
📅 2026/7/18 19:53:38
高并发读接口的缓存链路设计:从 Redis 到本地缓存,再到热点保护
一次高并发读接口的缓存链路设计从 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 本地缓存 RedisQPS 约 12500相对数据库直查整体吞吐提升到了约 3.5 倍。当然这个数字只代表当时本地压测环境和指定接口下的结果不能简单泛化成所有场景。但它至少说明在热点读场景里本地缓存确实能减少 Redis 网络访问并进一步提升吞吐。四、本地缓存带来的新问题一致性本地缓存的性能很好但它也带来一个问题多实例之间不共享。假设系统部署了三台应用实例实例 A本地缓存了一份资源详情 实例 B本地缓存了一份资源详情 实例 C本地缓存了一份资源详情如果后台修改了这条资源只删除 Redis 缓存是不够的。因为实例 A、B、C 的本地缓存里仍然可能保留旧值。所以本地缓存适合用在以下场景读多写少 允许短时间最终一致 数据更新频率低 业务能接受短暂旧值为了降低不一致影响一般会配合本地缓存 TTL 更新时删除 Redis 必要时广播删除本地缓存如果业务对一致性要求更高可以通过消息队列或发布订阅把缓存删除事件广播到所有实例让每个实例删除自己的本地缓存。在这次设计里本地缓存主要用于热点读接口允许短时间最终一致因此先采用 TTL Cache-Aside 的方式控制复杂度。五、缓存穿透非法请求不能一路打到数据库缓存解决了正常热点请求的问题但还有一类请求更危险不存在的数据。例如有人不断请求随机资源 IDid 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都不够。系统最终要做到的是热点请求尽量不进数据库非法请求尽量挡在前面缓存过期时也不要让数据库承受瞬时压力。