同一份 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=0、sync_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%。
三次被自己的数据误导
到这里我卡了很久,而且走错了方向。回头看,有三个地方我把证据读歪了。
一、Windows 上的 CPU profile 不可信
跑 -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 采样精度有已知问题,别拿它当排除依据。
二、tmpfs 实验证明的比我以为的少
「磁盘 348s vs 内存 343s」这个结果,我读成了「不是存储侧的问题」,进而把整个数据库方向排除了。
它真正证明的只有一句:不是磁盘。MySQL 之外还有网络、协议、驱动,这个实验一个都没覆盖。
三、拿 Python 测出的结论,不适用于 Go
这是最贵的一次。我用 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_PREPARE→COM_STMT_EXECUTE→COM_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/条。
结对的价值大概就在这儿:一方负责把每个假设都量出数字,另一方负责问「这个方案在别人那儿成立吗」。前者容易陷进自己的搜索空间,后者能把它拽出来。