Skip to content

分库之后的关联查询:五种做法与各自的代价 ​

拆库之前,一条 JOIN 就能拿到订单和用户名;拆完之后这条 SQL 不能用了。常见的替代方案有五种,选择依据不是「哪种优雅」,而是「能接受多大的延迟和多少维护成本」。

本文对比五种做法,并实测了最常用的那一种里最容易写错的细节:把「分两次查」写成 N+1,1000 条订单的关联从不到 1ms 变成 81ms;数据库和应用之间有 1ms 往返时,N+1 要 1.2 秒。

一、先说结论 ​

  • 在线查询的默认做法是「分两次查 + 内存拼装」:先查主表,收集外键,用一次 IN 批量查关联表。实测批量查询 0.6ms,N+1 写法 81ms;往返约 1ms 时,N+1 要 1.2 秒。
  • 少量稳定字段可以冗余:订单里冗余下单时的用户名和手机号,既避免关联,又保留了历史快照语义。
  • 需要按关联表字段过滤或排序时,内存拼装就不够了,要用宽表或搜索引擎,代价是数据同步和延迟。
  • 分析型查询不要在业务库上解决,同步到分析库再 JOIN,见 OLTP 与 OLAP。
  • 同一个实例内的跨库 JOIN 语法上可行(db1.t1 JOIN db2.t2),但会把两个库绑死,分片扩容时无法拆开,不建议在新代码里使用。

二、五种做法 ​

实时(查询时拼装)分两次查询 + 内存拼装第二次用 IN 批量查,不要 N+1字段冗余把少量必需字段冗余到主表准实时(提前同步)宽表 / 物化视图定时或流式构建同步到搜索引擎复杂条件与分页查询离线(分析用)同步到分析库后直接 JOIN实测:N+1 查询 81ms,批量 IN 查询 0.6ms;约 1ms 往返时 N+1 要 1.2 秒
图 1 · 越往下实时性越差、维护成本越高;大多数在线查询用「分两次查 + 内存拼装」就够了
做法实时性适合主要代价
分两次查 + 内存拼装实时绝大多数在线查询不能按关联表字段过滤、排序、分页
字段冗余实时少量、稳定或需要快照的字段源数据变更时的同步;存储冗余
宽表 / 物化视图秒级到分钟级固定的列表页、报表构建与回刷链路
搜索引擎(Elasticsearch)秒级多条件筛选、全文检索、复杂分页同步链路、数据一致性、额外集群
同步到分析库分钟级以上离线分析、对账只适合分析,不适合在线

三、分两次查 + 内存拼装 ​

3.1 正确写法 ​

java
// 第一步:查主表
List<Order> orders = orderMapper.selectRecent(1000);

// 第二步:收集外键,一次性批量查关联表
Set<Long> customerIds = orders.stream().map(Order::customerId).collect(toSet());
Map<Long, Customer> customers = customerMapper.selectByIds(customerIds).stream()
        .collect(toMap(Customer::id, identity()));

// 第三步:在内存里拼装
orders.forEach(o -> o.setCustomerName(
        customers.getOrDefault(o.customerId(), Customer.UNKNOWN).name()));

3.2 最常见的错误:写成 N+1 ​

java
// 每条订单查一次用户,1000 条订单就是 1000 次查询
orders.forEach(o -> o.setCustomerName(customerMapper.selectById(o.customerId()).name()));

1000 条订单涉及 200 个用户,给每条订单补上用户名(MySQL 8.4.11、JDK 21,5 次中位数)。「约 1ms 往返」是在应用和数据库之间加了一个每个方向延迟约 0.5ms 的代理,接近同机房跨主机:

写法查询次数本机直连约 1ms 往返
N+1 逐条查询100081ms1,228ms
先去重,再逐条查询20015ms246ms
去重后一次 IN 批量查询10.6ms1.7ms
同库 JOIN(对照)10.7ms2.0ms

耗时几乎与查询次数成正比,差距来自网络往返次数。本机直连时往返接近 0,N+1 的问题看起来不大;一旦有 1ms 往返,1000 次查询就是 1.2 秒,放到跨机房调用上还会更大。批量查询和同库 JOIN 在同一量级,所以「分两次查」本身不慢,慢的是写成 N+1。只去重不批量,能少查五分之四,但仍然比批量慢一百多倍。使用 ORM 时尤其容易无意中写出 N+1——遍历列表时访问懒加载的关联对象就会触发。

3.3 批量查询的注意事项 ​

  • IN 的元素数量要有上限,通常几百到一千,超了就分批,避免 SQL 过长和执行计划劣化。
  • 先去重:1000 条订单可能只涉及 200 个用户。
  • 缺失要有默认值:关联数据可能已被删除,不能让拼装过程抛空指针。
  • 能并行就并行:需要关联多张表时,几个批量查询可以并发执行。

3.4 这个方案的边界 ​

它只能解决「展示时补充字段」,解决不了:

  • 按关联表字段过滤:「查出所有 VIP 用户的订单」——用户等级在另一个库里;
  • 按关联表字段排序、分页:先按哪个库的数据排序都不对;
  • 聚合:按用户等级统计订单金额。

遇到这三类需求,就该考虑下面的方案了。

四、字段冗余 ​

把关联表里少量、稳定的字段直接存进主表:

sql
ALTER TABLE `order`
  ADD COLUMN customer_name  VARCHAR(64) NOT NULL DEFAULT '',
  ADD COLUMN customer_level TINYINT     NOT NULL DEFAULT 0;

适合冗余的字段有两个特征:变化很少,或者业务上就需要快照。订单里的收货人姓名、电话、地址正是后者——用户之后改了默认地址,也不应该影响历史订单显示的地址。

不适合冗余的是频繁变化的状态(比如用户的实时等级、账户余额),那样同步成本高,还容易出现不一致。

冗余之后要处理更新:源数据变更时通过消息通知更新冗余字段,并定期对账。冗余字段越多,这条链路越脆弱,所以只冗余真正需要的几个。

五、宽表与搜索引擎 ​

5.1 宽表 ​

把多张表的数据提前拼成一张查询专用的表,由定时任务或变更事件驱动构建。适合固定的列表页——字段确定、查询模式确定。

代价是:每增加一个字段就要改构建逻辑并回刷历史数据;构建任务失败时页面数据会停留在旧状态,需要监控数据新鲜度。

5.2 搜索引擎 ​

需要「多条件组合筛选 + 排序 + 深分页」时,把订单和关联字段一起写入 Elasticsearch:

text
MySQL 分片 → CDC(Canal / Debezium)→ Kafka → 索引构建 → Elasticsearch

这套方案能解决内存拼装解决不了的全部问题,代价是:

  • 同步延迟:通常秒级,业务要能接受「刚下单的订单在列表里晚几秒出现」;
  • 一致性:同步链路的每一环都可能丢数据,需要定期全量对账;
  • 运维成本:多一个集群,还要处理映射变更、重建索引。

判断是否值得引入:如果只是偶尔一个报表需求,用宽表或分析库;如果核心列表页就依赖复杂筛选,那么搜索引擎是必要的。ES 的机制见 Elasticsearch 核心机制。

六、几种不推荐的做法 ​

  • 同实例跨库 JOIN(db1.orders JOIN db2.users):语法可行,但它假设两个库永远在同一个实例上,一旦真正分片就得推倒重来。
  • 中间件的跨分片 JOIN:ShardingSphere 这类中间件支持部分跨分片关联,但会在内存中做笛卡尔积或多次查询,数据量稍大就很慢,且难以预估成本。
  • 把关联表整个加载到内存:小字典表(省市区、类目)可以,业务表不行——数据量和更新频率都不可控。

七、常见误区 ​

  • 「分两次查比 JOIN 慢」:实测批量查询 0.6ms,同库 JOIN 0.7ms,慢的是写成 N+1 的版本。
  • 「冗余字段就是反范式,不好」:订单里的收货信息本来就该是快照,冗余在这里是正确建模。
  • 「上了 ES 就不用管一致性」:同步链路会丢数据,必须有对账。
  • 「中间件能透明支持跨库 JOIN」:支持不等于可用,代价通常不可接受。

小结 ​

分库之后,「一条 SQL 拿到所有数据」这件事不复存在,取而代之的是一个选择题:用两次查询在内存里拼(实时、简单、但不能按关联字段筛选),用冗余字段避免关联(实时、需要同步),还是提前把数据拼好放到宽表或搜索引擎(能力最强、延迟和成本最高)。默认从第一种开始,并确保它是批量查询而不是 N+1;当出现「按关联表字段筛选和排序」的需求时,再往下走一层。

分库分表本身的决策,见 单表多大该拆分。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权