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

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

浅析Go内存分配器的实现

为什么需要内存分配器? 总说周知,内存作为一种相对稀缺的资源,在操作系统中以虚拟内存的形式来作为一种内存抽象提供给进程,这里可以简单地把它看做一个连续的地址集合{0, 1, 2, ..., M},由栈空间、堆空间、代码片、数据片等地址空间段组合而成,如下图所示(出自CS:APP3e, Bryant and O’Hallaron的第9章第9节) 这里我们重点关注Heap(堆),堆是一块动态的虚拟内存地址空间。在C语言中,我们通常使用malloc来申请内存以及使用free来释放内存,也许你想问,这样不就足够了吗?但是,这种手动的内存管理会带来很多问题,比如: 给程序员带来额外的心智负担,必须得及时释放掉不再使用的内存空间,否则就很容易出现内存泄露 随着内存的不断申请与释放,会产生大量的内存碎片,这将大大降低内存的利用率 因此,正确高效地管理内存空间是非常有必要的,常见的技术实现有Sequential allocation, Free-List allocation等。那么,在Go中,内存是如何被管理的呢? 注:此为Go1.13.6的实现逻辑,随版本更替某些细节会有些许不同 实现原理 Go的内存分配器是基于TCMalloc设计的,因此我建议你先行查阅,这将有利于理解接下来的内容。 大量工程经验证明,程序中的小对象占了绝大部分,且生命周期都较为短暂。因此,Go将内存划分为各种类别(Class),并各自形成Free-List。相较于单一的Free-List分配器,分类后主要有以下优点: 其一方面减少不必要的搜索时间,因为对象只需要在其所属类别的空闲链表中搜索即可 另一方面减少了内存碎片化,同一类别的空闲链表,每个对象分配的空间都是一样大小(不足则补齐),因此该链表除非无空闲空间,否则总能分配空间,避免了内存碎片 那么,Go内存分配器具体是如何实现的呢?接下来,我将以自顶向下的方式,从宏观到微观,层层拨开她的神秘面纱。 数据结构 首先,介绍Go内存分配中相关的数据结构。其总体概览图如下所示: heapArena 在操作系统中,我们一般把堆看做是一块连续的虚拟内存空间。 Go将其划分为数个相同大小的连续空间块,称之arena,其中,heapArena则作为arena空间的管理单元,其结构如下所示: type heapArena struct { bitmap [heapArenaBitmapBytes]byte spans [pagesPerArena]*mspan ... } bitmap: 表示arena区域中的哪些地址保存了对象,哪些地址保存了指针 spans: 表示arena区域中的哪些操作系统页(8K)属于哪些mspan mheap 然后,则是核心角色mheap了,它是Go内存管理中的核心数据结构,作为全局唯一变量,其结构如下所示: type mheap struct { free mTreap ... allspans []*mspan ... arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena ... central [numSpanClasses]struct { mcentral mcentral pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte } } free: 使用树堆的结构来保存各种类别的空闲mspan allspans: 用以记录了分配过了的mspan arenas: 表示其覆盖的所有arena区域,通过虚拟内存地址计算得到下标索引 central: 表示其覆盖的所有mcentral,一共134个,对应67个类别 mcentral 而mcentral充当mspan的中心管理员,负责管理某一类别的mspan,其结构如下:...

六月 15, 2021 · 1 分钟 · wmingj

Golang内存优化实践指南

最近做了许多有关Go内存优化的工作,总结了一些定位、调优方面的套路和经验,于是,想通过这篇文章与大家分享讨论。 发现问题 性能优化领域有一条总所周知的铁律,即:不要过早地优化。编写一个程序,首先应该保证其功能的正确性,以及诸如设计是否合理、需求等是否满足,过早地优化只会引入不必要的复杂度以及设计不合理等各种问题。 那么何时才能开始优化呢?一句话,问题出现时。诸如程序出现频繁OOM,CPU使用率异常偏高等情况。如今,在这微服务盛行的时代,公司内部都会拥有一套或简单或复杂的监控系统,当系统给你发出相关告警时,你就要开始重视起来了。 问题定位 1. 查看内存曲线 首先,当程序发生OOM时,首先应该查看程序的内存使用量曲线,可以通过现有监控系统查看,或者prometheus之类的开源工具。 曲线一般都是呈上升趋势,比如goroutine泄露的曲线一般是使用量缓慢上升直至OOM,而内存分配不合理往往时在高负载时快速攀升以致OOM。 2. 问题复现 这块是可选项,但是最好能保证复现。如果能在本地或debug环境复现问题,这将非常有利于我们反复进行测试和验证。 3. 使用pprof定位 Go官方工具提供了pporf来专门用以性能问题定位,首先得在程序中开启pprof收集功能,这里假定问题程序已开启pprof。(对这块不够了解的同学,建议通过这两篇文章(1, 2)学习下pprof工具的基本用法) 接下来,我们复现问题场景,并及时获取heap和groutine的采样信息。 获取heap信息: curl http://loalhost:6060/debug/pprof/heap -o h1.out 获取groutine信息:curl http://loalhost:6060/debug/pprof/goroutine -o g1.out 这里你可能想问,这样就够了吗? 当然不是,只获取一份样本信息是不够的。内存使用量是不断变化的(通常是上升),因此我们需要的也是期间heap、gourtine信息的变化信息,而非瞬时值。一般来说,我们需要一份正常情况下的样本信息,一份或多份内存升高期间的样本信息。 数据收集完毕后,我们按照如下3个方面来排查定位。 排查goroutine泄露 使用命令go tool pprof --base g1.out g2.out ,比较goroutine信息来判断是否有goroutine激增的情况。 进入交互界面后,输入top命令,查看期间goroutine的变化。 同时可执行go tool pprof --base g2.out g3.out来验证。我之前写了的一篇实战文章,记录了goroutine泄露的排查过程。 排查内存使用量 使用命令go tool pprof --base h1.out h2.out,比较当前堆内存的使用量信息来判断内存使用量。 进入交互界面后,输入top命令,查看期间堆内存使用量的变化。 排查内存分配量 当上述排查方向都没发现问题时,那就要查看期间是否有大量的内存申请了,以至于GC都来不及回收。使用命令go tool pprof --alloc_space --base h1.out h2.out,通过比较前后内存分配量来判断是否有分配不合理的现象。 进入交互界面后,输入top命令,查看期间堆内存分配量的变化。 一般来说,通过上述3个方面的排查,我们基本就能定位出究竟是哪方面的问题导致内存激增了。我们可以通过web命令,更为直观地查看问题函数(方法)的完整调用链。 问题优化 定位到问题根因后,接下来就是优化阶段了。这个阶段需要对Go本身足够熟悉,还得对问题程序的业务逻辑有所了解。 我梳理了一些常见的优化手段,仅供参考。实际场景还是得实际分析。 goroutine泄露 这种问题还是比较好修复的,需要显式地保证goroutine能正确退出,而非以一些自以为的假设来保证。例如,通过传递context.Context对象来显式退出 go func(ctx context.Context) { for { select { case <-ctx....

一月 9, 2021 · 1 分钟 · wmingj