锁一般有三种实现方式:1. 数据库乐观锁;2. 基于Redis的锁;3. 基于ZooKeeper的锁。
首先安装redis并启动服务 redis-3.0.6 :https://blog.youkuaiyun.com/raoxiaoya/article/details/92797864
首先,为了确保锁可用,我们至少要确保锁的实现同时满足以下四个条件:
1、互斥性。在任意时刻,只有一个客户端能持有锁。
2、不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
3、解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
redis服务器使用单线程来执行命令的,并且是原子性的,所不存在同时执行多个命令的情况,都是 one by one 的执行。
主要用到 setnx expire eval
代码如下:
<?php
class RedisCli {
protected static $redis;
public static function getRedisInstance($conf){
if(isset(self::$redis)){
return self::$redis;
}
try{
$redis = new \Redis();
$re = $redis->connect($conf['host'], $conf['port']);
if(!$re){
throw new \Exception('connect redis error');
}
if(isset($conf['auth'])){
$re = $redis->auth($conf['auth']);
if(!$re){
throw new \Exception('redis auth error');
}
}
self::$redis = $redis;
return $redis;
}catch(\Exception $e){
throw new \Exception($e->getMessage());
}
}
/*
获取锁
1、setnx + expire + lua
防止setnx后程序崩溃,导致expire没有执行,形成死锁,所以需要使用Lua脚本来优化,
$script = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then return redis.call('expire',
KEYS[1], ARGV[2]) else return 0 end";
2、set
当 redis >= 2.6.12 时set命令得到扩展
SET key value [EX seconds] [PX milliseconds] [NX|XX]
因此可以写成 $redis->set($lockKey, $value, 'EX' , $expire, 'NX'),但是遗憾的是phpredis
扩展提供的set方法是这样的:
public function set( $key, $value, $timeout = 0 ) {}
可以Lua来执行set了。或者使用rawCommand()来执行原生的set命令。
*/
public static function tryGetDistributedLock($redis, $lockKey, $value, $expire){
$script = "return redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX')";
if($redis->eval($script, [$lockKey, $value, $expire], 1)){
return true;
}else{
return false;
}
}
/*
释放锁
释放锁最要考虑的问题是不能释放了别人的锁,当我要del的时候,此时expire过期,锁被别
人拿到,如果我不加思索就delete,就会删掉别人的锁。
get + del + lua
*/
public static function releaseDistributedLock($redis, $lockKey, $value){
$script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
if($redis->eval($script, [$lockKey, $value], 1)){
return true;
}
return false;
}
}
function redisLock($resourceId){
$conf = ['host'=>'127.0.0.1', 'port'=>6379];
$client = RedisCli::getRedisInstance($conf);
$lockKey = 'lockKey_' . $resourceId;
$value = bin2hex(random_bytes(10));
$expire = 10;
$re = RedisCli::tryGetDistributedLock($client, $lockKey, $value, $expire);
if($re){
echo 'get lock success'.PHP_EOL;
}else{
echo 'get lock fail'.PHP_EOL;
return ;
}
try {
doSomething();
} catch(\Exception $e) {
echo $e->getMessage() . PHP_EOL;
} finally {
$re = RedisCli::releaseDistributedLock($client, $lockKey, $value);
if($re){
echo 'release lock success'.PHP_EOL;
}else{
echo 'release lock fail'.PHP_EOL;
}
return ;
}
}
function doSomething(){
$n = rand(1, 20);
switch($n){
case 1:
sleep(15);// 模拟超时
break;
case 2:
throw new \Exception('system throw message...');// 模拟程序中止
break;
case 3:
die('system crashed...');// 模拟程序崩溃
break;
default:
sleep(1);// 正常处理过程
}
}
$count = 100;
while(--$count > 0){
redisLock(0);
usleep(100000);// 0.5s
}
由于多线程和多进程都不方便查看打印,于是新开几个命令窗口来执行脚本,观看打印结果。
考虑到分布式的系统,每台服务器的时间可能不一样,所以不能用服务器生成时间来填充value值。
在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
expire的设置也是个问题,过长的话,如果我这台机器出问题,导致锁没有释放,会导致阻塞;过短会导致我程序还没执行完锁已释放并被别人拿去了,我再来释放锁是失败的(我在del时做了校验),这样其实就丧失了锁的意义了,万一我后续的业务需要在锁着的情况下来完成呢?
一个解决的方式:在获取锁成功后开启一个线程,此线程一直阻塞,直到key过期的前3秒去访问一下这个key,如果key还在且value也对的上,证明过期时间设置的短了,那么此线程就调用expire命令去延长过期时间。
为了减小锁的颗粒度,我们可以为每个资源设置不同的 lockKey ,提高并行效率,比如,要秒杀一些商品,应该为每个商品设置锁,而不是只有一个锁。
由于没有通知机制,每一个尝试获取锁的请求,当获取失败时,应该多给予一定次数的尝试机会,多次失败之后才返回 false。
以上情况是redis为单台服务器的时候,当redis为集群的时候,意味着我们的web服务器可能连到不同的redis,此时要上锁则需要把全部的redis都上锁,释放锁也是同样的道理,如果使用的是Laravel框架可直接使用插件 Laravel-redlock 当然其实现起来也不复杂,比如 redlock-php ,也可以自行整合到自己的项目中。
注意Laravel中的Redis类的eval方法的参数有点变化:
use Illuminate\Support\Facades\Redis;
// 获取锁
public static function tryGetDistributedLock($key, $val = 1, $expire = 60)
{
$script = "return redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX')";
if (Redis::eval($script, 1, $key, $val, $expire)) {
return true;
} else {
return false;
}
}
// 释放锁
public static function releaseDistributedLock($key, $val = 1)
{
$script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
if (Redis::eval($script, 1, $key, $val)) {
return true;
}
return false;
}
public static function tryGetDistributedLockBlock($key, $value, $timeout = 10, $expire = 60)
{
if (!is_int($timeout) || $timeout <= 0) {
return false;
}
if ($timeout > 60) {
$timeout = 60;
}
$flag = false;
$start = time();
while (true) {
$flag = self::tryGetDistributedLock($key, $value, $expire);
if (!$flag && (time() - $start) < $timeout) {
usleep(10000); //10ms
continue;
}
break;
}
return $flag;
}