数据库基准测试最危险的地方,往往不是数字本身是假的,而是数字背后的比较口径被悄悄换掉了。一个 PostgreSQL 工作负载可以从几百 TPS 变成几十万 OPS:缺索引的“基线”、关闭部分持久化保证、减少实际业务工作、把多次请求塞进函数、再把 TPS 改口叫 OPS,图表就会一路向上。
这篇文章不把它当成 PostgreSQL 性能神话,而是把它当成一次基准测试解剖:同一套数据库,怎样被包装成“慢得离谱”或“快得离谱”。
基准测试为什么容易变成宣传材料
早期数据库 benchmark 是公开争吵的战场。研究者、厂商、用户会围绕方法论、硬件、SQL、索引、配置吵得很凶。后来,许多商业数据库许可证加入了限制公开发布 benchmark 结果的条款,这类限制常被称为 DeWitt clause。结果不是 benchmark 变得更诚实,而是独立比较变得更麻烦。
这给了现代数据库营销一个很舒服的空间:
- 自己的数据库精心调优,对手接近默认配置;
- 选择刚好命中自家架构优势的 workload;
- 忽略成本、尾延迟、写放大、崩溃恢复和运维复杂度;
- 只发布赢得最漂亮的一张图;
- 把“脚本可复现”包装成“比较公平”。
可复现不等于公平。你可以复现一个偏置很强的实验,甚至可以精确复现一个糟糕基线。
第一个魔术:先制造一个很弱的基线
这个实验使用一个很小的 TPC-C 风格记账 workload,但它不是标准 TPC-C,也没有审计合规含义。事务大概做这些事:读账户、更新余额、写 ledger、写 audit、偶尔删除旧 audit,然后提交。
可以这样实践一个最小版本。下面脚本会创建测试库、建表、写入 100 个 branch 和 100 万个 account。运行前请确认本机已有 PostgreSQL,并且当前用户可以创建数据库。
createdb pgbench_darkside
psql pgbench_darkside <<'SQL'
DROP TABLE IF EXISTS bench_audit;
DROP TABLE IF EXISTS bench_ledger;
DROP TABLE IF EXISTS bench_accounts;
DROP TABLE IF EXISTS bench_branches;
CREATE TABLE bench_branches (
branch_id int PRIMARY KEY,
balance bigint NOT NULL DEFAULT 0
);
CREATE TABLE bench_accounts (
account_id bigint PRIMARY KEY,
branch_id int NOT NULL REFERENCES bench_branches(branch_id),
balance bigint NOT NULL DEFAULT 0
);
CREATE TABLE bench_ledger (
ledger_id bigserial PRIMARY KEY,
account_id bigint NOT NULL,
branch_id int NOT NULL,
amount int NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE bench_audit (
audit_id bigserial PRIMARY KEY,
account_id bigint NOT NULL,
action text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
INSERT INTO bench_branches(branch_id)
SELECT g FROM generate_series(1, 100) AS g;
INSERT INTO bench_accounts(account_id, branch_id, balance)
SELECT g, 1 + (g % 100), 100000
FROM generate_series(1, 1000000) AS g;
VACUUM ANALYZE;
SQL
接下来写一个故意有问题的 pgbench 脚本:它会按 created_at 查旧 audit,并且 ORDER BY created_at LIMIT 1,但表上没有 created_at 索引。
cat > case_zero_bad_code_missing_index.sql <<'SQL'
\set aid random(1, 1000000)
\set delta random(-500, 500)
BEGIN;
SELECT balance FROM bench_accounts WHERE account_id = :aid;
UPDATE bench_accounts SET balance = balance + :delta WHERE account_id = :aid;
INSERT INTO bench_ledger(account_id, branch_id, amount)
SELECT account_id, branch_id, :delta FROM bench_accounts WHERE account_id = :aid;
INSERT INTO bench_audit(account_id, action) VALUES (:aid, 'debit_credit');
DELETE FROM bench_audit WHERE audit_id IN (
SELECT audit_id
FROM bench_audit
WHERE created_at < now() - interval '10 minutes'
ORDER BY created_at
LIMIT 1
);
COMMIT;
SQL
pgbench -n -c 32 -j 8 -T 120 --protocol=simple \
-f case_zero_bad_code_missing_index.sql pgbench_darkside
这类基线很适合做宣传图。它不是 PostgreSQL 慢,而是 schema 懒。没有复合索引时,删除旧 audit 的子查询可能走顺序扫描和排序;加上索引后,查询可以变成很轻的 index-only scan。
psql pgbench_darkside <<'SQL'
CREATE INDEX IF NOT EXISTS bench_audit_created_at_audit_id_idx
ON bench_audit(created_at, audit_id);
VACUUM ANALYZE bench_audit;
SQL
原文实验中,缺索引版本大约 558 TPS;加上 bench_audit(created_at, audit_id) 后,基线到 23,178 TPS,约 41 倍。最大的“优化”不是神秘参数,而是把明显缺失的索引补上。
第二个魔术:换掉承诺,但继续画同一张图
接下来可以开始调参数。这里要谨慎:这些不是通用生产建议,有些会改变持久化语义,有些需要重启或受 PostgreSQL 版本影响。更诚实的做法是每组测试从干净数据库开始,记录配置、硬件、版本、运行时长、并发数和恢复语义。
源实验里比较了多种“作弊”方式:
synchronous_commit=off:事务可能在 WAL 真正落盘前被确认,约 1.08x;full_page_writes=off:削弱 torn page 保护,这次反而略慢,约 0.98x;fsync=off:移除核心持久化保证,约 1.07x;- checkpoint tuning:120 秒隔离测试里基本不动,约 0.99x;
- durability 全关:约 1.09x。
这组结果很有意思:听起来最吓人的持久化开关,并没有把 TPS 推上天。在这台本地 NVMe 机器、32 客户端、group commit 的组合下,PostgreSQL 已经把同步成本摊得不错。关闭安全保证可以成为“作弊”,但未必是最有效的作弊。
真正容易制造大数字的是改变 workload 本身:
- 少做事:去掉 SELECT、外键检查、audit 写入和清理,约 1.93x;
UNLOGGEDledger/audit:减少 WAL,约 1.60x,但崩溃后不适合当可靠账本;- PL/pgSQL 存储过程:减少多次客户端/服务端往返,约 1.99x;
LANGUAGE sql函数:同样逻辑工作下减少解释器上下文切换,约 3.05x;- prepared protocol:复用预处理语句,约 1.35x;
- batching:把一次事务里的操作数变多,然后把 y 轴从 TPS 换成 OPS。
这里的技术点都是真的。陷阱在于:比较对象是否也获得了等价优化?如果 PostgreSQL 用 SQL 函数和 prepared protocol,而另一个数据库只跑零散 ad-hoc SQL,这不是数据库能力对比,而是客户端执行形态对比。
第三个魔术:把 TPS 改名成 OPS
批处理是最漂亮也最危险的展示方式。比如一次事务处理 32 个账户操作,报告时不说 transactions per second,而说 operations per second。图表会变高,但事务吞吐可能下降。
源实验里:
- batch x8:报告 104,466 OPS,但只有 13,058 TPS;
- batch x32:报告 130,092 OPS,但只有 4,065 TPS;
- 全部技巧叠加再 batch x32:报告 363,915 OPS。
数字没有造假,单位在工作。一个图表可以诚实地展示 OPS,同时让读者误以为系统事务吞吐也线性变强。
可以这样在自己的报告里强制同时打印 TPS 和 OPS,避免 y 轴偷换概念:
# save as report_units.py
cases = [
{"name": "indexed_baseline", "tps": 23178, "ops_per_tx": 1},
{"name": "batch_x8", "tps": 13058, "ops_per_tx": 8},
{"name": "batch_x32", "tps": 4065, "ops_per_tx": 32},
]
baseline_tps = cases[0]["tps"]
print(f"{'case':<18} {'TPS':>10} {'OPS':>10} {'TPS x':>8} {'OPS x':>8}")
for c in cases:
ops = c["tps"] * c["ops_per_tx"]
print(
f"{c['name']:<18} "
f"{c['tps']:>10,.0f} "
f"{ops:>10,.0f} "
f"{c['tps'] / baseline_tps:>8.2f} "
f"{ops / baseline_tps:>8.2f}"
)
运行:
python3 report_units.py
你会看到 batch x32 的 OPS 看起来比基线高很多,但 TPS 只有基线的一小部分。做压测报告时,把这两列放在一起,能立刻拆掉很多“吞吐暴涨”的幻觉。
一个更像工程报告的 benchmark 清单
如果你要发布 PostgreSQL、MySQL、MariaDB 或任何数据库的性能对比,不要只给一张上升曲线。至少交代这些内容:
- 数据库版本、内核版本、磁盘类型、CPU、内存、文件系统;
- schema、索引、约束、触发器、外键是否一致;
- 配置文件和所有非默认参数;
- workload 脚本,尤其是每个事务到底做了什么;
- 客户端协议:simple、prepared、pipeline、server-side function 是否一致;
- 报告单位:TPS、QPS、OPS、rows/s 不要混用;
- p50、p95、p99 延迟,而不只平均吞吐;
- 测试时长是否覆盖 checkpoint、compaction、vacuum 或后台任务;
- crash 后数据是否应该存在,恢复时间是否计入成本;
- 每组测试是否从干净状态开始。
透明不等于没有立场。一个 benchmark 可以偏向某个架构,但它应该让读者看清楚偏在哪里。最好的争论不是“你不准发布”,而是“把代码、配置、成本、workload 和单位都放出来,然后我们讨论它是否公平”。
PostgreSQL 在这个实验里的形象反而更扎实:即使用“不诚实”的方式包装,真正能拉高数字的仍然是成熟而具体的工程机制:索引、WAL 策略、server-side execution、prepared statements、batching。它们都能用,也都要说清楚代价。基准测试的第一责任不是让柱状图好看,而是让读者知道自己到底在比较什么。