🧩 本章核心:分区不是简单地把数据拆开,而是根据访问模式,在负载均衡、查询局部性、索引维护、扩容成本和路由复杂度之间做取舍。 学习日期:2026-09-12 学习方式:订单系统案例 + 架构决策问答
一、全章主线
当数据量或查询吞吐超过单机能力时,需要把不同数据分布到多个分区。设计分区系统时要持续回答五个问题:
- 键:数据按照什么键进入哪个分区?
- 索:非分区键查询如何建立和维护索引?
- 热:数据倾斜和热点键如何处理?
- 迁:节点变化时如何安全地再平衡?
- 路:客户端如何找到当前负责目标分区的节点?
可以用“键、索、热、迁、路”记住本章。
二、分区与复制
- 复制(replication):同一份数据保存多个副本,主要提升可用性、容错能力和读取能力。
- 分区(partitioning / sharding):不同数据保存到不同分区,主要突破单机容量和处理能力。
实际系统通常同时使用两者:每条数据只属于一个逻辑分区,但该分区可以在多个节点上拥有副本;不同节点分别承担不同分区的 Leader。
理想的分区方案希望同时做到:
- 数据量均衡。
- 请求负载均衡。
- 高频查询只访问一个或少量分区。
- 扩容时只搬迁必要的数据。
这些目标通常无法全部最大化,设计的核心是明确优先级。
三、订单系统案例与访问模式
订单包含以下核心字段:
Order {
order_id
user_id
merchant_id
status
amount
created_at
}
| 占比 | 访问模式 | 要求 |
| 50% | 根据 order_id 查询或更新订单 | 低延迟、可用于支付回调和状态更新 |
| 30% | 查询某用户最近 100 个订单 | 按时间倒序、稳定分页 |
| 15% | 商家按时间和状态查询订单 | 头部商家可能产生极高流量 |
| 5% | 运营按时间范围统计 | 可以进入独立分析链路 |
四、范围分区与哈希分区
| 策略 | 优点 | 代价 |
| 键范围分区 | 保留键顺序,范围扫描高效 | 相邻写入可能集中;按时间分区时当前分区容易过热 |
| 键哈希分区 | 把不同键较均匀地分散到分区 | 破坏全局顺序,范围查询可能需要访问多个分区 |
哈希能够改善不同键之间的不均衡,但不能拆散同一个热点键。相同的 merchant_id 每次都会得到相同哈希值,因此头部商家仍可能压垮单个分区。
按 order_id 范围分区也有风险:如果订单号随时间递增,最新写入会集中到最后一个范围。若选择订单号作为分区键,应区分“按订单号范围分区”和“按订单号哈希分区”。
五、从访问模式推导订单主表
最终选择:
partition_key = hash(user_id)
sort_key = (created_at, order_id)
收益:
- 同一用户的订单进入同一逻辑分区。
- 用户最近订单查询只访问一个分区。
- 分区内部按时间高效扫描。
order_id用作时间相同时的稳定排序和分页边界。
按订单号查询需要定位表:
order_id
→ 定位表得到 user_id
→ hash(user_id)
→ 用户订单主表
这将一次全分区 scatter/gather 转换为两次定向点查。支付回调、退款和状态更新依赖定位信息,因此定位表不能被当作允许任意延迟的普通异步索引;它应可靠地同步写入,或者让 order_id 携带可路由的逻辑分区信息。
六、二级索引:本地索引与全局索引
| 方案 | 写入 | 读取 | 主要风险 |
| 文档分区索引 / 本地索引 | 只更新文档所在分区 | 非分区键查询需要 scatter/gather | 读放大和尾延迟放大 |
| 词项分区索引 / 全局索引 | 可能更新与主表不同的分区 | 按索引词项定向查询少量分区 | 跨分区写入、异步索引陈旧 |
为商家后台建立全局查询索引:
partition_key = (merchant_id, bucket)
sort_key = (status, created_at, order_id)
value = user_id
查询某个商家的指定状态订单时,先定位商家索引分区,再按创建时间扫描。merchant_id 属于分区键,不需要在概念上的 sort key 中重复。
七、Scatter/Gather 与热点分桶
如果订单主表按用户分区,而商家查询没有全局索引,那么一次商家查询需要发送到全部分区,合并结果并等待最慢分区,这就是 scatter/gather。
对头部商家采用稳定分桶:
bucket = hash(order_id) % bucket_count
partition_key = (merchant_id, bucket)
写入时已知 order_id,可以直接计算桶号。更新、删除和幂等重试也会进入相同桶。随机分桶则需要额外保存订单与桶的映射。
读取商家的最近订单时不知道结果中会出现哪些 order_id,因此需要查询所有桶:
并行查询多个桶
→ 每桶获取局部 Top N
→ 合并并按 created_at、order_id 排序
→ 截取全局 Top N
这是一种受控的 scatter/gather:用 16 个桶的读取扇出换取约 16 倍的热点写入分散能力,而不是访问全部数据库分区。
普通商家可以使用 1 个桶,头部商家使用多个桶。系统必须记录 merchant_id → bucket_count / layout_version,并处理从单桶向多桶迁移时的新旧布局共存。
八、同步主数据与异步查询视图
订单系统的数据职责应分开:
- 订单主表:权威事实来源。
- order_id 定位表:关键路由信息,服务支付和状态更新,需要可靠、及时。
- 商家查询索引:派生查询视图,在业务允许时异步维护。
异步商家索引可以缩短下单主路径,但会短暂陈旧,还要处理重复事件、乱序、重试和部分缺失。支付操作不能只依赖商家索引找到订单,应通过定位表进入主表并再次确认状态。
九、再平衡
错误做法:
node = hash(key) % node_count
节点数变化会让大量键重新映射,导致不必要的数据搬迁。
固定逻辑分区方案:
key → 256 个固定逻辑分区之一
逻辑分区 → 当前物理节点
4 个节点扩容到 8 个节点时,每个旧节点原有约 64 个分区,最终保留约 32 个并移出约 32 个;总计迁移约 128 个逻辑分区。键到逻辑分区的映射不变,只修改逻辑分区到节点的映射。
安全迁移流程:
- 旧节点继续服务。
- 将分区历史数据复制到新节点。
- 追平迁移期间产生的新写入。
- 校验新副本完整性和进度。
- 原子发布新的分区归属。
- 新请求切换到新节点。
- 旧节点重定向或转发迟到请求。
动态分区则根据分区大小进行拆分和合并。它能适应数据总量,但空数据库最初可能只有一个分区,需要预分区避免早期所有写入集中在单节点。
自动再平衡必须限速并限制并发迁移。把暂时过载的节点误判为故障后立即搬迁数据,可能增加网络和剩余节点负载,引发级联故障。
十、请求路由
| 方式 | 优点 | 代价 |
| 任意节点接收并转发 | 客户端简单 | 数据库节点承担协调和额外网络跳转 |
| 独立路由层 | 统一管理路由 | 路由层需要高可用,可能增加跳转 |
| 分区感知客户端 | 直接连接目标节点,路径短 | 客户端和驱动更复杂,必须处理缓存过期 |
三种方式都需要权威的“逻辑分区 → 节点”元数据。客户端缓存过期时,旧节点可以返回结构化重定向:
MOVED partition_42 TO node_8
→ 客户端向新节点重试
→ 刷新本地路由缓存
对写请求进行重试时应携带稳定幂等标识,避免超时或重定向导致重复执行。
十一、MySQL 中的 partition key 与 sort key
sort_key 不是 MySQL 关键字,而是 DynamoDB、Cassandra 等数据库常用的数据建模术语。在 MySQL 中可用联合 B-tree 索引表达相近意图:
CREATE TABLE orders (
user_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
created_at DATETIME(6) NOT NULL,
status TINYINT NOT NULL,
PRIMARY KEY (user_id, created_at, order_id)
) ENGINE = InnoDB;
SELECT *
FROM orders
WHERE user_id = ?
ORDER BY created_at DESC, order_id DESC
LIMIT 100;
这里可以类比为:
user_id ≈ partition_key
(created_at, order_id) ≈ sort_key
商家索引在 MySQL 中应把分区定位字段和排序字段共同写进联合索引:
CREATE INDEX idx_merchant_orders
ON merchant_orders (
merchant_id,
bucket,
status,
created_at DESC,
order_id
);
概念上 merchant_id 不属于 sort key;但在 MySQL 联合索引中,它必须位于前部,数据库才能先定位商家,再按照后续字段扫描。
如果还需要“不限定状态、按全局时间查询商家订单”,由于 status 位于 created_at 之前,可能需要另一个索引:
CREATE INDEX idx_merchant_orders_by_time
ON merchant_orders (
merchant_id,
bucket,
created_at DESC,
order_id
);
索引字段顺序必须由查询模式决定。
使用 MySQL 原生表分区时还需注意:分区表达式使用的字段必须出现在每一个唯一键中。按 user_id 分区后,单独对 order_id 建立全局唯一约束会受到限制。MySQL 单实例表分区也不能直接替代 DDIA 场景中的多服务器分片和路由层。
十二、学习问答与架构决策
- 最初选择
order_id作为分区键,正确识别订单号点查只访问一个分区,而用户和商家查询会访问多个分区。 - 识别
hash(merchant_id)无法消除头部商家热点,因为同一商家的订单仍进入同一个分区。 - 判断 50% 的订单号点查不能退化为访问全部 256 个分区。
- 进一步选择“按用户分区的主表 + 按订单号分区的定位表”,用两次定向点查替代全分区查询。
- 理解商家多桶查询为什么必须访问全部候选桶:查询条件不知道结果中的订单号,无法预先计算唯一桶。
- 选择稳定的订单号哈希分桶、异步商家索引、并行多桶读取。
- 扩容题中最初认为每个旧节点移出 64 个分区;修正后明确旧节点从 64 个降到 32 个,因此每个旧节点移出约 32 个。
- 选择旧节点返回重定向,让客户端更新缓存并重试。
- 理解
sort_key在 MySQL 中对应联合索引的有序后缀,而不是独立 SQL 关键字。
十三、易错点
- 选择分区字段后,还必须说明采用范围分区还是哈希分区。
- 数据量均衡不代表请求负载均衡。
- 哈希能分散不同键,不能拆开单个热点键。
- 分区数不应简单等于节点数;逻辑分区与物理节点应解耦。
- 本地索引降低写入扇出,但可能把成本转移到读取。
- 全局索引提升读取局部性,但会增加写路径和一致性处理。
- 分桶数量越多,热点写入越分散,读取扇出也越大。
- 异步查询索引不是权威事实来源。
- 迁移完成不等于数据刚复制过去;还必须追平增量写入并安全发布路由。
- MySQL 联合索引中的字段顺序由查询条件和排序需求决定。
十四、复习清单
- 能区分复制与分区
- 能比较范围分区与哈希分区
- 能根据访问模式选择订单主表分区键
- 能解释 scatter/gather 与尾延迟放大
- 能比较本地索引与全局索引
- 能设计稳定哈希分桶并说明读写取舍
- 能区分权威主表、关键定位表和异步查询视图
- 能解释固定逻辑分区的扩容方式
- 能比较三种请求路由方式
- 能处理客户端路由缓存过期
- 能解释 MySQL 中 sort key 的等价实现
十五、一句话复盘
分区设计的核心不是把数据平均切开,而是从真实访问模式出发,让关键请求能够确定路由,同时控制热点、跨分区索引、再平衡和路由变化带来的复杂度。