一条分片 Postgres 查询如何穿过路由器、四个分片再返回

2026-09-10 26 预计阅读时间: 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.

预计阅读时间:10 分钟

在单机 Postgres 中,一条 SQL 的边界通常止于一个数据库进程。加入分片后,查询会先进入路由器,被拆成多个子查询,分别送往四个 Postgres 分片,最后再由路由器合并结果。真正棘手的地方不是“同时查询四台机器”,而是如何保持 SQL 语义、控制尾延迟,并处理部分失败。

路由器先判断:单分片还是扇出查询

假设业务提交下面这条查询:

SELECT customer_id, SUM(total_amount) AS revenue
FROM orders
WHERE tenant_id IN (11, 42, 73, 104)
  AND created_at >= TIMESTAMPTZ '2025-01-01 00:00:00+00'
GROUP BY customer_id
ORDER BY revenue DESC
LIMIT 20;

路由器接到 SQL 后,需要解析查询并提取分片键。假设 tenant_id 是分片键,四个租户恰好映射到四个不同分片,那么这就是一次扇出查询;如果条件是 tenant_id = 11,路由器通常只需要访问一个分片。

一个典型生命周期可以拆成这些阶段:

  1. 解析 SQL,识别表、过滤条件、聚合、排序和 LIMIT
  2. 根据分片元数据,把 tenant_id 映射到目标分片。
  3. 为每个分片生成带有局部过滤条件的子查询。
  4. 从连接池取得四条连接,并发发送查询。
  5. 各分片由自己的 Postgres 优化器规划并执行 SQL。
  6. 路由器接收局部结果,完成最终聚合、排序和截断。
  7. 将一个符合原始 SQL 语义的结果集返回客户端。

路由器不应把 SQL 当作普通字符串随意替换。参数绑定、类型转换、别名、子查询和表达式都会让字符串拼接很快失控。工程实现通常需要真正的 SQL 解析树,或者限制可分布式执行的 SQL 范围。

四个分片执行的只是局部答案

每个 Postgres 分片仍然使用熟悉的执行链路:解析、重写、规划、执行。索引是否可用、统计信息是否准确、是否发生顺序扫描,都由该分片本地决定。因此,一次分布式查询可能产生四份不同的执行计划。

对于示例中的聚合,分片可以先执行局部 SUM,减少返回路由器的数据量。但局部结果不一定就是最终结果。如果同一个 customer_id 能出现在多个分片,路由器必须再次按 customer_id 求和。

ORDER BY revenue DESC LIMIT 20 也不能简单地让每个分片返回任意五行。常见做法是让每个分片返回自己的前 20 名,再由路由器合并并选出全局前 20 名。这个优化对简单 Top-N 查询成立,但遇到 DISTINCT、窗口函数、复杂连接或非可分解聚合时,需要更谨慎的执行策略。

可以直接检查四个分片上的局部计划。运行前请把数组中的连接串替换为测试环境地址:

#!/usr/bin/env bash
set -euo pipefail

SHARDS=(
  "postgresql://postgres:postgres@localhost:5433/app"
  "postgresql://postgres:postgres@localhost:5434/app"
  "postgresql://postgres:postgres@localhost:5435/app"
  "postgresql://postgres:postgres@localhost:5436/app"
)

for shard in "${SHARDS[@]}"; do
  echo "=== $shard ==="
  psql "$shard" -X -v ON_ERROR_STOP=1 -c "
    EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
    SELECT customer_id, SUM(total_amount) AS revenue
    FROM orders
    WHERE tenant_id IN (11, 42, 73, 104)
      AND created_at >= TIMESTAMPTZ '2025-01-01 00:00:00+00'
    GROUP BY customer_id
    ORDER BY revenue DESC
    LIMIT 20;
  "
done

这段命令适合确认某个慢分片是否缺少索引、读取了更多数据,或者产生了临时文件。生产环境执行 EXPLAIN ANALYZE 会真实运行查询,应先评估负载;只看计划时可以去掉 ANALYZE

合并阶段决定 SQL 是否仍然正确

下面是一个可直接运行的 Python 示例,演示路由器如何合并四个分片返回的局部聚合。这里假设每个元组都是某个分片产生的 (customer_id, partial_revenue)

from collections import defaultdict
from decimal import Decimal

shard_results = [
    [(101, Decimal("80.00")), (102, Decimal("30.00"))],
    [(101, Decimal("25.00")), (103, Decimal("70.00"))],
    [(104, Decimal("90.00")), (102, Decimal("40.00"))],
    [(105, Decimal("55.00")), (103, Decimal("35.00"))],
]

totals = defaultdict(Decimal)
for rows in shard_results:
    for customer_id, partial_revenue in rows:
        totals[customer_id] += partial_revenue

top_20 = sorted(
    totals.items(),
    key=lambda item: (-item[1], item[0]),
)[:20]

for customer_id, revenue in top_20:
    print(customer_id, revenue)

输出中 customer_id=101103 的金额会跨分片累加。排序键还加入了 customer_id,这样收入相同时结果顺序仍然稳定。真实路由器还要处理 SQL 的 NULL 规则、排序方向、字符排序规则、数值溢出和类型精度,不能直接用宿主语言的默认行为代替 Postgres 语义。

AVG 是另一个常见陷阱。全局平均值不能通过“对四个局部平均值再取平均”得到;每个分片应返回 SUMCOUNT,路由器再计算 SUM(all_sum) / SUM(all_count)。类似地,中位数和某些百分位数不能只靠固定大小的局部标量精确合并。

延迟、快照与部分失败

四个分片并发执行时,总耗时通常由最慢的那个分片决定。平均延迟看起来正常,也可能因为单个分片的数据倾斜、锁等待、缓存未命中或连接池耗尽而出现很高的尾延迟。因此监控至少要区分:路由解析耗时、连接等待时间、各分片执行时间、返回行数、合并时间和取消状态。

一致性边界也需要明确。四条普通连接分别取得快照时,未必观察到完全相同的逻辑时刻。要求跨分片严格一致的读取,通常意味着额外的快照协调或分布式事务机制,也会增加复杂度和成本。

如果三个分片成功、一个分片超时,聚合查询通常不能悄悄返回部分结果。路由器应取消仍在运行的子查询,丢弃不完整结果,并返回可识别、可重试的错误。只有 API 明确定义了“允许部分结果”时,才应返回降级数据,而且响应中必须标出缺失分片。

上线前检查清单

分片查询的优化重点不是只让单条 SQL 更快,而是减少不必要的扇出,并让合并语义可验证。上线前应确认:

  • 高频查询能否携带分片键,从四分片扇出变成单分片访问。
  • 聚合是否可分解,以及路由器的二次聚合是否与 Postgres 语义一致。
  • ORDER BYLIMITDISTINCT 和窗口函数由哪一层执行。
  • 超时是否覆盖连接等待、分片执行和结果合并,并能向下游传播取消信号。
  • 单个分片失败时,接口是整体失败还是显式返回部分结果。
  • 日志和追踪是否包含查询 ID、目标分片、各阶段耗时与返回行数。

分片提高了容量上限,却把原来隐含在一个 Postgres 实例中的语义搬到了路由器。路由器不仅是转发层,也是分布式查询执行器;只有把路由、局部执行、全局合并和失败处理放在同一条链路中观察,才能真正解释一条分片查询为什么快、为什么慢,以及它返回的结果是否正确。


相关推荐