实际应用中不可避免的存在并发场景,Redis也不例外,也会存在并发操作,比如用户下单时,有两个用户先从Redis查询到库存,然后同时下单,并发写操作,如果我们没做好控制,就可能导致数据被修改错,影响业务。为了保证并发访问的正确性,Redis提供了两种方法,分别是加锁和原子操作。
加锁是一种常用的方法,在读取数据前,客户端需要先获取锁,获取不到的话就无法操作。等一个客户端获得锁后,就会一直持有这把锁,直到客户端完成更新,才会释放锁。
加锁会有两个问题:
原子操作是另一种提供并发访问控制的方法。原子操作是指执行过程中保持原子性的操作,而且原子操作执行时不需要再加锁,实现无锁操作。
并发控制指多个客户端访问操作同一份数据的过程进行控制,以保证任何一个客户端发送的操作在Redis上执行具有互斥性。
并发访问控制对应的操作主要是修改操作。当客户端需要修改数据时,基本流程分成两步:
这个过程可以定义为:读取-修改-写回。当有多个客户端对同一份数据执行相同的操作时,就需要让该流程原子性的执行,这一执行过程的代码叫临界区代码。如下为伪代码
get data
update data
set data
首先是读取数据,然后进行修改,最后在写回修改后的数据。如果我们对临界区的代码没有进行控制,就会出现错误的数据。
如图所示,如果按照正确的逻辑处理,实际上的库存应该是90,如果没有并发控制,此时库存为95,库存会出现了错误。
出现这个现象的原因是,临界区代码中的客户端读取数据、更新数据、再写回数据涉及了三个操作,而这三个操作在执行时并不具有互斥性,多个客户端基于相同的初始值进行修改,而不是基于前一个客户端修改后的值再修改。
为了保证并发操作的正确性,可以通过加锁或原子操作来保证。
首先看下伪代码的实现,在临界代码区执行前后分别加上获取货和释放锁。下面是分布式锁的实现方式。
lock
get data
update data
set data
unlock
使用 SET key value NX PX milliseconds
命令尝试获取锁。其中 NX
表示只有当 key 不存在时才设置;PX
指定过期时间(毫秒)。
SET
命令执行成功,客户端获得了锁,并且锁会自动在指定时间内释放,避免死锁。SET
执行失败,则说明锁已经被其他客户端持有。这种方式实现相对简单,利用 Redis 原子操作确保加锁与解锁过程的原子性。加锁和释放锁的操作效率高,适合低延迟场景。但是也有些确定
RedLock 是 Redis 官方推荐的一种增强型分布式锁实现方法,它使用多个独立、非共享的 Redis 主节点(至少 N/2+1 个互不相关的实例)。客户端在同一时刻向所有节点请求加锁,每个节点上使用相同的加锁策略(如 SETNX + EXPIRE)并设定一个较短的有效期。如果客户端能在大部分节点上(超过半数)成功获取锁,并且所有锁的总耗时小于有效期限的一半,那么认为客户端成功获取了分布式锁。在释放锁时,客户端必须依次向各个节点发送删除命令,即使某个节点挂掉,只要大多数节点上的锁能被删除,也能保证锁最终会被释放。
这种方式的优点是避免了单点故障,提高了系统的容错性和可用性,即便部分 Redis 节点故障,只要多数节点正常运行,系统仍能正确地分配和释放锁。
但实现复杂度较高,需要管理多个 Redis 实例并处理跨节点的一致性问题。对于小型项目而言资源开销较大,因为需要维护多个 Redis 节点。尽管官方提出了 RedLock 算法,但该算法在学术界存在一些争议,特别是关于其安全性和一致性的问题,实际应用时需谨慎评估。
使用诸如 Redisson 这样的高级 Redis 客户端库,提供了封装好的分布式锁 API,支持自动续期、可重入锁等特性。库内部已经解决了上述单节点和 RedLock 算法的部分问题,例如通过定时任务自动续期,确保在业务未完成时锁不会过期。
Redisson的优点是开发者无需关注底层细节,使用方便。支持多种高级特性,提高开发效率和系统的稳定性。内置了一些容错机制,减少错误发生的可能性。
Redisson的缺点是增加了对特定客户端库的依赖,降低了通用性。若客户端库本身存在 bug 或使用不当,可能引入新的问题,如死锁、锁泄露等。
还记得其他文章分析的Redis的线程模型吗?Redis是对命令的操作时单线程的,所以Redis在执行单个命令时相当于是原子的,与其他命名互斥的,这是Redis的特点。
虽然 Redis 的单个命令操作可以原子性地执行,但是在实际应用中,数据修改时可能包含多个操作,至少包括读数据、数据增减、写回数据三个操作,这显然就不是单个命令操作了,那该怎么办呢?
Redis 提供了 INCR/DECR 命令,把这三个操作转变为一个原子操作了。INCR/DECR 命令可以对数据进行增值 / 减值操作,而且它们本身就是单个命令操作,Redis 在执行它们时,本身就具有互斥性。
但是,如果我们要执行的操作不是简单地增减数据,而是有更加复杂的判断逻辑或者是其他操作,那么,Redis 的单命令操作已经无法保证多个操作的互斥执行了。所以,这个时候,我们需要使用第二个方法,也就是 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
好了,通过上面的总结,你理解了Redis是如何控制并发的了吗?欢迎留言讨论。