MySQL 单表五亿行实测:加载、索引、读写延迟与 PostgreSQL 对照

上一篇在同一台桌面机上做了 PostgreSQL 单表五亿行实验。这次沿用相同的订单字段、数据规模与业务场景,换成 MySQL 9.7.1,再走一遍小样本校准、全量加载、建索引、数据验证和读写压测。

五亿行种子数据已完成精确校验,8 个正式压测场景全部成功,失败事务为 0。 主键查单约 7.53 万 TPS;客户最新订单、租户已支付列表和小时聚合分别约 714、297、130 TPS。逐事务同步提交的新增订单约 1,875 TPS,普通字段更新约 1,323 TPS,索引字段更新约 1,031 TPS。

这些数字属于本文这套数据、客户端、索引与缓存配置。客户端从 pgbench 换成 Go,时间索引从 BRIN 换成 B-tree,存储与日志机制也发生变化;文末的 PostgreSQL 结果是两次具体实验的旁列,不能直接当作数据库性能排名。

1. 先看完整结果

正式测量使用 16 个连接、Go GOMAXPROCS=8、Unix socket、服务器端 prepared statements。每项预热 10 秒,再测量 60 秒,仅运行一轮。下表按实际执行顺序排列。

场景 TPS 平均 ms 采样 p95 ms 采样 p99 ms 采样数
主键查单 75,281.21 0.212 0.383 0.532 44,949
客户最新订单 713.93 22.407 29.560 31.372 400
租户已支付订单 296.51 53.875 268.545 273.381 173
一小时订单聚合 130.25 122.747 154.009 157.882 73
新增订单 1,874.64 8.534 13.869 23.792 1,124
普通字段更新 1,323.42 12.087 17.658 43.476 788
索引字段更新 1,031.43 15.511 30.213 127.401 603
80% 读 / 20% 写 1,011.84 15.768 57.792 210.682 593
{ "animation": false, "tooltip": { "trigger": "axis", "axisPointer": { "type": "shadow" } }, "grid": { "left": 8, "right": 22, "top": 24, "bottom": 55, "containLabel": true }, "xAxis": { "type": "log", "name": "TPS / 对数刻度", "nameLocation": "middle", "nameGap": 32, "splitNumber": 3, "axisLabel": { "hideOverlap": true } }, "yAxis": { "type": "category", "inverse": true, "data": [ "主键查单", "客户最新订单", "租户已支付订单", "一小时订单聚合", "新增订单", "普通字段更新", "索引字段更新", "80% 读 / 20% 写" ], "axisLabel": { "width": 90, "overflow": "break", "interval": 0 } }, "series": [ { "name": "MySQL TPS", "type": "bar", "data": [ 75281.213, 713.927, 296.508, 130.252, 1874.641, 1323.416, 1031.425, 1011.844 ], "barMaxWidth": 24 } ], "aria": { "enabled": true } }

TPS 的分子是完成的业务脚本次数。客户列表和租户列表各包含两条 SELECT,不能把它们与主键查询当作相同工作量,也不能直接把 TPS 称为 SQL QPS。

平均延迟由所有成功脚本的客户端执行耗时求平均;p95、p99 来自成功事务的 1% 随机采样。平均值与采样分位数使用不同统计总体。客户端完成结果读取后才计成功,不把仅发送请求或未消费的结果集算作完成。

证据:原始汇总CSV正式运行参数完成标记

2. 实验环境:同一台机器,独立 MySQL 实例

项目 本次条件
CPU Intel Core i9-14900K,24 个物理核心 / 32 个逻辑 CPU,未绑核
内存 系统报告约 46.8 GiB 物理内存,另有约 23.4 GiB swap
操作系统 Arch Linux,Linux 7.2.4-arch1-2
存储 本机 NVMe;Btrfs,挂载选项含 compress=zstd:3
MySQL 9.7.1,使用本机已安装程序建立独立数据目录
客户端 Go 1.26.5,go-sql-driver/mysql 1.10.1
连接 私有 Unix socket;不经过 TCP 或容器网络
InnoDB Buffer Pool 4 GiB
redo 容量 8 GiB
提交持久性 innodb_flush_log_at_trx_commit=1,sync_binlog=1,log_bin=ON
I/O 方式 innodb_flush_method=O_DIRECT
二级索引构建 innodb_ddl_threads=2,innodb_ddl_buffer_size=512 MiB
时间 数据库存储与采集时间采用 UTC,本文流程时间另注明 UTC+8
flowchart TD A["桌面机:i9-14900K / 约 46.8 GiB RAM"] B["Go prepared 客户端<br/>16 连接 / GOMAXPROCS 8"] C["MySQL 9.7.1 独立实例<br/>redo + binlog / 同步提交"] D["InnoDB 普通订单表<br/>5 亿种子行 / 主键 + 3 个二级索引"] E["NVMe / Btrfs zstd:3<br/>数据与临时目录位于同一文件系统"] F["8 个场景:预热 10 秒 + 测量 60 秒<br/>1% 延迟采样 / 原始证据复算"] A --> B B -->|Unix socket| C C --> D D --> E B --> F

GOMAXPROCS=8 控制 Go 客户端同时执行 Go 代码的并行度,不是 MySQL 服务器线程数,也不等同于 pgbench 的 8 个工作线程。

两个实例都在桌面机上运行,没有控制 CPU 频率、温度或其他桌面程序,也没有主动清空操作系统页缓存。MySQL 与 PostgreSQL 的缓存路径不同:shared_buffers=4 GiBBuffer Pool=4 GiB 并不构成相同的总缓存预算。文件系统压缩还可能改变物理 I/O 与 CPU 成本。

运行设置见 before.json硬件环境在完成后补采;启动时信息另行保留。它们都不是持续的资源监控时间序列。

3. 五亿行是什么数据

业务表 orders 是普通、非分区的 InnoDB 表,采用 ROW_FORMAT=DYNAMIC。18 个字段覆盖主键、租户、客户、创建/更新/支付时间、状态、渠道、地区、件数、金额、优惠、运费、币种、城市、支付流水、JSON 元数据和 revision。

  • 种子 ID 连续为 1 至 500,000,000
  • 总共 1,000 个租户,其中 20 个热点租户精确拥有 400,000,000 行,即 80%。
  • 每租户最多 10,000 个合成客户,客户 ID 带有租户前缀。
  • 创建时间从 2024-01-01 UTC 起覆盖 730 天,最后一行是 2025-12-30 23:59:59.873856。730 天不等于两个完整自然年。
  • 按时间和主键递增装载;状态与客户来自不同哈希片段。

写压测之前,全表扫描得到以下精确状态分布:

状态 精确行数 比例
待支付 25,005,947 5.0012%
已支付 49,998,084 9.9996%
已发货 74,998,925 14.9998%
已完成 300,003,097 60.0006%
已取消 35,000,273 7.0001%
已退款 14,993,674 2.9987%

同时校验了 ID 上下界、租户/客户关系、时间范围、支付时间与状态的一致性,以及初始 revision。所有这些错误计数均为 0,见 seed-validation.json

MySQL 本次使用 SHA2() 的不同片段生成数据;原 PostgreSQL 使用 hashint8()。两边对齐的是规模、结构和分布,不是逐行完全相同的内容,客户端随机请求序列也不同。金额、渠道、件数等仍是简化的合成规则,不代表完整真实订单模型。

4. 从小样本到五亿行:实际花了多久

正式加载前先生成 100 万行,完成索引与全表校验,再外推容量。八个场景的预试验均成功;还主动中断了另一个样本的在途批次:之前已提交的 10 万行保持不变,续跑后精确达到 60 万行。

正式加载采用数据库内部 INSERT … SELECT,每批 250,000 行。辅助数字表只有 1,000 行;两个副本组合成批内 ID,不需要从客户端传输五亿行 CSV。业务行与加载水位在同一事务中提交,主键从第一批开始维护,其他索引延后构建。

加载本身耗时 5,909.80 秒,平均约 84,605 行/秒。这是批量生成、只维护主键时的吞吐,不能与完整索引下、逐事务同步提交的业务 INSERT TPS 直接比较。

整个流水线从 9 月 15 日 22:31 到 9 月 16 日 00:55,合计约 143.90 分钟,包含了一次索引失败与修复间隔。

阶段 开始 UTC+8 结束 UTC+8 分钟
加载五亿行 09-15 22:31:21 09-16 00:09:51 98.50
首次客户索引(失败) 09-16 00:09:51 09-16 00:13:23 3.53
诊断、迁移临时目录、重启 09-16 00:13:23 09-16 00:19:04 5.69
客户索引(恢复) 09-16 00:19:04 09-16 00:28:01 8.95
租户状态索引 09-16 00:28:01 09-16 00:35:50 7.82
时间 B-tree 索引 09-16 00:35:50 09-16 00:41:22 5.53
统计、精确校验、日志刷新 09-16 00:41:22 09-16 00:45:51 4.49
计划、预热与八场景测量 09-16 00:45:51 09-16 00:55:15 9.40
gantt title 实际流水线(UTC+8,包含失败与恢复) dateFormat YYYY-MM-DD HH:mm:ss axisFormat %H:%M tickInterval 1hour section 数据与索引 加载五亿行 :p0, 2026-09-15 22:31:21, 2026-09-16 00:09:51 首次客户索引(失败) :p1, 2026-09-16 00:09:51, 2026-09-16 00:13:23 诊断 迁移临时目录 重启 :p2, 2026-09-16 00:13:23, 2026-09-16 00:19:04 客户索引(恢复) :p3, 2026-09-16 00:19:04, 2026-09-16 00:28:01 租户状态索引 :p4, 2026-09-16 00:28:01, 2026-09-16 00:35:50 时间 B-tree 索引 :p5, 2026-09-16 00:35:50, 2026-09-16 00:41:22 统计 精确校验 日志刷新 :p6, 2026-09-16 00:41:22, 2026-09-16 00:45:51 计划 预热与八场景测量 :p7, 2026-09-16 00:45:51, 2026-09-16 00:55:15

首次建索引为什么失败

五亿行在 00:09 加载完成后,第一轮客户索引构建在 00:13 停止。客户端只显示 ERROR 1030 / InnoDB error 100,服务器错误日志给出的具体原因是:

Write to file (ddl) failed ...
Operating system error number 122
Disk quota exceeded

当时数据盘仍有约 458 GiB 空闲,但 MySQL 默认的 tmpdir 指向 /tmp,它在本机是带 usrquota 的 tmpfs。此前的容量检查覆盖了数据盘,没有覆盖这个独立临时存储约束。

修复时将 tmpdirinnodb_tmpdir 都指向工程 SSD 上的 tmp/,补上运行参数及同文件系统检查,重启专用实例,再从索引阶段恢复。已提交的五亿行保留。恢复后确认真实临时文件正在新目录生成,三个索引随后全部成功。

这次经验对应一个明确检查项:大型建索引任务需要检查实际临时目录的容量、挂载类型和配额,不能只看数据目录所在磁盘的 df。 100 万行预试验不会自动覆盖五亿行排序产生的临时文件规模。

故障与恢复见 恢复证据完整流水线日志。这段失败不能从总耗时中隐去,也不能算作一次连续成功的建索引。

5. 索引与存储:MySQL 没有 BRIN

最终索引如下:

PRIMARY KEY (order_id)
INDEX orders_customer_created_idx
    (tenant_id, customer_id, created_at DESC, order_id DESC)
INDEX orders_tenant_status_created_idx
    (tenant_id, status, created_at DESC, order_id DESC)
INDEX orders_created_idx (created_at)

前两个二级索引对应客户最新订单和租户已支付列表,金额不在索引中,因此读取结果仍需回表。时间索引用 B-tree;原 PostgreSQL 实验使用 BRIN(created_at)。时间索引的体积与读取路径由此发生变化。

InnoDB 的业务数据位于聚簇主键叶子页中,二级索引包含主键值;PostgreSQL 的 heap 与主键索引分开存储。下面保留各自的统计口径:

数据库 / 口径 表 GiB 索引 GiB 合计 GiB
PG 关系逻辑大小 117.200 52.478 169.678
MySQL InnoDB 分配页 103.212 49.379 152.591
{ "animation": false, "tooltip": { "trigger": "axis", "axisPointer": { "type": "shadow" } }, "grid": { "left": 8, "right": 22, "top": 24, "bottom": 55, "containLabel": true }, "xAxis": { "type": "value", "name": "GiB / 口径不同", "nameLocation": "middle", "nameGap": 32, "splitNumber": 3, "axisLabel": { "hideOverlap": true } }, "yAxis": { "type": "category", "inverse": true, "data": [ "PG 表", "PG 索引", "MySQL 表", "MySQL 二级索引" ], "axisLabel": { "width": 90, "overflow": "break", "interval": 0 } }, "series": [ { "name": "GiB", "type": "bar", "data": [ 117.2, 52.478, 103.212, 49.379 ], "barMaxWidth": 24 } ], "aria": { "enabled": true } }

MySQL 的表加索引约 152.59 GiB,来自压测前 information_schema.TABLES 的分配页统计。它不包含全部 redo、binlog、undo、临时文件,也不是 Btrfs 压缩后的物理占用。

TABLE_ROWS 仍是估计值,不能替代精确行数校验。压测前后表大小统计也可能因刷新时机而相同,不能由此推断写入没有增加数据。写压测开始前的 500,000,000 行来自精确扫描。

6. 八个脚本具体执行什么

场景 行为与边界
主键查单 在种子 ID 中均匀抽样,读取字段与 JSON;必须返回一行
客户最新订单 先抽一笔种子订单获取租户/客户,再取该客户最新 20 单;不是均匀抽客户
租户已支付列表 先抽订单获取租户,再取 status=1 最新 50 单;热点租户更常被选中
一小时聚合 在 17,520 个小时中均匀选一个,按状态统计订单数与金额;约 28,539 行/小时
新增订单 热点租户写入当前时间订单,AUTO_INCREMENT 从 10¹² 起,不占用种子 ID
普通字段更新 随机种子订单,修改 revision / updated_at;要求恰好影响一行
索引字段更新 将 status 在 1/2 之间切换,同时更新 revision / 时间;是简化操作,不是完整订单状态机
混合负载 主键 60%、客户 15%、租户 5%、插入 10%、普通更新 5%、状态更新 5%

两个列表脚本的两条 SELECT 都使用 autocommit,没有显式 BEGIN,因此不共享同一事务快照。80% 读 / 20% 写指的是脚本比例,不是 SQL 条数比例。

连接与 prepare 在计时前完成。客户端是闭环发送,完成一个脚本后再发送下一个,没有固定请求到达率。平均延迟覆盖执行与结果读取,随机选择及采样管理开销进入吞吐墙钟时间;该计时边界也不与 pgbench 的内部实现完全等价。

写场景的预热同样会修改数据。全部预热和正式测量结束后,独立写入 ID 区间精确计数为 136,751 行,与所有 insert / mixed 脚本记录的插入总数一致,见写入校验。后续重复测量会累积这些修改,不会自动恢复成相同快照。

7. 尾延迟:低采样数必须写出来

{ "animation": false, "tooltip": { "trigger": "axis", "axisPointer": { "type": "shadow" } }, "grid": { "left": 8, "right": 22, "top": 55, "bottom": 55, "containLabel": true }, "xAxis": { "type": "log", "name": "ms / 对数刻度", "nameLocation": "middle", "nameGap": 32, "splitNumber": 3, "axisLabel": { "hideOverlap": true } }, "yAxis": { "type": "category", "inverse": true, "data": [ "主键查单", "客户最新订单", "租户已支付订单", "一小时订单聚合", "新增订单", "普通字段更新", "索引字段更新", "80% 读 / 20% 写" ], "axisLabel": { "width": 90, "overflow": "break", "interval": 0 } }, "series": [ { "name": "p95", "type": "bar", "data": [ 0.383, 29.56, 268.545, 154.009, 13.869, 17.658, 30.213, 57.792 ], "barMaxWidth": 24 }, { "name": "p99", "type": "bar", "data": [ 0.532, 31.372, 273.381, 157.882, 23.792, 43.476, 127.401, 210.682 ], "barMaxWidth": 24 } ], "aria": { "enabled": true }, "legend": { "top": 0, "data": [ "p95", "p99" ] } }

主键查单有 44,949 个样本;客户列表 400 个;租户列表只有 173 个;小时聚合只有 73 个。样本数由完成事务数和随机采样率共同决定,不能把“采样率 1%”理解为所有场景都有足够稳定的尾部估计。

尤其是小时聚合的 p99,主要由极少数最慢样本及插值决定。本次采用排序后线性插值计算分位数,保留原始 CSV 供复算;这个结果不能拿来承诺生产 SLA。要讨论稳定尾延迟,需要更长测量、多轮重复,以及足够的成功样本量。

混合负载平均约 15.77 ms,采样中位数只有约 0.67 ms,p99 约 210.68 ms。大量短主键查询和少量慢列表、写请求进入同一个混合总体后,平均值、中位数和尾部可以相差很大;仅给一个“平均响应时间”会掩盖这种结构。

8. 列表查询慢,是没有走索引吗

归档的代表性 EXPLAIN ANALYZE 显示:

  • 客户列表使用 orders_customer_created_idx,上层有 LIMIT 20
  • 租户已支付列表使用 orders_tenant_status_created_idx,上层有 LIMIT 50
  • 小时聚合使用 orders_created_idx 做范围扫描,再进入临时表聚合;该小时实际读取 28,539 行。

主键字面量计划显示 Rows fetched before execution,属于常量查找的展示形式,不能拿那一行极小的时间充当并发主键延迟。

完成后还采集了真实 prepared 语句的累计摘要:两个列表对应的 SUM_SORT_ROWSSUM_SORT_SCANSUM_NO_INDEX_USED 均为 0。这与归档计划一致,没有支持“列表没有走索引或做了额外排序”的证据。这份摘要包含预热、混合场景等累计执行,采集时刻也晚于压测,所以不能把它当作单个正式 60 秒窗口的统计。

计划见 explain.json,累计摘要见 post-run-digests.tsv。字面量计划不证明所有 prepared 执行都采用完全相同的内部路径,摘要也不是逐请求追踪。

测量窗口内确实观察到了高读入量

以下按每个场景的前后实例计数器求差,再用完成脚本数归一化:

场景 实例读入 GiB 读入 MiB / 脚本 Buffer Pool 磁盘读次数 / 脚本
主键查单 68.37 0.015 0.99
客户最新订单 97.30 2.325 148.77
租户已支付订单 91.73 5.262 336.71
一小时订单聚合 53.57 7.008 127.13
新增订单 1.20 0.011 0.70
普通字段更新 1.19 0.015 0.98
索引字段更新 2.41 0.040 2.55
80% 读 / 20% 写 34.33 0.577 36.94

实例读入总量

{ "animation": false, "tooltip": { "trigger": "axis", "axisPointer": { "type": "shadow" } }, "grid": { "left": 8, "right": 22, "top": 24, "bottom": 55, "containLabel": true }, "xAxis": { "type": "value", "name": "GiB", "nameLocation": "middle", "nameGap": 32, "splitNumber": 3, "axisLabel": { "hideOverlap": true } }, "yAxis": { "type": "category", "inverse": true, "data": [ "主键查单", "客户最新订单", "租户已支付订单", "一小时订单聚合", "新增订单", "普通字段更新", "索引字段更新", "80% 读 / 20% 写" ], "axisLabel": { "width": 90, "overflow": "break", "interval": 0 } }, "series": [ { "name": "读入", "type": "bar", "data": [ 68.371, 97.296, 91.735, 53.575, 1.2, 1.189, 2.41, 34.33 ], "barMaxWidth": 24 } ], "aria": { "enabled": true } }

按脚本完成数归一化

{ "animation": false, "tooltip": { "trigger": "axis", "axisPointer": { "type": "shadow" } }, "grid": { "left": 8, "right": 22, "top": 24, "bottom": 55, "containLabel": true }, "xAxis": { "type": "value", "name": "MiB / 脚本", "nameLocation": "middle", "nameGap": 32, "splitNumber": 3, "axisLabel": { "hideOverlap": true } }, "yAxis": { "type": "category", "inverse": true, "data": [ "主键查单", "客户最新订单", "租户已支付订单", "一小时订单聚合", "新增订单", "普通字段更新", "索引字段更新", "80% 读 / 20% 写" ], "axisLabel": { "width": 90, "overflow": "break", "interval": 0 } }, "series": [ { "name": "读入", "type": "bar", "data": [ 0.015, 2.325, 5.262, 7.008, 0.011, 0.015, 0.04, 0.577 ], "barMaxWidth": 24 } ], "aria": { "enabled": true } }

两个列表场景的实例读入量很高。主键查单只需定位一笔订单;列表需要额外的二级索引路径和多笔非覆盖列读取,访问模式也不同。但本次没有通过覆盖索引、缓存大小或相同快照的受控对照来拆分各项成本,不能仅凭这些计数器解释全部性能差距

这里的 Innodb_data_read 是 InnoDB 的字节计数,Innodb_buffer_pool_reads 是 Buffer Pool 读次数。它们属于实例范围,快照间也包含少量采集开销;归一化值不是逐事务精确归因,更不是 NVMe 硬件层实测字节数。Btrfs 压缩与读预取等行为也会影响不同层次的数字关系。

混合负载窗口内 Innodb_buffer_pool_wait_free 增加了 3,131;它表示等待获得可用页的次数,不是等待毫秒数。本次记录能提示继续调查缓冲池与刷脏压力,但不足以给出完整的瓶颈归因。

9. 更新索引列的代价

普通字段更新得到约 1,323 TPS,状态索引字段更新约 1,031 TPS。后者还需维护状态二级索引,实际数据文件写入与 redo 归一化值也不同:

{ "animation": false, "tooltip": { "trigger": "axis", "axisPointer": { "type": "shadow" } }, "grid": { "left": 8, "right": 22, "top": 24, "bottom": 55, "containLabel": true }, "xAxis": { "type": "value", "name": "TPS", "nameLocation": "middle", "nameGap": 32, "splitNumber": 3, "axisLabel": { "hideOverlap": true } }, "yAxis": { "type": "category", "inverse": true, "data": [ "普通更新", "索引更新" ], "axisLabel": { "width": 90, "overflow": "break", "interval": 0 } }, "series": [ { "name": "吞吐", "type": "bar", "data": [ 1323.416, 1031.425 ], "barMaxWidth": 24 } ], "aria": { "enabled": true } }
{ "animation": false, "tooltip": { "trigger": "axis", "axisPointer": { "type": "shadow" } }, "grid": { "left": 8, "right": 22, "top": 24, "bottom": 55, "containLabel": true }, "xAxis": { "type": "value", "name": "KiB / 脚本", "nameLocation": "middle", "nameGap": 32, "splitNumber": 3, "axisLabel": { "hideOverlap": true } }, "yAxis": { "type": "category", "inverse": true, "data": [ "普通更新", "索引更新" ], "axisLabel": { "width": 90, "overflow": "break", "interval": 0 } }, "series": [ { "name": "数据文件写入", "type": "bar", "data": [ 18.512, 40.143 ], "barMaxWidth": 24 } ], "aria": { "enabled": true } }
{ "animation": false, "tooltip": { "trigger": "axis", "axisPointer": { "type": "shadow" } }, "grid": { "left": 8, "right": 22, "top": 24, "bottom": 55, "containLabel": true }, "xAxis": { "type": "value", "name": "KiB / 脚本", "nameLocation": "middle", "nameGap": 32, "splitNumber": 3, "axisLabel": { "hideOverlap": true } }, "yAxis": { "type": "category", "inverse": true, "data": [ "普通更新", "索引更新" ], "axisLabel": { "width": 90, "overflow": "break", "interval": 0 } }, "series": [ { "name": "redo 写入", "type": "bar", "data": [ 0.799, 0.995 ], "barMaxWidth": 24 } ], "aria": { "enabled": true } }

两项是顺序执行,未从同一快照恢复。数据文件写入计数包含后台刷脏,也可能反映此前积累的工作;不能将窗口里的所有字节都归因于当下某条 SQL。这里没有把 redo 当作完整日志成本,MySQL 本次还保留了 binlog。

原 PostgreSQL 实验记录了 HOT 更新比例。MySQL 的普通字段更新不应直接称作“HOT 更新”,本文也没有伪造一个可直接对应的比例。

10. 与 PostgreSQL 原实验放在一起看

两边都是同机五亿行订单、16 个连接、每项预热 10 秒并测量 60 秒、单轮测试。结果如下:

场景 PostgreSQL 18.6 TPS MySQL 9.7.1 TPS
主键查单 48,644.79 75,281.21
客户最新订单 16,858.86 713.93
租户已支付订单 45,159.06 296.51
一小时订单聚合 420.58 130.25
新增订单 4,024.59 1,874.64
普通字段更新 3,858.05 1,323.42
索引字段更新 2,494.61 1,031.43
80% 读 / 20% 写 8,403.97 1,011.84
{ "animation": false, "tooltip": { "trigger": "axis", "axisPointer": { "type": "shadow" } }, "grid": { "left": 8, "right": 22, "top": 55, "bottom": 55, "containLabel": true }, "xAxis": { "type": "log", "name": "TPS / 对数刻度", "nameLocation": "middle", "nameGap": 32, "splitNumber": 3, "axisLabel": { "hideOverlap": true } }, "yAxis": { "type": "category", "inverse": true, "data": [ "主键查单", "客户最新订单", "租户已支付订单", "一小时订单聚合", "新增订单", "普通字段更新", "索引字段更新", "80% 读 / 20% 写" ], "axisLabel": { "width": 90, "overflow": "break", "interval": 0 } }, "series": [ { "name": "PG 18.6", "type": "bar", "data": [ 48644.791, 16858.86, 45159.061, 420.581, 4024.592, 3858.045, 2494.605, 8403.968 ], "barMaxWidth": 24 }, { "name": "MySQL 9.7.1", "type": "bar", "data": [ 75281.213, 713.927, 296.508, 130.252, 1874.641, 1323.416, 1031.425, 1011.844 ], "barMaxWidth": 24 } ], "aria": { "enabled": true }, "legend": { "top": 0, "data": [ "PG 18.6", "MySQL 9.7.1" ] } }

在这些具体条件下,MySQL 的主键场景 TPS 更高,其余场景的 TPS 更低。但至少以下变量没有被严格控制为等价:

  1. 客户端实现不同。 pgbench 的 C 实现与 Go/database/sql/驱动具有不同调度和计时路径。
  2. 数据不是逐行相同。 哈希算法、随机数生成器和请求序列不同。
  3. 时间索引不同。 PostgreSQL 使用 BRIN,MySQL 使用 B-tree;表的物理存储结构也不同。
  4. 缓存路径不同。 同样设置 4 GiB 主缓冲区,不等于相同总缓存预算;MySQL 使用 O_DIRECT,前序加载、索引、校验和查询影响缓存状态。
  5. 持久化工作不同。 PostgreSQL WAL 与 MySQL redo + binlog 不能只按参数名字视作相同成本。
  6. 是两次桌面机实验。 测量时间不同,没有完整的频率、温度和竞争负载控制;这次还因临时目录问题重启过专用实例。

因此,这张表回答的是“这两套具体实现与配置,在本机这个业务模型上测到了什么”。若要回答引擎差异,需要统一数据、客户端和资源约束,分别控制索引、覆盖列、缓存及持久性,再增加重复次数。

PostgreSQL 原始证据位于已发布实验仓库。本文使用的对应摘要、参数与压测前存储数据也保存在 reference/

11. 怎样复现与复核

工程入口为 bench.py,数据结构和造数 SQL 位于 sql/,测量客户端位于 runner/main.go。完整安装与命令说明见 README

# 编译客户端
cd runner
go mod download
go build -trimpath -o ../bin/mysqlbench .
cd ..

# 独立实例与小样本;estimate 要在任何写压测前执行
python3 bench.py start
python3 bench.py --db bench_pilot init --rows 1000000
python3 bench.py --db bench_pilot load
python3 bench.py --db bench_pilot prepare
python3 bench.py --db bench_pilot estimate

# 确认容量后,执行全量流水线
python3 bench.py --db bench_orders pipeline \
  --rows 500000000 --batch-size 250000 \
  --clients 16 --jobs 8 --seconds 60 --warmup 10 --sample-rate 0.01

专用数据和临时目录都位于工程下。容量检查在长 SQL 期间每 5 秒运行,但不是操作系统硬配额;其他程序仍可能消耗空间。默认保留 100 GiB 余量,另外估算索引排序与 binlog 预算。完整流水线可以由 systemd 用户服务运行,关终端后继续;重启或休眠的行为取决于系统与用户会话设置。

已经存在的同名库会续用,指定不同目标行数会拒绝。再次执行成功过的流水线会重新测量并累积写入效果。需要严格对比时,应先恢复同一份数据快照。

本次报告已经独立复算以下项目:事务总数、TPS、全量平均延迟、采样数、p50/p95/p99、预热完成状态、提交持久性参数,以及归档文件的 SHA-256。原始事务 CSV 字节未修改;归档中的本机工程路径替换为 <repo>,原始哈希另外保留。

python3 tools/verify_docs.py
# 可选:重新生成配图及三个文章版本
python3 tools/build_docs.py

固定配图版、Vditor 原生图表版和离线 HTML 都由同一份文章模板及归档 JSON 生成。本文仅记录已测得的数据;长期稳态、冷缓存、不同并发度、覆盖索引优化,以及受控的跨数据库比较,仍属于后续实验。