github.com/benz9527/xboot@v0.0.0-20240504061247-c23f15593274/dlock/redis_dlock.go (about) 1 package dlock 2 3 // References: 4 // https://github.com/bsm/redislock 5 // https://github.com/go-redsync/redsync 6 // https://redis.io/docs/latest/develop/use/patterns/distributed-locks/ 7 8 import ( 9 "context" 10 _ "embed" 11 "errors" 12 "sync/atomic" 13 "time" 14 15 "github.com/redis/go-redis/v9" 16 "go.uber.org/multierr" 17 18 "github.com/benz9527/xboot/lib/id" 19 "github.com/benz9527/xboot/lib/infra" 20 ) 21 22 //go:embed lock.lua 23 var luaDLockAcquireScript string 24 25 //go:embed unlock.lua 26 var luaDLockReleaseScript string 27 28 //go:embed lockrenewal.lua 29 var luaDLockRenewalTTLScript string 30 31 //go:embed lockttl.lua 32 var luaDLockLoadTTLScript string 33 34 var ( 35 luaDLockAcquire = redis.NewScript(luaDLockAcquireScript) 36 luaDLockRelease = redis.NewScript(luaDLockReleaseScript) 37 luaDLockRenewalTTL = redis.NewScript(luaDLockRenewalTTLScript) 38 luaDLockLoadTTL = redis.NewScript(luaDLockLoadTTLScript) 39 ) 40 41 const randomTokenLength = 16 42 43 var nano, _ = id.ClassicNanoID(randomTokenLength) 44 45 var _ DLocker = (*redisDLock)(nil) 46 47 // TODO: Enable watchdog to renewal lock automatically 48 type redisDLock struct { 49 *redisDLockOptions 50 locked atomic.Bool 51 } 52 53 func (dl *redisDLock) Lock() error { 54 retry := dl.strategy 55 var ( 56 ticker *time.Ticker 57 merr error 58 ) 59 for { 60 if _, err := luaDLockAcquire.Eval( 61 *dl.ctx.Load(), // TODO pay attention to the context has been cancelled. 62 dl.scripterLoader(), 63 dl.keys, 64 dl.token, len(dl.token), dl.ttl.Milliseconds(), 65 ).Result(); err != nil && !errors.Is(err, redis.Nil) { 66 merr = multierr.Append(merr, err) 67 } else if err == nil || errors.Is(err, redis.Nil) { 68 dl.locked.Store(true) 69 return noErr 70 } 71 72 backoff := retry.Next() 73 if backoff.Milliseconds() < 1 { 74 if ticker != nil { 75 return infra.WrapErrorStackWithMessage(multierr.Combine(merr, ErrDLockAcquireFailed), "redis dlock lock retry reach to max") 76 } 77 // No retry strategy. 78 return noErr 79 } 80 81 if ticker == nil { 82 ticker = time.NewTicker(backoff) 83 defer ticker.Stop() // Avoid ticker leak. 84 } else { 85 ticker.Reset(backoff) 86 } 87 88 select { 89 case <-(*dl.ctx.Load()).Done(): 90 return infra.WrapErrorStack((*dl.ctx.Load()).Err()) 91 case <-ticker.C: 92 // continue 93 } 94 } 95 } 96 97 func (dl *redisDLock) Renewal(newTTL time.Duration) error { 98 if newTTL.Milliseconds() <= 0 { 99 return infra.NewErrorStack("renewal dlock with zero ms TTL") 100 } 101 if !dl.locked.Load() { 102 return infra.WrapErrorStackWithMessage(ErrDLockNoInit, "renewal dlock with no lock") 103 } 104 var ( 105 ctx context.Context 106 cancel context.CancelFunc 107 ) 108 // TODO pay attention to the context has been cancelled. 109 ctx, cancel = context.WithTimeout(*dl.ctx.Load(), newTTL) 110 if ctx != nil && cancel != nil { 111 if _, err := luaDLockRenewalTTL.Eval( 112 ctx, 113 dl.scripterLoader(), 114 dl.keys, 115 dl.token, newTTL.Milliseconds(), 116 ).Result(); err != nil && !errors.Is(err, redis.Nil) { 117 return err 118 } 119 dl.ctx.Store(&ctx) 120 dl.ctxCancel.Store(&cancel) 121 return noErr 122 } 123 return infra.NewErrorStack("refresh dlock ttl with nil context or nil context cancel function") 124 } 125 126 func (dl *redisDLock) TTL() (time.Duration, error) { 127 if !dl.locked.Load() { 128 return 0, infra.WrapErrorStackWithMessage(ErrDLockNoInit, "fetch dlock ttl failed") 129 } 130 res, err := luaDLockLoadTTL.Eval( 131 *dl.ctx.Load(), // TODO pay attention to the context has been cancelled. 132 dl.scripterLoader(), 133 dl.keys, 134 dl.token, 135 ).Result() 136 if err != nil && !errors.Is(err, redis.Nil) { 137 return 0, err 138 } 139 if res == nil { 140 return 0, infra.NewErrorStack("no error but nil dlock ttl value") 141 } 142 if num := res.(int64); num > 0 { 143 return time.Duration(num) * time.Millisecond, noErr 144 } 145 return 0, noErr 146 } 147 148 func (dl *redisDLock) Unlock() error { 149 if !dl.locked.Load() { 150 return infra.WrapErrorStackWithMessage(ErrDLockNoInit, "attempt to unlock a no init dlock") 151 } 152 if _, err := luaDLockRelease.Eval( 153 *dl.ctx.Load(), // TODO pay attention to the context has been cancelled. 154 dl.scripterLoader(), 155 dl.keys, 156 dl.token, 157 ).Result(); err != nil && !errors.Is(err, redis.Nil) { 158 return err 159 } 160 if cancel := *dl.ctxCancel.Load(); cancel != nil { 161 cancel() 162 } 163 return noErr 164 } 165 166 type redisDLockOptions struct { 167 ctx atomic.Pointer[context.Context] 168 ctxCancel atomic.Pointer[context.CancelFunc] 169 scripterLoader func() redis.Scripter 170 strategy RetryStrategy 171 keys []string 172 token string 173 ttl time.Duration 174 } 175 176 func RedisDLockBuilder(ctx context.Context, scripter func() redis.Scripter) *redisDLockOptions { 177 if ctx == nil { 178 ctx = context.Background() 179 } 180 opts := &redisDLockOptions{scripterLoader: scripter} 181 opts.ctx.Store(&ctx) 182 return opts 183 } 184 185 func (opt *redisDLockOptions) TTL(ttl time.Duration) *redisDLockOptions { 186 opt.ttl = ttl 187 return opt 188 } 189 190 func (opt *redisDLockOptions) Token(token string) *redisDLockOptions { 191 opt.token = token + "&" + nano() 192 return opt 193 } 194 195 func (opt *redisDLockOptions) Keys(keys ...string) *redisDLockOptions { 196 opt.keys = make([]string, len(keys)) 197 for i, key := range keys { 198 opt.keys[i] = key 199 } 200 return opt 201 } 202 203 func (opt *redisDLockOptions) Retry(strategy RetryStrategy) *redisDLockOptions { 204 opt.strategy = strategy 205 return opt 206 } 207 208 func (opt *redisDLockOptions) Build() (DLocker, error) { 209 if opt.scripterLoader == nil { 210 return nil, infra.NewErrorStack("redis dlock scripter loader is nil") 211 } 212 if opt.ttl.Milliseconds() <= 0 { 213 return nil, infra.NewErrorStack("redis dlock with zero ms TTL") 214 } 215 if len(opt.keys) <= 0 { 216 return nil, infra.NewErrorStack("redis dlock with zero keys") 217 } 218 if opt.strategy == nil { 219 opt.strategy = NoRetry() 220 } 221 var ( 222 ctx context.Context 223 cancel context.CancelFunc 224 ) 225 ctx, cancel = context.WithTimeout(*opt.ctx.Load(), opt.ttl) 226 if ctx == nil || cancel == nil { 227 return nil, infra.NewErrorStack("redis dlock build with nil context or nil context cancel function") 228 } 229 opt.ctx.Store(&ctx) 230 opt.ctxCancel.Store(&cancel) 231 return &redisDLock{redisDLockOptions: opt}, nil 232 } 233 234 type RedisDLockOption func(opt *redisDLockOptions) 235 236 func WithRedisDLockTTL(ttl time.Duration) RedisDLockOption { 237 return func(opt *redisDLockOptions) { 238 opt.TTL(ttl) 239 } 240 } 241 242 func WithRedisDLockKeys(keys ...string) RedisDLockOption { 243 return func(opt *redisDLockOptions) { 244 opt.Keys(keys...) 245 } 246 } 247 248 func WithRedisDLockToken(token string) RedisDLockOption { 249 return func(opt *redisDLockOptions) { 250 opt.Token(token) 251 } 252 } 253 254 func WithRedisDLockRetry(strategy RetryStrategy) RedisDLockOption { 255 return func(opt *redisDLockOptions) { 256 opt.Retry(strategy) 257 } 258 } 259 260 func RedisDLock(ctx context.Context, scripter func() redis.Scripter, opts ...RedisDLockOption) (DLocker, error) { 261 builderOpts := RedisDLockBuilder(ctx, scripter) 262 for _, o := range opts { 263 o(builderOpts) 264 } 265 return builderOpts.Build() 266 }