电商高并发必备:Redis 缓存穿透、击穿、雪崩防御方案

发布时间:2026/11/6 7:54:07
电商高并发必备:Redis 缓存穿透、击穿、雪崩防御方案

在 电商网站业务中,Redis 缓存是抗流量的核心手段之一,但缓存穿透、击穿、雪崩这三个经典故障场景,一旦触发会直接压垮数据库。开发人员必须要事先了解,才能做到开发防御,这样遇到事情才能稳住大局;

注:后续的示例代码为了简化逻辑,直接在方法内创建 Redis 连接。生产环境请使用单例模式、连接池管理 Redis 连接,避免频繁创建销毁连接造成性能损耗。

一、缓存穿透、击穿、雪崩三者概念通俗解释

你可以把 Redis 想象成挡在数据库前面的一道防火墙(缓存层),请求先打缓存,缓存拿不到才去打 DB:

  • 穿透:数据本身不存在,缓存和 DB 都没有这个数据,请求直接穿过缓存,每次过来都直达数据库;触发场景:例如恶意攻击、请求数据库不存在的非法参数;
  • 击穿:数据是存在的,只是缓存里单个热点 key 刚好过期了,大量请求一下冲破这个点,打到数据库;触发场景:例如秒杀爆款商品 key,平时在缓存扛 QPS,TTL 到点刚好过期;一瞬间几千请求冲进数据库(这一瞬间还没生成的缓存来抗请求);
  • 雪崩:大批量 key 同一时间集体过期(或 Redis 宕机),像雪崩一样一堆请求同时压垮数据库(成片崩塌);触发场景:例如批量设置缓存的时候,TTL 都设成 30 分钟,30 分钟后几百上千个 key 一起失效

二、缓存穿透(Cache Penetration)

2.1 问题描述

用户查询一条数据库里根本不存在的数据,Redis 缓存里自然也没有,请求会直接穿透缓存层打到数据库。如果被恶意攻击者用大量不存在的 ID 循环请求,数据库会被瞬间打满。

2.2 业务场景举例:商品详情页查询

电商站商品详情接口,例如:/goods/detail?goods_id=xxx,正常 goods_id 对应真实商品。攻击者构造 `goods_id=-1`、`goods_id=999999999` 等不存在的商品 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 → GET(redis)→ 缓存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 Breakdown)

3.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 Avalanche)

4.1 问题描述

两种情况会引发雪崩:

  1. 大面积 key 同时过期:比如系统启动时批量预热了 10 万条商品缓存,统一设置 1 小时过期,1 小时后这 10 万条同时失效,所有请求瞬间打向数据库。
  2. 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)); }
方案 2:Redis 高可用集群

使用 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 冲突;

技术进阶没有捷径,但有高效方法。 本号专注分享实战干货、避坑指南、性能调优、面试重难点, 每一篇都是亲手落地测试,帮你少走弯路、快速提升核心竞争力。

❤️ 点赞、在看、收藏、关注一键安排

关注账户,第一时间获取硬核技术干货,下期不见不散!