决胜高并发战场:Redis并发访问控制与实战解析

        实际应用中不可避免的存在并发场景,Redis也不例外,也会存在并发操作,比如用户下单时,有两个用户先从Redis查询到库存,然后同时下单,并发写操作,如果我们没做好控制,就可能导致数据被修改错,影响业务。为了保证并发访问的正确性,Redis提供了两种方法,分别是加锁和原子操作。

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

        加锁会有两个问题:

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

        原子操作是另一种提供并发访问控制的方法。原子操作是指执行过程中保持原子性的操作,而且原子操作执行时不需要再加锁,实现无锁操作。

并发控制

        并发控制指多个客户端访问操作同一份数据的过程进行控制,以保证任何一个客户端发送的操作在Redis上执行具有互斥性。

        并发访问控制对应的操作主要是修改操作。当客户端需要修改数据时,基本流程分成两步:

  1. 客户端先把数据读取到本地,在本地进行修改;
  2. 客户端修改完成后,再写回Redis

        这个过程可以定义为:读取-修改-写回。当有多个客户端对同一份数据执行相同的操作时,就需要让该流程原子性的执行,这一执行过程的代码叫临界区代码。如下为伪代码

get data
update data
set data

        首先是读取数据,然后进行修改,最后在写回修改后的数据。如果我们对临界区的代码没有进行控制,就会出现错误的数据。决胜高并发战场:Redis并发访问控制与实战解析_第1张图片

        如图所示,如果按照正确的逻辑处理,实际上的库存应该是90,如果没有并发控制,此时库存为95,库存会出现了错误。

        出现这个现象的原因是,临界区代码中的客户端读取数据、更新数据、再写回数据涉及了三个操作,而这三个操作在执行时并不具有互斥性,多个客户端基于相同的初始值进行修改,而不是基于前一个客户端修改后的值再修改。

        为了保证并发操作的正确性,可以通过加锁或原子操作来保证。

分布式锁

        首先看下伪代码的实现,在临界代码区执行前后分别加上获取货和释放锁。下面是分布式锁的实现方式。

lock
get data
update data
set data
unlock

        基于单节点 Redis 的分布式锁

        使用 SET key value NX PX milliseconds 命令尝试获取锁。其中 NX 表示只有当 key 不存在时才设置;PX 指定过期时间(毫秒)。

  • 如果 SET 命令执行成功,客户端获得了锁,并且锁会自动在指定时间内释放,避免死锁。
  • 如果 SET 执行失败,则说明锁已经被其他客户端持有。

        这种方式实现相对简单,利用 Redis 原子操作确保加锁与解锁过程的原子性。加锁和释放锁的操作效率高,适合低延迟场景。但是也有些确定

  • 有单点问题,如果这台 Redis 节点宕机或发生网络分区,可能导致锁丢失或者无法释放。
  • 锁续约问题:需要额外处理锁续期逻辑以防止业务处理超时但锁已过期的情况。
  • 并发性能瓶颈:随着并发量增大,单一 Redis 节点可能成为性能瓶颈。

        RedLock 算法

        RedLock 是 Redis 官方推荐的一种增强型分布式锁实现方法,它使用多个独立、非共享的 Redis 主节点(至少 N/2+1 个互不相关的实例)。客户端在同一时刻向所有节点请求加锁,每个节点上使用相同的加锁策略(如 SETNX + EXPIRE)并设定一个较短的有效期。如果客户端能在大部分节点上(超过半数)成功获取锁,并且所有锁的总耗时小于有效期限的一半,那么认为客户端成功获取了分布式锁。在释放锁时,客户端必须依次向各个节点发送删除命令,即使某个节点挂掉,只要大多数节点上的锁能被删除,也能保证锁最终会被释放。

        这种方式的优点是避免了单点故障,提高了系统的容错性和可用性,即便部分 Redis 节点故障,只要多数节点正常运行,系统仍能正确地分配和释放锁。

        但实现复杂度较高,需要管理多个 Redis 实例并处理跨节点的一致性问题。对于小型项目而言资源开销较大,因为需要维护多个 Redis 节点。尽管官方提出了 RedLock 算法,但该算法在学术界存在一些争议,特别是关于其安全性和一致性的问题,实际应用时需谨慎评估。

        高级客户端库支持的分布式锁(如 Redisson)

        使用诸如 Redisson 这样的高级 Redis 客户端库,提供了封装好的分布式锁 API,支持自动续期、可重入锁等特性。库内部已经解决了上述单节点和 RedLock 算法的部分问题,例如通过定时任务自动续期,确保在业务未完成时锁不会过期。

        Redisson的优点是开发者无需关注底层细节,使用方便。支持多种高级特性,提高开发效率和系统的稳定性。内置了一些容错机制,减少错误发生的可能性。

        Redisson的缺点是增加了对特定客户端库的依赖,降低了通用性。若客户端库本身存在 bug 或使用不当,可能引入新的问题,如死锁、锁泄露等。

原子操作

        还记得其他文章分析的Redis的线程模型吗?Redis是对命令的操作时单线程的,所以Redis在执行单个命令时相当于是原子的,与其他命名互斥的,这是Redis的特点。

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

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

        但是,如果我们要执行的操作不是简单地增减数据,而是有更加复杂的判断逻辑或者是其他操作,那么,Redis 的单命令操作已经无法保证多个操作的互斥执行了。所以,这个时候,我们需要使用第二个方法,也就是 Lua 脚本。

        Lua脚本

        Redis会把整个lua脚本当成一个整体来执行,在执行过程中不会被打断,从而保证了执行过程的原子性。如果有多个操作要执行,但又无法用 INCR/DECR 这种命令操作来实现,就可以把这些要执行的操作编写到一个 Lua 脚本中。然后,可以使用 Redis 的 EVAL 命令来执行脚本。这样一来,这些操作在执行时就具有了互斥性。下面来看一个案例:

-- 假设我们有一个键 "counter" 用于存储计数

-- 客户端可以将这个 Lua 脚本发送给 Redis 执行
-- KEYS[1] 是计数器键的名称
-- ARGV[1] 是要增加的数值
-- ARGV[2] 是设定的最大阈值

local counter = redis.call('INCR', KEYS[1])
if counter > tonumber(ARGV[1]) then
    -- 如果计数超过了给定的阈值,则返回一个特定消息
    return 'Counter exceeded threshold'
else
    -- 否则返回新的计数值
    return counter
end

-- 使用 EVAL 命令执行此脚本
-- 示例:设置阈值为 100,每次递增 1
redis-cli EVAL "$(cat increment_and_check.lua)" 1 counter 100 1

        使用lua脚本的注意事项        

  1. 在使用lua脚本时是有时间限制的,执行时间默认是5s,可通过配置修改,如果超过这个时间,Redis会终止这个操作以防止主线程阻塞,影响其他客户端。
  2. 如果lua脚本中有语法错误或执行过程中发生错误,整个脚本都会失败,并返回一个错误,所以执行前要对lua脚本进行检查
  3. 由于lua脚本时考Redis的单线程模型保证原子性的,所以势必会影响其他客户端的操作,如果执行耗时的lua脚本多的话,就会影响其他客户端,所以第一条中设置的执行时间要经过认真的分析才能设置。
  4. 这一点很关键,需要额外注意,lua脚本在Redis分片集群中会失效Redis Cluster 为了实现数据分片(sharding),将数据分布在多个节点上,并且每个节点只能操作属于自己的槽位中的键。当 Lua 脚本涉及到多个不同槽位的键时,直接执行会失败,因为单个节点无法跨槽处理数据。所以lua脚本需要再单节点上执行。

        好了,通过上面的总结,你理解了Redis是如何控制并发的了吗?欢迎留言讨论。

你可能感兴趣的:(redis,缓存,Redis并发控制)