分库分表之后怎么进行 Join 操作?


一则或许对你有用的小广告

欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书

  • 《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于 Spring AI + Spring Boot 3.x + JDK 21...查看介绍

  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...查看介绍;演示链接:http://116.62.199.48:7070/

  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/

  • 新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍

截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 方案储备广度:面试官想知道你是不是只停留在 "分库分表" 这四个字,遇到 join 这种真实业务诉求时能不能拿出可行的应对方案,而不是一句 "不能 join" 就完事。

  2. 架构权衡意识:每种方案都有代价——冗余字段会带来数据一致性问题,应用层组装会放大 QPS,数据同步又引入了新的组件。能不能讲清楚 trade-off,比能不能列方案更重要。

  3. 真实工程经验:实际项目里到底用哪种?为什么选这种?踩过哪些坑?这些只有做过分库分表的人才能答到位。

核心答案

先给结论:跨库 JOIN 本身就是分库分表后最头疼的问题之一,主流思路是 "能避就避,避不开就绕"。 工程上常用 6 种方案:

方案 核心思路 适用场景 代价
绑定表(父子表) 按相同分片键路由到同一库 主子表场景(订单/订单明细) 分片维度强约束
全局表 / 广播表 小表每个库都冗余一份 字典、配置类小表 写入需广播
字段冗余 把 join 字段直接存到主表 字段少、变更不频繁 数据一致性
应用层组装 分两次查询,代码里拼装 中等数据量、JOIN 表不多 放大 QPS、代码复杂
数据同步到 ES / Hive Canal 监听 binlog 同步到外部 复杂查询、报表、搜索 引入新组件、延迟
微服务拆分 + RPC 每个 Service 持有自己数据 DDD 拆分彻底 网络开销、分布式事务

下面挑常用的几个详细聊。

深度解析

一、为什么跨库 JOIN 这么难?

先看问题本质。单库时代,orders 表和 users 表都在同一个 MySQL 实例里,MySQL 自己就能走嵌套循环或哈希 JOIN,性能还不错。但分库之后:

跨库 JOIN
跨库 JOIN

跨库 JOIN
跨库 JOIN

上图就揭示了核心矛盾:ordersuser_id 分片到了 4 个库,users 也按 user_id 分片到了 4 个库,但它们不在同一个实例上,MySQL 自身的 JOIN 引擎根本就使不上劲。ShardingSphere 这种中间件虽然支持跨库 JOIN,但底层其实是把数据拉到内存里做笛卡尔积或归并,性能极差,生产几乎不可用。

所以主流做法是 从架构和设计上规避跨库 JOIN,别老想着硬改 SQL 让中间件能跑。

二、方案详解

1. 绑定表(父子表)——最优雅

如果两张表存在主子关系,比如 ordersorder_item,让它们按相同的分片键相同的分片算法路由,就能保证同一笔订单的数据落在同一个库。

绑定表 JOIN
绑定表 JOIN

绑定表 JOIN
绑定表 JOIN

ShardingSphere 里这种关系叫 BindingTable,配置一下就行:

# ShardingSphere 绑定表配置
shardingRule:
  bindingTables:
    - orders,order_item   # 这两张表使用相同分片键和算法

这样 SELECT * FROM orders o JOIN order_item i ON o.id = i.order_id 在路由时,中间件就能精确地把它下推到某一个具体的库里执行,性能跟单库几乎一致。

适用场景:1 对多的主子表,且分片键相同。

2. 全局表(广播表)——字典表标配

provincedict_type 这种数据量小(几千条)、几乎不更新的字典表,直接每个库都冗余一份完整的:

广播表 JOIN
广播表 JOIN

写入时中间件会广播到所有库,读取时直接用本地副本。ShardingSphere 的 BroadcastTable 就是干这个的。

适用场景:数据量小(建议 1 万条以内)、变更极少、被频繁 JOIN。

3. 字段冗余——简单粗暴但好用

订单表里直接存一份 user_nameuser_phone,避免 JOIN users 表。代价就是用户改名之后,所有相关订单都需要同步更新。

-- 冗余前:需要 JOIN 查用户名
SELECT o.id, o.amount, u.name
FROM orders o JOIN users u ON o.user_id = u.id;

-- 冗余后:直接查订单表
SELECT id, amount, user_name FROM orders WHERE id = ?;

实际工程做法:通常配合消息队列做异步同步。用户改名后发 MQ,消费者批量更新订单表中的冗余字段。短期内不一致没关系,最终一致即可。

4. 应用层组装——最灵活

把一条 JOIN SQL 拆成两条独立查询,在应用层用代码拼装:

@Service
public class OrderQueryService {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private UserMapper userMapper;

    public List<OrderVO> queryOrders(OrderQuery query) {
        // 1. 先查订单
        List<Order> orders = orderMapper.selectList(query);
        if (orders.isEmpty()) {
            return Collections.emptyList();
        }

        // 2. 收集所有 user_id,去重后批量查
        Set<Long> userIds = orders.stream()
                .map(Order::getUserId)
                .collect(Collectors.toSet());
        Map<Long, User> userMap = userMapper.selectByIds(userIds)
                .stream()
                .collect(Collectors.toMap(User::getId, u -> u));

        // 3. 在内存里拼装
        return orders.stream().map(order -> {
            OrderVO vo = new OrderVO(order);
            vo.setUserName(userMap.get(order.getUserId()).getName());
            return vo;
        }).collect(Collectors.toList());
    }
}

注意几个点

  • 第二次查询一定要批量查IN 或分批 IN),千万别循环单查,否则就是经典的 "N+1 查询" 性能灾难。
  • 数据量大时可以引入本地缓存或 Redis,缓解 QPS 压力。
  • 如果两个 Service 分属不同的微服务,就走 RPC,思路一样。

5. 数据同步到 ES / Hive——复杂查询兜底

如果业务真有那种 "跨十几个表、各种聚合" 的复杂查询(比如运营后台的报表、全文搜索),别硬撑在 MySQL 上,把数据通过 Canal 监听 binlog 同步到 Elasticsearch 或 ClickHouse:

异构查询
异构查询

这个方案的精髓是 "写还是走 MySQL,读复杂查询走 ES/ClickHouse",把不同场景交给最合适的存储。代价就是引入了新组件、有秒级同步延迟、要维护数据一致性。

三、方案选择决策树

JOIN 方案选型
JOIN 方案选型

简单总结一下:

  • 能设计规避就规避:业务建模阶段就考虑分片键,主子表绑在一起。
  • 小表广播、字段冗余、应用层组装:日常 80% 的场景就靠这三招。
  • 复杂查询走 ES/ClickHouse:留给那些真的搞不定的场景。

面试高频追问

  1. 追问一:字段冗余的数据一致性问题怎么解决?

    主流做法是 "异步补偿 + 最终一致"。写库时同步更新主表字段,同时发 MQ 异步更新冗余字段;或者定时任务对账兜底。短期内读到旧数据通常可以接受,关键是不能让不一致永远存在。

  2. 追问二:ShardingSphere 不是支持跨库 JOIN 吗?为什么不用?

    支持,但底层是 "流式归并" + "内存归并",跨库结果集拉到中间件层做笛卡尔积,数据量一大直接 OOM 或者超慢。生产环境只适合小结果集,复杂的还是绕开。

  3. 追问三:分库分表后分页查询(LIMIT offset, size)也很头疼,怎么做?

    这又是一个大坑。通用方案是 "禁止深分页" + "游标分页(基于上一页最大 ID)"。如果是后台必须深分页的场景,一般走 ES 或者 ClickHouse 二次查询。

  4. 追问四:Canal 同步到 ES,延迟和丢消息怎么处理?

    Canal 自身有位点记录,重启不会丢;但 MQ 这边要做幂等消费,防止重复写入。延迟通常在毫秒到秒级,业务能接受就 OK,接受不了可以加缓存兜底。

常见面试变体

  • "分库分表后如何做关联查询?"
  • "跨库 JOIN 的几种解决方案?"
  • "你们项目里分库分表后,复杂查询是怎么处理的?"
  • "为什么 ShardingSphere 的跨库 JOIN 性能差?"

记忆口诀

"绑、广、冗、组、同"——绑定表、广播表、字段冗余、应用层组装、数据同步。记住这五个字,方案就齐了。

总结

跨库 JOIN 这道题,光背方案没用,重点是讲清楚每种方案的适用场景和代价。生产实践里最常用的就是 "设计阶段用绑定表规避、字典表用全局表、字段少就冗余、查不动就上 ES" 这套组合拳。面试时把这套思路讲明白,面试官就知道你是真做过,不是背的。