分库分表后,分页、排序和唯一 ID 应该如何设计?
单库中的分页和排序看起来只是一个 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} 无法知道该查哪个库,只能广播。常见解决方式有三种:
- ID 中编码分片位:生成时把 shard ID 放入若干位,查询时可解码路由。代价是 ID 结构与分片拓扑耦合,扩容迁移要保留逻辑分片映射。
- 维护 ID 到分片的映射表:查询先查路由目录。多一次访问,并需要保证目录与数据创建一致。
- 接口强制携带分片键:例如
/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 决定唯一性和路由能力。把三者放在同一张访问模式表中设计,才能避免系统上线后用昂贵的广播查询弥补早期模型缺陷。
相关文章
数据库与消息队列如何保证最终一致性?本地消息表、事务消息与 Outbox
从双写失败窗口出发,对比本地消息表、消息队列事务消息与 Transactional Outbox,并给出投递、幂等、顺序和对账的工程实现。
Hibernate N+1 问题与抓取策略:从懒加载到实体图
复现关联查询中的 N+1,比较 JOIN FETCH、EntityGraph、批量抓取和 DTO 投影,并讨论分页、笛卡尔积与多集合抓取边界。
MyBatis 一级缓存为什么可能读到旧数据?从 SqlSession 到缓存失效
从 PerpetualCache、CacheKey 与 SqlSession 生命周期出发,复现一级缓存命中和跨会话陈旧读取,说明清理规则与工程治理方案。