加餐 中间件底层的通用设计理念
你好,我是丁威。
我们都知道,开发中间件的技术含量是比较高的,如果能参加中间件的开发,可以说是朝“技术大神”迈了一大步。
但是,中间件开发并不是遥不可及的。通过对各主流中间件的研究,我发现了中间件底层的一些通用设计理念,它们分别是数据结构、多线程编程(并发编程)、网络编程(NIO、Netty)、内存管理、文件编程和相关领域的知识。
其中,数据结构、多线程编程和网络编程是中间件的必备基础,在前面的课程中,我也做了详细介绍。这节课,我会重点介绍内存管理和文件编程相关的知识,带你了解开发中间件的核心要点。
你可能会问,六大技能,那最后一个技能是什么呢?最后这个技能就是相关领域的知识,它和中间件的类型有很大关系,和你需要解决的问题密切相连。
举个例子,数据库中间件的出现就是为了解决分库分表、读写分离等与数据库相关的问题。那如果要开发一款数据库中间件,你就必须对数据库有一个较为深入且体系化的理解。想要开发出一款MyCat这样基于代理模式的数据库,就必须了解MySQL的通信协议。我们甚至可以将相关领域的知识类比为我们要开发的业务系统的功能需求,这个是非常重要的。不过这部分我没有办法展开细讲,需要你自己去慢慢积累。我们还是说回内存管理。
内存管理
Java并不像C语言或者其他语言一样需要自己管理内存,因为JVM内置了内存管理机制(垃圾回收机制),所以在编写业务代码的过程中,我们只需要创建对象,而不需要关注对象在什么时候被回收。
垃圾回收机制(GC)对于业务开发来说无疑是非常方便的,但对于中间件开发来说就有点力不从心了。因为垃圾回收器执行回收时会出现停顿现象(Stop-World),不同垃圾回收器只是停顿的时间长短不同。不可控的垃圾回收对中间件的性能、可用性带来了比较大的影响。为了应对这一问题,通常的做法是,从操作系统申请一块内存,由中间件本身来管理这块内存的使用。
我们用Netty的内存管理机制来进一步说明一下。之前在讲解NIO读事件处理流程时我们说,IO线程需要将网卡中读取到的字节存储到累积缓存区。这里就要注意了,累积缓存区是需要使用内存的。我们用一张图来说明内存管理的一些需求:
在这张图中,我们从JVM内存模型视角创建了3个累积缓存区,然后,我们需要在栈内存创建3个指针,并分别在堆空间中创建3个ByteBuf对象。每个ByteBuf对象内部都有一块连续内存,用于存储从网卡中接收到的内容。
那Netty框架会接管哪部分内存呢?
我们来简单思考一下,如果Netty直接使用Java的垃圾回收机制,那ByteBuf对象还有内部持有的内存(byte[])就会频繁地创建与销毁,而且这些对象都是朝生暮死的,这会导致频繁的GC,高性能、高并发基本就成为奢望了。
为了有效降低垃圾回收发生的频率,减少需要回收的对象,Netty采用了下面两个解决手段。
首先,Netty会单独管理ByteBuf内部持有的内存,在启动进程时就向操作系统申请指定大小的内存。这样,这部分内存会被独占,并且在JVM存活期间一直可达。垃圾回收器不需要关注这部分内存的回收,由Netty负责管理内存的释放和分配。
其次,对ByteBuf对象本身采用对象池技术,避免频繁创建与销毁ByteBuf对象本身。
提到内存管理,你不妨回忆一下自己最开始接触到的操作系统是什么样子。 目前流行的操作系统的内存管理基本都是段页式思想,笼统地说就是系统会对内存进行分段管理,每一段又包含多个页。
我们这节课主要通过学习Netty的内存管理机制来学习内存编程的通用设计理念。
在Netty中,内存的管理采取区(Area)-块(chunk)-页(page)的管理方式。每个区包含一定数量的块,而块又由多个页构成。Netty的内存结构如下图所示:
之所以划分成区、块,主要是为了提高系统的并发能力。这里简单解释一下,因为内存是所有线程共享的,线程从块中申请内存或释放内存时必须加锁。否则容易导致一个块的内存同时分配给多个线程,造成数据错乱,程序异常。
也就是说,恰当地管理块中的内存是内存管理的重中之重。在上面这张示意图中,一个块包含了8页,那怎么对这些页进行管理,怎么表示这些页是已分配还是未分配,怎么根据分配情况快速找到合适的连续内存呢?
Netty的解决之道是将这些页映射到一颗完全二叉树上。它的映射方式大致是下图的样子。
在这里,一个块的内存是8页,也就是2的3次幂,我们把3称作maxOrder,maxOrder的值越大,一个块中包含的页就越多,管理的内存就越多。
Netty为了能够高效管理maxOrder的页,会将其映射到一颗完全二叉树上,每一个叶子节点代表一页,二叉树最后会完全存储在数组中。
具体的映射方法是:创建一个数组,长度为叶子节点的2倍,然后将完成二叉树按照每一层从左到右的顺序依次存储在数组中。注意,第一个节点要空出来,这样做的好处是根据数组下标能很方便地计算出父节点和两个子节点的下标。
具体的计算方法是:
- 如果节点的下标为n,则父节点的下标 n>>1,即 n/2。
- 如果父节点的下标为n,则其左节点下标 n << 1,即2n,右节点下标 n << 1 + 1,即2n+1。
完全二叉树映射到数组中,每一个元素存储的内容为该节点在二叉树中的深度,也就是上图中的order信息。为了统一深度的定义,我们默认根节点的深度为0(order=0)。也就是说,根节点存储在array[1] = 0中,根节点的两个子节点分别存在array[2]和array[3]中,并且它们的值都为1,其他节点以此类推。
这种存储方式能够清晰地让我们看到这个节点能一次分配的最大内存。如果一个节点在数组中存储的值为n,那么从该节点出发,能找到的叶子节点的个数为2的 (maxOrder-n)的幂,而每一个叶子节点表示一页,所以能分配到的最大内存是 2 的 (maxOrder-n)的幂 * 每页大小(pageSize)。
基于这个存储结构,Netty就可以方便地进行内存分配了。我们来看下具体的步骤。
首先,我们需要申请8K的内容,从根节点出发,找到第一个可分配的节点,整个查找过程如下图。
从根节点开始,优先遍历左子树,然后再遍历右子树,找到满足当前分配需求的最小节点,即从根节点,一直可以遍历到左边第一个叶子节点,将对应数组中的值array[8] 更新为 array[8] + 1 。在上面这个例子中,这个值从3变为了4。一旦存储的值大于maxOrder,表示该节点已被占用,无法继续分配内存。
与此同时,我们还要从当前节点向上遍历二叉树,依次通过n >> 1找到当前节点的父节点的下标, 将array[4]、array[2]、array[1]中存储的值依次加一。
这样,我们就完成了一次内存申请,在此基础上,如果我们需要再申请16K也就是2页的内存大小。查找过程如下图所示:
这里,我们还是从根节点开始遍历,当遍历到第一个左节点时,其存储的值为2,并且它的左节点存储3,右节点为2,因为这一次我们需要申请2页内存,左节点存储的值为3,能分配到的内存为2的(maxOrder[3]-order[3]),最终得出为1,即左节点只能分配1页的大小,故最终会定位它到右节点。从而将其右节点设置为(maxOrder+1),表示已分配,然后依次遍历父节点,其值加1。如果想要申请更多内存的话,重复上述步骤即可。
从这里也可以看出,内存的申请流程,基本都是从根节点开始遍历,先遍历左子树、然后遍历右子树,找到第一个大于指定内存最小内存的节点,把它设置为已分配,然后依次找到父节点并将相应在数组中的值减1。
内存的释放和内存的申请刚好相反。我们要从释放节点向上遍历,给数组中存储的值减1,因为这部分内容比较简单,这里我们就不展开讨论了。
文件编程
介绍完内存管理,我们再来看看如何基于文件进行高效编程。
之所以要讲文件编程,是因为文件可以比内存提供更加廉价、更大容量的存储。而且内存存储的时效性比较短,电脑关机后数据就会丢失。不过,虽然我说了文件存储这么多优点,但我还是得客观一点,毕竟它还是有缺点的。比方说,在性能上文件存储就远低于内存。
我们来看看怎么基于文件存储写出高性能的程序。
首先,我们需要为存储的内容设计存储协议。以消息中间件为例,RokcetMQ中消息的存储格式如下图所示:
从这张图里,我们可以看到文件存储设计的三个要点,也就是长度字段、魔数和CRC校验码。
- 长度字段
指的是存储一条消息的长度。这个字段通常使用一个定长字段来存储。比方说,一个字段有4个字节,那一条消息的最大长度为2的32次幂。有了长度字段,就能标识一条消息总共包含多少个字节,用len表示,然后我们在查找消息时只需要从消息的开始位置连续读取len个字节就可以提取一条完整独立的消息。 - 魔数
魔数不是强制的设计,设计它的目的是希望能够快速判断我们是否需要这些文件,通常情况下,魔数会取一个不太常用的值。 - CRC校验码
它是一种循环冗余校验码,用于校验数据的正确性。消息存储到磁盘之前,对消息的主体内容计算CRC,然后存储到文件中。当从磁盘读取一条消息时,可以再次对读取的内容计算一次CRC,如果两次计算的结果不一样,说明数据已被破坏。
学到这里我猜你已经发现了,这个文件存储协议的设计理念和网络编程领域的通信协议设计有着异曲同工之妙。对头,这个文件存储协议的设计基本也遵循 Header+Body 的结构,并且Header长度固定,并且包含长度字段。
不过,文件存储协议和通信协议有一个非常关键的区别,那就是:文件存储协议必须设计校验和字段,但通信协议不需要。数据存储在这是因为磁盘文件中,数据并不可靠,发生错误的概率比较大。而网络通讯协议在网络传输底层有相应的应对机制,能够及时发现错误并重发,从而确保数据传输的正确性。
好了,说回文件存储。解决了存储格式的问题,接下来就要考虑怎么从文件中检索消息了。
就像关系型数据库会为每一条数据引入一个 ID 字段一样,基于文件编程的模型也会为每条数据引入一个身份标志:起始偏移量,也就是数据存储在文件的起始位置。
起始偏移量的设计如下图所示:
通过起始偏移量 + SIZE,要从文件中提取一条完整的消息就轻而易举了。
我们在查询数据时,往往需要从多维度展开。以数据库查询为例,一个order表包含主键ID、订单编号、创建时间等字段,我们不仅可以通过主键ID进行查询,还可以通过订单编号进行检索。
但是,如果订单表中的数据不断增加,根据订单编号查询订单数据变得越来越慢,这个时候我们该怎么优化呢?
答案是,为订单编号建立索引。
所谓的索引,就是将需要检索的内容(订单编号)与主键ID进行关联,在检索时,我们是先找到主键ID,然后根据主键ID就能快速定位到内容,从而提升性能了。它的原理你可以参考一下下面这张图片。
数据库作为一个基于文件编程的系统,就可以通过建立索引来提升检索性能。但由于是用C语言编程的,深入探讨比较困难。这时候,RocketMQ的存储就给我们演示了索引的设计方法。
在RocketMQ中,所有的原始消息会按照它们到达RocketMQ服务器的顺序存储到Commitlog文件中,但消息消费时需要根据主题进行消费。也就是说,我们需要按照主题查找消息。上面我们也说过了,包括RocketMQ在内,基于文件的编程模型只有根据起始偏移量才能快速找到消息,为了提升根据主题检索消息的效率,需要为主题建立索引。
RocketMQ具体的做法是,为每一个主题、队列创建不同的文件夹,例如/topic/queue。然后,在该文件夹下再创建多个索引文件,每一个索引文件中存储数据的格式为:8字节的起始偏移量、4字节的数据长度、8字节的tag哈希值。如下图所示:
结合RocketMQ索引文件的构建规则,我们可以得出下面两个设计索引的关键点。
- 为了通过索引项快速查询到数据,索引项中包含了起始偏移量,并且为了支持快速根据tag进行过滤,索引项中也包含了tag的信息。
- 为了保证索引项的检索效率,索引项本身的查找机制必须非常高效。RocketMQ是根据主题、队列、消费进度三者快速找到消息的,所以索引项的设计借鉴了数组思想,将主题索引项设计为固定长度。
索引机制解决了文件层面的检索问题,但索引最后也是存储在文件中,索引自身的性能是没法提升的。为了提升访问文件的性能,我们还会使用另外一种优化手段:内存映射机制。
什么是内存映射机制呢?一言以蔽之,就是将文件直接映射到内存中,将直接操作文件的方式用操作内存的方式进行替换,从而提升性能。
在写入数据时,我们不是直接调用文件API,而是将数据先写入到内存,然后再根据不同的策略,将数据从内存中再刷写到文件。
在Java中,我们可以通过下面这段代码启动内存映射机制:
FileChannel fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
MappedByteBuffer mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
在 Linux 操作系统中,MappedByteBuffer 基本可以看成是页缓存(PageCache)。Linux 操作系统的内存使用策略是,最大可能地利用机器的物理内存并常驻在内存中,这就是所谓的页缓存。
只有当操作系统的内存不够时,我们才会采用缓存置换算法。例如,LRU会将不常用的页缓存回收,也就是说操作系统会自动管理这部分内存,无须使用者关心。如果从页缓存查询数据时未命中,会产生缺页中断,这时候操作系统自动将文件中的内容加载到页缓存。
将文件映射到内存中,数据写入时只是先将数据存储到内存,但这部分数据还没有真正写入到磁盘,需要采取一定的策略将内存中的数据同步刷写到磁盘中。我们知道,机器重启后会造成数据丢失,在平衡性能和数据可靠性时,通常会衍生出下面两种不同的策略。
- 同步刷盘。数据写入到内存后,需要立即将内存数据写入到磁盘,然后才向客户端返回“写入成功”。这会牺牲性能,但可以保证数据不丢失。
- 异步刷盘。数据写入到内存后,会立即向客户端返回“写入成功”,然后异步将内存中的数据刷写到磁盘。
“刷盘”这个名词是不是听起来很高大上,其实它并不是一个什么神秘高深的词语。所谓刷盘,就是将内存中的数据同步到磁盘,在代码层面其实是调用了 FileChannel 或 MappedBytebuffer 的 force 方法。
RocketMQ中实现MappedFile的刷盘的代码如下:
public int flush(final int flushLeastPages) {
if (this.isAbleToFlush(flushLeastPages)) {
if (this.hold()) {
int value = getReadPosition();
try {
//We only append data to fileChannel or mappedByteBuffer, never both.
if (writeBuffer != null || this.fileChannel.position() != 0) {
this.fileChannel.force(false);
} else {
this.mappedByteBuffer.force();
}
} catch (Throwable e) {
log.error("Error occurred when force data to disk.", e);
}
this.flushedPosition.set(value);
this.release();
} else {
log.warn("in flush, hold failed, flush offset = " + this.flushedPosition.get());
this.flushedPosition.set(getReadPosition());
}
}
return this.getFlushedPosition();
}
总之,无论是同步刷盘还是异步刷盘,最终都是调用文件存储设备的写入API。目前,文件的存储媒介还是以机械硬盘为主,机械硬盘在写入数据之前,需要先进行磁道寻址,如果写入磁盘的数据位置比较随机,那么寻址需要花费的时间也会相应增多,所以业界又引入了一种设计思想:文件顺序写机制。
文件顺序写的设计理念应用非常广泛,数据库领域的redo日志的底层就运用了顺序写机制。你可以先通过这张图理解一下它的运作机制。
一个数据库中有很多表,每一张表都存储了很多数据,这些数据分布在磁盘不同的区域,而且用户在更新数据时也很分散。例如他们会时而更新订单表,时而更新用户表,那该怎么优化呢?
我们以MySQL为例。MySQL中的InnoDB引擎是首先维护一个内存池,同样使用内存映射机制将磁盘中的文件映射到内存,用户的更新操作会首先更新内存。但我们知道,对于关系数据库来说,数据不丢失是一个硬性需求,但如果为了确保这一点采用同步刷盘将数据写入到磁盘,又必然是一个随机写的过程,无法满足性能要求。
为此,InnoDB引入了一个redo文件。这样,数据写入到内存后,会先同步刷盘到redo文件,但写入redo文件是一个不断追加的过程,也就是说先顺序写入redo文件,然后再异步将内存中的数据刷写到各个表中。这样,就算MySQL宕机等原因导致内存中的数据丢失,还是可以通过回放redo文件将数据恢复回来。这就在保证数据库不丢失的情况下,提升了性能。
总结
这节课就讲到这里。刚才,我们从中间件开发的视角入手,简单介绍了中间件开发工程师必须具备的基础技能。我还重点介绍了内存管理、文件管理的一些编程技巧。
内存管理主要包括内存的分配与内存的回收,我们可以通过进程管理内存,从而减少垃圾回收的发生频率,提升系统运行稳定性。
文件编程领域首先要解决的就是数据以什么格式存储在文件中,然后再通过引入索引、内存映射、同步刷盘、异步刷盘、顺序写等手段优化文件访问的性能。
希望学完这节课,你能更深入地认识一些业务开发领域平时不怎么关注,甚至看起来有点高大上的功能。当然更重要的是,以此为起点,展开对中间件更深的探索。如果你有什么其他的疑问和发现,也欢迎随时跟我沟通。
课后题
最后,给你留一道思考题吧。
有人说,在Netty的内存分配机制中,数组中存储的值代表该节点当前拥有的剩余内存。你觉得这句话对吗,为什么?
欢迎在留言区写下你的想法。我们下节课再见。
- kobe 👍(3) 💬(2)
大佬,我想问两个问题: 1.怎么能把自己感兴趣的一两个中间件了解的比较精通啊? 2.没有做中间件开发的经验,可以去面试基础架构/中间件开发相关的工作岗位吗?
2022-07-01 - 雨落~紫竹 👍(1) 💬(1)
所有的高性能 总结3类方法 就是 异步 缓存 分布式 mq 的设计 基本也类似 尤其是和kafka对比 基本都类似
2022-07-04 - William Ning 👍(1) 💬(0)
管理块中的内存的图,没看明白,还需要再看下,消化下~~
2022-07-13 - Geek_fb975d 👍(0) 💬(0)
这里,我们还是从根节点开始遍历,当遍历到第一个左节点时,其存储的值为 2,并且它的左节点存储 3,右节点为 2,因为这一次我们需要申请 2 页内存,左节点存储的值为 3,能分配到的内存为 2 的 (maxOrder[3]-order[3]),最终得出为 1,即左节点只能分配 1 页的大小,故最终会定位它到右节点。从而将其右节点设置为 (maxOrder+1),表示已分配,然后依次遍历父节点,其值加 1。如果想要申请更多内存的话,重复上述步骤即可。 这个(maxOrder[3]-order[3])咋算的了
2023-05-04 - Ray 👍(0) 💬(0)
这水平也是尴尬的
2023-04-01 - kylexy_0817 👍(0) 💬(0)
“一个节点在数组中存储的值为 n”,这里的n是指节点所在树的深度,还是存储容量的大小,抑或是内存的起始结束段?这里开始就没太看懂了。
2022-12-11 - Geek_87522c 👍(0) 💬(0)
关于Netty内存分配的那段,我有点疑问。 假如第二次仍然申请一页,则会遍历到9号节点。此时9号节点的父节点是4号节点,array[4] += 1,这是没有问题的。但是就不应该继续往上遍历4号节点的父节点并对array自增1了。因为往上的节点,可分配的最大内存数已经不受影响了(因为之前都是左子树的内存被分配,array成员已经增过1了)。 个人觉得,当一个节点被分配出去之后,除了应该对其父节点对应的array成员自增1之外,还应该做个检查: 如果本节点的父节点的另一个子节点(也就是本节点的兄弟节点)或其子节点没有被分配出去,那么说明本节点的分配,影响了父节点的父节点的最大一次性可分配页数,此时需要继续递归父节点,其对应的array成员自增1。 反之,说明本节点的分配,不影响父节点的父节点的最大可分配数,递归终止。 换言之,不是所有的内存节点被分配时,都要遍历父节点并将其对应的array成员自增1,而是要看是否真的对父节点的可分配数有影响。 不知道我的理解是否有问题?
2022-12-08 - open!? 👍(0) 💬(0)
存的是层数+左右节点已分配的?
2022-11-24