模拟 Redis 挂了、数据刚好过期、100 个请求同时打进来,这套单元测试怎么搭
做过缓存层重构的人都有这种体会:最怕的不是 Redis 挂了,是挂了之后你才发现单元测试根本没覆盖这个分支。最近我在给一个老项目的缓存层动刀,顺手搭了一套专门针对缓存穿透和雪崩场景的测试,这里记录一下踩坑过程。
先回答标题的问题:核心思路是用一个可控的 Mock Redis Client,在测试里精确控制它返回 null、抛异常或者延迟响应,然后用并发原语同时发起 100 个请求,验证你的缓存逻辑是否真的只穿透了一次数据库。
不是简单地写个 mock.returns(null) 就完事,那种测试基本是自欺欺人。
先把 Redis 的三种异常态模拟出来
缓存层最核心的依赖就是一个 Redis Client,我这里用的是 go-redis,但思路对任何语言都通用。关键点是不要 mock 整个 cache 层,而是 mock 最底层的 Redis 操作接口,这样你的缓存逻辑代码(查缓存、回填缓存、加锁等)才是真正被测试到的。
定义一个接口:
type RedisClient interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
Del(ctx context.Context, key string) error
}
然后在测试里用 testify/mock 来控制行为。三种场景分别这样模拟:
场景一:Redis 正常返回 null(缓存未命中)
mockRedis.On("Get", mock.Anything, "user:123").Return("", redis.Nil)
注意这里返回的是 redis.Nil,不是普通的 errors.New("not found"),你的缓存层必须能区分「key 不存在」和「Redis 连接失败」,这是两码事。
场景二:Redis 连接超时或不可用
mockRedis.On("Get", mock.Anything, mock.Anything).
Return("", errors.New("dial tcp 127.0.0.1:6379: connect: connection refused"))
或者更真实一点,模拟 context 超时:
mockRedis.On("Get", mock.Anything, mock.Anything).
Return("", context.DeadlineExceeded)
这时候你的缓存层应该走降级逻辑——是直接返回错误还是穿透到数据库,取决于你的设计。但如果静默地吞掉错误然后返回一个空结果,那就是线上事故的伏笔。
场景三:数据刚好在 Redis 里过期了,且 DB 里也没有
这就是经典的缓存穿透。模拟方式:第一次 Get 返回 redis.Nil,然后 mock DB 查询也返回空,此时 100 个并发请求如果没有互斥锁保护,就会全部砸到 DB 上。
mockRedis.On("Get", mock.Anything, "user:123").Return("", redis.Nil)
mockDB.On("QueryUser", mock.Anything, 123).Return(nil, sql.ErrNoRows)
// 注意:这里不应该调用 mockRedis.On("Set"),因为 DB 里没有数据
// 但为了防止穿透,你可能会缓存一个空值标记(比如 "NULL" 字符串,过期时间 60 秒)
100 个并发请求怎么发
别用 time.Sleep 去模拟并发,那测不出真正的竞态条件。用 sync.WaitGroup 或者 errgroup,让 100 个 goroutine 在同一个时间点同时触发:
func TestCachePenetration_ConcurrentRequests(t *testing.T) {
mockRedis := new(MockRedisClient)
mockDB := new(MockUserDB)
cache := NewUserCache(mockRedis, mockDB)
// 设置 mock 行为:缓存未命中,DB 有数据
mockRedis.On("Get", mock.Anything, "user:123").Return("", redis.Nil)
mockDB.On("QueryUser", mock.Anything, 123).Return(&User{ID: 123, Name: "test"}, nil).Once()
mockRedis.On("Set", mock.Anything, "user:123", mock.Anything, 5*time.Minute).Return(nil)
var wg sync.WaitGroup
results := make(chan *User, 100)
// 用 channel 做栅栏,让所有 goroutine 同时起跑
start := make(chan struct{})
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start // 等待起跑信号
user, err := cache.GetUser(context.Background(), 123)
if err == nil {
results <- user
}
}()
}
close(start) // 所有 goroutine 同时开始
wg.Wait()
close(results)
// 验证:DB 只被查询了一次(Once 已经保证了)
mockDB.AssertExpectations(t)
// 验证:所有请求都拿到了正确结果
assert.Equal(t, 100, len(results))
}
这里 mockDB.On(...).Once() 是最关键的断言。如果你的缓存层没有正确处理并发穿透,这行就会失败,因为 100 个请求会调用 100 次 DB。
模拟雪崩:让第一个请求卡住
缓存雪崩的单元测试稍微复杂一点,因为它涉及时间窗口。简单模拟的方式是让第一个获取缓存的请求故意延迟,看后续请求是被阻塞等待还是直接穿透:
func TestCacheAvalanche_FirstRequestSlow(t *testing.T) {
mockRedis := new(MockRedisClient)
mockDB := new(MockUserDB)
cache := NewUserCache(mockRedis, mockDB)
// 第一个 Get 调用延迟 500ms 返回 miss
mockRedis.On("Get", mock.Anything, "hotkey:1").
After(500 * time.Millisecond).
Return("", redis.Nil).
Once()
// 后续 Get 调用正常返回
mockRedis.On("Get", mock.Anything, "hotkey:1").
Return(`{"id":1,"name":"cached"}`, nil)
// DB 查询应该只被触发一次
mockDB.On("QueryUser", mock.Anything, 1).
Return(&User{ID: 1, Name: "db"}, nil).
Once()
mockRedis.On("Set", mock.Anything, "hotkey:1", mock.Anything, 5*time.Minute).
Return(nil)
// 同时发起 10 个请求
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, _ = cache.GetUser(context.Background(), 1)
}()
}
close(start)
wg.Wait()
mockDB.AssertExpectations(t)
}
这里用 After(500 * time.Millisecond) 模拟第一个请求回源很慢,如果你的代码用了 singleflight 或者 mutex,其他请求会等待第一个请求完成并共享结果。如果没有这个保护,DB 的 Once 断言就会失败。
要把这些测试放进 CI 流水线里
单独跑一次能过不算什么,关键是每次提交都能跑。这种测试有几点要注意:
- Mock 的断言要严格,
Once()、Times(n)必须精确,不要用Maybe()这种宽松匹配 - 并发测试在 CI 环境可能比本地慢很多,设置合理的超时:
go test -timeout 30s -race ./... - 一定要开 race detector(
-race参数),并发问题在本地可能一万次都不出现,但 CI 上开了 race 能抓到很多隐蔽的数据竞争
我在实际项目里碰到过一个 case:缓存穿透的保护逻辑用了一个 map 做互斥锁,但这个 map 没有加锁保护,单测跑一百遍都正常,开了 -race 立刻爆红。
边界情况:Redis 返回脏数据
还有一种容易被忽略的场景:Redis 连接正常,但返回了格式错误的数据。比如你存的是 JSON,但某个版本升级后改动了结构,老数据还在缓存里:
mockRedis.On("Get", mock.Anything, "user:123").
Return(`{"id":123,"name":123}`, nil) // name 应该是 string 但返回了 number
你的缓存层应该能处理 JSON 反序列化失败的情况,是直接返回错误还是清除这条脏缓存然后回源,需要明确的策略。测试里可以验证:
mockRedis.On("Del", mock.Anything, "user:123").Return(nil)
mockDB.On("QueryUser", mock.Anything, 123).Return(&User{...}, nil).Once()
这样能保证脏数据被及时清理掉。
常见问题
问题:为什么不直接 mock 整个 cache 层,而是 mock Redis client?
mock cache 层等于什么都没测。你测的是「如果缓存返回空,我的业务逻辑是否正确」,但这个「如果」在真实环境里是由 Redis 的状态决定的。mock Redis client 能模拟出 redis.Nil、连接断开、超时这些真实故障,你的缓存逻辑代码(包括重试、降级、回填)才会被真正执行到。
问题:并发测试不稳定,有时候过有时候不过,怎么办?
大概率是你的缓存保护机制有竞态条件,而不是测试本身的问题。先确认是否加了 -race,然后检查你的锁实现。常见的坑是用普通的 map 做锁表但没有加 sync.Mutex,或者 double-check 的逻辑写得不对。另外 mock 的 Once() 断言是严格的调用次数检查,如果偶尔失败说明确实有多次穿透,只是概率问题。
问题:生产环境 Redis 是集群模式,单测里要模拟吗?
不需要,单元测试只测你的缓存逻辑代码。集群模式影响的是数据分片、故障转移这些 Redis 本身的行为,这些应该由集成测试或混沌工程来覆盖。单测里你只要 mock 住 Redis 的接口,模拟它返回的各种状态就够了。把集群的复杂度带进单测只会让测试变得脆弱。