架构设计

DDIA 第六章:分区|订单系统实践笔记|2026-09-12

以订单系统为贯穿案例,理解范围分区与哈希分区、数据倾斜和热点、文档分区与词项分区二级索引、scatter/gather、固定与动态分区再平衡、请求路由,以及 MySQL 联合索引对 partition key 与 sort key 的对应实现。

🧩 本章核心:分区不是简单地把数据拆开,而是根据访问模式,在负载均衡、查询局部性、索引维护、扩容成本和路由复杂度之间做取舍。 学习日期:2026-09-12 学习方式:订单系统案例 + 架构决策问答

一、全章主线

当数据量或查询吞吐超过单机能力时,需要把不同数据分布到多个分区。设计分区系统时要持续回答五个问题:

  1. :数据按照什么键进入哪个分区?
  2. :非分区键查询如何建立和维护索引?
  3. :数据倾斜和热点键如何处理?
  4. :节点变化时如何安全地再平衡?
  5. :客户端如何找到当前负责目标分区的节点?

可以用“键、索、热、迁、路”记住本章。

二、分区与复制

  • 复制(replication):同一份数据保存多个副本,主要提升可用性、容错能力和读取能力。
  • 分区(partitioning / sharding):不同数据保存到不同分区,主要突破单机容量和处理能力。

实际系统通常同时使用两者:每条数据只属于一个逻辑分区,但该分区可以在多个节点上拥有副本;不同节点分别承担不同分区的 Leader。

理想的分区方案希望同时做到:

  1. 数据量均衡。
  2. 请求负载均衡。
  3. 高频查询只访问一个或少量分区。
  4. 扩容时只搬迁必要的数据。

这些目标通常无法全部最大化,设计的核心是明确优先级。

三、订单系统案例与访问模式

订单包含以下核心字段:

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 个逻辑分区。键到逻辑分区的映射不变,只修改逻辑分区到节点的映射。

安全迁移流程:

  1. 旧节点继续服务。
  2. 将分区历史数据复制到新节点。
  3. 追平迁移期间产生的新写入。
  4. 校验新副本完整性和进度。
  5. 原子发布新的分区归属。
  6. 新请求切换到新节点。
  7. 旧节点重定向或转发迟到请求。

动态分区则根据分区大小进行拆分和合并。它能适应数据总量,但空数据库最初可能只有一个分区,需要预分区避免早期所有写入集中在单节点。

自动再平衡必须限速并限制并发迁移。把暂时过载的节点误判为故障后立即搬迁数据,可能增加网络和剩余节点负载,引发级联故障。

十、请求路由

方式 优点 代价
任意节点接收并转发 客户端简单 数据库节点承担协调和额外网络跳转
独立路由层 统一管理路由 路由层需要高可用,可能增加跳转
分区感知客户端 直接连接目标节点,路径短 客户端和驱动更复杂,必须处理缓存过期

三种方式都需要权威的“逻辑分区 → 节点”元数据。客户端缓存过期时,旧节点可以返回结构化重定向:

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 的等价实现

十五、一句话复盘

分区设计的核心不是把数据平均切开,而是从真实访问模式出发,让关键请求能够确定路由,同时控制热点、跨分区索引、再平衡和路由变化带来的复杂度。

相关学习