# redis-02-用redis来做锁

在很多的业务场景中对数据的处理都会涉及到锁,常用的方案有

基于 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-pyredlock-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的封装。如果是高可用的场景,可以考虑使用。

设想下它的微服务架构:将多个没有主从关系的redis实例单独出来,做成一个Redlock锁的服务,其他redis集群另外独立,当需要锁时,就使用它。