userDetailsLoader_negativeCache_audit_test.go 5.0 KB

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