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(底层 sonicConfigStd,保持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 数据)
NewEncoder写io.Discard(大 Responses response)- apicompat 热路径整体转换:
AnthropicToResponses、ResponsesToChatCompletionsRequest、AnthropicToResponsesResponse、ResponsesEventToSSE
最后一条是关键:它测的不是"序列化一个结构",而是"一整条转换路径"。
因为 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 服务,最小的可运行切片是什么样子。我会把"加一个资源"的完整步骤走一遍,包括哪些是必须的、哪些可以后补,以及分层约束怎么在第一天就立起来。