Hi there 👋

Keep It Simple Stupid!

关于自适应并发控制算法的落地实践总结

1. 背景 (Background) 在可观测性系统中,数据流量通常呈现出极度的不均匀性。例如,白天业务高峰期的流量往往远超夜间,或者在进行全链路压测时,流量会瞬间激增至平时的数倍。这种流量的剧烈波动给固定并发模型带来了两难困境: 低负载场景:当流量较小时,若并发限制设置过低,无法充分利用后端的处理能力,导致数据发送延迟和资源浪费。 高负载场景:当流量爆发时,若并发限制设置过高,瞬间涌入的请求会导致后端服务压力过大,甚至引发雪崩效应(Crash)。 鉴于此,为了实现数据高效且稳定的传输,我们在发送端(采集端)引入了自适应并发控制算法。该机制能够根据实时网络状况和后端响应反馈,动态调整最大并发请求数(Limit),从而在保护后端系统稳定性的同时,最大化数据吞吐量。 1. 架构设计 (Architecture) 在原有的发送逻辑之上,引入了一个独立的 Limiter(限流)层。其核心工作流程如下: 准入控制:采用类似经典限流器的模式。每次执行 send 操作前,请求必须阻塞等待,直到成功获取令牌(Permit);操作结束后释放令牌。 指标反馈:Sender 端在请求完成后,必须返回两个关键指标:RTT (往返时延) 和 didDrop (是否被丢弃/限流)。 动态调整:Limiter 根据反馈的 RTT 和丢包情况,实时动态调整并发限制数(Limit)。 ⚠️ 注意: 现有架构通过预先建立“并发池”来实现异步发送。若并发池容量小于 Limiter 计算出的 Limit 值,会导致实际 Inflight(在途)请求数受限于池大小,从而无法达到最大吞吐量。 因此,当开启自适应并发功能时,系统将忽略 queue_concurrency 配置项,完全交由 Limiter 接管并发控制。 2. 核心算法 (Core Algorithms) 2.1 Vegas 算法 该算法起源于 TCP Vegas。其核心思想是:只要服务内部队列(或线程池)未满,请求的处理延迟通常保持稳定;一旦延迟增加,说明队列开始积压。 理论基础:Little’s Law 基于排队论中的重要公式 Little’s Law: $$ L = \lambda W $$ 其中: $L$:队列长度 (Queue Size) $\lambda$:请求到达速率 (Arrival Rate) $W$:等待时间 (此处指 RTT) 利用此公式,我们可以通过 RTT 估算对端服务内部的队列积压情况,进而判断服务端压力并动态调节 Limit。...

十月 12, 2025 · 1 分钟 · wmingj

Go 性能优化实战:从并行瓶颈到高效流水线

🛠️ 课前小贴士:Go Tool Trace 快捷键 在进行性能分析前,掌握 go tool trace 的视图操作非常重要。以下是常用的快捷键: 缩放视图:w (放大), d (缩小) 或按住 Alt + 鼠标滚轮。 移动视图:a (左移), s (右移)。 问题背景 场景描述 在 Agent 数据采集任务中,我们采用经典的 Pipeline 架构:Readers -> Transformers -> Senders。 其中 Transformer(数据转换)环节支持并行计算。 遇到问题 尽管启用了并行计算,但在高负载场景下,观察到 Agent 的 CPU 使用率始终处于低位(上限仅达到 150%),未能跑满多核 CPU 的性能。 深度分析与诊断 为了定位 CPU 上不去的原因,我们使用了 Profile 和 Trace 工具进行分析: 诊断数据 Profile 分析: chanrecv (通道接收) 和 chansend (通道发送) 的 CPU 占用率显著偏高(约占 10%)。这说明大量 CPU 时间消耗在调度通信上,而非实际计算上。 Goroutine 分析: 系统在运行时产生了数万个 Goroutine。过多的 Goroutine 导致了巨大的调度开销。 Trace 分析(关键证据):...

六月 23, 2025 · 2 分钟 · wmingj

在Go中如何访问和修改私有对象

我们都知道,基本上所有主流编程语言都支持对变量、类型以及函数方法等设置私有或公开,从而帮助程序员设计出封装优秀的模块。然而,实际开发中,难免需要使用第三方包的私有函数或方法,或修改其中的私有变量、熟悉等。在可观测性数据采集器开发中,由于集成了很多采集插件,经常需要魔改其中的代码,因此我对Go语言中如何修改这些私有对象的方式做了一个总结,以供后续参考。 方式方法 修改指针 指针本质上就是一个内存地址,这种方式下,我们通过对指针的计算(如果你有C/C++的经验,想必对指针运算一定有所耳闻),从而找到目标对象的内存地址,进而可以获取并修改指针所指向对象的值。 Examples: // pa/a.go package pa type ExportedType struct { intField int stringField string flag bool } func (t *ExportedType) String() string { return fmt.Sprintf("ExportedType{flag: %v}", t.flag) } // main/main.go func main() { et := &pa.ExportedType{} fmt.Printf("before edit: %s\n", et) ptr := unsafe.Pointer(et) // line 1 flagPtr := unsafe.Pointer(uintptr(ptr) + unsafe.Sizeof(0) + unsafe.Sizeof("")) // line 2 flagField := (*bool)(flagPtr) // line 3 *flagField = true // line 4 fmt....

八月 12, 2024 · 2 分钟 · wmingj

ClickHouse的分布式实现

当我们需要在实际生产环境中使用ClickHouse时,高可用与可扩展是绕不开的话题,因此ClickHouse也提供了分布式的相关机制来应对这些问题。在下文中,我们将主要从副本机制、分片机制两个个方面来对齐进行介绍。 副本机制 ClickHouse通过扩展MergeTree为ReplicatedMergeTree来创建副本表引擎(通过在MergeTree添加Replicated前缀来表示副本表引擎)。这里需要注意的是,副本表并非一种具体的表引擎,而是一种逻辑上的表引擎,实际数据的存取仍然通过MergeTree来完成。 注意:这里,我们假定集群名为local,且包含两个节点chi-0和chi-1 建表 ReplicatedMergeTree通过类似如下语句进行创建: CREATE TABLE table_name ( EventDate DateTime, CounterID UInt32, UserID UInt32, ver UInt16 ) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{cluster}-{shard}/table_name', '{replica}', ver) PARTITION BY toYYYYMM(EventDate) ORDER BY (CounterID, EventDate, intHash32(UserID)) SAMPLE BY intHash32(UserID); 有两个参数需要重点说明一下,分别为zoo_path和replica_name参数: zoo_path: 表示表所在的zk路径 replica_name: 表示副本名称,通常为主机名 ClickHouse会在zk中建立路径zoo_path,并在zoo_path的子目录/replicas下根据replica_name创建副本标识,因此可以看到replica_name参数的作用主要就是用来作为副本ID。 我们这里假定首先在chi-0节点上执行了建表语句 其首先创建一个副本实例,进行一些初始化的工作,在zk上创建相关节点 接着在/replicas节点下注册副本实例chi-0 启用监听任务,监听/log节点 参与leader节点选举(通过向/leader_election写入数据,谁先写入成功谁就是leader) 接着,我们在chi-1节点上执行建表语句: 首先也是创建副本实例,进行初始化工作 接着在/replicas节点下注册副本实例chi-1 启用监听任务,监听/log节点 参与leader节点选举(此时由于chi-0节点上已经执行过建表流程了,因此chi-0为leader副本) /log节点非常重要,用来记录各种操作LogEntry包括获取part,合并part,删除分区等等操作 写入 接着,我们通过执行INSERT INTO语句向chi-0节点写入数据(当写入请求被发到从节点时,从节点会将其转发到主节点)。 此时,会首先在本地完成分区数据的写入,然后向/blocks节点写入该分区的block_id block是ClickHouse中的最小数据单元,这里在/blocks节点中写入block_id主要是为了后续数据的去重 接着向/log节点推送日志,日志信息如下所示: format version: 4 create_time: 2022-09-04 14:30:58 source replica: chi-0 block_id: 20220904_5211346952104599192_1472622755444261990 get 20220904_269677_269677_0 part_type: Compact ....

四月 10, 2023 · 2 分钟 · wmingj

Clickhouse MergeTree解读

在众多的ClickHouse表引擎中,当属MergeTree(合并树)最为常用也最为完备,适用于中绝大部分场景,因此搞懂MergeTree对与理解ClickHouse至关重要! 在本文中,我将通过主要从数据模型、数据写入、数据读取3个方面来阐述MergeTree的实现 本文需要读者具备一定的ClickHouse使用经验,譬如建表、写入、查询等 数据模型 在MergeTree引擎底层实现中,从上至下主要有以下3种数据模型组成:Part、Block、PrimaryKey Part 这里需要注意的时,Part不是Partition,对于一张表来说: Part是用来存储一组行对应于,在磁盘上对应于一个数据目录,目录里有列数据、索引等信息 Partition则是一种虚拟的概念,在磁盘上没有具体的表示,不过可以说某个Partition包含多个Part 在建表的DDL中,我们可通过PARTITION BY参数来配置分区规则,ClickHouse会根据分区规则生成不同分区ID,从而在写入时将数据落盘到对应分区中。一但有数据写入,ClickHouse则根据分区ID创建对应的Part目录。 其中目录的命名规则为{PartiionID}_{MinBlockNum}_{MaxBlockNum}_{Level}: PartiionID:即为分区ID MinBlockNum:表示最小数据块编号,后续解释 MaxBlockNum:表示最大数据块编号,后续解释 Level:表示该Part被合并过的次数,对于每个新建Part目录而言,其初始值为0,每合并一次则累积加1 目录中的文件主要包括如下部分: 数据相关:{Column}.mrk、{Column}.mrk2、{Column}.bin、primary.idx(mrk, mrk2应该是版本不同) 二级索引相关:skp_idx_{Column}.idx、skp_idx_{Column}.mrk 此外,每个Part在逻辑上被划分为多个粒度(粒度大小由index_granularity或index_granularity_bytes控制);而在物理上,列数据则被划分为多个数据块。 Block Block即为数据块,在内存中由三元组(列数据,列类型,列名)组成。是ClickHouse中的最小数据处理单元,例如,在查询过程中,数据是一个块接着一个块被处理的。 而在磁盘上,其则通过排序、压缩序列化后生成压缩数据块并存储于{Column}.bin中,其中表示如下所示: 其中,头信息(Header)部分包含3种信息: CompressionMethod:Uint8,压缩方法,如LZ4, ZSTD CompressedSize:UInt32,压缩后的字节大小 UncompressedSize:UInt32,压缩前的字节大小 其中每个数据块的大小都会被控制在64K-1MB的范围内(由min_compress_block_size和max_compress_block_size指定)。 这里我们为什么要将{Column}.bin划分成多个数据块呢?其目的主要包括: 数据压缩后虽然可以显著减少数据大小,但是解压缩会带来性能损耗,因此需要控制被压缩数据的大小,以求性能与压缩率之间的平衡(这条我也不太理解,还请评论区大佬指教:)) 当读取数据时,需要将数据加载到内存中再解压,通过压缩数据块,我们可以不用加载整个.bin文件,从而进一步降低读取范围 PrimaryKey 主键索引(Primary Key)是一张表不可或缺的一部分,你可以不指定,但是这会导致每次查询都是全表扫描从而几乎不可用。 PrimaryKey主要是由{Column}.mrk,primary.idx和{Column}.bin三者协同实现,其中: primary.idx:保存主键与标记的映射关系 {Column}.mrk:保存标记与数据块偏移量的映射关系 {Column}.bin:保存数据块 具体实现可以参考我之前的文章 数据写入 ClickHouse的数据写入流程是比较简单直接的,整体流程如下图所示: 每收到写入请求,ClickHouse就会生成一个新的Part目录,接着按index_granularity定义的粒度将数据划分,并依次进行处理,生成primary.idx文件,针对每一行生成.mrk和.bin文件。 合并 写入结束后,ClickHouse的后台线程会周期性地选择一些Part进行合并,合并后数据依然有序。 在上文中,我们提到的MinBlockNum此时会取各个part中的MinBlockNum最小值,而MaxBlockNum则会取各个part中的MinBlockNum最小值。例如201403_1_1_0和201403_2_2_0合并后,生成的新part目录为201403_1_2_1。 查询 查询的过程本质上可以看做是不断缩小数据扫描的过程,流程如下图所示: 当ClickHouse收到查询请求时,其会首先尝试定位到具体的分区,然后扫描所有的part,然后通过part目录中的一级、二级索引定位到标记,再通过标记找到压缩数据块,并将其加载到内存中进行处理。 此外,为了提升查询性能,ClickHouse还是用了vectorized query execution和以及少量runtime code generation技术,从CPU层面提升性能(这块内容比较多,这里就不详解了,后续我将尝试再写一篇博客来介绍)。 总结 本文,我们首先从数据模型层面自顶向下分别介绍了分区、Part、Block、PrimaryKey,它们构建起了MergeTree的总体框架。然后,我们分别介绍了数据写入与数据查询流程,将数据模型串联起来,并详细介绍了它们之间是如何相互协同的。 总体看来,MergeTree实现上还是比较简单易懂的,希望本文能对你有所帮助 参考 https://stackoverflow.com/questions/60142967/how-to-understand-part-and-partition-of-clickhouse https://clickhouse.com/docs/en/intro/ 《ClickHouse原理解析与应用实践》

十二月 22, 2022 · 1 分钟 · wmingj