Redis 分布式锁
分布式锁 Redis 的实现
系统中有些业务,我们需要先读取,然后在进行保存,称为 CAS(check-and-set), 此时很容易遇到并发问题,无法保证业务的原子性
问题
系统中有多个并行的 sidekiq Job,都是同样的逻辑 代码如下:
# 查询
project_link_status = ProjectLinkStatus.find_or_initialize_by(project_id: xxx)
# project_link_status = ProjectLinkStatus.where(project_id: xxx)
# project_link_status ||= ProjectLinkStatus.new
# 保存
project_link_status.save!
现象:数据库会保存两条 project_id
一样的数据
结论:遇到了并发
解决方案
- 通过加数据唯一索引的方式
- 加 redis 分布式锁
Redis 分布式锁
开始我是想用 redis
做一个中间的缓存队列,数据都往 redis
里面塞,redis
再定时去消费
后来经过大佬推荐,可以使用 redis
分布式锁
实现
Redis
锁主要利用 Redis
的 setnx
命令。因为 redis
是单线程,操作是原子性的,所以不存在并发生成锁的问题。
就是给这个 Job
操作命一个 key
, 通过 setnx
生成一个锁,当代码执行的时候,能取到这个锁,就可以往下执行,否则就等待。执行完成之后就把锁给删掉。
伪代码如下:
if (setnx(key, 1) == 1){
expire(key, 30)
try {
# TODO 业务逻辑
} finally {
del(key)
}
}
我们按照这个逻辑转换成 ruby 代码:
def perform
key = 'setnx:test'
check_run(key)
end
def check_run(key)
if $redis.setnx(key, 1) == 1
$redis.expire(key, 30)
# 执行逻辑
# 执行完成
# 释放锁
else
sleep 3
check_run(key)
end
end
这样就解决了吗?真的这么轻松吗?
并没有,这样还存在 redis 两次操作不是原子性的问题
如果 setnx
成功, 服务器挂了,或者重启了, 等等,expire
就失败了
那这个锁就永远不会释放了,就会造成死锁了
为了保证 redis 两次操作的原子性,我们可以使用 lua
脚本, 什么是 lua
脚本?
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
然后结合 redis eval
一起使用, eval
有什么作用呢?
Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。
另一方面,这也意味着,执行一个运行缓慢的脚本并不是一个好主意。写一个跑得很快很顺溜的脚本并不难,因为脚本的运行开销(overhead)非常少,但是当你不得不使用一些跑得比较慢的脚本时,请小心,因为当这些蜗牛脚本在慢吞吞地运行的时候,其他客户端会因为服务器正忙而无法执行命令。
大致代码如下:
def perform
key = 'setnx:test'
check_run(key)
end
def check_run(key)
if $redis.eval("if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;", [key], [1, 30])
# 执行逻辑
# 执行完成
# 释放锁
else
sleep 3
check_run(key)
end
end
这样就解决了吗?会不会还有问题?
这样还会存在一个问题:
假如说有两个线程运行,分别为 Thead1
, Thead2
Thead1
先取到资源锁lock1
,执行超过了30s
, 资源锁lock1
过期了Thead2
然后取到了资源锁lock2
, 此时Thead1
和Thead2
并发执行了Thead1
执行完毕,要释放资源锁lock1
(但是lock1
已经不存在了), 因为锁没有区分标记,这时Thead1
会把的lock2
给释放掉
这样就会存在两个问题
-
Thead1
把Thead2
的锁给释放掉了
-
Thead1
和Thead2
并发执行了
解决:
第一个问题我们可以给每个线程加 uuid
来标识,只有对应的 uuid
才能解对应的锁。
第二个问题, 一般有两种方式解决该问题:
- 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成。
- 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。
进行到这里,我准备用过期时间设置足够长解决这个问题了
然后还有一种解决思路就是通过,redis
发布和订阅来获取锁和释放锁,
这样就又多了几次 redis
操作。
最终代码大致如下:
def perform
key = 'setnx:test'
uuid = UUID.generate
check_run(key, uuid)
end
def check_run(key, uuid)
if $redis.eval("if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;", [key], [uuid, 30])
# 执行逻辑
# 执行完成
if $redis.get(key) == uuid
# 释放锁
end
else
sleep 3
check_run(key, uuid)
end
end
参考
https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/