🔥 缓存三大问题:机制与原理详解

缓存击穿 · 缓存雪崩 · 缓存穿透 — 深入理解问题本质与解决方案

💥 缓存击穿 — 问题描述

⚠️
核心问题:一个热点 Key(被大量并发访问的缓存数据)在过期的瞬间,大量并发请求同时发现缓存失效,全部穿透到数据库,导致数据库压力骤增甚至崩溃。

🎯 问题本质

缓存击穿的关键词是"热点"和"过期"。与缓存穿透不同,击穿针对的是确实存在的数据,只是恰好在某一时刻缓存失效了。

  • 前提条件:某个 Key 是热点数据,访问量极大(如微博热搜、秒杀商品)
  • 触发条件:该 Key 的缓存恰好在某一时刻过期
  • 直接后果:瞬间大量请求同时 miss 缓存,全部打到数据库
  • 最终影响:数据库连接池耗尽,响应时间飙升,服务不可用

📊 时序图 — 击穿发生过程

T0 — 正常运行

热点数据 hot_key 存在于缓存中,所有请求直接命中缓存返回,数据库无压力。

T1 — 缓存过期

hot_key 的 TTL 到期,缓存中该数据被删除。

T2 — 并发穿透

同一时刻,10000+ 个请求到达,全部 cache miss,同时查询数据库。

T3 — 数据库过载

数据库瞬间承受 10000+ 并发查询,连接池耗尽,响应时间从 5ms 飙升到 5000ms+。

T4 — 恢复

第一个请求完成查询并回写缓存,后续请求重新命中缓存,系统逐渐恢复。

🔍 典型场景

🛒 秒杀活动

商品详情缓存过期瞬间,数万用户同时请求,全部穿透到数据库。

📰 热点新闻

突发新闻页面缓存失效,大量用户刷新页面导致数据库压力暴增。

👤 大V主页

知名用户的主页数据缓存过期,粉丝频繁访问导致数据库过载。

🛡️ 解决方案总览

针对缓存击穿问题,核心思路是避免大量请求同时穿透到数据库。以下是两种主流解决方案:

方案 核心思路 数据一致性 可用性 适用场景
🔒 逻辑过期(永不过期) 缓存不设物理 TTL,在 value 中存储逻辑过期时间,过期后异步重建 弱(可能返回旧数据) 高(不阻塞) 对一致性要求不高的热点数据
🔐 互斥锁排队 缓存 miss 时用分布式锁控制,只允许一个线程查 DB 重建缓存 强(等待最新数据) 中(可能等待超时) 对一致性要求较高的场景
💡
选择建议:如果业务允许短暂返回旧数据(如社交动态、商品浏览),优先选择逻辑过期方案保证高可用;如果对数据一致性要求严格(如库存、余额),选择互斥锁方案。

🔒 解决方案一:逻辑过期(永不过期)

💡
核心思路:缓存不设置物理 TTL(永不过期),而是在 value 中存储一个逻辑过期时间。读取时检查是否逻辑过期,如果过期则由一个线程异步重建缓存,其他线程返回旧数据。

📐 设计原理

  1. 缓存不设 TTL:数据一旦写入缓存,不会被 Redis 自动删除
  2. 逻辑过期字段:在 value 中增加 expire_time 字段,记录业务层面的过期时间
  3. 读取时判断:
    • 若未逻辑过期 → 直接返回缓存数据
    • 若已逻辑过期 → 尝试获取互斥锁,获取成功则异步重建缓存,获取失败则返回旧数据
  4. 异步重建:获取到锁的线程从数据库查询最新数据,更新缓存中的值和逻辑过期时间

🔄 流程图

请求到达
→
查询缓存
→
检查逻辑过期?
未过期 → 直接返回
|
已过期 → 获取互斥锁
获取成功 → 查DB → 更新缓存
|
获取失败 → 返回旧数据

💻 Go 代码实现

logical_expire.go — 逻辑过期方案
package cache

import (
    "context"
    "encoding/json"
    "fmt"
    "sync"
    "time"

    "github.com/redis/go-redis/v9"
)

// CacheData 带逻辑过期的缓存数据结构
type CacheData struct {
    Data       json.RawMessage `json:"data"`       // 实际业务数据
    ExpireTime int64           `json:"expire_time"` // 逻辑过期时间(Unix时间戳)
}

// LogicalExpireCache 逻辑过期缓存实现
type LogicalExpireCache struct {
    rdb        *redis.Client
    localMutex sync.Map // 本地互斥锁,防止多个goroutine同时重建
}

func NewLogicalExpireCache(rdb *redis.Client) *LogicalExpireCache {
    return &LogicalExpireCache{rdb: rdb}
}

// Get 获取缓存数据(核心逻辑)
func (c *LogicalExpireCache) Get(
    ctx context.Context,
    key string,
    logicalTTL time.Duration,
    loadFn func(ctx context.Context) (interface{}, error),
) (interface{}, error) {
    // Step 1: 从缓存中获取数据
    val, err := c.rdb.Get(ctx, key).Result()
    if err == redis.Nil {
        // 缓存不存在(首次),需要加载并设置
        return c.loadAndSet(ctx, key, logicalTTL, loadFn)
    }
    if err != nil {
        return nil, fmt.Errorf("redis get error: %w", err)
    }

    // Step 2: 反序列化
    var cacheData CacheData
    if err := json.Unmarshal([]byte(val), &cacheData); err != nil {
        return nil, fmt.Errorf("unmarshal error: %w", err)
    }

    // Step 3: 检查是否逻辑过期
    if time.Now().Unix() < cacheData.ExpireTime {
        // 未过期,直接返回缓存数据
        var result interface{}
        json.Unmarshal(cacheData.Data, &result)
        return result, nil
    }

    // Step 4: 已过期,尝试获取互斥锁进行异步重建
    lockKey := "lock:" + key
    locked := c.tryLock(ctx, lockKey, 5*time.Second)
    if locked {
        // 获取锁成功,开启goroutine异步重建缓存
        go func() {
            defer c.unlock(ctx, lockKey)
            c.loadAndSet(ctx, key, logicalTTL, loadFn)
        }()
    }
    // 无论是否获取到锁,都返回旧数据(保证可用性)
    var result interface{}
    json.Unmarshal(cacheData.Data, &result)
    return result, nil
}

// loadAndSet 从数据库加载数据并写入缓存
func (c *LogicalExpireCache) loadAndSet(
    ctx context.Context,
    key string,
    logicalTTL time.Duration,
    loadFn func(ctx context.Context) (interface{}, error),
) (interface{}, error) {
    // 双重检查:再次从缓存获取(可能其他goroutine已重建)
    val, _ := c.rdb.Get(ctx, key).Result()
    if val != "" {
        var cd CacheData
        json.Unmarshal([]byte(val), &cd)
        if time.Now().Unix() < cd.ExpireTime {
            var result interface{}
            json.Unmarshal(cd.Data, &result)
            return result, nil
        }
    }

    // 从数据库加载最新数据
    data, err := loadFn(ctx)
    if err != nil {
        return nil, err
    }

    // 构造带逻辑过期的缓存值
    dataBytes, _ := json.Marshal(data)
    cacheData := CacheData{
        Data:       dataBytes,
        ExpireTime: time.Now().Add(logicalTTL).Unix(),
    }
    cacheBytes, _ := json.Marshal(cacheData)

    // 写入Redis(不设TTL,永不过期)
    c.rdb.Set(ctx, key, cacheBytes, 0) // TTL=0 表示永不过期

    return data, nil
}

// tryLock 尝试获取分布式锁
func (c *LogicalExpireCache) tryLock(ctx context.Context, key string, ttl time.Duration) bool {
    return c.rdb.SetNX(ctx, key, "1", ttl).Val()
}

// unlock 释放分布式锁
func (c *LogicalExpireCache) unlock(ctx context.Context, key string) {
    c.rdb.Del(ctx, key)
}
⚡
优缺点分析:
✅ 优点:不会阻塞用户请求,始终返回数据(可能稍旧),可用性高
❌ 缺点:数据一致性有延迟(过期后一段时间内返回旧数据);需要额外内存存储过期时间字段;缓存不会自动清理,需要额外机制处理不活跃数据

🔐 解决方案二:互斥锁排队

💡
核心思路:缓存失效时,不立即去查数据库,而是先尝试获取互斥锁。只有获取到锁的线程才去查询数据库并重建缓存,其他线程等待或重试。

📐 设计原理

  1. 缓存正常设置 TTL:使用正常的过期策略
  2. 缓存 miss 时获取锁:使用 Redis 的 SETNX 实现分布式互斥锁
  3. 获取锁成功:查询数据库 → 写入缓存 → 释放锁
  4. 获取锁失败:短暂休眠后重试(sleep + retry),直到缓存重建完成

💻 Go 代码实现

mutex_lock.go — 互斥锁排队方案
package cache

import (
    "context"
    "errors"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

var (
    ErrCacheMiss  = errors.New("cache miss")
    ErrDBError    = errors.New("database error")
)

// MutexCache 互斥锁缓存实现
type MutexCache struct {
    rdb         *redis.Client
    retryCount  int           // 重试次数
    retryDelay  time.Duration // 重试间隔
    lockTTL     time.Duration // 锁的过期时间
}

func NewMutexCache(rdb *redis.Client) *MutexCache {
    return &MutexCache{
        rdb:        rdb,
        retryCount: 10,
        retryDelay: 50 * time.Millisecond,
        lockTTL:    10 * time.Second,
    }
}

// Get 带互斥锁的缓存查询
func (c *MutexCache) Get(
    ctx context.Context,
    key string,
    ttl time.Duration,
    loadFn func(ctx context.Context) (interface{}, error),
) (interface{}, error) {
    // Step 1: 尝试从缓存获取
    val, err := c.rdb.Get(ctx, key).Result()
    if err == nil {
        // 缓存命中,直接返回
        return val, nil
    }
    if err != redis.Nil {
        return nil, fmt.Errorf("redis error: %w", err)
    }

    // Step 2: 缓存未命中,尝试获取互斥锁
    lockKey := "lock:" + key
    return c.getWithLock(ctx, key, lockKey, ttl, loadFn)
}

// getWithLock 带锁的缓存重建逻辑
func (c *MutexCache) getWithLock(
    ctx context.Context,
    key, lockKey string,
    ttl time.Duration,
    loadFn func(ctx context.Context) (interface{}, error),
) (interface{}, error) {
    for i := 0; i < c.retryCount; i++ {
        // 尝试获取分布式锁
        locked, err := c.rdb.SetNX(ctx, lockKey, "1", c.lockTTL).Result()
        if err != nil {
            return nil, fmt.Errorf("set lock error: %w", err)
        }

        if locked {
            // 获取锁成功!执行数据库查询和缓存重建
            defer c.rdb.Del(ctx, lockKey) // 确保释放锁

            // 双重检查:获取锁后再次检查缓存
            // (可能其他线程已在我们等锁期间重建了缓存)
            val, err := c.rdb.Get(ctx, key).Result()
            if err == nil {
                return val, nil
            }

            // 从数据库加载数据
            data, err := loadFn(ctx)
            if err != nil {
                return nil, fmt.Errorf("load from db error: %w", err)
            }

            // 写入缓存
            c.rdb.Set(ctx, key, data, ttl)
            return data, nil
        }

        // 获取锁失败,等待后重试
        time.Sleep(c.retryDelay)

        // 重试时先检查缓存是否已重建
        val, err := c.rdb.Get(ctx, key).Result()
        if err == nil {
            return val, nil
        }
    }

    return nil, errors.New("timeout: failed to acquire lock after retries")
}

📊 两种方案对比

对比维度 逻辑过期(永不过期) 互斥锁排队
数据一致性 弱一致性(可能返回旧数据) 强一致性(等待最新数据)
可用性 高(不阻塞,始终返回数据) 中(可能等待超时)
实现复杂度 中等(需维护逻辑过期字段) 中等(需处理锁竞争)
资源消耗 额外内存(过期时间字段) 线程等待(CPU/内存)
适用场景 对一致性要求不高的热点数据 对一致性要求较高的场景

🏔️ 缓存雪崩 — 问题描述

⚠️
核心问题:大量缓存 Key 在同一时刻集中过期,或者 Redis 服务整体宕机,导致所有请求全部穿透到数据库,引发数据库崩溃,进而整个系统瘫痪。

🎯 与缓存击穿的区别

❌ 缓存击穿
  • 单个热点 Key 过期
  • 影响范围:一个数据的并发访问
  • 关键词:热点 单个Key
❌ 缓存雪崩
  • 大量 Key 同时过期
  • 影响范围:整个系统的缓存层
  • 关键词:批量 同时过期

📊 雪崩发生的两种场景

场景一:大量 Key 同时过期

缓存数据在初始化时设置了相同的 TTL(如都是 30 分钟),30 分钟后所有缓存同时失效。

TTL 相同 同时失效

场景二:Redis 服务宕机

Redis 节点故障、网络分区、内存溢出等原因导致整个缓存服务不可用。

服务不可用 全部 miss

📈 雪崩影响示意

用户请求 (10000 QPS)
↓
Redis 缓存层 (全部失效/宕机)
↓ 全部穿透
MySQL (10000 QPS 直接冲击 💀)
↓
系统崩溃 ❌

🛡️ 解决方案总览

针对缓存雪崩问题,需要从预防过期集中、限流降级保护、高可用架构三个层面进行防护:

方案 核心思路 防护层面 实现复杂度 适用场景
🎲 随机失效时间 在基础 TTL 上加随机值,分散 Key 的过期时间 预防层 极低 所有场景(必做)
🔐 加锁排队 + 限流降级 通过互斥锁 + 信号量 + 限流器控制并发,保护数据库 保护层 中等 高并发场景
🏗️ Redis 高可用架构 使用哨兵模式或 Cluster 集群,防止 Redis 整体宕机 架构层 中高 生产环境(推荐)
💡
最佳实践:三种方案应组合使用。随机 TTL 是基础防御,限流降级是兜底保护,Redis 高可用是架构保障。生产环境建议至少使用哨兵模式,大型项目推荐 Redis Cluster。

🎲 解决方案一:随机失效时间

💡
核心思路:在设置缓存 TTL 时,在基础过期时间上增加一个随机值,使得不同 Key 的过期时间分散开,避免同时过期。

📐 原理说明

公式:实际TTL = 基础TTL + random(0, 随机范围)

例如基础 TTL 为 30 分钟,随机范围为 1~5 分钟,则实际过期时间在 31~35 分钟之间随机分布。

  • 即使有 100 万个 Key,它们的过期时间也会分散在 4 分钟的窗口内
  • 任何时刻过期的 Key 数量只是总量的很小一部分
  • 数据库压力被平滑分散

💻 Go 代码实现

random_ttl.go — 随机过期时间
package cache

import (
    "context"
    "math/rand"
    "time"

    "github.com/redis/go-redis/v9"
)

// RandomTTLCache 随机TTL缓存实现
type RandomTTLCache struct {
    rdb       *redis.Client
    baseTTL   time.Duration // 基础过期时间
    jitterMax time.Duration // 随机抖动最大值
}

func NewRandomTTLCache(rdb *redis.Client, baseTTL, jitterMax time.Duration) *RandomTTLCache {
    return &RandomTTLCache{
        rdb:       rdb,
        baseTTL:   baseTTL,
        jitterMax: jitterMax,
    }
}

// Set 设置缓存(带随机TTL)
func (c *RandomTTLCache) Set(ctx context.Context, key string, value interface{}) error {
    // 计算实际TTL = 基础TTL + 随机抖动
    actualTTL := c.baseTTL + time.Duration(rand.Int63n(int64(c.jitterMax)))
  
    return c.rdb.Set(ctx, key, value, actualTTL).Err()
}

// SetBatch 批量设置缓存(每个Key独立随机TTL)
func (c *RandomTTLCache) SetBatch(ctx context.Context, items map[string]interface{}) error {
    pipe := c.rdb.Pipeline()
  
    for key, value := range items {
        // 每个Key使用独立的随机TTL
        actualTTL := c.baseTTL + time.Duration(rand.Int63n(int64(c.jitterMax)))
        pipe.Set(ctx, key, value, actualTTL)
    }
  
    _, err := pipe.Exec(ctx)
    return err
}

// 使用示例
func Example() {
    rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
  
    // 基础TTL 30分钟,随机抖动 0~5分钟
    cache := NewRandomTTLCache(rdb, 30*time.Minute, 5*time.Minute)
  
    // 设置商品缓存
    ctx := context.Background()
    cache.Set(ctx, "product:1001", productData1) // 可能 32分钟后过期
    cache.Set(ctx, "product:1002", productData2) // 可能 34分钟后过期
    cache.Set(ctx, "product:1003", productData3) // 可能 31分钟后过期
    // ... 每个Key的过期时间都不同,避免雪崩
}

📊 效果对比

固定 TTL vs 随机 TTL — 过期分布

❌ 固定 TTL (全部 30 分钟):

T=30min: 100% Key 同时过期 💥

✅ 随机 TTL (30~35 分钟):

每个时间点只有少量 Key 过期,数据库压力被平滑分散

🔐 解决方案二:加锁排队(限流降级)

💡
核心思路:即使大量缓存同时失效,通过互斥锁控制同时查询数据库的并发数量,配合限流降级策略保护数据库。

💻 Go 代码实现 — 多级保护

avalanche_protection.go — 雪崩多级保护
package cache

import (
    "context"
    "errors"
    "math/rand"
    "sync"
    "time"

    "github.com/redis/go-redis/v9"
    "golang.org/x/time/rate"
)

// AvalancheProtection 雪崩综合防护
type AvalancheProtection struct {
    rdb       *redis.Client
    limiter   *rate.Limiter      // 限流器
    semaphore chan struct{}     // 信号量,控制并发查DB的数量
    mu        sync.Mutex
    baseTTL   time.Duration
    jitterMax time.Duration
}

func NewAvalancheProtection(rdb *redis.Client, maxDBConcurrency int) *AvalancheProtection {
    return &AvalancheProtection{
        rdb:       rdb,
        limiter:   rate.NewLimiter(1000, 2000), // 每秒1000个请求,桶容量2000
        semaphore: make(chan struct{}, maxDBConcurrency), // 最多N个goroutine同时查DB
        baseTTL:   30 * time.Minute,
        jitterMax: 10 * time.Minute,
    }
}

// Get 带多级保护的缓存查询
func (p *AvalancheProtection) Get(
    ctx context.Context,
    key string,
    loadFn func(ctx context.Context) (interface{}, error),
) (interface{}, error) {
    // 第一层保护:限流
    if !p.limiter.Allow() {
        return nil, errors.New("rate limit exceeded, please retry later")
    }

    // 尝试从缓存获取
    val, err := p.rdb.Get(ctx, key).Result()
    if err == nil {
        return val, nil
    }

    // 第二层保护:信号量控制并发查DB数量
    select {
    case p.semaphore <- struct{}{}:
        defer func() { <-p.semaphore }()
    case <-time.After(3 * time.Second):
        // 等待超时,返回降级数据
        return p.getFallbackData(key)
    case <-ctx.Done():
        return nil, ctx.Err()
    }

    // 第三层保护:分布式锁,防止同一Key重复查DB
    lockKey := "lock:" + key
    locked := p.rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Val()
    if !locked {
        // 其他goroutine正在重建该Key的缓存,等待
        time.Sleep(100 * time.Millisecond)
        // 重试从缓存获取
        return p.rdb.Get(ctx, key).Result()
    }
    defer p.rdb.Del(ctx, lockKey)

    // 查询数据库
    data, err := loadFn(ctx)
    if err != nil {
        return p.getFallbackData(key) // 查询失败,返回降级数据
    }

    // 写入缓存(随机TTL)
    actualTTL := p.baseTTL + time.Duration(rand.Int63n(int64(p.jitterMax)))
    p.rdb.Set(ctx, key, data, actualTTL)

    return data, nil
}

// getFallbackData 获取降级数据
func (p *AvalancheProtection) getFallbackData(key string) (interface{}, error) {
    // 降级策略:返回默认值/上次缓存的快照/空结果
    return map[string]interface{}{
        "status":  "degraded",
        "message": "service temporarily unavailable",
    }, nil
}

🏗️ 解决方案三:Redis 高可用架构

💡
核心思路:通过 Redis 的高可用架构(主从复制、哨兵模式、Cluster 集群),确保缓存服务本身不会成为单点故障。

📐 三种高可用方案对比

方案 架构 自动故障转移 数据分片 适用规模
主从复制 1主N从 ❌ 需手动切换 ❌ 全量复制 小型项目
哨兵模式 1主N从+哨兵 ✅ 自动切换 ❌ 全量复制 中型项目
Cluster 集群 多主多从 ✅ 自动切换 ✅ 16384 哈希槽 大型项目

🏗️ Redis Cluster 架构图

客户端 (Redis Cluster Client)
↓ 根据 CRC16(key) % 16384 定位槽
Master 1
Slots 0-5460
Master 2
Slots 5461-10922
Master 3
Slots 10923-16383
↕ 主从复制
Slave 1
热备
Slave 2
热备
Slave 3
热备

任意 Master 宕机,对应 Slave 自动提升为 Master,保证缓存服务持续可用

💻 Go 代码 — Redis Cluster 客户端配置

redis_cluster.go — Cluster 高可用配置
package cache

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

// NewRedisCluster 创建 Redis Cluster 客户端
func NewRedisCluster() *redis.ClusterClient {
    return redis.NewClusterClient(&redis.ClusterOptions{
        // 集群节点地址(至少配置一个,客户端会自动发现其他节点)
        Addrs: []string{
            "192.168.1.10:7000",
            "192.168.1.10:7001",
            "192.168.1.11:7000",
            "192.168.1.11:7001",
            "192.168.1.12:7000",
            "192.168.1.12:7001",
        },
      
        // 密码认证
        Password: "your_password",
      
        // 最大重试次数(故障转移期间自动重试)
        MaxRedirects: 3,
      
        // 读取超时
        ReadTimeout: 3 * time.Second,
      
        // 写入超时
        WriteTimeout: 3 * time.Second,
      
        // 连接池配置
        PoolSize:     100,
        MinIdleConns: 20,
      
        // 路由函数:读请求可以路由到从节点(读写分离)
        RouteByLatency: true,
      
        // 集群拓扑刷新间隔
        ClusterSlotsRefreshInterval: 10 * time.Second,
    })
}

// SentinelClient 哨兵模式客户端
func NewSentinelClient() *redis.Client {
    return redis.NewFailoverClient(&redis.FailoverOptions{
        // 哨兵节点地址
        SentinelAddrs: []string{
            "192.168.1.10:26379",
            "192.168.1.11:26379",
            "192.168.1.12:26379",
        },
      
        // 主节点名称(在哨兵配置中定义的)
        MasterName: "mymaster",
      
        // 数据库密码
        Password: "your_password",
      
        // 数据库编号
        DB: 0,
      
        // 只读节点(读写分离)
        ReplicaOnly: false,
    })
}

// HealthCheck 缓存健康检查
func HealthCheck(ctx context.Context, client *redis.ClusterClient) error {
    if err := client.Ping(ctx).Err(); err != nil {
        return fmt.Errorf("redis cluster health check failed: %w", err)
    }
  
    // 检查集群状态
    clusterInfo, err := client.ClusterInfo(ctx).Result()
    if err != nil {
        return fmt.Errorf("failed to get cluster info: %w", err)
    }
  
    // 验证集群状态是否为ok
    if !contains(clusterInfo, "cluster_state:ok") {
        return fmt.Errorf("cluster state is not ok")
    }
  
    return nil
}
✅
高可用最佳实践总结:
  • 生产环境至少使用哨兵模式,推荐 Redis Cluster
  • 配置合理的连接池参数,避免连接耗尽
  • 实现健康检查和自动熔断机制
  • 开启读写分离,将读请求分散到从节点
  • 配置持久化策略(RDB + AOF),防止数据丢失

🕳️ 缓存穿透 — 问题描述

⚠️
核心问题:请求查询的数据在缓存和数据库中都不存在,每次请求都会穿透缓存直接打到数据库。如果这类请求量很大(如恶意攻击),数据库将承受巨大压力。

🎯 问题本质

缓存穿透的关键词是"数据不存在"。攻击者利用不存在的数据 ID 发起大量请求,这些请求在缓存中永远 miss,每次都穿透到数据库。

  • 前提条件:请求的数据在缓存和数据库中都不存在
  • 触发条件:大量此类请求持续到达
  • 直接后果:所有请求全部打到数据库,缓存形同虚设
  • 典型攻击:使用不存在的用户 ID、订单号等频繁查询

📊 穿透攻击示意

恶意请求: user_id=-1, user_id=999999...
↓ 每次请求
Redis 缓存
❌ 不存在 → miss
↓ 穿透
MySQL 数据库
❌ 也不存在 → 返回空
↓ 循环往复
10000 QPS 全部打到数据库 💀
缓存完全没有起到保护作用

🛡️ 解决方案总览

针对缓存穿透问题,核心思路是拦截无效请求,防止其穿透到数据库。推荐三层防护组合使用:

方案 核心思路 防护能力 内存开销 适用场景
✅ 参数校验 在请求到达缓存前,拦截明显不合法的参数(如负数 ID) 低(仅拦截格式错误) 无 所有场景(必做)
📦 缓存空对象 数据库查询为空时,缓存空值标记(短 TTL),防止重复穿透 中(防止重复查询) 中(空值占内存) 数据量不大,攻击 ID 有限
🌸 布隆过滤器 用位数组存储合法 ID,请求到达前先检查,不存在则直接拒绝 高(从根源拦截) 低(位数组省内存) 数据量大,防恶意攻击
✅
推荐防护顺序:
  1. 第一层 — 参数校验:拦截明显不合法请求(零成本,必做)
  2. 第二层 — 布隆过滤器:拦截数据一定不存在的请求(低成本,高效率)
  3. 第三层 — 缓存空对象:兜底保护,处理布隆过滤器误判的情况

✅ 解决方案一:参数校验

💡
核心思路:在请求到达缓存之前,先对参数进行合法性校验,直接拦截明显不合法的请求(如 ID ≤ 0、格式错误等)。

💻 Go 代码实现

param_validation.go — 参数校验
package handler

import (
    "errors"
    "fmt"
    "regexp"
    "strconv"
)

var (
    ErrInvalidID     = errors.New("invalid parameter: id must be positive")
    ErrInvalidFormat = errors.New("invalid parameter format")
    ErrIDOutOfRange  = errors.New("id out of valid range")
)

// ParamValidator 参数校验器
type ParamValidator struct {
    maxUserID int64 // 最大合法用户ID(可根据业务设定)
}

func NewParamValidator(maxUserID int64) *ParamValidator {
    return &ParamValidator{maxUserID: maxUserID}
}

// ValidateUserID 校验用户ID
func (v *ParamValidator) ValidateUserID(idStr string) (int64, error) {
    // 1. 非空校验
    if idStr == "" {
        return 0, fmt.Errorf("%w: id is empty", ErrInvalidFormat)
    }

    // 2. 格式校验(纯数字)
    matched, _ := regexp.MatchString(`^\d+$`, idStr)
    if !matched {
        return 0, fmt.Errorf("%w: id must be numeric", ErrInvalidFormat)
    }

    // 3. 数值转换与范围校验
    id, err := strconv.ParseInt(idStr, 10, 64)
    if err != nil {
        return 0, fmt.Errorf("%w: %v", ErrInvalidFormat, err)
    }

    // 4. 正数校验
    if id <= 0 {
        return 0, ErrInvalidID
    }

    // 5. 范围校验(防止超大ID)
    if id > v.maxUserID {
        return 0, fmt.Errorf("%w: max is %d", ErrIDOutOfRange, v.maxUserID)
    }

    return id, nil
}

// GetUserHandler 用户查询接口
func (h *Handler) GetUserHandler(idStr string) (interface{}, error) {
    // 第一道防线:参数校验
    userID, err := h.validator.ValidateUserID(idStr)
    if err != nil {
        // 参数不合法,直接拒绝,不查缓存也不查数据库
        return nil, fmt.Errorf("bad request: %w", err)
    }

    // 参数合法,继续走缓存查询逻辑...
    return h.cache.Get(ctx, fmt.Sprintf("user:%d", userID), ...)
}
⚡
局限性:参数校验只能拦截明显不合法的参数(如负数、格式错误),但无法拦截格式合法但数据不存在的请求(如 user_id=999999 格式正确但用户不存在)。因此需要配合其他方案。

📦 解决方案二:缓存空对象

💡
核心思路:当数据库查询结果为空时,仍然在缓存中写入一个空值标记,并设置较短的 TTL。后续相同请求命中缓存中的空值后直接返回,不再穿透到数据库。

📐 流程说明

请求: user_id=999999
→
查缓存
第一次请求:缓存 miss → 查 DB → DB 也空 → 写入空值到缓存(TTL=60s) → 返回空
请求: user_id=999999
→
查缓存 → 命中空值
→
直接返回空 ✅
后续请求:在 TTL 内全部命中缓存空值,不再穿透到数据库

💻 Go 代码实现

cache_null.go — 缓存空对象方案
package cache

import (
    "context"
    "encoding/json"
    "errors"
    "time"

    "github.com/redis/go-redis/v9"
)

var (
    ErrDataNotFound = errors.New("data not found")
)

// 空值标记常量
const (
    NullValue     = "@@NULL@@"          // 空值标记字符串
    NullTTL       = 60 * time.Second    // 空值的TTL(较短,防止长期占用内存)
    NormalTTL     = 30 * time.Minute    // 正常数据的TTL
)

// NullCache 支持缓存空对象的缓存实现
type NullCache struct {
    rdb       *redis.Client
    nullTTL   time.Duration
    normalTTL time.Duration
}

func NewNullCache(rdb *redis.Client) *NullCache {
    return &NullCache{
        rdb:       rdb,
        nullTTL:   NullTTL,
        normalTTL: NormalTTL,
    }
}

// Get 获取数据(自动处理空值缓存)
func (c *NullCache) Get(
    ctx context.Context,
    key string,
    loadFn func(ctx context.Context) (interface{}, error),
) (interface{}, error) {
    // Step 1: 从缓存获取
    val, err := c.rdb.Get(ctx, key).Result()
  
    if err == nil {
        // 缓存命中
        // 检查是否是空值标记
        if val == NullValue {
            // 命中空值,直接返回"数据不存在",不查数据库
            return nil, ErrDataNotFound
        }
        // 正常数据,反序列化返回
        var result interface{}
        json.Unmarshal([]byte(val), &result)
        return result, nil
    }

    if err != redis.Nil {
        return nil, err
    }

    // Step 2: 缓存未命中,查询数据库
    data, err := loadFn(ctx)
    if err != nil {
        return nil, err
    }

    // Step 3: 判断数据库查询结果
    if data == nil {
        // 数据库也不存在 → 缓存空值
        c.rdb.Set(ctx, key, NullValue, c.nullTTL)
        return nil, ErrDataNotFound
    }

    // 数据库存在 → 缓存正常数据
    dataBytes, _ := json.Marshal(data)
    c.rdb.Set(ctx, key, string(dataBytes), c.normalTTL)
    return data, nil
}

// 使用示例
func Example() {
    cache := NewNullCache(rdb)
  
    // 查询用户(用户不存在的情况)
    data, err := cache.Get(ctx, "user:999999", func(ctx context.Context) (interface{}, error) {
        return db.QueryUser(ctx, 999999) // 返回 nil, nil
    })
  
    if errors.Is(err, ErrDataNotFound) {
        // 数据不存在(已被缓存为空值,后续请求不再查DB)
        return "user not found"
    }
}

✅ 优点

  • 实现简单,改动小
  • 有效防止对不存在数据的重复查询
  • 空值 TTL 较短,不会长期占用内存

❌ 缺点

  • 如果攻击者使用大量不同的随机 ID,仍会写入大量空值缓存
  • 内存可能被空值占满(内存浪费)
  • 存在短暂的数据不一致窗口(数据新建后,空值缓存未过期)

🌸 解决方案三:布隆过滤器

💡
核心思路:使用布隆过滤器(Bloom Filter)存储所有合法的数据 ID。请求到达时先查询布隆过滤器,如果判定"不存在"则直接拒绝,从根源上杜绝无效请求穿透到缓存和数据库。

📐 布隆过滤器原理

数据结构

布隆过滤器由一个很长的二进制位数组和多个哈希函数组成。

  • 插入元素:用 k 个哈希函数计算 k 个位置,将对应位全部置为 1
  • 查询元素:用 k 个哈希函数计算 k 个位置,检查对应位是否全为 1

核心特性

✅ 判定"不存在" — 一定准确

如果任一位为 0,则该元素一定不存在。这个判断 100% 准确。

⚠️ 判定"存在" — 可能误判

如果所有位都为 1,该元素可能存在(有一定误判率/假阳性率)。

🔬 布隆过滤器可视化

位数组状态演示

假设位数组长度 16,使用 3 个哈希函数 H1, H2, H3

初始状态(全 0):

0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0

插入 "user:1001" → H1=1, H2=5, H3=12:

0
1
0
0
0
1
0
0
0
0
0
0
1
0
0
0

插入 "user:1002" → H1=3, H2=5, H3=9:

0
1
0
1
0
1
0
0
0
1
0
0
1
0
0
0

查询 "user:9999" → H1=2, H2=7, H3=14:

0
1
0
1
0
1
0
0
0
1
0
0
1
0
0
0

→ 位2、位7、位14 有0 → 一定不存在!直接拒绝 ✅

💻 Go 代码实现

bloom_filter.go — 布隆过滤器实现
package cache

import (
    "context"
    "hash/fnv"
    "math"

    "github.com/redis/go-redis/v9"
)

// BloomFilter 基于 Redis 的布隆过滤器实现
type BloomFilter struct {
    rdb     *redis.Client
    key     string  // Redis 中存储位数组的 key
    bitSize uint    // 位数组大小
    hashNum uint    // 哈希函数个数
}

// NewBloomFilter 创建布隆过滤器
// expectedInsertions: 预计插入的元素数量
// falsePositiveRate: 期望的误判率(如 0.01 表示 1%)
func NewBloomFilter(
    rdb *redis.Client,
    key string,
    expectedInsertions uint64,
    falsePositiveRate float64,
) *BloomFilter {
    // 计算最优位数组大小: m = -(n * ln(p)) / (ln(2))^2
    bitSize := uint(math.Ceil(
        -float64(expectedInsertions) * math.Log(falsePositiveRate) /
            (math.Log(2) * math.Log(2)),
    ))

    // 计算最优哈希函数数量: k = (m/n) * ln(2)
    hashNum := uint(math.Ceil(
        (float64(bitSize) / float64(expectedInsertions)) * math.Log(2),
    ))

    return &BloomFilter{
        rdb:     rdb,
        key:     key,
        bitSize: bitSize,
        hashNum: hashNum,
    }
}

// Add 添加元素到布隆过滤器
func (bf *BloomFilter) Add(ctx context.Context, value string) error {
    pipe := bf.rdb.Pipeline()
  
    for i := uint(0); i < bf.hashNum; i++ {
        index := bf.hash(value, i)
        pipe.SetBit(ctx, bf.key, int64(index), 1)
    }
  
    _, err := pipe.Exec(ctx)
    return err
}

// AddBatch 批量添加元素
func (bf *BloomFilter) AddBatch(ctx context.Context, values []string) error {
    pipe := bf.rdb.Pipeline()
  
    for _, value := range values {
        for i := uint(0); i < bf.hashNum; i++ {
            index := bf.hash(value, i)
            pipe.SetBit(ctx, bf.key, int64(index), 1)
        }
    }
  
    _, err := pipe.Exec(ctx)
    return err
}

// Exists 检查元素是否可能存在
// 返回 true: 元素可能存在(需要继续查缓存/DB)
// 返回 false: 元素一定不存在(直接拒绝)
func (bf *BloomFilter) Exists(ctx context.Context, value string) (bool, error) {
    for i := uint(0); i < bf.hashNum; i++ {
        index := bf.hash(value, i)
        bit, err := bf.rdb.GetBit(ctx, bf.key, int64(index)).Result()
        if err != nil {
            return false, err
        }
        if bit == 0 {
            // 任一位为0,元素一定不存在
            return false, nil
        }
    }
    // 所有位都为1,元素可能存在
    return true, nil
}

// hash 使用 FNV 哈希 + 种子模拟多个哈希函数
func (bf *BloomFilter) hash(value string, seed uint) uint {
    h := fnv.New64a()
    h.Write([]byte(value))
    // 加入种子产生不同的哈希值
    h.Write([]byte{byte(seed)})
    return uint(h.Sum64()) % bf.bitSize
}

// InitFromDB 从数据库加载所有合法ID到布隆过滤器
func (bf *BloomFilter) InitFromDB(ctx context.Context, loadAllIDs func() ([]string, error)) error {
    ids, err := loadAllIDs()
    if err != nil {
        return err
    }
  
    // 批量添加到布隆过滤器
    batchSize := 1000
    for i := 0; i < len(ids); i += batchSize {
        end := i + batchSize
        if end > len(ids) {
            end = len(ids)
        }
        if err := bf.AddBatch(ctx, ids[i:end]); err != nil {
            return err
        }
    }
    return nil
}

💻 完整集成方案 — 三层防护

penetration_full.go — 穿透三层防护完整方案
package cache

import (
    "context"
    "errors"
    "fmt"
    "time"
)

var (
    ErrInvalidParam   = errors.New("invalid parameter")
    ErrDataNotExist   = errors.New("data does not exist")
    ErrServiceDegraded = errors.New("service degraded")
)

// PenetrationGuard 缓存穿透防护器(三层防护)
type PenetrationGuard struct {
    bloomFilter *BloomFilter // 第三层:布隆过滤器
    nullCache   *NullCache   // 第二层:空值缓存
}

// Get 带三层防护的缓存查询
func (g *PenetrationGuard) Get(
    ctx context.Context,
    key string,
    loadFn func(ctx context.Context) (interface{}, error),
) (interface{}, error) {
    // ========== 第一层:参数校验 ==========
    // (在调用此方法之前已完成,此处省略)
  
    // ========== 第二层:布隆过滤器检查 ==========
    exists, err := g.bloomFilter.Exists(ctx, key)
    if err != nil {
        // 布隆过滤器查询失败,降级处理(放行,让后续层处理)
        // 这里选择放行而不是拒绝,保证可用性
    }
    if !exists {
        // 布隆过滤器判定不存在 → 一定不存在 → 直接拒绝
        return nil, ErrDataNotExist
    }

    // ========== 第三层:空值缓存 ==========
    // 布隆过滤器说可能存在,再查缓存(含空值缓存)
    data, err := g.nullCache.Get(ctx, key, loadFn)
    if err != nil {
        if errors.Is(err, ErrDataNotFound) {
            // 数据库也不存在
            return nil, ErrDataNotExist
        }
        return nil, err
    }

    return data, nil
}

// ============ 完整使用示例 ============
func GetUserExample() {
    // 初始化
    guard := &PenetrationGuard{
        bloomFilter: NewBloomFilter(rdb, "bloom:users", 1000000, 0.01),
        nullCache:   NewNullCache(rdb),
    }

    // 启动时从数据库加载所有合法用户ID到布隆过滤器
    guard.bloomFilter.InitFromDB(ctx, func() ([]string, error) {
        return db.GetAllUserIDs()
    })

    // 处理请求
    userID := "-1" // 恶意请求
  
    // 第一层:参数校验
    if id, err := validateUserID(userID); err != nil {
        return "参数不合法" // 直接拒绝,-1 不合法
    }

    // 第二层+第三层:布隆过滤器 + 空值缓存
    data, err := guard.Get(ctx, "user:999999", func(ctx context.Context) (interface{}, error) {
        return db.GetUser(ctx, 999999)
    })
  
    if errors.Is(err, ErrDataNotExist) {
        return "用户不存在" // 布隆过滤器或空值缓存拦截
    }
}

📊 三种方案综合对比

方案 防护能力 内存开销 实现复杂度 适用场景
参数校验 低(只能拦截格式错误) 无 极低 所有场景(必做)
缓存空对象 中(防止重复查询) 中(空值占内存) 低 数据量不大,攻击ID有限
布隆过滤器 高(从根源拦截) 低(位数组很省内存) 中高 数据量大,防恶意攻击
✅
最佳实践:三种方案组合使用!
  1. 第一层 — 参数校验:拦截明显不合法请求(零成本)
  2. 第二层 — 布隆过滤器:拦截数据一定不存在的请求(低成本,高效率)
  3. 第三层 — 缓存空对象:兜底保护,处理布隆过滤器误判的情况

🔧 布隆过滤器的维护与注意事项

⚠️ 不能删除元素

标准布隆过滤器不支持删除操作。如果需要删除,可使用计数布隆过滤器(Counting Bloom Filter),用计数器代替单个位。

⚠️ 误判率随元素增多而升高

当插入元素超过预期数量时,误判率会显著上升。需要定期重建或动态扩容。

✅ 定期重建策略

每隔一定时间(如每天凌晨),从数据库重新加载所有合法 ID 到新的布隆过滤器,然后切换。

✅ 增量更新

新增数据时同步添加到布隆过滤器。删除数据时,由于不能直接删除,可以标记为"逻辑删除",定期重建时过滤掉。

bloom_maintenance.go — 布隆过滤器维护
// RebuildBloomFilter 定期重建布隆过滤器
func RebuildBloomFilter(ctx context.Context, bf *BloomFilter) error {
    // 使用新key创建新的布隆过滤器
    newKey := bf.key + ":new"
    newBF := NewBloomFilter(bf.rdb, newKey, bf.estimatedCapacity, bf.falsePositiveRate)
  
    // 从数据库加载最新数据
    err := newBF.InitFromDB(ctx, func() ([]string, error) {
        return db.GetAllValidIDs(ctx) // 只加载有效数据
    })
    if err != nil {
        return err
    }
  
    // 原子切换:rename 操作在 Redis 中是原子的
    // 将旧key备份,新key替换为正式key
    pipe := bf.rdb.Pipeline()
    pipe.Rename(ctx, bf.key, bf.key+":old")
    pipe.Rename(ctx, newKey, bf.key)
    _, err = pipe.Exec(ctx)
    if err != nil {
        return err
    }
  
    // 延迟删除旧key
    go func() {
        time.Sleep(1 * time.Minute)
        bf.rdb.Del(context.Background(), bf.key+":old")
    }()
  
    return nil
}

// StartPeriodicRebuild 启动定时重建任务
func StartPeriodicRebuild(ctx context.Context, bf *BloomFilter, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
  
    for {
        select {
        case <-ticker.C:
            if err := RebuildBloomFilter(ctx, bf); err != nil {
                log.Printf("failed to rebuild bloom filter: %v", err)
            } else {
                log.Printf("bloom filter rebuilt successfully")
            }
        case <-ctx.Done():
            return
        }
    }
}