30.7 跨服务查询与数据聚合
一句话
服务拆开之后,“一次查询”往往要拼多个服务的数据,而跨服务分页 + 全局排序在没有全局索引时做不到正确——这不是实现细节,是设计约束:要么把数据聚合到一个读模型,要么接受“只能取各服务前 N 条再归并”的近似。
三种聚合方式
| 方式 | 做法 | 数据新鲜度 | 代价 | 什么时候用 |
|---|---|---|---|---|
| 同步聚合(API 组合) | 网关 / BFF 并发调用多个服务,内存合并 | 实时 | 可用性相乘(p^n)、N+1 风险 | 结果集小、调用方少、要求实时 |
| 异步投影(读模型) | 订阅各服务事件,维护一张查询表(CQRS) | 最终一致(秒级) | 要写投影与重建逻辑 | 列表页、搜索、报表(32.1 CQRS) |
| 数据冗余(快照) | 下单时把商品名/价格抄进订单 | 写入时定格 | 数据重复、更新要回溯 | 展示型字段、审计要求“当时的值” |
🔑 选型只问一句:这个查询需要“精确的全局排序/分页”吗? 需要 → 必须投影出全局索引;不需要 → 同步聚合并接受近似(下面会量化“近似”有多离谱)。
为什么“各取第 N 页”是错的
两个服务各持有一部分数据,前端要“全局按时间倒序的第 2 页”。最自然的实现是“两边各取第 2 页再合并”——它是错的。
package main
import (
"fmt"
"sort"
"strings"
)
type Item struct {
ID int
Rank int // 越小越新,全局排序键
}
// 两个服务各自持有一部分数据(按 (r*7)%20 交错分配,形成偏斜分布)
func load() (a, b []Item) {
for r := 1; r <= 200; r++ {
it := Item{ID: r, Rank: r}
if (r*7)%20 < 12 {
a = append(a, it)
} else {
b = append(b, it)
}
}
return a, b
}
func pageOf(items []Item, p, size int) []Item {
start := (p - 1) * size
if start >= len(items) {
return nil
}
end := start + size
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func mergeTake(a, b []Item, k int) []Item {
all := append(append([]Item{}, a...), b...)
sort.Slice(all, func(i, j int) bool { return all[i].Rank < all[j].Rank })
if len(all) > k {
all = all[:k]
}
return all
}
func idStr(items []Item) string {
out := make([]string, len(items))
for i, it := range items {
out[i] = fmt.Sprint(it.Rank)
}
return strings.Join(out, ",")
}
func main() {
a, b := load()
all := append(append([]Item{}, a...), b...)
sort.Slice(all, func(i, j int) bool { return all[i].Rank < all[j].Rank })
const size = 10
fmt.Println("=== 跨服务分页:naive(各服务取第 p 页)vs 正确(各服务取前 p*size 再归并)===")
fmt.Printf("%-4s %-38s %-38s %-8s\n", "页", "正确(全局 rank)", "naive 结果", "命中率")
for p := 1; p <= 5; p++ {
truth := pageOf(all, p, size)
naive := mergeTake(pageOf(a, p, size), pageOf(b, p, size), size)
hit := 0
want := map[int]bool{}
for _, it := range truth {
want[it.Rank] = true
}
for _, it := range naive {
if want[it.Rank] {
hit++
}
}
fmt.Printf("%-4d %-38s %-38s %-8s\n", p, idStr(truth), idStr(naive),
fmt.Sprintf("%d%%", hit*100/len(truth)))
}
fmt.Println("\n=== 修好的代价:每页要拉多少条 ===")
for p := 1; p <= 5; p++ {
fmt.Printf("第 %d 页:naive 拉 %2d 条(每服务 %d),正确拉 %3d 条(每服务 %d)\n",
p, 2*size, size, 2*p*size, p*size)
}
}
本机实测(macOS,Apple M1,Go 1.27):
=== 跨服务分页:naive(各服务取第 p 页)vs 正确(各服务取前 p*size 再归并)===
页 正确(全局 rank) naive 结果 命中率
1 1,2,3,4,5,6,7,8,9,10 1,2,3,4,5,6,7,8,9,10 100%
2 11,12,13,14,15,16,17,18,19,20 18,20,21,23,24,26,27,28,29,30 20%
3 21,22,23,24,25,26,27,28,29,30 33,35,38,40,41,43,44,46,47,49 0%
4 31,32,33,34,35,36,37,38,39,40 50,52,53,55,58,60,61,63,64,66 0%
5 41,42,43,44,45,46,47,48,49,50 67,69,70,72,73,75,78,80,81,83 0%
=== 修好的代价:每页要拉多少条 ===
第 1 页:naive 拉 20 条(每服务 10),正确拉 20 条(每服务 10)
第 2 页:naive 拉 20 条(每服务 10),正确拉 40 条(每服务 20)
第 3 页:naive 拉 20 条(每服务 10),正确拉 60 条(每服务 30)
第 4 页:naive 拉 20 条(每服务 10),正确拉 80 条(每服务 40)
第 5 页:naive 拉 20 条(每服务 10),正确拉 100 条(每服务 50)
这张表说明的事比“分页错了”更严重:
| 页 | 命中率 | 为什么 |
|---|---|---|
| 第 1 页 | 100% | 全局前 10 一定落在各服务的前 10 里(每服务最多贡献 10 个) |
| 第 2 页 | 20% | 全局第 12、13、15 名落在服务 A 的第 8–10 条(属于 A 的第 1 页),被“取第 2 页”整段跳过;而 A 的第 11–20 条里只有 18、20 勉强还在全局前 20 |
| 第 3 页起 | 0% | 完全错位:用户看到的是 33、35、38…,而真正的 21–30 一条都没有 |
🔑 根因是偏移量的含义变了:单库时
OFFSET 10是“全局第 11 条开始”;跨服务后它变成“每个服务各自跳过 10 条”,而每个服务的第 11 条在全局的位置完全不同。正确解只有一个:每个服务都要从第 1 条开始取,取到p × size条,再归并切片。
代价与三种解法
| 解法 | 每页拉取量 | 精度 | 适用 |
|---|---|---|---|
各服务取前 p × size 再归并 | 第 p 页要拉 2 × p × size 条(第 10 页就是 200 条) | 精确 | 页数浅(前 3–5 页)、数据量小 |
| 全局读模型(CQRS 投影) | 1 次查询,走索引 | 精确 | 深翻页、排序维度多、列表页主路径 |
| 游标(cursor / seek) | 每服务取 size 条,用“上一页最后一条的排序键”做条件 | 精确(要求排序键唯一且稳定) | 无限滚动、时间线 |
游标的做法值得单独说:不传 page=3,而传 after=2026-10-29T20:43:57Z,id=1001,每个服务返回“排序键小于该值的前 N 条”,归并后取 N 条,并把新的游标返回给前端。这样每个服务只需要返回 N 条,深翻页的成本不随页数增长。代价是:不能跳页(只能上一页/下一页),且排序键必须唯一(通常用 (时间, ID) 复合键)。
N+1:同步聚合的第二个坑
列表页 20 条,每条都要显示下单用户的名字:
| 做法 | 调用次数 | 延迟(跨机 1ms/次) |
|---|---|---|
| N+1:每条查一次用户服务 | 1 + 20 = 21 次 | 20ms+(串行) |
| 批量:一次查 20 个用户 | 1 + 1 = 2 次 | 2ms |
冗余:订单里存 user_name 快照 | 1 次 | 1ms(但改名要回溯) |
⚠️ 同步聚合的三个固定开销:可用性相乘(5 个依赖都 99.9% → 99.5%)、N+1(列表越长越慢)、扇出放大(一个列表请求变成 20 个下游请求,下游 QPS 被放大 20 倍)。所以同步聚合只适合“结果集小、调用方少、非高频”的查询。
反模式
| 反模式 | 后果 |
|---|---|
| 各服务取第 N 页再合并 | 第 2 页起结果就是错的,用户“翻页看到重复/缺失” |
| 在网关上做跨服务 join | 网关变成业务逻辑容器,每次改聚合规则都要发网关(33.6 BFF 与 API 组合) |
| 用分布式事务/锁保证查询一致性 | 查询不该加锁;跨服务强一致查询等于把系统吞吐压到最慢的那个服务 |
| 列表页 N+1 查详情 | 20 条列表 = 21 次调用,延迟与下游压力都翻倍 |
| 同步聚合调用 5 个以上服务 | 可用性 0.999^5 ≈ 99.5%,月停机 3.6 小时 |
| 用“共享库 join”绕开聚合 | 数据库变成隐式 API,独立部署失效(30.3 分布式单体反模式) |
| 读模型不做重建 | 投影逻辑一错,历史数据永远错;必须能从事件重放(32.5 投影与重建) |
自检
Q1 为什么“各服务取前 p × size 条再归并”能保证正确,而“各取第 p 页”不能?
▸ 答案:因为全局第 1 到第 p×size 条里,任何一个服务贡献的条数不可能超过 p×size(总共就这么多)。所以只要每个服务都从第 1 条开始取,取够 p×size 条,全局前 p×size 就完整地被覆盖,归并后切片必然精确。相反,“各取第 p 页”跳过了各服务的前 (p-1)×size 条,而这些条目里可能包含全局第 p 页的元素(某个服务在全局前 10 里贡献少、在 11–20 里贡献多),一旦跳过就永久丢失。
Q2 游标分页为什么能解决深翻页的成本问题?它牺牲了什么?
▸ 答案:因为游标把“偏移量”换成了“排序键的边界条件”(WHERE (created_at, id) < (?, ?) ORDER BY ... LIMIT n),每个服务都只返回 n 条,与页数无关;而 offset 分页要让每个服务返回 p×n 条,越翻越贵。牺牲的是:① 不能随机跳页(只能顺序前进/后退);② 排序键必须唯一且稳定(用 (时间, ID) 复合键);③ 数据在翻页过程中新增/删除时,游标语义要定义清楚(通常接受“新数据出现在下次刷新”)。
Q3 一个列表页要展示“商品 + 卖家昵称 + 最近一条评价”。用同步聚合实现,会踩哪几个坑?
▸ 答案:① N+1:20 个商品 → 20 次卖家查询 + 20 次评价查询,加首次列表共 41 次调用;② 可用性相乘:商品、用户、评价三个服务任一抖动都会让整页失败(0.999^3 ≈ 99.7%,还不算每个服务内部的依赖);③ 扇出放大:列表 QPS 100 时,用户与评价服务各承受 2000 QPS。正确做法是:商品列表走读模型(投影里直接带昵称与最新评价摘要),或至少改成批量接口(一次查 20 个卖家、一次查 20 条评价),并把评价服务做成可降级(失败时显示“暂无评价”)。
Q4 一个团队说“我们用共享库,查询直接 join,性能最好”。从本节的角度看,这笔交易的代价是什么?
▸ 答案:代价是放弃了独立部署与独立演进。跨库 join 让数据库成为所有服务的隐式契约:任何一张表的结构变更都要所有 join 方协调(30.3 的“共享表写者 ≥ 2”只是最严重的一种);而且 join 的查询性能一旦成为问题,你无法通过“拆服务 + 加缓存”解决,因为数据根本不归你管。正确的替代是读模型:把 join 的结果物化成一个由事件驱动的查询表,查询性能与耦合同时解决,代价是最终一致(秒级延迟)。