共计 8641 个字符,预计需要花费 22 分钟才能阅读完成。
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 工作的线程模型,模仿了服务端解决客户端命令的过程:
- 文件事件处理器应用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,行将套接字的 fd 注册到 epoll 上,当被监听的套接字筹备好执行连贯应答(accept)、读取(read)、写入(write)、敞开(close)等操作时,与操作绝对应的文件事件就会产生。
- 只管多个文件事件可能会并发地呈现,但 I / O 多路复用程序总是会将所有产生事件的套接字都推到一个队列外面,而后通过这个队列,以有序(sequentially)、同步(synchronously)、每次一个套接字的形式向文件事件分派器传送套接字。
- 此时文件事件处理器就会调用套接字之前关联好的事件处理器来解决这些事件。文件事件处理器以单线程形式运行,这就是之前始终提到的 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();}
}