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。汇总表与冗余字段的同步都依赖消息系统,这自然引出下一节:跨库事务怎么办。

延伸阅读