登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  php教程

PHP Redis 缓存穿透和击穿防护工作流:从空值缓存到互斥锁

来源:17golang原创

时间:2026-06-18 13:23:25 229浏览 收藏

PHP 后端接入 Redis 缓存后,最常见的两个线上风险是缓存穿透和缓存击穿:一个是不停查询不存在的数据,导致请求绕过缓存直打数据库;另一个是热点 Key 过期瞬间,大量请求同时回源。它们看起来都是“数据库突然变慢”,但修复手段并不一样。

本文用一套完整工作流,把 PHP + Redis 的缓存防护拆成几个阶段:先定义缓存 Key 和命中路径,再处理空值缓存,接着对热点 Key 加互斥锁和 TTL 抖动,最后用压测和日志验证修复效果。

目录
  • 目标和边界:要防的是穿透和击穿,不是所有慢查询
  • 全流程总览:请求、缓存、数据库和回填
  • 阶段 1:统一缓存 Key 和命中路径
  • 阶段 2:用空值缓存挡住穿透请求
  • 阶段 3:用互斥锁保护热点 Key 回源
  • 阶段 4:增加 TTL 抖动和回归指标
  • 推荐流程:一次缓存防护怎么落地
  • 容易踩坑:为什么加了缓存仍然打爆数据库
  • 速查表:场景、做法和检查点

目标和边界:要防的是穿透和击穿,不是所有慢查询

先把边界定清楚。缓存穿透通常发生在“不存在的数据”上,比如恶意或异常请求不断查 product:99999999。缓存击穿通常发生在“热点数据刚好过期”的瞬间,比如首页推荐、商品详情、活动库存等。

这篇文章只讨论 PHP 应用层常用的 Redis 防护方案,不展开数据库索引优化、队列削峰和复杂分布式一致性。目标是让读者能写出一条稳定的查询链路:先查缓存,缓存缺失再回源,回源时避免并发打满数据库。

全流程总览:请求、缓存、数据库和回填

先说结论:缓存防护不能只写一个 getset。完整链路应该包含命中判断、空值缓存、互斥回源、结果回填、TTL 抖动和指标观察。

PHP Redis 缓存穿透和击穿防护全流程示意图

阶段 目标 关键动作 检查点
Key 设计 让缓存入口统一 按业务实体生成稳定 Key 同一资源不会生成多个 Key
命中判断 区分命中、空值和缺失 约定空值标记 不存在数据不会反复查库
互斥回源 保护热点 Key 过期瞬间 只有拿到锁的请求查库 并发回源数量可控
TTL 抖动 避免大量 Key 同时过期 给过期时间加随机区间 过期曲线不集中尖峰
回归指标 证明方案有效 看命中率、回源次数、延迟 高峰期间数据库压力下降

阶段 1:统一缓存 Key 和命中路径

目标:先把缓存入口收拢到一个函数里,避免不同业务模块各写一套 Key 和过期时间。

工具选择:Key 生成函数负责命名规范,TTL 函数负责抖动。不要把 product_product:goods: 混着用,否则缓存命中率会被自己打散。

检查点:同一个资源在代码里只有一种 Key;缓存时间在一个地方定义;日志里能看出命中、缺失和回源。

阶段 2:用空值缓存挡住穿透请求

目标:不存在的数据也要短暂缓存,避免每次都去查数据库。

get($key);

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

    if ($cached !== false && $cached !== null) {
        return json_decode($cached, true);
    }

    $row = queryProductFromDb($id);
    if ($row === null) {
        $redis->setex($key, 60, EMPTY_MARK);
        return null;
    }

    $redis->setex($key, ttlWithJitter(600), json_encode($row, JSON_UNESCAPED_UNICODE));
    return $row;
}

工具选择:空值缓存的 TTL 要短,通常几十秒到几分钟即可。这样既能挡住异常请求,也不会让刚新增的数据长期不可见。

检查点:不存在 ID 的请求第一次会查库,后续短时间内直接返回空;数据库日志里不会持续出现同一个不存在 ID 的查询。

阶段 3:用互斥锁保护热点 Key 回源

目标:热点 Key 过期时,只允许少量请求回源,其余请求等待、短暂重试或返回旧值。

PHP Redis 热点 Key 互斥回源和回填流程图

set($lockKey, "1", ["nx", "ex" => 5]);
    if (!$locked) {
        usleep(100000);
        $retry = $redis->get($key);
        if ($retry && $retry !== EMPTY_MARK) {
            return json_decode($retry, true);
        }
        return null;
    }

    try {
        $row = queryProductFromDb($id);
        if ($row === null) {
            $redis->setex($key, 60, EMPTY_MARK);
            return null;
        }
        $redis->setex($key, ttlWithJitter(600), json_encode($row, JSON_UNESCAPED_UNICODE));
        return $row;
    } finally {
        $redis->del($lockKey);
    }
}

工具选择:set nx ex 适合做短时互斥。锁时间要短于业务可接受等待,避免异常请求长期占锁。更复杂的跨进程一致性场景,需要结合业务风险再选择更强方案。

检查点:热点 Key 过期瞬间,数据库回源次数明显下降;请求延迟不会集体冲高;锁 Key 能自动过期,不会卡死链路。

阶段 4:增加 TTL 抖动和回归指标

目标:避免一批 Key 同时过期,并通过指标确认方案真实有效。

 900,
        "normal" => 600,
        default => 300,
    };
    return $base + random_int(0, 120);
}

检查点:观察缓存命中率、数据库查询次数、慢请求数量和 Redis 锁等待次数。修复是否有效,不看“代码写了”,要看高峰期间回源压力有没有下降。

推荐流程:一次缓存防护怎么落地

  1. 先统一缓存 Key 命名,收敛到一个入口函数。
  2. 明确命中、缺失、空值三种状态,不要把空字符串和缺失混为一谈。
  3. 对不存在的数据写短 TTL 空值缓存。
  4. 对热点 Key 的回源路径加短时互斥锁。
  5. 给正常缓存 TTL 加随机抖动,避免同一时间集中失效。
  6. 记录命中率、回源次数、锁等待和请求耗时。
  7. 用压测或高峰日志对比修复前后的数据库压力。

容易踩坑:为什么加了缓存仍然打爆数据库

坑 1:把不存在数据当成普通缺失

如果数据库查不到就直接返回,不写空值缓存,攻击或异常参数仍然会绕过缓存持续查库。

坑 2:所有 Key 用同一个过期时间

批量写入时如果 TTL 完全相同,过期也会集中发生。加入小范围随机抖动,可以把回源压力摊开。

坑 3:锁没有过期时间

互斥锁必须设置短过期时间。否则拿锁请求异常退出后,后续请求会一直认为有人在回源。

坑 4:只看 Redis 命中,不看数据库回源

缓存命中率提升是好信号,但数据库回源次数、慢请求数量和高峰延迟才是最终判断依据。

速查表:场景、做法和检查点

场景 推荐做法 检查点
不存在 ID 高频请求 短 TTL 空值缓存 同一不存在 ID 不再反复查库
热点 Key 过期 短时互斥锁保护回源 同时查库请求数量下降
批量 Key 同时失效 TTL 加随机抖动 过期曲线不再集中尖峰
锁等待过多 缩短回源耗时或返回旧值 请求延迟不明显放大
方案是否有效 看命中率、回源、慢请求 高峰数据库压力下降

总结一下:PHP Redis 缓存防护要把穿透和击穿分开处理。不存在的数据用短 TTL 空值缓存,热点 Key 回源用互斥锁保护,正常缓存用 TTL 抖动分散过期,最后用回源次数和请求耗时验证效果。这样缓存才不只是“加了一层 Redis”,而是真正能保护数据库。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>