3.3 缓存层:Redis 缓存热点响应
场景:八成流量只读两成数据
限流与熔断上线后,我们又发现一个现象:QPS 曲线里八成的请求集中在少数查询接口上,而它们返回的数据变化极慢(库存余量、商品目录、门店列表这类)。这些请求被放行到后端,打满数据库连接,只为反复算同样的答案。压测显示,只要把这些热点响应挡在网关层,后端负载直接掉六成。
网关缓存与业务层缓存职责不同:业务缓存为自己优化,网关缓存为整条链路优化,它天然具备跨调用方复用与统一 TTL 管理两个优势。我们的设计原则是网关只缓存声明可缓存的接口,缓存键包含全部影响结果的维度,绝不缓存任何含个性化数据的响应。
还有一个决策要提前讲:缓存放在网关层还是业务层。答案不是二选一,而是各管一段。业务层的缓存为自己服务(如详情页的本地缓存),网关层的缓存为链路服务(跨调用方共享热点响应)。两者叠加时 TTL 要外短内长:网关 TTL 短保证可控,业务 TTL 长兜底。这个分层在后面的成本复盘中也立了功。
方案:键设计与击穿防护
缓存方案的核心全在键设计上。键里必须包含影响响应结果的一切维度:接口路径、版本、请求参数哈希、调用方级别(不同级别可能返回不同字段集)。包含这些维度后,跨调用方共享是安全的(公共数据),个性化维度则单独进键,互不串味:
| 设计点 | 我们的选择 | 踩坑后修正 |
|---|---|---|
| 键维度 | 路径加参数哈希加级别 | 初版漏了级别 |
| 过期策略 | 接口契约里声明 TTL | 初版全局统一 60 秒 |
| 击穿防护 | 热点 key 加互斥锁 | 初版无锁,缓存重建叠加 |
| 穿透防护 | 空结果也缓存 30 秒 | 初版不缓存空结果 |
| 雪崩防护 | TTL 加随机抖动 | 初版固定 TTL |
穿透与雪崩是缓存系统的两个经典病。穿透的意思是查一个不存在的 key,请求穿透缓存直击后端,恶意调用者最喜欢这招;解法是空结果也短暂缓存。雪崩是大量 key 同时过期,请求瞬间全部回源;解法是 TTL 加百分之十到三十的随机抖动,让过期时间错开。击穿是第三个病:单个热点 key 突然过期,瞬间大量请求同时回源重建;解法是互斥锁,谁抢到谁重建,其他人等结果。
缓存的价值要用数据说话。上线一个月后我们统计:缓存命中率百分之六十八,后端 QPS 从峰值 1800 降到 600 不到,接口平均时延从 45 毫秒降到 8 毫秒,P99 从 300 毫秒降到 60 毫秒。Redis 月租的成本与省下的后端机器相比,一比五。缓存是这整套系统里投入产出比最高的模块,没有之一。
配置:缓存中间件骨架
缓存中间件的逻辑章节清晰:算键、查缓存、回源、回写。注意回源时的互斥锁只在热点 key 命中冲突时才加,普通 key 直接放行,避免锁成为瓶颈。这段骨架与限流、鉴权共用同一个 Redis 实例但走不同 db 库位,互不影响:
<?php
// 响应缓存(骨架)
public function handle($request, callable $next)
{
$key = $this->cacheKey($request); // 含全部维度
$hit = $this->redis->get($key);
if ($hit !== null) {
return $this->restore($hit); // 命中直接返回
}
// 热点 key 互斥重建,防击穿
$lockKey = $key . ':lock';
if ($this->redis->set($lockKey, 1, 'EX', 3, 'NX')) {
$resp = $next($request);
$ttl = $this->spec['cache'] + mt_rand(0, $ttl / 3); // 抗雪崩抖动
$this->redis->setex($key, $ttl, (string) $resp->getBody());
$this->redis->del($lockKey);
return $resp;
}
// 抢锁失败:短暂自旋等结果
usleep(50000);
return $next($request);
}
踩坑:缓存里钻出来的事故
第一个坑是初版没加级别维度,付费与试用调用方拿到的字段集不同,结果试用客户命中了付费客户的缓存,看到了本不该看到的字段。所幸是内部巡检发现。键设计的原则从此刻入团队骨髓:任何影响结果的字段,宁错杀进键,不放过出键。
第二个坑是缓存与灰度的交互。灰度期间新旧后端并存,缓存里全是旧后端响应,灰度客户拿到旧结果,整组灰度验证全部失效,排查了一下午才发现。修复是缓存键加灰度版本号,灰度名单一变更,旧缓存自动失效。所有缓存场景先问一句:结果是否依赖某个随时可能变的维度。
第三个坑是缓存成了存储。有个同事图省事把缓存 TTL 设成了永不过期,说是数据稳定。三个月后数据源迁移,缓存里全是历史数据,客户看到的门店列表里夹着已经关店的门店。规范是 TTL 上限一律不超过 24 小时,永不过期这个词在缓存系统里不存在。
第四个坑是序列化体积。初版直接 json 序列化整个响应对象,缓存膨胀到几百 KB 一个,Redis 内存告警。压缩成只存 body 字符串加几个元数据头后,降到了几 KB。工具善其事,必先利其器。后来统一封装了 cacheSet 与 cacheGet 两个函数,体积与抖动逻辑内化,业务侧只传键值与 TTL,再没出现过这类问题。
第五个坑是缓存预热。每次大促前我们从 CLS 拉近一周的热点参数列表,提前把缓存灌满(脚本凌晨跑),大促开始时缓存命中率直接从零拉到八成,避免了开抢瞬间的回源尖峰。预热用的是低峰期空闲算力,成本几乎为零。缓存领域有句老话:临阵磨枪不如未雨绸缪,大促这场战役在十天前就打赢了。
缓存与鉴权的关系再强调一遍。有些团队为了性能把鉴权结果也缓存了,这是绝对的红线:缓存命中意味着鉴权结论可能已经过期,停用的 Key 仍可继续调用。我们的缓存键里刻意包含调用方级别,但从不缓存通过与否这个布尔值。缓存是性能工具,安全逻辑必须每次实时计算,这条线宁可慢一点也不能越界。
还有个小技巧值得分享。对于列表类接口,我们允许客户端传 If-None-Match 头,网关返回 ETag,内容没变时回 304 而不是完整 body。这个 HTTP 标准机制让大列表接口的带宽降了四成,手机客户端尤其受益。标准协议里早已备好的糖果,很多团队居然从来没用过。
还有一条关于热点发现的技巧。缓存策略上线后,怎么知道哪些接口值得缓存?我们直接从 CLS 的历史调用日志做统计聚合(第 4.1 节),按调用量与响应变化频率排个序,调用量前百分之二十、内容变化频率低于每小时一次的接口自动进入缓存候选清单,每周评审一次。识别热点的活交给数据,判断缓不缓的活交给人与业务方,分工明确。缓存清单的维护和代码一样进 git,每次增删有 PR review。
缓存失效的精细化是进阶课题。被动等 TTL 过期只够及格,主动失效才是刚需:后端管理员改了某个商品,网关侧对应缓存必须立刻清掉。我们提供的失效通道是后端调一个内部接口,按接口维度下发失效指令,网关收到后按前缀批量删除(先 SCAN 收集再删,避免 KEYS 阻塞)。主动失效的难点在于漏删:后端忘调或者调用失败,脏数据就留在缓存里直到自然过期。我们的兜底是缓存里存入生成时间戳,热点监控对超龄缓存自动告警,双保险才算把这件事按住。
最后说说缓存的成本账。云数据库 Redis 的内存是真金白银,我们每月review一次缓存内存占用:大 key(超过 10KB)逐个排查是否值得,低命中率的缓存接口直接取消缓存声明。有一次发现某接口缓存了三个月命中率千分之三,占着内存没人看,果断移除。缓存也讲究投入产出比,不是缓存越多越好,而是缓存到刀刃上。监控面板上命中率与占用两条曲线并排看,决策才有依据。
小结
缓存是网关里性价比最高的模块:代码不多,后端负载直降六成,响应时延从平均 45 毫秒降到 8 毫秒。但缓存也是最容易出隐蔽事故的地方,凡是绕过它的规则(键漏维度、TTL 乱设、灰度失效)最后都会以线上事故回访。记住缓存设计的四防:防穿透(缓存空值)、防击穿(互斥锁)、防雪崩(TTL 抖动)、防串味(键含全部维度)。
最后给一把丈量缓存价值的尺子:把命中率、平均时延、Redis 内存占用三条曲线放在同一张面板,每周看一次。命中率涨而内存稳,说明缓存策略健康;命中率涨而内存也暴涨,说明缓了一堆冷数据,该清理了。缓存系统像花园,想花开得好,定期除草比不停浇水更重要。
最后强调一下缓存与合约的联动:接口字段加减直接影响缓存键的维度。我们立了规矩,后端改返回结构必须提前通知网关,否则键里的版本号不更新,缓存就会出旧结构给新客户端。契约与缓存是上下游关系,沟通不到位,缓存系统第一个背锅。
补一条缓存验收的习惯:每次缓存模块改动上线,盯一周的三曲线面板(命中率、平均时延、Redis 内存)才算过关。上线不吃瓜,一周见真章,这个观察期制度帮我们先后抓到过三次小的缓存回归。
