分布式锁 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 一样的数据
结论:遇到了并发

解决方案

  1. 通过加数据唯一索引的方式
  2. 加 redis 分布式锁

Redis 分布式锁

开始我是想用 redis 做一个中间的缓存队列,数据都往 redis 里面塞,redis 再定时去消费

后来经过大佬推荐,可以使用 redis 分布式锁

实现

Redis 锁主要利用 Redissetnx 命令。因为 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

  1. Thead1 先取到资源锁 lock1,执行超过了 30s, 资源锁 lock1 过期了
  2. Thead2 然后取到了资源锁 lock2, 此时 Thead1Thead2 并发执行了
  3. Thead1 执行完毕,要释放资源锁 lock1(但是 lock1已经不存在了), 因为锁没有区分标记,这时 Thead1 会把的 lock2 给释放掉

这样就会存在两个问题

    1. Thead1Thead2 的锁给释放掉了
    1. Thead1Thead2 并发执行了

解决:
第一个问题我们可以给每个线程加 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/