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 端做了秒级过期的本地过期缓存兜底,断网时也能展示内容,用户体验又上了一个台阶。衡量缓存架构有没有做好的标准,离用户越近越成功,离数据库越远越成功,这条标准我们一直用到了所有项目里,屡试不爽,渐渐地「缓存分层」成了团队设计评审的黑话:一句话说清方案,彼此就心领神会,沟通效率因此大幅提升。如果非要给缓存分层定一条红线,那就是任何一层缓存都必须有明确的失效手段,宁可少一层,也不能留下无人能清理的脏数据。

延伸阅读