在 Redis 里,所谓 SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,能够利用它来实现锁的效果,不过不少人没有意识到 SETNX 有陷阱!php
好比说:某个查询数据库的接口,由于调用量比较大,因此加了缓存,并设定缓存过时后刷新,问题是当并发量比较大的时候,若是没有锁机制,那么缓存过时的瞬间,大量并发请求会穿透缓存直接查询数据库,形成雪崩效应,若是有锁机制,那么就能够控制只有一个请求去更新缓存,其它的请求视状况要么等待,要么使用过时的缓存。redis
下面以目前 PHP 社区里最流行的 PHPRedis 扩展为例,实现一段演示代码:数据库
<?php缓存
$ok = $redis->setNX($key, $value);并发
if ($ok) {dom
$cache->update(); $redis->del($key);
}code
?>接口
缓存过时时,经过 SetNX 获取锁,若是成功了,那么更新缓存,而后删除锁。看上去逻辑很是简单,惋惜有问题:若是请求执行由于某些缘由意外退出了,致使建立了锁可是没有删除锁,那么这个锁将一直存在,以致于之后缓存再也得不到更新。因而乎咱们须要给锁加一个过时时间以防不测:get
<?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 请求建立的锁,此问题远比想象的要复杂,具体解决方案参见本文最后关于锁的若干个参考连接。