MySQL 单表五亿行实测:加载、索引、读写延迟与 PostgreSQL 对照
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 |
TPS 的分子是完成的业务脚本次数。客户列表和租户列表各包含两条 SELECT,不能把它们与主键查询当作相同工作量,也不能直接把 TPS 称为 SQL QPS。
平均延迟由所有成功脚本的客户端执行耗时求平均;p95、p99 来自成功事务的 1% 随机采样。平均值与采样分位数使用不同统计总体。客户端完成结果读取后才计成功,不把仅发送请求或未消费的结果集算作完成。
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 |
GOMAXPROCS=8 控制 Go 客户端同时执行 Go 代码的并行度,不是 MySQL 服务器线程数,也不等同于 pgbench 的 8 个工作线程。
两个实例都在桌面机上运行,没有控制 CPU 频率、温度或其他桌面程序,也没有主动清空操作系统页缓存。MySQL 与 PostgreSQL 的缓存路径不同:shared_buffers=4 GiB 与 Buffer 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 |
首次建索引为什么失败
五亿行在 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。此前的容量检查覆盖了数据盘,没有覆盖这个独立临时存储约束。
修复时将 tmpdir 和 innodb_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 |
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. 尾延迟:低采样数必须写出来
主键查单有 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_ROWS、SUM_SORT_SCAN、SUM_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 |
实例读入总量
按脚本完成数归一化
两个列表场景的实例读入量很高。主键查单只需定位一笔订单;列表需要额外的二级索引路径和多笔非覆盖列读取,访问模式也不同。但本次没有通过覆盖索引、缓存大小或相同快照的受控对照来拆分各项成本,不能仅凭这些计数器解释全部性能差距。
这里的 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 归一化值也不同:
两项是顺序执行,未从同一快照恢复。数据文件写入计数包含后台刷脏,也可能反映此前积累的工作;不能将窗口里的所有字节都归因于当下某条 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 |
在这些具体条件下,MySQL 的主键场景 TPS 更高,其余场景的 TPS 更低。但至少以下变量没有被严格控制为等价:
- 客户端实现不同。 pgbench 的 C 实现与 Go/database/sql/驱动具有不同调度和计时路径。
- 数据不是逐行相同。 哈希算法、随机数生成器和请求序列不同。
- 时间索引不同。 PostgreSQL 使用 BRIN,MySQL 使用 B-tree;表的物理存储结构也不同。
- 缓存路径不同。 同样设置 4 GiB 主缓冲区,不等于相同总缓存预算;MySQL 使用 O_DIRECT,前序加载、索引、校验和查询影响缓存状态。
- 持久化工作不同。 PostgreSQL WAL 与 MySQL redo + binlog 不能只按参数名字视作相同成本。
- 是两次桌面机实验。 测量时间不同,没有完整的频率、温度和竞争负载控制;这次还因临时目录问题重启过专用实例。
因此,这张表回答的是“这两套具体实现与配置,在本机这个业务模型上测到了什么”。若要回答引擎差异,需要统一数据、客户端和资源约束,分别控制索引、覆盖列、缓存及持久性,再增加重复次数。
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 生成。本文仅记录已测得的数据;长期稳态、冷缓存、不同并发度、覆盖索引优化,以及受控的跨数据库比较,仍属于后续实验。