7

1. 前言

我原本只是想学习Redis的事务,但后来发现,Redis和传统关系型数据库的事务在ACID的表现上差异很大。而要想详细了解其中的缘由,就离不开Redis独特的单线程模型,因此本文将二者联系在一起讲解。

下面先会补充一些知识储备,包括解答几个常犯错的问题,分析Redis的线程模型,为后面的章节打好基础。随后再讲解Redis的事务实现,和关系型数据库的事务做对比,以及会附上springboot中实现事务的代码。

2. 常见问题

2.1. 高并发不等于高并行

我们最多听到的就是并发,但实际上很多时候并不严谨,有些情况应该被定义为并行

  • 并发,是指在一个时间段内有多个进程在执行。只不过在人的角度看,因为这个计算机角度的时间实在是太短暂了,人根本就感受不到是多个进程,看起来像是同时进行,这种是并发。
  • 并行,指的是在同一时刻有多个进程在同时执行。

一个是时间段内发生的,一个是某一时刻发生的,如果是在只有单核CPU的情况下,是无法实现并行的,因为同一时刻只能有一个进程被调度执行,如果此时同时要执行其他进程则必须上下文切换,这种只能称之为并发,而如果是多个CPU的情况下,就可以同时调度多个进程,这种就可以称之为并行。

2.2. 什么时候该用多线程

我们首先要明确,多线程不一定比单线程快,因为多线程还涉及到CPU上下文切换的消耗,和频繁创建、销毁线程的消耗 。那么多线程是为了优化什么而使用的呢?我所了解的有两点:

1.充分利用多核CPU的资源,实现并行

因为多核cpu每一个核心都可以独立执行一个线程,所以多核cpu可以真正实现多线程的并行。
但这点优化算不上什么,一台服务器上一般部署了很多的应用,哪有那么多空闲的CPU核心空闲着。

2.应对CPU的“阻塞”

我认为这才是主要原因。“阻塞”包括网络io、磁盘io等这类io的阻塞,还包括一些执行很慢的逻辑操作等。例如:某个接口的方法中,按照执行顺序分成A、B、C三个独立的部分。

如果每个部分执行的都很慢(如:查询数据库视图,将数据导出excel文件),都要10秒。那么方法执行完成,单线程要用30秒,多线程分别执行只需要10秒。优化了20秒,线程创建和CPU上下文切换的影响,和20秒比起来不算什么。

如果每个部分执行的都很快,都只需要10毫秒。按照上面的计算方式,理论上优化了20毫秒,可线程创建和CPU上下文切换的影响,可是要大于20毫秒的。

因此总体来说,多线程开发对于程序的优化,主要体现在应对导致CPU“阻塞”的点。

3. 线程模型

Redis服务端通过单进程单线程,处理所有客户端的请求。

Redis官方数据是说支持100000+ 的QPS(峰值时间的每秒请求),很难相信这是靠单线程来支撑的。因此我们要探究一下,Redis的线程模型为啥能支持它执行这么快?

3.1. 性能瓶颈

官方表示,Redis是基于内存操作,CPU不是Redis的性能瓶颈,Redis的性能瓶颈是机器的内存和网络带宽。

看到这句话,我有个疑惑,为啥 “Redis是基于内存操作,CPU不是Redis的性能瓶颈”

这就联系到第二章中“2.多线程不一定快”的知识点了-- 在多线程开发对于程序的优化,主要体现在应对导致CPU“阻塞”的点。普通数据库的瓶颈在于磁盘io,可Redis是基于内存操作,没有磁盘io的瓶颈,而且基于Reactor模型,也没有网络io的阻塞。没有多线程的必要,CPU也就不是Redis的性能瓶颈。

另外Redis是将所有的数据全部放在内存中的,所有说使用单线程去操作执行效率就是最高的,多线程在执行过程中需要进行 CPU 的上下文切换,这个是耗时操作。对于内存系统来说,如果没有上下文切换效率就是最高的,多次读写都是在一个 CPU 上的,在内存情况下,这个就是最佳方案。

我们可以理解成,因为Redis作为内存数据库,又有个很好的线程模型,并不存在io阻塞和CPU等性能瓶颈。再往后可以提升Redis空间的,就在于机器的内存和网络带宽了。

3.2. 线程模型

我之前的很多篇文章都提到了Reactor线程模型,像Tomcat、Netty等,都使用了Reactor线程模型来实现IO多路复用,这次再加上Redis。还记得之前有介绍Reactor模型有三种:单线程Reactor模型,多线程Reactor模型,主从Reactor模型。

通常来说,主从Reactor模型是最健壮的,Tomcat和Netty都是使用这种,但是 Redis是使用单线程Reactor模型

image

上图描述了Redis工作的线程模型,模拟了服务端处理客户端命令的过程:

  1. 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,即将套接字的fd注册到epoll上,当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生。
  2. 尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生事件的套接字都推到一个队列里面,然后通过这个队列,以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字。
  3. 此时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。文件事件处理器以单线程方式运行,这就是之前一直提到的Redis线程模型中,效率很高的那个单线程。

值得注意的是,在执行命令阶段,由于Redis是单线程来处理命令的,所有每一条到达服务端的命令不会立刻执行,所有的命令都会进入一个队列中,然后逐个被执行。并且多个客户端发送的命令的执行顺序是不确定的。但是可以确定的是,不会有两条命令被同时执行,不会产生并行问题,这也是后面我们讨论Redis事务的基础

3.3. 分析

为什么不怕Reactor单线程模型的弊端?

我们回顾之前的文章,Reactor单线程模型的最大缺点在于:Acceptor和Handlers都共用一个线程,只要某个环节发生阻塞,就会阻塞所有。整个尤其是Handlers是执行业务方法的,最容易发生阻塞,像Tomcat就默认使用200容量大线程池来执行。那Redis为什么就不怕呢?

原因就在于Redis作为内存数据库,它的Handlers是可预知的,不会出现像Tomcat那样的自定义业务方法。不过也建议不要在Reids中执行要占用大量时间的命令。

总结:Redis单线程效率高的原因
  • 纯内存访问:数据存放在内存中,内存的响应时间大约是100纳秒,这是Redis每秒万亿级别访问的重要基础。
  • 非阻塞I/O:Redis采用epoll做为I/O多路复用技术的实现,再加上Redis自身的事件处理模型将epoll中的连接,读写,关闭都转换为了时间,不在I/O上浪费过多的时间。
  • 单线程避免了线程切换和竞态产生的消耗。

4. 事务

前面说过,由于Redis单线程的特性,所有的命令都是进入一个队列中,依次执行。因此不会有两条命令被同时执行,不会产生并行问题。这点和传统关系型数据库不一样,没有并行问题,也就没有像表锁、行锁这类锁竞争的问题了。

4.1. 概念

那么Redis的事务是为了处理什么情况?

假设,客户端A提交的命令有A1、A2和A3 这三条,客户端B提交的命令有B1、B2和B3,在进入服务端队列后的顺序实际上很大部分是随机。假设是:A1、B1、B3、A3、B2、A2,可客户端A期望自己提交的是按照顺序一起执行的,它就可以使用事务实现:B2、A1、A2、A3、B1、B3,客户端B的命令执行顺序还是随机的,但是客户端A的命令执行顺序就保证了。

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。  

Redis事务相关命令
  • watch key1 key2 ... : 监视一或多个key,如果在事务执行之前,被监视的key被其他命令改动,则事务被打断 ( 类似乐观锁 )
  • multi : 标记一个事务块的开始( queued )
  • exec : 执行所有事务块的命令 ( 一旦执行exec后,之前加的监控锁都会被取消掉 ) 
  • discard : 取消事务,放弃事务块中的所有命令
  • unwatch : 取消watch对所有key的监控
事务执行过程

multi命令可以将执行该命令的客户端从非事务状态切换至事务状态,执行后,后续的普通命令(非multi、watch、exec、discard的命令)都会被放在一个事务队列中,然后向客户端返回QUEUED回复。

事务队列是一个以先进先出(FIFO)的方式保存入队的命令,较先入队的命令会被放到数组的前面,而较后入队的命令则会被放到数组的后面。

当一个处于事务状态的客户端向服务器发送exec命令时,这个exec命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有的命令,最后将执行命令所得的结果返回给客户端。

当一个处于事务状态的客户端向服务器发送discard命令时,表示事务取消,客户端从事务状态切换回非事务状态,对应的事务队列清空。

watch

watch命令可被用作乐观锁。它可以在exec命令执行前,监视任意数量的数据库键,并在exec命令执行时,检查监视的键是否至少有一个已经被其他客户端修改过了,如果修改过了,服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复。而unwatch命令用于取消对所有键的监视。

要注意,watch是监视键被其他客户端修改过,即其他的会话连接中。如果你在同一个会话下自己watch自己改,是不生效的。

4.2. ACID分析

在传统关系型数据库中,事务都是遵循ACID四个特性的,那么Redis的事务遵循吗?

原子性(Atomicity)
原子性是指事务包含的所有操作要么全部成功,要么全部失败回滚。

Redis 开始事务 multi 命令后,Redis 会为这个事务生成一个队列,每次操作的命令都会按照顺序插入到这个队列中。这个队列里面的命令不会被马上执行,直到 exec 命令提交事务,所有队列里面的命令会被一次性,并且排他的进行执行。

但是呢,当事务队列里面的命令执行报错时,会有两种情况:(1)一种错误类似于Java中的CheckedException,Redis执行器会检测出来,如果某个命令出现了这种错误,会自动取消事务,这是符合原子性的;(2)另一种错误类似于Java中的RuntimeExcpetion,Redis执行器检测不出来,当执行报错了已经来不及了,错误命令后续的命令依然会执行完毕,并不会回滚,因此不符合原子性。

一致性(Consistency)
一致性是指事务必须使数据库从一个一致性状态变换到另一个一致性状态,也就是说一个事务执行之前和执行之后都必须处于一致性状态。

因为达不成原子性,其实严格上来讲,也就达不成一致性。

隔离性(Isolation)
隔离性是当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。

回顾前面的基础,Redis 因为是单线程依次执行队列中的命令的,没有并发的操作,所以在隔离性上有天生的隔离机制。,当 Redis 执行事务时,Redis 的服务端保证在执行事务期间不会对事务进行中断,所以,Redis 事务总是以串行的方式运行,事务也具备隔离性。

持久性(Durability)
持久性是指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作。

Redis 是否具备持久化,这个取决于 Redis 的持久化模式:

  • 纯内存运行,不具备持久化,服务一旦停机,所有数据将丢失。
  • RDB 模式,取决于 RDB 策略,只有在满足策略才会执行 Bgsave,异步执行并不能保证 Redis 具备持久化。
  • AOF 模式,只有将 appendfsync 设置为 always,程序才会在执行命令同步保存到磁盘,这个模式下,Redis 具备持久化。(将 appendfsync 设置为 always,只是在理论上持久化可行,但一般不会这么操作)

简单总结:

  • Redis 具备了一定的原子性,但不支持回滚。
  • Redis 不具备 ACID 中一致性的概念。(或者说 Redis 在设计时就无视这点)
  • Redis 具备隔离性。
  • Redis 通过一定策略可以保证持久性。

当然,我们也不应该拿传统关系型数据库事务的ACID特性去要求Redis,Redis设计更多的是追求简单与高性能,不会受制于传统 ACID 的束缚。

4.3. 代码

这里结合springboot代码做示例,加深我们对Redis事务的应用开发。在springboot中构建Redis客户端,一般通过spring-boot-starter-data-redis来实现。

jedis 和 lettuce

Lettuce和Jedis的都是连接Redis Server的客户端程序。Jedis在实现上是直连redis server,多线程环境下非线程安全,除非使用连接池,为每个Jedis实例增加物理连接。Lettuce基于Netty的连接实例(StatefulRedisConnection),可以在多个线程间并发访问,且线程安全,满足多线程环境下的并发访问,同时它是可伸缩的设计,一个连接实例不够的情况也可以按需增加连接实例。

可见Lettuce是要优于Jedis的,在spring-boot-starter-data-redis早期版本都是使用Jedis连接的,但到了2.x版本,Jedis就直接被替换成Lettuce。

下面直接看代码吧。

pom

pom文件主要是引入了spring-boot-starter-data-redis

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
controller

controller中定义了两个接口:

  • 接口1 watch:watch键A,在事务中修改键A和B的值,在阻塞3秒后,提交事务。
  • 接口2 change:修改键A。

    @RestController
    public class DemoController {
      public final static String STR_KEY_A="key_a";
      public final static String STR_KEY_B="key_b";
    
      private final StringRedisTemplate stringRedisTemplate;
    
      public DemoController(StringRedisTemplate stringRedisTemplate) {
          this.stringRedisTemplate = stringRedisTemplate;
      }
    
      @GetMapping("/watch")
      public void watch(){
          SessionCallback sessionCallback = new SessionCallback<Object>() {
              @Override
              public Object execute(RedisOperations redisOperations) throws DataAccessException {
                  redisOperations.watch(STR_KEY_A);
                  redisOperations.multi();
                  try {
                      redisOperations.opsForValue().set(STR_KEY_A, "watch_a");
                      redisOperations.opsForValue().set(STR_KEY_B, "watch_b");
                      Thread.sleep(3000);
                  }catch (Exception e){
                      e.printStackTrace();
                      redisOperations.discard();
                  }
                  redisOperations.exec();
                  redisOperations.unwatch();
                  return redisOperations.exec();
              }
          };
          stringRedisTemplate.execute(sessionCallback);
      }
    
      @GetMapping("/change")
      public void change(){
          stringRedisTemplate.opsForValue().set(STR_KEY_A,"change_a");
      }
    
    }
测试用例

我们写一个测试用例,大致逻辑是:先调用接口1,0.5秒后(为了保证接口1先于接口2执行,因为线程实际执行顺序不一定按照业务代码顺序来),再调用接口2,并且在两个接口的线程中,都会将键A和B的值打印出来。

因为接口1的事务是延迟3秒提交的,因此执行顺序是:

接口1 watch 键A ->接口1 multi开始事务 -> 接口2 修改键A -> 接口1 提交事务

结果也符合我们预想的,因为在接口1 watch的键值,被接口2修改了,所以接口1 的事务执行失败了,最终输出的日志是:

2020-10-11 23:32:14.133  Thread2执行结果:
key_a:change_a
key_b:null
2020-10-11 23:32:16.692  Thread1执行结果:
key_a:change_a
key_b:null
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class DemoControllerTest {
    private final Logger logger = LoggerFactory.getLogger(DemoControllerTest.class);

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Test
    public void transactionTest() throws InterruptedException{
        /**
         * 清空数据,删除 A、B 键
         */
        stringRedisTemplate.delete(DemoController.STR_KEY_A);
        stringRedisTemplate.delete(DemoController.STR_KEY_B);
        /**
         * 线程1:watch A 键
         * 事务:修改A、B 键值,阻塞10秒后exec、unwatch
         * 输出:A、B键值
         */
        Thread thread1 = new Thread(() -> {
            try {
                mockMvc.perform(MockMvcRequestBuilders.get("/watch"));
                logger.info(new StringBuffer(Thread.currentThread().getName()).append("执行结果:\n")
                        .append(DemoController.STR_KEY_A).append(":").append(stringRedisTemplate.opsForValue().get(DemoController.STR_KEY_A))
                        .append("\n").append(DemoController.STR_KEY_B).append(":").append(stringRedisTemplate.opsForValue().get(DemoController.STR_KEY_B))
                        .toString());
            } catch (Exception e) {
                logger.error("/watch",e);
            }
        });
        thread1.setName("Thread1");
        /**
         * 线程2:修改 A 键
         * 事务:无事务,无阻塞
         * 输出:A、B 键值
         */
        Thread thread2 = new Thread(() -> {
            try {
                mockMvc.perform(MockMvcRequestBuilders.get("/change"));
                logger.info(new StringBuffer(Thread.currentThread().getName()).append("执行结果:\n")
                        .append(DemoController.STR_KEY_A).append(":").append(stringRedisTemplate.opsForValue().get(DemoController.STR_KEY_A))
                        .append("\n").append(DemoController.STR_KEY_B).append(":").append(stringRedisTemplate.opsForValue().get(DemoController.STR_KEY_B))
                        .toString());
            } catch (Exception e) {
                logger.error("/change",e);
            }
        });
        thread2.setName("Thread2");
        /**
         * 线程1 比 线程2 先执行
         */
        thread1.start();
        Thread.sleep(500);
        thread2.start();
        /**
         * 主线程,等待 线程1、线程2 执行完成
         */
        thread1.join();
        thread2.join();
    }
}

5. Redis6 多线程

2020年5月份,Redis正式推出了6.0版本,这个版本中有很多重要的新特性,其中多线程特性引起了广泛关注。

但是,需要提醒大家的是,Redis 6.0中的多线程,也只是针对处理网络请求过程采用了多线程,而数据的读写命令,仍然是单线程处理的。

但是,不知道会不会有人有这样的疑问:

  1. Redis不是号称单线程也有很高的性能么?不是说多路复用技术已经大大的提升了IO利用率了么,为啥还需要多线程?

主要是因为我们对Redis有着更高的要求。

根据测算,Redis 将所有数据放在内存中,内存的响应时长大约为 100 纳秒,对于小数据包,Redis 服务器可以处理 80,000 到 100,000 QPS,这么高的对于 80% 的公司来说,单线程的 Redis 已经足够使用了。

但随着越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的 QPS。

为了提升QPS,很多公司的做法是部署Redis集群,并且尽可能提升Redis机器数。但是这种做法的资源消耗是巨大的,还是要继续提高redis性能。

  1. 回到前面“CPU不是Redis的性能瓶颈,Redis的性能瓶颈是机器的内存和网络带宽”

既然性能瓶颈是:内存和网络带宽,那我们就先专注于解决网络瓶颈。限制Redis的性能的主要瓶颈出现在网络IO的处理上,虽然之前采用了多路复用技术。但是我们前面也提到过,多路复用的IO模型本质上仍然是同步阻塞型IO模型。在多路复用的IO模型中,在处理网络请求时,调用 select (其他函数同理)的过程是阻塞的,也就是说这个过程会阻塞线程,如果并发量很高,此处可能会成为瓶颈。

虽然现在很多服务器都是多个CPU核的,但是对于Redis来说,因为使用了单线程,在一次数据操作的过程中,有大量的CPU时间片是耗费在了网络IO的同步处理上的,并没有充分的发挥出多核的优势。

如果能采用多线程,使得网络处理的请求并发进行,就可以大大的提升性能。多线程除了可以减少由于网络 I/O 等待造成的影响,还可以充分利用 CPU 的多核优势。

所以,Redis 6.0采用多个IO线程来处理网络请求,网络请求的解析可以由其他线程完成,然后把解析后的请求交由主线程进行实际的内存读写。提升网络请求处理的并行度,进而提升整体性能。

但是,Redis 6.0 出现的多线程主要致力于解决网络模块的瓶颈,通过使用多线程处理读/写客户端数据,进而分担主IO线程的压力。值得注意的是,命令处理仍然是单线程执行。

3. 那么,在引入多线程之后,如何解决并发带来的线程安全问题呢?

这就是为什么我们前面多次提到的"Redis 6.0的多线程只用来处理网络请求,而数据的读写还是单线程"的原因。

Redis 6.0 只有在网络请求的接收和解析,以及请求后的数据通过网络返回给时,使用了多线程。而数据读写操作还是由单线程来完成的,所以,这样就不会出现并发问题了。


KerryWu
641 声望159 粉丝

保持饥饿