在 电商网站业务中Redis 缓存是抗流量的核心手段之一但缓存穿透、击穿、雪崩这三个经典故障场景一旦触发会直接压垮数据库。开发人员必须要事先了解才能做到开发防御这样遇到事情才能稳住大局注后续的示例代码为了简化逻辑直接在方法内创建 Redis 连接。生产环境请使用单例模式、连接池管理 Redis 连接避免频繁创建销毁连接造成性能损耗。一、缓存穿透、击穿、雪崩三者概念通俗解释你可以把 Redis 想象成挡在数据库前面的一道防火墙缓存层请求先打缓存缓存拿不到才去打 DB穿透数据本身不存在缓存和 DB 都没有这个数据请求直接穿过缓存每次过来都直达数据库触发场景例如恶意攻击、请求数据库不存在的非法参数击穿数据是存在的只是缓存里单个热点 key 刚好过期了大量请求一下冲破这个点打到数据库触发场景例如秒杀爆款商品 key平时在缓存扛 QPSTTL 到点刚好过期一瞬间几千请求冲进数据库(这一瞬间还没生成的缓存来抗请求)雪崩大批量 key 同一时间集体过期或 Redis 宕机像雪崩一样一堆请求同时压垮数据库成片崩塌触发场景例如批量设置缓存的时候TTL 都设成 30 分钟30 分钟后几百上千个 key 一起失效二、缓存穿透Cache Penetration2.1 问题描述用户查询一条数据库里根本不存在的数据Redis 缓存里自然也没有请求会直接穿透缓存层打到数据库。如果被恶意攻击者用大量不存在的 ID 循环请求数据库会被瞬间打满。2.2 业务场景举例商品详情页查询电商站商品详情接口例如/goods/detail?goods_idxxx正常 goods_id 对应真实商品。攻击者构造 goods_id-1、goods_id999999999 等不存在的商品 ID每秒发起上千次请求由于缓存无记录、数据库也查不到每次请求都会直达 MySQL很快造成数据库连接数耗尽。2.3 解决方案方案 1缓存空值实现成本最低也是最常用当数据库查询返回空时在 Redis 中写入一个空值如 ___NULL___ 或特殊标记并设置较短过期时间后续相同请求直接命中缓存空值不再打 DB。实现思路用php代码举例// 商品详情查询 - 空值缓存实现 public function getGoodsDetail(int $goodsId) { $redis new Redis(); $redis-connect(127.0.0.1, 6379); $cacheKey goods:detail: . $goodsId; // 1. 先查缓存 $cacheData $redis-get($cacheKey); if ($cacheData ! false) { // 命中缓存判断是否为空值标记 return $cacheData ___NULL___ ? [] : json_decode($cacheData, true); } // 2. 缓存未命中查数据库 $dbData $this-goodsModel-findById($goodsId); if ($dbData) { // 真实数据缓存1小时 $redis-setex($cacheKey, 3600, json_encode($dbData)); } else { // 空值缓存5分钟过期避免长期占用内存 $redis-setex($cacheKey, 300, ___NULL___); } return $dbData; }注意事项1、空值过期时间不能太长建议 30s~5min否则数据新增后如果未删除缓存可能会出现长时间脏读。2、缓存空值方案无法应对高强度恶意攻击因为攻击者用大量随机不存在的 ID 请求时会在 Redis 中生成海量空值缓存造成内存污染。此场景需搭配布隆过滤器或接口限流使用。方案 2布隆过滤器大数据量防攻击1、将所有存在的商品 ID 预先存入布隆过滤器 并且 新增商品都要写入布隆过滤器2、请求先经过过滤器判断如果过滤器说不存在直接返回根本不走到 Redis 和数据库层。适合百万级以上数据量、恶意攻击频繁的场景3、布隆过滤器真正收益在于无效请求被截断省去后续所有链路拿爬虫遍历非法 ID 场景举例没有布隆非法 ID → GETredis→ 缓存miss → 查 DB → 写空标记 SETEX 一次无效请求2 次 Redis 命令 一次 DB 查询DB 是最贵的环节有布隆非法 ID → BF.EXISTS 返回 0 → 直接 return没有 GET、没有 DB、没有 SETEX这才是性能差的来源。代价是每一个正常用户都多一次 BF.EXISTS 判断所以布隆过滤器比较适合的是无效请求占比很高的场景。原生 Redis 不支持 BF.EXISTS 命令需安装 RedisBloom 扩展模块后方可使用布隆过滤器的核心特性判断「不存在」则 100% 不存在判断「存在」时有概率误判实际不存在误判率可通过位数组长度、哈希函数数量调整。存在误判意味着少量非法 ID 仍会穿透到缓存层但不会影响正常数据的访问。// 布隆过滤器前置校验 public function getGoodsDetail(int $goodsId) { $redis new Redis(); $redis-connect(127.0.0.1, 6379); // 1. 布隆过滤器快速拦截 if (!$redis-rawCommand(BF.EXISTS, goods_bloom_filter, $goodsId)) { return null; // 一定不存在直接返回 } // 2. 正常走缓存以及缓存不存在走数据库流程同方案1代码 }方案 3接口层参数合法性校验在最外层加基础校验比如 ID 必须大于 0、格式必须为正整数直接拦截明显非法请求这是成本最低的第一道防线。三、缓存击穿Cache Breakdown3.1 问题描述某一个热点 key比如爆款商品、置顶文章承载着极高并发当这个 key 刚好过期失效的瞬间成千上万的请求会同时击穿缓存全部涌向数据库造成数据库压力骤增甚至宕机。3.2 业务场景举例秒杀活动商品页限时秒杀商品详情页活动开始后每秒有 5000 次请求访问同一个商品 ID。缓存设置了 30 分钟过期当缓存到期那一刻5000 个并发请求同时发现缓存失效一起去查数据库MySQL 瞬间被打挂。3.3 解决方案方案 1互斥锁Mutex Lock保证只有一个请求打DB缓存失效时只允许第一个请求去查数据库并回写缓存其他请求等待重试保证只有一个请求打 DB。PHP 中用 Redis 的setnx实现分布式锁。php代码参考示例public function getSeckillGoods(int $goodsId) { $redis new Redis(); $redis-connect(127.0.0.1, 6379); $cacheKey seckill:goods: . $goodsId; $lockKey lock:seckill: . $goodsId; // 1. 查缓存 $cacheData $redis-get($cacheKey); if ($cacheData) { return json_decode($cacheData, true); } // 2. 缓存失效尝试加锁 // setnx只有key不存在时才设置成功原子操作 $lockSuccess $redis-set($lockKey, 1, [nx, ex 5]); // 锁5秒过期防死锁 if ($lockSuccess) { try { // 3. 拿到锁查数据库并回写缓存 $dbData $this-goodsModel-findById($goodsId); if ($dbData) { $redis-setex($cacheKey, 1800, json_encode($dbData)); } return $dbData; } finally { // 4. 释放锁 $redis-del($lockKey); } } else { // 5. 没拿到锁休眠100毫秒后重试 usleep(100000); return $this-getSeckillGoods($goodsId); // 递归重试实际项目建议加重试次数限制 } }方案 2热点数据永不过期对于确定的热点数据如秒杀主商品物理上不设置过期时间(或设置一个很久的时间)由后台异步脚本定时更新缓存。永远不会出现过期击穿的问题。// 写入时不设过期时间 $redis-set(seckill:goods: . $goodsId, json_encode($dbData)); // 后台定时任务每分钟主动更新缓存 public function refreshHotGoodsCache(int $goodsId) { $dbData $this-goodsModel-findById($goodsId); $this-redis-set(seckill:goods: . $goodsId, json_encode($dbData)); }方案 3逻辑过期缓存中不设置物理 TTL而是在 value 里存一个过期时间字段。读取时判断逻辑是否过期如果快过期了触发异步更新缓存主线程直接返回旧数据用户无感知。Swoole/Hyperf 等常驻协程框架可直接启动协程异步更新缓存传统 PHP-FPM 模式将更新任务投递到消息队列Redis List/RabbitMQ由独立的消费进程异步更新缓存。例如value结构加个过期时间{ data: {}, // 真实业务数据 expire_ts: 1735689600, // 逻辑过期时间戳秒 update_ts: 1735603200, // 上次更新时间 is_refreshing: false // 刷新标记防止并发大量异步线程 }刷新条件比如提前 30s 刷新expire_ts - now 30 → 判定为需要刷新这个阈值根据数据变更容忍度进行异步调整。四、缓存雪崩Cache Avalanche4.1 问题描述两种情况会引发雪崩大面积 key 同时过期比如系统启动时批量预热了 10 万条商品缓存统一设置 1 小时过期1 小时后这 10 万条同时失效所有请求瞬间打向数据库。Redis 节点故障单实例 Redis 宕机所有缓存请求全部失败流量直接压到数据库。4.2 典型业务场景首页分类商品列表假设电商首页有 20 个商品分类每个分类下有 50 个商品凌晨统一刷新了所有分类缓存统一设置 2 小时过期。上午 10 点流量高峰时刚好所有缓存同时到期数据库瞬间承受平时 10 倍的查询量连接池爆满。4.3 解决方案方案 1过期时间加随机偏移量解决同时过期在基础过期时间上增加一个随机数让各个 key 的过期时间分散开避免同一时刻大面积失效。这是成本最低、见效最明显的方案。// 批量缓存写入时过期时间打散 public function setCategoryCache(int $cateId, array $data) { $redis new Redis(); $redis-connect(127.0.0.1, 6379); $cacheKey category:goods: . $cateId; // 比如基础7200秒 0~300秒随机偏移避免集中过期 $ttl 7200 rand(0, 300); $redis-setex($cacheKey, $ttl, json_encode($data)); }方案 2Redis 高可用集群使用 Redis 主从 哨兵模式或 Redis Cluster 集群避免单点故障导致整体缓存不可用方案 3服务熔断与降级在 PHP 业务层增加熔断机制当检测到数据库连接数、慢查询数超过阈值时暂时关闭非核心接口的缓存穿透查询直接返回默认降级数据如 加载中、本地缓存旧数据保护数据库不被打垮。以下为简易计数版熔断器实现。生产级标准熔断器包含「关闭→打开→半打开」三种状态关闭正常放行请求统计错误率打开错误率达标后熔断直接返回降级数据半打开熔断一段时间后放行少量请求试探恢复成功则关闭熔断失败则继续打开。 有条件可接入成熟的熔断组件避免自行实现的边界问题。public function getCategoryList(int $cateId) { // 简单熔断器统计10秒内DB错误次数 $fuseKey fuse:category:error_count; $errorCount $this-redis-get($fuseKey); if ($errorCount 50) { // 熔断触发返回降级数据本地内存/默认数据 return $this-getFallbackData($cateId); } try { // 正常缓存DB查询逻辑 // ... } catch (\Exception $e) { // 错误计数1 $this-redis-incr($fuseKey); $this-redis-expire($fuseKey, 10); throw $e; } }五、统一封装缓存服务类实际项目中建议封装统一的缓存服务类统一处理穿透、击穿逻辑业务层无需关心底层实现所有读缓存方法默认内置空值缓存逻辑(防御缓存穿透)热点数据查询方法提供锁机制开关防御缓存击穿批量写入缓存自动追加随机 TTL防御缓存雪崩统一的异常捕获与降级兜底统一缓存 Key 命名规范业务模块标识避免 Key 冲突技术进阶没有捷径但有高效方法。 本号专注分享实战干货、避坑指南、性能调优、面试重难点 每一篇都是亲手落地测试帮你少走弯路、快速提升核心竞争力。❤️ 点赞、在看、收藏、关注一键安排关注账户第一时间获取硬核技术干货下期不见不散