使用 Redis 作为分布式锁用于简单的用例
我最近遇到了一个系统问题,我们编写的一些作为 Ruby Rake 任务的批处理作业会出现重复调用,进而导致数据库中出现重复记录或在 Slack 中出现重复警报。这肯定是一个基础设施问题,但在从基础设施方面修复它之前,我需要从编码角度找到一种解决方法。尝试将所有内容重写为尽可能幂等的不太现实,增强应用程序级别验证或数据库唯一性约束也不是一个选择,因为并非所有作业都可以是幂等的,也并非所有作业都会写入数据库,此外问题是由竞争条件引起的,不是来自两个线程,而是来自两个进程。经过一些研究和实验,我决定使用“Redis”作为一个简单的分布式锁解决方案,它已经在我们的应用程序中可用,不需要任何额外的设置,而且它是一个单一实例,因此没有复制同步问题。“Redis”是单线程架构,因此无论有多少并发客户端尝试写入它,它都可以保证是顺序和原子的。我发现可以利用这种性质来解决面临的问题。
任务示例
namespace :main do
task :hello do
sleep 1 # simulate long-running task
puts '***hello, world***'
end
end
无锁运行
$ bundle exec rake main:hello & bundle exec rake main:hello &
[1] 38358
[2] 38359
***hello, world***
***hello, world***
[2] + done bundle exec rake main:hello
[1] + done bundle exec rake main:hello
两个进程同时运行,并且运行两次。
添加redis分布式锁
- 定义锁
namespace :lock do
redis = Redis.new
task :before do
task_name = Rake.application.top_level_tasks[0]
lock_obtained = redis.set task_name, '1', ex: 60, nx: true
abort "Another process is already running #{task_name}" unless lock_obtained
end
task :after do
task_name = Rake.application.top_level_tasks[0]
redis.del task_name
end
end
- 任务加锁
Rake::Task['main:hello'].enhance(['lock:before']) do
Rake::Task['lock:after'].execute
end
带锁运行
$ bundle exec rake main:hello & bundle exec rake main:hello &
[1] 38560
[2] 38561
Another process is already running main:hello
[1] - exit 1 bundle exec rake main:hello
***hello, world***
[2] + done bundle exec rake main:hello
两个进程同时运行它,但它只运行一次。
概括
重点是利用 set
命令的 nx: true
选项,这意味着仅当 key 不存在时才设置它
。通过 redis 的单线程设计,我们可以确保只有一个进程能够成功设置 key,这意味着只有一个进程获得锁。
作为参考,Distributed Locks with Redis 有更多关于如何实现更可靠、更安全的基于算法的锁(如 Redlock
)的详细信息,适用于更复杂的情况。