引言
这篇文章,还是对之前技术思想的一些总结,经历过软件设计的共同点,才发现万变不离其宗,变化的只是形式,也算是对自己基础的一点警醒
以前我在思考为什么要学习计算机基础原理、网络编程、算法和数据结构,直到前几日:我与TL聊起MQ队列的可靠性消息与TCP的可靠性传输ack机制惊人的相似,我们在学习一门技术的时候,可能也没想到以后在软件技术中我们会以另外一种方式认识它们
软件设计与计算机原理
1. 事件消息与CPU中断机制
事件风暴工作坊是领域驱动设计(DDD)中寻找聚合的一种手段,利用事件消息的方式将微服务之间解耦;CPU的中断机制就是CPU不与硬件或者其他软件命令直接打交道,而是在需要的时候发送一个信号,CPU放下当前处理而去处理中断请求,而不使用“轮训”的方式来巡查提升性能,也提高了接入硬件的效率。
我在理解CPU的中断信号时,就很感叹计算机的基础原理很神奇,我之前一直以为键盘等外设是有一个线程监听这个事件,在触发的时候去处理,但后来一想这种方式实现起来也太消耗资源了;后来想到使用IO多路复用的思路有一个线程轮训监听所有的键盘请求,再交给CPU的去处理,但这种方式,避免不了消耗资源因为CPU一直在空转;直到学习了CPU的中断,我们的消息也不用一直轮训,而是等待对方的通知
为了更好的体现这个性能差异,我们来看一下中断机制如何处理IO操作
- 当我们程序需要从硬盘读取一个文件时,会先检查内核缓存中是否有数据,如果没有数据,则执行实际I/O操作
- 实际的I/O操作过程中,若没有中断操作,CPU会不断轮询检查I/O操作是否完成,若I/O操作没有完成则继续调度其他线程,就需要反复再来检查
- 若操作完成,CPU将线程加入到线程就绪队列中并恢复线程上下文信息
- 线程处于就绪队列,可以被操作系统调度从而继续执行读操作,此时会将数据从操作系统内核缓存读取到用户缓存中
- 当我们程序需要从硬盘读取一个文件时,会先检查内核缓存中是否有数据,如果没有数据,则执行实际I/O操作
- 在实际的I/O操作过程中,CPU向I/O模块(DMA控制器)发送读指令,然后就去调度其他线程
- 当I/O模块(DMA控制器)I/O执行完成后,会产生中断信号在通知CPU,CPU将线程加入到线程就绪队列中并恢复线程上下文信息
- 线程处于就绪队列,可以被操作系统调度从而继续执行读操作,此时会将数据从操作系统内核缓存读取到用户缓存中
这里的CPU中断和事件消息有极大的相似之处,解耦上下游服务的同时还避免的了CPU的轮训消耗,消息及时响应且高效,我们在设计软件与外部系统交互时,应该优先考虑事件解耦,而非轮训抓取;软件设计也讲究资源的合理利用,我们应该从计算机基础原理中借鉴其优秀的思想,应用到软件设计中,逐渐完善我们的软件设计。
2. MQ队列的可靠性消息和TCP可靠性传输
前几日遇到一个问题,如何保证客户端和RocketMQ服务端消息的一致性,一般情况为了做到业务逻辑与事件解耦,往往会在执行完业务逻辑后将消息发出,如下图
按照上图设计,我们的业务逻辑一定要等待事件消息发送完成后才能提交事务,这样发送就一定OK了吗?其实不然: MQ也分为同步和异步消息,我们分情况探讨:
- 异步消息表示客户端生产者发送MQ消息时不会立即发送,而是存储到一个buffer区域,在达到buffer大小的数据时再批量发出;如若此时客户端服务宕机或者断电,将会导致消息丢失(存在消息丢失)
- 同步消息表示客户端生产者发送MQ消息时会立刻执行Api将消息发出,等待服务端的返回,直到服务端Broker返回结果再做事务处理;存在两个问题:1. 发送消息效率太低,依赖MQ服务接受消息效率;2. 如果存在两个以上事件消息,第二个消息发送失败,无法回滚第一个消息的发送状态(效率低下,且引入MQ无法保证事务正常运行)
在遇到此问题时,真的无法保证消息的一致性吗?
在我们理解一下TCP的可靠性传输机制就会发现,研究计算机网络的大佬们已经解决过类似的问题,确认机制:当TCP发出一个数据段后,它启动一个定时器,等待服务端收到这个报文段并返回ack确认。如果没有按时收到服务端端的确认,将重发这个报文段(TCP有延迟确认的功能);重传机制:TCP协议用于控制数据段是否需要重传的依据是设立重发定时器。在发送一个数据段的同时启动一个定时器,如果超时前没有收到确认,则重传该数据段。
同理RabbitMQ也实现了类似的确认消息,引入了事务机制和发送方确认机制(publisher confirm),由于事务机制过于耗费性能所以一般不用,这里可以使用发送方确认机制:这个机制就是消息发送到MQ那端之后,MQ会回一个确认收到的消息给我们。我们可以在发送消息时,将消息持久化起来,在接受到ack消息(确认机制)时找到对应的消息删除,在增加一个定时任务不断重试发送失败的消息(重试机制),这样就借助了TCP的可靠性消息机制保证了MQ的发送端可靠性。(RocketMQ同理使用可靠异步传输可自行封装)
3. 缓存一致性问题和缓存一致性(MESI)协议、Binlog的刷盘机制
缓存一致性问题是指的在软件设计中为了协调两个组件的速度差距,而引入的中间层缓存层来提升性能,为了保证数据库和缓存一致而提出的问题;缓存一致性(MESI)协议是说的多核CPU与内存交互时,避免太慢而引入的CPU cache,为了保证这些数据和内存的一致性问题引出的一种协议。
之前也遇到过很多缓存场景,这里主要针对后端服务与数据库DB之间存在的缓存系统,这一部分也是我打交道最多的部分,我们常常会在访问数据接口处加一层缓存,从而来提升频繁操作DB数据的性能:
引入了缓存,也提升了软件设计的复杂性,比如如何保证缓存和数据库的一致性:
- 先删缓存,再更新数据库(在删除缓存后,如果此时其他线程读取缓存,发现数据不在读取DB旧数据到缓存)
- 先更新数据库,再删除缓存(存在同样的问题,更新数据库后还没更新缓存,此时另外线程读取缓存旧数据)
- 引入延迟删除缓存或者MQ消息删除缓存(与2存在同样的问题,缓存和数据库仍然存在短期不一致)
真的没有方法来保证缓存的一致性吗?
方案肯定还是有的,不过我们应该更加专注我们的场景:
-
缓存数据需要保证强一致性时,那么此时就应该直接修改缓存,缓存再逐渐同步给DB,保证最终一致性即可;比如MySQL的buffer pool设计初衷只是为了操作数据更快,它会有一个后台线程定时刷新脏的数据。保证操作数据是在内存中操作而提升了性能,但为了保证一致性也引入了软件设计的复杂性比如:redo log和undo log
-
缓存数据允许短期的不一致,那么我们可以使用上面引入延迟删除缓存或者MQ消息删除缓存(引入MQ的删除机制主要还是保证删除缓存一定会成功);比如CPU缓存行的MESI协议,缓存行有四种状态来表示它们当前的状态,每次修改后都会通知其他CPU此缓存行已经失效(标识已更新),那么每次通知都太慢怎么保证效率呢?引入Store Buffer(修改操作队列)和Invalid Queue(失效缓存行队列)来保证消息的最终一致性,当然这里面引入了新的问题:如何保证缓存行刚刚写入就能保证其他CPU读取到呢?内存屏障就产生了,这里不细讲了,具体可以查看抽象资源同步器框架AQS原理(五)——从内存屏障到volatile原理
性能提升和缓存一致性的实时性肯定是要做一些取舍的,这个就要看我们在业务中如何理解业务的缓存的含义了;这里举了两个例子来引出底层技术学习的意义,我们并不是要精通每一项技术,而是我们要学习其优秀的思想,从而运用到项目中、软件设计中,这样既能指导我们的软件设计,也能走的更远。
技术思想相关性
上面是技术和一些技术的相似性,这里和上面一样,也是自己所学、所认为思想类似的技术,识别技术的共性,提炼和总结其思想,可能我们一辈子也遇不到大师们遇到的问题,但是总结这些思想方便以后解决类似问题时提供思路
1. 数据可靠性持久化:MySQL的binlog二阶段提交和RocketMQ的Dledger集群消息同步
二阶段提交(英语:Two-phase Commit)是指在计算机网络以及数据库领域内,为了使基于分布式系统架构下的所有节点在进行事务提交时保持原子性和一致性而设计的一种算法。它的大概步骤为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作
(MySQL中InnoDB引擎二阶段提交示意图)
二阶段提交是一个分布式系统中的协调一致性算法,它保证的是每个节点的一致性;Mysql的InnoDB引擎在持久化数据时,为了保证redo log和binlog的一致性,也借鉴了二阶段提交的思想,在写入数据时时并不是直接先写入redo log或者先写入binlog,而是先写入redo log的prepare阶段,此时代表redo log已经OK了,此时再写入binlog日志的数据,如果写入binlog也成功,才会将刚刚写入的redo log修改为commit状态
(为什么要保证redo log和binlog的一致性? InnoDB使用redo log作为重做日志,用来保证事务的原子性和持久性,记录了事务开始时修改数据的操作,可以简单理解它写入成功就代表事务要操作成功,使用binlog作为归档日志,可以作为数据恢复和高可用集群间的主从同步,它代表数据库每时每刻的操作归档,事务提交时需要将它们俩一起提交写入,它们俩的一致性保证了数据库从任何时刻宕机或者异常情况都能保证数据的一致性,即写入他们俩任何一个失败时都能保证数据的正确性)
RocketMQ的Dledger集群模式,为了保证消息发送的可靠性同步也借助了这个思想,Dledger会通过两阶段提交的方式保证文件在主从之间成 功同步
简单来说,数据同步会通过两个阶段,一个是uncommitted阶段,一个是commited阶段。Leader在收到一个消息数据时,会将其标记为uncommitted状态,然后将其发送给所有的Follower,Follower在收到这个uncommitted消息后会返回给给Loader一个ack消息,当Leader收到超过半数以上的Follower发送的ack之后,就会把消息标记为committed状态,随后再发送committed消息给所有的Follower,随之它们再将其uncommitted数据标记为committed状态,这样完成了二阶段的消息同步
为什么要二阶段提交呢?
其实还是为了给所有操作的人一个机会,大家(或者集群中半数以上)都ok了,我们再一起提交;退一步想,是一种公平的方式,让参与者存在一个中间状态,收到大家的ack之后再进行操作;而不是说谁先操作,因为总有异常情况或者特殊情况,会导致后者无法继续操作。
2. 高可用分片副本:Redis的cluster集群和Kafka的Partition副本策略
Redis的Cluster集群是由多个主从节点共同组成的无中心式分布式集群结构
Redis Cluster将所有的数据划分为16384个槽位,每个节点负责一部分槽位。拆分成为16384个槽位其实是一种分片(Sharding)思想,将所有的数据分别存储在不同的机器上,类似于MySQL的分表,当一个表的数据太多时我们可以考虑使用一定规则将其划分为多个表存储,这样既可以提升性能又可以水平扩展。Redis的Cluster集群也是这个思想,我们可以为每个节点按照机器性能分配不同数目的槽位,这样就可以在存储数据上做到水平扩展。
节点又分为主节点和从节点,主节点负责处理槽,而从节点负责复制某个主节点,并在被复制的主节点下线时,代替下线的主节点继续处理命令请求。可以理解为:每个主节点都有自己的backup(从节点),当自己不行了的时候都会有backup(从节点)顶上,这样冗余备份的机制就保证了集群的高可用性,去除了单点故障。
与此同理还有Kafka的Partition副本策略
Kafka将每一个主题分为多个Partition来(分片)存储数据(具体的分片规则可以用户自定义),分片(Sharding)保证了发送端和消费端的并行度,可以对每一个Partition分片进行单独消费和存储,也是提升性能和扩展能力的一种思想。
为了保证Partition的高可用性,Kafka也有自己的副本策略,每个Partition有自己的副本(Replica),Partition的Leader负责消息的消费和处理,而Follower负责向Leader pull拉取消息进行数据同步,并在Leader出现异常不能正常服务或者宕机时选择一个Follower作为Leader继续服务,这样同上所述,冗余备份的机制就保证了集群的高可用性,去除了单点故障。
数据分片间的热点均衡,在方便扩展的同时又可以减少资源争用并提高性能,服务冗余是为了去除单点故障,并可以支持服务的弹性伸缩,以及故障迁移,学习框架的目的并不是仅仅停留在使用层面,我们应该学习其设计思想,类似要开发一个高可用的服务时,我们也可以借鉴其思想来解决我们的问题
结语
本篇文章总结了一些技术的思想共性,不同的技术但在解决相同的问题都有很多相似之处,在学习中可以不断举一反三,触类旁通,逐渐提升自己。总结其思想,在下次如果能遇到类似的问题时,想到对应的解决方案。