目录

同一份 Go 代码,Windows 上每条 SQL 慢 200 倍

一个 Go 后端项目,本地跑真实 MySQL 的集成测试要 348 秒,同样的测试在 CI(Linux)上只要 76 秒

差 4.6 倍。而这台开发机是 16 核、15G 内存,比 CI runner 强。

更奇怪的是差距不均匀:

测试包 本地 (Windows) CI (Linux)
models 14 s 30 s
contract 68 s 66 s
integration 353 s 76 s

models 包本地反而更快contract 持平,只有 integration 慢 4.6 倍。如果是 CPU 或磁盘的普遍问题,不该是这个形状。

我按「先便宜后昂贵」的顺序量,每一项都留了数字。

MySQL 版本。本地开发库是 9.4,线上和 CI 是 8.1。起了个 8.1 容器同参数对比,差 6–10%,在噪声范围内。不是版本。

MySQL 配置。本地那个容器早就调过 innodb_flush_log_at_trx_commit=0sync_binlog=0、关 binlog 和 doublewrite。起一个全默认的对比,2000 条 INSERT 从 0.75 秒变成 10.1 秒 —— 配置值 13 倍,但本地已经调过了,这条路已经吃掉了。

磁盘 IO。这个实验很干脆:起一个数据目录挂 tmpfs(内存盘)的 MySQL,跑同一个包。

磁盘 volume   348 s
tmpfs 内存盘   343 s

差 1.5%。磁盘不是瓶颈。

连接与往返。用 Python 的 pymysql 从 Windows 连过去测:

建立并关闭一次连接   3.3 ms
同一连接一次往返     0.15 ms

都很正常。

每个用例的固定开销。测试框架每个用例要重置模板库(对 135 张表逐个 DELETE)。第一次测得 482 ms,吓了一跳;改用长连接重测是 5.2 ms —— 第一次每轮都新起一个 mysql CLI 进程,量的是进程启动。

SQL 数量与慢查询。挑最慢的那个用例(38 秒),跑之前之后各读一次 SHOW GLOBAL STATUS LIKE 'Questions'

墙钟 38.3 s / SQL 1143 条 / 按 0.22ms/条算往返共 0.25 s(0.7%)

再开慢查询日志,阈值 50ms:整个用例只抓到 1 条,累计 0.46 秒

sleep。整个 integration 包只有两处 time.Sleep,各 500ms。

加起来,MySQL 那一侧的全部开销占不到 2%

到这里我卡了很久,而且走错了方向。回头看,有三个地方我把证据读歪了。

-cpuprofile 得到:

Duration: 27.43s, Total samples = 330ms (1.20%)
  63.64%  runtime.cgocall

CPU 使用率 1.2%,最大的一项是 cgocall(Windows 上的系统调用都走这条)。我据此判断「时间都在等 I/O」,加上 SQL 往返只占 0.7%,得出了一个自相矛盾的结论:既不在 CPU,也不在数据库,那 98% 在等什么?

后来才知道,真相是每条 SQL 卡 44 毫秒 —— profile 完全没采到,因为那段时间进程确实在等,只是等的东西不是我以为的那些。

Windows 上 Go 的 CPU profile 采样精度有已知问题,别拿它当排除依据。

「磁盘 348s vs 内存 343s」这个结果,我读成了「不是存储侧的问题」,进而把整个数据库方向排除了。

它真正证明的只有一句:不是磁盘。MySQL 之外还有网络、协议、驱动,这个实验一个都没覆盖。

这是最贵的一次。我用 pymysql 测出「Windows → 容器里的 MySQL,每条 SQL 0.22 ms」,得出「跨边界不慢」,于是彻底放下了网络方向,转而怀疑平台本身,甚至开始搭 WSL 环境准备把测试整个搬过去。

直到写了一个 Go 版的同款基准:

Python (Windows) → MySQL   0.22 ms/条
Go     (Windows) → MySQL   44.3 ms/条

同一台机器、同一个 MySQL、同样的 SQL,Go 慢 200 倍。

测什么就用什么语言测。语言与驱动的行为差异,可以比平台差异大两个数量级。

44 毫秒这个数字很眼熟 —— 接近 TCP 延迟确认(delayed ACK)的典型值。顺着这条线做了三写法对照,每种 500 条 INSERT:

写法 Windows WSL (同机)
占位符 db.Exec("... VALUES (?, ?)", a, b) 44.76 ms/条 0.165 ms/条
interpolateParams=true 0.205 ms/条 0.086 ms/条
裸 SQL 字符串(无参数) 0.186 ms/条 0.103 ms/条

结论清楚了:

  • Go 的 database/sql 遇到带占位符的语句,默认走 prepared statement —— COM_STMT_PREPARECOM_STMT_EXECUTECOM_STMT_CLOSE,三次往返,而 EXECUTE 会分成命令头和参数两个小包发出。
  • 多包小写在 Windows 这条路径上撞上了 40 毫秒级的延迟确认。单包的 COM_QUERY 就完全没事。
  • pymysql 默认在客户端拼好完整 SQL 再发,天然是单包 —— 所以它一直是 0.22 ms,把我引到了错误的方向。
  • 同一份 Go 代码在 Linux 上是 0.165 ms/条,所以这不是 Go 的问题,也不是 prepared statement 的固有成本,是这三者凑在一起才出现的。

go-sql-driver/mysql 有个 DSN 参数 interpolateParams=true,让驱动在客户端拼好 SQL 一次发出:

root:***@tcp(127.0.0.1:3306)/?parseTime=true&charset=utf8mb4&interpolateParams=true

真实效果:

之前 之后
integration 全包 348 s 56.7 s
services 包 283 s 19.1 s
完整验证脚本墙钟 ~6 min 3 min 49 s

作为参照,把测试搬进 WSL 是 53 秒 —— 一个连接参数就在 Windows 上拿到了 Linux 的速度

但这个参数不是免费的,它把参数转义的责任从 MySQL 挪到了驱动。所以最后的配置是分开的:

环境 开不开 理由
本地测试 6 倍,且开发者机器上没有不可信输入
CI 不开 Linux 上本来就不慢;让它继续走生产同款的 prepared 路径,兜住「本地与线上发送路径不同」的风险
生产 不开 安全模型的改变不该顺手做

这个组合的好处是:本地快,而真实路径由 CI 覆盖。如果哪天出现 time.Time[]byte 这类的格式化差异,CI 会红在合并之前。

另外注意 interpolateParams 不要和 multiStatements 同开 —— 前者把参数拼进 SQL,后者允许一次发多条语句,两者叠加会放大任何转义缺陷的后果。我们那条要导入整份 schema、必须开 multiStatements 的连接,会主动把 interpolateParams 摘掉。

排查里我做对的是每一步都留了数字,做错的是急着用数字做减法

tmpfs 实验、Python 基准、CPU profile,三个都是真实测量,但我从每一个里读出了比它实际证明的更多的东西。「不是磁盘」被我读成「不是存储」,「Python 不慢」被我读成「网络不慢」,「CPU 采样低」被我读成「在等 I/O」。

排除法的每一步都在缩小搜索空间,一旦某一步多排除了一点,正确答案就落在了搜索空间之外 —— 后面再怎么仔细,也找不回来了。

最后打开局面的,是一个我早该做的对照:用被测系统实际使用的语言和驱动,重跑一遍已经"测过"的东西。

这篇文章记录的排查是我和 Claude Code 一起做的 —— 上面那些容器、基准脚本、对照实验,绝大部分是它写的和跑的,文章初稿也出自它手。

有意思的是,把方向掰回来的不是某次更聪明的测量,而是一个跟性能无关的问题。

它当时已经量到「Windows 慢 6.5 倍」,方案是把测试整个搬进 WSL,环境都搭好了。我问了一句:这样会不会不方便多人协作?别人的开发环境和我的不一样。

这个问题跟「为什么慢」毫无关系,但它否掉了「绕开平台」这条路 —— 既然方案必须对所有人都成立,就不能停在「Windows 就是慢」这个结论上,得继续往下挖到能修的东西。十几分钟后,Go 版基准跑出了 44 ms/条。

结对的价值大概就在这儿:一方负责把每个假设都量出数字,另一方负责问「这个方案在别人那儿成立吗」。前者容易陷进自己的搜索空间,后者能把它拽出来。