标签 性能优化 下的文章

GraalPython凭借多语言无缝协同的特性成为技术选型热点,但互操作背后的性能损耗往往隐藏在“无缝”的表象之下。这种损耗并非单一环节的低效,而是跨语言语义转译、语境切换、内存协同等多重因素交织的隐性壁垒——当GraalPython与Java、Rust等语言进行数据交互时,Polyglot API的中间适配、Truffle框架的动态优化延迟、不同语言内存模型的语义冲突,都会在高频调用场景中放大为显著的性能瓶颈。例如在工业物联网设备的实时质检场景中,GraalPython负责处理传感器采集的非结构化动态数据流,完成数据清洗与特征提取后,需将结果传递给Java模块进行业务规则校验,再由Rust模块执行底层算法加速运算,看似流畅的三级协同背后,类型语义的隐性转译、上下文状态的频繁切换,会使单次调用的延迟从微秒级累积至毫秒级,在每秒数十万次的高频调用场景下,直接导致整体系统吞吐量下降三成以上。更值得注意的是,这种瓶颈的隐蔽性极强,在低频次的功能测试中性能差异微乎其微,只有进入大规模数据处理或高并发交互的真实生产场景,深层的协同损耗才会集中爆发,成为制约系统性能上限的隐形枷锁,甚至会让前期针对单一语言的优化策略全部失效。

类型语义转译的隐性开销是互操作面临的核心瓶颈,这种开销源于不同语言类型体系的本质差异与转译过程中的语义损耗。GraalPython的动态类型表征与Java的静态类型谱系、Rust的强类型约束在核心语义上存在天然分歧,而Polyglot API作为转译中介,需在不同类型体系间构建临时映射关系,这种映射不仅涉及数据格式的转换,更包含语义逻辑的适配与补全。例如GraalPython的动态数组可能混合存储整数、字符串、布尔值等多种类型元素,传递给Java时需转译为统一类型的有序集合,转译过程中不仅要逐一对元素进行类型校验与转换,还需对不兼容元素进行语义适配,比如将Python的None值转换为Java的null,将Python的布尔值映射为Java的Boolean类型,这种适配往往需要额外的计算资源与时间开销。更复杂的是,不同语言对同一数据类型的语义定义可能存在偏差,GraalPython的字符串默认采用UTF-8编码且支持动态拼接,而Rust的字节序列更强调内存安全与固定长度,二者在底层存储逻辑上的差异,会导致转译时需进行编码格式的转换与内存空间的重构,高频次下这种转换的累积开销会急剧上升。同时,转译过程中还需维护类型元数据的同步,确保跨语言调用时的数据一致性,这种元数据管理本身也会占用额外的系统资源,比如构建类型映射表、跟踪类型转换记录,这些隐性操作都成为了性能损耗的隐形来源。

语境切换的累积损耗构成了互操作的另一重性能障碍,GraalPython与其他语言的协同需频繁切换执行语境,而语境切换过程中的状态保存、环境重建会产生显著的时间开销。在实时数据处理场景中,GraalPython负责数据预处理,Java负责业务逻辑计算,Rust负责底层算法加速,三者之间的频繁调用会导致执行语境在不同语言 runtime 间反复切换。每次切换都需保存当前语言的执行状态,包括程序计数器的值、寄存器中的临时数据、栈帧中的局部变量等,再加载目标语言的运行环境,初始化上下文配置、恢复目标语言的执行参数,这个过程在微秒级别的单次切换中看似微不足道,但在每秒数万次的高频调用场景下,累积损耗会占据相当比例的系统资源。更关键的是,语境切换会导致CPU缓存失效,CPU的L1、L2缓存原本存储着当前语言的指令与数据,切换后需要重新加载目标语言的指令与数据到缓存中,破坏了缓存的局部性原理,使得后续指令的执行不得不从内存中读取数据,进一步降低了执行效率。此外,不同语言的线程模型差异会加剧切换损耗,GraalPython的协程调度采用轻量级的用户态切换,Java的线程池管理依赖操作系统的内核态调度,Rust的无栈协程则强调零成本的上下文切换,三者在调度机制上的不兼容,会导致跨语言调用时出现调度冲突,需引入额外的同步机制进行协调,比如使用互斥锁或信号量保证线程安全,这无疑又增加了性能开销,让语境切换的损耗雪上加霜。

内存语义协同的冲突是深层性能瓶颈,GraalPython的动态内存调度与其他语言的内存管理机制在语义上存在本质分歧,跨语言数据共享时的内存所有权界定、生命周期同步成为核心难题。GraalPython依赖自身的垃圾回收机制管理内存,对象的创建与释放无需手动干预,垃圾回收器会定期扫描内存空间,回收不再被引用的对象;而Rust采用严格的所有权模型,内存的分配与释放由编译器静态检查,确保每一块内存都有唯一的所有者,避免出现空指针或悬垂引用;Java则通过JVM的垃圾回收机制自动管理内存,其回收策略与GraalPython的GC存在显著差异。三者的内存语义差异导致跨语言数据传递时需进行复杂的内存适配,例如GraalPython的对象传递给Rust时,需将动态分配的内存转换为Rust可识别的所有权模型,这个过程不仅要复制数据到Rust的内存空间,还需构建临时的内存管理代理,通过引用计数的方式跟踪内存的使用状态,确保Rust使用期间内存不被GraalPython的GC回收,使用完毕后及时通知GC释放代理资源。这种适配不仅增加了内存拷贝的开销,还可能导致内存泄漏——当跨语言调用因网络波动或系统异常中断时,内存管理代理可能无法正常销毁,导致部分内存无法被回收,长期运行会使系统可用内存逐渐减少。在数据密集型场景中,大量跨语言数据传递会使这种内存协同开销呈指数级增长,比如处理百万级别的传感器数据时,内存拷贝与代理管理的时间占比可达总执行时间的40%以上,严重影响系统的整体性能。

版本协同的隐性陷阱加剧了互操作的性能波动,GraalVM生态的版本迭代与多语言模块的版本兼容性要求,使得GraalPython在互操作时面临优化失效的风险。GraalVM的版本管理采用严格的语义化版本控制,主版本号的差异可能导致Polyglot API的调用逻辑、Truffle框架的优化策略发生根本性变化,而不同语言模块如Java的polyglot库、Rust的FFI绑定在版本迭代时可能未及时同步适配,导致跨语言调用时出现优化不兼容的问题。例如使用GraalVM 23.0版本运行时调用基于22.0版本开发的Java模块,可能会因Polyglot API的参数传递方式变化,导致JIT编译的跨语言内联优化失效,原本可通过内联减少的调用开销无法实现,单次跨语言调用的耗时增加两倍以上;而低版本的GraalPython对接高版本的Rust模块时,可能因FFI接口的语义变化,导致数据转译过程中出现冗余操作,比如重复进行类型校验、额外生成中间数据结构,这些冗余操作都会显著增加性能损耗。更复杂的是,部分语言模块的版本更新会引入新的内存管理机制或线程调度策略,与GraalPython的原有适配逻辑产生冲突,比如Rust模块升级后采用了新的异步内存分配器,而GraalPython的内存代理机制未同步更新,导致跨语言数据传递时出现内存分配冲突,不得不引入额外的同步锁进行协调,进一步降低了执行效率。这种版本协同的复杂性要求开发者在选型时需严格匹配所有相关模块的版本,而频繁的版本迭代又使得版本维护的成本急剧上升,成为性能优化过程中难以规避的隐性障碍。

动态优化的边界限制是长期存在的性能瓶颈,GraalPython依赖Truffle框架的动态优化能力提升执行效率,但多语言互操作的复杂性使得优化策略难以充分覆盖,导致部分跨语言调用无法获得有效的优化支持。Truffle框架的核心优化手段包括部分评估、跨语言内联、类型特化等,这些优化依赖于对代码执行路径的静态分析与运行时数据收集,而多语言互操作的动态特性往往超出了优化策略的覆盖范围。例如GraalPython调用Java的泛型方法时,由于Java的泛型类型擦除特性,Truffle框架难以在编译期确定具体的类型信息,无法进行精准的类型特化优化,只能采用通用的类型处理逻辑,导致调用开销居高不下;而调用Rust的复杂结构体方法时,因结构体的内存布局与GraalPython的对象模型存在显著差异,部分评估优化无法充分展开,只能依赖runtime的动态适配,增加了执行延迟。此外,多语言调用的路径多样性也会影响优化效果,不同语言的函数调用栈嵌套、参数传递方式的差异,使得Truffle框架难以构建统一的优化模型,比如三级嵌套的跨语言调用,Python调用Java再调用Rust,框架无法对整个调用链进行全局优化,只能对单一环节进行局部优化,优化效果大打折扣。

打磨了一周的卡片式 ui 大佬们还有更多的 功能/样式 建议吗 准备发布啦

它主要解决什么问题?

  • 长楼“找上下文”困难:不知道自己在回复谁
  • 讨论树太深,页面太长:滚动疲劳、定位困难
  • 想快速扫顶层(L0)主线程:上下跳楼不够顺手
  • 深色模式/阅读舒适度:原生样式太简陋,眼睛累


核心功能一览(当前脚本已实现)

1) 评论区卡片化重排(核心)

  • 把 HN 原生的表格树评论,重建为 卡片式树结构(视觉层级更清晰)
  • 每条评论是一个卡片:用户名/时间/操作按钮在顶部,正文在下面,子回复作为卡片嵌套
  • 支持 最大宽度、圆角、间距、缩进等布局调节

适用场景:长讨论阅读体验会明显提升,尤其是多层回复。


2) 主题与外观:浅色/深色/跟随系统 + 渐变层级背景

  • 主题模式:跟随系统 / 强制浅色 / 强制深色
  • 页面背景可自定义(浅色/深色各一套)
  • 评论卡片支持“按层级渐变”:L0/L1/L2… 背景颜色可调
  • 也提供一些预设配色(比如蓝灰、暖米色、灰阶极简、深海蓝黑等)

目标:让“长时间刷楼”变得更舒服。


3) 折叠/展开(偏扫楼用)

为了快速扫楼,它提供多种折叠策略(可开关/可配置):

  • 每条评论可折叠:右上角有“折叠/展开”按钮
  • 折叠触发方式(可选):
    • 仅按钮
    • 按钮 + 单击正文
    • 按钮 + 双击正文
  • 双击正文折叠/展开整个子树(可选):
    双击某条评论正文,直接折叠/展开它下面整棵回复树
  • 默认折叠策略(两套):
    • 按数量:顶层评论如果“后代回复总数”超过阈值,默认折叠(便于扫主楼)
    • 按深度:从指定深度开始默认折叠(避免页面无限延长)
  • 全局快捷按钮(右下角):
    • 全部折叠(收起所有有子回复的评论)
    • 全部展开(展开所有评论)


4) “展开提示” + 数量颜色提醒(更好扫大楼)

当某条评论被折叠时,会出现类似:

  • [+展开 8 条回复](单击默认展开 双击展开全部子级)

并且这个数字会根据数量变色(可调阈值/颜色):

  • 少量:更偏主题强调色
  • 中量:偏粉/亮色
  • 大量:偏红(提醒这楼很长)

另外支持把这个提示按钮放在:

  • 评论底部(默认)
  • 或者放在右上角按钮行里(更省垂直空间)


5) L0 主线程导航(扫楼神器)

每条评论右上角会有:

  • 上 L0 / 下 L0:快速跳到上一个/下一个顶层主线程
  • L0 折叠:一键折叠当前评论所在的顶层主线程(快速收起一整楼)
  • 还有一个 “层展”:只展开下一层,下一层的子回复继续保持折叠(用于逐层读)

(这块我自己用得最多,长讨论基本靠它快速扫楼。)


6) Hover 父级高亮:找上下文更快

鼠标悬停某条评论时:

  • 自动高亮它的直接父评论
    用来快速确认“我现在在回复谁/这层上下文是什么”。


7) OP(楼主)高亮

楼主(story author)的评论会有额外高亮描边,方便追踪 OP 在楼里说了什么。


8) Dead 评论处理(单选模式)

对被标记为 dead 的评论可以选择:

  • 不处理
  • 弱化(降低透明度/饱和度)
  • 隐藏(同时自动启用弱化)


9) 像素头像(基于用户名生成)

每个用户名旁边会生成一个 对称像素头像(纯前端生成,无请求外部资源):

  • 默认开启
  • 支持调整大小、是否加边框、边框颜色、边框样式
  • 有做性能优化:懒加载 + 缓存,避免长楼卡顿

用途:快速识别同一个人在楼里出现的回复。


10) 复制评论直达链接(可选)

每条评论右上角可以显示 copy

  • 一键复制 item?id=xxx#commentId 的直达链接
    方便分享某条具体回复。


11) 顶部导航吸附 + 回到顶部

  • 顶部 HN 导航栏可吸附(sticky)
  • 右下角 回到顶部按钮(滚动到一定距离才出现)


12) 快捷键(可选)

Alt + Shift:

  • C 全部折叠
  • E 全部展开
  • T 循环切换主题(auto/light/dark)

今天这篇博客文章由 Streamfold 的 Mike Heffner 和 Ray Jenkins 撰写。他们是 Rotel 的维护者,该项目是一个用 Rust 编写的开源工具,致力于实现高性能、资源高效的 OpenTelemetry 数据采集。

TLDR;

大规模系统中的效率至关重要:哪怕是资源消耗的小幅下降,也可能带来显著的成本节约和效率提升。

OTel + ClickHouse 的基准测试:我们构建了一套数据管道,用于评估将流式追踪数据写入 ClickHouse 的性能。

Rotel 实现了 4 倍性能提升:我们展示了如何将 OTel Collector 每核每秒处理 13.7 万个追踪跨度 (trace span) 提升到 Rotel 的 46.2 万个,并详细说明了多项关键的性能优化。

工具和资源:文末提供了我们在基准测试中所用的工具清单。

引言

在 PB 级规模下运营一个可观测性平台,需要持续关注资源利用效率。即便是每核性能或内存使用上的细微改进,也能大幅降低基础设施开销。

本文源自我们在丹佛举办的一场 ClickHouse 技术见面会的演讲。在会上,我们分享了我们对 Rotel 的开发工作。Rotel 是一个面向大规模系统的高性能 OpenTelemetry 数据平面 (data plane)。

得益于其高压缩比和良好的成本效益,ClickHouse 越来越多地被用于大规模的 OpenTelemetry 负载中。而在这些系统中,OTel Collector 往往是整条数据管道中成本最高的部分。

最近,ClickHouse 发布了一篇关于大规模系统中效率重要性的文章(https://clickhouse.com/blog/scaling-observability-beyond-100pb-wide-events-replacing-otel),并介绍了他们在内部平台 LogHouse 上运行 OTel 的实际经验。

其中有个细节引起了我们的注意:

“OTel Collector:使用超过 800 个 CPU 核传输每秒 200 万条日志。”

按每核每秒约 2500 条日志计算,对于典型的日志行来说,这意味着一个 8 核服务器每秒仅能传输约 10MB,远远低于现代硬件的处理能力。而 ClickHouse 每核每秒最多可处理超过 1.25 万条 OTel 事件,吞吐能力是前者的五倍以上。因此,当前的瓶颈在数据采集端,而非存储端。尽管我们无法复现 ClickHouse 内部的 LogHouse 平台,但我们认为,对现代 OpenTelemetry 数据管道进行基准测试仍具有重要意义。因此,我们希望借此次演讲探讨一个核心问题:将追踪数据发送到 ClickHouse 时,不同的 OpenTelemetry 数据平面表现如何?

本文将逐步介绍我们构建的基准测试流程,比较 OpenTelemetry Collector 和 Rotel 的性能。我们搭建了一套合成的追踪数据管道,先通过 Kafka 传输追踪跨度,再写入 ClickHouse。在 OTel Collector 达到 110 万跨度/秒的基准测试后,我们展示了通过以下几项优化如何让 Rotel 在相同硬件上实现最高 370 万跨度/秒的处理能力:实现 JSON 的二进制序列化、分析 Tokio 任务调度性能 (perf analysis of Tokio task management)、以及引入改进的 LZ4 压缩算法。

文末我们还列出了用于此次基准测试的框架和工具。

基准测试框架

在进入测试结果之前,我们先介绍一下评估 OpenTelemetry Collector 和 Rotel 所采用的测试方法。如果你感兴趣,也可以直接跳转到结果部分,链接见这里。

追踪数据管道

我们此次基准测试的重点是将追踪跨度 (trace span) 写入 ClickHouse,因为在大型系统中,追踪数据增长极快。我们参考了一个高度可靠的流处理管道进行建模,选用 Kafka 作为日志流层。测试目标是模拟这样一种场景:大量边缘采集器将数据发送至 Kafka 流,由少数几个核心采集器批量写入 ClickHouse。在这个管道中,Rotel 和 OTel Collector 使用相同的 Kafka Protobuf 编码,因此两者可以互换使用。

图片

评估方法

我们希望找出在固定硬件条件下,单个采集器能够稳定支持的最大吞吐量,重点关注的是采集效率,而非系统扩容能力。测试的关键是观察单节点在不降级的前提下可以被“压榨”到什么程度,同时也控制在我们的评估预算范围内。我们选择网关采集器作为测试核心组件,因为它直接将数据输出至 ClickHouse,而后者在处理大批量数据插入时效率最高。为实现更高的批处理效率,部署少量、但能力更强的网关采集器是理想方案,因此我们专注对该组件进行优化与测量。

饱和识别

我们通过两个关键信号来判断采集器是否已达到处理极限:

  • 内存激增:当下游系统产生反压时,采集器会将数据缓存于内存中,导致内存快速增长;

  • Kafka 消费延迟:如果采集器处理速度赶不上 Kafka 流的速度,其消费延迟会不断增加,即“上次读取的消息时间”与“当前时间”的间隔在变大。

测试配置

每项测试我们都将数据管道运行在接近饱和的边缘状态,持续 15 分钟,记录以下两个关键指标:

  • 每秒处理的追踪跨度数,由负载生成器记录;

  • 向 ClickHouse 写入的数据吞吐量(MB/s),由 AWS Cloudwatch 监控采集。

带宽这一指标有助于统一比较不同环境下的处理能力,因为追踪跨度在实际场景中大小差异可能很大。在测试配置中,边缘采集器会将数据打包优化后发送至 Kafka,这样可以减少消息总数,同时提高单条消息的体积。

测试过程中,我们也记录了 Rotel 与 ClickHouse 的平均 CPU 使用率。

OpenTelemetry 的 ClickHouse 数据模式

本次测试使用的 ClickHouse 表结构同时兼容 OpenTelemetry Collector 与 Rotel。这一数据模式是官方推荐的方案之一,适用于 ClickHouse 与 ClickStack 可观测性平台,支持 OTel 的指标、日志与追踪数据。

OTel 的数据模型高度依赖键值属性(key/value attributes)来描述基础设施与应用环境中的关键特征,有助于进一步分析。最初在 ClickHouse 中,这类字段采用 Map 类型存储。但近期,OTel Collector 引入了支持 JSON 列类型(JSON column type)的新特性,使得原有结构可以转换为 JSON 格式。虽然这会给 ClickHouse 带来更高的 CPU 压力,但查询表达能力也因此大大增强。我们的测试选择启用这一新特性。你可以在这里查看我们使用的完整 ClickHouse 追踪数据结构(https://gist.github.com/mheffner/dc332a61f3b9ba1d03fd7c7d5c1b7fbb)。

我们的测试配置中还使用了 ClickHouse 的 Null 表引擎,这是一项关键优化手段。Null 引擎可以接受写入请求但不进行实际存储,因此能帮助我们剥离磁盘 I/O 的影响,专注评估写入吞吐能力与数据结构正确性。在完成峰值吞吐的测试后,我们会进一步评估 ClickHouse 如何处理真实的磁盘写入负载。

负载生成器

我们尝试使用 telemetrygen CLI 工具生成数据,但它难以达到所需的数据量。因此我们改用之前内部构建的负载生成器,该工具最初用于测试 OpenTelemetry 与其他遥测管道。该项目可在 Github 的 otel-loadgen 仓库中找到。它还具备验证端到端数据传输等增强功能,我们将在后续文章中进一步介绍。

我们构造的每个追踪包含约 100 个跨度,涵盖丰富的属性与元数据。和所有合成测试一样,这些数据并不完全等同于真实生产环境中的流量。

测试硬件

所有基准测试均在 AWS EC2 实例上执行。数据管道的每一层组件部署在独立的实例中,所有实例均位于同一可用区,以确保测试结果的一致性与准确性。

图片

为了最大化磁盘吞吐能力,我们将 Kafka 和 ClickHouse 的数据卷直接挂载在实例自带的 NVMe 本地磁盘上。所有测试均使用 Amazon Linux 2023 操作系统,通过 Docker Compose 编排运行各个组件。

本次基准测试的目标是评估单台网关采集器主机所能承载的最高吞吐量。我们最终选择的测试机器为 m8i.2xlarge 实例,配备 8 核 CPU 和 32GB 内存。随着测试规模扩大,数据管道中的其他节点进行了扩容,但网关采集器始终保持不变,便于横向对比。

测试 OpenTelemetry Collector

测试从 OpenTelemetry Collector 开始,它在测试中既作为边缘采集器,也作为网关采集器。

测试配置

图片

你可以在这里查看 Docker Compose 的配置文件(https://github.com/streamfold/rotel-clickhouse-benchmark/blob/main/docker-compose-otelcoll.yml)。

测试结果如下:

在运行单个 Collector 实例时,我们在处理速率达到约 70 万个追踪跨度每秒(约 40 MB/s)时遇到了性能瓶颈。此后内存占用开始持续上升,尽管此时 CPU 利用率尚不足 50%。

OTel 的 Kafka 接收器采用单个 goroutine(轻量线程)处理消息,这很可能成为吞吐量的限制瓶颈。我们尝试了若干 Kafka 参数调整,包括消息大小限制等,但都未能显著提升性能。于是我们转向横向扩展方案,在同一主机上启动第二个 Collector 实例(通过 Docker Compose 的 scale: 2 设置)。

当两个 Collector 实例各自消费一半 Kafka 分区后,系统最大稳定吞吐量达到 110 万追踪跨度每秒(69 MB/s)。一旦超过这个阈值,发送队列开始堆积,内存使用迅速上升。当队列完全填满后,Kafka 接收器仍然会继续读取消息,但会直接丢弃数据。这意味着表面上 Kafka 消费延迟没有上升,但实际上我们已经在丢失追踪数据!

测试期间,网关采集器的 CPU 峰值使用率超过 83%,成为主要瓶颈。而 ClickHouse 的 CPU 使用率维持在 23% 左右。

图片

测试 Rotel

测试配置

图片

你可以在这里查看 Rotel 的 Docker Compose 配置(https://github.com/streamfold/rotel-clickhouse-benchmark/blob/main/docker-compose-rotel.yml)。

测试结果如下:

Rotel 同样使用一个接收循环从 Kafka 拉取数据,与 OTel Collector 架构类似。这使我们初步判断存在串行处理瓶颈。果然,当我们在主机上运行两个 Rotel 实例并充分利用 CPU 后,吞吐量大幅提升。

在两个 Rotel 实例并行运行时,我们实现了每秒 145 万个追踪跨度(76 MB/s)的最大吞吐能力,较 OTel Collector 提升约 1.3 倍。继续提升负载后,我们观察到 Kafka 消费延迟开始缓慢上升,说明消费速率已逼近极限。

此时,网关采集器 CPU 使用率达到 91.3%,成为新的瓶颈;ClickHouse CPU 使用率也升至 60.4%。

图片

我们随后继续挖掘进一步的优化空间,将注意力转向了如何更高效地处理 JSON 列类型的数据传输。

优化 RowBinary 格式下的 JSON 编码

在 Rotel 中,我们基于官方的 Rust ClickHouse 库 clickhouse-rs 进行了改造。该库使用的是 RowBinary 格式——一种面向行的二进制序列化协议,通过 HTTP 与 ClickHouse 进行数据读写。相比之下,OTel Collector 所使用的 Go 驱动和 ClickHouse 内部组件则采用面向列的原生协议。

在处理 JSON 列时,clickhouse-rs 的默认做法是:先将 JSON 内容转为字符串后再发送。虽然 ClickHouse 本身不会以原样字符串存储 JSON 列,但这一“字符串化”过程是为了传输而必须进行的。不过,这样做在高并发情况下代价很大:客户端需要序列化 JSON,服务端则需重新解析,还必须扫描键名和字符串值以转义引号、反斜杠等字符。尤其当字符串体量较大时,这一过程非常耗资源。

在 ClickHouse Slack 社区的帮助下,我们发现其实可以将 JSON 列直接编码为 RowBinary 原生格式。这种方式下,JSON 会被序列化为一系列键值对,每个键为字符串,紧跟一个表示值类型的标签,再跟上该值的原始二进制内容。这种结构可以跳过整个 JSON 的序列化解析过程,从而实现更高效的结构化数据传输。

比如,考虑下面这样一个简单的 JSON 对象:

{  "a": 42,  "b": "dog",  "c": 98}
复制代码

RowBinary 格式下的编码方式如下:

图片

首先,它会将键值对的数量以变长整数(varint)编码,例如上例中是 03 对;然后逐个对键值对编码。每个键会先写入一个表示字符串长度的 varint(如 01),再写入字符串本身,接着是对应值的编码。如果值的类型在 JSON 声明中已知(如上例中的键 a 是整数 42),那么会直接用固定类型编码,如 2a 00 00 00 00 00 00 00。如果类型未声明,则使用动态类型编码方式,例如键 c 用 0a 表示 Int64 类型,后跟值 98(62 00 00 00 00 00 00 00)。最后,键 b 表示字符串类型(15),跟上字符串长度 03 和字符串内容 “dog”。

相比传统的 JSON 传输方式,这种方法在客户端和服务端两端都能显著减少序列化和解析的资源消耗。虽然 clickhouse-rs 库目前尚未原生支持这种编码方式,但我们计划参与贡献该功能的开发。

重新测试

在将 Rotel 升级为使用上述优化编码后,我们重新进行了性能测试,以评估实际效果。结果显示,虽然总体吞吐量依然维持在之前的峰值——每秒 145 万个跨度未变,但 ClickHouse 服务器的 CPU 使用率下降了约 10%,网关采集器的 CPU 占用也略有减少。这种改善在多次测试中均表现稳定,说明服务端解析负担确实得到了缓解。

需要说明的是,此次测试中使用的合成负载并未包含大量长字符串,同时每个跨度的属性数量也与真实生产环境可能有所不同。因此,虽然客户端的改进不明显,但我们相信,在处理属性字段较多、字符串数据较大的实际业务场景中,这种更快的 JSON 序列化路径将带来更明显的性能收益。

图片

用一个意想不到的技巧将吞吐量翻倍(定位并解决内存分配器锁争用)

在这轮测试中,为了充分利用网关采集器主机的 8 个 vCPU 并将事件吞吐量提升至每秒 145 万条写入 ClickHouse,我们必须同时运行两个 Rotel 实例。Rotel 的 Kafka 接收器逻辑原本运行在一个 Tokio 异步任务中,其处理流程简化如下:

图片

这种实现方式存在两个主要问题:

1. 整个处理流程是串行执行的,缺乏并行能力;

2. 数据反序列化(unmarshaling)是一项高度依赖 CPU 的操作,容易阻塞 Tokio 的执行线程。

Tokio 是 Rust 语言的异步运行时,采用协作式调度模型。这要求任务在运行过程中需主动在 `.await` 或其他让出点交还执行权给调度器。网络上已有大量文章探讨该机制的重要性以及忽视它可能带来的严重性能问题。通俗来说,一个 tokio 任务应尽可能靠近 `.await` 点,业界建议任务在两个 `.await` 之间的执行时间应控制在 10 到 100 微秒以内。

在 Rotel 的 exporter 模块中,我们使用了一个专用线程池来处理诸如数据序列化与压缩等 CPU 密集型任务。而在 Kafka 接收流程中,rust-rdkafka 库会将解压缩工作分派至后台线程,在调用 `recv()` 之前完成。但最初我们依然将数据的反序列化逻辑保留在 Tokio 异步任务中。随着分析深入,我们确认反序列化过程极其耗费 CPU,因此决定将其改为异步提交到与 exporter 共用的线程池中,以避免阻塞主线程。

经过这一重构后,Kafka 接收器的主处理循环结构如下:

loop {  select! {    message = recv() => {        unmarshaling_futures.push(spawn_blocking(unmarshal(message)))         },    unmarshaled_res = unmarshaling_futures.next() => {        send_to_pipeline(unmarshaled_res)    }  }}
复制代码

我们随后在网关采集器上使用单个 Rotel 实例重新运行测试,并将负载生成器设置为此前的最大值——每秒 145 万个 trace span。结果系统依旧稳定运行。但让我们颇为意外的是:CPU 使用率相比优化前下降了约 40%!

原先我们之所以要运行两个 Rotel 实例,是因为单个实例未能充分利用主机资源,Kafka 接收模块存在明显的串行处理瓶颈。而此次重构将反序列化逻辑迁移至专用线程池后,这一限制被有效解除。我们预期,在这种并行架构下,单实例就能实现与双实例相同的吞吐性能,并维持类似的资源利用率。

在看到 CPU 占用大幅下降后,我们继续提升负载压力。最终,我们成功将吞吐量从每秒 145 万条提升至 360 万条 trace span,实现翻倍增长!

当处理速率达到每秒 360 万条时,CPU 占用再次达到约 93%,系统达到新一轮饱和。

图片

在验证性能大幅提升后,我们开始着手分析背后 CPU 效率提升的具体原因。为此,我们借助 Linux 的 Perf 工具以及 flame graph(火焰图),对优化前后的 Rotel 构建版本进行了性能剖析,从而定位 CPU 时间的实际开销位置。

使用 Flamegraph 剖析 Rotel 的 Kafka 接收器

我们针对 Rotel Kafka 接收器的旧版本与新版本重新运行了一轮测试,并捕获了 flamegraph(火焰图)用于性能分析。起初乍一看,两者并未显现出明显差异。你是否能发现其中关键?在两个版本中,我们都观察到:将追踪数据准备并导出至 ClickHouse 占据了主要运行时间,此外,接收器中的消息反序列化操作(在 prost::message::Message::decode 函数中执行)也消耗了相当多的资源。这类负载会创建大量生命周期极短的对象,因此系统在内存分配与释放上耗费了大量时间。

旧版本:

图片

新版本:

图片

使用 Linux Perf 评估接收器优化前后的变化

通过运行 perf stat,我们发现两个版本在底层表现上差异巨大。

perf stat -c cycles,instructions,cache-misses,cache-references,context-switches,cpu-migrations 
复制代码

旧版本

295612663445 cycles 264853636815 instructions # 0.90 insn per cycle 615670230 cache-misses 1867733351 cache-references 1224819 context-switches 1230 cpu-migrations 50.296446757 seconds time elapsed
复制代码

新版本:

150590256805 cycles 287007890213 instructions # 1.91 insn per cycle 598469068 cache-misses 1163669589 cache-references 37675 context-switches 43 cpu-migrations 43.716966122 seconds time elapsed
复制代码

新版本平均每周期执行 1.9 条指令,每秒仅发生 862 次上下文切换。尽管这在指令级并行性(ILP)方面不算极致表现,但相较之下已经有明显进步。而旧版本平均仅能达到 0.9 条指令/周期,且上下文切换次数竟高达每秒 24,350 次 —— 新版本将此指标降低了约 32.5 倍!说明旧版本几乎无法并行处理,线程频繁被挂起与唤醒,调度开销巨大。

此外,CPU 迁移数据也显示出改进:新版本平均仅有 1 次迁移/秒,表明线程在相同 CPU 核上保持良好的缓存亲和性,而旧版本则高达 24.5 次/秒。这些迹象显示,旧版本中调度器难以保持线程驻留在固定核心上。

新版本具备更优秀的并行处理特性,使得我们可以进一步扩大吞吐负载。而这一切的背后,仅仅是我们将部分任务拆分至独立线程处理。

我们原先推测旧版本性能瓶颈可能源于 Tokio 执行线程被阻塞,导致运行时不得不频繁轮询、空转,甚至尝试工作窃取。但 perf report 的深入分析为我们揭示了更具体的问题。

深入分析 perf report 结果

通过 perf record 捕获更详细的运行数据后,我们对比了优化前后的性能差异。

在新版本中,大部分计算时间用于压缩数据、将 OTLP 转换为 ClickHouse 所需的数据行结构、以及内存分配与释放等正常开销,整体运行表现健康。

图片

但旧版本的问题非常突出:15% 的运行时间用于释放内存,而压缩和构造数据的时间仅占 9.75%,相比之下新版本达到了 20%。同时,我们发现大量 CPU 时间耗费在如下底层函数中:_raw_spin_unlock_irqrestore、finish_task_switch.isra.0、__lll_lock_wait_private 与 __lll_lock_wake_private。

图片

开启子调用视图后,我们发现这些锁操作多数出现在内存释放阶段。

图片

这些函数具体负责什么?

  • _raw_spin_unlock_irqrestore 是 Linux 内核中的函数,用于释放自旋锁并恢复中断状态。当任务即将被抢占时会调用它,以便调度器执行上下文切换;

  • finish_task_switch.isra.0 是编译器优化后的上下文切换清理函数,负责完成切换后的调度收尾;

  • __lll_lock_wait_private 和 __lll_lock_wake_private 是 glibc 内部函数,用于 mutex 互斥锁等同步机制的实现。

值得注意的是,这些锁相关的函数在释放内存时频繁出现,暗示我们的旧版本在这一过程中产生了严重的锁争用。

回过头来看旧版本的 flamegraph,这一问题其实非常明显。理想情况下,如果我们能使用类似差分 flamegraph(differential flamegraph)工具对比两个版本图谱,可能会更快定位瓶颈点(此处也再次呼吁出现更多易用的 flamegraph 工具)。不过幸运的是,我们还是通过 perf stat 和 perf record 快速找到了问题根因 —— 并不是 Kafka 接收器的串行处理导致瓶颈,而是 ClickHouse exporter 中的序列化函数(TransformPayload)在进行 marshaling 操作时产生了锁争用。

图片

Glibc 多线程内存分配机制下的锁争用问题

从测试数据来看,我们已能清楚解释为何在优化后,系统的 CPU 使用率大幅下降而吞吐能力却反而提升。原先的版本确实消耗了大量计算资源,但主要耗在了无效的系统开销上 —— 本质上,它的大量 CPU 时间都被花在释放内存时的自旋锁等待中。

为了理解这种情况,我们需要简单了解 glibc 默认内存分配器的工作方式。系统将内存划分为多个“arena”(内存区域),每个 arena 都通过一个互斥锁(mutex)来保障内存的分配与释放线程安全。为了减少锁争用,不同线程会尝试创建独立的 arena,随着线程池规模扩大,arena 数量也会同步增长。

但关键在于,如果某段内存在 A 线程上分配,最终却在 B 线程上释放,B 线程就必须锁住 A 所属的 arena,这很容易造成其他线程等待,从而产生锁争用。

在旧版本中,我们在 Kafka 接收器的反序列化(unmarshaling)过程中,于 Tokio 的 I/O 执行线程上分配处理 trace 数据所需的内存。这类异步任务调度在线程数受限的 Tokio executor 上 —— 网关采集器上仅有 8 个线程,刚好一核一个。之后,这段内存在 ClickHouse exporter 的数据序列化过程中被释放,而这部分逻辑则运行在一个大规模阻塞线程池上(包含几十甚至上百个线程)。线程之间频繁切换导致 arena 访问被频繁锁定,进而引发了锁争用问题。

图片

新版本中,我们将反序列化阶段的内存分配也迁移到了与释放操作相同的阻塞线程池中,解决了跨线程释放的问题。随着管道内数据量增加,线程池自动扩展,arena 的数量也随之增加,锁争用的风险被大幅降低。

图片

验证 arena 锁争用:Jemalloc 的对比测试

一位审阅本文的工程师提问:“如果换成 jemalloc 分配器,会出现同样的问题吗?”Jemalloc 是一个专为多线程环境优化的内存分配器,目标之一就是减少锁争用。我们曾在早期测试中尝试过 jemalloc,但当时未见显著性能收益。然而,随着 ClickHouse exporter 的负载提升,以及 Kafka 接收器架构的变化,内存分配压力大幅增加,这促使我们重新测试旧版和新版在 jemalloc 下的表现。

我们将旧版本切换为使用 jemalloc 后,CPU 使用率从 93% 降至 40%,与我们将反序列化迁移至共享线程池后的效果几乎一致,进一步印证了锁争用才是核心瓶颈。

尽管 jemalloc 能缓解 CPU 压力,但在满负载条件下,Kafka 消费延迟却有所增加。加上 jemalloc 当前已不再活跃维护,我们决定不将其设为默认分配器。不过,未来我们可能会引入 feature flag,让用户根据需求自由选择 jemalloc、mimalloc 等自定义内存分配器。

额外提升:开启快速 LZ4 压缩优化

Rotel 采用 ClickHouse 官方推荐的 LZ4 压缩配置来减少网络传输数据量。我们使用的是 lz4_flex crate —— 与 clickhouse-rs 相同的依赖库,但我们是直接引用的。在引入相关压缩支持时,我们忽略了 Cargo.toml 中的功能标志配置。

Lz4-flex 同时提供安全(safe)和非安全(unsafe)两种实现版本,其中 unsafe 版本的性能更优(关于 Rust 中的 unsafe,请参考官方文档(https://doc.rust-lang.org/book/ch20-01-unsafe-rust.html))。默认情况下,需要显式启用 unsafe 特性才能使用高性能实现。

clickhouse-rs 默认启用了 unsafe 模式,而我们最初未启用此选项。启用之后,我们观察到网关采集器的吞吐能力由每秒 360 万条 trace span 提升至 370 万条,网络压缩输入达到了 209 MB/s。

与此同时,网关采集器的 CPU 使用率也进一步小幅下降。

图片

完整的端到端性能评估

在对 Rotel 进行了多轮性能优化,并基于 ClickHouse 的 Null 表引擎完成初步测试后,我们成功将单实例的吞吐能力从最初的每秒 110 万条 trace span 提升至 370 万条,相当于每核每秒处理 46.25 万条。这比最早测试 OTel Collector 所获得的吞吐性能提升了超过 4 倍。

我们随后将评估重点转向了整个链路的最后一环 —— 数据写入 ClickHouse 并真正持久化至磁盘。在扩展 ClickHouse 写入能力时,通常需要从写入性能与查询效率两个维度优化数据表结构。本次测试我们仍采用默认的 OTel 数据模式,因此主要通过选择具备足够写入能力的实例来支撑高负载。

为应对大规模写入负载,我们将 ClickHouse 部署在更高性能的 AWS 实例上,并通过 4 块本地存储构建 RAID0,以确保不会受到磁盘带宽瓶颈限制。

在测试期间,我们关闭了 Rotel 的异步写入功能,并将批处理规模大幅提升至 --batch-max-size=102400,以提升整体写入效率。通过设置 --clickhouse-exporter-async-inserts=false,我们成功维持了每秒 370 万条 trace span 的网关采集器吞吐量。

此时 ClickHouse 的 CPU 占用率约为 50%,压缩后的写入流量达到 210MB/s。

图片

Visual inspection 

可视化效果上,我们在 ClickHouse 中成功查询到了超过 30 亿条追踪数据,验证了端到端链路的可用性。

图片

总结

极限规模下,效率至关重要

ClickHouse 内部 LogHouse 平台所运行的 PB 级可观测性场景表明,效率不再是优化选项,而是生存必要条件。他们将管道吞吐能力提升了 20 倍,同时仅使用之前 10% 的资源。如果仍按原有路径扩展,运维成本将变得无法承受。Netflix 与 OpenAI 等大型技术公司也达成了类似共识 —— 当数据量达到如此规模时,效率的优劣将直接影响业务运转。

本项目的目标正是在这一背景下,推动 OpenTelemetry 数据采集效率提升,并推出 Rotel。

近 4 倍的吞吐性能提升

通过本次工作,我们将 Rotel 打造为一款高吞吐量的 OpenTelemetry 流式采集工具。在相同硬件环境下,Rotel 的处理能力几乎是 OpenTelemetry Collector 的 4 倍。这种差异在大规模场景下可以带来显著的资源节省。Rotel 原生支持 OpenTelemetry 的 trace、metric 和 log 类型。本篇文章聚焦追踪数据,未来我们还将扩展基准测试到日志与指标场景。

我们也希望了解,在海量数据处理场景下,用户最看重哪些功能特性。如果你有宝贵经验或希望分享你的扩展实践,欢迎加入我们的 Discord 社区(https://rotel.dev/discord),或在 GitHub 上(https://github.com/streamfold/rotel)提交贡献。

后续方向

以下是我们在完成本次测试后,计划进一步探索的几个方向:

深入探讨 Kafka 的可靠传输机制

本文仅简单提及了 Rotel 的消息可靠性设计。Rotel 支持端到端消息确认机制,确保从 Kafka 中读取数据时实现“至少一次(at-least-once)”语义,避免依赖 Kafka 默认的自动提交机制可能导致的数据丢失。为此我们对数据管道做了多处修改,并进行了严格测试,以确保在避免重复的同时不丢失任何数据。未来我们计划单独撰文,深入介绍其设计与验证方法。

评估 ClickHouse 原生通信协议

Rotel 当前通过 clickhouse-rs 实现与 ClickHouse 的集成,采用基于 HTTP 的 RowBinary 协议。相比之下,OTel Collector 使用 Go 实现的 ClickHouse 驱动,采用的是 ClickHouse 的原生协议。该协议也是 ClickHouse 节点之间通信所用方式,基准测试显示其性能比 RowBinary 高出约 20%。ClickHouse 还新增了对 Apache Arrow Flight 的支持,后者基于内存格式 Arrow 实现高效传输。我们计划评估是否可将 RowBinary 替换为这些列式协议,以进一步提升 Rotel 吞吐性能。

进一步分析 tokio 中的阻塞任务影响

类似反序列化这类阻塞操作对 tokio 的运行时性能影响显著。在本次评估中我们首次直观感受到其影响,因此希望在其他处理路径中继续探讨类似瓶颈。目前我们已知 Rotel 的 OTLP 接收器在处理连接时会在异步任务中直接执行较重的 Protobuf 反序列化操作,该处理逻辑由 tonic crate 承担。我们计划分析如何将其拆分为独立任务。初步通过 perf 工具观察,预计该处存在巨大优化潜力。

优化内存分配路径

虽然 Rust 本身没有垃圾回收机制,但高频率的内存分配与释放在高负载场景中依然会成为瓶颈。Rotel 在处理短生命周期对象时存在大量内存分配行为。如果我们采用内存重用池(freelist)的方式跳过分配器,将常用缓冲区复用,有望显著减少开销。当然,这类机制的实现难度较高,若不慎也可能导致内存占用飙升。我们可能需要深入修改 tonic crate 才能实现该优化。

我们特别感谢 Sujay Jayakar、Ben Sigelman、Rick Branson、Vlad Seliverstov、Rory Crispin 和 Achille Roussel 对本文早期版本的审阅与反馈。

附录

评估过程中考虑但未纳入的项目

在设计本次基准测试框架时,我们曾希望纳入更多支持 OpenTelemetry 的数据平面工具。但实际测试中发现,它们与我们所设定的测试流程并不兼容。我们之所以选择分布式追踪作为测试对象,是因为它是推动 OTel 被广泛采用的关键场景之一,且在大规模系统中数据增长迅速。然而,日志与指标则属于传统监控领域,很多工具对 trace 类型的遥测数据仍缺乏完善支持。因此虽然未在本次测试中覆盖这些工具,我们仍计划未来开展日志与指标方面的基准评估。

Vector

Vector 是一款专为构建高性能遥测数据管道设计的轻量级工具。它支持广泛的数据源和输出目标,能很好地融入多种系统中。该项目目前由 DataDog 主导开发,并被用于其 observability pipelines 产品。

不过,Vector 对 OpenTelemetry 的支持还处于早期阶段,目前尚无法与多种目标系统对接。尤其在 trace 数据方面,其内部数据模型起初并不支持追踪结构,因此目前对 OTel trace 的支持较为有限。由于 Kafka 和 ClickHouse 的输出插件对 trace 数据尚不兼容,我们未能将其纳入此次测试。

例如,我们曾尝试:

  • 从 OpenTelemetry source 向 Kafka sink 发送 trace 数据(https://github.com/vectordotdev/vector/discussions/21018);

  • 在 ClickHouse 中存储 trace span(https://github.com/vectordotdev/vector/issues/17307#issuecomment-1641075239)。

Fluent Bit

Fluent Bit 是一个以性能为重点、由 C 编写的 Fluentd 替代方案。它提供了对 OpenTelemetry 的输入与输出支持,包括日志、指标与追踪数据。Fluent Bit 支持 Kafka 输入输出,因此理论上可用于构建可靠的数据流管道。

然而我们测试发现,当前版本中,在将 OpenTelemetry 作为输入的同时通过 Kafka 或 HTTP 输出 trace 与 metric 数据,尚不完全支持。这一限制使其暂时无法参与本次评估。

简化 OTel 与 ClickHouse 的迁移操作

根据 ClickHouse 官方文档建议,用户应在部署前手动创建表结构,而不是依赖导出器自动建表。但由于这些迁移脚本通常打包在 OTel exporter 中,缺乏独立部署方式,因此部署过程并不直观。

为了解决这个问题,我们在 Rotel 中将表结构管理逻辑拆分为一个独立的命令行工具 —— clickhouse-ddl,用于便捷地部署数据模式(schema)。该工具可创建与 Rotel 和 OTel Collector 完全兼容的表结构。

我们将该工具封装为一个 Docker 镜像,用户只需运行一条命令即可快速创建用于接收 OTel trace 数据的表。例如,下面是一个用于创建 trace span 表结构的命令示例:

docker run streamfold/rotel-clickhouse-ddl create \    --endpoint https:    
复制代码

此外,也可以像我们在本篇文章中所做的那样,使用 Null 表引擎来创建 schema,以便进行基准测试:

docker run streamfold/rotel-clickhouse-ddl create \    --endpoint https:        
复制代码

参考资料

/END/

征稿启示

面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出 &图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com。

本文分享了扩展云和分布式应用程序的目标与策略,重点介绍了摩根大通(JPMorgan Chase)旗下Chase.com在云迁移过程中汲取的经验教训。

 

讨论围绕三个核心目标展开,详细阐述了实现这些目标的具体策略,最后说明了这些方法在实践中的落地方式。对于管理大规模系统的从业者而言,这些经验源自我们在摩根大通及其他金融机构多年来的实战积累,具有宝贵的指导意义。

 

在规划的时候,人们通常只会考虑两到三倍的负载增长。然而,一旦系统部署在互联网上,就无法控制入站流量的规模、时间或使用模式。任何事件(无论是源于合法业务增长,还是恶意攻击者的行为)都可能引发巨大的负载激增。这两种情形各自会带来截然不同的挑战。

 

安全控制措施可以阻止恶意流量,但当市场波动引发真实客户需求激增时,情况则有所不同。客户恰恰会在这些情况下需要访问金融交易服务。在系统压力下,多个组件可能同时发生故障;网络设备、负载均衡器、应用程序和数据库连接都可能同时中断。

 

目标

我们的云迁移聚焦于三大核心目标:以成本效益高且高效的方式实现弹性扩展、实现高韧性(这对金融机构尤为重要),以及提供卓越性能,防止因系统迟缓而迫使用户转向其他服务。

 

高效扩展

实现高效性需要分析客户的使用模式和行为。组织必须在保持弹性扩展能力的同时,发展预测能力。

 

流量整形(Traffic shaping)提供了一种识别高频率功能的方法论,从而能够有针对性地对关键应用进行扩展。

 

整体容量管理是另一个关键要素。简单地增加服务器并不能保证成功,还需要仔细权衡成本的因素。

流量模式与容量规划

流量模式是高效扩展的基础。平均流量代表了系统日常处理的基准水平。可预测的模式确实存在,例如,工资入账等周期性事件会促使客户查询账户余额。全年还会出现季节性高峰,这要求提前规划。

 

突发事件则会带来不同的挑战。DDoS 攻击频繁发生,其流量可能超过正常负载十倍甚至更高。攻击者利用的是与合法用户相同的云资源。组织必须在阻断这些攻击的同时,确保合法客户的真实交易仍能满足服务等级协议(SLA)。

 

基于已知模式进行合理的容量规划有助于预防运维方面的问题。然而,弹性扩展存在局限性:在扩展过程中,应用程序需要启动并建立与服务及数据库的连接,而建立连接需要时间。从实例启动到完全就绪,可能已经过去数分钟。若大量请求恰好在此期间涌入,系统将面临资源争用的问题。

 

因此,不能仅依赖弹性扩展,而应该全面考虑完整的运维图景,包括流量模式及相关因素。预留计算容量(Reserved compute capacity)可以应对这些挑战。预留资源能在需要时保证可用性,尤其在多租户共享资源池出现争用时更为关键。此外,预留计算还能带来成本节约。

 

成本管理需要进行持续关注。应该定期(如每月或每周)应用 FinOps 流程,而非偶尔为之。

超越单纯增加服务器的扩展

扩展不应该局限于简单地增加服务器。当发生扩展时,有一个根本性的问题,那就是,应用程序是否真的因为真实客户的需求而需要扩展,还是因为上游服务排队导致响应变慢?当线程等待响应而无法执行时,CPU 和内存的压力会上升,即使实际需求并未增长,也会触发弹性扩展。

 

这种场景要求我们在设计中考虑容错,并将其与扩展策略整合。断路器(Circuit breaker)在此过程中会发挥关键作用。当上游服务变慢或失败时,断路器可以防止应用无限期等待响应,而是强制设置超时限制:系统要么在限定窗口内收到成功响应,要么快速失败并继续处理。这种设计可避免线程耗尽、减少不必要的资源消耗,并防止错误地触发扩展。如果没有断路器的话,缓慢的依赖项可能会引发全系统的性能退化,导致弹性扩展,从而添加更多无法解决根本问题的服务器。

高韧性

韧性(Resiliency)要求为不可避免的系统故障做好准备。早期检测和随时执行故障转移程序至关重要。然而,为所有组件实现 100%的可用性既不现实,也无必要。

 

基础设施可根据关键性分为四个层级。关键(Critical)类的组件必须尽可能接近 100%可用。DNS 就属此类,无论网站架构多么完善,DNS 如果出现故障将会导致所有访问中断。

 

可管理(Manageable)层的组件在发生故障时可通过故障转移维持运行,目标为“四个九”的可用性(99.99%,即每年可接受约 52 分钟的停机时间)。

 

可容忍(Tolerable)层的组件具备内置韧性。例如,缓存长期数据的令牌服务,若服务在缓存有效期内不可用,系统仍可使用已缓存的数据继续运行。

 

最后,可接受(Acceptable)层的组件允许有限的数据丢失,比如,某些日志系统。韧性目标由影响的严重程度决定。

性能

性能会显著影响用户体验和基础设施成本,但并非所有应用程序的表现完全相同。通过部署接入点(Point of Presence, PoP)可以提升用户体验,因为它对网站延迟(尤其在移动设备上)尤为敏感。

 

速度至关重要,因为它能建立用户信任,用户期望更快、更好的体验。谷歌等搜索引擎已经认识到这一点,并将速度纳入其排名算法。在网络连接受限的场景下,移动端性能尤为关键。从基础设施角度看,客户完成相同任务所花费的时间越少,运营成本就越低。

 

我们通过实施全面的性能策略,从初始部署到完整架构落地,系统延迟降低了 71%。这些策略可适配其他业务场景。

五大核心策略

架构方法围绕五个重点领域展开:多区域部署、高性能优化、全面自动化、具备自愈能力的可观测性,以及强大的安全性。

多区域部署

多区域架构通过隔离和分段实现功能化的解耦。这种方法有助于管理区域故障、可用区故障和网络故障,并限制故障的爆炸半径(blast radius)。面对 9400 万客户,可用区级别的故障可被限制为仅影响一小部分用户,而非全部用户。

 

实现多区域架构需要解决 DNS 管理的问题,因为不同区域拥有独立的负载均衡器,需要协调一致。还需要确定区域间流量的调度策略。在包含多个可用区的区域内,也需选择流量的分配方式。

可用区故障

假设一个负载均衡器将流量分发至同一区域内的两个可用区。两个应用均报告健康状态,可用区看似正常,流量持续流入。然而,如果其中一个可用区的应用与后端系统连接异常,而另一可用区正常,流量仍将流入受损的可用区。若应用虽实现了就绪(readiness)和存活(liveness)探针,却未将依赖系统状态纳入健康检查,那么就有可能出现问题。缺乏适当的反馈机制时,负载均衡器会继续路由流量,导致应用失败。

针对这种情况,解决方案包括将依赖系统的健康状态通过就绪或存活探针反馈给负载均衡器,或采用基于代理的重路由机制将流量导向正常的可用区。这需要有效管理内外部故障,以应对应用停机。

区域性的故障

在多区域部署中,我们依赖统一的区域健康脉搏检查(每 10 秒执行一次),以确保对区域健康状况的一致性和及时可见性。在这里,关键的决策在于,故障是否需要完全切换至备用区域,或者降级服务是否可接受。降级服务的可行性取决于应用的分段情况。若关键服务(如仪表盘首页)失效,则需要故障转移;若非关键组件失效,那么可以继续运行以避免更大的影响。但故障转移会引发“惊群效应”(thundering herd),例如,整个区域失效时,重定向流量突增可能压垮剩余区域,而自动扩展需要时间才能提供额外的容量。健康检查标准(包括失败与成功阈值)决定了对应的响应策略。

多区域的挑战

跨区域的数据复制与确保数据一致性是主要的关注点。当数据中心位置有限而客户遍布全国时,客户分片(customer sharding)是一种可行的方案:将客户按地理位置分片,并由就近的数据中心提供服务,这样可以减少复制的需求,并简化架构。

 

状态管理需要战略性的决策。为活跃会话维护会话亲和性(session affinity),并在必要时支持故障转移,这有助于高效运行。

高性能

高性能对用户体验至关重要。良好的性能如同可靠的拨号音,用户期望即时响应,不容延迟。边缘计算是实现性能目标的主要手段。现代网站具有复杂的用户界面,内容密集。我们可将静态内容卸载至靠近客户的入网点(Point of Presence,PoP),而源服务器(origin server)仅处理动态操作和关键服务,如登录、账户、支付等。

流量整形(traffic shaping)可以对流量进行分类。关键流量指的是支撑业务运营的核心功能,比如,客户日常的登录、余额查询和支付。分配给关键服务的资源必须始终保持运行。在压力条件下,即便其他流量出现降级也是可以接受的。

内容分发

地理分布会显著影响性能。如果每次资源请求都需要跨越很长的距离,物理网络的屏障将会造成显著的延迟。如果相同的内容已在 PoP 缓存,检索可在 100 毫秒内完成,远优于访问源站所需的较长响应时间(比如,大于 500 毫秒)。性能提升的同时会带来安全方面的收益,因为恶意的流量可以在边缘被拦截。

 

“最后一公里连接(last-mile connectivity)”的问题值得关注。互联网通信涉及多个网络跳转。边缘计算改变了这一模式,从用户到边缘节点通常只需要一跳,再结合优化的网络传输,这样所带来的效率远高于标准的 ISP-to-ISP 连接。

 

移动应用也有优化的空间。移动设备具备本地存储,可用于缓存网络解析结果、配置设置和预抓取的内容。

自动化

自动化是关键的战略元素。在整个流水线的各个阶段实施全面自动化可带来巨大收益,这需要涵盖部署、基础设施供应、环境配置、集成自动化操作的健康检查,以及整体的流量管理。

架构不能止步于文档。通过创建“带有倾向性的(opinionated)”架构模板,可以帮助团队构建自动继承架构标准的应用。应用通过基于清单(manifest)定义进行自动化部署,这样能够让团队聚焦业务功能,而非基础设施工具的复杂性。

基础设施“重铺”

基础设施“重铺(repaving)”是一种高效的实践,即在每个迭代周期系统性地重建基础设施。自动化的流程会定期清理运行中的实例。这种方式能够通过消除配置漂移(configuration drift)来增强安全性。当存在漂移或需要应用补丁(包括零日漏洞修复)时,所有更新均可系统性地实现。

 

长期运行会导致资源陈旧、性能下降和安全漏洞。我们可以定期(如每周或每两周)自动重建环境,步骤大致如下:先优雅地移除流量,再重建环境并重启服务,从而保障运维的稳定性。

 

重铺实现涉及多个组件:自动化脚本监控实例的生命周期;基于时间的有效性触发器会移除路由,阻止新请求进入但允许现有请求完成;随后关闭实例、清理节点并创建新实例。创建新实例时,可以使用更新后的镜像,以解决零日(zero-day)漏洞并添加安全补丁,也可以仅简单地重建实例。具体的操作由策略决定。所有流程均自动化完成,在重铺前会移除流量,确保对客户不会产生任何影响。

 

自动化故障转移

具备优雅降级能力的自动化故障转移需要考虑活跃的会话。对于正在进行处理的客户,会话的处理方式不同于新的会话,需要进行特殊路由。除此之外,必须防止故障转移循环,如果两个区域均不健康,持续切换只会加剧问题。不同场景对延迟容忍度不同;非关键服务故障时,可在受影响区域继续运行。

 

可观测性与自愈能力

可观测性要求对观测到的事件进行自动化的响应。云环境在各组件中会产生大量事件,比如系统事件、基础设施事件和应用事件。所有的可观测事件都需要自动处理。自动化会通过无服务器函数与可观测性进行集成,也就是在事件触发后,函数自动执行,并且会根据预设的条件切换在哪个区域执行。

 

数据库问题会触发独立的数据库切换函数;维护活动可以触发函数以屏蔽特定区域或虚拟私有网络(virtual private cloud,VPC)。这些示例场景展示了如何实现自动化行为,同时确保与可观测性的集成。仪表盘监控能够提供辅助价值,但不应该作为主要的响应机制。

健康检查

健康监控需要在多个层级进行。在应用层,健康判断可能涉及复杂的评估,不仅要检查应用本身是否正常运行,还包括与数据库、缓存及其他系统的连接是否通畅。健康检查器内部可以包含复杂的逻辑,但返回状态必须简单,仅用布尔值表示健康或不健康状态即可。

 

应用内的健康检查要向上传播至可用区这一层级,它要检查所有的实例。然后,这个信息转移至 VPC 层级,以评估整体 VPC 的健康状况,最终输入全局的路由器。每一层级均通过简单的布尔指标实现自动化健康评估,从而支持快速决策。该方法通过系统性健康检查以实现自愈的能力。

决策标准

我们可能会遇到如下的场景,告警指示节点不可用且容量受损,这可能是供应商的问题,流量需要从受影响的 VPC 进行重定向;应用告警显示延迟问题且性能受损,组织需要根据业务需求决定是继续提供降级服务,还是满足服务等级协议(service level agreement,SLA)的要求。在这样的场景中,选择降级服务意味着接受较慢的性能,而非切换至可能存在相同问题的其他可用区。

“灰度故障”(gray failures)指的是故障确定存在但连接仍存在的模糊故障场景。网络相关的故障更难诊断。当某项业务功能受损时,可以考虑将流量重路由至健康的可用区。可以基于可观测性数据执行多种应对措施。

健壮的安全性

安全需要采用零信任模型的分层实现。每一层必须独立运作,假定其他层均可能失效。客户端设备可能会被恶意软件攻陷;边界安全功能需要在边缘实现过滤和 Web 应用防火墙;内部网络需要分段和隔离;容器安全方面,需要进行镜像扫描并采用最小权限原则;应用安全方面,需要确保正确地认证与授权;数据安全方面,要实施加密与隐私控制。各层之间要实现互相强化。

迁移

文化转型是成功迁移的基础,因为云运维与传统的企业自建系统存在根本性的差异。云服务商会持续更新服务,网络策略在不断演进,浏览器也在不断变化,诸多因素都要求我们持续适应。Well-Architected Framework 及相关原则在这方面提供了指导。

 

“谁构建、谁拥有、谁部署”的所有权模型将责任赋予了应用团队。人为错误与疏忽不可避免,而自动化可以确保一致性。

测试与验证

测试方法各异。Chaos Monkey 等工具通过向运行中的系统注入故障实现反应式测试;失效模式与影响分析(FMEA,Failure Mode and Effects Analysis)则通过系统性组件评估进行预测性分析,识别潜在的故障并制定缓解策略。这两种方法均有它的价值,但 FMEA 更适用于在各应用层进行全面测试,确保能够开发分析与缓解策略。

 

公司开发了名为 TrueCD 的 CI/CD 方法论,这是一套包含了十二个步骤的自动化流程,相关文档已经在官方博客进行详细阐述。该流程类似于航空业的飞行前安全检查。

抽象层

从企业自建环境向云迁移会影响应用的架构。应用包含了大量的业务逻辑,持续变更可能对业务运维产生连锁影响。抽象层可以最大限度地减少此类影响。该架构方法可在单云、多云、自建环境或混合环境中灵活选用业界最佳组件。Dapr是一个广受认可的开源框架,支持多云架构。

迁移客户流量

大型应用的迁移无法一蹴而就。在初期,可以先在内部用户群体中验证系统,使应用趋于稳定。压缩时间表往往会适得其反,因为某些问题和使用模式需要长期观察才能发现。应用需要充足的运行时间以完成优化。

 

面对庞大的功能集,在有限时间内完成所有功能可能不现实。将系统拆分为离散应用集可以应对该挑战。在迁移的各个阶段,可逐步将小比例的客户群体进行迁移,最终再实现全面迁移。

结果

这些策略的实施带来了可衡量的成果:显著降低成本,性能指标大幅提升,平台在对比分析中名列前茅。Dynatrace 的公开报告对美国银行网站进行了比较,它指出实现亚秒级(<1 秒)响应的站点代表了最优的性能。

结论

从这些策略中可以提炼出一些关键的考量因素。权衡是不可避免的,我们需要综合考虑成本与性能,同时不损害其他的需求。例如,在多区域架构中,需要评估缓存复制策略:是在单区域还是多区域维护缓存?运维的复杂性随云架构组件的增多而上升。降低复杂性、减少应用监控中的人工干预至关重要,而自动化是实现这一目标的关键机制。

控制故障的爆炸半径始终至关重要。站点必然会遇到各种问题,组件难免失效。关键在于故障发生时的影响范围,是波及所有客户,还是仅限一小部分?这一关注点至关重要。我们必须建立面向行动的可观测性,并与自动化操作紧密关联起来。

 

所有决策必须以客户为中心。业务运营服务于客户。请思考“拨号音体验”:拿起电话,用户期望立即听到拨号音。应用亦应如此,用户打开移动应用,理应立即看到结果。

 

核心原则:智能扩展,保持可靠性。当下一次流量激增不可避免地到来时,系统弱点必将暴露出来。这些策略的直接目标在于,当流量激增时,关键组件必须能够保持运行,核心系统必须维持响应能力,客户必须像往常一样获得即时响应。

 

原文链接:

Scaling Cloud and Distributed Applications: Lessons and Strategies

总有人说 MoonTV 卡,其实我自己也觉得挺卡的,归根结底就是因为原版设计的存储结构有缺陷。

一个号有 80 条播放记录响应 10 秒才返回数据,能不慢吗,还有一些其他的问题就不多说了
现在优化到基本上返回都在 1s 以内,体验可谓是大幅提高,没更新的都建议更新一下,速度变快超多。

还加了不少功能,详情可以看 changelog

最近应该会暂时停更了,被某个二开抄袭,我上啥新功能他就抄啥


📌 转载信息
转载时间:
2026/1/1 15:31:19