lockx.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. package redisx
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "strings"
  7. "time"
  8. "github.com/google/uuid"
  9. "github.com/redis/go-redis/v9"
  10. "go.uber.org/zap"
  11. )
  12. // 分布式锁
  13. // errLockNotOwner 表示当前实例已不再持有该锁(锁已过期或被他人抢占)。
  14. // 用哨兵错误以便 errors.Is 判定,供观测日志区分“锁被抢走”与其他失败。
  15. var errLockNotOwner = errors.New("lock not owner")
  16. type RedisLock struct {
  17. key string
  18. token string
  19. ttl time.Duration
  20. acquired time.Time // 加锁成功的时刻,用于统计持锁时长(配合 time.Since 使用,单调)
  21. label string // 业务标签,从 key 推断,便于按锁类型聚合观测日志
  22. }
  23. // lockLabel 从 key 推断业务标签,便于日志聚合。
  24. // 约定 key 形如 "<domain>:<category>:<id>",取前两段,例如
  25. // "data-download:content:xxx" -> "data-download:content"。无法识别时返回 "unknown"。
  26. func lockLabel(key string) string {
  27. parts := strings.SplitN(key, ":", 3)
  28. if len(parts) >= 2 {
  29. return parts[0] + ":" + parts[1]
  30. }
  31. if len(parts) == 1 && parts[0] != "" {
  32. return parts[0]
  33. }
  34. return "unknown"
  35. }
  36. // TryLock 非阻塞尝试加锁
  37. func TryLock(ctx context.Context, key string, ttl time.Duration) (*RedisLock, bool, error) {
  38. token := uuid.NewString()
  39. ok, err := getClient().SetNX(ctx, key, token, ttl).Result()
  40. if err != nil {
  41. return nil, false, err
  42. }
  43. if !ok {
  44. return nil, false, nil
  45. }
  46. return &RedisLock{key: key, token: token, ttl: ttl, acquired: time.Now(), label: lockLabel(key)}, true, nil
  47. }
  48. // Lock 阻塞加锁,可设置重试间隔和超时
  49. func Lock(ctx context.Context, key string, ttl, retryInterval, timeout time.Duration) (*RedisLock, error) {
  50. start := time.Now()
  51. for {
  52. lock, ok, err := TryLock(ctx, key, ttl)
  53. if err != nil {
  54. return nil, err
  55. }
  56. if ok {
  57. return lock, nil
  58. }
  59. if timeout > 0 && time.Since(start) >= timeout {
  60. return nil, errors.New("lock timeout")
  61. }
  62. select {
  63. case <-ctx.Done():
  64. return nil, ctx.Err()
  65. case <-time.After(retryInterval):
  66. }
  67. }
  68. }
  69. // Release 安全释放锁(Lua 脚本) - 通过key和token直接释放
  70. func LockRelease(ctx context.Context, key string, token string) error {
  71. const luaScript = `
  72. if redis.call("get", KEYS[1]) == ARGV[1] then
  73. return redis.call("del", KEYS[1])
  74. else
  75. return 0
  76. end
  77. `
  78. res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{key}, token).Int()
  79. if err != nil {
  80. return err
  81. }
  82. if res == 0 {
  83. return fmt.Errorf("unlock failed: %w", errLockNotOwner)
  84. }
  85. return nil
  86. }
  87. // Release 安全释放锁(Lua 脚本)
  88. func (l *RedisLock) Release(ctx context.Context) error {
  89. const luaScript = `
  90. if redis.call("get", KEYS[1]) == ARGV[1] then
  91. return redis.call("del", KEYS[1])
  92. else
  93. return 0
  94. end
  95. `
  96. res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{l.key}, l.token).Int()
  97. if err != nil {
  98. return err
  99. }
  100. if res == 0 {
  101. return fmt.Errorf("unlock failed: %w", errLockNotOwner)
  102. }
  103. return nil
  104. }
  105. // SafeRelease 带日志保护版本。
  106. //
  107. // 除了尽力释放锁外,它还承担 DD-001 的观测职责:
  108. // - 若释放时发现锁已不属于自己("not owner"),说明临界区耗时超过 TTL、
  109. // 锁在持有期间被别的持有者抢走,可能已发生并发覆盖,记为 Error(关键信号)。
  110. // - 若正常释放但持锁时长已逼近 TTL(>70%),记为 Warn(风险预警)。
  111. // - 其他释放错误记为 Error。
  112. // 该方法不改变控制流,也不返回错误,调用方无感知。
  113. func (l *RedisLock) SafeRelease(ctx context.Context) {
  114. held := time.Since(l.acquired)
  115. err := l.Release(ctx)
  116. switch {
  117. case err == nil:
  118. if l.ttl > 0 && held > l.ttl*7/10 {
  119. zap.L().Warn("redis lock held close to ttl",
  120. zap.String("label", l.label),
  121. zap.String("key", l.key),
  122. zap.Duration("held", held),
  123. zap.Duration("ttl", l.ttl))
  124. }
  125. case errors.Is(err, errLockNotOwner):
  126. // 关键信号:干完活时锁已不是自己的,临界区超时被抢,可能已并发覆盖。
  127. zap.L().Error("redis lock lost before release (possible concurrent overwrite)",
  128. zap.String("label", l.label),
  129. zap.String("key", l.key),
  130. zap.String("token", l.token),
  131. zap.Duration("held", held),
  132. zap.Duration("ttl", l.ttl))
  133. default:
  134. zap.L().Error("redis lock release failed",
  135. zap.String("label", l.label),
  136. zap.String("key", l.key),
  137. zap.Duration("held", held),
  138. zap.Error(err))
  139. }
  140. }
  141. // Extend renews the lock only while this instance still owns it.
  142. func (l *RedisLock) Extend(ctx context.Context, ttl time.Duration) error {
  143. const luaScript = `
  144. if redis.call("get", KEYS[1]) == ARGV[1] then
  145. return redis.call("pexpire", KEYS[1], ARGV[2])
  146. else
  147. return 0
  148. end
  149. `
  150. res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{l.key}, l.token, ttl.Milliseconds()).Int()
  151. if err != nil {
  152. return err
  153. }
  154. if res == 0 {
  155. return fmt.Errorf("extend failed: %w", errLockNotOwner)
  156. }
  157. l.ttl = ttl
  158. return nil
  159. }
  160. // IsOwner reports whether this instance still owns the lock.
  161. func (l *RedisLock) IsOwner(ctx context.Context) (bool, error) {
  162. value, err := getClient().Get(ctx, l.key).Result()
  163. if err == redis.Nil {
  164. return false, nil
  165. }
  166. if err != nil {
  167. return false, err
  168. }
  169. return value == l.token, nil
  170. }
  171. // SetIfOwner writes a completion marker only while this instance still owns
  172. // the lock. It is used by lease-based workflows to avoid publishing a stale
  173. // worker's result after its lease has expired.
  174. func (l *RedisLock) SetIfOwner(ctx context.Context, key, value string, ttl time.Duration) error {
  175. const luaScript = `
  176. if redis.call("get", KEYS[1]) == ARGV[1] then
  177. redis.call("set", KEYS[2], ARGV[2], "PX", ARGV[3])
  178. return 1
  179. else
  180. return 0
  181. end
  182. `
  183. res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{l.key, key}, l.token, value, ttl.Milliseconds()).Int()
  184. if err != nil {
  185. return err
  186. }
  187. if res == 0 {
  188. return fmt.Errorf("commit failed: %w", errLockNotOwner)
  189. }
  190. return nil
  191. }
  192. func (l *RedisLock) GetKey() string {
  193. return l.key
  194. }
  195. func (l *RedisLock) GetToken() string {
  196. return l.token
  197. }