性能决策:一个“变慢了”的方案我怎么判断,以及一条不降的准入线

这篇讲两个性能决策,它们的结论看起来是相反的。

0. 两次看起来相反的判断

这篇讲两个性能决策,它们的结论看起来是相反的。

第一个:一个方向被微基准证明"变慢了 2.1 倍"的优化,我最后决定保留。

第二个:一个我花了很多时间做的重构,在 24 小时观察里 P95 回归 69.7%,我决定回滚,而且不降准入线。

看起来矛盾——一个"慢"的留着,一个"快"的(它本来是为了更快)砍掉。

但它们背后的判断标准是同一条:我要的改善必须出现在真实负载的请求级指标上,而不是出现在任何可以被我解释的局部数字里。

这篇把这两个决策讲清楚。


1. 决策一:把 JSON 序列化包一层,而不是直接用 sonic

1.1 背景

我做过一次全仓库统一:把所有 JSON 序列化收敛到一个 pkg/jsonx,底层是 sonic 的 ConfigStd(保持 encoding/json 语义)。

为什么不让大家直接调 sonic?因为 sonic 有两个配置档,而语义不同:

sonic 默认配置 ConfigStd encoding/json
HTML 转义 不转义 转义 转义
map key 排序 不排序 排序 排序
解码字符串 共享输入缓冲区 拷贝 拷贝

这几条差异里的第三条最危险:默认配置下,Unmarshal 解出来的字符串可能指向输入的那个字节切片。 如果那个切片之后被复用(比如它来自一个 sync.Pool 或一个 per-request buffer),解码出来的字符串会静默地变

这类问题极难查:你会看到某个字段偶尔变成别的值,而且只在负载高的时候出现。

所以 jsonx 存在的第一个理由是语义:让全仓库用一个不会因为性能配置而改变行为的 JSON 层。

它的用法约束我写进了 AGENTS.md

// Import micro-one-api/pkg/jsonx for Marshal / Unmarshal / MarshalIndent / Valid / NewEncoder / NewDecoder / Get.
// Never call sonic.Marshal / sonic.Unmarshal package-level functions directly.

1.2 那个"变慢了"的发现

统一之后有一个遗留问题:jsonx 内部用的是 sonic.ConfigStd

而在 Apple Silicon(arm64)上跑微基准,出现了一个不好看的结果:

v0.15.3 将全仓库 JSON 序列化统一收敛到 pkg/jsonx(底层 sonic ConfigStd,保持 encoding/json 语义)。v0.17 P3.2 要求回答:Marshal 方向 sonic 比 std 慢(Apple Silicon 微基准确认小结构约慢 2.1x),是否应该把 Marshal 单独回退到 encoding/json

Marshal 方向慢 2.1 倍。 如果只看这个数字,结论很明显:回退。

但我给自己定了门槛,而且写在文档里:

本决策只允许在 Linux/amd64 代表性负载上出现可重复的请求级改善 时才回退。

一句拆成三个限定:

  • Linux/amd64 —— 生产是 x86_64,不是 Apple Silicon;
  • 代表性负载 —— 不是我手写的微基准,是真实的请求结构;
  • 请求级改善 —— 不是单个函数的纳秒数,是端到端指标。

1.3 怎么造"代表性负载"

这一节是我觉得这次决策里最实在的部分。fixture 不是随便挑几个结构,而是按"代码里真实的序列化点"列出来的:

  • 小 request struct(chat completion 请求,既有 json_test.go
  • 大 Responses response(3 个 output item:reasoning + 长文本 message + function_call,五桶 usage)
  • Anthropic 流式 delta 事件(长 text delta,adaptor 逐事件序列化点)
  • admin/billing 聚合响应(map + slice,5 行 per-model 数据)
  • NewEncoderio.Discard(大 Responses response)
  • apicompat 热路径整体转换:AnthropicToResponsesResponsesToChatCompletionsRequestAnthropicToResponsesResponseResponsesEventToSSE

最后一条是关键:它测的不是"序列化一个结构",而是"一整条转换路径"。

因为 jsonx 散落在转换路径的各个点上。单独看每个点的纳秒数,你会得出一个结论;把它们放回路径里,可能得出另一个——因为中间还有别的开销,而占比会变。

方法上也写了要求:

每个 benchmark -count=3,取中位数;-benchmem 记录分配;CPU profile 用 -cpuprofile + go tool pprof -top

取中位数而不是平均值,记录分配(因为 GC 压力常常比单次耗时更重要),并且存 CPU profile——因为如果结论是"变慢了",我需要知道慢在哪。

1.4 复测的结果

在 Linux/amd64 上用一个真实的机器配置复测:

环境:Intel Xeon E5-2686 v4 @ 2.30 GHz / Ubuntu 24.04 / go1.26 / 36 核 / 31 GB count=5 取中位数

结论反过来了:

已决策(2026-08-10):Linux/amd64 复测完成,sonic 在 Marshal 和 Unmarshal 方向均全面优于 std。 最终决策:保留 pkg/jsonx 单一封装层,不回退任何方向到 encoding/json

1.5 这件事我的收获

"你的机器上慢"和"生产上慢"是两件不同的事。

Apple Silicon 上的 2.1x 是一个真实的测量结果,但它回答的是"在这台机器上、对这个小结构、Marshal 这个操作,sonic 比 std 慢"。而我要回答的是"在生产机器上、对真实的请求、整条路径,回退能不能带来可测的改善"。

从一个真实测量到一个有效结论,中间隔着"这个测量代表什么"的判断。 而这个判断错了,后面的优化做得再精确也没用。

那个"只允许在 X 前提 Y 被证明时才做 Z"的写法,现在的价值我看得更清楚了:它把我当时可能会冲动做出的决定,变成了一个必须先满足条件才能做的决定。


2. 决策二:那条 20% 的准入线

2.1 背景

executor 是第 2 篇讲的那条新执行路径。它是一个架构重构,把请求链路从"一个 297 行的 handler"变成"阶段化的编排器"。

但它同时处在最热的位置——每一个 /v1/responses/v1/messages/v1/chat/completions 请求。所以它有一个性能准入线

7 天窗口通过需同时满足:

  • 每个实际承载 allowlisted 生产流量的 endpoint / stream 均有 orchestrator 样本,不能用零样本判定通过;
  • orchestrator 成功率不低于同 endpoint / stream 的 legacy 对照,且没有新增稳定错误类型;
  • orchestrator P95 相对 legacy 不回归超过 20%
  • quota_error=0,没有重复 Commit、成功流 Release、截断流 Commit 或 reservation 卡单;
  • failover exhausted 比例没有显著高于 legacy,reason 分布能够解释;
  • billing 的 endpoint / stream 归因正确,usage 日志的 stream、channel / subscription account 归因一致。

六条,必须同时满足

我特别说一下第一条,因为它防的是一个很常见的作弊:

每个实际承载 allowlisted 生产流量的 endpoint / stream 均有 orchestrator 样本,不能用零样本判定通过

如果一个 endpoint 上没有任何新路径的样本,那它就不能算"通过"。 否则我可以只让新路径处理最简单的请求,然后在没有样本的 endpoint 上宣布"没有回归"。

2.2 观察过程不是一次跑完的

这份文档是分窗口记录,而且作废过好几次窗口。我把过程列一下,因为它比结论更有意思:

窗口 结果
2026-08-26,二次部署后 36min P95 56s vs 35.6s(+57%),样本小,继续观察
2026-08-26 → 08-27,固定 [24h] 成功率 62.14% vs 100%,5xx 37.15% vs 0,P95 300s vs 54.6s(+449%)
2026-08-28,第四次窗口起点 条件齐备,正式起点成立
2026-08-30 → 08-31 只有 1 个 orchestrator 样本,无稳定对照,暂不采信
2026-09-01 → 09-02,第五次正式窗口 P95 48.3s vs 28.46s(+69.7%),FAIL

中间那次 +449% 是什么?

触发 §5 回滚线:成功率、5xx、P95 三项不合格

三项全不合格。这不是"性能有点差",这是"新路径在 /v1/responses 流式上基本不可用"。

2.3 一个我拒绝采信的数据

08-30 那行值得单独讲。当时有一个 orchestrator 样本,成功率 100%。

如果我想让这个功能上线,我可以拿这一行说"新路径成功率 100%"。但我在那一行的结论里写的是:

新窗口有 1 个 orchestrator 成功,但无稳定对照;non-stream legacy P95 与 stream 值相同的原始查询未复核,暂不采信,继续观察

一个样本的成功率不能代表成功率。 而且我还发现了一个数据质量问题:non-stream legacy 的 P95 和 stream 的 P95 值相同——这很可疑(两类的延迟分布不该一样),说明我的查询可能没有正确按 stream 维度分组。在核实之前,这份对照不可用。

"暂不采信"这个动作我认为是这份文档里最重要的部分。 它挡住了我拿一个好看但无效的数字去支持一个决定。

2.4 最终判定和回滚

第五次正式窗口的最终快照:

endpoint/stream       orchestrator   legacy          成功率        5xx      
/v1/messages / true   ≈98            ≈179            100%/98.32%   0/1.68%  
                      P95: 48.3s / 28.46s (+69.7%)
                      reserve≈99, commit≈98, release≈1
                      → FAIL:P95 超过 20% 回滚线;成功率、账务与协议指标未见异常

注意它 FAIL 的方式:成功率更好(100% vs 98.32%),账务干净(reserve≈commit+release),协议指标正常,唯一不合格的是 P95。

这时候有一个很诱人的选择:"只看 P95 是不是太苛刻了?成功率还提升了。"

我做了另一个选择:回滚。理由是我在文档里写的回滚规则:

出现稳定性错误、计费不一致、P95 超线或协议兼容回归时,清空 allowlist 或关闭 RELAY_ORCHESTRATOR_ENABLED,所有请求立即回到 legacy。回滚不删除旧 handler,也不需要数据库回滚。

"P95 超线"是单独一项回滚条件,不需要和其他项一起看。 我定这条的时候就是防止自己在结果不好看时去找补——因为"整体上还行"是一个无法被证伪的判断。

2.5 回滚之后

回滚的动作是清空 allowlist(或者关掉那个开关),立刻全量回旧路径。文档里记了回滚之后的状态:

2026-09-02 10:50:06   /v1/responses / true   0(flag=false)   2(success)
2026-09-02 15:20–16:11 /v1/responses / true   0(flag=false)  40(success)
2026-09-02 15:20–16:11 /v1/messages  / true   0(flag=false)  15(success)

「新进程重启后仍全部走 legacy,回滚持续生效」。

回滚要能被验证"真的生效了",而不是"我把开关关了"。 所以我继续看了几十分钟的指标,确认 orchestrator 样本数稳定为 0。

而这个回滚设计(清 allowlist)之所以能这么干净,是因为第 2 篇讲的那道门禁从第一天就是"按 token 摘要的 allowlist"。如果当初的灰度粒度是"按实例百分比",回滚就不会这么快,也无法验证得这么彻底。

2.6 一件我特意没做的事

回滚之后有一次发布(v0.26.6),我在发布说明里写了一句:

当前生产已经运行修复等价镜像,发布 tag 不重启正在执行 72 小时 charge 验收的容器。

发布 tag 不等于重启生产容器。 因为有一个验收窗口正在跑,重启会让那个窗口的计时作废。

这是同一条纪律的另一个面:不要让一个无关的动作污染一个正在进行的测量。 和第 2 篇里"不在观察期内改执行模型"、第 23 篇里"不与资金修复混在一个完成边界"是同一件事。


3. 这两个决策共用的规则

回头看,两次判断用的是同一条标准,只是方向不同:

jsonx executor
局部信号 Marshal 慢 2.1x(arm64 微基准) 成功率 +1.68%
决定性指标 Linux/amd64 请求级 P95 不回归 20%
决策 保留 回滚

三条规则:

第一,只看能代表生产的那个指标。 微基准的纳秒数、成功率的小幅提升,都不是我要的答案。

第二,准入门槛要在看结果之前定。 20% 这条线是我在观察开始前写的,不是看到 69.7% 之后定的。先定线后看数,和先看数后定线,是完全不同的两件事。

第三,不合格就是不合格。 不能因为"其他项都挺好"就放行。第 2.4 节那次 FAIL 的时候,账务和成功率都很干净——正因为干净,才更容易说服自己放行。


4. 现在的状态和欠账

已经能用的:

  • pkg/jsonx 单一封装层,语义固定为 encoding/json 兼容(避免 sonic 默认配置的三处行为差异)
  • 性能决策有方法要求:代表性负载、Linux/amd64、count≥3 取中位数、记录分配、存 CPU profile
  • 决策留档:docs/design/p32-jsonx-performance-decision.md,含结论与依据
  • executor 有六条并列准入线,P95 回归 20% 是其中之一且单独构成回滚条件
  • 观察窗口在数据质量可疑时明确"暂不采信"
  • 回滚动作(清 allowlist)可执行、可验证、不需要数据库回滚
  • 不在观察期内做无关改动(不改执行模型、不重启容器)

还没解决的:

第一,那条 20% 没有推导依据。 我选它是因为"20% 看起来是个合理的容忍度",没有算过它对用户体验的影响。这是一条拍出来的线,虽然它被严格执行了。

第二,jsonx 的 go 版本边界是一个隐性风险。 文档里写了 sonic 在 go1.28+、非 amd64/arm64、或 arm64 + go<1.20 时会 fallback 到 encoding/json——行为一致但性能收益消失。所以 go.mod 停在 1.27。这意味着"升级 Go"这件事被一个性能决策绑住了。

第三,executor 现在还是关着的,而它的 P95 回归原因我到今天没有定位。 回滚是对的,但"为什么新路径在流式上慢 70%"这个问题还是开放的。没有定位原因,就没有第二次尝试的把握。

第四,性能验证没有常态化。 这两个决策都是"集中做一次判断",没有持续的性能回归门禁。第 21 篇也提过同一个缺口——那边的表现是"页面慢了一秒要我自己感觉到"。

第五,代表性负载的 fixture 是一次性的。 它们存在于测试文件里,但没有被要求"新增序列化点时同步新增 fixture"。所以下次再做同类判断时,我需要重新挑一遍负载。


5. 下一篇

下一篇讲一个更基础的东西:如果要按这套架构从头做一个 Kratos 服务,最小的可运行切片是什么样子。我会把"加一个资源"的完整步骤走一遍,包括哪些是必须的、哪些可以后补,以及分层约束怎么在第一天就立起来。

《如果要照这套结构做一个新服务:最小的可运行切片是什么》