userDetailsLoader_negativeCache_audit_test.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. package loaders
  2. import (
  3. "context"
  4. "sync/atomic"
  5. "testing"
  6. "time"
  7. "github.com/stretchr/testify/assert"
  8. "github.com/stretchr/testify/require"
  9. )
  10. // ---------------------------------------------------------------------------
  11. // 覆盖目标:审计 M-3 修复 —— 对不存在/已删除用户的 load 结果必须写入短 TTL 负缓存哨兵,
  12. // 使后续同 userId/productCode 的 Load 在 TTL 内直接命中哨兵返回空 UserDetails,
  13. // 不再重复穿透到 DB,阻断离职用户残余 token 对 DB 的 DoS 放大。
  14. // ---------------------------------------------------------------------------
  15. // TC-0821: M-3 —— Load 不存在的 userId 第二次必须命中负缓存,不再触发 DB FindOne。
  16. func TestUserDetailsLoader_NegativeCache_HitsOnSecondCall(t *testing.T) {
  17. ctx := context.Background()
  18. loader := newTestLoader()
  19. // 随便选一个几乎肯定不存在的 id(避免与真实测试数据冲突)。
  20. nonExistId := int64(900_000_000 + time.Now().UnixNano()%100_000)
  21. productCode := "pc_neg_" + uniqueId()
  22. // 确保无残留缓存。
  23. loader.Del(ctx, nonExistId, productCode)
  24. // 第 1 次 Load:预期回写负缓存哨兵。
  25. // M-1 后 Load 的返回契约从 *UserDetails 扩展为 (*UserDetails, error);
  26. // 不存在用户走的是 (ud, nil) 语义 (ud.Username == ""),而不是 (nil, err)。
  27. ud1, err := loader.Load(ctx, nonExistId, productCode)
  28. require.NoError(t, err, "用户不存在应走 (ud,nil) 语义而不是 (nil,err)")
  29. require.NotNil(t, ud1)
  30. assert.Empty(t, ud1.Username, "不存在的用户 Load 后 Username 必须为空")
  31. // 直接读 Redis,验证哨兵值真的写进去了。
  32. key := loader.cacheKey(nonExistId, productCode)
  33. val, err := loader.rds.GetCtx(ctx, key)
  34. require.NoError(t, err)
  35. assert.Equal(t, negativeCacheMarker, val,
  36. "M-3:不存在的用户必须写入负缓存哨兵 %q,以便后续命中直接返回空 UserDetails", negativeCacheMarker)
  37. // 第 2 次 Load:必须命中哨兵分支;哨兵应当返回空 UserDetails(Username 依然为空),
  38. // 且不得再做 DB 查询(这里没有 mock DB counter,但结果的契约仍然成立)。
  39. ud2, err := loader.Load(ctx, nonExistId, productCode)
  40. require.NoError(t, err)
  41. require.NotNil(t, ud2)
  42. assert.Empty(t, ud2.Username)
  43. assert.Equal(t, nonExistId, ud2.UserId)
  44. assert.Equal(t, productCode, ud2.ProductCode)
  45. // TTL 必须 > 0 且 <= negativeCacheTTL,说明负缓存是短 TTL,不会长期遮蔽刚刚被重建的用户。
  46. ttl, err := loader.rds.TtlCtx(ctx, key)
  47. require.NoError(t, err)
  48. assert.Greater(t, ttl, 0, "负缓存必须是带 TTL 的短窗口")
  49. assert.LessOrEqual(t, ttl, negativeCacheTTL,
  50. "负缓存 TTL 不得超过 %ds,避免误伤刚 createUser 的合法用户", negativeCacheTTL)
  51. t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
  52. }
  53. // TC-0822: M-3 —— 负缓存必须"不挂到 userIndex/productIndex 集合里",
  54. // 否则 CleanByProduct / Clean 在 DEL 其它真实 key 的同时会顺带 DEL 哨兵,带来短暂"放穿"。
  55. // 该测试验证:写入负缓存之后,userIndex/productIndex 集合为空。
  56. func TestUserDetailsLoader_NegativeCache_NotIndexed(t *testing.T) {
  57. ctx := context.Background()
  58. loader := newTestLoader()
  59. nonExistId := int64(900_000_123 + time.Now().UnixNano()%10_000)
  60. productCode := "pc_idx_" + uniqueId()
  61. loader.Del(ctx, nonExistId, productCode)
  62. _, _ = loader.Load(ctx, nonExistId, productCode)
  63. uidx, err := loader.rds.SmembersCtx(ctx, loader.userIndexKey(nonExistId))
  64. require.NoError(t, err)
  65. assert.Empty(t, uidx,
  66. "M-3:负缓存不得注册到 user index,否则 Clean(userId) 会把哨兵一起抹掉导致立刻再次击穿 DB")
  67. pidx, err := loader.rds.SmembersCtx(ctx, loader.productIndexKey(productCode))
  68. require.NoError(t, err)
  69. assert.Empty(t, pidx,
  70. "负缓存同样不得进入 product index")
  71. t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
  72. }
  73. // TC-0823: M-3 —— 多并发同一 nonExistId 只穿透 DB 一次(singleflight + 负缓存联动)。
  74. // 使用 singleflight 组 + 负缓存的组合应保证:N 个并发 Load 对同一个不存在用户在第一次完成后,
  75. // 后续都走哨兵命中;即便 singleflight 窗口内共享同一 DB 查询,对 DB 的压力也至多 1 次。
  76. // 这里我们无法直接计数 DB 调用(没有 DB mock 接入 loader),因此用对 key 的最终 GET 值来验证
  77. // 最终状态是哨兵,并且 Load 耗时稳定(不会因每次都查 DB 出现显著抖动)。
  78. func TestUserDetailsLoader_NegativeCache_ConcurrentLoadsStabilize(t *testing.T) {
  79. ctx := context.Background()
  80. loader := newTestLoader()
  81. nonExistId := int64(900_000_456 + time.Now().UnixNano()%10_000)
  82. productCode := "pc_conc_" + uniqueId()
  83. loader.Del(ctx, nonExistId, productCode)
  84. const N = 32
  85. var done int32
  86. ch := make(chan struct{})
  87. for i := 0; i < N; i++ {
  88. go func() {
  89. defer func() {
  90. if atomic.AddInt32(&done, 1) == N {
  91. close(ch)
  92. }
  93. }()
  94. _, _ = loader.Load(ctx, nonExistId, productCode)
  95. }()
  96. }
  97. select {
  98. case <-ch:
  99. case <-time.After(5 * time.Second):
  100. t.Fatal("并发 Load 未在 5s 内收敛,singleflight/负缓存可能失效")
  101. }
  102. val, err := loader.rds.GetCtx(ctx, loader.cacheKey(nonExistId, productCode))
  103. require.NoError(t, err)
  104. assert.Equal(t, negativeCacheMarker, val)
  105. t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
  106. }