架构革新:揭示卓越性能与高可扩展的共赢秘诀

简介: 为了构建现代化的可观测数据采集器LoongCollector,iLogtail启动架构通用化升级,旨在提供高可靠、高可扩展和高性能的实时数据采集和计算服务。然而,通用化的过程总会伴随性能劣化,本文重点介绍LoongCollector的性能优化之路,并对通用化和高性能之间的平衡给出见解。

1.引言

从iLogtail诞生至今已走过了10多个年头,伴随着云原生和可观测性概念的逐步推广,iLogtail原本主要针对日志采集的架构已经无法满足多样化的数据采集和处理需求。为了打造统一的可观测Agent,构建集高可靠、高性能和灵活性于一体的采集器,iLogtail启动架构通用化升级,旨在重新构建新一代的可观测数据实时采集和处理引擎,并将升级后的产品给予新的品牌命名:LoongCollector。


然而,在现实场景下,架构通用化升级的背后,往往伴随着性能劣化的副作用。如何抽丝剥茧,通过对代码和业务场景的不断深入理解,逐步改善性能甚至超越原有架构便成为了一个重要的课题,而这也是本文的重点内容。为了便于读者能够更好地理解相关内容,在展开性能优化之路之前,我们首先简单介绍一下LoongCollector的架构升级概况以及一些关键的数据结构。


2.架构升级:通用化重构

iLogtail由C++主程序和Golang插件系统构成。由于历史原因,在iLogtail时代,C++部分的能力仅为采集和解析日志文件并发送至SLS后端,整体架构如下所示:

然而,从现代可观测数据采集器的角度,iLogtail的架构已经无法满足演进需求:

  • 数据源不再局限于日志场景,Prometheus抓取和eBPF等场景同样需要高性能的采集和处理能力;
  • 对于日志场景,用户同时需要处理的多样性和高性能;
  • SPL的引入必须在C++主程序实现;

基于此,必须对iLogtail进行整体的架构升级,尤其是C++主程序部分,通过引入流水线的概念,将输入、处理和输出能力彻底插件化,支持能力的自由组合,从而满足上述需求。有关架构升级的部分内容,可参考《破浪前行:iLogtail十年老架构如何浴火重生》 ,本文仅对新架构进行简单介绍。

2.1流水线

在LoongCollector中,每一个采集任务都对应了一个采集配置,它描述了如何采集、处理和发送所需的可观测数据。在代码实现上,每一个配置都对应于内存中的一条流水线,其通用形态如下所示:

可以看到,每条流水线可包含任意多个输入、处理和输出插件,分别用于采集、处理和发送可观测数据。由于LoongCollector支持多租场景,因此同一个LoongCollector实例支持同时运行多个采集任务,即同时存在多条流水线。


那如何高效运行这些流水线呢?LoongCollector采用总线模式,按功能划分线程。具体来说,根据流水线的形态,LoongCollector共有三大工作线程:Input Runner线程、Processor Runner线程和Flusher Runner线程,分别负责运行所有流水线的输入插件、处理插件和输出插件,各个线程之间通过缓冲队列进行连接。为了保证流水线之间的公平性和隔离性,LoongCollector进一步采用如下设计:


  • 在每一个工作线程内,每一条流水线都按照优先级分配了相应的时间片;
  • 每个流水线都拥有自己独立的处理和发送队列。


基于上述描述,LoongCollector的总线模式示意图如下:

2.2数据模型

在LoongCollector中,流转于各个组件之间最基本的数据单位称为事件(Event)。事件可根据类型进一步分为Log事件、Metric事件和Span事件,分别对应可观测数据中的Log、Metric和Trace。本文以Log事件作为例子,其定义如下:


class LogEvent: public PipelineEvent {
private:
    std::map<std::string, std::string> mContents;
}

多个具有共性的事件可进一步打包成为事件组(Event Group),事件共性的部分以元信息的形式存放在事件组中,其定义如下:


class PipelineEventGroup {
private:
    std::map<std::string, std::string> mTags; 
    std::vector<std::unique_ptr<PipelineEvent>> mEvents;
}


3.性能劣化:通用化的必然结果?

当我们完成了整个LoongCollector的通用化架构升级之后,一个必不可缺的环节就是测试采集性能。为此,我们采用如下的测试场景:

  • 环境:主机
  • 采集任务:采集一份持续生成的单行日志,日志生成速率为1GB/s,采集配置如下:
inputs:
  - Type: input_file
    FilePaths: ["/path/to/file.log"]
flushers:
  - Type: flusher_sls
    Project: test_project
    Logstore: test_logstore
  • LoongCollector系统参数:
  • Processor Runner线程数:1
  • 单次文件读取最大值:512K
  • 最大发送流量限制:无上限

我们测试架构升级前后的性能变化,结果如下:

从表里可以看到,在高输入负载的情况下,架构升级后的采集性能下降了约15%。虽然不愿看到这样的结果,但是确实在预期范围内。

那这个情况不可改变吗?答案显然是否定的。


4.突破瓶颈:性能飞升的秘籍

为了分析LoongCollector的性能瓶颈,需要对LoongCollector进行profiling。由于LoongCollector已经使用了tcmalloc,因此使用gperftools来分析CPU和内存使用情况是一个最佳选择。

可以看到,作为一个实时流计算的引擎,流转于各个组件之间的PipelineEventGroup是一个高频对象。因此,PipelineEventGroup的实现在一定程度上决定了LoongCollector的性能。因此,我们的性能优化将重点围绕PipelineEventGroup展开。

4.1Step 1:Memory Arena

从PipelineEventGroup和PipelineEvent的原始定义可以看到,这些数据结构中包含了大量的字符串,这带来了两大问题:

  • 大量高频的字符串创建和销毁,会显著影响性能;
  • 对于日志解析场景,解析后的文本内容会被重复拷贝,造成大量不必要的浪费;


例:假设原始日志内容为2025-01-01 10:00:00 [INFO] test message,现在对该条日志进行正则提取,正则表达式为:(\d+-\d+-\d+\s+\d+:\d+:\d+)\s+\[(\S+)]\s+(.*),提取的key为time,level,msg,则提取后的LogEvent中包含如下内容:(time:2025-01-01 10:00:00)(level:INFO)(msg:test message)。


显然,我们在提取的时候,几乎将原始字符串完整复制了一份。如果我们能够引用原始的字符串片段,那么这个复制理论上是可以完全可以避免的。


为了解决这一问题,我们可以为PipelineEventGroup引入内存池的概念,即每个Group拥有一个内存池,Group内所有的字符串内存分配均在这个池上进行操作。相应地,Group内所有涉及string的变量类型都改成对于内存池某个片段的引用,即string_view。修改后的PipelineEventGroup定义如下:


class PipelineEventGroup {
private:
    std::map<std::string_view, std::string_view> mTags; 
    std::vector<std::unique_ptr<PipelineEvent>> mEvents;
    std::shared_ptr<SourceBuffer> mSourceBuffer;
}

其中,SourceBuffer是一个自定义实现的内存分配器,可根据请求的大小在内存池上分配合适的空间,并返回相应的引用。


相应地,为了能够直接在内存池上分配空间,PipelineEvent内部也需要共享同一个内存池:


class LogEvent : public PipelineEvent {
private:
    std::map<std::string_view, std::string_view> mContents;
    std::shared_ptr<SourceBuffer> mSourceBuffer;
}

现在回到上面的例子,在引入了内存池以后,解析过程完全不涉及任何字符串复制操作,LogEvent的mContents字段只需要存储原始字符串片段的引用即可。

4.2Step 2:避免shared_ptr

通过观察PipelineEventGroup的定义不难发现,PipelineEvent和PipelineEventGroup之间并没有强绑定关系。换言之,一个PipelineEvent完全可以从Group 1挪到Group 2,但是它所引用的内存池仍然是Group 1的。显然,这种设计非常灵活,但是带来的副作用也是很明显的:


由于内存池是通过shared_ptr包装的,因此每一个PipelineEvent生成和析构的时候,都会涉及shared_ptr的构造和析构。而shared_ptr内部是有引用计数的,而且该引用计数是一个原子变量,因此频繁地创建和销毁会产生不必要的cpu开销。


那么一个自然的问题是:我们需不需要这种灵活性?答案显然是否定的。既然如此,那我们可以作出如下假定:一个PipelineEvent一定是从属于某个PipelineEventGroup。基于此,我们可以将PipelineEvent里的mSourceBuffer成员替换成指向所属PipelineEventGroup的指针,通过这个指针去间接访问所属的内存池。


修改后的LogEvent定义如下所示:

class LogEvent : public PipelineEvent {
private:
    std::map<std::string_view, std::string_view> mContents;
    PipelineEventGroup* mPipelineEventGroupPtr;
}

优化前后的示意图如下:

如此一来,只有PipelineEventGroup创建和销毁时才涉及shared_ptr的操作。考虑到一个Group内往往存放成百上千的Event,这种性能提升是非常明显的。实验也证明,结合当前步骤与第一步的优化,LoongCollector的采集性能已经超越iLogtail的性能。


细心的读者可能会问,既然现在只有PipelineEventGroup持有内存池,考虑到unique_ptr的性能是优于shared_ptr的,那么是否可以直接将管理内存池的shared_ptr替换成unique_ptr?答案依然是否定的,由于在发送阶段涉及路由和拆包等操作,因此在发送阶段可能存在多个PipelineEventGroup都共享某个内存池的操作,因此只能使用shared_ptr。

4.3Step 3:事件池

虽然经过前两步的优化,LoongCollector的采集性能已经优于iLogtail,但这是否就是性能上限呢?


我们前面提到,PipelineEvent是一个高频数据结构,存在大量的构造和销毁操作。从CPU profile的结果也能发现,与此相关的cpu开销占到总体的10%。因此,一个自然的想法就是对事件进行池化,即维护一个事件池,每次新建事件时直接从池中获取而非从零构建,事件销毁的时候直接归还到池中而非直接析构。


然而,考虑到应用场景的复杂性,为了尽可能减少额外引入的开销,并不能简单地维护一个全局的事件池。根据事件申请和释放的位置不同,大致可以分为以下两个场景:


  • 场景一:Processor Runner线程生成事件,Processor Runner线程释放事件
  • 场景二:Input Runner线程生成事件,Processor Runner线程释放事件

针对这两个场景,需要分别采用不同的策略来应对。


场景一

这个场景相对简单,每个Processor Runner线程都维护一个自己的事件池,申请事件时从当前线程的事件池中申请,归还时直接归还到当前线程的事件池中。由于同一个事件池只有一个线程会访问,因此该事件池是无锁的。另外值得注意的是,由于Processor Runner线程可能有多个,因此事件申请和归还的事件池不一定是同一个,但这不影响事件池的无锁性。

场景二

显然,在当前场景下,如果继续沿用场景一的策略,会直接失效。这是因为Input Runner线程只负责生产事件,不存在归还事件的可能。为此,当前场景的合理策略为,Processor Runner线程在归还事件时,应该将该事件归还到对应的Input Runner线程。因此,可以在Input Runner线程中维护一个事件池,Processor Runner线程将事件归还到该事件池中。


由于同一个事件池会有多个线程并发访问,对事件池进行加锁保护是一个必须的行为,但是这又会额外引入新的锁竞争开销。为了降低这个开销,可以做如下优化:


  • Processor Runner线程归还事件时,可以以PipelineEventGroup为单位一次性归还多个事件,减少事件池访问次数;
  • 采用双buffer策略,即同时维护两个事件池对,分别供Input Runner线程和Processor Runner线程使用。只有当Input Runner访问的事件池为空时,才将Processor Runner线程访问的事件池内容整体转移过去。

实验证明,通过引入事件池,整体采集性能能够进一步提高约15%。

4.4Step 4:直接序列化

与PipelineEventGroup有关的最后一个热点存在于发送阶段。显然,每个PipelineEventGroup都必须经过序列化成字节流后才能进行网络发送。以SLS后端为例,它接收的是LogGroup Protobuf结构。为此,最直接的方法当然是首先将PieplineEventGroup组装成LogGroup PB,再通过调用PB的Serialize方法得到最终的字节流。显然,group内的数据经过了两次拷贝,而中间LogGroup这次拷贝实质上是多余的。

那我们能否绕过中间这个临时对象的构造,直接将PipelineEventGroup的内容序列化为字符串?答案显然是肯定的。我们只需要按照Protobuf的协议,将PipelineEventGroup中的内容依次写入到结果字符串中即可。

具体细节这里不再赘述,有关Protobuf协议的具体内容看参考Protobuf的编码:https://protobuf.dev/programming-guides/encoding/

4.最终效果

经过上述4轮的优化,我们惊奇地发现,在高输入负载的场景下,LoongCollector单行日志采集的性能相比于iLogtail整整提升了100%!


5.更上一层楼:特定输入场景的优化

前文所描述的针对PipelineEventGroup的优化主要是框架层面的优化,由于LoongCollector是一个拥有较强可扩展性的数据采集和处理系统,因此每一种输入类型都可以拥有自己的插件。前文我们始终以文件采集场景为例进行性能比对,而文件采集本身就是LoongCollector的一个重要的输入场景。随着OneAgent的发展,有越来越多的输入插件陆续集成到LoongCollector中,这些插件的实现本身是独立于引擎的,但是想要获得较优的采集性能,就必须针对框架的特性进行调优。作为示例,本文以Prometheus抓取场景为例,简单介绍如何针对特定输入场景进行性能优化。

5.1避免内存拷贝

在Prometheus抓取场景中,输入插件会首先通过网络请求抓取指标文本,然后对指标文本按行切分,最后对指标文本行进行解析生成最终的Metric事件。由于解析文本行的操作是在Processor Runner线程中进行,因此原始文本行必须以PipelineEvent的形式传递给线程。显然,从语义的角度,Log事件是一个比较合适的载体。

在LoongCollector中,我们使用libcurl来完成网络请求,默认情况下,网络请求返回的内容(即指标文本)是保存在一个字符串中的,请求成功后将该字符串返回给调用方(即Prometheus输入插件)。输入插件获得指标文本字符串后,再生成Log事件以及后续操作。


这里不难发现,为了将指标文本转换成Log事件,我们必须将整个指标文本的内容拷贝到PipelineEventGroup的内存池中,显然,这个拷贝是多余的。对于单次抓取能达到几十MB的target,会产生大量的无效拷贝。


如何避免不必要的字符串拷贝呢?我们可以借用前文所描述的优化序列化性能的方法,让licurl直接将网络请求的结果写到PipelineEventGroup的内存池中,这样一来就完美避免了内存拷贝。为此,可以为该输入场景定制libcurl的数据回调函数,直接将事先构建好的PipelineEventGroup作为参数传给libcurl,就能实现该目的。

5.2流式处理

作为一个流处理系统,LoongCollector各条流水线中驻留的数据量很大程度决定了RSS的大小。进一步地,每一条流水线主要贮存数据的位置就在两个缓冲队列中。虽然缓冲队列是有容量限制的,但是该容量是以PipelineEventGroup为单位计算的,而PipelineEventGroup内承载的PipelineEvent数量并没有规定上限。因此,如果单个PipelineEventGroup内的事件数量过多必然会导致该条流水线占用的内存升高,进而导致LoongCollector整体内存升高。


回到Prometheus抓取场景,默认情况下,输入插件会等单次网络请求结束后再对文本进行处理。那么对于单次抓取文本量可达几十MB的target,必然会导致驻留数据量增加,从而增大内存。显然,我们可以引入流式处理来缓解这一问题。


具体来说,在上一节提到的libcurl回调函数中,我们可以对抓取到的指标文本进行实时地按行切分生成Log事件,当PipelineEventGroup的大小达到指定阈值时,直接将其放到处理队列中,然后重新生成一个新的PipelineEventGroup用于承载后续的指标。通过这个方式,可以限定每一个PipelineEventGroup的大小,将原本超大的Group拆分成多个小的Group,从而显著降低内存占用。

5.3最终效果

对于Prometheus抓取,我们设定一个通用负载场景,即单个LoongCollector副本负责抓取1000 Targets。在这个背景下,我们对比了LoongCollector和VMAgent的采集性能,结果如下:

可以看到,相比于VMAgent,LoongCollector在内存和CPU性能上占有明显优势,由此也证明了上述优化对于降低内存的作用。


6.尾声

随着LoongCollector的正式上线,当前阶段的性能优化也暂告一个段落。显然,LoongCollector的性能上限不止于此,在一些插件的内部实现上仍然有较多的提升空间,包括但不限于一些基础数据结构如map的更优实现替换、锁粒度的下沉、容器场景元信息的共享等。


通过本次性能调优的过程,也充分认识到,代码实现的通用性与软件的高性能之间并不是一个非此即彼的关系,通过不断的profiling和对业务场景的深入理解,是完全可以在保证代码可扩展性的同时达到一个较高的性能水准的。当然,从可发展的角度来说,代码的可扩展性应当永远优先于性能的,如果为了追求极致性能而破坏代码的可读性和通用性,除非是特定应用领域,不然就是得不偿失。因此,如何在二者之间找到平衡,考验每一位代码开发者的智慧和经验。



来源  |  阿里云开发者公众号

作者  |  笃敏

目录
打赏
0
8
8
0
2734
分享
相关文章
vivo 湖仓架构的性能提升之旅
聚焦 vivo 大数据多维分析面临的挑战、StarRocks 落地方案及应用收益。 在 **即席分析** 场景,StarRocks 使用占比达 70%,查询速度提升 3 倍,P50 耗时从 63.77 秒缩短至 22.30 秒,查询成功率接近 98%。 在 **敏捷 BI** 领域,StarRocks 已完成 25% 切换,月均查询成功数超 25 万,P90 查询时长缩短至 5 秒,相比 Presto 提升 75%。 在 **研发工具平台** 方面,StarRocks 支持准实时数据查询,数据可见性缩短至 3 分钟,查询加速使 P95 延迟降至 400 毫秒,开发效率提升 30%。
vivo 湖仓架构的性能提升之旅
云原生时代的架构革新,Apache Doris 存算分离如何实现弹性与性能双重提升
随着云基础设施的成熟,Apache Doris 3.0 正式支持了存算分离全新模式。基于这一架构,能够实现更低成本、极致弹性以及负载隔离。本文将介绍存算分离架构及其优势,并通过导入性能、查询性能、资源成本的测试,直观展现存算分离架构下的性能表现,为读者提供具体场景下的使用参考。
云原生时代的架构革新,Apache Doris 存算分离如何实现弹性与性能双重提升
阿里云服务器架构解析:从X86到高性能计算、异构计算等不同架构性能、适用场景及选择参考
当我们准备选购阿里云服务器时,阿里云提供了X86计算、ARM计算、GPU/FPGA/ASIC、弹性裸金属服务器以及高性能计算等多种架构,每种架构都有其独特的特点和适用场景。本文将详细解析这些架构的区别,探讨它们的主要特点和适用场景,并为用户提供选择云服务器架构的全面指南。
122 18
MeteoRA:多任务AI框架革新!动态切换+MoE架构,推理效率提升200%
MeteoRA 是南京大学推出的多任务嵌入框架,基于 LoRA 和 MoE 架构,支持动态任务切换与高效推理。
79 3
记忆层增强的 Transformer 架构:通过可训练键值存储提升 LLM 性能的创新方法
Meta研究团队开发的记忆层技术通过替换Transformer中的前馈网络(FFN),显著提升了大语言模型的性能。记忆层使用可训练的固定键值对,规模达百万级别,仅计算最相似的前k个键值,优化了计算效率。实验显示,记忆层使模型在事实准确性上提升超100%,且在代码生成和通用知识领域表现优异,媲美4倍计算资源训练的传统模型。这一创新对下一代AI架构的发展具有重要意义。
111 11
记忆层增强的 Transformer 架构:通过可训练键值存储提升 LLM 性能的创新方法
Java高级应用开发:基于AI的微服务架构优化与性能调优
在现代企业级应用开发中,微服务架构虽带来灵活性和可扩展性,但也增加了系统复杂性和性能瓶颈。本文探讨如何利用AI技术,特别是像DeepSeek这样的智能工具,优化Java微服务架构。AI通过智能分析系统运行数据,自动识别并解决性能瓶颈,优化服务拆分、通信方式及资源管理,实现高效性能调优,助力开发者设计更合理的微服务架构,迎接未来智能化开发的新时代。
AArch64架构调用链性能数据采集原理
本次分享的主题是AArch64架构调用链性能数据采集原理,由阿里云苏轩楠分享。主要分为五个部分: 1. 术语解释 2. Frame Pointer RegisterStack Unwind 3. Dwarf-based Stack Unwind 4. /BRBE/CSRE Stack Unwind 5. Kernel-space Stack Unwind&eBPF Unwinders
NeurIPS 2024 Oral:小参数,大作为!揭秘非对称 LoRA 架构的高效性能
近期,一篇题为《\model~: 非对称LoRA架构实现高效微调》的论文被NeurIPS 2024接收为口头报告,该研究提出了一种创新的非对称LoRA架构,旨在解决大型语言模型(LLMs)在保持高性能的同时提高训练和部署效率的问题。通过引入共享A矩阵和多个B矩阵,\model~不仅提高了参数效率,还在多个数据集上展示了超越现有PEFT方法的性能,尤其是在多任务域和复杂数据集上的表现尤为突出。此架构还有效减少了训练能耗和延迟,为LLMs的高效应用提供了新思路。
111 4
后端服务架构的微服务化转型
本文旨在探讨后端服务从单体架构向微服务架构转型的过程,分析微服务架构的优势和面临的挑战。文章首先介绍单体架构的局限性,然后详细阐述微服务架构的核心概念及其在现代软件开发中的应用。通过对比两种架构,指出微服务化转型的必要性和实施策略。最后,讨论了微服务架构实施过程中可能遇到的问题及解决方案。
云计算的未来:云原生架构与微服务的革命####
【10月更文挑战第21天】 随着企业数字化转型的加速,云原生技术正迅速成为IT行业的新宠。本文深入探讨了云原生架构的核心理念、关键技术如容器化和微服务的优势,以及如何通过这些技术实现高效、灵活且可扩展的现代应用开发。我们将揭示云原生如何重塑软件开发流程,提升业务敏捷性,并探索其对企业IT架构的深远影响。 ####
101 3

热门文章

最新文章