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和对业务场景的深入理解,是完全可以在保证代码可扩展性的同时达到一个较高的性能水准的。当然,从可发展的角度来说,代码的可扩展性应当永远优先于性能的,如果为了追求极致性能而破坏代码的可读性和通用性,除非是特定应用领域,不然就是得不偿失。因此,如何在二者之间找到平衡,考验每一位代码开发者的智慧和经验。
来源 | 阿里云开发者公众号
作者 | 笃敏