使用 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)的详细信息,适用于更复杂的情况。

redis