| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130 |
- package loaders
- import (
- "context"
- "sync/atomic"
- "testing"
- "time"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
- // ---------------------------------------------------------------------------
- // 覆盖目标:审计 M-3 修复 —— 对不存在/已删除用户的 load 结果必须写入短 TTL 负缓存哨兵,
- // 使后续同 userId/productCode 的 Load 在 TTL 内直接命中哨兵返回空 UserDetails,
- // 不再重复穿透到 DB,阻断离职用户残余 token 对 DB 的 DoS 放大。
- // ---------------------------------------------------------------------------
- // TC-0821: M-3 —— Load 不存在的 userId 第二次必须命中负缓存,不再触发 DB FindOne。
- func TestUserDetailsLoader_NegativeCache_HitsOnSecondCall(t *testing.T) {
- ctx := context.Background()
- loader := newTestLoader()
- // 随便选一个几乎肯定不存在的 id(避免与真实测试数据冲突)。
- nonExistId := int64(900_000_000 + time.Now().UnixNano()%100_000)
- productCode := "pc_neg_" + uniqueId()
- // 确保无残留缓存。
- loader.Del(ctx, nonExistId, productCode)
- // 第 1 次 Load:预期回写负缓存哨兵。
- // M-1 后 Load 的返回契约从 *UserDetails 扩展为 (*UserDetails, error);
- // 不存在用户走的是 (ud, nil) 语义 (ud.Username == ""),而不是 (nil, err)。
- ud1, err := loader.Load(ctx, nonExistId, productCode)
- require.NoError(t, err, "用户不存在应走 (ud,nil) 语义而不是 (nil,err)")
- require.NotNil(t, ud1)
- assert.Empty(t, ud1.Username, "不存在的用户 Load 后 Username 必须为空")
- // 直接读 Redis,验证哨兵值真的写进去了。
- key := loader.cacheKey(nonExistId, productCode)
- val, err := loader.rds.GetCtx(ctx, key)
- require.NoError(t, err)
- assert.Equal(t, negativeCacheMarker, val,
- "M-3:不存在的用户必须写入负缓存哨兵 %q,以便后续命中直接返回空 UserDetails", negativeCacheMarker)
- // 第 2 次 Load:必须命中哨兵分支;哨兵应当返回空 UserDetails(Username 依然为空),
- // 且不得再做 DB 查询(这里没有 mock DB counter,但结果的契约仍然成立)。
- ud2, err := loader.Load(ctx, nonExistId, productCode)
- require.NoError(t, err)
- require.NotNil(t, ud2)
- assert.Empty(t, ud2.Username)
- assert.Equal(t, nonExistId, ud2.UserId)
- assert.Equal(t, productCode, ud2.ProductCode)
- // TTL 必须 > 0 且 <= negativeCacheTTL,说明负缓存是短 TTL,不会长期遮蔽刚刚被重建的用户。
- ttl, err := loader.rds.TtlCtx(ctx, key)
- require.NoError(t, err)
- assert.Greater(t, ttl, 0, "负缓存必须是带 TTL 的短窗口")
- assert.LessOrEqual(t, ttl, negativeCacheTTL,
- "负缓存 TTL 不得超过 %ds,避免误伤刚 createUser 的合法用户", negativeCacheTTL)
- t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
- }
- // TC-0822: M-3 —— 负缓存必须"不挂到 userIndex/productIndex 集合里",
- // 否则 CleanByProduct / Clean 在 DEL 其它真实 key 的同时会顺带 DEL 哨兵,带来短暂"放穿"。
- // 该测试验证:写入负缓存之后,userIndex/productIndex 集合为空。
- func TestUserDetailsLoader_NegativeCache_NotIndexed(t *testing.T) {
- ctx := context.Background()
- loader := newTestLoader()
- nonExistId := int64(900_000_123 + time.Now().UnixNano()%10_000)
- productCode := "pc_idx_" + uniqueId()
- loader.Del(ctx, nonExistId, productCode)
- _, _ = loader.Load(ctx, nonExistId, productCode)
- uidx, err := loader.rds.SmembersCtx(ctx, loader.userIndexKey(nonExistId))
- require.NoError(t, err)
- assert.Empty(t, uidx,
- "M-3:负缓存不得注册到 user index,否则 Clean(userId) 会把哨兵一起抹掉导致立刻再次击穿 DB")
- pidx, err := loader.rds.SmembersCtx(ctx, loader.productIndexKey(productCode))
- require.NoError(t, err)
- assert.Empty(t, pidx,
- "负缓存同样不得进入 product index")
- t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
- }
- // TC-0823: M-3 —— 多并发同一 nonExistId 只穿透 DB 一次(singleflight + 负缓存联动)。
- // 使用 singleflight 组 + 负缓存的组合应保证:N 个并发 Load 对同一个不存在用户在第一次完成后,
- // 后续都走哨兵命中;即便 singleflight 窗口内共享同一 DB 查询,对 DB 的压力也至多 1 次。
- // 这里我们无法直接计数 DB 调用(没有 DB mock 接入 loader),因此用对 key 的最终 GET 值来验证
- // 最终状态是哨兵,并且 Load 耗时稳定(不会因每次都查 DB 出现显著抖动)。
- func TestUserDetailsLoader_NegativeCache_ConcurrentLoadsStabilize(t *testing.T) {
- ctx := context.Background()
- loader := newTestLoader()
- nonExistId := int64(900_000_456 + time.Now().UnixNano()%10_000)
- productCode := "pc_conc_" + uniqueId()
- loader.Del(ctx, nonExistId, productCode)
- const N = 32
- var done int32
- ch := make(chan struct{})
- for i := 0; i < N; i++ {
- go func() {
- defer func() {
- if atomic.AddInt32(&done, 1) == N {
- close(ch)
- }
- }()
- _, _ = loader.Load(ctx, nonExistId, productCode)
- }()
- }
- select {
- case <-ch:
- case <-time.After(5 * time.Second):
- t.Fatal("并发 Load 未在 5s 内收敛,singleflight/负缓存可能失效")
- }
- val, err := loader.rds.GetCtx(ctx, loader.cacheKey(nonExistId, productCode))
- require.NoError(t, err)
- assert.Equal(t, negativeCacheMarker, val)
- t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
- }
|