应对高并发

我们在使用 Redis 时,不可避免地会遇到并发访问的问题。

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

加锁

加锁是一种常用的方法,在读取数据前,客户端需要先获得锁,否则就无法进行操作。当一个客户端获得锁后,就会一直持有这把锁,直到客户端完成数据更新,才释放这把锁。

缺点:

  • 如果加锁操作多,会降低系统的并发访问性能。
  • Redis 客户端要加锁时,需要用到分布式锁,而分布式锁实现复杂,需要用额外的存储系统来提供加解锁操作。

分布式锁

  1. 基于单个 Redis 节点实现分布式锁

    用Redis存锁变量,key为变量名,value为锁的值;例如:lock_key:0

    image-20230331202109712

    为了保证锁操作的原子性,需要使用原子操作的语句来实现加锁与释放锁。

    加锁:SET key value [EX seconds | PX milliseconds] [NX]例如:SET lock_key unique_value NX PX 10000其中,unique_value 是客户端的唯一标识,可以用一个随机生成的字符串来表示,PX 10000 则表示 lock_key 会在 10s 后过期,以免客户端在这期间发生异常而无法释放锁。

    释放锁:使用lua脚本保证原子性

    1
    2
    3
    4
    5
    6
    //释放锁 比较unique_value是否相等,避免误释放
    if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
    else
    return 0
    end

    其中,KEYS[1]表示 lock_key,ARGV[1]是当前客户端的唯一标识

  2. 多节点的高可靠分布式锁

    基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。

    实现步骤:

    • 客户端获取当前时间
    • 客户端按顺序依次向 N 个 Redis 实例执行加锁操作。(这里的加锁操作和在单实例上执行的加锁操作一样)
    • 一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。

    客户端只有在满足下面的这两个条件时,才能认为是加锁成功。

    • 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;
    • 条件二:客户端获取锁的总耗时没有超过锁的有效时间,并且剩余有效时间够完成数据操作。

    如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端向所有 Redis 节点发起释放锁的操作。

原子操作

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

实现方式

  1. 把多个操作在 Redis 中实现成一个操作,也就是单命令操作

    虽然 Redis 的单个命令操作可以原子性地执行,但是在实际应用中,数据修改时可能包含多个操作,至少包括读数据、数据增减、写回数据三个操作,这显然就不是单个命令操作了,那该怎么办呢?

    Redis 提供了 INCR/DECR 命令,把这三个操作转变为一个原子操作了。INCR/DECR 命令可以对数据进行增值 / 减值操作,而且它们本身就是单个命令操作,Redis 在执行它们时,本身就具有互斥性。

    如果操作语句比较复杂,Redis 的单命令操作已经无法保证多个操作的互斥执行了,我们只有使用Lua脚本。

  2. 把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。

    Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。

    在编写 Lua 脚本时,你要避免把不需要做并发控制的操作写入脚本中