目录
前言
1.分布式锁
1.基于单个节点
2.基于多个节点
3.watch(乐观锁)
2.原子操作
1.单命令操作
2.Lua 脚本(多命令操作)
3.事务
1.执行步骤
2.错误处理
3.崩溃处理
总结
前言
在多个客户端并发访问Redis的时候,虽然Redis是单线程执行指令,但是由于客户端指令达到Redis的时序无法保证,所以可能出现如下的情况,导致并发问题。
2个客户端都执行 get, set指令,期望将key的值设置为3,结果因为并发问题,导致结果为2
client1 get x => 1
client2 get x => 1
client1 set x => 2
client2 set x => 2
本文介绍 Redis 并发方面的解决方案。
Redis 的单个命令是原子的,但是一个业务操作可能包含多条命令,比如以下场景:客户端查询值,并递增,在高并发场景下就可能出现并发问题,导致数据不一致。
为了保证并发访问的正确性,Redis 提供了三种方法,原子操作、分布式锁、事务。
1.分布式锁
与分布式锁相对的是本地锁,假如只有一个服务实例,就可以直接在该单应用本地使用锁变量来控制多个客户端的访问。
如果使用的是多实例的分布式系统,就需要使用分布式锁,即将锁保存在一个第三方的共享存储系统中,可以被多个客户端共享访问和获取。通常将一个 Redis 实例作为分布式锁的存储系统。
实现分布式锁的关键在于:
- 保证每个加锁、释放锁操作都是原子的;
- 保证共享存储系统的可靠性,即锁的可靠性;
分布式锁相较于 Lua 脚本,更简单易用,但是可能存在死锁问题。分布式锁的性能不如 Lua 脚本。
1.基于单个节点
Redis 提供了 SETNX 命令(在 SET 命令后加上 NX 选项也能达到同样的效果),保证了加锁操作的原子性。
同时,为了避免客户端加锁后不释放,应该给锁变量设置过期时间(set NX EX),且在过期释放锁时,判断业务代码是否执行完成,如果未完成则给锁续期。如果多次续期后,业务仍然未完成,再释放锁。(仍然存在风险)
为了区分不同客户端的操作,应该将锁变量设置为随机值或唯一值,在释放锁时进行验证。
释放锁的逻辑包含了读取锁变量、判断值、删除锁变量的多个操作,所以应该使用 Lua 脚本来保证互斥执行。
单个节点可以实现分布式锁的功能,但是无法保证可靠性。
2.基于多个节点
为了避免 Redis 实例故障而导致的锁无法工作的问题,Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,就认为客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个实例发生故障,因为锁变量在其它实例上也有保存,所以客户端仍然可以正常地进行锁操作,锁变量并不会丢失。
加锁过程:
- 客户端获取当前时间;
- 客户端按顺序依次向 N 个 Redis 实例执行加锁操作。同样使用 SETNX 命令,并设置超时时间。如果请求加锁一直超时,则视为加锁失败,向下一个实例执行加锁操作。
- 客户端完成所有加锁操作后,计算整个加锁过程的总耗时。
客户端只有在满足下面的这两个条件时,才能认为是加锁成功:
- 从超过半数(大于等于 N/2+1)的实例上成功获取到了锁;
- 获取锁的总耗时没有超过锁的有效时间。
在满足了这两个条件后,还需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
如果没能同时满足这两个条件,则视为加锁失败,执行释放锁的过程:客户端会向所有节点发起释放锁的操作,执行释放锁的 Lua 脚本。
判断是否加锁时,需要查询所有节点,以半数以上节点的锁状态来判断整个分布式锁的状态。在释放锁之前,需要先判断分布式锁的状态。
为了避免 Redis 节点发生崩溃重启后造成锁丢失,从而影响锁的安全性,antirez 还提出了延时重启的概念,即一个节点崩溃后不要立即重启,而是等待一段时间后再进行重启,这段时间应该大于锁的有效时间。优点是保证了锁不会被多个客户端获取;缺点是延长了重启时间,可能对系统造成影响。
性能和一致性是冲突的,如果为了分布式锁的高可用性,可以开启持久化,但是会有额外的性能开销,需要根据实际场景进行选择。
3.watch(乐观锁)
watch通常跟redis事务配合使用,watch某个key在操作过程中有没有被其他指令改变,进而做出相应的处理。底层利用了CAS操作,后面讲Redis事务会讲到。
2.原子操作
为了实现并发控制要求的临界区代码互斥执行,Redis 的原子操作采用了两种方法:单命令操作和 Lua 脚本。
1.单命令操作
Redis 的每个操作都是原子性的。
Redis 是使用单线程来串行处理客户端的请求操作命令的,所以,当 Redis 执行某个命令操作时,其他命令是无法执行的,这相当于单个操作是原子的。虽然 Redis 的单个操作是原子的,但是通常修改数据是包含多个操作的,至少包括读数据、修改数据、写回数据这三个操作,此时仍然可能出现并发问题。
针对常用的修改数据场景,Redis 提供了 INCR/DECR 命令,可以对数据进行简单的递增/递减操作,它们本身就是单个命令操作,在执行时,具有互斥性。但是如果要执行更复杂的操作,Redis 的单命令操作就无法保证互斥执行了。
2.Lua 脚本(多命令操作)
Redis 可以将多个操作写在 Lua 脚本中,然后把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。
为什么是 Lua 脚本,而不是其他语言的脚本?
Lua 是一种高效的轻量级 脚本语言,用标准 C 语言编写并以源代码形式开放。其设计目的就是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
Lua 脚本可以在服务器端执行,不需要将数据传输到客户端再进行处理,可以减少网络传输的开销,因此性能较高。
使用 Lua 脚本不仅可以实现将多个操作原子执行,还能够复用 Lua 脚本。但是使用 Lua 脚本需要额外的语言学习成本,还有调试困难、可读性较差的问题。
如果把很多操作都放在 Lua 脚本中原子执行,会导致 Redis 执行脚本的时间增加,同样也会降低 Redis 的并发性能。所以,在编写 Lua 脚本时,要避免把不需要做并发控制的操作写入脚本中。
在 Lua 脚本执行过程中崩溃怎么办?
Redis 会在内部维护一个已经加载脚本的 哈希表,记录了每个脚本的 SHA1 值和对应的 Lua 脚本代码。当 Redis 服务器重启时,Redis 会自动重新加载这个哈希表中记录的所有脚本,再重新执行,此时可能导致部分修改被应用。所以 Lua 脚本并不能严格保证原子性。如果对数据一致性非常严格,可以使用 Lua 脚本+事务 WATCH 的办法。
3.事务
Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。
Redis 提供了实现事务的几个命令:
MULTI :开启事务,redis 会将后续的命令逐个放入队列中,然后使用 EXEC 命令来原子化执行这个命令系列。
EXEC:执行事务中的所有操作命令。
DISCARD:取消事务,放弃执行事务块中的所有命令。
WATCH:在开启事务之前监视一个或多个 key,如果事务在执行前,这个 key (或多个 key)被其他命令修改,则事务被中断,不会执行事务中的任何命令(一般需要在 EXEC 执行失败后重新执行整个函数)。
UNWATCH:取消 WATCH 对所有 key 的监视。
为什么 WATCH 是中断事务,而不是阻塞其他进程?这样不会导致并发量高的时候,被 WATCH 的事务一直得不到执行吗?
这种机制称为 乐观锁,因为在大多数情况下,碰撞的概率很小,所以选用了更容易实现的方式(且影响不大)。
在使用事务时,可以配合 Pipeline 使用:一次性将所有命令打包好,再全部发送到服务端。
相比于事务的入队,同样是一次性执行,这样不仅能减少网络 IO,还能保证在开启 WATCH 时不会被其他操作打断。
1.执行步骤
- 开启事务:使用 MULTI 命令开启事务;
- 入队:接收到命令后并不会立即执行,而是放到等待执行的事务队列里;
- 执行:由 EXEC 命令触发事务执行。
当客户端切换到事务状态之后, 服务器会根据这个客户端发来的不同命令执行不同的操作:
- 如果客户端发送的命令为 EXEC 、DISCARD、WATCH、MULTI 四个命令的其中一个, 那么服务器立即执行这个命令;
- 如果是其他命令, 那么服务器并不立即执行命令, 而是将这个命令放入一个事务队列里面, 然后向客户端返回 QUEUED 回复;
2.错误处理
在事务执行过程中可能遇到两种不同类型的错误,会有不同的应对方案:
- 编译器错误:命令在编译时出错,会导致整个事务提交失败,即所有命令执行不成功;
- 运行时错误:命令在运行时检测到错误,最终会导致事务提交失败,但是事务并不会回滚,而是跳过错误命令继续执行并保留结果;
为什么 Redis 不支持 事务回滚?
Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。
3.崩溃处理
Redis 在执行事务时会使用一个单独的内存空间来保存事务中的所有修改操作,只有当事务成功提交时,这些修改操作才会被应用到 Redis 中。因此,如果事务被中止,所有的修改操作也都会被撤销,从而保证了数据的一致性。
如果开启了 AOF 持久化,会先将事务中的所有命令写入 AOF 缓冲区,然后执行事务中的命令,再将 AOF 缓冲区中的数据写入到 AOF 文件。
如果在写入 AOF 文件前崩溃,则持久化失败,相当于事务没有发生,不会出现数据不一致。
另外,RDB 快照不会在事务执行途中进行。
总结
本文介绍了 Redis 应对并发问题的三种方案,Redis 中的单条命令都是原子操作,而且还有 INCR/DECR 来应对简单的场景。对于复杂的场景,Redis 可以使用 Lua 脚本、分布式锁、事务来实现操作的原子性。Lua 脚本是将一系列操作放在一个脚本中原子执行。分布式锁是通过共享的锁变量来限制客户端的并发访问。事务是将一系列操作放到执行队列中,再按顺序原子执行。