Redis的线程模型和事务

1. 前言

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

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

2. 常见问题

2.1. 高并发不等于高并行

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

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

一个是时间段内发生的,一个是某一时刻发生的,若是是在只有单核CPU的状况下,是没法实现并行的,由于同一时刻只能有一个进程被调度执行,若是此时同时要执行其余进程则必须上下文切换,这种只能称之为并发,而若是是多个CPU的状况下,就能够同时调度多个进程,这种就能够称之为并行。数据库

2.2. 何时该用多线程

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

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

由于多核cpu每个核心均可以独立执行一个线程,因此多核cpu能够真正实现多线程的并行。
但这点优化算不上什么,一台服务器上通常部署了不少的应用,哪有那么多空闲的CPU核心空闲着。安全

2.应对CPU的“阻塞”

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

若是每一个部分执行的都很慢(如:查询数据库视图,将数据导出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提交的命令有A一、A2和A3 这三条,客户端B提交的命令有B一、B2和B3,在进入服务端队列后的顺序实际上很大部分是随机。假设是:A一、B一、B三、A三、B二、A2,可客户端A指望本身提交的是按照顺序一块儿执行的,它就可使用事务实现:B二、A一、A二、A三、B一、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(){
        stringRedisTemplate.setEnableTransactionSupport(true);
        stringRedisTemplate.watch(STR_KEY_A);
        stringRedisTemplate.multi();
        try {
            stringRedisTemplate.opsForValue().set(STR_KEY_A, "watch_a");
            stringRedisTemplate.opsForValue().set(STR_KEY_B, "watch_b");
            Thread.sleep(3000);
        }catch (Exception e){
            e.printStackTrace();
            stringRedisTemplate.discard();
        }
        stringRedisTemplate.exec();
        stringRedisTemplate.unwatch();
    }

    @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();
        /**
         * 主线程,等待 线程一、线程2 执行完成
         */
        thread1.join();
        thread2.join();
    }
}
相关文章
相关标签/搜索