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 的结果物化成一个由事件驱动的查询表,查询性能与耦合同时解决,代价是最终一致(秒级延迟)。

关联