前阵子电商大促活动,核心商品详情页突然打不开,报警短信轰炸式地发过来。进监控一看,数据库 CPU 直接拉满到 100%,连接池瞬间被撑爆。排查后发现,运营临时配置了一个爆款商品的秒杀活动,由于缓存预热没做好,活动一开始,几万个高并发突发请求同时打过来。当时缓存正好过期,这股海量流量直接穿透缓存击穿到 DB,导致数据库瞬间瘫痪。

这种高并发下的“热点数据击穿”和“缓存雪崩”绝对是每个后端开发都绕不开的大坑。如果不对查库操作做并发限制,一旦缓存失效,所有请求都会觉得“我有责任去更新缓存”,然后疯狂调 SQL。

要搞定这个痛点,最常用的招数就是基于 Redis 的 SETNX(或者新版 Redis 的 SET key value NX PX)去实现一个分布式互斥锁(Mutex)。当缓存失效时,只有一个线程能抢到互斥锁,获准去查库并重构缓存,其他抢不到锁的线程只能稍微眯一会儿(Sleep)然后重新尝试获取缓存。

下面是一个在 Go 语言中利用 go-redis 客户端实现的完整、防击穿的 Mutex 缓存读取逻辑:

package main

import (
	"context"
	"errors"
	"time"

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

type CacheManager struct {
	redisClient *redis.Client
	dbClient    *MockDBClient
}

type MockDBClient struct{}

func (db *MockDBClient) FetchFromDB(ctx context.Context, key string) (string, error) {
	// 模拟一次耗时 200ms 的复杂 DB 查询
	time.Sleep(200 * time.Millisecond)
	return "mocked-db-value-for-" + key, nil
}

func (cm *CacheManager) GetWithMutex(ctx context.Context, key string, expire time.Duration) (string, error) {
	// 1. 尝试直接从 Redis 获取缓存
	val, err := cm.redisClient.Get(ctx, key).Result()
	if err == nil {
		return val, nil
	}
	if !errors.Is(err, redis.Nil) {
		return "", err
	}

	// 2. 缓存失效,尝试抢占互斥锁
	lockKey := key + ":mutex"
	// 设置 5 秒的锁超时时间,防止拿到锁的实例挂掉导致死锁
	acquired, err := cm.redisClient.SetNX(ctx, lockKey, "1", 5*time.Second).Result()
	if err != nil {
		return "", err
	}

	if acquired {
		// 抢锁成功,负责查 DB 并重写缓存
		defer cm.redisClient.Del(ctx, lockKey)

		dbVal, dbErr := cm.dbClient.FetchFromDB(ctx, key)
		if dbErr != nil {
			return "", dbErr
		}

		// 将新值写入缓存
		err = cm.redisClient.Set(ctx, key, dbVal, expire).Err()
		if err != nil {
			return "", err
		}
		return dbVal, nil
	} else {
		// 抢锁失败,说明有其他线程正在重构缓存。等待 50 毫秒后重试
		time.Sleep(50 * time.Millisecond)
		return cm.GetWithMutex(ctx, key, expire)
	}
}

使用这种机制后,我们在线上进行了压测,数据库的并发压力直线下降,原本上万的并发查询在缓存失效的一瞬间,最终只有 1 个请求真正打到了 DB 上,其余全部在短时间内被阻挡在外,等待重试直接命中新缓存。

不过需要注意,这种自旋重试的写法如果高并发请求过多,可能会导致大量协程堆积,甚至造成内存溢出。在更极端的生产环境里,可能需要结合 SingleFlight(Go 官方库提供的单路合并机制)来做本地内存级别的合并,或者直接做物理层面的热点数据永不过期策略。

你们在生产环境中碰到缓存击穿,是用 SingleFlight 还是用 Redis 互斥锁,或者是其他更骚的操作?