userDetailsLoader_singleflight_audit_test.go 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. package loaders
  2. import (
  3. "context"
  4. "database/sql"
  5. "sync"
  6. "sync/atomic"
  7. "testing"
  8. "perms-system-server/internal/consts"
  9. userModel "perms-system-server/internal/model/user"
  10. "github.com/stretchr/testify/assert"
  11. "github.com/stretchr/testify/require"
  12. )
  13. // countingUserModel 包装真实 SysUserModel, 仅拦截 FindOne 计数。
  14. // 其余 40+ 方法通过 embedding 直接 pass-through, 无需手写。
  15. type countingUserModel struct {
  16. userModel.SysUserModel
  17. findOneHits int64
  18. }
  19. func (c *countingUserModel) FindOne(ctx context.Context, id int64) (*userModel.SysUser, error) {
  20. atomic.AddInt64(&c.findOneHits, 1)
  21. return c.SysUserModel.FindOne(ctx, id)
  22. }
  23. // TC-0792: L-5 延伸 —— UserDetailsLoader 必须用 singleflight 合并同一 key 的并发 Load,
  24. // 保证缓存 miss 时 DB 只被打一次, 防止冷启动/缓存击穿。
  25. // 实现方式: 用 countingUserModel 拦截 SysUserModel.FindOne, 断言 N 个并发 Load
  26. // 触发的 FindOne 次数远少于 N (严格来说, 在我们控制的并发时序下必须恰好 1 次)。
  27. // 为避免 "第一个 goroutine 太快, 写完缓存后其他 goroutine 走 cache 路径也只是少调用"
  28. // 这种"假阳性平局", 本用例刻意先 Del 缓存 + 用 WaitGroup barrier 同时释放所有 goroutine,
  29. // 把所有 goroutine 都塞进 singleflight.Do 的同一 key flight 里。
  30. func TestLoader_Load_SingleflightCollapsesConcurrentCalls(t *testing.T) {
  31. ctx := context.Background()
  32. rds := testRedis()
  33. realModels := testModels()
  34. counting := &countingUserModel{SysUserModel: realModels.SysUserModel}
  35. // 替换 models 里的 SysUserModel 为计数包装; 其他模型保持真实以便 loader 的产品/成员/部门/角色/权限流转能跑通
  36. wrappedModels := *realModels
  37. wrappedModels.SysUserModel = counting
  38. loader := NewUserDetailsLoader(rds, testKeyPrefix, &wrappedModels)
  39. u := &userModel.SysUser{
  40. Username: "ld_sf_" + uniqueId(), Password: hashPwd("x"), Nickname: "sf",
  41. Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
  42. MustChangePassword: consts.MustChangePasswordNo, Status: consts.StatusEnabled,
  43. CreateTime: now(), UpdateTime: now(),
  44. }
  45. userId := insertUser(ctx, t, realModels, u)
  46. t.Cleanup(func() { cleanTable(ctx, testConn(), "sys_user", userId) })
  47. // 确保缓存为空
  48. loader.Del(ctx, userId, "")
  49. loader.Clean(ctx, userId)
  50. const workers = 50
  51. var (
  52. wg sync.WaitGroup
  53. start = make(chan struct{})
  54. ptrs = make([]*UserDetails, workers)
  55. )
  56. for i := 0; i < workers; i++ {
  57. wg.Add(1)
  58. go func(idx int) {
  59. defer wg.Done()
  60. <-start
  61. ptrs[idx] = loader.Load(ctx, userId, "")
  62. }(i)
  63. }
  64. close(start)
  65. wg.Wait()
  66. // 每个 goroutine 都应拿到完整的用户数据
  67. for i, p := range ptrs {
  68. require.NotNil(t, p, "worker %d 返回 nil", i)
  69. assert.Equal(t, u.Username, p.Username, "worker %d 读到的 Username 错乱", i)
  70. }
  71. hits := atomic.LoadInt64(&counting.findOneHits)
  72. assert.LessOrEqual(t, hits, int64(workers/5),
  73. "singleflight 必须把 DB 命中压到极少次 (远低于 workers=%d); 实际 FindOne 被调 %d 次", workers, hits)
  74. assert.Greater(t, hits, int64(0), "至少要有一次 DB 命中 (否则说明缓存未被真正清空)")
  75. }
  76. // TC-0793: L-5 延伸 —— 第二波 Load 必须命中缓存, FindOne 不再增加。
  77. // 这是对 TC-0762 的成对断言: singleflight 合并仅作用于"同一飞行中的并发",
  78. // 而一旦首次加载完成并写入 Redis, 后续读取应进入 cache fast-path 而非再次走 DB。
  79. func TestLoader_Load_SecondRoundHitsCache(t *testing.T) {
  80. ctx := context.Background()
  81. rds := testRedis()
  82. realModels := testModels()
  83. counting := &countingUserModel{SysUserModel: realModels.SysUserModel}
  84. wrappedModels := *realModels
  85. wrappedModels.SysUserModel = counting
  86. loader := NewUserDetailsLoader(rds, testKeyPrefix, &wrappedModels)
  87. u := &userModel.SysUser{
  88. Username: "ld_sf2_" + uniqueId(), Password: hashPwd("x"), Nickname: "sf2",
  89. Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
  90. MustChangePassword: consts.MustChangePasswordNo, Status: consts.StatusEnabled,
  91. CreateTime: now(), UpdateTime: now(),
  92. }
  93. userId := insertUser(ctx, t, realModels, u)
  94. t.Cleanup(func() { cleanTable(ctx, testConn(), "sys_user", userId) })
  95. loader.Del(ctx, userId, "")
  96. loader.Clean(ctx, userId)
  97. _ = loader.Load(ctx, userId, "")
  98. firstHits := atomic.LoadInt64(&counting.findOneHits)
  99. require.Equal(t, int64(1), firstHits, "首次 Load 应命中 DB 一次")
  100. for i := 0; i < 20; i++ {
  101. _ = loader.Load(ctx, userId, "")
  102. }
  103. secondRoundHits := atomic.LoadInt64(&counting.findOneHits) - firstHits
  104. assert.Equal(t, int64(0), secondRoundHits,
  105. "后续 Load 必须命中 Redis 缓存; 若持续打到 DB, 说明 cache 写入失败或 TTL 异常")
  106. }