数据库优化最容易犯的错误,是看到一条慢 SQL 就立即加索引,或者直接调整 shared_bufferswork_mem。这些操作有时有效,但如果没有先确认瓶颈来自哪里,很可能只是把问题暂时藏起来,同时增加写入、内存或运维成本。

这套手册从诊断开始。本篇先把业务延迟对应到具体数据库活动,并保存可复测的基线;后续优化才有明确对象,也能用同一组证据判断改动是否真的有效。示例数据只用于观察定位过程,不代表真实生产负载。

优化前先定义问题

性能优化是一个证据闭环不要从“加索引”开始;从业务影响与累计数据库时间开始,最后以可比较的复测结果结束。
先从应用和数据库指标发现真正消耗时间的查询,再用等待事件与执行计划解释原因,选择最小改动,并在相同参数、数据规模和缓存条件下重新验证。

“接口很慢”不是足够具体的问题。开始优化前,先确认慢的是平均响应时间还是 p95、p99 长尾延迟,时间消耗在执行、锁等待、连接池排队还是网络,以及目标是降低延迟、提高吞吐量、减少 CPU 还是减少磁盘读取。

优化优先级也不能只看单次耗时。一条偶尔执行的慢查询,业务影响可能小于单次不慢但高频调用、累计占用大量数据库时间的查询。应同时比较业务影响、调用频率、累计数据库时间与资源消耗。

发现慢请求
  -> 确认数据库是否为主要耗时
  -> 找到高总耗时或高频 SQL
  -> 排除锁、连接与外部等待
  -> 保存执行计划和数据条件
  -> 用相同条件重新验证

准备订单系统案例

示例使用用户、订单、订单明细和支付记录四张表。内部关联键使用 bigint identity,对外订单编号使用 PostgreSQL 18 的 uuidv7()。这里只建立主键、唯一约束和外键,不预先添加业务索引,以免在没有查询证据前假定访问模式。

查看订单系统建表 SQL
CREATE TABLE users (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email text NOT NULL,
  display_name text NOT NULL,
  profile jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE orders (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  public_id uuid NOT NULL DEFAULT uuidv7(),
  user_id bigint NOT NULL REFERENCES users (id),
  status text NOT NULL CHECK (
    status IN ('pending', 'paid', 'cancelled', 'refunded')
  ),
  total_amount numeric(14, 2) NOT NULL CHECK (total_amount >= 0),
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (public_id)
);

CREATE TABLE order_items (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id bigint NOT NULL REFERENCES orders (id),
  product_id bigint NOT NULL,
  quantity integer NOT NULL CHECK (quantity > 0),
  unit_price numeric(14, 2) NOT NULL CHECK (unit_price >= 0)
);

CREATE TABLE payments (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id bigint NOT NULL REFERENCES orders (id),
  provider text NOT NULL,
  provider_trade_no text,
  status text NOT NULL CHECK (
    status IN ('created', 'succeeded', 'failed', 'refunded')
  ),
  paid_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now()
);

PostgreSQL 不会因为创建外键就自动为引用列创建索引。是否为 orders.user_idorder_items.order_idpayments.order_id 建索引,应由后续观察到的连接、父记录删除与父键更新模式决定。

查看测试数据生成 SQL
SELECT setseed(0.42);

INSERT INTO users (email, display_name, created_at)
SELECT
  'user-' || n || '@example.com',
  'User ' || n,
  now() - random() * interval '1000 days'
FROM generate_series(1, 100000) AS n;

INSERT INTO orders (
  user_id,
  status,
  total_amount,
  created_at,
  updated_at
)
SELECT
  1 + floor(random() * 100000)::bigint,
  CASE
    WHEN n % 20 = 0 THEN 'pending'
    WHEN n % 20 IN (1, 2, 3) THEN 'cancelled'
    WHEN n % 20 = 4 THEN 'refunded'
    ELSE 'paid'
  END,
  round((10 + random() * 990)::numeric, 2),
  now() - random() * interval '730 days',
  now()
FROM generate_series(1, 500000) AS n;

INSERT INTO order_items (order_id, product_id, quantity, unit_price)
SELECT
  o.id,
  1 + floor(random() * 50000)::bigint,
  1 + floor(random() * 4)::integer,
  round((5 + random() * 495)::numeric, 2)
FROM orders AS o
CROSS JOIN LATERAL generate_series(
  1,
  1 + (o.id % 3)::integer
);

INSERT INTO payments (
  order_id,
  provider,
  provider_trade_no,
  status,
  paid_at,
  created_at
)
SELECT
  o.id,
  (ARRAY['stripe', 'wechat', 'alipay'])[
    1 + floor(random() * 3)::integer
  ],
  'trade-' || o.public_id,
  CASE WHEN o.status IN ('paid', 'refunded')
    THEN 'succeeded'
    ELSE 'created'
  END,
  CASE WHEN o.status IN ('paid', 'refunded')
    THEN o.created_at + random() * interval '30 minutes'
    ELSE NULL
  END,
  o.created_at
FROM orders AS o
WHERE o.status <> 'cancelled';

ANALYZE users;
ANALYZE orders;
ANALYZE order_items;
ANALYZE payments;

setseed() 让当前会话的伪随机序列可重复;生成后执行 ANALYZE,让规划器基于当前数据分布估算选择性。行数可以按本机资源调小,但每轮对比应保持规模与分布一致。

从应用链路定位数据库耗时

先从接口的 trace 或分段计时确认数据库调用确实占主要耗时。连接池排队、DNS、网络、序列化和下游服务延迟都可能被笼统地记在一次请求里,不能看到“SQL 调用”就认定数据库执行慢。

把请求的 Trace ID、接口名称、数据库实例、会话标识与规范化 SQL 关联起来,并同时记录调用频率和 p95/p99。应用侧的长尾分布能够补足数据库聚合视图只有平均执行时间、没有请求级分位数的限制。若时间主要花在取连接之前,应先检查连接池容量、泄漏和事务边界。

慢查询日志发现单次异常

log_min_duration_statement 会记录执行时间达到阈值的语句,适合捕获偶发的单次异常:

log_min_duration_statement = 500ms

500ms 只是示例,不是通用推荐值。阈值应来自业务延迟目标与可接受日志量;设得过高会漏掉高频但累计成本大的查询,过低则会放大日志写入、存储与分析开销。生产环境还要评估参数值是否包含敏感数据,并通过 Trace ID 和会话信息把日志对应回具体请求。

pg_stat_statements 定位累计成本

pg_stat_statements 聚合规范化语句的规划与执行统计,适合回答“哪些查询累计占用了最多数据库时间”。PostgreSQL 18 需要先在 postgresql.conf 中预加载模块并启用查询标识;修改 shared_preload_libraries 后需要重启实例。

shared_preload_libraries = 'pg_stat_statements'
compute_query_id = auto

然后在需要读取该视图的数据库中创建扩展:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

优先按累计执行时间筛选,再结合频率、单次均值、处理行数与块读写判断成本来源:

SELECT
  queryid,
  calls,
  round(total_exec_time::numeric, 2) AS total_exec_ms,
  round(mean_exec_time::numeric, 2) AS mean_exec_ms,
  rows,
  shared_blks_hit,
  shared_blks_read,
  temp_blks_written,
  query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

calls 表示调用频率,total_exec_time 表示累计执行时间,mean_exec_time 表示单次均值;shared_blks_readtemp_blks_written 可提示存储读取或临时文件压力。统计会跨观察窗口累计,比较优化前后必须记录 stats_since 或统一采集区间。不要为方便对比就直接全局重置统计,否则会破坏其他人的诊断基线。

先排除锁与连接等待

应用看到的 SQL 耗时不等于 CPU 执行时间。pg_stat_activitystatewait_event_typewait_event 可以帮助区分正在执行与等待中的会话:

SELECT
  pid,
  usename,
  state,
  wait_event_type,
  wait_event,
  now() - query_start AS query_age,
  now() - xact_start AS transaction_age,
  left(query, 160) AS query
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
  AND state <> 'idle'
ORDER BY query_start;

wait_event_typeLock,应优先定位阻塞者、持锁语句和事务边界。索引可能缩短获得锁后的执行时间,却不能修复长事务、错误的加锁顺序或应用忘记提交。若大量请求卡在获取连接且数据库中没有对应会话,则问题更可能在连接池或连接上限,而不是 SQL 访问路径。

建立可复测基线

进入索引、SQL 或表结构优化前,为每个候选查询保存同一份基线:

  • SQL 与参数:保留规范化 SQL,也保存能复现选择性的数据类型与代表性参数。
  • 调用频率:记录固定观察窗口内的调用次数,并注明接口、任务或租户来源。
  • 平均与 p95/p99:数据库均值与应用链路分位数分开记录,避免把两个统计口径混为一谈。
  • 累计数据库时间:记录查询在同一窗口内消耗的总执行时间,用于排序优化优先级。
  • 执行计划:保存计划文本及采集命令;对写语句使用 EXPLAIN ANALYZE 会真实执行,必须先控制事务与副作用。
  • 数据规模与分布:记录表行数、关键条件占比、时间范围以及统计信息是否新鲜。
  • 缓存状态:注明冷缓存、预热后缓存或生产自然缓存,避免把缓存差异误认为方案收益。
  • 等待事件:记录会话是否等待锁、I/O、客户端或其他资源,并保留阻塞链证据。

优化后使用相同 SQL、参数、数据快照、并发模型和采集窗口复测。只有业务目标改善、累计成本下降且写入与维护代价可接受,才说明改动值得上线。

参考资料