Redis SETNX 命令只有在指定的 key 不存在的情况下,将 key 设置为 value 值,这时等同于 SET 命令;若指定的 key 存在,则什么也不做。SETNX 是“SET if Not eXists”的缩写。
命令格式
SETNX key value
可用版本:>=1.0.0
时间复杂度:O(1)
命令返回值
若设置成功,返回 1。
若设置失败,则返回 0。
示例
redis> EXISTS cpc # cpc 不存在
(integer) 0
redis> SETNX cpc "ctr" # cpc 设置成功
(integer) 1
redis> SETNX cpc "pv" # 尝试覆盖 cpc,失败
(integer) 0
redis> GET cpc # 没有被覆盖
"ctr"
SETNX 实现分布式锁
如上所述,SETNX 只有在 key 不存在的时才设置成功,结合 Redis 服务单线程的性质,可以实现分布式锁的效果,但是其实现有一些陷阱,需要开发者了解。
SETNX 最典型的使用场景是,某个查询数据库的接口,因为调用量比较大,所以加了缓存,并设定缓存过期后刷新,问题是当并发量比较大的时候,如果没有锁机制,那么缓存过期的瞬间,大量并发请求会穿透缓存直接查询数据库,造成雪崩效应,如果有锁机制,那么就可以控制只有一个请求去更新缓存,其它的请求视情况要么等待,要么使用过期的缓存。
以 PHP 代码为例:
<?php
$ok = $redis->setNX($key, $value);
if ($ok) {
$cache->update();
$redis->del($key);
}
?>
上述实例的步骤如下:
- 每个请求都先执行 SETNX 命令,按照设计,只有一个请求获取锁;
- 获取锁的请求,更新缓存;
- 然后再释放锁(删除对应的 key)。
上述逻辑看上去逻辑非常简单,但会有问题:如果请求执行因为某些原因意外退出了,导致创建了锁但是没有删除锁,那么这个锁将一直存在,以至于以后缓存再也得不到更新。
于是乎我们尝试给锁加一个过期时间以防不测:
<?php
$redis->multi();
$redis->setNX($key, $value);
$redis->expire($key, $ttl);
$redis->exec();
?>
由于 SETNX 不能设置过期时间,所以我们需要借助 EXPIRE 命令来设置过期时间,同时我们需要把两个命令用 Redis 事务相关的 MULTI 和 EXEC 命令包裹起来以确保请求的原子性。
但是还会存在问题:当多个请求到达时,虽然能保证只有一个请求的 SETNX 可以成功,但是任何一个请求的 EXPIRE 却都可以成功,如此就意味着即便获取不到锁,也可以刷新过期时间,如果请求过于密集的话,那么过期时间会一直被刷新,导致锁一直释放不了。
于是乎我们需要在保证原子性的同时,有条件的执行 EXPIRE 操作:
<?php
$ok = $redis->setNX($key, $value);
if ($ok) {
$redis->expire($key, $ttl);
$cache->update();
}
?>
值得庆幸的是,Redis 从 2.6.12 版本开始,可以创建一个更加简单的加锁原语,那就是 SET 命令,它涵盖了 SETNX 功能和 SETEX 功能,也就是说,我们前面需要的功能只用 SET 就可以实现。
<?php
$ttl = 60;
$ok = $redis->set($key, $value, array('nx', 'ex' => $ttl));
if ($ok) {
$cache->update();
$redis->del($key);
}
//这里$ttl设置的时间要恰到好处,要大于请求执行的时间。
?>
上述实例看起来很完美,但还是存在小问题:如果一个请求更新缓存的时间比较长,甚至比锁的有效期还要长,导致在缓存更新过程中,锁就失效了,此时另一个请求会获取锁,但前一个请求在缓存更新完毕的时候,如果不加以判断直接删除锁,就会出现误删除其它请求创建的锁的情况。
所以我们在创建锁的时候需要引入一个唯一值:
<?php
$uuid = uuid();
//对查询进行加锁(一般是唯一数据项,例如订单号)
$ok = $redis->set($key, $uuid, array('nx', 'ex' => $ttl));
if($ok){
//相当于业务这里一般来处理逻辑
$cache->update();//更新缓存
if ($redis->get($key) == $uuid) {//保证删自己的,别因为请求时间长而删掉别人的内容
$redis->del($key);//处理完之后 释放锁
}
}
?>