1 面试中、实际工作中,经常涉及到 redis 分布式锁,正确写法如下。先奉上代码,再讲解。
connect('192.168.4.147',6179);
return $redis;
}
/**
* 加锁(原子操作)
* @param string $key 要加锁的key
* @param string $value 必须是唯一值
* @param int $expires 锁的过期时间(秒)
* @return bool
* @throws \RedisException
*/
public static function lock(string $key,string $value,int $expires): bool
{
# redis实例
$redis = self::redis();
# 原子操作 -- 设置锁且设置过期时间
return $redis->set($key,$value,['nx','ex'=>$expires]);
}
/**
* 解锁(原子操作)
* @param string $key 要解锁的key
* @param string $value 加锁时的值
* @return mixed|\Redis
* @throws \RedisException
*/
public static function unlock(string $key,string $value)
{
# redis实例
$redis = self::redis();
# lua 脚本
$lua = "
if redis.call('GET',KEYS[1]) == ARGV[1] then
return redis.call('DEL',KEYS[1])
else
return 0
end
";
return $redis->eval($lua,[$key,$value],1);
}
/**
* 生成唯一值
* @return string
*/
public static function generateValue(): string
{
return microtime(true).mt_rand(10000,99999);
}
}
2 使用方式
use app\common\library\Lock;
# 抢到锁
if(Lock::lock($key,$value,300)){
try{
# 业务逻辑
}
catch (\Exception $e){}
finally {
# 释放锁
Lock::unlock($key,$value);
}
}
抢红包、秒杀下单、扣库存、…
在并发情况下,避免业务逻辑的重复执行,导致性能低下,或数据不一致。重复执行没意义的工作,浪费性能,比如数据清理、数据归档、检测日志等 。重复执行有意义的工作,导致数据出错,比如重复多扣了库存。
1 setnx + expire
说到 redis 的分布式锁,很多同学马上会想到 setnx + expire,先用 setnx 抢锁,如果抢到之后,再用expire 给锁设置一个过期时间防止忘记释放。
setnx 是 set if not exists 的缩写,表示如果 key 不存在,则去设置,成功返回1,否则返回0。
//抢锁
if($redis->setnx($key,$value)){
//设置过期时间
$redis->expire($key,300);
try{
//业务逻辑
}
catch (\Exception $e){}
finally {
//释放锁
$redis->del($key);
}
}
注:这个方案的问题在于,setnx 和 expire 两个命令分开了,不是原子操作。如果执行完加锁操作(setnx)后正要执行 expire 设置过期时间,进程崩了或者要重启维护,那么这个锁就长生不老了,别的线程永远也获取不到这个锁了。
2 使用Lua脚本(包含setnx + expire两条指令)
抢锁的改进如下,解决了上面写法1抢锁时的非原子操作。利用 lua 脚本把多个指令一起执行,达到原子操作的目的。
# lua脚本
$lua = "
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
redis.call('expire',KEYS[1],ARGV[2])
else
return 0
end
";
# 执行lua脚本,并传入参数
return $redis->eval($lua,[$key,$value,$expires],1);
3 SET的扩展命令(SET EX PX NX)
效果等同于写法2,也是原子性的。
//抢锁
if($redis->set($key,$value,['NX','EX'=>300])){
try{
//业务逻辑
}
catch (\Exception $e){}
finally {
//释放锁
$redis->del($key);
}
}
SET key value[EX seconds][PX milliseconds][NX|XX]
NX
:表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
EX seconds
:设定key的过期时间,时间单位是秒。
PX milliseconds
: 设定key的过期时间,单位为毫秒
XX
: 仅当key存在时设置值
注:但是,这个方案还有个问题:「锁被别的线程误删」
比如线程A执行完后,去释放锁,但它不知道当前的锁可能是线程B持有。线程A就把线程B的锁释放了,但线程B临界区业务代码可能还没执行完成。
释放锁的代码改进如下,判断所有者是否是自己,然后再释放。也用原子操作:
# redis实例
$redis = $this->redis();
# lua 脚本
$lua = "
if redis.call('GET',KEYS[1]) == ARGV[1] then
return redis.call('DEL',KEYS[1])
else
return 0
end
";
return $redis->eval($lua,[$key,$value],1);
4 SET EX PX NX + 校验所有者,再删除
改进后,最终的代码,见文章开头。
注意,请根据实际业务情况合理设置锁的过期时间 expires 。
一、
无论如何,依然存在一个问题:「锁过期释放了,业务还没执行完」
假设线程A获取锁成功,一直在执行业务代码,300秒过后,它还没执行完。这时候锁就过期了,别的线程又请求进来获取到锁了,也开始执行业务代码。问题就来了,业务代码并不是严格串行执行。
一般情况中小项目中,做好日志、容错判断等即可。如果你项目到了一定规模,如果你追求锁的决定安全性,解决方案是:自动续期。
这个话题,值得再另写一篇文章讲解。自行上网搜索学习,此处省略。
JAVA 提供了很好的一个分布式锁框架: Redisson
,它很好的解决了此问题。PHP 暂时我还没找到好的类库。
还有就是,自己实现自动续期。
二、
多个 redis 实例、集群模式时,解决方案请看官方提供的 RedLock
。这又值得另写一篇文章讲解。自行上网搜索学习,此处省略。