分库之后的关联查询:五种做法与各自的代价
拆库之前,一条
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),但会把两个库绑死,分片扩容时无法拆开,不建议在新代码里使用。
二、五种做法
| 做法 | 实时性 | 适合 | 主要代价 |
|---|---|---|---|
| 分两次查 + 内存拼装 | 实时 | 绝大多数在线查询 | 不能按关联表字段过滤、排序、分页 |
| 字段冗余 | 实时 | 少量、稳定或需要快照的字段 | 源数据变更时的同步;存储冗余 |
| 宽表 / 物化视图 | 秒级到分钟级 | 固定的列表页、报表 | 构建与回刷链路 |
| 搜索引擎(Elasticsearch) | 秒级 | 多条件筛选、全文检索、复杂分页 | 同步链路、数据一致性、额外集群 |
| 同步到分析库 | 分钟级以上 | 离线分析、对账 | 只适合分析,不适合在线 |
三、分两次查 + 内存拼装
3.1 正确写法
// 第一步:查主表
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
// 每条订单查一次用户,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 逐条查询 | 1000 | 81ms | 1,228ms |
| 先去重,再逐条查询 | 200 | 15ms | 246ms |
去重后一次 IN 批量查询 | 1 | 0.6ms | 1.7ms |
同库 JOIN(对照) | 1 | 0.7ms | 2.0ms |
耗时几乎与查询次数成正比,差距来自网络往返次数。本机直连时往返接近 0,N+1 的问题看起来不大;一旦有 1ms 往返,1000 次查询就是 1.2 秒,放到跨机房调用上还会更大。批量查询和同库 JOIN 在同一量级,所以「分两次查」本身不慢,慢的是写成 N+1。只去重不批量,能少查五分之四,但仍然比批量慢一百多倍。使用 ORM 时尤其容易无意中写出 N+1——遍历列表时访问懒加载的关联对象就会触发。
3.3 批量查询的注意事项
IN的元素数量要有上限,通常几百到一千,超了就分批,避免 SQL 过长和执行计划劣化。- 先去重:1000 条订单可能只涉及 200 个用户。
- 缺失要有默认值:关联数据可能已被删除,不能让拼装过程抛空指针。
- 能并行就并行:需要关联多张表时,几个批量查询可以并发执行。
3.4 这个方案的边界
它只能解决「展示时补充字段」,解决不了:
- 按关联表字段过滤:「查出所有 VIP 用户的订单」——用户等级在另一个库里;
- 按关联表字段排序、分页:先按哪个库的数据排序都不对;
- 聚合:按用户等级统计订单金额。
遇到这三类需求,就该考虑下面的方案了。
四、字段冗余
把关联表里少量、稳定的字段直接存进主表:
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:
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;当出现「按关联表字段筛选和排序」的需求时,再往下走一层。
分库分表本身的决策,见 单表多大该拆分。
配套实验
- codesphere-labs/distributed/batch-vs-n-plus-one:N+1、去重后逐条、一次 IN 批量与同库 JOIN,直连与约 1ms 往返两种网络(验证记录)
参考资料