关于redis:Redis的线程模型和事务

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模型

上图形容了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(){
        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();
        /**
         * 主线程,期待 线程1、线程2 执行实现
         */
        thread1.join();
        thread2.join();
    }
}

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理