网关选型:Go、Java、Graal 谁都不能完胜

0 阅读32分钟

关于网关选型,现在有三种说法,各自都有真实的数字撑腰:

  • "网关就该用 Go,Java 太重";
  • "ZGC 停顿已经进微秒了,JVM 早就不慢";
  • "上 GraalVM native image(下文简称 native),启动 68 毫秒,跟 Go 一样快"。

三句都没说错,只是各量各的:Go 那句量的是内存和启动,ZGC 那句量的是停顿,native 那句量的是启动。

所以我把三条路线放进同一批实验,用同一把尺子量七个维度,再把结果画成一张雷达图。

先给结论:七格里没有任何一条线全在圆外。  Go 拿下内存和启动,每请求 CPU 与 Java 打平;Java 拿下 p99 和 10 毫秒以上请求的占比;native 拿下最深的那段尾部,但付出了三成吞吐。

三条线谁都有明确输掉的那一格——所以严格说,谁都不能完胜,你只能选自己输得起的那格。

还有一个更硬的发现:Java 和 native 的最优 GC 都是 G1。  于是能做一个干净的对照:同一个 GC 算法,只差 JIT 在不在,代价可以逐项标出来(4.2 节)。

一、先定尺子:七个维度和它们的测法

"哪个网关快"没法直接回答。不先说清拿什么指标、怎么测,任何数字都能被解释成想要的结论。

所以先立七把尺子,每一把都对应一个真实的决策:

维度指标怎么测它决定什么
吞吐rps2 核配额、并发 32、预热 20 s 后测 30 s单副本的容量上限
p99第 99 百分位延迟同窗口约 20~30 万个请求,丢前 2 秒重算绝大多数用户感受到的"慢"
p99.9第 99.9 百分位延迟同上深处尾部是否可控
尾部占比>10 ms 请求占比同窗口计数(max 只作参考,不参与排名)会被 SLO 罚钱的那部分
内存满载 RSS负载期 /proc 稳态值一台机器能放几个副本
冷启动进程起到能返回 200每种配置 3 次取中位扩缩容与故障重建速度
每请求 CPUcpu_s ÷ 完成请求数配额打满时的单请求成本同样的核算力能干多少活

三条原则先说清楚,后面所有数字都遵守:

一,不混口径。  要么"给同样的资源比服务"(吞吐、延迟、内存),要么"给同样的服务比资源"(每请求 CPU、冷启动)。两种口径的数字绝不放进同一句话。

二,每个数字都带前置条件。  不带并发数的吞吐、不带堆大小的内存、不带 CPU 配额的延迟,都不可比。这不是讲究措辞:同一个 JVM 网关、同一份负载,只改堆大小,RSS 从 351 MB 变到 4096 MB,中间几档吞吐几乎不动——同一条结论换个配置就能被读成两个故事。

三,落在噪声里的差异不叫差异。  同一份配置重复测,吞吐本身就有 ±7% 波动(基线见第二节)。所以 1.01 倍的吞吐差,本文读作"打平",不读作"Go 快 1%"。

二、让三条线量的是同一件事

先列公平性设计,这八条缺一条,后面的对比就站不住:

  1. 三层绑核:上游在核 2,被测网关在核 0-1,压测器在核 3,互不抢占。
  2. 活动处理器数对齐到 2:Go 侧 GOMAXPROCS=2,Java 侧 -XX:ActiveProcessorCount=2 并且加 -Dreactor.netty.ioWorkerCount=2。
  3. 职责逐项对齐:同上游地址、同 header 注入集合、同 JSON 访问日志。
  4. 日志体量对齐:见下文,这一步不做,"带职责"组的对比是假的。
  5. GC 统计只取测量窗:按日志行号差值切片,排除启动与预热期的 GC。
  6. 预热 20 s + 测量 30 s,并发 32。
  7. 延迟数字丢前 2 秒重算:见下文,这是 JVM 的专属偏差。
  8. 三条路线同批:Go、JVM(两种 GC)、native(两种 GC)由同一个脚本、连续跑完,中间不换机器不换配置。

下面三件事,任何一件没处理,结论都会反过来。

第一件:只锁 ActiveProcessorCount 是压不住线程数的。

Reactor Netty 的 io worker 默认是 max(可用处理器数, 4),有个"最小 4"的下限。按 /proc/<pid>/task/*/comm 数 reactor-http-epoll-* 线程:

JVM 参数event loop 线程数
默认4
-XX:ActiveProcessorCount=24
-XX:ActiveProcessorCount=2 -Dreactor.netty.ioWorkerCount=22

给了 2 个核却起 4 个事件循环,测出来的是调度器抖动,不是网关本身的能力。这一步必须显式配。

第二件:日志体量先对齐,再比"带职责"。

第一轮实测发现 Traefik 的 JSON 访问日志是 1108 字节/请求,Java 侧只有 250 字节,差 4.4 倍。补齐 Java 侧字段后重跑,最终 1108 vs 997~1008 字节/请求。

第三件:JVM 的测量窗口头部必须丢。

JVM 的第 0 秒只处理了后续秒 57~65%  的请求,那一秒的 p99 是后续的 2.4~4.8 倍。这不是"慢 20%",是三分之一容量还没上线。

把这条修正回算到 132 个历史用例上:

实现用例数p99 变化中位变化超 5% 的用例
Go420.0%0 / 42
GraalVM native12+0.1%0 / 12
上游直连4−0.1%0 / 4
G135−17.9%31 / 35
ZGC38−12.6%31 / 38

58 个 Go / native / 直连用例里 0 个受影响,74 个 JVM 用例里 63 个降幅超过 5%。  这是 JVM 的专属偏差,不是通用降噪——不改口径,JVM 在所有 p99 对比里系统性吃亏。

下面是噪声基线,它决定了多小的差异还能算数:

  • 同一份 ZGC + 2g 配置三次独立测量:8438 / 8871 / 9066 rps,极差 7.1%
  • 1 核配额下 Traefik 三次:7200 / 7203 / 7262 rps,极差 0.9%
  • max 这种单样本极值:同配置三轮极差 106%~385% ,本文不用它做排名

最后交代一件事:本文用了三批数据,每批内部自成对照,绝不跨批比绝对值。

批次内容用途
主对照批Go / JVM+G1 / JVM+ZGC / native+G1 / native+Serial,五家同批,堆 512 MB雷达图与第四节主表
堆梯度批ZGC,堆从 128m 到 4g解释"内存是参数不是常数"
专项批裸转发、四种 GC 停顿、AOT 启动、1 核方差解释某一条轴的成因

为什么主对照批用 512 MB 而不是社区常见的 2g?因为堆梯度批已经证明:512m 那一档吞吐是 2g 的 0.96 倍,落在噪声内。选 512m 是让三方在同一个堆口径下比较——按 2g 算,内存那一格会从 5.25 倍变成 20 倍。这不是 Java 的极限:G1 压到 256m 只要 413 MB,吞吐和 p99 都不动,4.4 节给完整梯度。

三、一张图:七个维度,三条路线

把七个维度画进同一个坐标系,需要先解决"量纲不同"的问题。做法是以 Go 为原点:每个维度的分数 = 100 + 30 × lg(该实现的优势 ÷ Go 的优势)。七条轴都以 Go 为基准,所以 Go 始终落在那条虚线圆上,另外两条线凸出或凹进多少,就是它们相对 Go 的强弱。

打分公式只是为了让 rps、毫秒、兆字节、微秒能放进同一个坐标系看形状,结论请以下表的原始数字为准:

维度Traefik(Go)SCG + G1native + G1
吞吐(带职责,2 核,并发 32)10392 rps10538 rps7138 rps
p99(丢前 2 秒)10.370 ms5.100 ms7.622 ms
p99.9(丢前 2 秒)13.551 ms14.269 ms13.028 ms
>10 ms 占比(丢前 2 秒)1.265%0.254%0.398%
>25 ms 请求数(丢前 2 秒)32 个13 个0 个
max 延迟(只作参考)69.08 ms28.17 ms17.10 ms
内存(满载 RSS)106 MB557 MB219 MB
冷启动(到能返回 200)63 ms5284 ms75 ms
每请求 CPU(并发 32)188 µs187 µs(打平)277 µs

读这张图只要三句话:

第一,每条线都有凹口,而且各自最深的那格互不相同。  Go 最深处是 p99(差 2.03 倍)和 >10 ms 占比(差 4.98 倍);Java 最深处是启动(83.9 倍)和内存(5.25 倍);native 最深处是吞吐(0.69 倍)。没有一个格子是三方都满意。

第二,native 的形状在三条里最接近正圆。  它在七格里没拉出过数量级优势,也没崩到 50 分以下——Java 的启动只有 42 分。这条线的形状说明:AOT 在交出"重"和"启动慢"的同时,也把"极端优势"一并交了出去。

第三,三家的 p99.9 只差 9.5%。  p99 那一格 Java 只有 Go 的一半,到 p99.9 却挤成 13.03 / 13.55 / 14.27。差距在更深的尾部被抹平——这条在 4.3 节展开。

四、逐条读数

4.1 吞吐:Go 和 Java 打平,native 掉三成

带完整职责(header 注入 + JSON 访问日志)时:Traefik 10392,SCG+G1 10538,SCG+ZGC 9847,native+G1 7138,native+Serial 7303。

Go 与 Java 差 1.4%,落在 ±7% 噪声内,读作打平。native 是 Go 的 0.69 倍。

但把职责摘掉,只做裸转发(专项批),差距立刻变成 18711 vs 11276 = 1.66 倍。差距去哪了?看日志这一项的成本:

网关裸转发带职责日志成本
Traefik(Go)104.8 µs205.1 µs+100.3 µs
SCG(Java)176.7 µs224.3 µs+47.6 µs

这张表的"带职责"是专项批内的绝对值(205.1 / 224.3),与雷达图那批的 188 / 187 不同批——只用来看差值,不要跨批比大小。

Traefik 写一行 JSON 日志的成本是 Java 侧的 2.1 倍,而两者日志体量只差 10%。  原因是 Traefik 走 Go 标准库 encoding/json(反射序列化),Java 侧是我手写 StringBuilder 拼的。

这一格是偏袒 Java 的——真换成 Jackson 反射序列化,Java 会输得更多。

所以"网关快多少"这个问题的正确答案是:看你让它干多少活。  纯管道 1.66 倍,加了真实职责后收敛到噪声内。

4.2 p99:Java 赢了 2 倍,而且两边的最优 GC 都是 G1

这是全文最重要的一张表。同为"带职责、2 核、并发 32",五个实现,延迟数字全部丢前 2 秒重算:

实现GCp50p99p99.9>10 ms 占比max>25 ms
Traefik(Go)GoGC2.64310.37013.5511.265%69.0832
SCG(JVM)G12.8615.10014.2690.254%28.1713
SCG(JVM)ZGC2.9997.61914.8490.776%39.4143
native imageG14.3717.62213.0280.398%17.100
native imageSerial4.13611.31314.3201.113%16.880

这张表里藏着两个结论。

第一,Java 侧换 GC 是真的有用,而且最好的是 G1,不是 ZGC。

G1 的 p99 是 5.100,ZGC 是 7.619,G1 低 33% ;>10 ms 占比 0.254% 对 0.776%,G1 只有 ZGC 的三分之一。这跟"ZGC 停顿更短所以延迟更低"的直觉相反(原因在 5.3 节)。

第二,native 侧的最优 GC 也是 G1,但它赢不了 JVM。

native+G1 的 p99 是 7.622,native+Serial 是 11.313——G1 低 33%,和 JVM 侧的幅度一模一样。但 native+G1 的 7.622 仍然比 JVM+G1 的 5.100 高 49% 。

于是我们有了一个干净的对照:同一个 GC 算法(G1),唯一的差别是 JIT 在不在。  这张定价表可以直接读出来:

项JVM + G1native + G1JIT 值多少
p995.100 ms7.622 msJIT 让 p99 低 33%
吞吐10538 rps7138 rpsJIT 让吞吐高 48%
每请求 CPU187 µs277 µsJIT 让单请求省 32%
内存557 MB219 MBJIT 要花 2.5 倍内存
冷启动5284 ms75 msJIT 要花 70 倍启动时间

这就是 JIT 的账:买 33% 的 p99、48% 的吞吐、32% 的 CPU,付 2.5 倍内存和 70 倍启动。  后两条是物理成本,前三条是可优化的收益——所以这笔账没有标准答案,只有你的约束落在哪边。

再往下看曲线形状。Go 从 p99 到 p99.9 只涨 1.31 倍(10.370→13.551),JVM+G1 涨 2.80 倍(5.100→14.269),native+G1 涨 1.71 倍。曲线在 p99 与 p99.9 之间交叉:p99 处 JVM 比 Go 低一半,p99.9 处反而比 Go 高 5%。Go 是"肩宽尾短",JVM 是"肩窄尾长",native 两头都不极端。拿单个分位数下结论,等于用一个交点代表整条曲线。

4.3 尾部:Go 的 1.265% 对 Java 的 0.254%

同一个窗口超过 10 毫秒的请求占比:Go 1.265% ,Java+G1 0.254% ,native+G1 0.398%。Go 的慢请求占比是 Java 的 4.98 倍。

但换成"超过 25 毫秒"这个更狠的阈值,排序反过来了:Go 32 个,Java+G1 13 个,native 0 个。

再看 max(虽然不参与排名):Go 69.08,Java+G1 28.17,native 17.10。

三个数字拼出一个完整形状:

  • Go 的尾部是"平的" :从 p99 的 10.37 到 max 的 69.08,中间没有断崖,也没有第二个峰。29 万个请求里 32 个越过 25 ms——不是没有,是均匀地撒在 30 秒里。
  • Java 的尾部是"陡的" :p99 到 p99.9 涨 2.80 倍,到 max 涨 5.5 倍。中间更整齐,一旦掉出去就是几十毫秒。
  • native 的尾部是"截断的" :max 只有 17.10,25 毫秒以上一个没有。它把尾部整个压住了——代价是每请求多花 89 µs 的 CPU。

对 SLO 来说,这三种形状的后果完全不同:如果你的阈值是 10 ms,Java 最好;如果是 25 ms,native 最好;如果你只关心"绝大多数人",Go 的 p99 是 10.37,比另外两家都差。

4.4 内存:5.25 倍,但 Java 的内存是参数,Go 是常数

带着 512 MB 堆时:Traefik 106 MB,SCG+G1 557 MB,native+G1 219 MB。

Java 是 Go 的 5.25 倍,native 是 2.07 倍。

先说一个必须交代的口径。按社区对 Java 网关的常规推荐值 -Xms2g -Xmx2g,Traefik 107 MB 对 SCG+ZGC 2150 MB,是 20 倍。但这个 20 倍是配置造成的,不是代码造成的。同一个 JAR、同一份负载,只改堆(堆梯度批):

堆吞吐 rps相对 2g最大停顿常驻内存相对 2g
128m74620.85×0.024 ms351 MB0.16×
512m84550.96×0.016 ms726 MB0.34×
2g87911.00×0.016 ms2165 MB1.00×
4g78700.90×0.020 ms4096 MB1.89×

512m 那一档吞吐是 2g 的 0.96 倍——落在 ±7% 噪声基线以内。把堆从 2 GB 砍到 512 MB,测不出吞吐损失,内存省 67%。

这张梯度表是 ZGC 的。换成 G1,同样 512m 堆的 RSS 是 557 MB(主对照批)——比 ZGC 少 170 MB。

G1 的梯度我另外单独测了一轮(NMT 开着,只在本轮内部对照):

G1 堆RSSrpsp99
128m322 / 33010682 / 109129.16 / 8.27
256m413109895.98
512m576 / 57810778 / 110606.15 / 5.81

这一轮 512m 档的 576 比主对照批的 557 高 19 MB(3.4%),差额来自 NMT 自身的记账开销与批次噪声——档间相对关系不受影响,但别把这个 576 拿去覆盖主表的 557。

G1 的拐点在 256m,不在 512m:413 MB 拿到 10989 rps、p99 5.98——比 512m 省 165 MB,吞吐和尾延迟都不动。压到 128m 才开始付 p99 的代价(退化 45%),而吞吐仍然是满的。所以 G1 不像 ZGC 那样"堆小了就掉吞吐",它只在尾延迟上让步。

三个边界要记住:

  • ZGC 的下界是 128m(吞吐掉 15%),4g 是纯浪费(吞吐掉 10%、内存翻倍)。
  • 弹性堆省不了内存:-Xms512m -Xmx2g 实测 2147 MB,和固定 2g 的 2165 MB 几乎一样。持续高分配负载下,"按需扩展"的"需"就是满。想省只能真把 Xmx 调小。
  • 堆不是全部:128m 堆那一档的 322 MB 里,只有 128 MB 是堆,剩下 194 MB 是与 -Xmx 无关的固定开销。它的逐项构成在 5.2 节(元空间 36、代码缓存 30、GC 结构 39、JVM 二进制 36、符号表 9、分配器碎片 26、线程栈 1.75)。

native 的 219 MB 说明另一件事:AOT 把元空间和代码缓存的账清了,但没有清干净。  同一份 native 代码,Serial GC 版只要 95 MB,G1 版要 219 MB——多出来的 124 MB 挂在 G1 这侧(这块我没拆开,native 不支持 NMT,见 5.2)。也就是说,native 只省掉了"类元数据 + JIT 代码缓存",省不掉"堆 + GC 结构 + 线程栈 + 运行时映射"。

反过来看 Go:GOMEMLIMIT 设 32 / 64 / 128 MiB 或不设,四个点全部钉在 99~108 MB 这条竖线上,压到 32 MiB 吞吐反而掉 11%。它不是可调参数,而是一道压不动的下限。

所以这一格的正确说法不是"Java 费 5 倍内存",而是:Java 的内存是参数(3.020 倍,看你给多少堆、给哪个 GC),Go 是常数(约 105 MB),native 是半参数(95219 MB,看 GC)。

4.5 冷启动:Go 63 ms、native 75 ms、JVM 5284 ms

每种配置测 3 次,取中位:Traefik 63 ms,native+G1 75 ms,SCG+G1 5284 ms。

native 是 Go 的 1.19 倍,基本同档;JVM 是它们的 70~84 倍。

这个差距能不能补?我测了两条路。

第一条:Project Leyden AOT cache。  2 核配额下,JVM 基线启动 5496 ms,加 AOT cache 后 4915 ms(−10.6% ),再加懒加载 4717 ms。方向对,但幅度离 70 倍差得远。

第二条:GraalVM native image。  构建 6 分 29 秒、峰值内存 4.98 GB,产出 90.0 MiB 的静态二进制。启动 68 ms(Serial GC 版),同批 Go 是 63 ms。

但代价必须一起说,否则这个结论站不住:

项JVM + G1native + G1
冷启动5284 ms75 ms
吞吐(并发 32)10538 rps7138 rps(JVM 的 0.68×)
每请求 CPU187 µs277 µs
常驻内存557 MB219 MB(Serial 版 95 MB)
p995.100 ms7.622 ms
jcmd / jstack / jmap可用全部 AttachNotSupportedException
-Xmx64m生效静默失效

两件事值得单独看一眼。

native 的尾延迟是全场最好的(max 17.10 ms,>25 ms 一个都没有,而 Go 有 32 个)。这符合机制:没有 JIT 就没有反优化,没有大堆就没有大停顿。它输的地方全在吞吐上。

-Xmx64m 在 native 上不报错、照常服务、RSS 82 MB——JVM 参数在 native 上是静默失效的,堆上限要改用 -XX:MaximumHeapSizePercent 或构建期参数。这类"以为配了其实没配"的坑,会一直留到线上。

换 AOT 能买回启动,但买不回吞吐,还会赔掉可观测性。  对基础设施组件来说,最后一条往往最难接受。

4.6 每请求 CPU:188 / 187 / 277

配额打满时,每请求的 CPU 成本:Traefik 188 µs,SCG+G1 187 µs,native+G1 277 µs。

Go 和 Java 打平,native 贵 47%。

这一格最值得琢磨的问题是:没有 JIT 的 native,为什么反而比有 JIT 的 JVM 更费 CPU?

先排除三个嫌疑(都用同批数据):

嫌疑一:GC。  JVM 侧只换 GC:G1 10538、ZGC 9847,差 6.6%,落在噪声边缘。native 侧只换 GC:G1 7138、Serial 7303,差 2.3%。两边换 GC 都动不了吞吐的量级,GC 不是主因。

嫌疑二:GC 的频率。  把 Traefik 的 GOGC 从 100 拉到 1000,GC 次数从 556 降到 60(降 89%),吞吐只涨 11%,内存却涨 2.3 倍。把 GC 砍掉近九成只换 11%,说明 Go 侧的 GC 总共也只占一成的 CPU。

嫌疑三:JIT 缺不缺失。  这条最反直觉:native 全是构建期编译好的机器码,没有 JIT,也没有 C2 的峰值优化。同批吞吐 7138,只有 JVM 版(10538)的 0.68 倍。所以 native 更慢,不是因为"JIT 还没热起来"——恰恰相反,把 JIT 整个拿走只会更慢。

三个嫌疑都排除后,剩下的只能是编译质量与每请求的固定工作两个来源:

  • 编译质量:GraalVM 的 AOT 编译不可能做 C2 那样的激进内联与投机优化,热点路径上的指令数天生更多。这一条我没有做指令级对比,只作为机制解释。
  • 固定工作:对象图的构造与销毁、Pipeline 的适配层、Filter 链的逐层传递、两次 HTTP 解析。这部分工作没有"热点"可优化,跑多少遍都是那么多活。

这一段我只给机制,没有逐项测量。  本文能确认的只有:它不是 GC,不是 GC 频率,也不是 JIT 缺失——三者都被上面的实验单独否掉了。

有一个反向旁证值得记住:日志那一格反过来了(专项批:Go 100.3 µs vs Java 47.6 µs)。同样是每请求的固定工作,一旦落到"反射 + 序列化"这种需要抽象的地方,Go 反而更贵。Go 的优势区间是"最短路径",不是"每个环节都更快"。

4.7 一项没进雷达图:JVM 要爬十几秒才满速

就绪不等于能按满速接流量。把网关就绪后的头 12 秒单独量出来,和之后的稳态窗口比(专项批,同一进程、同一份代码、同一份负载):

用例头 12 s 吞吐稳态吞吐倍数
Traefik 裸转发18686.618710.91.00×
Traefik 带职责9645.79644.81.00×
SCG + G1 带职责2089.99312.54.46×
SCG + ZGC 带职责2225.78870.63.99×

Go 从第一个请求就是满速;JVM 要爬十几秒,爬升期的平均吞吐只有稳态的 1/4。  native 这一段我没有单独测——它的启动只有 75 ms,按同样口径截头 12 秒,窗口里已经没有"爬坡"可言。所以这一项我只对 Go 和 JVM 下结论。

这一项没有进雷达图:它只对 JVM 一家成立,放进去等于给另外两家各送一格满分,重复计分。但它对"扩容救火"这个场景是决定性的:流量涨上来的头十几秒,JVM 只能接住四分之一。

低配下更糟。0.5 核配额,把预热从 25 秒拉到 60 秒:

预热时长SCG+ZGC @0.5 核Traefik @0.5 核
25 s774 rps3360 rps
60 s2616 rps3326 rps

同样拉长预热,Go 只动了 1%,Java 涨了 3.4 倍;而且即便给足 60 秒,Java 在 0.5 核下也只有 Go 的 79%。低配额意味着 JIT 编译本身也在抢那点配额——"预热不够"和"配额不够"在低配下是同一件事。

4.8 雷达图上没有的一格:重复测量的方差

1 核配额下各测三次(专项批):

方案三次 rps三次 p99
Traefik7200 / 7203 / 7262(±0.9% )12.79 / 12.77 / 12.73(±0.2%)
SCG + ZGC6405 / 6279 / 6723(±7%)56.28 / 57.85 / 18.35(3.1 倍)

同一个二进制、同一份负载、同一台机器,Java 的 p99 在 18 ms 和 58 ms 之间跳。这一条在雷达图上没有轴,因为它不是一个"维度",而是一种性质:同样配置重复跑,Go 像一台机器,Java 像抽奖。

五、原理:这个形状是怎么长出来的

前面量的是"是什么"。这一节回答"为什么",而且只写能指向某一次测量的话;指不到的,我会明说没测。

5.1 启动的三段账,AOT 只能还掉第一段

JVM 启动慢常被归给"类加载"。这个解释只对了一小部分:

  • 加 Leyden AOT cache(把类加载、链接、验证的产物缓存下来)只省了 10.6% (5496 → 4915 ms)。说明类加载那一段只占一成。
  • 换成 native image(把整个应用在构建期编译成机器码,运行时没有类加载、也没有 JIT)直接到 75 ms,是 Go 的 1.19 倍。

剩下的 89% 里,"Spring 上下文装配"和"JIT 把热点方法编译到 C2"各占多少,本文没有分开测。但有一条可以确定:它不是 GC 的账——JVM 侧换 GC,启动时间落在 5103~5514 ms 之间,动不了。

这也解释了为什么 AOT 不是一个"调参"选项:你要么保留 JIT 并继续付启动与爬坡的钱,要么上 native 并把 JIT 的收益一起交出去(吞吐 0.68×)。

5.2 内存的差:堆能调,固定开销调不动

把 Xmx 从 2g 移到 512m,RSS 从 2165 掉到 726,吞吐不动——大头确实在堆上,所以它是一条可平移的线。

但这条线平移不到 Go 那里去。"平移不动的那一截"到底是什么,之前只能拿 RSS 减堆来估;这次我用 NMT(-XX:NativeMemoryTracking=summary)加 /proc/<pid>/smaps,在压测过程中直接采了一遍。

G1、-Xms = -Xmx、并发 32、带职责,128m 堆下的 322 MB 是这么构成的:

项MB是什么
堆128压测期间是满的(30 秒 508 次 GC)
类元数据(Metaspace)35.910141 个类的字段布局、字节码、常量池
JIT 代码缓存30.1C1/C2 编译出来的机器码
GC 自己的结构39.2G1 的 region 元数据、记忆集、位图
JVM 二进制与库36libjvm.so 18.4 + CDS 归档 13.6 + 其它
符号表8.611.9 万条方法名/类名
内部表9.7perf 数据、哈希表、编译器 arena
malloc 碎片~26分配器未归还的空洞
线程栈1.7525 个线程,预留 25 MB,实际只提交 1.75

下面三条和直觉不一样,其中两条是我上一版写错的:

第一,线程栈根本不是大头。  25 个线程各预留 1 MB,但每个只提交约 70 KB——合计 1.75 MB,占 0.5%。我上一版把它和元空间、代码缓存并列成"三根支柱",是照着 Xss 的预留值想当然写下的。

第二,真正的大头我漏了:GC 自己的 39 MB。  G1 的 region 数组、记忆集、标记位图,不跟 JIT 有关,也不怎么跟堆大小有关(128m 时 39.2,512m 时 47.0)。

第三,libjvm.so 一个文件就 18.4 MB。  运行时自身的体积也是要付的。

所以"有 JIT 就得付"的钱是 类元数据 36 + 代码缓存 30 + 符号表 9 = 75 MB。剩下 119 MB 里,39 MB 是 GC 的账,36 MB 是 JVM 二进制的账,26 MB 是分配器碎片。(上一版写的是 219——那是 ZGC 在 128m 档的数,混进了 ZGC 自己的 149 MB。)

这 194 MB 与堆无关,是可复现的。  同一份代码换三档堆,NMT 记的堆外 committed 分别是 150 / 154 / 159 MB——几乎不动。

注意别用 RSS 减堆去算这块固定开销:这样算 128m 档是 194、256m 档是 157、512m 档只有 66。变小的不是固定开销,是堆的驻留率(100% / 86% / 75%)——region 分配了但没写过,就不占物理页。

再往下,两个 GC 在这 194 MB 上走向不同:

G1 到 256m 才见拐点。  128m 堆时 rps 10682 / 10912,和 512m 的 10778 / 11060 一样;但 p99 从 5.8 退化到 8.3~9.2。到 256m 就都正常了:413 MB、10989 rps、p99 5.98——比 512m 省 165 MB,吞吐和尾延迟都不动。

ZGC 要在这 194 MB 上再叠 149 MB,而且这笔钱是隐形的。  同为 512m 堆,ZGC 的 RSS 是 727,G1 是 578。可 NMT 报表里 ZGC 的 GC 类别只有 7.1 MB(G1 是 47.0),元空间、代码缓存、符号表三项逐 MB 相同,committed 总量 ZGC 的 642 还比 G1 的 671 少。

那 149 MB 去哪了?它落在 smaps 的匿名段里,NMT 没有一个类别肯认这笔账。它是 ZGC 的标记位图、转发表、多视图重映射的页表——JVM 不把它记在 GC 名下。  按内存给容器配额时,这块会被漏算。

native 也印证了这一点:AOT 清掉了类元数据和代码缓存,RSS 从 557 掉到 219,但掉不到 Go 的 106——堆、GC 结构、运行时映射都还在,跟 JIT 无关(完整数字见 4.4 节)。

同一份 native 代码,GC 从 Serial 换成 G1,RSS 从 95 涨到 219,多付 124 MB。这 124 MB 挂在 G1 这一侧,但我没有把它拆开——native image 不支持 NMT,jcmd VM.native_memory 直接报 AttachNotSupportedException。能说的是:同机 JVM 侧的 G1 结构实测只有 39~47 MB,124 MB 明显超出这个量级,说明 native 上的 G1 还有本文没测到的额外开销。

Go 那边反过来:静态链接、运行时不加载类元数据也不生成机器码,所以它的 105 MB 是一个常数。GOMEMLIMIT 只能在这个常数附近微调,压过头只会让 GC 更勤快(32 MiB 时吞吐掉 11%)。

一句话:Java 网关光固定开销就 194 MB,而 Go 整个进程才 106 MB——这一块比对手的全部还多 83%。  这个差距不是选错 GC 造成的,也不是堆给多了,它在你决定"要有类加载器、要 JIT、要 GC 元数据"的那一刻就定死了。

还有一个容易被忽略的差别:负载停了之后。  同一组实验里,Go 把 RSS 从 106 MB 还到 38 MB;ZGC 从 743 MB 只回到 723 MB,G1 从 542 MB 回到 517 MB。堆提交出去就不还——这条在容器里意味着 limit 只能按峰值设。

5.3 p99 的差距,不是 GC 停顿带来的

这是全文最反直觉的一格,先把四家的牌摊开。

专项批(堆 2g)里有四个实现,各自的最大单次 GC 停顿和 p99:

实现最大单次停顿p99(丢前 2 秒)
Parallel7.452 ms7.292
G124.569 ms7.637
ZGC0.016 ms7.812
Go0.244 ms10.798

停顿最长的 G1(24.569 ms)p99 是 7.637;停顿最短的 ZGC(0.016 ms)p99 是 7.812。两者停顿差 1536 倍,p99 只差 2.3%,而且方向反了。

p99 最低的一档是 Parallel——7.292,它的最大停顿是 ZGC 的 466 倍。

停顿和 p99 之间,没有单调关系。

主对照批(512m)复现了同样的形态:G1 的 p99 是 5.100,ZGC 是 7.619——停顿短的那个反而 p99 更高。

为什么?先算一个数:一次请求在网关里的平均驻留时间。

并发 ÷ 吞吐 = 32 ÷ 10392 rps ≈ 3.08 ms

也就是说,一个请求从进来到出去,平均要排 3 毫秒的队。而 Go 的 GC 停顿是 0.244 毫秒——占驻留时间的 8% 。

再看 p99 那一格的实际差距:10.370 − 5.100 = 5.27 ms。就算把 Go 那 0.244 ms 的停顿全额记到 p99 头上,也只够解释 4.6% 。

这里可以提炼出一条更一般的规律:停顿只在"分位阈值和停顿时长同量级"的地方留下痕迹。

  • p99 的阈值在 5~10 ms,而 ZGC(0.016 ms)和 Go(0.244 ms)的停顿都小于 0.3 ms——差一到两个数量级,什么也看不见;
  • 25 ms 这个阈值,和 G1 的 24.569 ms 停顿是同一量级——所以 G1 在那里露了头(专项批里 G1 有 373 个 >25 ms,Go 只有 1 个)。

换句话说,不是"停顿没用",是"停顿的量级要和你想改善的那个阈值对上"。  想改善 p99,得先有毫秒级的停顿;而毫秒级的停顿,恰恰是 ZGC 已经消灭掉的东西——它想改善的是 p99,可 p99 的阈值比它的停顿高了两个数量级。

5.4 p99 领先的那 5.27 ms 从哪来,我只测到一半

主对照批里,JVM+G1 的 p99 比 Go 低 5.27 ms,比 native+G1 低 2.52 ms。能排除的是:

  • 不是 G1 的停顿:G1 的停顿(24.6 ms)比 Go(0.244 ms)长两个数量级,p99 却更低。停顿的方向和 p99 的方向是反的。
  • 不是 cgroup 冻结:这一批用的是绑核(核 0-1),不是 cfs 配额,不存在周期性冻结。
  • 不是内存压力:ZGC 30 秒内只有 37 次 GC、0 次 allocation stall。

剩下的候选方向是 JVM 的非 GC 停顿(safepoint、去优化、代码缓存、页错误)与运行时调度差异。要定论需要 JFR 的 safepoint 事件或 async-profiler 的 wall-clock 采样——那是下一轮实验的事。这一格我只报数,不报原因。

六、这张图怎么用

七个维度不是让你取平均分,而是让你找到自己的约束落在哪一格:

你的处境该选谁依据
网关是边缘入口/纯转发,副本几十上百Go内存 106 MB、启动 63 ms 都是第一,每请求 CPU 与 Java 并列
延迟 SLO 卡在 10 ms 以内,且扩容不频繁Java + G1p99 领先 2.03 倍,>10 ms 占比只有 Go 的 1/5
要秒级弹性、按内存计费、故障要能重建Go 或 native63 ms 与 75 ms,比 JVM 快 70 倍以上
又要快启动、又要深尾部可控,能接受吞吐降三成native + G1启动 75 ms、max 17.1 ms、>25 ms 零个;代价是 rps 0.69×
团队全栈 Java,要与 Spring 生态深度集成Java语言切换的隐性成本大于任何基准差异
网关里塞了大量业务逻辑(鉴权、聚合、灰度)Java逻辑越重 JIT 越有用,本文结论越不适用
需要 JFR / APM 的持续可观测Javanative image 下 jcmd/jstack 全失效

还有一条雷达图量不到、但同样真实的差别:运维成本。  Go 网关是一个静态二进制加两个 GC 旋钮;JVM 网关要决定堆大小、选 GC、看元空间、考虑 GC 日志与 OOM dump,还要记住 -Dreactor.netty.ioWorkerCount 这个默认 4 的坑。native 则是第三种麻烦:构建 6 分半、要吃 5 GB 内存,参数还静默失效——它把运行时的复杂度搬到了构建期。

最后要说清楚本文的边界:这里测的是"转发型网关" ——请求进来、改几个头、写一行日志、出去。业务逻辑越重,Java 的长期优化能力越能发挥,本文的结论越不适用。这不是"Java 不适合做网关",而是"转发型负载下,三条路线各有一条明显短板,谁也没法全拿"。

七、带走三句话

第一,七格里没有一条线全在圆外,三处最深的凹口还落在不同的格子上。  Go 凹在 p99(2.03 倍)和 >10 ms 占比(4.98 倍);Java+G1 凹在启动(83.9 倍)和内存(5.25 倍);native+G1 凹在吞吐(0.69 倍)。选型就是选你愿意在哪一格认输。

第二,JIT 的账这次单独标了价。  同一个 G1,JIT 在不在,p99 差 33%、吞吐差 48%、CPU 差 32%、内存差 2.5 倍、启动差 70 倍。前三条买得到,后两条买不到——这就是为什么"用 native 换启动"不是免费的。

第三,停顿改善 p99 这件事,需要量级对得上。  停顿 0.016 ms 的 ZGC 和停顿 24.569 ms 的 G1,p99 分别是 7.812 和 7.637;主对照批里 G1 的 p99 甚至比 ZGC 低 33%。落到主对照批那 5.27 ms 的差距上(10.370 − 5.100),Go 自己的停顿最多只能解释 4.6%。想靠压停顿改善 p99,先看看你的停顿和阈值差几个数量级。

选型不是挑"哪个指标更漂亮",而是问一句"我的约束落在哪一格,那一格差多少倍"。七个维度量一遍,答案通常比直觉朴素得多。


测试环境:OpenCloudOS 8.10 / 4 vCPU / 7 GB / kernel 5.4.241,CPU 型号被虚拟化隐藏为 General Processors(支持 AVX2 + FMA,无 AVX-512)。

工具链:go1.27.1;Temurin JDK 25.0.4+7(编译目标 21);GraalVM JDK 25.0.4+7(native image 专项)。

被测:Traefik 3.7.14(GOMAXPROCS=2);Spring Cloud Gateway 4.3.5 + Spring Boot 3.5.16(G1 / 分代 ZGC / Parallel);GraalVM native image(Serial GC / G1 两个版本)。JVM 与 native 均设 -XX:ActiveProcessorCount=2 且 -Dreactor.netty.ioWorkerCount=2。

方法:三层绑核(上游=核 2、网关=核 0-1、压测器=核 3);两侧活动处理器数与 event loop 线程数均对齐到 2(按 /proc//task/ /comm 的 reactor-http-epoll- 线程名实测核对);职责逐项对齐(同上游、同 header 注入、同 JSON 访问日志,日志体量 1108 vs 9971008 字节/请求);预热 20 s + 测量 30 s,并发 32;GC 统计只取测量窗内新增日志;所有延迟数字丢前 2 秒重算(依据:132 个历史用例中 74 个 JVM 用例有 63 个降幅超 5%,58 个 Go/native/直连用例 0 个受影响);并发 32 的全部用例两侧 CPU 均打满 1.921.98 核,故 rps 比值即每请求 CPU 成本比值。

主对照批:五家(Traefik / SCG+ZGC / SCG+G1 / native+Serial / native+G1)同一脚本连续跑完,堆统一 -Xms512m -Xmx512m。

噪声基线:同配置三次 8438 / 8871 / 9066 rps(极差 7.1%),1 核配额三次 7200 / 7203 / 7262(0.9%),max 同配置三轮极差 106%~385%(故不用于排名)。

完整原始数据与复现脚本:drafts/gw-bench/(RESULTS.md · rungw.sh · run_native_cmp.sh · run_heap.sh · mem_decomp.sh · run_aot.sh · run_dim2.sh · baseline_noise.sh · recalc_head.py · analyze**.py · results/ · conf/ · scgw/ · upstream/)

参考:xpbob.site