Postgres 大表为什么会变慢:从可预测的问题到分片实践

2026-08-25 21 预计阅读时间: 1 分钟
来源: planetscale.com 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.

预计阅读时间:8 分钟

当 PostgreSQL 中的表持续增长,性能问题往往不是突然出现的,而是沿着比较清晰的路径逐步暴露:查询需要处理更多数据,索引和维护成本不断上升,备份与变更窗口也越来越难控制。对于这类规模问题,分片是一种可以把数据和负载拆开的解决方案。

大表的压力来自哪里

大表并不意味着每条查询都会变慢。带有高选择性条件、能够命中合适索引的查询,仍然可能保持稳定。但随着数据量增加,系统通常会面临几类压力:

  • 查询扫描的数据范围扩大,低选择性条件尤其容易触发大量读取。
  • 索引本身变大,缓存命中率和随机访问成本受到影响。
  • VACUUMANALYZE、索引创建、备份和恢复需要处理更多数据。
  • 热点数据、历史数据和不同租户的数据共享同一组资源,彼此产生干扰。
  • 单表和单实例的容量、IOPS、连接数或维护窗口逐渐接近边界。

这些问题具有一定的可预测性:数据规模和访问模式增长到某个阶段后,原有的单表设计会越来越难以维持稳定的延迟。

分片和分区不是一回事

分区通常发生在同一个 PostgreSQL 集群内。一个逻辑表被拆成多个物理分区,查询可以通过分区裁剪减少需要访问的数据。例如,按月份拆分订单表,可以让带有时间条件的查询只访问相关月份。

分片则进一步把数据分布到多个数据库实例、集群或节点上。这样可以把存储、计算和 IO 压力分散到不同资源上,也能降低单个节点需要承载的数据规模。

可以把两者理解为不同层次的拆分:

  • 分区主要解决单个数据库内的大表管理和查询范围问题。
  • 分片主要解决单个数据库实例的容量和资源上限问题。

在真正采用分片前,应该确认问题确实来自数据规模或单节点资源限制。如果只是缺少索引、查询条件不合理或连接池配置错误,分片会增加系统复杂度,却不能修复根因。

一个可改造的分区起点

下面的例子使用 PostgreSQL 原生声明式分区,按订单创建月份拆分数据。它不是完整的跨节点分片方案,但适合先验证访问模式和数据拆分键是否合理。

运行前可以将 orders 替换为业务表名,并根据实际查询条件调整索引。

CREATE TABLE orders (
    id          bigint       NOT NULL,
    customer_id bigint       NOT NULL,
    created_at  timestamptz  NOT NULL,
    amount      numeric(12,2) NOT NULL,
    status      text         NOT NULL,
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2025_01
    PARTITION OF orders
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE orders_2025_02
    PARTITION OF orders
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

CREATE INDEX orders_2025_01_customer_idx
    ON orders_2025_01 (customer_id, created_at);

CREATE INDEX orders_2025_02_customer_idx
    ON orders_2025_02 (customer_id, created_at);

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, amount, status
FROM orders
WHERE customer_id = 42
  AND created_at >= '2025-02-01'
  AND created_at <  '2025-03-01';

重点不是分区数量越多越好,而是查询必须携带分区键,优化器才有机会跳过无关分区。生产环境还需要制定自动创建未来分区、归档旧分区、监控分区大小和处理默认分区的流程。

走向真正的分片

跨节点分片需要先选择稳定的分片键。常见选择包括租户 ID、客户 ID 或区域 ID。理想的分片键通常满足以下条件:

  • 大多数查询都能带上它,路由时不需要广播到所有节点。
  • 数据分布相对均匀,避免某个分片成为热点。
  • 业务关系清晰,相关数据尽量落在同一个分片上。
  • 分片键不会频繁变化,否则迁移数据的成本会很高。

例如,一个多租户系统可以根据 tenant_id 路由请求:

# Python 3,示例假设使用 psycopg 3。
# 运行前安装:pip install "psycopg[binary]"

import os
import hashlib
import psycopg

SHARD_URLS = [
    os.environ["PG_SHARD_0_URL"],
    os.environ["PG_SHARD_1_URL"],
    os.environ["PG_SHARD_2_URL"],
]


def shard_for_tenant(tenant_id: int) -> str:
    # 使用稳定哈希,避免 Python 进程间 hash() 的随机化行为。
    digest = hashlib.sha256(str(tenant_id).encode()).digest()
    index = int.from_bytes(digest[:8], "big") % len(SHARD_URLS)
    return SHARD_URLS[index]


def load_recent_orders(tenant_id: int):
    dsn = shard_for_tenant(tenant_id)
    with psycopg.connect(dsn) as conn:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT id, amount, status, created_at
                FROM orders
                WHERE tenant_id = %s
                ORDER BY created_at DESC
                LIMIT 100
                """,
                (tenant_id,),
            )
            return cur.fetchall()


if __name__ == "__main__":
    print(load_recent_orders(42))

这个例子只展示路由思路。真实系统还需要处理分片配置管理、扩缩容后的重平衡、跨分片查询、全局唯一 ID、事务边界、故障切换和数据迁移。尤其要避免把没有分片键的查询直接广播到全部节点;这种查询可能抵消分片带来的收益。

采用前后的检查清单

可以按以下顺序推进:

  1. EXPLAIN (ANALYZE, BUFFERS) 找出真实的扫描量、排序、回表和 IO 瓶颈。
  2. 检查索引、统计信息、连接池和慢查询,先排除配置或查询缺陷。
  3. 明确数据增长曲线、热点访问模式、维护窗口和单节点容量边界。
  4. 通过分区验证拆分键是否能让查询稳定裁剪数据。
  5. 如果单实例仍然无法满足容量或吞吐要求,再设计跨节点分片。
  6. 为迁移、重平衡、跨分片查询和故障恢复建立可演练的流程。

分片的价值在于把原本集中在一张大表和一个节点上的压力拆开,但它也会把一部分数据库问题转化为应用路由和运维问题。只有当数据规模和资源边界已经证明单节点方案不再合适时,分片才值得承担这种复杂度。


相关推荐