分片键与路由策略
分库分表拆完之后,每条 SQL 落到哪张表由分片键 + 分片算法决定。分片键选错,团队会被"大部分查询都得扫全部 64 张表"长期折磨;路由算法选错,将来扩容会变成一场全量数据搬迁。本文讲清选键原则、常见算法与路由实现。
分片键选择原则
| 原则 | 含义 | 反面例子 |
|---|---|---|
| 高基数 | 取值足够多,能把数据打散 | 用"订单状态"当分片键,只有几个值 |
| 分布均匀 | 各分片数据量与流量接近 | 用"用户省份",一线城市分片爆满 |
| 查询命中 | 绝大多数查询都带这个字段 | 按 user_id 分片却总按 order_no 查 |
第三条最关键:分片键必须出现在查询条件里,否则只能广播到所有分片。
按 user_id 分片:
SELECT * FROM orders WHERE user_id = 123 → 命中 1 张表
SELECT * FROM orders WHERE order_no = 'M001' → 命中全部 64 张表
一条 SQL 打到 64 张表,分片带来的收益被全部抵消,还多了归并开销。
常见分片算法
| 算法 | 规则 | 优点 | 缺点 |
|---|---|---|---|
| 范围分片 | 按区间,如 id 1~1000 万进 t_0 | 扩容方便,范围查询高效 | 新数据集中最后一片,易热点 |
| 取模分片 | hash(key) % N | 分布最均匀 | 扩容需全量迁移 |
| 一致性哈希 | 哈希环 + 虚拟节点 | 扩容只迁移少量数据 | 实现复杂,需虚拟节点防倾斜 |
| 时间分片 | 按月/按日建表 | 天然支持归档与冷热分离 | 跨时间段查询要合并多表 |
| 基因法 | 把分片基因嵌入业务 ID | 非分片键也能直接定位 | 需要 ID 生成器配合 |
取模:分片总数 64,user_id = 12345 → 12345 % 64 = 57 → 第 57 张表
时间:orders_202401、orders_202402 ... → 查近 3 个月需合并 3 张表的结果
路由实现:应用层取模
最简单的路由是在应用层算下标,再拼出库名与表名。以下片段需放入工程:
// 片段,需放入工程
public final class OrderRouter {
private static final int TOTAL = 64; // 分片总数:4 库 × 每库 16 表
private static final int PER_DB = 16;
private static int slot(long userId) {
return (int) Math.floorMod(userId, TOTAL); // 0~63,避免负数取模问题
}
public static String db(long userId) { return "order_db_" + (slot(userId) / PER_DB); }
public static String table(long userId) { return "t_order_" + (slot(userId) % PER_DB); }
}
user_id = 12345 → slot = 12345 % 64 = 57
db = 57 / 16 = 3 → order_db_3
tbl = 57 % 16 = 9 → t_order_9
路由表与双写
取模只解决了算法问题,还要处理"分片数变更后如何不停机调整"。把分片规则从代码挪到配置表即可:
CREATE TABLE shard_route (
logic_slot INT NOT NULL COMMENT '逻辑槽位 0~63',
db_name VARCHAR(32) NOT NULL COMMENT '物理库名',
table_name VARCHAR(64) NOT NULL COMMENT '物理表名',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
PRIMARY KEY (logic_slot)
) ENGINE=InnoDB COMMENT='分片路由表';
-- 扩容时只改这张表的数据,不必改代码、不必发版
按非分片键查询时,常用三种兜底办法:
| 办法 | 做法 | 代价 |
|---|---|---|
| 基因法 | 生成订单号时把 user_id 的哈希位嵌入 ID 尾部 | 需要统一 ID 生成器 |
| 冗余映射表 | 建 order_no → user_id 的索引表 | 多一次查询,需保证一致 |
| 双写宽表 | 同时按 user_id 和 order_no 存两份 | 存储翻倍,需保证一致 |
基因法:订单号尾部若干位 = user_id % 64
查询时 order_no % 64 直接得到分片下标,无需再查映射表
案例一:订单按 user_id 分片
订单查询模式很固定,一套分片不可能满足所有场景:
C 端:我的订单列表(带 user_id) → 命中单表
C 端:订单详情(带 order_no) → 基因法或映射表
B 端:按商户查订单(merchant_id) → 再建一套冗余表
运营:按时间统计成交量 → 走离线汇总,不查在线分片表
所以订单表的典型设计是"按 user_id 分片 + 订单号携带分片基因 + 运营统计走数仓"。
案例二:日志按时间分片
日志、监控、流水类数据"写得极多、按时间查、老数据基本不查",恰好适合时间分片:
| 特征 | 结论 |
|---|---|
| 时间单调递增 | 用时间分片,范围查询高效 |
| 老数据访问极少 | 可按月归档,直接删整表 |
| 单日数据量大 | 日表再按 hash(log_id) 分若干子表 |
| 写入集中在当前时段 | 热点只在最新一张表,需保证其写入能力 |
logs_20240101、logs_20240102 ...
查近 7 天趋势 → 合并 7 张表
查单日明细 → 命中 1 张表,顺序扫描也很快
删 90 天前数据 → DROP TABLE,秒级完成
选键自查清单
- 分片键是否出现在 80% 以上的查询条件里
- 分片键基数是否远大于分片数(建议 10 倍以上)
- 是否存在明显热点(超大客户、超热商品),是否需要单独拆分
- 按非分片键的查询有几条,分别用哪种办法兜底
- 分片总数是否取 2 的幂,便于将来双倍扩容
- 路由规则放在代码还是配置表,扩容时要不要改代码
小结:分片键的选择标准是高基数、分布均匀、查询命中,最忌讳选了一个大多数查询都不带的字段。路由可以先用取模实现,但要把规则抽到路由表里,扩容时改配置而不改代码;非分片键查询用基因法、映射表或冗余表兜底,不要退化成全分片广播。