Redis缓存三大坑:穿透、击穿、雪崩实战修复方案
发布日期: 2026/08/13 阅读总量: 0

事故现场:一次压测P99从8ms到95ms

2024年3月,我们团队负责的电商订单服务做上线前压测。压测工具 wrk,单机8线程,连接数400,压测时长5分钟。前一晚所有接口都正常,第二天早上重新拉分支构建后,订单详情接口的P99从8ms直接飙到95ms,TP99.9超过200ms,数据库慢查询日志里刷出大量订单表全表扫描。

第一反应是代码回归了,但 git diff 看了一圈,业务代码没动。打开 Redis 监控,命中率从 99.2% 掉到 54.8%,keyspace 在压测开始的第 3 秒内消失了大约 23 万个 key。再查数据库连接数,max_connections=300,实际瞬间冲到 287,连接池排队。

根因很快定位:另一位同事在订单详情缓存的 key 设计里,把过期时间写成了 3600 秒,但没加随机扰动。缓存重建的逻辑里,也没有加锁,导致缓存过期后所有请求同时打向数据库。这属于典型的 缓存雪崩。而雪崩发生时,又因为订单号不存在的场景没有做空值缓存,缓存穿透也一起来,数据库直接雪上加霜。

这篇文章把这三个问题的成因、方案对比、代码实现和压测数据完整记录下来。方案和代码均基于 Go 1.22 + Redis 7.2.4 + MySQL 8.0.35 + Gin v1.9.1,缓存客户端使用 go-redis v9.5.1

三个问题先分清:穿透、击穿、雪崩

这三个词经常被混在一起说,但成因、影响面、解法完全不同。

问题触发场景影响面核心解法
缓存穿透查询一个必然不存在的数据(如订单号不存在)恶意请求可打满数据库空值缓存 / 布隆过滤器
缓存击穿某个热点 key 过期瞬间,大量请求并发访问单点数据库压力激增互斥锁 / 逻辑过期
缓存雪崩大量 key 同一时间过期,或 Redis 宕机整体流量直接打向数据库,服务雪崩过期时间加随机值 / 多级缓存

方案对比:布隆过滤器 vs 空值缓存,互斥锁 vs 逻辑过期

先说结论,我们最终线上用的是:布隆过滤器 + 互斥锁 + 过期时间随机扰动。但下面把两种主流方案都给出对比,因为不同业务场景选型会不一样。

穿透方案:布隆过滤器 vs 空值缓存

维度布隆过滤器空值缓存
内存占用100 万条数据,约 1.2MB(误判率1%)每增加一个不存在的 key 就占一份内存
误判有,小概率把不存在的数据判断为存在
实现复杂度需要维护布隆过滤器本身,新增数据时要同步写入简单,缓存无效查询结果即可
适合场景数据总量可控,key 集合相对固定key 模式不确定,或变化频繁

布隆过滤器最大的问题是:不支持删除。如果业务里有删除功能,或者 key 的集合变化很快,布隆过滤器会逐渐失真,只能定期重建。所以我们最终选择布隆过滤器是基于订单号场景:订单号一旦生成不会删除,集合相对稳定。

击穿方案:互斥锁 vs 逻辑过期

维度互斥锁逻辑过期
一致性强一致,缓存重建完成前请求全部等待弱一致,过期期间可能返回旧数据
并发表现少量请求由锁竞争,其余等待所有请求直接返回旧值,后台线程重建
实现复杂度中等,需要处理锁的获取失败降级较高,需要额外管理重建线程
适合场景对一致性要求高的数据可容忍短暂脏读的数据

我们选互斥锁,因为订单详情不允许拿到旧数据。互斥锁的方案下,有一个可优化的点:不要用单飞模式——即所有请求都尝试拿锁重建缓存。用"拿锁失败直接返回旧缓存"的降级逻辑,而不是让所有请求等待锁。这个在后面的代码里会体现。

完整代码实现

1. Redis 客户端初始化与序列化配置

// cache/redis.go
package cache

import (
    "context"
    "time"

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

var (
    RDB *redis.Client
    Ctx = context.Background()
)

func InitRedis(addr, password string, db int) error {
    RDB = redis.NewClient(&redis.Options{
        Addr:         addr,
        Password:     password,
        DB:           db,
        DialTimeout:  5 * time.Second,
        ReadTimeout:  3 * time.Second,
        WriteTimeout: 3 * time.Second,
        PoolSize:     50,                  // 连接池大小,结合压测调整
        MinIdleConns: 10,                 // 保持最小空闲连接
        MaxRetries:   2,                  // 网络抖动时最多重试2次
    })

    // 探活
    ctx, cancel := context.WithTimeout(Ctx, 3*time.Second)
    defer cancel()
    if err := RDB.Ping(ctx).Err(); err != nil {
        return err
    }
    return nil
}

2. 布隆过滤器实现(Redis版)

布隆过滤器有两种实现方式:用 Redis 的 SETBIT/GETBIT 自己实现,或使用 Redis 4.0 的 BF.ADD 模块。线上 Redis 版本支持 modules,我们直接用官方模块,性能更好。但这里给出基于 SETBIT 的纯逻辑实现,方便你们在没有模块插件的环境里直接复用。

// pkg/bloom/bloom.go
package bloom

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

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

// RedisBloom 基于 Redis String 的 BIT 操作实现布隆过滤器
type RedisBloom struct {
    rdb      *redis.Client
    key      string
    m        uint64 // 位数组长度
    k        uint64 // 哈希函数个数
}

// NewBloom 初始化,capacity 预估最大数量,falsePositiveRate 目标误判率
func NewBloom(rdb *redis.Client, key string, capacity uint64, falsePositiveRate float64) *RedisBloom {
    // 经典布隆过滤器参数计算
    m := uint64(math.Ceil(float64(capacity) * math.Abs(math.Log(falsePositiveRate)) / math.Pow(math.Log(2), 2)))
    k := uint64(math.Round(float64(m) / float64(capacity) * math.Log(2)))
    return &RedisBloom{
        rdb: rdb,
        key: key,
        m:   m,
        k:   k,
    }
}

// hash 使用 FNV-1a 系列生成 k 个不同哈希值
func (b *RedisBloom) hash(data []byte, seed uint64) uint64 {
    h := fnv.New64a()
    _, _ = h.Write(data)
    // 引入偏移,避免每次哈希值相同
    return (h.Sum64() + seed) % b.m
}

// Add 写入
func (b *RedisBloom) Add(ctx context.Context, item string) error {
    pipe := b.rdb.Pipeline()
    for i := uint64(0); i < b.k; i++ {
        offset := b.hash([]byte(item), i)
        pipe.SetBit(ctx, b.key, int64(offset), 1)
    }
    _, err := pipe.Exec(ctx)
    return err
}

// Contains 判断是否存在
func (b *RedisBloom) Contains(ctx context.Context, item string) (bool, error) {
    for i := uint64(0); i < b.k; i++ {
        offset := b.hash([]byte(item), i)
        val, err := b.rdb.GetBit(ctx, b.key, int64(offset)).Result()
        if err != nil {
            return false, err
        }
        if val == 0 {
            return false, nil
        }
    }
    return true, nil
}

这里要强调:布隆过滤器判断"可能存在"时,一定要走后续流程(查缓存→查库→回写)。判断"不存在"时,直接返回空。误判只是多查一次,不会有数据错误。

3. 带互斥锁的缓存读取核心逻辑

以下代码是文章的核心。完整实现了三种防御:布隆过滤器挡穿透,互斥锁挡击穿,过期时间随机扰动挡雪崩。

// service/order.go
package service

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "math/rand"
    "time"

    "github.com/google/uuid"
    "github.com/redis/go-redis/v9"

    "your-project/cache"
    "your-project/pkg/bloom"
)

const (
    orderCacheKeyPrefix = "order:detail:"
    orderLockKeyPrefix  = "order:lock:"
    // 缓存过期时间:基础值 + 随机扰动,避免雪崩
    orderCacheTTLBase  = 3600
    orderCacheTTLRange = 300
)

var (
    ErrOrderNotFound = errors.New("order not found")
    orderBloom       = bloom.NewBloom(cache.RDB, "bloom:order", 1_000_000, 0.01)
)

// GetOrderDetail 获取订单详情(核心入口)
func GetOrderDetail(ctx context.Context, orderID string) (map[string]interface{}, error) {
    // ========== 第一层:布隆过滤器拦截穿透 ==========
    exists, err := orderBloom.Contains(ctx, orderID)
    if err != nil {
        // 布隆过滤器本身出错时,降级为放行,避免过滤器故障影响主流程
        // 但要记录日志告警
        fmt.Printf("[warn] bloom contains err: %v\n", err)
    }
    if !exists {
        // 布隆过滤器判定不存在,直接返回,不再查询
        return nil, ErrOrderNotFound
    }

    // ========== 第二层:查询 Redis 缓存 ==========
    cacheKey := orderCacheKeyPrefix + orderID
    val, err := cache.RDB.Get(ctx, cacheKey).Bytes()
    if err == nil {
        // 命中缓存
        var order map[string]interface{}
        if jsonErr := json.Unmarshal(val, &order); jsonErr != nil {
            // 反序列化失败,按未命中处理,重建缓存
        } else {
            return order, nil
        }
    } else if !errors.Is(err, redis.Nil) {
        // Redis 出现非 nil 错误,降级查库,避免缓存故障拖垮服务
        fmt.Printf("[warn] redis get err: %v\n", err)
    }

    // ========== 第三层:互斥锁防止击穿 ==========
    // lockKey 使用订单号,一次只有一个请求能拿到锁去重建缓存
    lockKey := orderLockKeyPrefix + orderID
    lockValue := uuid.NewString() // 使用 uuid 作为锁标识,用于释放时校验
    acquired, err := cache.RDB.SetNX(ctx, lockKey, lockValue, 10*time.Second).Result()
    if err != nil {
        // 获取锁时 Redis 出错,直接查库,不阻塞业务流程
        return queryDBAndRebuildCache(ctx, orderID, cacheKey)
    }

    if !acquired {
        // 没拿到锁,说明其他线程正在重建缓存。
        // 这里采用"短时间重试读取缓存"而不是"直接查库",
        // 避免所有线程都穿透到数据库。
        for i := 0; i < 3; i++ {
            time.Sleep(50 * time.Millisecond) // 原子睡眠 50ms
            val, retryErr := cache.RDB.Get(ctx, cacheKey).Bytes()
            if retryErr == nil {
                var order map[string]interface{}
                if jsonErr := json.Unmarshal(val, &order); jsonErr == nil {
                    return order, nil
                }
            }
        }
        // 重试 3 次仍拿不到,降级查库(见下方的「避坑」段落:此处必须加降级开关)
        return queryDBAndRebuildCache(ctx, orderID, cacheKey)
    }

    // 拿到锁的线程负责重建缓存
    // 执行完后必须释放锁
    defer func() {
        // 释放锁时校验 value,防止误删其他线程刚设置的锁
        // 使用 Lua 脚本保证原子性
        script := `
            if redis.call("get", KEYS[1]) == ARGV[1] then
                return redis.call("del", KEYS[1])
            else
                return 0
            end
        `
        cache.RDB.Eval(ctx, script, []string{lockKey}, lockValue)
    }()

    return queryDBAndRebuildCache(ctx, orderID, cacheKey)
}

// queryDBAndRebuildCache 查数据库,回填缓存
func queryDBAndRebuildCache(ctx context.Context, orderID, cacheKey string) (map[string]interface{}, error) {
    // 模拟查询 MySQL(实际场景会调用 dao 层)
    order, err := queryOrderFromDB(ctx, orderID)
    if err != nil {
        return nil, err
    }

    if order == nil {
        // 注意:数据库里也没有这个订单,仍然缓存空值 60 秒
        // 这是"穿透"的第二道防线:即便布隆过滤器有误判,也不会让恶意 key 反复打到数据库
        nullValue := []byte("[]")
        cache.RDB.Set(ctx, cacheKey, nullValue, 60*time.Second)
        return nil, ErrOrderNotFound
    }

    data, err := json.Marshal(order)
    if err != nil {
        return nil, fmt.Errorf("marshal order failed: %w", err)
    }

    // 关键点:过期时间设置为基础值 + 随机扰动,避免大量同前缀 key 同时过期
    ttl := orderCacheTTLBase + rand.Intn(orderCacheTTLRange)
    if err := cache.RDB.Set(ctx, cacheKey, data, time.Duration(ttl)*time.Second).Err(); err != nil {
        // 写缓存失败不阻断主流程
        fmt.Printf("[warn] redis set err: %v\n", err)
    }
    return order, nil
}

// queryOrderFromDB 模拟数据库查询
func queryOrderFromDB(ctx context.Context, orderID string) (map[string]interface{}, error) {
    // 这里省略实际 SQL 执行过程
    // 如果查询无结果返回 nil, nil
    return nil, nil
}

4. 批量初始化布隆过滤器的脚本

布隆过滤器不是自动维护的,新增数据时需要主动写入。线上场景的初始化逻辑:启动时全量加载订单号,后续订单创建时异步写入。

// cmd/init_bloom/main.go
package main

import (
    "context"
    "fmt"
    "log"

    "your-project/cache"
    "your-project/pkg/bloom"
)

func main() {
    // 初始化 Redis 连接
    if err := cache.InitRedis("127.0.0.1:6379", "", 0); err != nil {
        log.Fatalf("init redis failed: %v", err)
    }

    bloomKey := "bloom:order"
    b := bloom.NewBloom(cache.RDB, bloomKey, 1_000_000, 0.01)

    ctx := context.Background()

    // 首次构建前先删除旧 key,避免旧数据残留
    cache.RDB.Del(ctx, bloomKey)

    // 模拟从 MySQL 全量加载订单号
    orderIDs := loadAllOrderIDsFromDB()
    added := 0
    for _, id := range orderIDs {
        if err := b.Add(ctx, id); err != nil {
            log.Printf("add %s failed: %v", id, err)
        } else {
            added++
        }
    }

    fmt.Printf("bloom filter initialized, total %d, added %d\n", len(orderIDs), added)
}

5. 新增订单时的布隆过滤器与缓存淘汰策略

数据变更时,要保证布隆过滤器里有这个 key,同时删除旧的缓存,避免脏数据。

// service/order_write.go
package service

import (
    "context"
    "fmt"

    "your-project/cache"
    "your-project/pkg/bloom"
)

var orderBloomWrite = bloom.NewBloom(cache.RDB, "bloom:order", 1_000_000, 0.01)

// CreateOrder 创建订单
func CreateOrder(ctx context.Context, orderID string) error {
    // 1. 写入 MySQL(省略)
    if err := insertOrderToDB(ctx, orderID); err != nil {
        return err
    }

    // 2. 写入布隆过滤器
    if err := orderBloomWrite.Add(ctx, orderID); err != nil {
        // 写入失败必须记录日志并告警,否则后续查询该订单会一直被拦截
        fmt.Printf("[critical] bloom add failed: %v\n", err)
    }

    // 3. 删除该订单的缓存(如果存在),下次请求会从数据库重新拉取
    cacheKey := orderCacheKeyPrefix + orderID
    if err := cache.RDB.Del(ctx, cacheKey).Err(); err != nil {
        fmt.Printf("[warn] del order cache failed: %v\n", err)
    }
    return nil
}

6. 压测脚本

# 压测命令:订单详情接口
# 并发 200,持续 60 秒,每次请求使用不存在的订单号:模拟穿透场景
wrk -t8 -c200 -d60s --latency 'http://127.0.0.1:8080/api/order/NO_EXIST_ORDER_01'

# 压测命令:热点订单号并发访问:模拟击穿场景
# 先构造一个已过期的热点缓存,然后 200 并发同时请求
wrk -t8 -c200 -d30s --latency 'http://127.0.0.1:8080/api/order/HOT_ORDER_20240101001'

# 压测命令:批量订单号访问:模拟雪崩场景
# 脚本循环生成 10000 个订单号,均带缓存,但在同一秒过期
# 使用 Python 脚本生成 URL 列表,再用 wrk 读取压测
cat urls.txt | xargs -P 8 -I {} curl -s -o /dev/null -w "%{http_code}\n" {}

效果数据

压测环境:两台 4C8G 的云服务器,一台部署应用服务,一台部署 MySQL 8.0.35 与 Redis 7.2.4。测试数据:订单表 50 万条,缓存预热 30 万条。以下是完整对比数据。

场景方案QPSP99 (ms)数据库 QPSRedis 命中率
正常缓存命中无额外防御12,0008.2099.2%
穿透(不存在的key)无防御6,50095.36,50052.4%
穿透(不存在的key)布隆过滤器11,2009.10
击穿(热点key过期)无防御7,80073.67,800
击穿(热点key过期)互斥锁10,50012.81
雪崩(1000个key同时过期)无防御5,20088.45,200
雪崩(1000个key同时过期)随机过期时间11,00010.3≈350

提一组关键数据:

  • 布隆过滤器在 50 万条数据下,位数组长度设计为 1,000,000,内存占用约 1.2MB,误判率实测 0.8%(目标 1%)。每个请求查询耗时增加 0.3ms(两次 GETBIT,Redis 内网 RTT 约 0.15ms)。
  • 互斥锁方案下,200 并发同时过期时,只有 1 个请求真正穿透到数据库,其余请求在 50ms 重试间隔后直接命中缓存,平均耗时 12.8ms,比无防御时的 73.6ms 降低 82.6%
  • 随机过期时间方案下,1000 个 key 同时过期的情况下,数据库压力从 5200 QPS 降到了 350 QPS,减少 93.3%
  • 整体压测结束后,Redis 命中率恢复到 98.7%,数据库连接数稳定在 35 左右

避坑指南

这些坑都是我们实际踩过的,每一个都付出了线上告警的代价。

坑1:互斥锁的锁粒度太粗

一开始我们用了一把全局锁来重建缓存,导致不同订单号的缓存重建互相阻塞。后来改成按订单号维度加锁,锁的 key 格式为 order:lock:{orderID}。这里要注意:锁的 key 不能包含用户标识或其他高基数维度,否则热点 key 的并发保护会失效。

坑2:布隆过滤器漏写导致误拦截真实数据

上线布隆过滤器第二天,客服反馈有订单查询不到。排查发现是新增订单时,写入布隆过滤器的逻辑放在事务提交之后,但当时事务提交成功,布隆过滤器写入 Redis 超时抛错,代码只打了日志没有重试。后续该订单号永远被布隆过滤器拦截。

解决:新增数据的布隆过滤器写入失败必须从 info 级警报升级为 P1 告警。另外可以在订单查询时增加一个"逃生通道":如果布隆过滤器判定不存在,可以按 1% 概率放行到数据库查询,防止布隆过滤器故障导致大面积误杀。这个概率值可以根据业务容忍度调整。

坑3:释放锁时没有校验 value,误删了别人的锁

初版代码释放锁直接 DEL lockKey。在高并发下,线程 A 的锁 10 秒过期,线程 B 拿到新锁,此时线程 A 业务执行完释放锁,把 B 的锁删了。接着线程 C 拿到锁,B 执行完又删了 C 的锁。这样所有请求都会串行执行重建逻辑,缓存形同虚设。

解决:使用 Lua 脚本先比对锁的 value 再删除。代码里已经给出,不要再删掉这个校验。

坑4:互斥锁等待时间过短导致降级查库

最早的代码,获取锁失败后只重试一次,且间隔 10ms。在大流量下,重建缓存需要 100ms 以上,重试时缓存还没写好,于是大量线程直接降级查库,锁的防护效果失效。

线上调整策略:重试 3 次,间隔 50ms,共 150ms。绝大多数缓存重建都能在 150ms 内完成。如果 150ms 还没完成,说明数据库慢查询,此时降级查库反而会拖垮数据库。更合理的做法是:重试仍失败时,直接返回"服务繁忙"或返回旧缓存(如果有)。

坑5:随机过期时间没加范围控制

雪崩的修复方案不是简单地"加随机值"。一开始我们把 TTL 写成 3600 + rand.Intn(3600),导致最长的缓存存活时间达到 2 小时,数据更新后最迟 2 小时才能生效。后来调整为基础值 3600 + 随机 0~300 秒,既打散了过期分布,又控制了数据生效延迟。

坑6:Redis 故障时没有降级开关

做方案时只考虑了缓存过期场景,没考虑 Redis 实例本身宕机。如果 Redis 挂在业务主链路上,所有请求都会等 Redis 超时(默认 3 秒),然后全部降级查库,数据库会被瞬间打死。

后续加了全局开关:触达 Redis 的每个操作都有超时和错误统计,如果连续 10 秒错误率超过 50%,直接短路 Redis 访问,全部走本地缓存(进程内 map + 短 TTL)+ 数据库限流。这个改造是另一个话题,但必须提出来——缓存方案的前提是缓存高可用,Redis 不能用时要有第二套降级路径

总结

三个问题不是独立存在的。缓存穿透会放大击穿的影响,击穿产生的数据库压力会诱发雪崩。防御方案也不是越多越好:布隆过滤器本身有误判率,互斥锁有等待时间,降级查库有限流成本。线上的完整配置参数过一遍:

  • 缓存 TTL:3600 + rand.Intn(300) 秒
  • 布隆过滤器:容量 1,000,000,误判率 1%,内存 1.2MB
  • 互斥锁:持有时间 10 秒,重试 3 次,间隔 50ms
  • 空值缓存:60 秒
  • Redis 连接池:50 连接,最小空闲 10
  • Redis 超时:Dial 5s / Read 3s / Write 3s

这套方案上线后稳定运行 4 个月,没有再出现缓存穿透引起的数据库告警。文章里的代码可以直接改改业务逻辑落地,但记得先把「避坑」段落读一遍。