跳过导航

分库分表后,分页、排序和唯一 ID 应该如何设计?

约 9 分钟...次浏览
专栏MySQL 与 ORM第 11 篇

单库中的分页和排序看起来只是一个 ORDER BY ... LIMIT ...。分库分表后,一条逻辑查询会被拆成多个物理查询,结果还要在应用或中间件中归并。真正困难的不是 SQL 语法,而是三个问题:查询能否精准路由、不同分片之间如何建立全局顺序、全局 ID 是否同时满足唯一性与业务查询需求。

1. 先明确分片模型

假设订单按 user_id 哈希到 16 个分片:

shard = hash(user_id) % 16
table = orders_${shard}

查询某个用户的订单时携带 user_id,路由器可以只访问一个分片:

SELECT * FROM orders_07
WHERE user_id = 10007
ORDER BY created_at DESC, id DESC
LIMIT 20;

这类查询的复杂度接近单库。困难来自没有分片键的后台查询,例如“查询全平台最近 20 笔待支付订单”。它必须访问全部 16 个分片,再做全局归并。

因此,分库分表设计的第一原则不是先选算法,而是列出核心访问模式:

  • 是否总能携带租户或用户 ID?
  • 是否需要按时间、状态做全局运营查询?
  • 是否有按订单 ID 直接查询的入口?
  • 是否要求任意跳页、精确总数和实时排序?

如果高频查询无法携带分片键,系统将长期支付广播查询成本。

2. OFFSET 在多个分片上会放大

全局查询第 10001 到 10020 条,不能简单地让每个分片执行 LIMIT 10000,20。因为全局前 10020 条可能分布在任意分片中。通用做法是每个分片先取前 offset + size 条:

-- 16 个分片分别执行
SELECT id, created_at, amount
FROM orders_xx
WHERE status = 0
ORDER BY created_at DESC, id DESC
LIMIT 0,10020;

归并层最多接收 16 × 10020 行,排序后丢弃前 10000 行,只返回 20 行。随着页码和分片数增加,数据库扫描、网络传输、归并内存都会被放大。

这也是为什么“单库深分页优化”在分片环境下还不够:即使每个分片只扫描窄索引,跨网络搬运和全局归并仍然昂贵。

3. 全局 Top-K 如何归并

每个分片内部已经按同一规则排序时,不必把所有结果放进内存后全量排序。可以使用 K 路归并:

shard-0:  10:05/id9, 10:01/id3, ...
shard-1:  10:04/id8, 10:02/id5, ...
shard-2:  10:03/id7, 09:59/id2, ...
                    |
             最大堆/优先队列
                    |
             全局前 K 条

把每个分片当前第一条放入最大堆,取出最大值后,再补入该分片下一条。对 S 个分片、取 K 条结果,归并计算量约为 O(K log S),内存约为 O(S),明显优于对全部候选行全排序。

但这只降低归并成本,不能消除各分片读取深 offset 的成本。要解决连续翻页,应使用游标。

4. 使用复合游标代替页码

全局排序必须稳定且可比较。仅按 created_at 排序不够,因为多条订单可能拥有相同时间。加入全局唯一 ID 作为 tie-breaker:

ORDER BY created_at DESC, id DESC

第一页每个分片查询前 20 条并归并,返回结果中的最后一条形成游标:

{
  "createdAt": "2026-07-12T10:20:30.123Z",
  "id": "2012345678901234567"
}

下一页向每个相关分片下推:

SELECT id, created_at, amount
FROM orders_xx
WHERE status = 0
  AND (
       created_at < :lastCreatedAt
       OR (created_at = :lastCreatedAt AND id < :lastId)
  )
ORDER BY created_at DESC, id DESC
LIMIT 20;

配套索引应与过滤和排序匹配,例如:

KEY idx_status_created_id(status, created_at, id)

每个分片只需从游标位置继续向后读取,再做一次 Top-K 归并。游标通常应签名或加密,防止客户端篡改过滤边界;还要包含影响结果集的过滤条件版本,避免客户端拿旧查询游标套用到新条件。

5. 一个全局游标有时还不够

如果分片数据分布极不均匀,统一全局边界可能导致某些分片重复扫描大量未入选数据。更精细的方案是在服务端保存每个分片的读取位置:

{
  "queryHash": "status=0&sort=createdAt,id",
  "positions": {
    "0": ["2026-07-12T10:20:30.123Z", "...901"],
    "1": ["2026-07-12T10:20:29.884Z", "...633"]
  },
  "expiresAt": 1783852800
}

这种 continuation token 更高效,但体积、状态管理和兼容成本更高。对大多数时间有序列表,单一复合游标已经足够;只有在压测证明归并读取成为瓶颈时再引入分片级位置。

6. 任意跳页与精确总数是昂贵需求

游标适合“下一页”,不能天然跳到第 5000 页。产品如果必须跳页,可以考虑:

  • 限制可跳页深度,深处改为时间范围筛选。
  • 对离线报表构建独立搜索或分析索引。
  • 预计算稀疏锚点,例如每隔一万条保存一个时间与 ID 边界。
  • 将查询导出改为异步任务,而不是在线翻页。

COUNT(*) 同样需要对所有分片求和,且在并发写入下,“总数”和后续页面不是同一时刻的快照。很多列表只需返回 hasNext:每个分片多取少量记录,归并后判断是否还有候选数据。

不要为了界面上一个很少被使用的精确页数,让每次请求扫描整个集群。

7. 分布式 ID 需要满足哪些性质

分库分表后不能依赖每张表的自增 ID 作为全局主键,否则不同分片会重复。常见方案包括:

方案优点主要问题
UUID v4无中心、生成简单128 位、随机写入、索引局部性差
UUID v7 / 时间有序 UUID全局唯一、趋势递增存储较宽,生态兼容需确认
数据库号段趋势递增、吞吐高依赖号段服务和持久化分配
Snowflake 类64 位、吞吐高、近似时间有序时钟回拨、节点号管理、寿命规划

经典 Snowflake 类结构可表示为:

0 | timestamp | worker-id | sequence

时间戳决定大体顺序,节点号区分生成器,同一毫秒内用序列号递增。设计时不能只复制位数,还要明确:

  • 自定义纪元与可使用年限。
  • 最大节点数和每毫秒最大生成量。
  • 时钟回拨时等待、拒绝、使用备用序列还是切换逻辑时钟。
  • 容器弹性扩缩时如何确保 worker ID 不重复。
  • ID 是否会暴露业务量与创建时间。

worker-id 写死在镜像或只取 IP 低位,都会在容器重建和网段重叠时产生冲突风险。

8. ID 唯一不等于可以路由

如果订单 ID 不包含分片信息,接口 GET /orders/{id} 无法知道该查哪个库,只能广播。常见解决方式有三种:

  1. ID 中编码分片位:生成时把 shard ID 放入若干位,查询时可解码路由。代价是 ID 结构与分片拓扑耦合,扩容迁移要保留逻辑分片映射。
  2. 维护 ID 到分片的映射表:查询先查路由目录。多一次访问,并需要保证目录与数据创建一致。
  3. 接口强制携带分片键:例如 /users/{userId}/orders/{orderId}。设计最清晰,但调用方必须可靠持有用户 ID。

推荐使用“逻辑分片”而不是把物理库编号直接写入 ID。逻辑分片数量预先设置得较多,再将逻辑分片映射到物理节点;扩容时迁移映射,ID 路由语义不变。

9. ID 近似有序也不是业务顺序

Snowflake ID 可近似反映生成时间,但不能无条件替代 created_at

  • 多节点时钟存在偏差。
  • ID 可能在业务事务提交前生成,提交顺序不同。
  • 数据迁移或补录可能沿用旧业务时间却生成新 ID。
  • 不同服务的 ID 生成规则可能不同。

如果业务要求“按创建时间展示”,仍应显式保存并排序 created_at,ID 只作为相同时间下的稳定第二排序键。若要求严格事件顺序,应引入单分区日志、版本号或业务序列,而不是从分布式 ID 猜测因果关系。

10. 一个可落地的查询架构

客户端游标
   -> 查询服务校验签名与过滤条件
      -> 路由层并发请求相关逻辑分片
         -> 每分片利用复合索引取 Top-N
      -> K 路归并、截取 pageSize
   -> 返回结果与下一页游标

工程上还需要:

  • 为分片请求设置统一 deadline,避免串行叠加超时。
  • 限制并发扇出,防止一个查询同时压垮所有连接池。
  • 明确部分分片失败时整体失败还是返回不完整结果;交易列表通常应整体失败。
  • 记录各分片扫描行数、返回行数、耗时与归并耗时。
  • 扩容迁移期间处理双读或路由版本,避免漏查。

11. 设计检查清单

  • 高频查询是否都包含分片键?
  • 全局查询是否真的需要实时、精确、任意跳页?
  • 排序字段是否加入全局唯一 tie-breaker?
  • 复合索引是否覆盖过滤条件和游标条件?
  • 游标是否签名、带版本且有过期时间?
  • ID 生成器如何应对节点号冲突和时钟回拨?
  • 只有 ID 时能否准确定位逻辑分片?
  • 扩容后旧 ID 的路由是否仍然有效?
  • 是否监控广播查询的扇出、扫描量和长尾延迟?

分库分表不是把一张表平均切开就结束了。分片键决定查询边界,排序键决定归并正确性,ID 决定唯一性和路由能力。把三者放在同一张访问模式表中设计,才能避免系统上线后用昂贵的广播查询弥补早期模型缺陷。

分享:
文章作者:狼码纪
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。文章可能参考了其他优秀文章,如有侵权请联系删除。