2.4 缓存策略:热点商品与本地缓存
场景:Redis 也被打爆的那个下午
库存扣减走 Redis 之后,洪峰期的压力集中到了 Redis 上。第一次大促,商品详情接口每秒 2 万次请求,Redis 的 QPS 直接冲到 8 万,CPU 跑满后命令耗时从 1 毫秒飙到 30 毫秒,整个链路全在等 Redis。分析后发现绝大多数请求读的是同一个秒杀商品的信息,这些数据在活动期间根本不变,完全可以放在离应用更近的地方。这就是多级缓存的出发点。
方案:四级缓存的分工
我们最终的缓存体系分四级,越靠近用户越快、容量越小:
| 层级 | 载体 | 命中耗时 | 失效策略 |
|---|---|---|---|
| CDN 边缘 | CDN 节点 | 10 毫秒内 | 定时刷新 |
| 进程本地 | PHP 内存(数组) | 微秒级 | 进程级失效标记 |
| 集群缓存 | 云数据库 Redis | 1 毫秒级 | TTL 或事件失效 |
| 源数据 | 云数据库 MySQL | 10 毫秒级以上 | 永远以它为准 |
本地缓存是性价比最高的一层。秒杀商品信息在活动期间不变,放在 PHP 进程的静态数组里,读一次内存就完事,连 Redis 都不碰。代价是数据一致性变弱,所以本地缓存只放不可变数据(标题、图片、秒杀价、限购规则),库存余量这种实时数据绝不放,实时数据永远走 Redis。
这条可变与不可变的分界线是整个方案的灵魂。我们刚开始没分这么清,把库存余量也放进了本地缓存,结果扣减发生在 Redis,本地缓存还显示有货,用户看到几百件库存却下单失败,投诉量暴增。想明白这层之后规则就一条:任何会被秒杀链路修改的数据,它的家只能在 Redis 或 MySQL,本地缓存碰都不许碰。这一点在团队里立成规矩之后,缓存相关的故障几乎绝迹。
本地缓存的失效是个难题。一个进程一份缓存,发版或改配置时不可能逐个踢掉。我们的做法是引入一个版本号:每个应用进程定期读 Redis 上的 seckill:cfg_version,发现版本变了就清空本地缓存。改配置时运营先递增版本号,几秒内所有进程自动刷新。这个灵感来自我们曾经踩过的一个坑:有次秒杀价改了之后,十二台机器里有三台因为进程没刷新一直返回旧价格,用户截图对比上了热搜。版本号机制本质上是把多机一致性收敛为一个共享计数器,简单到不会出故障,这也是我们最喜欢的解法类型。
class LocalItemCache
{
private static array $items = [];
private static int $version = -1;
public static function get(int $skuId): ?array
{
$v = (int) Redis::get('seckill:cfg_version');
if ($v !== self::$version) {
self::$items = [];
self::$version = $v;
}
return self::$items[$skuId] ?? null;
}
public static function set(int $skuId, array $item): void
{
self::$items[$skuId] = $item;
}
}
配置:Redis 侧的键设计与缓存击穿防护
我们对 Redis 键做了命名分层,便于批量管理和监控,命名前缀统一也是接入监控告警的前提,脚本可以按前缀模式扫描指标,这一点在 5.2 节大盘设计里会用到:
| 键名模式 | 内容 | TTL |
|---|---|---|
seckill:info:{sku_id} |
商品与活动信息 | 10 分钟 |
seckill:stock:{sku_id} |
实时库存余量 | 活动期常驻 |
seckill:bought:{sku_id} |
已购用户集合 | 活动期常驻 |
seckill:cfg_version |
本地缓存版本号 | 常驻 |
洪峰开场瞬间,所有请求同时查同一个不存在的键会造成缓存击穿。防护手段有两条:一是对查询为空的结果也缓存一个短 TTL 的占位值,避免穿透打到 MySQL;二是热 Key 的取值进程内合并,通过本地缓存兜住第二跳请求。击穿、穿透、雪崩这三种故障形态容易混淆,我们的值班同学必须背熟下面这张区分表,告警时才能第一时间判断处置方向:
| 故障形态 | 触发原因 | 处置手段 |
|---|---|---|
| 缓存击穿 | 单个热 Key 过期瞬间 | 互斥单飞加常驻键 |
| 缓存穿透 | 查询根本不存在的数据 | 空值占位加布隆过滤 |
| 缓存雪崩 | 大量键同时过期 | TTL 随机抖动加分级 |
| 热点倾斜 | 单分片流量过高 | 只读副本与本地缓存 |
秒杀场景下击穿与雪崩最常见,穿透反而少,因为商品 ID 都是运营配好的合法值。但防穿透的手段最便宜,空值占位几行代码,值得顺手做上。
// 缓存击穿防护:空结果也缓存,穿透时回源有单飞机制
public function itemOf(int $skuId): array
{
$cache = Cache::get("seckill:info:{$skuId}");
if ($cache === 'empty') {
return []; // 占位值,不回源
}
if ($cache) {
return $cache;
}
return $this->singleFlight("item:{$skuId}", function () use ($skuId) {
$item = Sku::with('liveSeckill')->find($skuId)?->toArray() ?? [];
Cache::put("seckill:info:{$skuId}", $item ?: 'empty', $item ? 600 : 30);
return $item;
});
}
踩坑:缓存策略翻车的三次经历
坑一:本地缓存清不干净。 早期版本号机制没上线前,运营改了秒杀价,部分机器还在返回旧价格,用户看到两个价格截图对比,客服炸锅。版本号机制上线后,配置变更 5 秒内全量生效,这个坑才算填上。这里有个细节值得注意:版本号的读取要设置短 TTL 缓存,比如一秒内复用,否则版本检查这一步本身就会成为 Redis 的高频读取点,违背了引入本地缓存的初衷。
坑二:热点库存键的读流量被打到同一个分片。 云数据库 Redis 集群版里库存键集中在单个分片,该分片 CPU 首先告警。解决办法是用只读副本分摊读流量,扣减走主节点,读取走副本节点,热点缓解一大半。
坑三:缓存与库存键同时过期。 一开始图省事把商品信息与库存设了同样 TTL,结果同一时刻大量键过期,瞬间流量直击后端。所有 TTL 统一加随机抖动,在基准值上下浮动百分之二十,过期时间错开之后洪峰变得平滑。随机抖动这个手段极其简单但有效,几乎可以无脑应用到所有批量设置的 TTL 上,我们后来把这条写进了团队的缓存使用规范。
坑四:把监控指标也缓存了。 有一次库存看板上的余量不变,值班同学以为活动还没开场,其实是面板读的是带 10 秒 TTL 的缓存接口,真实余量早见底了。监控类接口一律绕开缓存直读 Redis,宁可慢一秒也不能给你一个过时的数字。这个教训后来直接写进了大盘的接口设计规范,监控数据必须新鲜。
小结
多级缓存的核心是可变数据与不可变数据分流:不可变的商品信息尽量往用户端放,可变的库存余量留在 Redis。本地缓存用版本号机制解决一致性,缓存击穿用空值占位与单飞机制挡住。缓存层数不是越多越好,能挡住洪峰的层数就是合适的层数。压测数据可以证明这一点:引入本地缓存之后,Redis 的 QPS 从洪峰期的 8 万降到 1 万出头,CPU 占用回落到安全线以下,而用户侧的响应耗时几乎不变。一次小小的分层调整,比扩容两个 Redis 实例都管用。后来我们把这套分层缓存作为新业务的标配模板,任何热点接入都先问一句「能不能先走本地缓存」,形成习惯后 Redis 的容量压力再也没有紧张过。缓存分层的思想后来还延伸到了前端:App 端做了秒级过期的本地过期缓存兜底,断网时也能展示内容,用户体验又上了一个台阶。衡量缓存架构有没有做好的标准,离用户越近越成功,离数据库越远越成功,这条标准我们一直用到了所有项目里,屡试不爽,渐渐地「缓存分层」成了团队设计评审的黑话:一句话说清方案,彼此就心领神会,沟通效率因此大幅提升。如果非要给缓存分层定一条红线,那就是任何一层缓存都必须有明确的失效手段,宁可少一层,也不能留下无人能清理的脏数据。
