谈谈Redis的SETNX

在 Redis 里,所谓 SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果,不过很多人没有意识到 SETNX 有陷阱!

比如说:某个查询数据库的接口,因为调用量比较大,所以加了缓存,并设定缓存过期后刷新,问题是当并发量比较大的时候,如果没有锁机制,那么缓存过期的瞬间,大量并发请求会穿透缓存直接查询数据库,造成雪崩效应,如果有锁机制,那么就可以控制只有一个请求去更新缓存,其它的请求视情况要么等待,要么使用过期的缓存。

下面以目前 PHP 社区里最流行的 PHPRedis 扩展为例,实现一段演示代码:

<?php

$ok = $redis->setNX($key, $value);

if ($ok) {
    $cache->update();
    $redis->del($key);
}

?>

缓存过期时,通过 SetNX  获取锁,如果成功了,那么更新缓存,然后删除锁。看上去逻辑非常简单,可惜有问题:如果请求执行因为某些原因意外退出了,导致创建了锁但是没有删除锁,那么这个锁将一直存在,以至于以后缓存再也得不到更新。于是乎我们需要给锁加一个过期时间以防不测:

<?php

$redis->multi();
$redis->setNX($key, $value);
$redis->expire($key, $ttl);
$redis->exec();

?>

因为 SetNX 不具备设置过期时间的功能,所以我们需要借助 Expire 来设置,同时我们需要把两者用 Multi/Exec 包裹起来以确保请求的原子性,以免 SetNX 成功了 Expire 却失败了。 可惜还有问题:当多个请求到达时,虽然只有一个请求的 SetNX 可以成功,但是任何一个请求的 Expire 却都可以成功,如此就意味着即便获取不到锁,也可以刷新过期时间,如果请求比较密集的话,那么过期时间会一直被刷新,导致锁一直有效。于是乎我们需要在保证原子性的同时,有条件的执行 Expire,接着便有了如下 Lua 代码:

local key   = KEYS[1]
local value = KEYS[2]
local ttl   = KEYS[3]

local ok = redis.call('setnx', key, value)
 
if ok == 1 then
  redis.call('expire', key, ttl)
end
 
return ok

没想到实现一个看起来很简单的功能还要用到 Lua 脚本,着实有些麻烦。其实 Redis 已经考虑到了大家的疾苦,从 2.6.12 起,SET 涵盖了 SETEX 的功能,并且 SET 本身已经包含了设置过期时间的功能,也就是说,我们前面需要的功能只用 SET 就可以实现。

<?php

$ok = $redis->set($key, $value, array('nx', 'ex' => $ttl));

if ($ok) {
    $cache->update();
    $redis->del($key);
}

?>

如上代码是完美的吗?答案是还差一点!设想一下,如果一个请求更新缓存的时间比较长,甚至比锁的有效期还要长,导致在缓存更新过程中,锁就失效了,此时另一个请求会获取锁,但前一个请求在缓存更新完毕的时候,如果不加以判断直接删除锁,就会出现误删除其它请求创建的锁的情况,所以我们在创建锁的时候需要引入一个随机值:

<?php

$ok = $redis->set($key, $random, array('nx', 'ex' => $ttl));

if ($ok) {
    $cache->update();

    if ($redis->get($key) == $random) {
        $redis->del($key);
    }
}

?>

补充:本文在删除锁的时候,实际上是有问题的,没有考虑到 GC pause 之类的问题造成的影响,比如 A 请求在 DEL 之前卡住了,然后锁过期了,这时候 B 请求又成功获取到了锁,此时 A 请求缓过来了,就会 DEL 掉 B 请求创建的锁,此问题远比想象的要复杂,具体解决方案参见本文最后关于锁的若干个参考链接。

如此基本实现了单机锁,假如要实现分布锁,请参考:Distributed locks with Redis,不过分布式锁需要注意的地方更多:How to do distributed lockingIs Redlock safe。此外,还有中文版:基于Redis的分布式锁到底安全吗()。

谈谈Redis的SETNX》上有33个想法

  1. 伪代码:
    $lock = 0
    while($lock != 1){
    $timestamp = time() + $timeout + 1;
    $lock = SETNX(‘lock.foo’,$timestamp);
    if($lock == 1 or (time() > (GET(‘lock.foo’) and time() > (GETSET(‘lock.foo’,timestamp))){
    break;
    }
    else{
    sleep(10ms);
    }
    }
    do_job()

    # release
    if(now() < GET('lock.foo')){
    DEL('lock.foo');
    }

    • # release
      if(now() < GET('lock.foo')){
      DEL('lock.foo');
      }

      这里的判断有问题,如果第一个获锁的进程超时,导致第二个进程通过getset 设置了新的值, 那么这里的判断就会出现问题。

  2. 最后一个情况我认为其实也有问题,如果同时有很多请求,而每次请求都遇到了锁过期,但更新还没执行完的情况,也可能会产生雪崩吧

    • SET 涵盖了 SETEX 的功能,不会在发生雪崩了

  3. lua脚本和第一种php实现有同样的隐患吧?能解释lua不会在设置过期时间前中断么

    • lua 脚本在 redis 中执行的时候是原子的,要成功都成功,要失败都失败,不会出现成功一部分的情况,所以没问题。

  4. Pingback引用通告: [狗尾续貂第二篇]Redis内存锁的实现方法 - IT大道

  5. 最后一个情况也是有问题的
    当key加了随机数之后,这个key就变成了另一个key了,这样每个请求生成的key就不一致了,那setnx 这个函数就没意义了。

    • 不行,muti/exec模式,后一条命令不能依赖前一条命令的输出结果

  6. Pingback引用通告: (转)谈谈Redis的SETNX - xwuxin

  7. Pingback引用通告: [转]谈谈Redis的SETNX – 王春伟的技术博客

  8. 最后一个情况:

    如果update时间很长,超过过期时间,此时,redis应该会自动帮你删除这个key吧,此时的手动删除,没任何意义吧!!!也会导致同样的问题,不知道我的理解正确不?个人觉得第一楼给出的答案,还是比较完善的,但是也会有这类问题。。。

    • 这里的删除操作还是需要的,不删除的话后续需要更新缓存的操作就必须等到缓存失效才能做更新了
      考虑下一个业务上的更新要求缓存失效更新其值为新的值的情况

      不会发生雪崩
      因为有失效时间,假设是5分钟,那5分钟之内只有一个更新请求会进来
      这样不会在5分钟之内有大量访问更新db的操作,但会存在大量访问db操作(目标缓存已经失效)

发表回复

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