在单机 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,路由器通常只需要访问一个分片。
一个典型生命周期可以拆成这些阶段:
- 解析 SQL,识别表、过滤条件、聚合、排序和
LIMIT。 - 根据分片元数据,把
tenant_id映射到目标分片。 - 为每个分片生成带有局部过滤条件的子查询。
- 从连接池取得四条连接,并发发送查询。
- 各分片由自己的 Postgres 优化器规划并执行 SQL。
- 路由器接收局部结果,完成最终聚合、排序和截断。
- 将一个符合原始 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=101 和 103 的金额会跨分片累加。排序键还加入了 customer_id,这样收入相同时结果顺序仍然稳定。真实路由器还要处理 SQL 的 NULL 规则、排序方向、字符排序规则、数值溢出和类型精度,不能直接用宿主语言的默认行为代替 Postgres 语义。
AVG 是另一个常见陷阱。全局平均值不能通过“对四个局部平均值再取平均”得到;每个分片应返回 SUM 和 COUNT,路由器再计算 SUM(all_sum) / SUM(all_count)。类似地,中位数和某些百分位数不能只靠固定大小的局部标量精确合并。
延迟、快照与部分失败
四个分片并发执行时,总耗时通常由最慢的那个分片决定。平均延迟看起来正常,也可能因为单个分片的数据倾斜、锁等待、缓存未命中或连接池耗尽而出现很高的尾延迟。因此监控至少要区分:路由解析耗时、连接等待时间、各分片执行时间、返回行数、合并时间和取消状态。
一致性边界也需要明确。四条普通连接分别取得快照时,未必观察到完全相同的逻辑时刻。要求跨分片严格一致的读取,通常意味着额外的快照协调或分布式事务机制,也会增加复杂度和成本。
如果三个分片成功、一个分片超时,聚合查询通常不能悄悄返回部分结果。路由器应取消仍在运行的子查询,丢弃不完整结果,并返回可识别、可重试的错误。只有 API 明确定义了“允许部分结果”时,才应返回降级数据,而且响应中必须标出缺失分片。
上线前检查清单
分片查询的优化重点不是只让单条 SQL 更快,而是减少不必要的扇出,并让合并语义可验证。上线前应确认:
- 高频查询能否携带分片键,从四分片扇出变成单分片访问。
- 聚合是否可分解,以及路由器的二次聚合是否与 Postgres 语义一致。
ORDER BY、LIMIT、DISTINCT和窗口函数由哪一层执行。- 超时是否覆盖连接等待、分片执行和结果合并,并能向下游传播取消信号。
- 单个分片失败时,接口是整体失败还是显式返回部分结果。
- 日志和追踪是否包含查询 ID、目标分片、各阶段耗时与返回行数。
分片提高了容量上限,却把原来隐含在一个 Postgres 实例中的语义搬到了路由器。路由器不仅是转发层,也是分布式查询执行器;只有把路由、局部执行、全局合并和失败处理放在同一条链路中观察,才能真正解释一条分片查询为什么快、为什么慢,以及它返回的结果是否正确。