likes
comments
collection
share

Redis 如何保证原子性来应对并发访问(八)

作者站长头像
站长
· 阅读数 21

这是我参与11月更文挑战的第8天,活动详情查看:2021最后一次更文挑战

​ 在使用Redis时不可避免地会遇到并发访问的问题,比如多个用户同时下单,就会对缓存中的商品库存数据进行并发更新。一旦有了并发写操作,数据就会被修改,如果没有做好并发控制,就会导致数据被修改错误,影响到业务的正常使用。(例如秒杀场景下的超卖情况)

​ 为了保证并发访问的正确性,Redis提供了两个方法,分别是加锁原子操作

原子操作是指执行过程中保持原子性的操作,而且原子操作执行时并不需要再加锁,实现了无锁操作。这样既保证了并发控制,还能减少对系统并发性能的影响。

Redis加锁会有两个问题,一方面是加锁操作多,会降低系统的并发访问性能。另一方面Redis客户端加锁时,需要用到分布式锁,而这需要额外的存储系统来提供加解锁的操作。

原子操作

​ 并发控制针对的操作范围主要是数据修改操作。当有多个客户端对同一份数据执行RMW(Read-Modify-Write)操作时,我们就需要RMW操作涉及的代码以原子性方式执行。访问同一份数据的RMW操作代码,就叫做临界区代码RMW操作即是指客户端要对数据做修改操作时所需要执行的步骤,即要先读取Redis中的内存数据到客户端中,然后在本地修改,最后写入到Redis服务中。而这部分操作就是指临界区的操作逻辑了。

Redis 的两种原子操作方法

​ 为了实现并发控制要求的临界区代码互斥执行。Redis的原子操作采用了两种方法:

  • 把多个操作在Redis中实现一个操作,也就是单命令操作;
  • 把多个操作写到一个Lua脚本中,以原子性方式执行单个Lua脚本。

单命令操作

​ 虽然Redis的单个命令可以原子性执行,但实际操作中数据修改包含了多个命令的操作,包括数据读取、数据增减、写回数据三个操作。

​ 这种情况就需要使用Redis提供的单命令操作了。例如INCR/DECR命令就可以实现数据的增减操作,而且因为它们本身就是单个命令操作,所以在执行它们时,就保证了它们的互斥性。

Lua脚本

​ 如果只是简单的增减操作,那么就可以使用单命令保证其原子性了。但是可能会有更复杂的判断逻辑或者其它操作,那么就需要通过封装多个命令在Lua脚本执行操作了。

​ Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。但是如果把很多操作都放在 Lua 脚本中原子执行,会导致 Redis 执行脚本的时间增加,同样也会降低 Redis 的并发性能。所以在编写Lua脚本时需要避免把不需要做并发控制的操作写入脚本中。

分布式加锁

​ 在应对并发问题时,除了原子操作,Redis客户端可采用加锁的方法,来控制并发写操作对共享数据的修改,从而保证数据的正确性。

​ 当有多个客户端需要争抢锁时,需要保证的是这把锁不能是某个客户端本地的锁。否则的话,其它客户端的是无法访问到这把锁的,更不要说是获取锁了。所以在分布式系统中,当有多个客户端需要获取锁时,需要使用分布式锁。此时锁是保存在共享存储系统中的,可以被多个客户端共享访问和获取。

​ 而Redis正好可以被多个客户端共享访问,可以保存分布式锁。

加锁操作和释放锁的操作就是针对锁的键值进行读取、判断、设置的过程。

  • 加锁时根据锁变量值判断是否可以加锁,如果可以则对锁变量值进行修改,表示持有锁;
  • 释放锁时同样需要进行判断,因为需要判断当前加锁的是不是该客户端,如果不判断直接释放锁的话,会被其它客户端将持有的锁给释放掉了;如果可以释放锁,则重置锁变量的值;

这样一来,因为加锁释放锁涉及了多个操作,所以实现分布式锁时需要两个保证:

  • 锁操作的原子性;
  • 分布式锁的可靠性;

锁操作的原子性

锁操作的原子性可以采用上面提到的单命令操作和Lua脚本操作。

单命令操作和Lua脚本

使用 SETNXDEL命令即可实现加锁和释放锁的操作。SETNX命令表示在执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。

SETNX key value

释放锁时直接将锁删除掉即可。

但进行操作时需要注意两个问题:

  • 一是锁的过期时间设置;在加锁后,如果后面的逻辑发生了异常导致没有释放锁,这时就需要过期时间去保证该客户端不能一直持有锁。
  • 还有一个是需要区别不同客户端的释放锁操作;这可以让每个客户端加锁时设置唯一值;

锁过期时间的设置和释放锁的操作都需要保证原子性;这里使用SET命令的NX选项 和Lua脚本保证了。

SET KEY VALUE [EX seconds | PX milliseconds] [NX]

SET lock_key unique_value NX PX 10000 表示给lock_key这个键设置unique_value值,同时设置过期时间为10000ms。

释放锁也包含了读取锁变量值、判断锁变量值和删除锁变量三个操作,不过,我们无法使用单个命令来实现,所以,我们可以采用 Lua 脚本执行释放锁操作,通过 Redis 原子性地执行 Lua 脚本,来保证释放锁操作的原子性。

释放锁时的Lua脚本:

//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
redis-cli  --eval  unlock.script lock_key , unique_value 

分布式锁的可靠性

为了避免锁实例出现故障而导致的锁无法工作的问题,需要按照一定的步骤和规定。Redis 的开发者 Antirez 提出了分布式锁算法 Redlock

RedLock的基本思路是,是让客户端和多个独立的Redis实例依次请求加锁,如果客户端能够与半数以上的实例成功地完成加锁操作,那么就认为客户端获得了分布式锁。这样一来,即使有某个Redis实例发生故障,那么也有其它实例可以做锁操作的支撑。

RedLock算法实现的可以分为3个步骤,假设需要有N个独立的Redis实例:

  • 客户端获取当前时间;
  • 客户端按顺序依次向N个Redis实例执行加锁操作;
    • 向Redis实例请求加锁,一样是采用SET NX 原子操作的命令,为了保障在加锁过程中Redis故障了,需要给加锁操作设置一个超时时间。如果超时了,那么会去下一个Redis实例继续请求加锁。
    • 加锁操作的超时时间需要远远小于锁的有效时间,一般也是设置几十毫秒。
  • 一旦客户端完成了和所有Redis实例的加锁操作,客户端计算整个加锁过程的总耗时。
    • 客户端需要满足以下两个条件才能认为是加锁成功:
    • 客户端从超过半数(大于等于N/2 + 1)的Redis实例上成功获取到了锁;
    • 客户端获取锁的总耗时没有超过锁的有效时间。

在满足加锁成功的条件后,需要重新计算锁的有效时间,计算结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,那么就需要释放锁,以免出现还没完成数据操作,锁就过期的情况。

转载自:https://juejin.cn/post/7028202018338504734
评论
请登录