php, redis

redis中的事务与锁

这篇文章是我在查找如何对redis中的值做原子操作时的一系列笔记,虽然最初的目的只是研究有哪些方式可以实现事务(transaction)操作,但是后来的延伸很多,所以我认为有必要做一些笔记防止忘记。

1.redis中的事务

redis中的事务其实并不满足数据库事务的四个特性:原子性(Atomicity)、一致性(Consistency)、隔离型(Isolation)、持久性(Durability),简称ACID。redis只能在一致性和隔离性上提供保证,而原子性和持久性是无法保证的。因为redis的事务操作如果中断,无法回滚,满足不了原子性,并且redis的持久化策略有很多中,比如在纯内存模式下是无法持久化数据库更改的,满足不了持久性。

所以下面针对与redis事务的讨论都是有局限性的。仅仅是指对redis数据进行操作时,不会受到其他客户端的干扰。举例来说:客户端A读取keyA,修改keyA中间,不会受到客户端B的影响。

    //客户端A执行以下代码
    //获取user1的性别,如果性别是男,头像设置为男性角色
    //否则头像设置为女性角色
    $gender = $redis->get('user1_gender');
    if($gender === 'male'){
        $redis->set('user1_photo','male.png');
    }else{
        $redis->set('user1_photo','female.png');
    }
    //客户端B执行以下代码
    //更改user1的性别,如果是男则改为女,如果是女则改为男
    //并根据性别设置头像
    $gender = $redis->get('user1_gender');
    if($gender === 'male'){
        $redis->set('user1_gender','female');
        $redis->set('user1_photo','female.png');
    }else{
        $redis->set('user1_gender','male');
        $redis->set('user1_photo','male.png');
    }

如果客户端A、B同时只有一个执行,那么性别和头像一定是对应的。但是A、B同时执行,并且执行顺序如下:
时序图

那么执行结果就是user1性别为女,但头像为男性角色。

所以针对于这种情况,我们需要一定的措施去预防。

redis的事务实现方式有多种。据我所知有三种方式:

  • 1.MULTI/EXEC
  • 2.WATCH/UNWATCH
  • 3.lua脚本

1.1 MULTI/EXEC

MULTI/EXEC是成对出现的指令。大家都知道,redis执行指令是单线程的,也就是说所有指令都处于一个队列,一个一个执行。MULTI/EXEC可以保证所有的指令都会被同时放松到redis中,中间不会掺杂来自其他客户端的指令。

但是这个针对于上述情况并不适用。因为我们需要在GET到数据之后,才能做下面的更新操作。

1.2 WATCH/UNWATCH

在redis中我们可以使用watch来监视一个值。

redis> WATCH name
OK

redis> MULTI
OK

redis> SET name peter
QUEUED

redis> EXEC
(nil)

比如这段代码,在我们WATCH name后,如果另外一个客户端修改了name的值,那么这个客户端再次修改name则无法成功。这其实就是乐观锁的一种实现。它保证了代码的执行结果不会错乱。用一开始的例子来说,就是不会出现性别和头像不对应的情况。

1.3 lua脚本
redis中可以执行lua脚本来完成事务操作。lua脚本和MULTI/EXEC很像,也就是说在lua脚本执行的过程中,redis是不执行其他客户端的指令的。

但是lua脚本的不同之处在于,你可以使用GET操作来获取数据并判断,再执行后来的操作。

2.在redis操作中使用锁

在多线程环境中,为了防止资源出现race condition,需要借助锁来互斥的访问资源。在这里也是一样的,user1_gender就是我们要互斥访问的资源。

还是上述那个例子,如果我们使用悲观锁,只有获得锁的客户端才能读取和修改user1的的值,也可以很好的解决这个问题。

伪代码如下:

$can_lock = lock('user1_gender'); //得到锁
if($can_lock){
    do_something();
    $release_lock('user1_gender');  //释放锁
}

不同于多线程环境下的是,我们这里的锁的范围是针对于不同的客户端。因此没法使用基于系统的、或者基于语言的锁,而是得使用分布式的锁。这样的分布式锁我们同样可以借助于redis来实现。

整个分布式锁的实现可以概括为以下几个步骤:

    1. 获得锁。得到要锁的资源的唯一hash:lockname,以及一个随机字符串:identifier,设置expire:20s,这个expire就是锁的有效期,在有效期后锁会自动释放,防止出现死锁。在redis中使用setnx(lockname,identifier,expire)。这句代码的意思是:如果redis中不存在lockname,则存入lockname,值为identifier,过期时间是expire。
    1. 第一步我们就得到了一个锁,这一步我们开始执行获得锁之后的代码
    1. 释放锁。我们根据lockname来从redis中查找。如果get(lockname) == identifier,则表示我们仍然持有这把锁,使用delete(lockname)来释放锁。如果不等于,说明我们已经不持有这把锁了,则什么也不做。

那么lock函数可以用以下代码来描述:

function lock($lockname,$identifier,$expire = 20){
    $acquire_timeout = 10;      //花费10秒去获得锁,否则就放弃
    $end = time() + $acquire_timeout
    while (time() < $end){
        $can_lock = $redis->setnx($lockname,$identifier,$expire);
        if($can_lock){
            return true;
        }
        sleep(0.1);
    }

    return false;
}
    function release_lock($lockname,$identifier){
        $redis->watch($lockname);
        try{
            if($redis->get($lockname) === $identifier){
                //锁仍然持有,释放锁
                $redis->delete($lockname);
                return true;
            }

            $redis->unwatch();
        }catch(Exception $e){

        }

        return false;
    }

这样我们就很轻松的得到了借助于redis实现的分布式锁。但是这样的实现方式依然是有问题的。

问题1:如果在某个客户端获得锁后,redis主服务器宕机了,那么即使我们使用了主从备份,从属服务器被提升为主服务器,因为redis备份是异步的原因,这里的锁是没法及时同步到从属服务器的。

问题2:如果一个客户端在获得锁后,执行的操作超过了锁的有效期,锁被自动释放了。那么后续的操作是没法受到锁的保护的。

问题2的解决方案可以是watch,在获取锁后,可以立刻watch资源,然后再执行余下操作。

问题1的解决方案则是接下来要介绍的redlock算法

3.redlock

redlock算法是Redis的作者antirez提出来的。
可以被概述为以下几个步骤:

  • 1.获取当前时间(毫秒数):start_time。

  • 2.按顺序获得N个Redis节点的锁(使用相同的key和identifier,并设置初始有效时间:init_validity_time)。在获得每个redis结点的锁的时候,都要设置一个timeout参数,这个timeout要远小于锁的自动释放时间。例如:如果锁的自动释放时间是10s,timeout应该为~5-55ms(还得视网络情况决定)。这样可以防止在获取锁时,节点宕机,导致耗时过长锁被释放了。如果获取锁失败则立刻获取下一个redis节点的锁

  • 3.client计算为了获取锁花了多长时间:used_time = current_time – start_time。当且仅当client可以获取大多数实例的时候(至少N / 2 + 1个),所花费的时间小于锁的有效时间,才认为获得了锁。

  • 4.如果获得了锁,重新计算锁的有效时间:validity_time = init_validity_time – used_time

  • 5.如果锁获取失败(无法获取N/2 + 1个节点的锁,或者有效时间validity_time是负数),则释放所有实例的锁(即使获得锁的时候失败了,这主要是考虑到有的时候锁获得成功了,但是告知客户端时网络异常)。

这很好的解决了上述的问题1,通过多个redis节点了来保证分布式锁服务的可靠性。

参考文章

Distributed locks with Redis

基于Redis的分布式锁到底安全吗(上)?

基于Redis的分布式锁到底安全吗(下)?

redis事务

6.2.3 Building a lock in Redis

Be the First to comment.

Leave a Comment

电子邮件地址不会被公开。 必填项已用*标注