Redis 分布式锁的实现
在 Redis 的常见应用中,分布式锁是一个老生常谈的问题,本文主要讲讲怎么去实现一个分布式锁(最近真·写了不少 Lua 脚本)。
加锁
对于加锁操作,理论上应该是:
- 尝试加锁,如果成功,则记录锁,并且返回 true
- 如果失败,则不更新锁,返回 false
另外,1 或者 2 应该都是原子的。而 Redis 中针对这个操作只要一个 Set 就能搞定。
为此,我们先复习一下 SET
:
SET key value [NX | XX] [GET] [EX seconds | PX milliseconds |
EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]
其中 Options:
- EX:多少秒后过期
- PX:多少毫秒后过期
- EXAT:什么时候过期(时间戳-秒)
- PXAT:什么时候过期(时间戳-毫秒)
- NX:只有当 key 不存在时才 SET
- XX:只有 key 存在时才能 SET
- KEEPTTL:意味着更新时过期时间保持不变
- GET:返回更新前的旧字符串
换言之,假设我们把锁作为一个 redis key,那么加锁只要使用 SET key value NX
就行了。
另外,为了避免程序错误导致锁没释放,还需要加入一个超时时间,比如我们预估一个请求 timeout = 800ms,那么锁的时间可以设计为 1s,进行一些容错 SET key value NX EX 1
释放锁
因为加锁 = SET key,那么解锁自然是 DEL。但是问题是,不是谁都能释放锁的,只有那个拥有锁的对象可以释放锁。
因此对象匹配和释放锁需要是原子的:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
结合 Golang 的实现
type Lock struct {
redis *redis.Client
name string
timeout time.Duration
uuid string
}
type Options struct {
Uuid string
}
func (o *Options) GetUuid() string {
if o.Uuid == "" {
return ""
} else {
return o.Uuid
}
}
// KEYS[1] = lockName
// ARGV[1] = uuid
const lockLua = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])else
return 0
end
`
func NewLock(name string, redis *redis.Client, timeout time.Duration, options *Options) *Lock {
if name == "" {
panic("lock name is empty")
} return &Lock{
redis: redis,
name: name,
timeout: timeout,
uuid: options.GetUuid(),
}}
func (l *Lock) Uuid() string {
return l.uuid
}
func (l *Lock) Lock(ctx context.Context, options *Options) (bool, error) {
uuid := l.uuid
if options.GetUuid() != "" {
uuid = options.GetUuid()
} res, err := l.redis.SetNX(ctx, l.name, uuid, l.timeout).Result()
if err != nil {
fmt.Printf("SetNX failed: %v\n", err)
return false, err
}
return res, nil
}
func (l *Lock) Unlock(ctx context.Context, options *Options) (bool, error) {
uuid := l.uuid
if options.GetUuid() != "" {
uuid = options.GetUuid()
} result, err := l.redis.Eval(ctx, lockLua, []string{l.name}, uuid).Result()
if err != nil {
return false, err
}
return result.(int64) == 1, nil
}
基于 Redis 的分布式锁的优缺点
优点很明显:
- 相比 DB 来当锁,拥有更好的性能
- 实现简单,因为实现原子操作的成本更低
- 避免了单点故障,因为 Redis 本身是分布式的
当然,也不全是优点,比如:
- 超时时间的设置:这个问题也不能说是 Redis 的问题,但是需要避免程序还在运行但锁超时了的情况发生
主从并非强一致,可能会导致其实上锁时主节点宕机了,但是还没来得及同步到其他节点,因此数据不一致:
- 客户端 A 从 master 中获取到锁
- 同步期间 master crash
- 从节点被提升为 master,但缺乏相应数据
- 客户端 B 从新 master 获取到锁,就产生了两条不同的记录
要解决这个问题,Redis 提供了一个 Redlock
算法来实现分布式锁。
Redlock
核心逻辑
Redlock 的核心理念和投票类似,也就是,既然一个 master 可能会存在问题,那我多加几个 master,不就不会出现问题了吗?
假设我们准备了五个 Redis master 节点,那么客户端在获取锁时会往五个实例申请持有锁。这里需要注意的是:
- 超时处理:超时时间的配置应该要 < 自动过期时间,避免节点阻塞,也不能最后一个节点申请完第一个已经过期了
- 异常处理:如果节点出现异常,应该尽快下一个
因此我们记录拿第一个锁的开始时间,和最后一个锁的结束时间来判断锁的持有情况:
- 多数实例持有(>= 3 个)
- t(申请结束)-t(申请开始) > t(锁有效期)
如果最终只有少数持有了锁,我们还需要释放资源。
对于释放资源来说,可能存在「其实我成功了,但是网络失败」的情况,因此不应当只针对成功的节点发释放请求,而应该广播给每一个 master。
快速重试
如果失败时会先尝试重试,避免同时有多个客户端都在申请获取资源产生脑裂问题,最终没有人可以持有锁,也因此客户端的总体响应速度越快,出现这种情况的概率就越小。
延迟重启
要保证崩溃恢复,我们必然会考虑将数据持久化,如果不进行持久化,那么节点重启时就可能会遇到当时我们单 Redis 中遇到的问题。
如果持久化了,那么问题将会改善很多,但改善并不代表着解决,如果实例崩溃后一直不可用,那只是参与投票的人少了,似乎没什么问题。
但如果实例崩溃后快速的恢复了,而此时 AOF 的数据没有来得及刷到磁盘中,就仍然会遇到相同的问题。解决方案就是将恢复时间拉长,这个恢复重启时间需要大于锁的有效时间,这样重启时所有的锁都到期了,就不会存在问题了。
时钟同步
上一步我们提到要考虑过期时间,但即使时钟是近似同步的,可能每个 master 中的 time 也会存在一定误差,因此我们可以设置一个漂移量来修复这个问题。
扩展锁
如果锁本身有效期较短,且得到时已经快到期了,可以尝试发送一个指令来进行续期。在 go 的 redlock 包中已有实现:
var touchWithSetNXScript = redis.NewScript(1, `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
elseif redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2], "NX") then
return 1
else
return 0
end
`)
var touchScript = redis.NewScript(1, `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
`)
可能存在的问题
RedLock 算法相比单机来说更加可靠,但是实际应用中仍然会受到一些挑战:
- 需要更多的资源,且引入了更多的网络 IO 和耗时
- 依赖时钟:如果机器中的时间被人工修改造成较大偏差,那可能会存在灾难性问题
- 延迟重启对系统的入侵:作为一个业务系统,往往不是独享 Redis 的,重启时间不一定可控
下图是其中一个问题出现的例子:
在例子中提到了 GC 导致的 pause,当然实际上,可能也会有其他原因导致类似的效果,比如 CPU 资源竞争,网络延迟。此时光看时间判断就没什么用了。
要解决这个问题,可以在存储侧引入版本号校对,有点类似于一些业务的更新策略,如果发现是老的版本,则不允许更新。
但问题是,Redlock 本身并没有这样的机制去保证这一设计,我们也很难保证计数器的一致性。而如果真的在业务侧计入了版本,那么相当于有序写入,似乎和「互斥锁」也没多大关系。
这也成为了一个 Redlock 高不成低不就的漏洞。因此也有文章抨击这一算法没什么卵用。
总结
综上来看,选择怎么样的锁也是一个问题,在现实程序中,我们经常看到单 master 的锁实现,因为他相比 RedLock 来说更加轻量,如果并不需要强一致性和可靠性,允许少量误差的前提下,用它可能更方便。
除了 Redis 以外,我们也可以用 etcd 或者 zookeeper 来实现分布式锁,这个我们下次再研究。
参考资料
植入部分
如果您觉得文章不错,可以通过赞助支持我。
如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。