ThinkPHP 8 多级缓存实战:商品详情接口 QPS 从 320 到 8500 的四次调优

2026-10-07 0 416

去年年初接手一个电商 App 的后端。上线三个月流量涨得很快,商品详情接口开始报警。P99 从 300 毫秒涨到 2.2 秒,运维那边一直在扩数据库只读副本,扩到第五个还是撑不住。

打开慢查询日志一看,前十名全是 SELECT * FROM products WHERE id = ?。这个接口每秒接收上万次请求,每次都要查一次数据库,就算有索引也扛不住。

整个优化过程做了四轮,前后两个星期。最后同样的硬件配置下,QPS 从 320 涨到 8500,P99 降到 96 毫秒。这篇文章把每一轮做了什么、遇到什么问题、怎么解决的完整记录下来。

第一轮:最朴素的 Redis 缓存

第一反应肯定是加缓存。商品详情这种读多写少的数据,天然适合缓存。

代码大概长这样:

<?php
namespace appservice;

use appmodelProduct;
use thinkfacadeCache;

class ProductService
{
    public function detail(int $id): array
    {
        $key = "product:detail:{$id}";
        $cached = Cache::get($key);

        if ($cached !== null) {
            return $cached;
        }

        $product = Product::with(['skus', 'specs', 'brand'])
            ->where('id', $id)
            ->where('on_sale', 1)
            ->find();

        if (!$product) {
            return [];
        }

        $data = $product->toArray();
        Cache::set($key, $data, 300);

        return $data;
    }
}

上线之后 QPS 从 320 直接涨到 2800,数据库的读压力降了 90%。看起来效果不错,但紧接着出现了三类问题。

问题一:缓存穿透。有一批商品下架了,但被 App 里的历史收藏链接不断访问,每次查 id 都在数据库里查不到,缓存又不为空,导致每次请求都直接打到数据库。那批 id 一天被打了几十万次,数据库压力反而比加缓存之前还大。

问题二:缓存击穿。有一个爆款商品,缓存刚好过期的那一秒,几百个并发请求全涌到数据库查询,直接把它顶到 CPU 100%。数据库的慢查询里出现了这个商品的查询耗时 4 秒。

问题三:缓存雪崩。上线的时候统一设置了 300 秒 TTL,结果五分钟后有一批商品的缓存同时过期,数据库瞬间被打出一波尖峰,跟没加缓存一样。

这三个问题不处理,光有缓存架构是撑不住的。

第二轮:处理穿透、击穿、雪崩

三类问题的解法各不相同,一个一个来。

缓存穿透的核心是”空结果也要缓存”。查询不到的数据也写一个空值占位,TTL 短一点,比如 60 秒。这样短时间内重复的无效请求都会命中缓存,不会打到数据库。

if (!$product) {
    // 空值缓存,TTL 60 秒,比正常数据短
    Cache::set($key, 'EMPTY', 60);
    return [];
}

// 读到空值标记时直接返回
if ($cached === 'EMPTY') {
    return [];
}

为什么不设成 null?因为 Cache::get() 返回 null 无法和”key 不存在”区分。用一个特殊字符串做标记更清晰。

另外我在 controller 那层加了一道预校验:商品 id 是自增的,一旦超过当前最大 id,直接返回 404 不进入业务逻辑。这一层挡掉了大部分批量扫描性质的恶意请求。

缓存击穿的核心是”只有一个请求去数据库刷新,其他请求等结果”。最常用的做法是分布式锁。

public function detail(int $id): array
{
    $key = "product:detail:{$id}";
    $cached = Cache::get($key);

    if ($cached === 'EMPTY') {
        return [];
    }

    if ($cached !== null) {
        return $cached;
    }

    // 缓存未命中,加锁重建
    $lockKey = "lock:product:{$id}";
    $locked = Cache::store('redis')->handler()
        ->set($lockKey, '1', ['nx', 'ex' => 10]);

    if (!$locked) {
        // 没抢到锁,等 50 毫秒再读一次
        usleep(50000);
        $cached = Cache::get($key);
        if ($cached !== null && $cached !== 'EMPTY') {
            return $cached;
        }
        // 等待超时还没拿到,降级:直接查数据库
        // (这种情况极罕见,允许少量请求打库)
    }

    try {
        $product = Product::with(['skus', 'specs', 'brand'])
            ->where('id', $id)
            ->find();

        if (!$product) {
            Cache::set($key, 'EMPTY', 60);
            return [];
        }

        $data = $product->toArray();
        Cache::set($key, $data, $this->randomTtl(300));
        return $data;
    } finally {
        Cache::store('redis')->handler()->del($lockKey);
    }
}

抢到锁的那个请求去重建缓存,没抢到的请求等一下再读一次缓存。这样就避免了”同一秒内几百个请求全打数据库”的问题。等待的逻辑虽然有一点点写入延迟,但相比让数据库崩掉,代价小得多。

缓存雪崩的核心是”让过期时间不要统一”。我把 TTL 从固定 300 秒改成基础值加随机抖动。

private function randomTtl(int $base): int
{
    // 基础 300 秒,上下浮动 60 秒
    return $base + random_int(-60, 60);
}

这样即使同一批数据在同一时刻写入缓存,过期时间也会分散在两分钟内,极大地削弱了尖峰。

改完这三个问题,QPS 涨到 4200。但接下来遇到了新的瓶颈。

第三轮:多级缓存,本地缓存顶在最前面

QPS 到 4200 的时候,Redis 开始报警。CPU 使用率 70%,网络带宽跑满。虽然 Redis 本身还能撑,但每一毫秒的网络往返都是成本。就算 Redis 延迟只有 1 毫秒,每秒上万次请求累积起来也是不可忽视的开销。

这时候的思路是:能不能把最热的那种几百个商品,直接放在应用进程的内存里?这样一来可以减少一层网络开销。

ThinkPHP 8 里做本地缓存,最简单的就是用一个静态数组。但要考虑几个问题:进程重启后数据丢失(可以接受,会从 Redis 重新加载);多个进程之间数据不同步(这是个问题);内存占用上限(要控制)。

我给本地缓存加了三层保护:容量上限、过期时间、更新广播。

<?php
namespace appcommoncache;

class LocalCache
{
    private static array $store = [];
    private static int $maxSize = 5000;

    public static function get(string $key): mixed
    {
        $item = self::$store[$key] ?? null;
        if ($item === null) return null;

        if ($item['expire'] > 0 && $item['expire'] < time()) {
            unset(self::$store[$key]);
            return null;
        }

        return $item['value'];
    }

    public static function set(string $key, mixed $value, int $ttl = 0): void
    {
        // 容量控制:满了就随机淘汰一批旧数据
        if (count(self::$store) >= self::$maxSize) {
            self::evictOldest(self::$maxSize / 4);
        }

        self::$store[$key] = [
            'value'  => $value,
            'expire' => $ttl > 0 ? time() + $ttl : 0,
        ];
    }

    public static function delete(string $key): void
    {
        unset(self::$store[$key]);
    }

    private static function evictOldest(int $count): void
    {
        $keys = array_keys(self::$store);
        shuffle($keys);
        foreach (array_slice($keys, 0, $count) as $k) {
            unset(self::$store[$k]);
        }
    }
}

容量上限我用 5000 个 key。经过测算,每个商品详情的数据大概 8KB 到 20KB,5000 个满打满算占 100MB,对于一个常驻进程来说能接受。到了上限就随机淘汰四分之一。

然后 ProductService 变成三级:本地缓存 → Redis → 数据库。

public function detail(int $id): array
{
    $key = "product:detail:{$id}";

    // 1. 本地缓存
    $local = LocalCache::get($key);
    if ($local !== null) {
        return $local === 'EMPTY' ? [] : $local;
    }

    // 2. Redis
    $cached = Cache::get($key);
    if ($cached === 'EMPTY') {
        LocalCache::set($key, 'EMPTY', 30);
        return [];
    }
    if ($cached !== null) {
        // 从 Redis 回填本地缓存,TTL 短一点
        LocalCache::set($key, $cached, 30);
        return $cached;
    }

    // 3. 数据库 + 分布式锁
    // ... 同前面的重建逻辑

    // 重建后同时写入本地和 Redis
    LocalCache::set($key, $data, 30);
    Cache::set($key, $data, $this->randomTtl(300));
    return $data;
}

本地缓存的 TTL 我设成 30 秒,比 Redis 短一个数量级。这样即使本地数据和 Redis 有短暂的偏差,最多也就持续 30 秒。对于商品详情这种数据,用户可以接受。

这一轮下来 QPS 从 4200 涨到 7600。Redis 的请求量下降了 78%,因为大多数请求都被本地缓存拦住了。

第四轮:处理本地缓存的一致性问题

多级缓存带来了一致性问题。当商品价格变了的时候,Redis 缓存要删,本地缓存也要删。但本地缓存分布在多个应用进程里,你没法通知别的进程”快把这个 key 删掉”。

常见的方案有三种:把 TTL 缩短到可以接受的偏差范围内;用 Redis 的 pub/sub 广播失效消息;直接不用本地缓存,只留 Redis。

我们选了第一种:把本地缓存的 TTL 设短(30 秒),接受最长 30 秒的数据延迟。对于一个电商商品页,价格延迟 30 秒更新是可接受的——客户下单的时候,会走单独的、不带缓存的实时校验接口。

但如果业务要求”价格改了要立即生效”,那就只能上第二种:用 pub/sub 广播。

<?php
namespace appcommand;

use appcommoncacheLocalCache;
use thinkconsoleCommand;
use thinkconsoleInput;
use thinkconsoleOutput;
use thinkfacadeCache;

class CacheSubscriber extends Command
{
    protected function configure()
    {
        $this->setName('cache:subscribe')
             ->setDescription('订阅缓存失效消息');
    }

    protected function execute(Input $input, Output $output)
    {
        $redis = Cache::store('redis')->handler();
        $redis->setOption(Redis::OPT_READ_TIMEOUT, -1);

        $redis->subscribe(['cache:invalidate'], function ($redis, $channel, $message) use ($output) {
            $keys = json_decode($message, true);
            if (!is_array($keys)) return;

            foreach ($keys as $key) {
                LocalCache::delete($key);
            }

            $output->writeln("Invalidated " . count($keys) . " keys");
        });
    }
}

然后用 php think cache:subscribe 启动一个常驻进程,在商品变更的时候发布消息:

public function updateProduct(int $id, array $data): void
{
    Product::where('id', $id)->update($data);

    $key = "product:detail:{$id}";
    Cache::delete($key);

    Cache::store('redis')->handler()->publish(
        'cache:invalidate',
        json_encode([$key])
    );
}

但这里有个问题:subscribe 是阻塞的,一个进程只能跑一个订阅命令。如果项目是多机部署,每台机器上都要跑一个这个进程。

我们最后用的方案更简单一点:本地缓存的 TTL 缩短到 10 秒,不再做广播。因为广播本身也有延迟(毫秒级到几十毫秒级),而 TTL 10 秒意味着最坏情况下用户看到的数据偏了 10 秒。对于一个商品详情页,这个偏差可以接受。

之所以不用广播,还有一个工程上的原因:增加了一个需要监控的常驻进程,也要考虑进程挂了的时候怎么报警。10 秒 TTL 的方案没有这个维护成本。这是取舍,不是优劣。

压测数据对比

四次优化,同一台 4C8G 的应用服务器 + 同样的数据库配置 + 同样的压测脚本,每秒发起 10000 个请求,统计 QPS、P99 和数据库 QPS。

阶段 接口 QPS P99 延迟 数据库 QPS Redis QPS
无缓存 320 2180ms 320 0
第一轮:Redis 缓存 2840 420ms 78 2840
第二轮:处理穿透/击穿/雪崩 4180 280ms 42 4180
第三轮:加本地缓存 7620 132ms 32 920
第四轮:本地 TTL 缩短 8460 96ms 28 1010

最后一轮 QPS 差别不大,但数据一致性更好。P99 从 132 降到 96 是因为本地缓存的命中率高了,同时 Redis 的负载进一步降低。

数据库的 QPS 从 320 降到了 28,低于万分之一。这下终于不用继续扩数据库副本了。

三个踩过的坑

坑一:Cache::has() 和 Cache::get() 的语义不一致。用 Cache::has($key) 判断 key 是否存在,然后 Cache::get($key) 拿值——这是老套路。但在 ThinkPHP 里,如果 value 是 null 或者空字符串,has() 返回 true 但 get() 返回 null,会产生”key 存在但读不到”的错觉。所以我的代码里始终用 get() 配合一个 EMPTY 标记,从来不用 has()。

坑二:Redis 序列化的兼容性。ThinkPHP 的 Cache 门面默认用 PHP 序列化,跨语言调用(比如 Node 的另一个服务)会有兼容问题。后来我们把所有商品的缓存改成 JSON,用 Cache::store('redis')->handler() 直接调原生 setex 和 get,绕开了序列化。

$redis = Cache::store('redis')->handler();
$redis->setex($key, 300, json_encode($data, JSON_UNESCAPED_UNICODE));

// 读的时候
$raw = $redis->get($key);
$data = $raw ? json_decode($raw, true) : null;

这样不仅跨语言没问题,跨 Redis 版本也没问题,而且数据在看数据库的时候也直观。

坑三:usleep 等待是不对的起点。我最初写”没抢到锁就 usleep 50ms 再读一次”,后来发现 50ms 太长了。商品重建可能只需要 5ms,我等 50ms 属于浪费。但也不能不等——万一重建刚好慢一点,没等到又去查数据库就失去意义了。最后改成”循环等 + 上限”:

$deadline = microtime(true) + 0.1;  // 最多等 100ms
while (microtime(true) < $deadline) {
    usleep(5000);  // 每次等 5ms
    $cached = Cache::get($key);
    if ($cached !== null) {
        return $cached === 'EMPTY' ? [] : $cached;
    }
}
// 超时,降级查库
// ...

这样大多数情况下第一个 5ms 循环就能拿到数据,用户体验比固定等 50ms 好很多。

什么样的场景适合这套方案

这套三级缓存的方案不是所有场景都适合。我用它的时候会先问自己三个问题。

读多写少吗?读写比超过 20:1 的场景值得上多级缓存。如果写操作比例很高,缓存的命中率上不去,反而增加了维护成本。

数据能否容忍几秒的延迟?如果业务要求实时一致(库存、余额),本地缓存就不合适,最多只能上 Redis。可以做本地缓存的通常是商品信息、用户基础资料、文章内容这类。

流量是否集中在少部分数据上?本地缓存的容量有限,只有热点数据才会被反复访问、反复命中。如果是均匀分布的访问(比如每天访问一遍全库),本地缓存的命中率会很差。

三个都”是”,才值得上多级缓存。否则可能一级 Redis 缓存就够了。

写在最后

回头看这次优化,最让我印象深刻的是:每一次流量翻倍,都会逼出一个新的架构问题。一开始加缓存就够,然后要处理穿透击穿雪崩,再然后要减少网络往返,再然后要处理数据一致性。每一个阶段的问题都很清晰,但只有走到那个阶段你才知道下一步要做什么。

不少教程上来就讲”最好的架构是多级缓存,加 pub/sub 广播”,但真实项目里的演进路径往往更朴素——先解决眼前的问题,再评估引入一个复杂度的收益到底值不值。

希望这篇文章能给正在被流量问题困扰的同行一点参考。别着急上最复杂的方案,一步一步来,把每一步的理由想清楚,比一步到位更靠谱。

ThinkPHP 8 多级缓存实战:商品详情接口 QPS 从 320 到 8500 的四次调优
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 多级缓存实战:商品详情接口 QPS 从 320 到 8500 的四次调优 https://www.taomawang.com/server/thinkphp/2902.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务