28-31节 SSD与Redis、应对并发、分布式锁、ACID

28-Pika-如何基于SSD实现大容量Redis?

如果要保存的数据总量很大,但是每个实例保存的数据量较小的话,就会导致集群的实例规模增加,这会让集群的运维管理变得复杂,增加开销。

增加Redis单实例的内存容量,形成大内存实例,每个实例可以保存更多的数据,这样一来,在保存相同的数据总量时,所需要的大内存实例的个数就会减少,就可以节省开销。——> 基于大内存的大容量实例在实例恢复、主从同步过程中会引起一系列潜在问题,例如恢复时间增长、主从切换开销大、缓冲区易溢出。

推荐基于SSD来实现大容量的Redis实例。360公司DBA和基础架构组联合开发的Pika键值数据库,正好实现了这一需求。

Pika在刚开始设计的时候,就有两个目标:

  • 一是,单实例可以保存大容量数据,同时避免了实例恢复和主从同步时的潜在问题;
  • 二是,和Redis数据类型保持兼容,可以支持使用Redis的应用平滑地迁移到Pika上。

如果你一直在使用Redis,并且想使用SSD来扩展单实例容量,Pika就是一个很好的选择。

大内存Redis实例的潜在问题

Redis使用内存保存数据,内存容量增加后,就会带来两方面的潜在问题,分别是

  • 内存快照RDB生成和恢复效率低
    • 实例内存容量大,RDB文件也会相应增大,那么,RDB文件生成时的fork时长就会增加,这就会导致Redis实例阻塞。而且,RDB文件增大后,使用RDB进行恢复的时长也会增加,会导致Redis较长时间无法对外提供服务。
  • 主从节点全量同步时长增加、缓冲区易溢出。
    • 主从节点间的同步的第一步就是要做全量同步。全量同步是主节点生成RDB文件,并传给从节点,从节点再进行加载。试想一下,如果RDB文件很大,肯定会导致全量同步的时长增加,效率不高,而且还可能会导致复制缓冲区溢出。一旦缓冲区溢出了,主从节点间就会又开始全量同步,影响业务应用的正常使用。如果我们增加复制缓冲区的容量,这又会消耗宝贵的内存资源。

此外,如果主库发生了故障,进行主从切换后,其他从库都需要和新主库进行一次全量同步。如果RDB文件很大,也会导致主从切换的过程耗时增加,同样会影响业务的可用性。

Pika是如何解决这两方面的问题呢?这就要提到Pika中的关键模块RocksDB、binlog机制和Nemo了,这些模块都是Pika架构中的重要组成部分。所以,接下来,我们就来先看下Pika的整体架构。

Pika的整体架构

Pika键值数据库的整体架构中包括了五部分,分别是网络框架、Pika线程模块、Nemo存储模块、RocksDB和binlog机制,如下图所示:

  • 网络框架主要负责底层网络请求的接收和发送。
    • Pika的网络框架是对操作系统底层的网络函数进行了封装。Pika在进行网络通信时,可以直接调用网络框架封装好的函数。
  • Pika线程模块采用了多线程模型来具体处理客户端请求,包括一个请求分发线程(DispatchThread)、一组工作线程(WorkerThread)以及一个线程池(ThreadPool)。
    • 请求分发线程专门监听网络端口,一旦接收到客户端的连接请求后,就和客户端建立连接,并把连接交由工作线程处理。工作线程负责接收客户端连接上发送的具体命令请求,并把命令请求封装成Task,再交给线程池中的线程,由这些线程进行实际的数据存取处理,如下图所示:
    • 在实际应用Pika的时候,我们可以通过增加工作线程数和线程池中的线程数,来提升Pika的请求处理吞吐率,进而满足业务层对数据处理性能的需求。
  • Nemo模块很容易理解,它实现了Pika和Redis的数据类型兼容。【重点关心
    • 当我们把Redis服务迁移到Pika时,不用修改业务应用中操作Redis的代码,而且还可以继续应用运维Redis的经验,这使得Pika的学习成本就较低。
  • 基于SSD保存数据的功能。
    • 它使得Pika可以不用大容量的内存,就能保存更多数据,还避免了使用内存快照。
  • Pika使用binlog机制记录写命令
    • 用于主从节点的命令同步,避免了刚刚所说的大内存实例在主从同步过程中的潜在问题。

Pika如何基于SSD保存更多数据?

为了把数据保存到SSD,Pika使用了业界广泛应用的持久化键值数据库RocksDB。【只要记住RocksDB的基本数据读写机制

下面我结合一张图片,来给你具体介绍下RocksDB写入数据的基本流程。

当Pika需要保存数据时,RocksDB会使用两小块内存空间(Memtable1和Memtable2)来交替缓存写入的数据。Memtable的大小可以设置,一个Memtable的大小一般为几MB或几十MB。当有数据要写入RocksDB时,RocksDB会先把数据写入到Memtable1。等到Memtable1写满后,RocksDB再把数据以文件的形式,快速写入底层的SSD。同时,RocksDB会使用Memtable2来代替Memtable1,缓存新写入的数据。等到Memtable1的数据都写入SSD了,RocksDB会在Memtable2写满后,再用Memtable1缓存新写入的数据。

这么一分析你就知道了,RocksDB会先用Memtable缓存数据,再将数据快速写入SSD,即使数据量再大,所有数据也都能保存到SSD中。而且,Memtable本身容量不大,即使RocksDB使用了两个Memtable,也不会占用过多的内存,这样一来,Pika在保存大容量数据时,也不用占据太大的内存空间了。

当Pika需要读取数据的时候,RocksDB会先在Memtable中查询是否有要读取的数据。这是因为,最新的数据都是先写入到Memtable中的。如果Memtable中没有要读取的数据,RocksDB会再查询保存在SSD上的数据文件,如下图所示:

我刚才向你介绍过,当使用大内存实例保存大量数据时,Redis会面临RDB生成和恢复的效率问题,以及主从同步时的效率和缓冲区溢出问题。那么,当Pika保存大量数据时,还会面临相同的问题吗?

其实不会了,我们来分析一下。

一方面,Pika基于RocksDB保存了数据文件,直接读取数据文件就能恢复,不需要再通过内存快照进行恢复了。而且,Pika从库在进行全量同步时,可以直接从主库拷贝数据文件,不需要使用内存快照,这样一来,Pika就避免了大内存快照生成效率低的问题。

另一方面,Pika使用了binlog机制实现增量命令同步,既节省了内存,还避免了缓冲区溢出的问题。binlog是保存在SSD上的文件,Pika接收到写命令后,在把数据写入Memtable时,也会把命令操作写到binlog文件中。和Redis类似,当全量同步结束后,从库会从binlog中把尚未同步的命令读取过来,这样就可以和主库的数据保持一致。当进行增量同步时,从库也是把自己已经复制的偏移量发给主库,主库把尚未同步的命令发给从库,来保持主从库的数据一致。

不过,和Redis使用缓冲区相比,使用binlog好处是非常明显的:binlog是保存在SSD上的文件,文件大小不像缓冲区,会受到内存容量的较多限制。而且,当binlog文件增大后,还可以通过轮替操作,生成新的binlog文件,再把旧的binlog文件独立保存。这样一来,即使Pika实例保存了大量的数据,在同步过程中也不会出现缓冲区溢出的问题了。

Pika如何实现Redis数据类型兼容?

Pika的底层存储使用了RocksDB来保存数据,但是,RocksDB只提供了单值的键值对类型,RocksDB键值对中的值就是单个值,而Redis键值对中的值还可以是集合类型。

对于Redis的String类型来说,它本身就是单值的键值对,我们直接用RocksDB保存就行。但是,对于集合类型来说,我们就无法直接把集合保存为单值的键值对,而是需要进行转换操作。

为了保持和Redis的兼容性,Pika的Nemo模块就负责把Redis的集合类型转换成单值的键值对。简单来说,我们可以把Redis的集合类型分成两类:

  • 一类是List和Set类型,它们的集合中也只有单值;
  • 另一类是Hash和Sorted Set类型,它们的集合中的元素是成对的,其中,Hash集合元素是field-value类型,而Sorted Set集合元素是member-score类型。

List类型

在Pika中,List集合的key被嵌入到了单值键值对的键当中,用key字段表示;而List集合的元素值,则被嵌入到单值键值对的值当中,用value字段表示。因为List集合中的元素是有序的,所以,Nemo模块还在单值键值对的key后面增加了sequence字段,表示当前元素在List中的顺序,同时,还在value的前面增加了previous sequence和next sequence这两个字段,分别表示当前元素的前一个元素和后一个元素。

此外,在单值键值对的key前面,Nemo模块还增加了一个值“l”,表示当前数据是List类型,以及增加了一个1字节的size字段,表示List集合key的大小。在单值键值对的value后面,Nemo模块还增加了version和ttl字段,分别表示当前数据的版本号和剩余存活时间(用来支持过期key功能),如下图所示:

Set类型

Set集合的key和元素member值,都被嵌入到了Pika单值键值对的键当中,分别用key和member字段表示。同时,和List集合类似,单值键值对的key前面有值“s”,用来表示数据是Set类型,同时还有size字段,用来表示key的大小。Pika单值键值对的值只保存了数据的版本信息和剩余存活时间,如下图所示:

Hash类型

Hash集合的key被嵌入到单值键值对的键当中,用key字段表示,而Hash集合元素的field也被嵌入到单值键值对的键当中,紧接着key字段,用field字段表示。Hash集合元素的value则是嵌入到单值键值对的值当中,并且也带有版本信息和剩余存活时间,如下图所示

Sorted Set类型

该类型是需要能够按照集合元素的score值排序的,而RocksDB只支持按照单值键值对的键来排序。所以,Nemo模块在转换数据时,就把Sorted Set集合key、元素的score和member值都嵌入到了单值键值对的键当中,此时,单值键值对中的值只保存了数据的版本信息和剩余存活时间,如下图所示:

Pika的其他优势与不足

跟Redis相比,Pika最大的特点就是使用了SSD来保存数据,这个特点能带来的最直接好处就是,Pika单实例能保存更多的数据了,实现了实例数据扩容。

除此之外,Pika使用SSD来保存数据,还有额外的两个优势。

  • 首先,实例重启快。Pika的数据在写入数据库时,是会保存到SSD上的。当Pika实例重启时,可以直接从SSD上的数据文件中读取数据,不需要像Redis一样,从RDB文件全部重新加载数据或是从AOF文件中全部回放操作,这极大地提高了Pika实例的重启速度,可以快速处理业务应用请求。
  • 另外,主从库重新执行全量同步的风险低。Pika通过binlog机制实现写命令的增量同步,不再受内存缓冲区大小的限制,所以,即使在数据量很大导致主从库同步耗时很长的情况下,Pika也不用担心缓冲区溢出而触发的主从库重新全量同步。

其不足如下:

  • 当把数据保存到SSD上后,会降低数据的访问性能。这是因为,数据操作毕竟不能在内存中直接执行了,而是要在底层的SSD中进行存取,这肯定会影响,Pika的性能。
  • 而且,我们还需要把binlog机制记录的写命令同步到SSD上,这会降低Pika的写性能。

不过,Pika的多线程模型,可以同时使用多个线程进行数据读写,这在一定程度上弥补了从SSD存取数据造成的性能损失。当然,你也可以使用高配的SSD来提升访问性能,进而减少读写SSD对Pika性能的影响。
我们在使用Pika时,需要在单实例扩容的必要性和可能的性能损失间做个权衡。如果保存大容量数据是我们的首要需求,那么,Pika是一个不错的解决方案。

小结

我给你提供两个降低读写SSD对Pika的性能影响的小建议:

利用Pika的多线程模型,增加线程数量,提升Pika的并发请求处理能力;
为Pika配置高配的SSD,提升SSD自身的访问性能。
最后,我想再给你一个小提示。Pika本身提供了很多工具,可以帮助我们把Redis数据迁移到Pika,或者是把Redis请求转发给Pika。比如说,我们使用aof_to_pika命令,并且指定Redis的AOF文件以及Pika的连接信息,就可以把Redis数据迁移到Pika中了,如下所示:

1
aof_to_pika -i [Redis AOF文件] -h [Pika IP] -p [Pika port] -a [认证信息]

课后问题

29-无锁的原子操作:Redis如何应对并发访问?

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

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

  • 一个是,如果加锁操作多,会降低系统的并发访问性能;
  • 第二个是,Redis客户端要加锁时,需要用到分布式锁,而分布式锁实现复杂,需要用额外的存储系统来提供加解锁操作,我会在下节课向你介绍。

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

并发访问中需要对什么进行控制?

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

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

我们把这个流程叫做“读取-修改-写回”操作(Read-Modify-Write,简称为RMW操作)。当有多个客户端对同一份数据执行RMW操作的话,我们就需要让RMW操作涉及的代码以原子性方式执行。访问同一份数据的RMW操作代码,就叫做临界区代码。

虽然加锁保证了互斥性,但是加锁也会导致系统并发性能降低

Redis的两种原子操作方法

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

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

Redis是使用单线程来串行处理客户端的请求操作命令的,所以,当Redis执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的。当然,Redis的快照生成、AOF重写这些操作,可以使用后台线程或者是子进程执行,也就是和主线程的操作并行执行。不过,这些操作只是读取数据,不会修改数据,所以,我们并不需要对它们做并发控制。

Redis提供了INCR/DECR命令,把这三个操作转变为一个原子操作了。INCR/DECR命令可以对数据进行增值/减值操作,而且它们本身就是单个命令操作,Redis在执行它们时,本身就具有互斥性。
所以,如果我们执行的RMW操作是对数据进行增减值的话,Redis提供的原子操作INCR和DECR可以直接帮助我们进行并发控制。

如果我们要执行的操作不是简单地增减数据,而是有更加复杂的判断逻辑或者是其他操作,这个时候,我们需要使用第二个方法,也就是Lua脚本。Redis会把整个Lua脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了Lua脚本中操作的原子性。

例如每分钟的访问次数不能超过20。所以,我们可以在客户端第一次访问时,给对应键值对设置过期时间,例如设置为60s后过期。同时,在客户端每次访问时,我们读取客户端当前的访问次数,如果次数超过阈值,就报错,限制客户端再次访问。你可以看下下面的这段代码,它实现了对客户端每分钟访问次数不超过20次的限制。
此时,我们就可以使用Lua脚本来保证并发控制。我们可以把访问次数加1、判断访问次数是否为1,以及设置过期时间这三个操作写入一个Lua脚本,如下所示:

1
2
3
4
5
local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
redis.call("expire",KEYS[1],60)
end

假设我们编写的脚本名称为lua.script,我们接着就可以使用Redis客户端,带上eval选项,来执行该脚本。脚本所需的参数将通过以下命令中的keys和args进行传递。

1
redis-cli  --eval lua.script  keys , args

这样一来,访问次数加1、判断访问次数是否为1,以及设置过期时间这三个操作就可以原子性地执行了。即使客户端有多个线程同时执行这个脚本,Redis也会依次串行执行脚本代码,避免了并发操作带来的数据错误。

小结

Redis的Lua脚本可以包含多个操作,这些操作都会以原子性的方式执行,绕开了单命令操作的限制。不过,如果把很多操作都放在Lua脚本中原子执行,会导致Redis执行脚本的时间增加,同样也会降低Redis的并发性能。所以,我给你一个小建议:在编写Lua脚本时,你要避免把不需要做并发控制的操作写入脚本中

当然,加锁也能实现临界区代码的互斥执行,只是如果有多个客户端加锁时,就需要分布式锁的支持了。所以,下节课,我就来和你聊聊分布式锁的实现。

课后问题

30-如何使用Redis实现分布式锁?

Redis属于分布式系统,当有多个客户端需要争抢锁时,我们必须要保证,这把锁不能是某个客户端本地的锁。否则的话,其它客户端是无法访问这把锁的,当然也就不能获取这把锁了。

单机上的锁和分布式锁的联系与区别

我们先来看下单机上的锁。
对于在单机上运行的多线程程序来说,锁本身可以用一个变量表示。

  • 变量值为0时,表示没有线程获取锁;
  • 变量值为1时,表示已经有线程获取到锁了。
  • 和单机上的锁类似,分布式锁同样可以用一个变量来实现。客户端加锁和释放锁的操作逻辑,也和单机上的加锁和释放锁操作逻辑一致:加锁时同样需要判断锁变量的值,根据锁变量值来判断能否加锁成功;释放锁时需要把锁变量值设置为0,表明客户端不再持有锁

但是,和线程在单机上操作锁不同的是,在分布式场景下,锁变量需要由一个共享存储系统来维护,只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。相应的,加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值

这样一来,我们就可以得出实现分布式锁的两个要求。

  • 要求一:分布式锁的加锁和释放锁的过程,涉及多个操作。所以,在实现分布式锁时,我们需要保证这些锁操作的原子性;
  • 要求二:共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那么客户端也就无法进行锁操作了。在实现分布式锁时,我们需要考虑保证共享存储系统的可靠性,进而保证锁的可靠性。

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

我们要赋予锁变量一个变量名,把这个变量名作为键值对的键,而锁变量的值,则是键值对的值,这样一来,Redis就能保存锁变量了,客户端也就可以通过Redis的命令操作来实现锁操作。

为了帮助你理解,我画了一张图片,它展示Redis使用键值对保存锁变量,以及两个客户端同时请求加锁的操作过程。
image.png

我们先来看下,Redis可以用哪些单命令操作实现加锁操作。

因为加锁包含了三个操作(读取锁变量、判断锁变量值以及把锁变量值设置为1),而这三个操作在执行时需要保证原子性。那怎么保证原子性呢?

首先是SETNX命令,它用于设置键值对的值。具体来说,就是这个命令在执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。
对于释放锁操作来说,我们可以在执行完业务逻辑后,使用DEL命令删除锁变量。不过,你不用担心锁变量被删除后,其他客户端无法请求加锁了。因为SETNX命令在执行时,如果要设置的键值对(也就是锁变量)不存在,SETNX命令会先创建键值对,然后设置它的值。所以,释放锁之后,再有客户端请求加锁时,SETNX命令会创建保存锁变量的键值对,并设置锁变量的值,完成加锁。

1
2
3
4
5
// 加锁 
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁 DEL lock_key

不过,使用SETNX和DEL命令组合实现分布锁,存在两个潜在的风险。

  • 第一个风险是,假如某个客户端在执行了SETNX命令、加锁之后,紧接着却在操作共享数据时发生了异常,结果一直没有执行最后的DEL命令释放锁。因此,锁就一直被这个客户端持有,其它客户端无法拿到锁,也无法访问共享数据和执行后续操作,这会给业务应用带来影响。
    • 针对这个问题,一个有效的解决方法是,给锁变量设置一个过期时间
  • 再来看第二个风险。如果客户端A执行了SETNX命令加锁后,假设客户端B执行了DEL命令释放锁,此时,客户端A的锁就被误释放了。如果客户端C正好也在申请加锁,就可以成功获得锁,进而开始操作共享数据。这样一来,客户端A和C同时在对共享数据进行操作,数据就会被修改错误,这也是业务层不能接受的。
    • 为了应对这个问题,我们需要能区分来自不同客户端的锁操作* 。
      • 在加锁操作时,可以让每个客户端给锁变量设置一个唯一值,这里的唯一值就可以用来标识当前操作的客户端。在释放锁操作时,客户端需要判断,当前锁变量的值是否和自己的唯一标识相等,只有在相等的情况下,才能释放锁。
      • 执行下面的命令时,只有key不存在时,SET才会创建key,并对key进行赋值。另外,key的存活时间由seconds或者milliseconds选项值来决定
      • SET key value [EX seconds | PX milliseconds] [NX] 其中,unique_value是客户端的唯一标识,可以用一个随机生成的字符串来表示,PX 10000则表示lock_key会在10s后过期,以免客户端在这期间发生异常而无法释放锁。

下面是使用Lua脚本(unlock.script)实现的释放锁操作的伪代码

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]是当前客户端的唯一标识,这两个值都是我们在执行Lua脚本时作为参数传入的。

【可以注意到】在释放锁操作中,我们使用了Lua脚本,这是因为,释放锁操作的逻辑也包含了读取锁变量、判断值、删除锁变量的多个操作,而Redis在执行Lua脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。

基于多个Redis节点实现高可靠的分布式锁

为了避免Redis实例故障而导致的锁无法工作的问题,Redis的开发者Antirez提出了分布式锁算法Redlock。

Redlock算法的基本思路,是让客户端和多个独立的Redis实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个Redis实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。

我们来具体看下Redlock算法的执行步骤。Redlock算法的实现需要有N个独立的Redis实例。接下来,我们可以分成3步来完成加锁操作。

第一步是,客户端获取当前时间。

第二步是,客户端按顺序依次向N个Redis实例执行加锁操作。

这里的加锁操作和在单实例上执行的加锁操作一样,使用SET命令,带上NX,EX/PX选项,以及带上客户端的唯一标识。当然,如果某个Redis实例发生故障了,为了保证在这种情况下,Redlock算法能够继续运行,我们需要给加锁操作设置一个超时时间。

如果客户端在和一个Redis实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个Redis实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。

第三步是,一旦客户端完成了和所有Redis实例的加锁操作,客户端就要计算整个加锁过程的总耗时。

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

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

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

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

在Redlock算法中,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的Lua脚本就可以了。这样一来,只要N个Redis实例中的半数以上实例能正常工作,就能保证分布式锁的正常工作了。

小结

在基于单个Redis实例实现分布式锁时,对于加锁操作,我们需要满足三个条件。

  1. 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用SET命令带上NX选项来实现加锁;
  2. 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在SET命令执行时加上EX/PX选项,设置其过期时间;
  3. 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用SET命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端。

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

不过,基于单个Redis实例实现分布式锁时,会面临实例异常或崩溃的情况,这会导致实例无法提供锁操作,正因为此,Redis也提供了Redlock算法,用来实现基于多个实例的分布式锁。这样一来,锁变量由多个实例维护,即使有实例发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。Redlock算法是实现高可靠分布式锁的一种有效解决方案,你可以在实际应用中把它用起来。

课后题目

image.png

31-事务机制:Redis能实现ACID属性吗?

我们就先了解ACID属性对事务执行的具体要求,有了这个知识基础后,我们才能准确地判断Redis的事务机制能否保证ACID属性。

事务ACID属性的要求

  • 原子性【最被看重】
  • 一致性:指数据库中的数据在事务执行前后是一致的。
  • 隔离性:它要求数据库在执行一个事务时,其它操作无法存取到正在执行事务访问的数据。
  • 持久性

Redis如何实现事务?

事务的执行过程包含三个步骤,Redis提供了MULTI、EXEC两个命令来完成这三个步骤。下面我们来分析下。

第一步,客户端要使用一个命令显式地表示一个事务的开启。在Redis中,这个命令就是MULTI

第二步,客户端把事务中本身要执行的具体操作(例如增删改数据)发送给服务器端。这些操作就是Redis本身提供的数据读写命令,例如GET、SET等。不过,这些命令虽然被客户端发送到了服务器端,但Redis实例只是把这些命令暂存到一个命令队列中,并不会立即执行

第三步,客户端向服务器端发送提交事务的命令,让数据库实际执行第二步中发送的具体操作。Redis提供的EXEC命令就是执行事务提交的。当服务器端收到EXEC命令后,才会实际执行命令队列中的所有命令。

下面的代码就显示了使用MULTI和EXEC执行一个事务的过程,你可以看下。

1
2
3
4
5
6
7
8
9
10
11
12
13
#开启事务
127.0.0.1:6379> MULTI
OK
#将a:stock减1,
127.0.0.1:6379> DECR a:stock
QUEUED
#将b:stock减1
127.0.0.1:6379> DECR b:stock
QUEUED
#实际执行事务
127.0.0.1:6379> EXEC
1) (integer) 4
2) (integer) 9

好了,通过使用MULTI和EXEC命令,我们可以实现多个操作的共同执行,但是这符合事务要求的ACID属性吗?接下来,我们就来具体分析下。

Redis的事务机制能保证哪些属性?

原子性

如果事务正常执行,没有发生任何错误,那么,MULTI和EXEC配合使用,就可以保证多个操作都完成。但是,如果事务执行发生错误了,原子性还能保证吗?我们需要分三种情况来看。

  • 第一种情况是,在执行EXEC命令前,客户端发送的操作命令本身就有错误(比如语法错误,使用了不存在的命令),在命令入队时就被Redis实例判断出来了。
    • 在命令入队时,Redis就会报错并且记录下这个错误。此时,我们还能继续提交命令操作。等到执行了EXEC命令之后,Redis就会拒绝执行所有提交的命令操作,返回事务失败的结果。这样一来,事务中的所有命令都不会再被执行了,保证了原子性。
  • 第二种情况是,事务操作入队时,命令和操作的数据类型不匹配,但Redis实例没有检查出错误
    • 在执行完EXEC命令以后,Redis实际执行这些事务操作时,就会报错。不过,需要注意的是,虽然Redis会对错误命令报错,但还是会把正确的命令执行完。在这种情况下,事务的原子性就无法得到保证了。
    • Redis中并没有提供回滚机制。虽然Redis提供了DISCARD命令,但是,这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。
  • 第三种情况:在执行事务的EXEC命令时,Redis实例发生了故障,导致事务执行失败
    • 如果Redis开启了AOF日志,那么,只会有部分的事务操作被记录到AOF日志中。我们需要使用redis-check-aof工具检查AOF日志文件,这个工具可以把已完成的事务操作从AOF文件中去除。这样一来,我们使用AOF恢复实例后,事务操作不会再被执行,从而保证了原子性。
    • 如果AOF日志并没有开启,那么实例重启后,数据也都没法恢复了,此时,也就谈不上原子性了。

你了解了Redis对事务原子性属性的保证情况,我们来简单小结下:

  • 命令入队时就报错,会放弃事务执行,保证原子性;
  • 命令入队时没报错,实际执行时报错,不保证原子性;
  • EXEC命令执行时实例故障,如果开启了AOF日志,可以保证原子性。

一致性

我们按照命令出错和实例故障的发生时机,分成三种情况来看。

  • 情况一:命令入队时就报错
    • 在这种情况下,事务本身就会被放弃执行,所以可以保证数据库的一致性。
  • 情况二:命令入队时没报错,实际执行时报错
    • 在这种情况下,有错误的命令不会被执行,正确的命令可以正常执行,也不会改变数据库的一致性。
  • 情况三:EXEC命令执行时实例发生故障。 在这种情况下,实例故障后会进行重启,这就和数据恢复的方式有关了,我们要根据实例是否开启了RDB或AOF来分情况讨论下。
    • 如果我们没有开启RDB或AOF,那么,实例故障重启后,数据都没有了,数据库是一致的。
    • 如果我们使用了RDB快照,因为RDB快照不会在事务执行时执行,所以,事务命令操作的结果不会被保存到RDB快照中,使用RDB快照进行恢复时,数据库里的数据也是一致的。
    • 如果我们使用了AOF日志,而事务操作还没有被记录到AOF日志时,实例就发生了故障,那么,使用AOF日志恢复的数据库数据是一致的。如果只有部分操作被记录到了AOF日志,我们可以使用redis-check-aof清除事务中已经完成的操作,数据库恢复后也是一致的。

总结来说,在命令执行错误或Redis发生故障的情况下,Redis事务机制对一致性属性是有保证的。

隔离性

事务的隔离性保证,会受到和事务一起执行的并发操作的影响。而事务执行又可以分成命令入队(EXEC命令执行前)和命令实际执行(EXEC命令执行后)两个阶段,所以,我们就针对这两个阶段,分成两种情况来分析:

  1. 并发操作在EXEC命令前执行,此时,隔离性的保证要使用WATCH机制来实现,否则隔离性无法保证;
  2. 并发操作在EXEC命令后执行,此时,隔离性可以保证。

WATCH机制的作用是,在事务执行前,监控一个或多个键的值变化情况,当事务调用EXEC命令执行时,WATCH机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。然后,客户端可以再次执行事务,此时,如果没有并发修改事务数据的操作了,事务就能正常执行,隔离性也得到了保证。
WATCH机制的具体实现是由WATCH命令实现的,我给你举个例子,你可以看下下面的图,进一步理解下WATCH命令的使用。
image.png

如果没有使用WATCH机制,在EXEC命令前执行的并发操作是会对数据进行读写的。而且,在执行EXEC命令的时候,事务要操作的数据已经改变了,在这种情况下,Redis并没有做到让事务对其它操作隔离,隔离性也就没有得到保障。下面这张图显示了没有WATCH机制时的情况,你可以看下。
image.png

刚刚说的是并发操作在EXEC命令前执行的情况,下面我再来说一说第二种情况:并发操作在EXEC命令之后被服务器端接收并执行

因为Redis是用单线程执行命令,而且,EXEC命令执行后,Redis会保证先把命令队列中的所有命令执行完。所以,在这种情况下,并发操作不会破坏事务的隔离性,如下图所示:
image.png

持久性

因为Redis是内存数据库,所以,数据是否持久化保存完全取决于Redis的持久化配置模式。

  • 如果Redis没有使用RDB或AOF,那么事务的持久化属性肯定得不到保证。
  • 如果Redis使用了RDB模式,那么,在一个事务执行后,而下一次的RDB快照还未执行前,如果发生了实例宕机,这种情况下,事务修改的数据也是不能保证持久化的。
  • 如果Redis采用了AOF模式,因为AOF模式的三种配置选项no、everysec和always都会存在数据丢失的情况,所以,事务的持久性属性也还是得不到保证。
    所以,不管Redis采用什么持久化模式,事务的持久性属性是得不到保证的

小结

在这节课上,我们学习了Redis中的事务实现。Redis通过MULTI、EXEC、DISCARD和WATCH四个命令来支持事务机制,这4个命令的作用,我总结在下面的表中,你可以再看下。
image.png

通过这节课的分析,我们了解到了,Redis的事务机制可以保证一致性和隔离性,但是无法保证持久性。不过,因为Redis本身是内存数据库,持久性并不是一个必须的属性,我们更加关注的还是原子性、一致性和隔离性这三个属性。

原子性的情况比较复杂,只有当事务中使用的命令语法有误时,原子性得不到保证,在其它情况下,事务都可以原子性执行。

所以,我给你一个小建议:严格按照Redis的命令规范进行程序开发,并且通过code review确保命令的正确性。这样一来,Redis的事务机制就能被应用在实践中,保证多操作的正确执行。

课后问题

image.png