user_msg_queue_cache.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. package repository
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "strconv"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. "github.com/redis/go-redis/v9"
  10. )
  11. // Redis Key 模式(使用 hash tag 确保 Redis Cluster 下同一 accountID 的 key 落入同一 slot)
  12. // 格式: umq:{accountID}:lock / umq:{accountID}:last
  13. const (
  14. umqKeyPrefix = "umq:"
  15. umqLockSuffix = ":lock" // STRING (requestID), PX lockTtlMs
  16. umqLastSuffix = ":last" // STRING (毫秒时间戳), EX 60s
  17. // 锁索引用来替代后台清理对 umq:*:lock 的全量 SCAN。
  18. // member 是 accountID,score 是锁预计过期的 Redis Unix 毫秒时间戳。
  19. umqLockIndexKey = "umq:lock:index" // ZSET member=accountID, score=lockExpireAtUnixMs
  20. umqLockIndexCleanupBatchSize = 1000
  21. )
  22. // Lua 脚本:原子获取串行锁(SET NX PX + 重入安全)
  23. // 返回 {是否获取成功, 锁预计过期时间毫秒},让 Go 侧用同一 Redis 时间源更新索引。
  24. // 获取失败(锁被他人持有)时也返回观测到的到期时间,供 Go 侧回填锁索引:
  25. // 这让升级窗口遗留、索引写失败、释放竞态误删索引的存量锁在下一次被争用时自动重新入索引,
  26. // 是替代旧 SCAN 兜底的自愈机制。PTTL == -1 的异常锁返回当前时间,使其立即成为 reconcile 候选。
  27. var acquireLockScript = redis.NewScript(`
  28. redis.replicate_commands()
  29. local cur = redis.call('GET', KEYS[1])
  30. local ttl = tonumber(ARGV[2])
  31. if cur == ARGV[1] then
  32. redis.call('PEXPIRE', KEYS[1], ttl)
  33. local t = redis.call('TIME')
  34. local ms = tonumber(t[1])*1000 + math.floor(tonumber(t[2])/1000)
  35. return {1, ms + ttl}
  36. end
  37. if cur ~= false then
  38. local t = redis.call('TIME')
  39. local ms = tonumber(t[1])*1000 + math.floor(tonumber(t[2])/1000)
  40. local pttl = redis.call('PTTL', KEYS[1])
  41. if pttl and pttl > 0 then
  42. return {0, ms + pttl}
  43. end
  44. return {0, ms}
  45. end
  46. redis.call('SET', KEYS[1], ARGV[1], 'PX', ttl)
  47. local t = redis.call('TIME')
  48. local ms = tonumber(t[1])*1000 + math.floor(tonumber(t[2])/1000)
  49. return {1, ms + ttl}
  50. `)
  51. // Lua 脚本:原子释放锁 + 记录完成时间(使用 Redis TIME 避免时钟偏差)
  52. var releaseLockScript = redis.NewScript(`
  53. -- Redis 3.2-4.x compat: opt into effects replication so redis.call('TIME')
  54. -- replicates correctly. No-op on Redis 5.0+ (effects replication is default).
  55. redis.replicate_commands()
  56. local cur = redis.call('GET', KEYS[1])
  57. if cur == ARGV[1] then
  58. redis.call('DEL', KEYS[1])
  59. local t = redis.call('TIME')
  60. local ms = tonumber(t[1])*1000 + math.floor(tonumber(t[2])/1000)
  61. redis.call('SET', KEYS[2], ms, 'EX', 60)
  62. return 1
  63. end
  64. return 0
  65. `)
  66. // Lua 脚本:校验锁 TTL 状态,PTTL == -1 时原子删除异常锁。
  67. // 返回状态: -2=锁不存在,-1=无 TTL 的异常锁已删除,1=锁仍存活并返回剩余 PTTL。
  68. var reconcileLockScript = redis.NewScript(`
  69. local pttl = redis.call('PTTL', KEYS[1])
  70. if pttl == -2 then
  71. return {-2, 0}
  72. end
  73. if pttl == -1 then
  74. redis.call('DEL', KEYS[1])
  75. return {-1, 0}
  76. end
  77. return {1, pttl}
  78. `)
  79. type userMsgQueueCache struct {
  80. rdb *redis.Client
  81. }
  82. // NewUserMsgQueueCache 创建用户消息队列缓存
  83. func NewUserMsgQueueCache(rdb *redis.Client) service.UserMsgQueueCache {
  84. return &userMsgQueueCache{rdb: rdb}
  85. }
  86. func umqLockKey(accountID int64) string {
  87. // 格式: umq:{123}:lock — 花括号确保 Redis Cluster hash tag 生效
  88. return umqKeyPrefix + "{" + strconv.FormatInt(accountID, 10) + "}" + umqLockSuffix
  89. }
  90. func umqLastKey(accountID int64) string {
  91. // 格式: umq:{123}:last — 与 lockKey 同一 hash slot
  92. return umqKeyPrefix + "{" + strconv.FormatInt(accountID, 10) + "}" + umqLastSuffix
  93. }
  94. // AcquireLock 尝试获取账号级串行锁
  95. // 无论成功与否都尽力写入锁索引:成功时登记自己的锁,失败时回填观测到的持有者锁,
  96. // 保证任何被争用的锁都能被后台 reconcile 发现,无需扫描所有锁 key。
  97. func (c *userMsgQueueCache) AcquireLock(ctx context.Context, accountID int64, requestID string, lockTtlMs int) (bool, error) {
  98. key := umqLockKey(accountID)
  99. result, err := acquireLockScript.Run(ctx, c.rdb, []string{key}, requestID, lockTtlMs).Result()
  100. if err != nil {
  101. return false, fmt.Errorf("umq acquire lock: %w", err)
  102. }
  103. acquired, err := redisScriptInt64At(result, 0)
  104. if err != nil {
  105. return false, fmt.Errorf("umq parse acquire lock result: %w", err)
  106. }
  107. expireAtMs, err := redisScriptInt64At(result, 1)
  108. if err != nil {
  109. return false, fmt.Errorf("umq parse acquire lock expire: %w", err)
  110. }
  111. if expireAtMs > 0 {
  112. if err := c.rdb.ZAdd(ctx, umqLockIndexKey, redis.Z{
  113. Score: float64(expireAtMs),
  114. Member: strconv.FormatInt(accountID, 10),
  115. }).Err(); err != nil {
  116. logger.LegacyPrintf("repository.umq", "Warning: update lock index for account %d failed: %v", accountID, err)
  117. }
  118. }
  119. return acquired == 1, nil
  120. }
  121. // ReleaseLock 释放锁并记录完成时间
  122. // 只有 requestID 匹配时才删除锁索引,避免误删其他请求重入后写入的新锁。
  123. func (c *userMsgQueueCache) ReleaseLock(ctx context.Context, accountID int64, requestID string) (bool, error) {
  124. lockKey := umqLockKey(accountID)
  125. lastKey := umqLastKey(accountID)
  126. result, err := releaseLockScript.Run(ctx, c.rdb, []string{lockKey, lastKey}, requestID).Int()
  127. if err != nil {
  128. return false, fmt.Errorf("umq release lock: %w", err)
  129. }
  130. if result == 1 {
  131. // 与下一个 AcquireLock 的 ZAdd 存在竞态:可能误删新持有者刚写入的索引项。
  132. // 该锁下次被争用时 AcquireLock 的回填路径会重新登记,无需在此加锁。
  133. if err := c.rdb.ZRem(ctx, umqLockIndexKey, strconv.FormatInt(accountID, 10)).Err(); err != nil {
  134. logger.LegacyPrintf("repository.umq", "Warning: remove lock index for account %d failed: %v", accountID, err)
  135. }
  136. }
  137. return result == 1, nil
  138. }
  139. // GetLastCompletedMs 获取上次完成时间(毫秒时间戳)
  140. func (c *userMsgQueueCache) GetLastCompletedMs(ctx context.Context, accountID int64) (int64, error) {
  141. key := umqLastKey(accountID)
  142. val, err := c.rdb.Get(ctx, key).Result()
  143. if errors.Is(err, redis.Nil) {
  144. return 0, nil
  145. }
  146. if err != nil {
  147. return 0, fmt.Errorf("umq get last completed: %w", err)
  148. }
  149. ms, err := strconv.ParseInt(val, 10, 64)
  150. if err != nil {
  151. return 0, fmt.Errorf("umq parse last completed: %w", err)
  152. }
  153. return ms, nil
  154. }
  155. // GetCurrentTimeMs 通过 Redis TIME 命令获取当前服务器时间(毫秒),确保与锁记录的时间源一致
  156. func (c *userMsgQueueCache) GetCurrentTimeMs(ctx context.Context) (int64, error) {
  157. t, err := c.rdb.Time(ctx).Result()
  158. if err != nil {
  159. return 0, fmt.Errorf("umq get redis time: %w", err)
  160. }
  161. return t.UnixMilli(), nil
  162. }
  163. // ReconcileExpiredLockCandidates 只处理索引里已经到期的候选锁。
  164. // 候选到期不等于锁一定失效:可能是续租后索引滞后,所以必须再用 PTTL 二次确认。
  165. func (c *userMsgQueueCache) ReconcileExpiredLockCandidates(ctx context.Context, maxCount int) (int, error) {
  166. if maxCount <= 0 {
  167. maxCount = umqLockIndexCleanupBatchSize
  168. }
  169. nowMs, err := c.GetCurrentTimeMs(ctx)
  170. if err != nil {
  171. return 0, err
  172. }
  173. members, err := c.rdb.ZRangeArgs(ctx, redis.ZRangeArgs{
  174. Key: umqLockIndexKey,
  175. Start: "-inf",
  176. Stop: strconv.FormatInt(nowMs, 10),
  177. ByScore: true,
  178. Count: int64(maxCount),
  179. }).Result()
  180. if err != nil {
  181. return 0, fmt.Errorf("umq read lock index: %w", err)
  182. }
  183. cleaned := 0
  184. for _, member := range members {
  185. accountID, err := strconv.ParseInt(member, 10, 64)
  186. if err != nil || accountID <= 0 {
  187. c.removeLockIndexMember(ctx, member)
  188. continue
  189. }
  190. result, err := reconcileLockScript.Run(ctx, c.rdb, []string{umqLockKey(accountID)}).Result()
  191. if err != nil && !errors.Is(err, redis.Nil) {
  192. return cleaned, fmt.Errorf("umq reconcile lock: %w", err)
  193. }
  194. status, err := redisScriptInt64At(result, 0)
  195. if err != nil {
  196. return cleaned, fmt.Errorf("umq parse reconcile status: %w", err)
  197. }
  198. pttl, err := redisScriptInt64At(result, 1)
  199. if err != nil {
  200. return cleaned, fmt.Errorf("umq parse reconcile pttl: %w", err)
  201. }
  202. switch status {
  203. case -2:
  204. // 锁自然过期或已释放,只需移除索引残留。
  205. c.removeLockIndexMember(ctx, member)
  206. case -1:
  207. // 无 TTL 的锁会永久阻塞队列,Lua 已原子删除它,这里统计一次清理。
  208. c.removeLockIndexMember(ctx, member)
  209. cleaned++
  210. case 1:
  211. // 锁仍存活,说明索引过期时间滞后;按剩余 PTTL 重新排期。
  212. if err := c.rdb.ZAdd(ctx, umqLockIndexKey, redis.Z{
  213. Score: float64(nowMs + pttl),
  214. Member: member,
  215. }).Err(); err != nil {
  216. logger.LegacyPrintf("repository.umq", "Warning: reschedule lock index member %s failed: %v", member, err)
  217. }
  218. }
  219. }
  220. return cleaned, nil
  221. }
  222. // removeLockIndexMember 移除锁索引残留;索引维护是 best-effort,失败只记日志。
  223. func (c *userMsgQueueCache) removeLockIndexMember(ctx context.Context, member string) {
  224. if err := c.rdb.ZRem(ctx, umqLockIndexKey, member).Err(); err != nil {
  225. logger.LegacyPrintf("repository.umq", "Warning: remove lock index member %s failed: %v", member, err)
  226. }
  227. }
  228. // redisScriptInt64At 兼容 go-redis 对 Lua 数组元素的不同返回类型。
  229. func redisScriptInt64At(result any, index int) (int64, error) {
  230. values, ok := result.([]any)
  231. if !ok {
  232. return 0, fmt.Errorf("expected redis script array, got %T", result)
  233. }
  234. if index < 0 || index >= len(values) {
  235. return 0, fmt.Errorf("redis script array missing index %d", index)
  236. }
  237. switch v := values[index].(type) {
  238. case int64:
  239. return v, nil
  240. case int:
  241. return int64(v), nil
  242. case string:
  243. return strconv.ParseInt(v, 10, 64)
  244. case []byte:
  245. return strconv.ParseInt(string(v), 10, 64)
  246. default:
  247. return 0, fmt.Errorf("unexpected redis script value %T", v)
  248. }
  249. }