解决MySql布尔型新旧版本兼容问题,采用枚举来表示布尔型的数据表。由正向工程赋值
大石头 authored at 2018-05-15 21:21:05
10.23 KiB
X
# APM 埋点性能测试报告 > 测试日期:2026-10-11 | 口径:单机微基准(BenchmarkDotNet,Release 模式) > 基准代码:`Benchmark/TracingBenchmarks/` | 原始输出:`BenchmarkDotNet.Artifacts/results/` ## 测试目标 量化两件事,为"是否/如何接入 APM"提供数据依据: 1. NewLife 原生埋点(`ITracer`,星尘侧)相对**完全不用 APM** 增加多少开销; 2. 在此基础上再叠加 **OpenTelemetry 输出**增加多少开销。 ## 测试环境 | 项 | 值 | |---|---| | OS | Windows 10 (10.0.19045.6456 / 22H2) | | CPU | Intel Core i9-10900K @ 3.70GHz,10 物理核 / 20 逻辑核 | | 运行时 | .NET 10.0.12(X64 RyuJIT,x86-64-v3) | | GC | Concurrent Workstation | | 工具 | BenchmarkDotNet 0.15.8 | | Job | LaunchCount=1、WarmupCount=3、IterationCount=10 | | 内存诊断 | `[MemoryDiagnoser]`(Allocated 为每次操作的托管分配量) | ## 测试方法 **同一份被测方法体,只切换跟踪器档位**,保证各档严格可比: | 档位 | 配置 | |---|---| | None | `tracer = null`。`Tracer?.NewSpan()` 被 `?.` 短路,代表"不用 APM" | | Star | `DefaultTracer`。NewLife 原生本地聚合;`StarTracer` 的每片段路径与它相同(仅覆盖周期上报) | | OtelNoListener | `OpenTelemetryTracer`,进程内没有任何 `ActivityListener` | | OtelListener | `OpenTelemetryTracer` + 一个真实的活动监听者(`AllDataAndRecorded`) | | Dual | `OpenTelemetryTracer { Inner = DefaultTracer }`,OTel 输出 + 星尘双写 | 四种真实埋点形态:**空埋点**(`using var span = tracer?.NewSpan("op")`)、**带标签**(匿名对象)、**嵌套两层**、**异常埋点**(`SetError`)。 --- ## 测试结果 ### 1. 单线程:每埋点开销 | 形态 | 无 APM | 星尘(Star) | OTel 无监听者 | OTel 有监听者 | 双写(Dual) | |---|---|---|---|---|---| | 空埋点 | 0.02 ns / 0 B | **137 ns / 72 B** | **5.2 ns / 0 B** | **254 ns / 672 B** | **443 ns / 832 B** | | 带标签 | 0.05 ns / 0 B | 144 ns / 72 B | 5.3 ns / 0 B | 1 110 ns / 2 208 B | 2 063 ns / 3 880 B | | 嵌套两层 | 1.4 ns / 0 B | 401 ns / **360 B** | 11.5 ns / 0 B | 1 266 ns / 2 864 B | 2 574 ns / 4 744 B | | 异常埋点 | 0.34 ns / 0 B | 2 815 ns / 2 528 B | 6.4 ns / 0 B | 3 172 ns / 4 536 B | 8 726 ns / 10 064 B | > 无 APM 档位的纳秒数字是 JIT 把空操作消解后的循环开销,跨运行会随机器状态波动,该列只需看分配为 0 B——即**不接 APM 就是零成本**。 ### 2. 8 线程并发(每操作摊算的墙钟时间) | 档位 | 单线程 | 8 线程 | |---|---|---| | 无 APM | 0.02 ns | 0.84 ns | | 星尘(Star) | 137 ns | **125 ns** | | OTel 无监听者 | 5.2 ns | 16.8 ns | | OTel 有监听者 | 254 ns | **57.0 ns** | | 双写(Dual) | 443 ns | **158 ns** | ### 3. OpenTelemetry 采集器(第三方活动转为星尘埋点) | 场景 | Mean | 分配 | |---|---|---| | 未挂采集器(进程内无任何监听者) | 4.61 ns | 0 B | | 已挂采集器,但源不在采集范围内 | **4.57 ns** | 0 B | | 已挂采集器,且源命中被采集 | **431 ns** | 640 B | ### 4. 星尘侧周期成本(`TakeAll`,每采样周期一次) | 周期内累计埋点条数 | Mean | 分配 | |---|---|---| | 100 条 | 6.72 µs | 1.41 KB | | 1 000 条 | 6.78 µs | 1.41 KB | > 两者差异在测量噪声内(Error 分别为 0.46 µs / 0.60 µs):成本只与**不同埋点名的数量**(本例 8 个)有关,与埋点条数无关——这是聚合模型的核心优势。按 60s 采样周期折算完全可忽略。 --- ## 分析 **1. 不接 APM 是真正的零成本。** 埋点位置写法 `Tracer?.NewSpan(...)` 在 `tracer` 为 null 时被 JIT 消解(实测 0.02 ns / 0 B),因此"默认不启用"不会给现有代码带来任何负担。 **2. 一次普通星尘埋点约 0.14 µs / 72 B,而这 72 B 全部来自上下文写入。** 对照真实业务:HTTP 接口 1–10 ms、SQL 数百 µs,0.13 µs 占 0.001%–0.01%;但对**纯内存高频操作**(缓存命中、协议栈每包)就是数量级差异,真正需要关注的是分配速率。用线程级探针(`GC.GetAllocatedBytesForCurrentThread()`,与 `[MemoryDiagnoser]` 同源)逐项归因: | 动作 | 实测 | |---|---| | 对象池 `Get` + `Return` | 0 B | | 构建器字典命中(`BuildSpan`) | 0 B | | `AsyncLocal` 写入但值未变化 | 0 B | | `AsyncLocal` 值发生变化(`DefaultSpan.Current = this`) | **72 B** | | `[ThreadStatic]` 写入(对照) | 0 B | | `new DefaultSpan()`(未走池化) | 112 B | 即**单层埋点的全部分配就是一次 `ExecutionContext` 重建**(约 40 B 的 EC + 32 B 的值映射节点)。`ExecutionContext` 不可变,`AsyncLocal` 值一变就只能新建——这是 .NET 模型下限,不是实现缺陷。探针实测单层 72 B、两层 360 B,与上表 BDN 的 72 B / 360 B 完全一致。 两个必须知道的边界: - **这不是常数,随宿主增长。** 分配量取决于环境 `ExecutionContext` 中已有多少个 `AsyncLocal` 槽位:干净进程 72 B、已有 1 个槽 160 B、已有 3 个槽 **224 B**(模拟真实 Web 宿主:`HttpContext` + `Activity.Current` + 日志作用域各占一槽。为探针模拟值,非真实 ASP.NET Core 实测)。**上表的 72 B 是最好情况,真实宿主应预期 200 B 量级。** - **嵌套更贵,且一半不是上下文。** 实测 1/2/3/4 层 = 72 / 360 / 560 / 760 B,可精确拟合为:**每层 +200 B = 2 次上下文写入(144 B)+ 父片段 `Id` 字符串(56 B)**,其中第 2 层额外 +88 B(父片段 `TraceId` 首次生成)。用"纯 `Current` 操作、不含字符串"的合成序列对照(2 层 216 B / 3 层 360 B)验证了该拟合。也就是说,`Id`/`TraceId` 的惰性生成在链路上并未省掉,只是**推迟到第一个子片段出现时由父片段补付**。 **为什么不能到 0:** 唯一能到 0 的办法是把 `Current` 换成 `[ThreadStatic]`,但那样埋点在 `await` 之后会丢失父级、链路直接断裂;把 `AsyncLocal` 换成"可变的 `StrongBox`"看似 0 B,却会让并行分支共享同一个盒子互相覆盖,属于静默串包。两者都不可接受,故保留当前实现(详见《链路追踪ITracer》收口记录)。10 万埋点/秒按真实宿主的 224 B 折算约 22 MB/s,属可观但可控的成本。 **3. 带标签几乎不额外收费(星尘档)。** 144 ns vs 137 ns,因为 `BuildTag` 仅在采样(`TraceFlag > 0`)时序列化复杂对象,未采样时几乎零成本——这是埋点体系的一个良好设计。 **4. 异常埋点约 20× 于普通埋点(2.8 µs / 2.5 KB)。** `DefaultSpan.SetError` 会为异常**额外生成一条 `ex:Xxx` 埋点**,一次调用产生两份片段与多份异常字符串。异常风暴场景下的埋点成本不可忽略。 **5. OpenTelemetry 输出的分配高于星尘档,"带标签"差距尤其明显。** - 空埋点:OTel 254 ns / 672 B vs 星尘 137 ns / 72 B(OTel 的分配为 9.3×) - 带标签:OTel **1 110 ns** vs 星尘 144 ns —— **7.7×**。原因是 OTel 语义要求标签变成活动属性,**必须**先序列化;星尘档在未采样时不序列化 - 异常:3 172 ns vs 2 815 ns(相差约 13%) **6. 双写成本 ≈ 两者相加,没有隐藏损耗。** 443 ns vs 254 + 137 = 391 ns,说明装饰器实现未引入额外同步或重复序列化。 **7. 采集器:过滤几乎免费,命中后按活动收费。** 挂上采集器后,源不在采集范围内的活动仅 4.57 ns(与完全无监听者的 4.61 ns 持平)→ 白名单/排除清单策略本身不构成负担。命中后每个活动 431 ns / 640 B(其中活动自身仅 4.6 ns)——**640 B/活动**是主要风险:若第三方库每秒产生 10 万活动(如高频 Redis 调用),就是 64 MB/s 分配。 **8. 并发下无争用放大。** 8 线程时每操作摊算耗时**下降**(星尘 137→125 ns,OTel 有监听者 254→57 ns),说明埋点路径(对象池、`ConcurrentDictionary` 聚合、AsyncLocal)没有全局锁瓶颈,可随多核扩展。唯一的反向变化是无 APM 档 0.02→0.84 ns,来自 `HasListeners` 对共享静态结构的读取——绝对值仍可忽略。 --- ## 优化建议 1. **高频低延迟路径按需埋点**:网络栈每包、缓存命中等场景建议用采样或条件埋点控制条数,重点盯分配速率——单层埋点在真实宿主约 200 B,嵌套每多一层再 +200 B。 2. **异常路径节制**:异常埋点成本约为普通埋点的 20 倍(额外生成 `ex:` 埋点),避免在异常风暴路径上无差别埋点。 3. **采集器加限流**:为高频第三方源设置每周期条数上限或按源采样,避免活动转埋点带来的持续分配。 4. **OTel 输出控制标签大小**:标签会整串写入活动属性并随之导出,建议只放关键字段(星尘档在未采样时不序列化,可放心传对象)。 --- ## 已知限制 - 微基准为**单机、单进程、Workstation GC**;服务端 Server GC 与高并发下的分配/回收曲线不同,绝对值不可直接外推。 - 本机为共享开发机,**同一天内重复运行同样基准,耗时列曾出现约 35% 的漂移**(分配列不受影响,为确定性数值)。因此**不要跨运行比较耗时**,只可在同一次运行内横向比较;表内耗时取自同一次完整运行。 - 第 2 节的并发数据是**每操作摊算的墙钟时间(吞吐口径)**,不是争用下的单次延迟,也未给分位(P99)。 - 标签使用小型匿名对象;真实埋点若传 `IPacket`/大 JSON,序列化成本会明显上升。 - 埋点名固定为少数几个,**埋点名基数**对聚合字典与 `TakeAll` 的影响未单独测量。 - 分析第 2 条的分配构成,来自**线程级探针**(`GC.GetAllocatedBytesForCurrentThread()` 逐段测量)与合成对照,非 BenchmarkDotNet 全量复跑;各项绝对值与 BDN 交叉验证一致(单层 72 B、两层 360 B),但不含 BDN 的统计平滑。 - 真实宿主(ASP.NET Core)的 `AsyncLocal` 槽位数量未实测,用 3 个自定义 `AsyncLocal` 模拟得出 224 B,属上界估计的近似而非直接测量。 - 未覆盖 net8 与 net10 的差异(同属一个运行时家族),也未覆盖 .NET Framework(已决定不为它提供 OpenTelemetry 输出)。