在一台桌面机上,给 PostgreSQL 塞进 5 亿行订单
在一台桌面机上,给 PostgreSQL 塞进 5 亿行订单
PostgreSQL 18.6 · 单张非分区表 · 16 并发 · 开启持久化 · 2026-09-14 实测
作者:Hanserwei。代码与完整结果:pg-single-table-bench。本文所有性能图表来自仓库的原始结果,不是示意数值。
“单表有 5 亿行,是不是就一定很慢?”
要回答这个问题,光看行数不够。按主键查一笔订单、取一个租户最新的 50 单、统计一小时的成交额,数据库要做的工作完全不同。再加上索引、缓存、磁盘和事务提交方式,同一个“TPS”背后可能是完全不同的实验。
所以我在自己的桌面机上实际生成了 5 亿行订单,没有分表,也没有分区,跑了一组读写场景。
先交代结果:主键查询约 4.86 万 TPS,逐事务持久化插入约 4,025 TPS,80% 读、20% 写混合约 8,404 TPS。整个流程从开始造数到最后一个报告生成,约 68 分钟。
这些数字有明确前提:同机 Unix socket、16 并发、每场景测 60 秒、Btrfs 开启 zstd 压缩,没有清理操作系统页缓存。 这不是数据库极限排行榜,也不意味着真实业务可以直接照搬这个吞吐。
01 / 先把实验条件摆出来
不是服务器集群,就是一台桌面机
| 项目 | 实验配置 |
|---|---|
| CPU | Intel Core i9-14900K,24 核 / 32 逻辑 CPU,未绑核 |
| 内存 | 操作系统报告约 46 GiB 总内存,另有约 23 GiB zram swap |
| 磁盘 | TOPMORE Gemini NVMe,设备容量约 1.9 TiB |
| 文件系统 | Btrfs,compress=zstd:3 |
| 系统 | Arch Linux,内核 7.2.4-arch1-2 |
| PostgreSQL / pgbench | 18.6 / 18.6 |
| 部署 | 数据库和压测客户端同机,使用私有 Unix socket |
硬件和挂载信息是在压测完成后补采的,不是全过程监控。桌面程序和原有系统数据库没有被全部关闭,也没有记录整段 CPU 温度、频率或功耗变化。因此,本文不会把某一次波动武断归因于某个硬件指标。
写入不能靠关闭持久性拿高分
几个关键参数如下:
shared_buffers = '4GB'
effective_cache_size = '24GB'
work_mem = '16MB'
maintenance_work_mem = '512MB'
fsync = on
synchronous_commit = on
full_page_writes = on
wal_compression = on
max_wal_size = '8GB'
checkpoint_timeout = '15min'
checkpoint_completion_target = 0.9
jit = off
effective_cache_size 是优化器对可用缓存的估计,不是 PostgreSQL 又申请了 24 GiB 内存。work_mem 也不是整个数据库的内存上限,它与具体执行操作有关。
本次 wal_compression=on 实际使用 pglz。它压缩的是 WAL 中适用的内容,与文件系统的 zstd 压缩是两个层次,不能混为一谈。
表使用普通 LOGGED 存储,没有 UNLOGGED,也没有把 synchronous_commit 改成 off。完整参数和测量条件放在 README,运行时配置证据在 before.json。
02 / 订单表不能只有 id 和一串随机文本
为了让查询有一点真实业务属性,我设计了一张 18 字段的订单表:
CREATE TABLE bench.orders (
order_id bigint PRIMARY KEY,
tenant_id integer NOT NULL,
customer_id bigint NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
paid_at timestamptz,
status smallint NOT NULL CHECK (status BETWEEN 0 AND 5),
channel smallint NOT NULL CHECK (channel BETWEEN 0 AND 3),
region_id smallint NOT NULL,
item_count smallint NOT NULL,
total_amount numeric(12,2) NOT NULL,
discount_amount numeric(12,2) NOT NULL,
shipping_amount numeric(8,2) NOT NULL,
currency varchar(3) NOT NULL DEFAULT 'CNY',
shipping_city varchar(32) NOT NULL,
payment_ref varchar(40),
metadata jsonb NOT NULL,
revision integer NOT NULL DEFAULT 0
) WITH (fillfactor=90);
这里省略了完整脚本中的 autovacuum 参数,实际 DDL 以 schema.sql 为准。
数据不是完全均匀分布:
- 1,000 个租户中,20 个热点租户拥有约 80% 的订单。
- 每租户最多 10,000 个合成客户,客户 ID 包含租户前缀。
- 订单状态包含待支付、已支付、已发货、已完成、已取消、已退款,比例约为 5%、10%、15%、60%、7%、3%。
- 金额、优惠、运费使用明确的小数类型;JSONB 保存活动、终端、会员和 SKU 信息。
- 时间从 2024-01-01 UTC 开始覆盖 730 天,并按创建时间递增插入。
730 天不等于两个完整自然年。 2024 年是闰年,因此这份数据的时间上界在 2025-12-31 UTC 之前。
还有一个需要承认的局限:它依然是合成数据。部分字段复用哈希而存在相关性,状态更新也只是压测写放大的操作,不是真正完整的订单状态机。
4 个索引,各自服务一种访问路径
-- 主键索引由 PRIMARY KEY 创建
CREATE INDEX orders_customer_created_idx
ON bench.orders (tenant_id, customer_id, created_at DESC, order_id DESC);
CREATE INDEX orders_tenant_status_created_idx
ON bench.orders (tenant_id, status, created_at DESC, order_id DESC);
CREATE INDEX orders_created_brin_idx
ON bench.orders USING brin (created_at) WITH (pages_per_range=64);
客户列表和状态列表的索引没有把金额等返回字段全部 INCLUDE 进去,因此不是纯覆盖索引演示,仍然需要回表。
BRIN 则利用了时间和物理位置的相关性。它不是为每行保存完整定位项,而是记录一组相邻数据页的摘要。对于这种按时间追加的数据,可以用很小的索引排除大量不相关页。不过它是有损筛选,命中的页面仍要回表复查。官方 BRIN 文档也明确强调了物理相关性这个前提。
03 / 5 亿行,怎么生成才不会一次失败重头再来
我没有先生成一个巨大 CSV,再从客户端发送到 PostgreSQL。
做法是让服务器用 generate_series 直接生成数据,每批 25 万行,一共 2,000 批。每批的插入与进度标记在同一个事务里提交:
BEGIN;
-- 锁定进度记录
-- INSERT INTO bench.orders ... SELECT ... FROM generate_series(...)
-- UPDATE bench.bench_control SET loaded_rows = 本批最后一个ID
COMMIT;
上面只是展示事务边界,完整可执行版本在 load.sql。
如果中断,当前未提交批次回滚;重启时从已提交的高水位继续。控制表仅保存进度,实际压测 SQL 仍只访问订单表,没有 JOIN。
主键在造数时就存在,另外三个索引在全量加载后构建。这一点非常重要:批量造数的每秒几十万行,不能拿来等同于业务逐笔 INSERT 的 TPS。 它们的事务大小、提交次数和索引维护成本完全不同。
真正花了多久
| 阶段 | 本机时间 UTC+8 | 耗时 |
|---|---|---|
| 生成 5 亿行 | 19:54:40 → 20:16:29 | 21 分 49 秒 |
| 客户列表索引 | 20:16:29 → 20:28:19 | 11 分 50 秒 |
| 状态列表索引 | 20:28:19 → 20:33:27 | 5 分 8 秒 |
| BRIN 索引 | 20:33:27 → 20:33:56 | 29 秒 |
| VACUUM、ANALYZE、CHECKPOINT | 20:33:56 → 20:53:14 | 19 分 18 秒 |
| 执行计划、预热与全部压测 | 20:53:14 → 21:02:36 | 9 分 22 秒 |
造数实际平均约 381,981 行/秒。阶段时刻来自 后台日志边界记录,时间轴按日志的秒级精度绘制。
为什么新表还要 VACUUM ANALYZE
大量插入之后,ANALYZE 会收集统计信息,让规划器了解列的分布、不同值数量、常见值等。没有这些信息,复杂度并不高的查询也可能选错计划。
VACUUM 除了回收不再需要的旧行版本,还会维护可见性映射等内部状态。这份刚加载的表通常没有很多死元组,因此不是为了“清掉大量垃圾”,也不是把表压缩成更小文件。
显式做这一步,是为了建立一个清楚的压测起点。但代价也必须记入整个准备流程,而不能只宣传造数速度。官方维护文档解释了 VACUUM 与统计维护的区别。
04 / 先看空间,再看吞吐
压测开始前:
| 内容 | PostgreSQL 报告的逻辑大小 |
|---|---|
| 表及表辅助存储 | 117.20 GiB |
| 所有索引 | 52.48 GiB |
| 合计 | 169.68 GiB |
这里的数据来自 pg_table_size、pg_indexes_size 和 pg_total_relation_size,不是磁盘使用率截图。
文件系统开启了 zstd 压缩。 合成订单里有重复城市、状态和 JSON 键名,可能有较好的压缩性;但本次没有测量完整物理压缩率,也没有单独验证压缩带来的性能增益。因此,不能说“5 亿行业务数据只需要某个固定物理空间”,更不能拿它与未压缩文件系统直接比较。
05 / 8 个场景的最终结果
测试参数统一为:16 客户端、8 个 pgbench 工作线程、prepared 协议,每项预热 10 秒、测量 60 秒,单轮运行。
| 场景 | TPS | 平均延迟 ms | 采样 p95 ms | 采样 p99 ms |
|---|---|---|---|---|
| 随机主键查单 | 48,644.79 | 0.329 | 0.619 | 0.714 |
| 客户最新 20 单 | 16,858.86 | 0.949 | 3.299 | 4.361 |
| 租户最新 50 条已支付订单 | 45,159.06 | 0.354 | 0.682 | 0.788 |
| 一小时订单分组聚合 | 420.58 | 38.009 | 87.273 | 92.248 |
| 插入一笔订单 | 4,024.59 | 3.974 | 4.261 | 7.231 |
| 修改普通字段 | 3,858.05 | 4.146 | 7.254 | 7.987 |
| 修改索引字段 | 2,494.61 | 6.412 | 8.122 | 11.072 |
| 80% 读 / 20% 写混合 | 8,403.97 | 1.903 | 5.912 | 8.561 |
8 个测量场景合计完成 7,793,274 次脚本事务,失败事务为 0。这个合计不包括预热,也不把客户端查询条数混算为事务数。
短查询与写入的采样尾延迟
小时聚合的采样尾延迟
两图采用独立刻度;小时聚合只有 256 个采样,p99 不应当作稳定 SLA。
先别急着横向比较
“客户列表”和“租户列表”各执行两条 SELECT:第一条从随机订单中取客户/租户,第二条才是列表查询。这里报告的是整个脚本的吞吐和耗时,而不是只截取第二条 SQL。
1% 采样日志用于计算 p50/p95/p99,但 TPS 和平均延迟直接取 pgbench 原始输出。小吞吐的一小时聚合只获得 256 个延迟样本,其 p99 只是少数尾部样本的估计,不能当作稳定的 SLA。其他场景的采样数量也随报告一并公开。
06 / 结果里最值得读的,不是最大的数字
主键查询快,不意味着在内存里放下了 5 亿行
169.68 GiB 的关系逻辑大小明显大于机器物理内存,主键查询在 1 至 5 亿的种子范围中均匀随机抽样。
代表性执行计划使用 orders_pkey 的 Index Scan。并发压测期间,数据库统计也有明显的 shared block read 增量。但是 PostgreSQL 的块读取不必然等于物理 SSD 读取,操作系统页缓存还可能命中;更不能因为没有清缓存,就把它称为严格冷盘实验。
实测主键查询平均延迟为 0.329 ms,说明在这组索引和运行条件下,单表行数本身并没有让一次精确查找退化成扫描 5 亿行。
状态列表为什么接近主键查询的 TPS
它做的不是“扫描一个大租户所有已支付订单”,而是:
SELECT order_id, customer_id, total_amount, created_at
FROM bench.orders
WHERE tenant_id = :tenant AND status = 1
ORDER BY created_at DESC, order_id DESC
LIMIT 50;
复合索引匹配过滤和排序,找到足够结果就可以停止。同时,大多数请求会反复落到 20 个热点租户的最新结果区域,缓存局部性很好。
所以 4.52 万 TPS 描述的是 热点 Top-N 列表,不是一个“遍历大租户订单”的性能承诺。换成深 OFFSET、全量导出或更大时间范围,这个数字不能套用。
一小时聚合不是全表扫描,也不会像查一行那么轻
5 亿行均匀铺在 17,520 小时中,平均每小时约 28,539 行。
代表性计划走 BRIN Bitmap Index Scan,再做 Bitmap Heap Scan 和 HashAggregate;该计划实际返回 28,539 行到聚合前的扫描节点,还复查并排除了部分不匹配行。
HashAggregate
-> Bitmap Heap Scan on orders
-> Bitmap Index Scan on orders_created_brin_idx
这是“用较小索引缩小需要读取的物理范围,再处理几万行”的场景。平均约 38 ms、约 421 TPS,与单行精确读取是不同工作量,不能只看 TPS 排名判断哪条 SQL 写得差。
更新是否改索引,差距真的能看见
普通更新只修改 revision 和 updated_at;状态更新还会修改出现在 B-tree 索引里的 status。
更新吞吐
HOT 更新比例
实例 WAL 增量
本次场景前后统计的增量如下:
| 指标 | 普通字段更新 | 索引字段更新 |
|---|---|---|
| 更新行数增量 | 232,732 | 150,147 |
| HOT 更新增量 | 232,732 | 0 |
| 吞吐 | 3,858.05 TPS | 2,494.61 TPS |
| 同一测量窗口实例 WAL 字节增量 | 606,078,763 | 1,826,708,964 |
这次普通更新的 HOT 比例达到 100%,状态更新为 0。普通更新不修改索引列,而 fillfactor=90 又为同页新版本留出空间,符合 HOT 的触发条件。
索引字段更新的吞吐约低 35.3%。WAL 也更多,但这些是实例级计数器,可能包含全页镜像和其他后台影响。两组测试顺序执行,并没有恢复成同一快照,所以不能把所有差异精确归因于“多维护一个索引”。
正确的结论是:是否能走 HOT,以及更新会触及哪些索引,值得在自己的业务表上单独测。 不是“所有普通字段更新都能达到 100% HOT”。
混合负载的平均延迟会掩盖读写差异
混合场景权重为:主键读 60%、客户列表 15%、状态列表 5%、插入 10%、普通更新 5%、状态更新 5%。80/20 按脚本事务计,不按 SQL 条数计。
其平均延迟约 1.903 ms,但采样 p50 约 0.382 ms,p99 约 8.561 ms。读操作较快、写操作需要持久化提交,混在一起之后,一个平均值无法代表两类请求各自的体验。
真实接口有延迟目标时,最好进一步拆开混合场景里的读写分位数,而不是只给整个系统贴一个“平均 2 ms”的标签。
07 / 哪些结论,这次实验不能给
- 不能推断生产峰值。 只有 16 并发,没有完整并发阶梯,也没有接近真实流量的固定到达率模型。
- 不能承诺长时间稳态。 每场景只跑 60 秒,未充分覆盖 checkpoint、autovacuum、持续更新膨胀与 SSD 长时间写入行为。
- 不能当作冷缓存成绩。 建索引、维护、EXPLAIN、预热和前面的场景都会改变缓存状态。
- 不能比较文件系统或硬件优劣。 只跑了这一台机器、这一种挂载条件,没有关闭压缩的受控对照。
- 不能将小样本成绩乘除外推。 100 万行样本能驻留内存;它只用于验证脚本与估算容量,和本次全量并发、测量时间也不同。
- 不能把零失败理解为业务正确性认证。 它意味着这次 pgbench 没有失败事务,不代表订单状态机、支付流程和生产恢复机制已得到验证。
- 不能将 EXPLAIN 的单次执行时间当成压测延迟。 公开的是代表性字面量计划,不是整个 prepared 并发运行的通用计划证明。
公开这些限制,不会让实验失去价值。它让读者知道哪些数字可以参考,哪些必须在自己的环境中重新测。
08 / 自己复现
需要普通 Linux 用户、Python,以及同一主版本的 PostgreSQL 18 工具链。完整压测本身只有 Python 标准库依赖,不需要安装数据库驱动,也不要求 Docker。
先跑 100 万行样本,确认空间,再做全量:
git clone https://github.com/Hanserwei/pg-single-table-bench.git
cd pg-single-table-bench
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
脚本会创建当前用户自己的实例,数据放在仓库 data/ 中,不改现有 5432 数据库。默认保留 100 GiB 磁盘余量,但这只是操作边界检查,不是实时磁盘配额。大批量实验之前仍要为 WAL、索引排序、膨胀和同盘其他文件留足空间。
结果不只是一张表。仓库附有:
- 8 个场景的原始 pgbench 输出、1% 事务采样日志与汇总 CSV/JSON。
- 参数、表/索引大小、WAL/I/O 和每场景前后统计快照。
- 代表性执行计划、阶段边界日志,以及可复算图表的生成脚本。
- 原始源码摘要与结果校验和;公开文本中的本机绝对工程路径替换为
<repo>,数值不改。
不必相信文章里的手抄数字,可以直接复算:
python3 -m unittest -v test_bench.py
python3 tools/verify_report.py
仓库的 CI 只做测试与历史报告复核,不会在 GitHub Actions 上再造一次 5 亿行。
结语
这次实验没有得到一个“PostgreSQL 到多少行就必须分表”的神奇阈值。
它得到的是更具体的观察:5 亿行不是查询复杂度本身。 主键精确查找、按复合索引取热点 Top-N、按时间摘要定位几万行再聚合,以及维护索引并提交 WAL,是四种不同的工作。
决定查询体验的,不只是数据有多少,还包括请求需要访问多少数据、访问路径能否提前停止、缓存覆盖的是哪一部分,以及一次更新会带来多少额外写入。
下一步值得做的,也不是急着把这次数字包装成结论,而是固定住环境,分别改变并发、缓存状态、索引和数据分布,再重复测量。