3.1 跨库 join:怎么绕开
分库落地的第一周,工单系统就炸了:商家后台的「订单加店铺名称」列表查询全部报错——因为 t_order 在订单分片,t_shop 在店铺库,跨库 join 直接不存在了。这一节记录我们绕开跨库 join 的三种武器:绑定表、汇总表、应用层拼装,以及各自适用的场景。
场景:五个必须 join 的查询
盘点下来,订单系统里有五个强依赖 join 的核心查询。它们各不相同,没有一种方案能通吃,最后是三种武器组合使用。分库前做这个盘点非常重要:一旦分库完成,join 失效的影响面就固定了,提前盘点能留出改造时间窗,避免上线第一周救火。
| 查询 | 原来 join 的两张表 | 频率 | 延迟要求 |
|---|---|---|---|
| 订单列表加店铺名称 | t_order join t_shop | 极高 | 100ms |
| 订单加明细列表 | t_order join t_order_detail | 高 | 100ms |
| 订单加渠道名称 | t_order join t_channel | 中 | 200ms |
| 订单加用户昵称 | t_order join t_user | 高 | 150ms |
| 订单加物流状态 | t_order join t_logistics | 中 | 300ms |
第一类(订单与明细)的解法最优雅:绑定表。两张表按同一个分片键分片,路由规则保证同一 order_no 的订单与明细落到同一物理库,join 只在单库内发生。第二类(店铺、渠道、用户)用广播表或冗余字段;第三类(物流这类弱关联)直接应用层异步拼装。
方案:三种武器的适用边界
| 方案 | 适用 join 类型 | 优点 | 代价 |
|---|---|---|---|
| 绑定表 | 同分片键的两张大表 | join 零成本 | 两表必须同分片键同分片数 |
| 广播表 | 小字典表 | 自动同步、join 透明 | 写入时广播 16 库 |
| 冗余字段 | 只需名称等少量字段 | 单表搞定 | 字段更新要同步刷 |
| 汇总表 | 统计聚合类 | 查询走预计算 | 实时性折损 |
| 应用层拼装 | 异步关联类 | 灵活 | 两次查询加代码复杂度 |
订单列表加店铺名称最后用的是「冗余字段」:直接在 t_order 里加 shop_name 冗余列。店铺改名时通过消息异步刷新所有相关订单的冗余列(按 shop_id 定位订单用 2.4 节的二级路由)。冗余字段的代价是店铺改名后老订单显示新名称——但对订单场景来说这反而符合预期:商家改名不应该改变历史订单的展示一致性。
物流状态拼装走应用层:先查订单拿 logi_no,再调物流服务的 HTTP 接口。这两次调用之间没有事务要求,拼装失败就显示「物流信息加载中」。
对比之下,单体时代的实现只是一条 SELECT ... FROM t_order JOIN t_logistics,方案迁移做的其实是「把数据库的计算能力翻译成应用层代码」。成本核算时要把这部分开发与维护算进分库总成本,而不是只算实例费。当初做选型评审时这一栏被低估了,后来补充评估显示 join 改造占整个分库项目工作量的三成。
方案选择决策树
实际评审时我们把五个查询逐一落到方案上,形成一张「join 治理决策表」。这张表后来成了新同事入职的必读材料,任何新增 join 需求先过这张表:
| 查询需求 | 选定方案 | 决策依据 |
|---|---|---|
| 订单加明细 | 绑定表 | 同分片键,零成本 |
| 订单加店铺名 | 冗余字段 | 高频且只需名称 |
| 订单加渠道名 | 广播表 | 字典表仅 200 行 |
| 订单加用户昵称 | 冗余字段 | 用户名极少改 |
| 商家销量榜 | 汇总表 | 聚合统计必然预计算 |
| 订单加物流 | 应用层拼装 | 弱关联允许异步 |
| 财务区间流水 | 广播加归并 | 内部低频用户 |
决策树本身可以总结成一句话:先问 join 的两张表是否同分片键,是则绑定表;否则问数据量,小则广播、中则冗余字段、大且涉及聚合则汇总表;关联弱且可异步则应用层拼装。这套思路在 Java 实战:订单系统 的报表改造章节也有类似总结,单体时代的「SQL 万能主义」必须主动戒掉。
配置:绑定表与广播表的规则
绑定表的配置只多两行。t_order 与 t_order_detail 都按 order_no 分片,声明绑定关系后,中间件保证它们同库:
bindingTables:
- t_order, t_order_detail
broadcastTables:
- t_channel, t_shop_config
广播表(渠道字典、店铺配置类小表)在写入时中间件会自动向所有 16 个库广播,读取时从任意一个库读(一般读本地库,因为数据一样)。广播表的数据量必须小——配置类表通常只有几百行,完全没问题。如果某张「字典表」有上百万行,广播成本就不可接受了。
汇总表的建立是重量级方案,这里给一个最小例子。商家销量榜的汇总表按 shop_id 分片,订单写入后消息驱动累加:
CREATE TABLE t_shop_stat (
shop_id BIGINT PRIMARY KEY,
day DATE,
order_cnt INT,
amount DECIMAL(12,2),
KEY idx_day (day, order_cnt)
);
查询销量榜直接 SELECT shop_id FROM t_shop_stat WHERE day = ? ORDER BY order_cnt DESC LIMIT 100,一条 SQL 在汇总表上跑完。汇总表的建设可以参考 PHP 实战:SaaS 系统 里的统计表章节,思路完全一致。
踩坑:绑定表分片数必须一致
绑定表生效的前提是两张表分片键、分片算法、分片数完全一致。我们给订单明细表换过一次算法(字符串列的哈希实现),与主表不同步后绑定关系失效,join 瞬间退化成跨笛卡尔积查询,压垮了整个集群。绑定表的变更必须两张表同步进行,最好放在同一个发布窗口。
第二个坑是广播表的更新延迟。广播表更新后,16 个库的数据可能有秒级差异——如果有查询在那个窗口内从不同库读,会看到不一致的名称。对策是核心字典避免高频更新(店铺改名走消息异步刷冗余字段,而不是改广播表)。
第三个坑是应用层拼装的 N 加 1 问题。列表页 20 条订单逐条调物流接口,耗时叠加到 2 秒以上。改为按 logi_no 批量查询接口后降回 100 毫秒。应用层拼装的第一守则就是批量化:所有跨服务拼装都必须是批量接口。
第四个坑是冗余字段的更新代价被低估。店铺改名时要刷新该店铺所有订单的冗余列,大店铺有上百万条订单,直接 UPDATE 会长时间锁行。最终方案是「按月分批刷新加限速」,每批 5000 条、间隔 100 毫秒,一个百万订单的改名操作要跑 3 分钟,期间新旧名称并存是可接受的。
操作清单:join 改造的五步流程
分库前对 join 的改造可以按固定流程执行,这是我们实际跑过两轮的清单:
| 步骤 | 动作 | 产出物 |
|---|---|---|
| 1 盘点 | 搜集代码里所有 join 语句 | join 清单表 |
| 2 分类 | 按「同分片键、字典表、展示字段、聚合、弱关联」分类 | 方案选定 |
| 3 改造 | 逐一实现绑定、广播、冗余、汇总、拼装 | 代码与配置 |
| 4 验证 | 构造分片不均的测试数据回归 | 回归报告 |
| 5 监控 | 上线后跟踪慢查询与接口耗时变化 | 效果对照表 |
第四步的测试数据有讲究:要刻意构造「数据只在个别分片」的不均匀场景,验证绑定表与汇总表在不均匀分布下依然正确。均匀的数据会掩盖路由类问题。
小结
跨库 join 的解法是「分开治疗」:同分片键大表用绑定表、小字典表用广播表、展示类字段用冗余、统计类用汇总表、异步类用应用层拼装。每类 join 都有对应武器,唯一错误的做法是试图用一种方案通吃所有 join。汇总表与冗余字段的同步都依赖消息系统,这自然引出下一节:跨库事务怎么办。
延伸阅读
- Java 实战:订单系统:单体订单系统里 join 的原始用法,对比感受分库后的变化
- PHP 实战:SaaS 系统:统计汇总表的另一种实现思路
- MySQL 实战:分库分表订单系统:回到本书,3.2 节讲分布式事务
