Redis应对并发问题
并发访问
redis的并发访问,是指多个客户端,对同一份数据进行修改。并发访问控制对应的操作是数据修改,当客户端需要修改数据时,基本流程为:
- 客户端从redis读取数据到本地,在本地修改
- 修改完毕后,客户端写回redis
这个流程叫“读取-修改-写回”操作(read-modify-write, RMW操作),多个客户端对同一份数据的RMW代码,叫做临界区代码。并发的客户端必须互斥地进入临界区,否则可能发生并发问题,对数据的修改不符合预期。
对redis数据的互斥访问,有两种方法:
- 对临界区代码加锁(应用代码实现,本质是让临界区代码具备原子性)
- 让临界区代具备原子性(redis支持)
应用加锁会降低系统的并发访问性能,redis支持的原子操作更高效,对并发性能影响较小。
redis原子操作方法
redis提供两种方法来支持对redis数据的原子性操作:
- 单命令操作:把多个操作在redis中实现成一个操作
- lua脚本:把多个redis命令写到一个lua脚本中
本质上来说,这两种方式的原子性,都是由redis以单线程的方式执行命令实现的。redis在执行命令时,其他命令无法执行,命令之间是互斥地执行的。
单命令操作
RMW代码包含多个操作,redis提供了INCR/DECR两类单命令操作,将RMW转换为单命令操作。
lua脚本
redis提供的单命令操作毕竟有限,在复杂的业务场景下,有的RMW代码可能涉及多次多redis的操作,此时可将这些操作写入一个lua脚本,提交给redis互斥地执行。如下:
//获取ip对应的访问次数
current = GET(ip)
//如果超过访问次数超过20次,则报错
IF current != NULL AND current > 20 THEN
ERROR "exceed 20 accesses per second"
ELSE
//如果访问次数不足20次,增加一次访问计数
value = INCR(ip)
//如果是第一次访问,将键值对的过期时间设置为60s后
IF value == 1 THEN
EXPIRE(ip,60)
END
//执行其他操作
DO THINGS
END
特别地,应该尽量保持lua脚本的简洁性、通用性、高效性。lua脚本执行时间过长,有损redis性能。在编写lua脚本时,避免将不需要做并发控制的代码放入其中。
Redis分布式锁
除了redis原子操作,还可以通过加锁的方式,来实现对RMW操作的互斥访问。因为多个客户端可能是分布式的,不在一个进程里,所以客户端本地的锁,无法对其他进程的客户端形成约束。
分布式环境下的锁,需要保存在一个第三方的共享存储系统中,可以被多个客户端共享访问和获取。redis读写性能高,可以应对高并发的锁操作场景,正好可以作为分布式锁的共享存储系统。
redis分布式锁的实现
使用一个String类型的值 lock
作为分布式锁的具体表示:
- lock存在时,表示处于“加锁”的状态
- lock不存在时,表示处于“未加锁”的状态
从操作上来说:
- 加锁时,读取redis lock变量,判断是否存在;存在时,加锁失败;不存在时,设值lock,加锁成功。
- 解锁时,删除lock即可。
这里,加锁是一个RMW操作,必须保证其原子性。
对分布锁的要求如下:
- 分布锁的加锁、释放锁操作若涉及多个操作,必须保证加锁、释放锁的原子性;
- 共享存储系统保持了锁变量,如果发生宕机,那么客户端将无法进行锁操作;所以必须保证共享存储系统的可靠性,从而保证分布锁的可靠性。
基于redis单节点的分布式锁
加锁解锁伪码如下
// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000
// 业务逻辑
DO THINGS
// 释放锁, 调用释放锁的lua脚本
call delete script
加锁:
- 为保证加锁的原子性,使用
SET
命令的NX
选项,实现“如果不存在时,设值,存在时,加锁失败”的逻辑。 - 增加了锁的有效期,防止业务逻辑异常导致的锁一直占用,无法释放的问题;设置锁有效期也是单命令的原子操作。
- 将占位变量
lock
的值利用起来,用以表示客户端标识;来实现“只有加锁的客户端,才能实现释放锁操作”,防止其他客户端误删除锁。
释放锁:
使用Lua脚本
//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
为防止其他客户端将占位的redis变量 lock
误删,在释放锁时判断值;这也是一个RMW操作,所以使用lua脚本保证其原子性。
可以看到,redis分布式锁的实现,强依赖于客户端的逻辑。
基于多个redis节点实现高可用分布式锁
单个redis节点宕机时,redis锁就不可用了,是个不可靠的redis分布锁实现。基于多个redis节点实现的分布式锁更可靠。
Redis的开发者提出了分布式锁算法Redlock。基本思想是:让客户端和多个独立的redis实例依次请求加锁,如果客户端能够和半数以上的实例成功加锁,那么认为客户端成功获得分布式锁,否则加锁失败。
具体实现是:
- 客户端获取当前时间;
- 客户端按顺序依次向N个redis实例执行加锁操作;
- 同样地,NX + 有效期
- 加锁时设置超时时间,超时时间远小于有效期;超时时间内加锁未成功,尝试下一个redis实例
- 一旦客户端和所有redis实例完成了加锁操作,客户端计算整个加锁过程的总耗时;重新计算锁的有效期。满足以下两个条件认为加锁成功:
- 客户端从超过半数redis实例上成功获得了锁
- 客户端总耗时没有超过锁的有效期
Redlock分布式锁的缺点是很重,繁琐,优点是可靠。
问题:是否可以使用 setnx+expire来完成加锁操作?
答:不可以,不具备原子性
问题:基于主从集群redis的分布式锁可靠性如何?
答:主从集群也难以保证redis分布式锁的可靠性。如果在master上加锁成功,此时master宕机,由于主从复制是异步的,锁变量的变更可能尚未同步到slave,此时主从切换,新的master丢失锁状态,导致分布式锁失效。