现代Kafka-API存储系统的每核线程缓存管理

2020-10-31 09:55:07

超标量CPU具有宽GB/s内存,NVMe访问时间在10-100微秒量级,这就需要为低延迟存储系统进行新的缓冲区管理。

正如我之前观察到的,软件不是基于类别理论运行的,它运行在超标量CPU上,具有宽的多通道Gb/s存储单元和10-100微秒量级的NVMe固态硬盘访问时间。十年前在不同的硬件平台上编写的一些软件感觉很慢的原因是它没有利用现代硬件的进步。

存储系统中的新瓶颈是CPU。SSD设备比旋转磁盘快100-1000倍,今天的价格比十年前便宜10倍[1],从每TB 2500美元降到200美元。从1Gbps到100Gbps,公共云中的网络吞吐量提高了100倍。

事实上,虽然计算机确实变得更快了,但单核速度大致保持不变。原因是CPU频率与功耗之间存在立方依赖关系,因此我们遇到了障碍。指令级并行性、预取、推测性执行、分支预测、数据高速缓存和指令高速缓存的深层层次结构等,使程序在与它们交互时感觉速度更快,但在数据中心,实质性的改进来自于内核数量的增加。虽然每个时钟的指令数比十年前增加了3倍,但内核数量增加了20倍。

这就是说,随手可得的多核心系统的兴起需要一种不同的基础设施建设方法。案例[9]:为了充分利用AWS上i3en.Metal上的96个vCPU,您需要找到一种方法来利用3.1 GHz的持续CPU时钟速度、60 TB的总NVMe实例存储、768 GiB的内存以及能够以4 KB块大小提供高达200万随机IOPS的NVMe设备。这种野兽需要一种新的存储引擎和线程模型来利用这些硬件的进步。

Redpanda--一个适用于任务关键型工作负载的Kafka-API兼容系统[3]--解决了所有这些问题。它使用具有结构化消息传递(SMP)的每核线程架构来在这些固定的线程之间通信。线程化是任何应用程序的基本决策,无论您是使用线程池、通过单生产者单消费者SPSC[7]队列网络固定的线程,还是任何其他高级安全内存回收(SMR)技术,线程都是您的环-0,是应用程序的真正内核。它会告诉你你对阻塞的敏感度是什么--对于小熊猫来说,这个敏感度小于500微秒-否则,Seastar的[4]反应堆将打印堆栈跟踪,警告你阻塞,因为它有效地增加了网络轮询器的延迟。

一旦您决定了您的线程模型,下一步就是您的内存模型,最终对于存储引擎而言,是您的缓冲区管理。在这篇文章中,我们将讨论每核线程环境中缓冲区管理的危险,并描述我们在Seastar世界中的零拷贝内存管理解决方案iobuf。

如前所述,redpanda在每个核心架构中使用单个固定线程来执行所有操作。网络轮询、向内核提交异步IO、获取事件、触发计时器、安排计算任务等等。从结构上讲,这意味着没有任何东西可以阻塞超过500微秒,否则您将在堆栈的其他部分引入延迟。这是一个令人难以置信的严格编程范例,但是这种固执己见的想法迫使一个真正的异步系统,无论您作为程序员是否喜欢它。

TPC(每核线程)体系结构[8]中的挑战是核心之间的所有通信都是显式的。这迫使程序员通过互斥锁实现更偏爱核心局部性(d-cache、i-cache)而不是直接的多线程实现的算法。这一要求必须与基于未来<;>;的实现的异步性共同设计。

对于我们的Kafka-API实现(如图1所示),我们显式地交换内存使用量,以通过具体化关键组件来减少延迟并提高吞吐量。元数据高速缓存在每个核心上被具体化,因为每个请求都必须知道该分区是否存在,并且该特定机器实际上是该分区的领导者。分区路由器维护机器上哪个逻辑核心实际拥有底层Kafka分区的映射。访问控制列表(Access Control List,ACL)等其他内容会推迟到请求到达目标内核,因为它们可能会占用大量内存。我们没有严格的规则来确定我们在每个核心上实现的内容与目标核心延迟实现的内容,而且通常取决于内存(较小的数据结构很适合用于广播)、计算(决定花费多少时间)和访问频率(很可能操作往往会在每个核心上实现)。

剩下的一个问题是,内存管理在TPC架构中究竟是如何工作的?在完全异步执行模型中,数据如何使用SPSC队列网络安全地从L-CORE-0传输到L-CORE-66呢?在完全异步执行模型中,可以在任何时间点挂起数据。

要理解iobuf,我们需要了解我们的TPC框架Seastar的实际内存限制。在程序引导过程中,Seastar会分配计算机的全部内存,并将其平均分配给所有内核。它会咨询硬件以了解哪个内存属于每个特定的内核,从而减少到主内存的内核间流量。

如图2所示,在core-0上分配的内存必须在core-0上解除分配。然而,无法保证连接到redpanda的Java或Go客户端实际上会与拥有数据的确切核心进行通信。

在其核心,iobuf是一个具有延迟删除的引用计数的分段缓冲链,它允许redpanda在碎片传入时简单地共享远程核心的已解析消息的视图,而不会招致复制开销。

碎片化的缓冲区抽象并不是什么新鲜事。Linux内核的sk_buff[5]和FreeBSD内核的mbuf[6]大致相似。Iobuf的另一个扩展是它在TCP模型中工作,它利用Seastar的SPSC队列网络进行适当的删除,此外还能够任意共享子视图,这些子视图是为类似存储的工作负载量身定做的。

删除C++模板、分配器、池、指针缓存等,可以认为iobuf等同于:

Struct{void*data;size_t ref_count;size_t acity;size_t size;Fragment*Next;//列出片段*prev;}struct{Fragment*Head;};

Iobuf的起源源于我们为任务关键型系统构建Kafka®替代品的核心产品原则之一-让用户将大多数工作负载的尾部延迟缩短为原来的1/10。除了每核线程架构之外,如果没有从头开始设计延迟,内存管理将成为我们的第二个瓶颈。在长时间运行的存储系统上,内存碎片是一个真正的问题,最终要么会遇到适当的解决方案(Iobuf),要么会停滞,要么会出现OOM。

与它的前身skbuff和mbuff一样,iobuf允许我们使用可预测的内存大小优化和训练内存分配器。下面是我们的iobuf分配表逻辑:

Struct{静态常量expr size_t max_chunk_size=128*1024;静态常量expr size_t default_chunk_size=512;//>;>;x=512//>;>;>;而x<;int((1024*128))://...。打印(X)//...。X=int(x*3)+1)/2)//...。X=int(min(1024x128,x))//print(1024x128)静态常量expr std::array<;uint32_t,15>;alloc_table=//上面的python脚本{{5127681152 17282592388858328748,13122,19683,29525,44288,66432,99648,131072}};static size_t NEXT_ALLOCATION_SIZE(SIZE_T DATA_SIZE);};

可预测性、内存池、固定大小、大小上限、分段遍历等都是减少延迟的已知技术。请求连续且大小可变的内存可能会导致分配器压缩所有区域,并为可能是短暂的请求重新洗牌大量字节,这不仅会给请求路径带来延迟,而且会给整个系统带来延迟,因为我们只有一个线程执行所有操作。

硬件就是平台。当我们要求网络层为我们提供11225字节的连续内存时,我们只是要求分配器将该大小的空缓冲区线性化,并要求网络层将来自硬件的碎片复制到目的缓冲区中。当试图挤压硬件的每一盎司性能时,最终都不会有免费的午餐,而且通常需要从零开始重新设计。

如果你走到这一步,我鼓励你注册我们的社区松弛(这里!)。并直接向我们提问或在Twitter上通过@Vector torizedio或亲自发送电子邮件至@emaxerrno与我们联系。

特别感谢我们的莎拉、诺亚、本、大卫、米哈尔以及我们的外部评论员马克·帕帕达基斯和特拉维斯·唐斯审阅了这篇文章的早期草稿。