Redis 缓存实战:穿透、击穿、雪崩三大问题解决方案

Redis 缓存实战:穿透、击穿、雪崩三大问题解决方案

admin
2026-07-24 / 0 评论 / 0 阅读

前言

Redis 作为缓存使用时,会遇到三大经典问题:缓存穿透、缓存击穿和缓存雪崩。这三个问题在实际生产中频繁出现,处理不当可能导致数据库被压垮甚至服务不可用。本文将深入分析每个问题的成因并给出完整解决方案。

一、缓存穿透

1.1 问题描述

用户请求的数据在缓存和数据库中都不存在,每次请求都直接打到数据库。常见于恶意攻击(用不存在的 ID 大量请求)或业务异常数据。

请求 → 缓存MISS → 数据库MISS → 返回空
  ↑                                    │
  └──── 不断重复 ──────────────────────┘

1.2 危害

  • 数据库承受大量无效查询
  • 如果是恶意攻击,可能打垮数据库
  • 浪费服务器资源

1.3 解决方案

方案一:缓存空值

function getUser($id) {
    $cacheKey = "user:{$id}";
    $data = $redis->get($cacheKey);
    
    if ($data !== false) {
        // 命中缓存(包括空值缓存)
        return $data === 'NULL' ? null : json_decode($data, true);
    }
    
    // 查数据库
    $data = $db->query("SELECT * FROM users WHERE id = ?", [$id]);
    
    if (empty($data)) {
        // 缓存空值,设置较短过期时间(60秒)
        $redis->setex($cacheKey, 60, 'NULL');
        return null;
    }
    
    $redis->setex($cacheKey, 3600, json_encode($data));
    return $data;
}

优点:实现简单
缺点:可能缓存大量空值,占用内存;数据后来新增了也不会被感知(除非过期)

方案二:布隆过滤器(Bloom Filter)

// 系统初始化时,将所有存在的ID加入布隆过滤器
function initBloomFilter() {
    $redis->del('bloom:user_ids');
    $userIds = $db->query("SELECT id FROM users");
    foreach ($userIds as $id) {
        // 使用多个哈希函数
        bloomAdd('bloom:user_ids', $id);
    }
}

// 检查ID是否可能存在
function bloomAdd($key, $value) {
    $hashes = multiHash($value);
    foreach ($hashes as $h) {
        $redis->setBit($key, $h, 1);
    }
}

function bloomExists($key, $value) {
    $hashes = multiHash($value);
    foreach ($hashes as $h) {
        if ($redis->getBit($key, $h) == 0) {
            return false; // 一定不存在
        }
    }
    return true; // 可能存在(有误判率)
}

// 请求处理
function getUser($id) {
    // 先过布隆过滤器
    if (!bloomExists('bloom:user_ids', $id)) {
        return null; // 一定不存在,直接返回
    }
    
    // 再查缓存和数据库
    // ...
}

方案三:接口层参数校验

// 在入口处拦截非法参数
function getUser($id) {
    // ID 必须是正整数
    if (!is_numeric($id) || $id <= 0 || $id > PHP_INT_MAX) {
        throw new InvalidArgumentException('Invalid user ID');
    }
    
    // 其他业务规则校验
    if (strlen($id) > 10) {
        throw new InvalidArgumentException('ID too long');
    }
    
    // 正常流程...
}

二、缓存击穿

2.1 问题描述

某个热点 key 突然过期(或被删除),大量并发请求同时访问这个 key,全部穿透到数据库。

热点key过期
     │
     ├──→ 请求1 → 缓存MISS → 查DB ──┐
     ├──→ 请求2 → 缓存MISS → 查DB ──┤
     ├──→ 请求3 → 缓存MISS → 查DB ──┤
     ├──→ 请求N → 缓存MISS → 查DB ──┤
     │                               │
     └──────── 数据库被压垮 ◄────────┘

2.2 解决方案

方案一:互斥锁(推荐)

function getHotData($key) {
    $data = $redis->get($key);
    if ($data !== false) {
        return json_decode($data, true);
    }
    
    // 获取互斥锁
    $lockKey = "lock:{$key}";
    $lockValue = uniqid('', true);
    
    // SET NX EX 尝试加锁
    $locked = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 10]);
    
    if ($locked) {
        try {
            // 再次检查缓存(双重检查)
            $data = $redis->get($key);
            if ($data !== false) {
                return json_decode($data, true);
            }
            
            // 查数据库
            $data = $db->query("SELECT * FROM hot_table WHERE key_name = ?", [$key]);
            
            // 写入缓存
            $redis->setex($key, 3600, json_encode($data));
            
            return $data;
        } finally {
            // 释放锁(Lua脚本保证原子性)
            $lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            $redis->eval($lua, [$lockKey, $lockValue], 1);
        }
    } else {
        // 没拿到锁,短暂等待后重试
        usleep(100000); // 100ms
        return getHotData($key); // 递归重试
    }
}

方案二:逻辑过期

不给 key 设置 TTL,而是在 value 中存储逻辑过期时间。后台异步刷新:

function getWithLogicalExpire($key, $expireSeconds) {
    $data = $redis->get($key);
    if ($data === false) {
        return null; // 首次加载,需要手动初始化
    }
    
    $cached = json_decode($data, true);
    $now = time();
    
    // 未逻辑过期,直接返回
    if ($cached['expire_at'] > $now) {
        return $cached['data'];
    }
    
    // 已逻辑过期,尝试获取锁刷新
    $lockKey = "lock:refresh:{$key}";
    $locked = $redis->set($lockKey, '1', ['NX', 'EX' => 10]);
    
    if ($locked) {
        // 后台异步刷新(非阻塞)
        // 实际项目中可以用队列或协程
        go(function() use ($key, $expireSeconds) {
            $data = $db->query("SELECT * FROM table WHERE key_name = ?", [$key]);
            $redis->set($key, json_encode([
                'data' => $data,
                'expire_at' => time() + $expireSeconds,
            ]));
            $redis->del("lock:refresh:{$key}");
        });
    }
    
    // 无论是否刷新,先返回旧数据(不阻塞用户)
    return $cached['data'];
}

方案三:永不过期 + 主动更新

// 热点数据不设置过期时间
$redis->set('hot:ranking', json_encode($data));
// 不设置 TTL

// 数据变更时主动更新缓存
function updateRanking() {
    $data = $db->query("SELECT * FROM ranking ORDER BY score DESC LIMIT 100");
    $redis->set('hot:ranking', json_encode($data));
    // 仍然不设置 TTL
}

// 定时任务刷新
// crontab: */10 * * * * php artisan ranking:refresh

三、缓存雪崩

3.1 问题描述

大量 key 在同一时间集中过期,或者 Redis 宕机,导致大量请求同时打到数据库。

大量key同时过期
       │
       ├──→ 请求1 → MISS → 查DB ──┐
       ├──→ 请求2 → MISS → 查DB ──┤
       ├──→ 请求3 → MISS → 查DB ──┤
       ├──→ 请求N → MISS → 查DB ──┤
       │                           │
       └──── 数据库被压垮 ◄────────┘

3.2 与缓存击穿的区别

对比缓存击穿缓存雪崩
影响范围单个热点 key大量 key
原因热点 key 过期大量 key 同时过期 / Redis 宕机
量级

3.3 解决方案

方案一:随机过期时间

function cacheData($key, $data, $baseTTL = 3600) {
    // 在基础TTL上加随机偏移,避免同时过期
    $randomTTL = $baseTTL + mt_rand(0, 600); // 0~10分钟随机
    $redis->setex($key, $randomTTL, json_encode($data));
}

// 批量缓存时尤其重要
function batchCache($items) {
    foreach ($items as $key => $value) {
        $ttl = 3600 + mt_rand(0, 1800); // 1~1.5小时随机
        $redis->setex($key, $ttl, json_encode($value));
    }
}

方案二:多级缓存

function getWithMultiLevel($key) {
    // L1: 本地缓存(进程内)
    $data = $localCache->get($key);
    if ($data !== null) {
        return $data;
    }
    
    // L2: Redis 缓存
    $data = $redis->get($key);
    if ($data !== false) {
        $localCache->set($key, $data, 60); // 本地缓存60秒
        return json_decode($data, true);
    }
    
    // L3: 数据库
    $data = $db->query("SELECT * FROM table WHERE key_name = ?", [$key]);
    if ($data) {
        $redis->setex($key, 3600 + mt_rand(0, 600), json_encode($data));
        $localCache->set($key, $data, 60);
    }
    
    return $data;
}

方案三:熔断降级

function getProductList($categoryId) {
    try {
        $data = $redis->get("products:{$categoryId}");
        if ($data !== false) {
            return json_decode($data, true);
        }
        
        // 数据库查询带超时和限流
        $data = $db->query("SELECT * FROM products WHERE category_id = ?", 
            [$categoryId], ['timeout' => 1]);
        
        $redis->setex("products:{$categoryId}", 
            3600 + mt_rand(0, 600), 
            json_encode($data));
        
        return $data;
        
    } catch (Exception $e) {
        // 降级:返回默认数据或静态数据
        return getDefaultProductList();
    }
}

方案四:Redis 高可用

# Redis Sentinel 哨兵模式自动故障转移
sentinel monitor mymaster 192.168.1.10 6379 2
sentinel down-after-milliseconds mymaster 30000
sentinel failover-timeout mymaster 180000

# Redis Cluster 集群模式
# 数据分散在多个节点,单节点宕机不影响整体

四、综合对比

问题本质核心方案推荐组合
缓存穿透查询不存在数据布隆过滤器 + 空值缓存布隆过滤器 + 短TTL空值
缓存击穿热点key过期互斥锁 + 双重检查互斥锁 + 逻辑过期
缓存雪崩大量key同时过期随机TTL + 多级缓存随机TTL + 熔断降级

五、完整防护代码示例

class CacheService {
    private $redis;
    private $db;
    private $localCache = [];
    
    /**
     * 安全缓存读取(防穿透+击穿+雪崩)
     */
    public function get($key, $dbQuery, $ttl = 3600) {
        // 1. 本地缓存
        if (isset($this->localCache[$key])) {
            return $this->localCache[$key];
        }
        
        // 2. Redis 缓存
        $data = $this->redis->get($key);
        if ($data !== false) {
            if ($data === 'NULL') return null;
            $this->localCache[$key] = json_decode($data, true);
            return $this->localCache[$key];
        }
        
        // 3. 互斥锁防击穿
        $lockKey = "lock:{$key}";
        $locked = $this->redis->set($lockKey, '1', ['NX', 'EX' => 10]);
        
        if (!$locked) {
            usleep(100000); // 等待100ms
            return $this->get($key, $dbQuery, $ttl); // 重试
        }
        
        try {
            // 双重检查
            $data = $this->redis->get($key);
            if ($data !== false) {
                return $data === 'NULL' ? null : json_decode($data, true);
            }
            
            // 4. 查数据库
            $data = $dbQuery();
            
            // 5. 缓存(随机TTL防雪崩)
            $randomTTL = $ttl + mt_rand(0, 300);
            if ($data === null) {
                // 空值缓存(短TTL防穿透)
                $this->redis->setex($key, 60, 'NULL');
            } else {
                $this->redis->setex($key, $randomTTL, json_encode($data));
                $this->localCache[$key] = $data;
            }
            
            return $data;
        } finally {
            $this->redis->del($lockKey);
        }
    }
}

// 使用
$cache = new CacheService();
$user = $cache->get("user:1001", function() use ($db) {
    return $db->query("SELECT * FROM users WHERE id = 1001");
}, 3600);

总结

缓存三大问题的核心防护策略:

  1. 穿透:布隆过滤器拦截 + 空值缓存兜底
  2. 击穿:互斥锁保证只有一个请求查DB + 双重检查
  3. 雪崩:随机TTL打散过期时间 + 多级缓存 + 熔断降级

生产环境建议三者组合使用,形成完整的缓存防护体系。

0

评论 (0)

取消
0:00