人人都会AI编程

27.3 跨库查询、分布式事务、全局 ID 方案

更新时间:2026-07-11

分库分表之后,原本单机上理所当然的事情——比如一条 SQL 关联两个表、一个事务包裹多条写入、生成一个自增主键——都会变成需要重新设计的工程难题。这一节就围绕这三个最核心的挑战,给出实用的解决思路。

27.3.1 跨库查询

数据一拆,查询就麻烦了:用户表在 A 库,订单表在 B 库,原本一条 JOIN 搞定的事情现在无从下手。处理跨库查询通常有几个层次的做法。

1. 应用层组装(最常用)

最简单的办法就是老老实实在应用代码里查多次,然后拼接结果。

比如要查“用户张三的所有订单”,不再是一个 JOIN,而是两步走:

  1. 根据用户标识到用户库查到用户 ID。
  2. 用这个 ID 到订单库(可能也是分片的)查询该用户的订单列表。

这种做法简单直接,可控性强,不引入额外中间件,是中小型系统最常用的办法。但缺点也很明显:

  • 网络开销增大,多一次远程调用。
  • 分页排序变复杂:如果订单需要按时间排序且跨多个分片,就需要把所有分片的数据拿到应用内存中做全局排序,数据量大时几乎不可行。
  • 关联查询变复杂:三表、四表关联时,应用层代码会变得臃肿。

2. 使用中间件(适合较复杂的场景)

引入 ShardingSphere-Proxy 或 MyCat 这类中间件,可以部分屏蔽分库分表的细节。它们通常支持:

  • 单库查询:直接按分片键路由,中间件帮你转发到正确节点。
  • 跨库笛卡尔积:对简单的关联,中间件可以把两个表的全量数据拉取到自身节点然后做 Join,但数据量大时会立即成为瓶颈,基本不可行。
  • 半自动化路由:比如配置绑定表(binding tables),规定某些关联表以相同的分片键分布,确保关联查询落在同一个分片内,从而避免真正的跨库 Join。这是分库分表设计中极为重要的一条原则:应当让需要 Join 的表使用相同分片键,使它们尽可能地共库存储。

例如,订单表 order 和订单明细表 order_item 都按 user_id 分片,那么同一用户的所有订单及明细都会落在同一个数据库实例上,业务查询可以在单库内完成,几乎不感到分片的存在。这个原则在实际设计时优先级很高,应该在一开始就规划好。

3. 全局查询层(搜索引擎或同步宽表)

当确实需要跨多维度、大范围数据检索时,不宜直接查询分片库。常见的做法是将数据异构到搜索引擎(如 Elasticsearch)或构建宽表

  • 搜索引擎:通过 Binlog 订阅(如 Canal)将 MySQL 数据同步到 Elasticsearch,复杂搜索条件交给 ES 处理,得到主键后再回查 MySQL 获取完整数据。这种方案需要维护额外的同步链路,但查询能力极强,适合订单检索、商品筛选等场景。
  • 宽表:在数仓或单独的 MySQL 实例中维护一张冗余的宽表,按业务查询需求预先把多表关联的结果合并成一条记录。查询时直接查宽表,写入时通过异步机制更新。代价是数据有一定延迟和冗余。

无论哪种方案,都应该把跨库 Join 视为设计上的最后防线,而不是日常操作。能用拆表前规划好的分片键路由就尽量路由,能在应用层简单组装就简单组装,实在不行再引入额外组件。

27.3.2 分布式事务

在一个数据库实例里,事务是天然的。分库之后,一个业务操作可能同时修改 A 库的订单状态和 B 库的库存,如何保证这两个操作要么都成功要么都失败?这就是分布式事务要解决的问题。

1. 尽量避免分布式事务

首先,最务实的建议是:在设计之初就尽量避免分布式事务。如果两个数据必须在同一个事务里,那么它们就应该放在同一个数据库实例中。可以通过精心选择分片键,把有关联的表都按相同键分片,使得事务内碰到的表都在同一个分片上。

例如,下单操作涉及订单表、订单明细表、库存表。若订单表和订单明细表按用户 ID 分片,但库存表按商品 ID 分片,那么下单时很可能需要跨库操作。更好的设计是把库存扣减也围绕用户维度设计,或者把库存数据冗余到用户分片下,保证事务局部化。虽然这样会增加冗余和同步逻辑,但换来了事务的简单可靠,整体上利大于弊。

2. 柔性事务与最终一致性

如果确实避不开跨库操作,业界主流做法是放弃强一致的 ACID 分布式事务,转而追求最终一致性。这意味着系统允许短暂的数据不一致,但通过某种补偿机制保证最终数据是对的。

常见的模式有两类:

  • 事务消息 + 本地事务表:以 RocketMQ 的事务消息为例,系统先发送半消息,然后执行本地事务,根据本地事务成功与否来提交或回滚消息。消费方收到消息后执行自己的本地事务。如果消费方失败,可依靠消息的重试机制最终完成。这种方案依赖可靠的消息中间件和幂等的消费逻辑。
  • 补偿事务(TCC):将业务操作拆分为 Try(预留资源)、Confirm(确认提交)、Cancel(取消释放)三个阶段。每个参与者都实现这三个接口。协调者先调用所有参与者的 Try,若全部成功则调 Confirm,否则调用 Cancel 回滚。Seata 等开源框架提供了 TCC 模式的实现,但业务侵入性较强,每个参与者都要按 TCC 规范重新设计接口,而且补偿逻辑要处理空回滚、悬挂等问题。

这两种方案都不是银弹:事务消息依赖中间件,TCC 改动量大。但它们是生产环境中真正可行且被广泛验证的路径,远比 XA 二阶段提交更易落地。

3. XA 二阶段提交(慎用)

MySQL 从 5.0 开始支持 XA 事务,即通过两阶段提交协议实现跨多个 MySQL 实例的分布式事务。它的确能保证强一致性,但缺陷非常明显:

  • 性能极差,需要多轮网络交互,锁定资源时间长。
  • 没有协调者高可用方案,一旦协调者宕机,参与者持有的锁无法释放,可能造成大面积阻塞。
  • 运维复杂,故障恢复需要人工介入。

因此,在实际业务系统中几乎没有人会使用 XA 事务来支撑核心链路。它的存在更多是为了兼容异构数据源的规范需求。在日常开发中,你可以直接把它看作理论方案,不必投入精力。

总的思路是:优先通过数据分片设计避免分布式事务 > 使用事务消息或 TCC 实现最终一致性 > 极特殊情况下才考虑 XA。配合幂等性设计、异步对账和补单机制,最终一致性能满足绝大多数的业务需求。

27.3.3 全局 ID 方案

单库时代,主键 ID 直接靠 AUTO_INCREMENT 就能完美自增。分库分表后,多个数据库节点的自增 ID 必然会冲突,而且主键 ID 很难再连续。因此需要一套全局唯一的 ID 生成方案,它必须满足:

  • 全局唯一,绝对不冲突。
  • 趋势递增,以保持 InnoDB 的聚簇索引插入性能。
  • 高可用,生成服务不能成为单点。
  • 需求场景允许的情况下,尽量简单。

以下是几种主流方案。

1. UUID 及其变体

最简单的就是使用 UUID(),生成 32 位随机字符串。优点是每个节点可以独立生成,不会冲突,不需要中心化服务。代价也很明显:

  • 存储代价高,字符串类型占据空间比 bigint 大很多。
  • 无序性严重伤害插入性能:InnoDB 主键索引是 B+ 树,乱序的 UUID 会导致大量的页分裂和磁盘随机 I/O,写入效率断崖式下跌。
  • 可读性差,查问题时肉眼无法识别。

不建议直接使用原始 UUID 作为主键。如果确实需要避免中心化,可以使用有序的 UUID 生成器(如基于时间戳和 MAC 地址的 UUID v7,或 Twitter Snowflake 算法),核心是利用时间递增性保证一定程度的有序。

2. 号段模式(美团 Leaf-segment)

这是业界很成熟的方案,思路是:从数据库中批量获取一个号段,存放在本地内存中,用完再取下一个号段。

具体实现:

  • 建一张 id_alloc 表,字段 biz_tag 标识业务类型,max_id 记录已分配的最大 ID,step 为每次获取的号段大小。
  • 应用启动时,执行 UPDATE id_alloc SET max_id = max_id + step WHERE biz_tag = 'order',然后拿到原 max_id 作为起始值,生成本地一批 ID。
  • 这批 ID 用完或剩余不多时,再去更新一次拿到新号段。

优点:

  • 性能极高,号段在内存中直接递增,不会每次都访问数据库。
  • ID 趋势递增,对 InnoDB 友好。
  • 实现简单,不依赖额外中间件。

缺点:

  • max_id 表可能成为单点,但可以通过双写或主从切换来提高可用性。
  • 当服务重启或号段用完时需要更新数据库,偶尔有短暂阻塞,但通过提前预取可以缓解。
  • ID 严格连续但不保证绝对单调递增:不同实例获取的号段可能产生交叉(例如实例 A 拿了 1-1000,实例 B 拿了 1001-2000,虽然整体递增,但如果 B 处理快,可能 ID 1001 比 999 先入库)。不过由于趋势基本递增,对性能影响很小。

美团开源的 Leaf 正是基于这种号段模式,还增加了双 buffer 预取优化,可靠性更高。

3. 雪花算法(Snowflake)

Twitter 的 Snowflake 算法生成 64 位的长整型 ID,结构如下:

0 - 41位时间戳 - 10位机器标识 - 12位序列号
  • 第一位固定为 0。
  • 41 位时间戳精确到毫秒,可用约 69 年。
  • 10 位机器标识,可部署 1024 个节点。
  • 12 位序列号,每毫秒最多生成 4096 个 ID。

优势:

  • 完全去中心化,每台机器独立生成,不需要数据库或协调服务。
  • 趋势递增,对索引友好。
  • 生成速度极快,纯内存计算,每秒可几十万 QPS。

缺点:

  • 强依赖机器时钟:如果服务器时钟回拨,会导致 ID 重复。解决思路是运行时检测时钟回拨并拒绝生成,或者利用预分配的序列号避免回溯。
  • 机器标识需要手工分配或通过 ZooKeeper 等自动注册,有一定运维成本。
  • ID 不是严格递增,在时间戳相同时不同机器生成的 ID 会有交叉,但整体趋势向上。

实际生产中,可以引入美团 Leaf-snowflake 方案,它通过 ZooKeeper 协调时钟回拨时的处理;或者使用基于 Redis 的递增序列简化实现。不过对于大多数团队,如果不需要极致的去中心化,号段模式就够用了

4. 基于 Redis 的原子递增

利用 Redis 的 INCRINCRBY 获取全局自增 ID,优点是部署简单,速度快,能保证趋势递增。但 Redis 本身需要持久化和高可用(比如 Redis Cluster 或哨兵),一旦 Redis 故障或数据丢失,可能导致 ID 回溯。适合对 ID 连续性要求不严苛的场景,但通常不推荐作为主要方案。

方案选择建议

  • 如果分表数量不多,且对 ID 顺序要求不高,可以用 Snowflake 去中心化,但要注意处理时钟回拨。
  • 推荐大部分场景使用号段模式,兼顾简单、高性能和 InnoDB 友好,Leaf-segment 有现成实现。
  • 无论哪种方案,都建议将全局 ID 的生成封装成一个独立的服务(或 SDK),统一提供 nextId() 接口,避免各业务自行乱造。

另外还要注意,分库分表后,ID 不再反映插入顺序,这就要求业务代码不能依赖自增 ID 的大小来推断数据先后,而应该用显式的时间戳字段来判断。

综合来看,跨库查询、分布式事务、全局 ID 这三个问题,不是等分库分表落地之后才去被动解决的,而是应该在拆分决策阶段就一并思考,并在数据模型和业务逻辑上做出让步和优化。多数分布式难题的最佳解,是让分布式尽可能少地发生——能共库就共库,能异步就异步,能在单分片内闭环就闭环。剩下必须面对的部分,再用成熟的工程手段逐个处理。