lockx.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  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. // TryLockWithToken 使用调用方指定的 token 非阻塞加锁。
  49. // 若锁已被同一 token 持有(例如进程重启后同一任务接管自己留下的锁),视为加锁成功并续期。
  50. func TryLockWithToken(ctx context.Context, key, token string, ttl time.Duration) (*RedisLock, bool, error) {
  51. const luaScript = `
  52. if redis.call("set", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
  53. return 1
  54. end
  55. if redis.call("get", KEYS[1]) == ARGV[1] then
  56. redis.call("pexpire", KEYS[1], ARGV[2])
  57. return 1
  58. end
  59. return 0
  60. `
  61. res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{key}, token, ttl.Milliseconds()).Int()
  62. if err != nil {
  63. return nil, false, err
  64. }
  65. if res == 0 {
  66. return nil, false, nil
  67. }
  68. return &RedisLock{key: key, token: token, ttl: ttl, acquired: time.Now(), label: lockLabel(key)}, true, nil
  69. }
  70. // Lock 阻塞加锁,可设置重试间隔和超时
  71. func Lock(ctx context.Context, key string, ttl, retryInterval, timeout time.Duration) (*RedisLock, error) {
  72. start := time.Now()
  73. for {
  74. lock, ok, err := TryLock(ctx, key, ttl)
  75. if err != nil {
  76. return nil, err
  77. }
  78. if ok {
  79. return lock, nil
  80. }
  81. if timeout > 0 && time.Since(start) >= timeout {
  82. return nil, errors.New("lock timeout")
  83. }
  84. select {
  85. case <-ctx.Done():
  86. return nil, ctx.Err()
  87. case <-time.After(retryInterval):
  88. }
  89. }
  90. }
  91. // Release 安全释放锁(Lua 脚本) - 通过key和token直接释放
  92. func LockRelease(ctx context.Context, key string, token string) error {
  93. const luaScript = `
  94. if redis.call("get", KEYS[1]) == ARGV[1] then
  95. return redis.call("del", KEYS[1])
  96. else
  97. return 0
  98. end
  99. `
  100. res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{key}, token).Int()
  101. if err != nil {
  102. return err
  103. }
  104. if res == 0 {
  105. return fmt.Errorf("unlock failed: %w", errLockNotOwner)
  106. }
  107. return nil
  108. }
  109. // Release 安全释放锁(Lua 脚本)
  110. func (l *RedisLock) Release(ctx context.Context) error {
  111. const luaScript = `
  112. if redis.call("get", KEYS[1]) == ARGV[1] then
  113. return redis.call("del", KEYS[1])
  114. else
  115. return 0
  116. end
  117. `
  118. res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{l.key}, l.token).Int()
  119. if err != nil {
  120. return err
  121. }
  122. if res == 0 {
  123. return fmt.Errorf("unlock failed: %w", errLockNotOwner)
  124. }
  125. return nil
  126. }
  127. // SafeRelease 带日志保护版本。
  128. //
  129. // 除了尽力释放锁外,它还承担 DD-001 的观测职责:
  130. // - 若释放时发现锁已不属于自己("not owner"),说明临界区耗时超过 TTL、
  131. // 锁在持有期间被别的持有者抢走,可能已发生并发覆盖,记为 Error(关键信号)。
  132. // - 若正常释放但持锁时长已逼近 TTL(>70%),记为 Warn(风险预警)。
  133. // - 其他释放错误记为 Error。
  134. // 该方法不改变控制流,也不返回错误,调用方无感知。
  135. func (l *RedisLock) SafeRelease(ctx context.Context) {
  136. held := time.Since(l.acquired)
  137. err := l.Release(ctx)
  138. switch {
  139. case err == nil:
  140. if l.ttl > 0 && held > l.ttl*7/10 {
  141. zap.L().Warn("redis lock held close to ttl",
  142. zap.String("label", l.label),
  143. zap.String("key", l.key),
  144. zap.Duration("held", held),
  145. zap.Duration("ttl", l.ttl))
  146. }
  147. case errors.Is(err, errLockNotOwner):
  148. // 关键信号:干完活时锁已不是自己的,临界区超时被抢,可能已并发覆盖。
  149. zap.L().Error("redis lock lost before release (possible concurrent overwrite)",
  150. zap.String("label", l.label),
  151. zap.String("key", l.key),
  152. zap.String("token", l.token),
  153. zap.Duration("held", held),
  154. zap.Duration("ttl", l.ttl))
  155. default:
  156. zap.L().Error("redis lock release failed",
  157. zap.String("label", l.label),
  158. zap.String("key", l.key),
  159. zap.Duration("held", held),
  160. zap.Error(err))
  161. }
  162. }
  163. // Extend renews the lock only while this instance still owns it.
  164. func (l *RedisLock) Extend(ctx context.Context, ttl time.Duration) error {
  165. const luaScript = `
  166. if redis.call("get", KEYS[1]) == ARGV[1] then
  167. return redis.call("pexpire", KEYS[1], ARGV[2])
  168. else
  169. return 0
  170. end
  171. `
  172. res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{l.key}, l.token, ttl.Milliseconds()).Int()
  173. if err != nil {
  174. return err
  175. }
  176. if res == 0 {
  177. return fmt.Errorf("extend failed: %w", errLockNotOwner)
  178. }
  179. l.ttl = ttl
  180. return nil
  181. }
  182. // IsOwner reports whether this instance still owns the lock.
  183. func (l *RedisLock) IsOwner(ctx context.Context) (bool, error) {
  184. value, err := getClient().Get(ctx, l.key).Result()
  185. if err == redis.Nil {
  186. return false, nil
  187. }
  188. if err != nil {
  189. return false, err
  190. }
  191. return value == l.token, nil
  192. }
  193. // SetIfOwner writes a completion marker only while this instance still owns
  194. // the lock. It is used by lease-based workflows to avoid publishing a stale
  195. // worker's result after its lease has expired.
  196. func (l *RedisLock) SetIfOwner(ctx context.Context, key, value string, ttl time.Duration) error {
  197. const luaScript = `
  198. if redis.call("get", KEYS[1]) == ARGV[1] then
  199. redis.call("set", KEYS[2], ARGV[2], "PX", ARGV[3])
  200. return 1
  201. else
  202. return 0
  203. end
  204. `
  205. res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{l.key, key}, l.token, value, ttl.Milliseconds()).Int()
  206. if err != nil {
  207. return err
  208. }
  209. if res == 0 {
  210. return fmt.Errorf("commit failed: %w", errLockNotOwner)
  211. }
  212. return nil
  213. }
  214. func (l *RedisLock) GetKey() string {
  215. return l.key
  216. }
  217. func (l *RedisLock) GetToken() string {
  218. return l.token
  219. }