在很多的业务场景中对数据的处理都会涉及到锁,常用的方案有
基于 DB 的唯一索引。
基于 ZK 的临时有序节点。
基于 Redis 的 NX EX 参数。
这次用redis和PHP来做个锁,为了方便说明,统一以多进程的场景做例子。
一.思路
上锁
说到redis锁,首先想到的是用setex来做,比如某个进程先上一把锁
127.0.0.1:6379> setnx lock 1
(integer) 1
这时当另一个进程去读的时候,返回的是0,认为被锁了
127.0.0.1:6379> setnx lock 1
(integer) 0
但实际上会有问题:
某个进程上锁后,还没解锁,进程就crash了,则该key会一直存在redis中,变成了死锁
于是想到了另一个指令expire,给这个key加上过期时间不就可以了嘛,但还是有缺陷:
setex和expire是独立的两个操作,不是原子的,如果这两个操作中间出现问题,则还是会出现死锁
作为一把好锁,能避免的缺陷就要去避免,继续看发现在Redis 2.8 版本中,作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行,于是上锁的问题就解决了
set lock 1 ex 5 nx
#设置一个key值为1,有效时间为5秒,该key不存在时返回OK,已存在时返回nil
解锁
直接通过del删除该key不就可以了吗,但实际会有问题:
进程A上了把锁a,设置了锁a的过期时间为5秒,之后进程A执行业务逻辑。
5秒后,锁a释放了,第6秒进程B跑过来上了把新锁b,设置过期时间10秒。
到第7秒时,进程A的业务逻辑执行完毕,接着执行解锁操作,结果把进程B的锁释放了。
这里暴露了两个问题:
1.上锁后的业务执行时间过长,超过了锁的有效时间,导致锁混乱了
2.解锁时,把其他人的锁解了
对于问题1,需要在代码处理中优化好业务逻辑的处理,避免执行时间过长,超过了锁的有效时间。
对于问题2,要保证只解自己的锁,有个方案是使用lua脚本, 因为Lua 脚本可以保证连续多个指令的原子性执行。脚本内容如下:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
二.PHP实现
总结刚才的思路:上锁使用set的扩展参数ex和nx,解锁使用lua脚本
本次使用predis库,在laravel下运行,代码如下
const LOCK_SUCCESS = 'OK';
const IF_NOT_EXIST = 'NX';
const MILLISECONDS_EXPIRE_TIME = 'PX';
const RELEASE_SUCCESS = 1;
public function __construct()
{
$config = [
'host' => '127.0.0.1',
'port' => 6379,
];
$redis = new \Predis\Client($config);
$this->ser = $redis;
}
//上锁
public function tryGetLock(String $key, String $requestId, int $expireTime) {
$result = $this->set($key, $requestId, self::MILLISECONDS_EXPIRE_TIME, $expireTime, self::IF_NOT_EXIST);
return self::LOCK_SUCCESS === (string)$result;
}
//解锁
public function releaseLock(String $key, String $requestId) {
$lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
$result = $this->ser->eval($lua, 1, $key, $requestId);
return self::RELEASE_SUCCESS === $result;
}
public function set($key,$val)
{
return $this->ser->set($key,$val);
}
测试
$key = 'test';
$id = rand(1,100); #模拟不同的会话
$ser = new RedisTool();
$lock = $ser->tryGetLock($key,$id,10000); #设置过期时间为10秒
if($lock){
echo '获取了锁';
fastcgi_finish_request();
sleep(12); #模拟业务执行时间过长
$ser->releaseLock($key,$id); #释放锁
return;
}else{
return '获取锁失败';
}
第一次访问,显示“获取了锁”,key的值为59.
127.0.0.1:6379> get test
"59"
接着不断刷新页面,显示为“获取锁失败”,10秒后,显示“获取了锁”,看到key的值为4
127.0.0.1:6379> get test
"4"
继续获取key的值,发现一直是4,没有被第一次的访问释放。
127.0.0.1:6379> get test
"4"
127.0.0.1:6379> get test
"4"
127.0.0.1:6379> get test
"4"
127.0.0.1:6379> get test
"4"
之后第二次获取的锁也到期了,自动释放
127.0.0.1:6379> get test
(nil)
三.分布式锁
以上的过程是基于单机redis的,如果是redis集群时,则要考虑主从切换的问题:
进程A在主redis上加了把锁,这时主挂了,从接管,但该锁还没来得及同步到从,这时进程B去加锁,则可以加锁成功,导致一个集群里出现两把相同的锁
为了解决这个问题,Antirez 发明了 Redlock 算法。python和PHP都有相关的封装:redlock-py,redlock-php。开箱即用,省去了造轮子的麻烦。
$servers = [
['127.0.0.1', 6379, 0.01],
['127.0.0.1', 6389, 0.01],
['127.0.0.1', 6399, 0.01],
];
$redLock = new RedLock($servers);
//上锁
$lock = $redLock->lock('my_resource_name', 1000);
//解锁
$redLock->unlock($lock)
Redlock的原理是部署多个没有主从关系的redis服务,加锁时,它会向过半节点发送 set(key, value, nx=True, ex=xxx) 指令,只要过半节点 set 成功,那就认为加锁成功。释放锁时,需要向所有节点发送 del 指令。
Redlock的缺点是对性能有损耗,增加了运维的复杂度,在开发中需要单独引入Redlock的封装。如果是高可用的场景,可以考虑使用。