用 DBT-5 给 EC2 上的 OLTP 系统定尺寸:从并发扫描到合法吞吐边界

2026-09-19 20 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

给 OLTP 系统定尺寸,不能只把并发数拉高、记录一次峰值 TPS 就结束。数据库规模、客户端并发、TPC-E 类工作负载的合法交易速率,以及压测工具自身的并发实现,都会改变最终结论。

这次实验使用 DBT-5 这一 TPC-E-like fair-use 实现,在 EC2 系统上执行机械化搜索:从最小有效的 5000 客户数据库开始,把用户数从 1 增加到逻辑处理器数量的 2 倍,每组测试保持 1 小时稳定运行,再根据规范允许的交易速率调整数据库规模。经过多轮扩大、折半和重新验证,观察到的最佳组合是 32000 客户、24 用户

需要强调的是,这是一种容量分析方法,而不是一次跑分;DBT-5 的结果也不等同于经过审计的官方 TPC-E 成绩。

峰值 TPS 为什么不能直接作为容量答案

OLTP 压测至少存在两个相互关联的搜索维度:

  • 数据库规模:本次从 5000 客户起步,最终测试范围扩展到 97000 客户。
  • 并发用户数:从 1 开始,逐个增加到逻辑处理器数量的 2 倍。

如果只固定一个数据库规模并扫描并发,得到的峰值可能不符合 TPC-E 类规范中数据库规模与交易速率之间的约束。反过来,如果只根据 CPU 数选择一个并发值,也可能错过数据库规模变化之后出现的新峰值。

因此更可靠的过程是:

  1. 使用最小合法规模 5000 客户创建数据库。
  2. 在 1 到 2 × 逻辑处理器数 的范围内逐一测试用户数。
  3. 每组测试保持 1 小时稳定状态,避免用短时突发代表持续容量。
  4. 找出当前规模下的最大稳定吞吐。
  5. 按 TPC-E 规范或 DBT-5 对应规则,判断该交易速率对当前客户数是否合法。
  6. 如果不合法,扩大数据库规模并重新扫描并发。
  7. 建立合法与不合法的规模边界后,用折半法逼近合适规模。
  8. 每次改变客户数后,重新确认原来的最佳并发是否仍然最佳。

第 8 步很重要。数据库变大后,缓存命中、I/O 压力和锁竞争都可能变化,不能把某个规模下得到的 24 用户机械地套到所有规模上。

把测试计划变成可审计的实验矩阵

AI 工具适合生成测试矩阵、调度长时间任务、检查缺失结果和汇总数据,但合法交易速率必须来自你实际采用的规范版本或测试工具规则,不能让模型凭空推断。

下面的脚本可以直接生成测试计划,也可以从结果 CSV 中选出每个数据库规模下的最佳合法结果。候选规模只是演示;实际使用时应替换为扩容和折半过程中得到的客户数。

#!/usr/bin/env python3
import argparse
import csv
import os
from pathlib import Path


def make_plan(output: Path, scales: list[int], logical_cpus: int) -> None:
    with output.open("w", newline="") as f:
        writer = csv.DictWriter(
            f,
            fieldnames=["run_id", "customers", "users", "steady_state_seconds"],
        )
        writer.writeheader()
        run_id = 1
        for customers in scales:
            for users in range(1, logical_cpus * 2 + 1):
                writer.writerow({
                    "run_id": run_id,
                    "customers": customers,
                    "users": users,
                    "steady_state_seconds": 3600,
                })
                run_id += 1
    print(f"Wrote {run_id - 1} runs to {output}")


def analyze(results: Path) -> None:
    required = {
        "customers", "users", "tps", "legal_max_tps", "errors"
    }
    with results.open(newline="") as f:
        rows = list(csv.DictReader(f))

    if not rows or not required.issubset(rows[0]):
        raise SystemExit(
            "results CSV must contain: " + ", ".join(sorted(required))
        )

    legal_rows = []
    for row in rows:
        row["customers"] = int(row["customers"])
        row["users"] = int(row["users"])
        row["tps"] = float(row["tps"])
        row["legal_max_tps"] = float(row["legal_max_tps"])
        row["errors"] = int(row["errors"])
        if row["errors"] == 0 and row["tps"] <= row["legal_max_tps"]:
            legal_rows.append(row)

    if not legal_rows:
        raise SystemExit("No error-free, legal results found")

    best_by_scale = {}
    for row in legal_rows:
        customers = row["customers"]
        current = best_by_scale.get(customers)
        if current is None or row["tps"] > current["tps"]:
            best_by_scale[customers] = row

    print("Best legal result at each scale:")
    for customers in sorted(best_by_scale):
        row = best_by_scale[customers]
        print(
            f"customers={customers:>7} users={row['users']:>3} "
            f"tps={row['tps']:.2f} legal_max={row['legal_max_tps']:.2f}"
        )

    winner = max(legal_rows, key=lambda row: row["tps"])
    print("\nOverall best legal result:")
    print(winner)


def main() -> None:
    parser = argparse.ArgumentParser()
    sub = parser.add_subparsers(dest="command", required=True)

    plan = sub.add_parser("plan")
    plan.add_argument("--output", type=Path, default=Path("plan.csv"))
    plan.add_argument("--logical-cpus", type=int, default=os.cpu_count() or 1)
    plan.add_argument("--scales", type=int, nargs="+", required=True)

    report = sub.add_parser("analyze")
    report.add_argument("results", type=Path)

    args = parser.parse_args()
    if args.command == "plan":
        make_plan(args.output, args.scales, args.logical_cpus)
    else:
        analyze(args.results)


if __name__ == "__main__":
    main()

保存为 size_oltp.py 后,可以这样生成实验矩阵:

chmod +x size_oltp.py

./size_oltp.py plan \
  --logical-cpus "$(getconf _NPROCESSORS_ONLN)" \
  --scales 5000 16000 24000 32000 48000 97000 \
  --output plan.csv

head plan.csv

这里的规模序列不是对原实验路径的复刻,而是一组可修改的候选值。实际做折半搜索时,应根据上一轮结果生成新的中点。

压测执行器需要把每行计划映射到你自己的 DBT-5 初始化和运行命令。完成后生成如下表头的 results.csv

customers,users,tps,legal_max_tps,errors

其中 legal_max_tps 应通过规范或经过确认的规则计算,不能直接使用本轮观测到的 TPS。随后运行:

./size_oltp.py analyze results.csv

如果有 24 个逻辑处理器,单个数据库规模就需要执行 48 组一小时测试。正式开跑前应计算实例时间和成本,并通过冒烟测试尽早排除配置错误。

工具缺陷会伪装成数据库瓶颈

这轮工作中最值得警惕的发现,并不是某个 PostgreSQL 参数,而是测试工具曾经把 Trade ResultMarket Feed 交易限制为一次只能执行一个。这样的串行化会制造人为吞吐上限:CPU、存储和数据库可能还有余量,但驱动端已经无法继续施压。

允许多个此类交易并发处理后,系统表现出现了显著改善,也意味着之前的实验需要重新开始。这个代价无法通过修补图表消除,因为工具语义已经改变,新旧数据不再属于同一个实验基线。

每轮长测之前可以安排短冒烟测试,至少检查:

  • 所有交易类型都实际发生,而不是只跑了少数路径。
  • 预期可并发的交易没有被全局锁、单消费者或单线程队列串行化。
  • 客户端没有先于数据库耗尽连接、线程或文件描述符。
  • 错误数、超时数和重试数被单独记录,不能只看成功 TPS。
  • 重新加载数据库后,客户数和数据分布与计划一致。
  • 工具、数据库配置和代码提交版本被写入结果元数据。

PostgreSQL 参数先固定,再逐轮调优

实验预先调整了 shared_buffersmax_wal_size 等已知需要关注的设置,但没有把第一次配置当作最终答案。更稳妥的做法是先建立一个固定基线,完成规模和并发搜索,再结合系统行为继续调优。

可以先记录当前配置,保证每轮实验可追溯:

psql "$DATABASE_URL" -X -v ON_ERROR_STOP=1 <<'SQL'
SELECT version();
SHOW shared_buffers;
SHOW max_wal_size;
SHOW max_connections;
SHOW checkpoint_timeout;
SQL

若要修改参数,应根据实例内存、WAL 生成速度和恢复要求选择数值。不要直接复制另一台机器的配置;同时注意某些参数需要重启 PostgreSQL 才能生效。

采用这套方法时的检查清单

这次结果将最佳点定位在 32000 客户和 24 用户,但这个数字只属于当时的 EC2 环境、数据库配置、数据规模和 DBT-5 实现。迁移到另一种实例、存储或代码版本后,都应重新执行搜索。

落地时可以坚持四条原则:

  • 把 AI 当作实验编排器,而不是性能结论的裁判。
  • 同时搜索规模与并发,并在每个新规模上复核最佳用户数。
  • 只比较配置、代码和工具语义一致的实验结果。
  • 先验证压测工具能产生真实并发,再分析数据库瓶颈。

真正可靠的容量结论,来自可重复的矩阵、足够长的稳定窗口、明确的合法性规则,以及发现工具缺陷后愿意推倒重来的纪律。


相关推荐