“数据量大就分表”不是设计方法。对核心 OLTP 表,普通 InnoDB 单表通常应作为默认起点:先优化 SQL、索引、归档和容量治理,再用证据决定是否引入额外结构。原生分区只有在生命周期或高价值查询与分区键高度一致时才值得评估;业务水平分表则要等单节点容量、吞吐或故障域边界已经明确。

原生分区仍然是一张逻辑表,由一个 MySQL 实例管理;业务水平分表则把路由、一致性、聚合和扩容问题交给应用或中间层。两者解决的问题不同,也带来不同约束。本文不会把“生产很少使用分区”当成无法验证的普遍统计结论,而是通过当前订单模型给出可复核的适用条件与否决条件。

一、本文要解决的问题

  • 原生分区与水平分表的边界在哪里?
  • RANGE 和 HASH 分区分别适合什么访问与生命周期?
  • 分区裁剪为什么不等于查询一定高效?
  • MySQL 8.4 的唯一键和外键限制如何反向影响表设计?
  • 分区维护为什么仍受表级 MDL 和具体 Online DDL 能力约束?
  • 以 user_id 分片后,订单号点查、聚合和扩容如何处理?

二、证据地图

实验 证据 状态
EX-M04-01 分区列必须进入所有唯一键的失败 DDL RESULT_RECORDED
EX-M04-02 RANGE 分区分布与裁剪 RESULT_RECORDED
EX-M04-03 分区 InnoDB 表的外键限制 RESULT_RECORDED
EX-M04-04 HASH(id) 的裁剪与时间查询 RESULT_RECORDED
EX-M04-05 user_id % 4 路由与聚合一致性 RESULT_RECORDED
补充验证 不同分区访问仍共享逻辑表的 MDL NOT_STARTED

三、先把两个概念分开

维度 MySQL 原生分区 业务水平分表
对应用暴露 一张逻辑表 多个路由目标
管理边界 单个 MySQL 实例 可跨表、实例或集群
优化器 可做分区裁剪 单库优化器不知道全局
事务 仍是本实例事务 跨分片事务需额外方案
扩容 增删/重组分区 数据迁移与路由切换
DDL/MDL 边界 一个逻辑表,共享表级 MDL DDL 对目标物理表取得 MDL,可逐片发布但可能产生 schema 漂移
故障与资源边界 仍共享实例资源和故障域 跨实例时可隔离资源和故障,同实例分表不能做到
主要目标 生命周期、裁剪、管理 突破单节点容量/吞吐边界

这张表是架构边界说明;性能收益仍需要结合查询和数据分布验证。

原生分区把一张表的数据与索引按规则放入多个分区,但 SQL 入口、优化器、事务、表定义、MDL、redo/binlog 和实例资源仍在同一个 MySQL Server 内。它常用于按时间管理数据生命周期、快速交换/删除分区,或在谓词与分区表达式匹配时减少需访问的分区。它不会把 CPU、内存、写入或故障域分散到多台机器,也不能把各分区当作可以独立变更 schema 的普通表。

业务水平分表则把数据放到多个逻辑表或实例,应用/中间层必须先计算路由。它可以突破单实例容量和吞吐,同时失去了全局约束、本地事务和单库聚合的简单性。“大表”只是现象;选择前要说清是管理历史数据,还是真正遇到单节点资源边界。

3.1 先给出生产选型基线

原生分区和业务分片都不应成为核心 OLTP 表的默认配置。更稳妥的顺序是先确认问题,再选择能够直接解决该问题的最小方案:

已确认的问题 优先评估 采用条件
普通点查、列表或慢 SQL 普通 InnoDB 表上的 SQL 与索引优化 用执行计划和负载数据验证收益
历史数据按固定时间窗口淘汰 归档流程;满足条件时使用 RANGE 分区 保留周期和分区键一致,并接受唯一键、外键和分区 DDL 约束
单节点容量或吞吐触顶 业务水平分片 大多数请求包含稳定分片键,且具备迁移、回退和一致性治理能力
需要隔离故障域 跨实例或跨集群部署 不能用同实例分表或原生分区代替

就本文的核心 orders 表而言,按 created_at 原生分区不应作为默认生产设计:id 和 order_no 的数据库全局唯一语义会被改变,用户维度和订单号点查又不能据此裁剪时间分区,分区维护还会引入表级 MDL 与 DDL 发布窗口。更合适的候选通常是写入模式简单、按时间保留且主要按时间访问的日志、事件或历史归档表;核心订单表则先保持普通表,在单节点边界得到证明后再评估业务分片。

3.2 公共基线与实验边界

本篇复用第一篇创建的公共 orders 表及其 10000 行数据,但不直接改变公共表结构或数据。开始前先确认公共基线和所有实验对象:

USE mysql_lab;

SELECT COUNT(*) AS orders_count,
       MIN(id) AS min_id,
       MAX(id) AS max_id
FROM orders;

SHOW CREATE TABLE orders\G

SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'mysql_lab'
  AND TABLE_NAME IN (
    'orders_partition_bad',
    'orders_partitioned',
    'orders_hash_partitioned',
    'partition_mdl_lab',
    'orders_00','orders_01','orders_02','orders_03'
  )
ORDER BY TABLE_NAME;

预期 orders_count=10000、min_id=1、max_id=10000,实验表查询结果为空。公共订单状态使用整数编码 10/20/30/40,后续派生表继续沿用该字段定义。

如果任何实验表已经存在,应先确认它是否来自未完成的实验,不要直接覆盖。本文所有 INSERT ... SELECT 都使用显式列清单;所有分区、索引和分片变更只作用于实验副本。还要注意,建表、改表和删表属于 DDL,可能隐式提交当前事务,不应夹在其他待提交事务中执行。

四、EX-M04-01:先保留唯一键失败的 DDL

这个实验只改变一个条件:表按 created_at 分区,但主键和订单号唯一键都不包含 created_at。执行前先写下预测,再原样执行失败 DDL,不要修改 SQL 后只保留成功版本。

CREATE TABLE orders_partition_bad (
  id BIGINT UNSIGNED NOT NULL,
  order_no VARCHAR(32) NOT NULL,
  created_at DATETIME(6) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_order_no (order_no)
) ENGINE=InnoDB
PARTITION BY RANGE COLUMNS(created_at) (
  PARTITION p2024 VALUES LESS THAN ('2025-01-01'),
  PARTITION p2025 VALUES LESS THAN ('2026-01-01'),
  PARTITION pmax VALUES LESS THAN (MAXVALUE)
);
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'mysql_lab'
  AND TABLE_NAME = 'orders_partition_bad';

结果回填:

项目 执行前预测 手工执行结果
DDL 是否成功 否 否
错误码 — 1503
错误消息 — A PRIMARY KEY must include all columns in the table's partitioning function (prefixed columns are not considered).
失败后表是否存在 不存在 不存在

MySQL 8.4 要求分区表达式使用的全部列出现在表的每一个唯一键中,主键也属于唯一键。这里的 PRIMARY KEY(id) 和 UNIQUE(order_no) 都缺少 created_at,因此违反分区唯一键规则。

把它们改成 (id,created_at) 和 (order_no,created_at) 可以满足结构规则,但也改变了约束语义:数据库不再分别保证 id、order_no 单列全局唯一,只保证复合值唯一。如果业务必须保持订单号全局唯一,需要另设不分区的唯一性映射、调整分区键,或放弃这种原生分区设计,不能只以“DDL 能执行”为验收标准。

五、EX-M04-02:RANGE 时间分区与分区内访问

对于按时间清理历史数据的需求,RANGE COLUMNS(created_at) 可以直接使用时间边界。实验从公共 orders 派生完整订单模型,不再引入额外的事件表或 JSON 字段。

5.1 创建 RANGE 分区副本

CREATE TABLE ... LIKE ... 先复制字段、检查约束和索引,再显式调整不满足分区规则的两个唯一键:

CREATE TABLE orders_partitioned LIKE orders;

ALTER TABLE orders_partitioned
  DROP INDEX uk_orders_order_no,
  DROP PRIMARY KEY,
  ADD PRIMARY KEY (id,created_at),
  ADD UNIQUE KEY uk_orders_order_no_created (order_no,created_at);

ALTER TABLE orders_partitioned
PARTITION BY RANGE COLUMNS(created_at) (
  PARTITION p2024 VALUES LESS THAN ('2025-01-01'),
  PARTITION p2025 VALUES LESS THAN ('2026-01-01'),
  PARTITION p2026 VALUES LESS THAN ('2027-01-01'),
  PARTITION pmax VALUES LESS THAN (MAXVALUE)
);

SHOW CREATE TABLE orders_partitioned\G

确认分区定义与唯一键列顺序后,再复制公共数据:

INSERT INTO orders_partitioned
  (id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at)
SELECT id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at
FROM orders
ORDER BY id;

ANALYZE TABLE orders_partitioned;

SELECT COUNT(*) AS copied_rows
FROM orders_partitioned;

预期复制 10000 行。这里的 MAXVALUE 是分区语法中的特殊上界标记,不是可以在普通 SQL 中查询或计算的内置常量。pmax 接收 created_at >= '2027-01-01' 的数据,可以避免未来时间越界时立即写入失败,但不会自动创建年度分区,也可能掩盖新分区未按时创建的问题。

生产设计需要在两种失败模式之间明确取舍:保留 pmax 可以兜底越界写入,但拆分它需要 REORGANIZE PARTITION;不保留 pmax,可以预建多个具有有限上界的未来分区并继续使用 ADD PARTITION,但维护任务失效且时间越过最后边界后,新写入会因为找不到目标分区而失败。通常应至少预建若干未来周期、监控剩余分区窗口和各分区行数;若选择 pmax,还要监控其中是否出现数据,并为拆分操作预留受控窗口。

5.2 分区维护仍是表级 DDL

“删除一个分区比逐行 DELETE 更适合淘汰历史数据”只描述了数据处理方式,不代表操作没有锁,也不代表每个分区拥有独立的 schema。MySQL 8.4 对常见 InnoDB 分区操作的 Online DDL 能力并不相同:

分区操作 In-place 允许并发 DML 生产含义
RANGE/LIST ADD PARTITION 是 是 不复制现有数据;适合在有限上界后追加新分区
RANGE/LIST DROP PARTITION 是 是 INPLACE 时不复制现有数据,但会直接删除目标分区数据
TRUNCATE PARTITION 是 是 清空目标分区数据但保留分区定义
REORGANIZE PARTITION 是 否 拆分 pmax、合并或调整范围时需要写入窗口
OPTIMIZE PARTITION 否 否 对 InnoDB 会重建并分析整张分区表,不是只优化一个分区
PARTITION BY / REMOVE PARTITIONING 否 否 使用 COPY 重分布整表数据

这张表来自 MySQL 8.4 的 Online DDL 分区操作支持矩阵。实际执行时仍要根据目标版本、表结构和具体语句显式验证支持的 ALGORITHM 与 LOCK,不能根据“INPLACE”这个名字推断操作一定轻量。

更重要的是,分区表仍只有一个表定义。普通事务即使通过 PARTITION (p2025) 只访问一个分区,取得的元数据锁仍保护逻辑表;另一个会话针对 p2024 发起 ALTER TABLE ... DROP PARTITION 时,不能绕开这个表级 MDL。Online DDL 初始化时取得 shared-upgradable MDL,执行阶段可能短暂升级为排他锁,提交表定义时必须升级为排他 MDL;任一分区上的长事务都可能让升级等待,而已经排队的排他 MDL 又会阻塞后续访问同一表的请求。Online DDL 与 Metadata Lock

因此,LOCK=NONE 的准确含义是“该算法在主要执行阶段允许并发查询和 DML”,不是“整个 DDL 生命周期没有锁”。生产维护至少需要:

  • 检查长事务、performance_schema.metadata_locks 和副本状态;
  • 为执行会话设置有界的 lock_wait_timeout,超时后停止并重新评估,不使用 innodb_lock_wait_timeout 代替 MDL 超时;
  • 显式指定已验证的 ALGORITHM 和 LOCK,不接受意外回退到更重的方式;
  • 监控业务延迟、连接堆积、磁盘与 I/O、binlog 和复制延迟;
  • 在 DROP/TRUNCATE PARTITION 前确认备份、恢复路径、时间边界和目标分区行数。

通用的 Online DDL 三阶段、等待链、资源和回滚治理将在后续《MySQL 8.4 实战(十):在线 DDL、MDL 与结构变更》中展开;本篇后面的补充实验只验证分区特有的事实:访问不同分区并不能隔离表级 MDL。

5.3 核对分区分布

INFORMATION_SCHEMA.PARTITIONS.TABLE_ROWS 是统计估算,适合同时查看容量;需要精确行数时,应直接指定分区执行 COUNT(*):

SELECT PARTITION_NAME,TABLE_ROWS,DATA_LENGTH,INDEX_LENGTH
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'mysql_lab'
  AND TABLE_NAME = 'orders_partitioned'
ORDER BY PARTITION_ORDINAL_POSITION;

SELECT 'p2024' AS partition_name,COUNT(*) AS exact_rows
FROM orders_partitioned PARTITION (p2024)
UNION ALL
SELECT 'p2025',COUNT(*) FROM orders_partitioned PARTITION (p2025)
UNION ALL
SELECT 'p2026',COUNT(*) FROM orders_partitioned PARTITION (p2026)
UNION ALL
SELECT 'pmax',COUNT(*) FROM orders_partitioned PARTITION (pmax);

根据第一篇的固定造数规则,执行前预测为:

分区 时间范围 预测精确行数 实际精确行数
p2024 created_at < '2025-01-01' 5123 5123
p2025 2025 年 4877 4877
p2026 2026 年 0 0
pmax 2027 年及以后 0 0

精确计数合计为 5123 + 4877 = 10000,与复制后的总行数一致;p2026 和 pmax 都为 0,说明当前种子数据没有越过 2025 年。这个结果只证明数据按边界正确路由,不能证明时间查询一定走索引,也不能说明各分区在生产数据下仍会均匀。

5.4 用四类查询拆开裁剪与索引

第一类查询只提供时间范围,用于观察分区裁剪本身:

EXPLAIN FORMAT=TRADITIONAL
SELECT id,order_no,total_amount
FROM orders_partitioned
WHERE created_at >= '2024-05-01'
  AND created_at <  '2024-06-01';

EXPLAIN ANALYZE
SELECT id,order_no,total_amount
FROM orders_partitioned
WHERE created_at >= '2024-05-01'
  AND created_at <  '2024-06-01';

第二类查询增加现有联合索引的等值前缀,用于观察“裁剪一个分区后继续做索引范围访问”:

EXPLAIN FORMAT=TRADITIONAL
SELECT id,order_no,total_amount,created_at
FROM orders_partitioned
WHERE tenant_id = 2
  AND state = 20
  AND created_at >= '2024-05-01'
  AND created_at <  '2024-06-01'
ORDER BY created_at,id;

EXPLAIN ANALYZE
SELECT id,order_no,total_amount,created_at
FROM orders_partitioned
WHERE tenant_id = 2
  AND state = 20
  AND created_at >= '2024-05-01'
  AND created_at <  '2024-06-01'
ORDER BY created_at,id;

第三类查询对分区列使用函数,作为直接半开区间的对照:

EXPLAIN FORMAT=TRADITIONAL
SELECT id,order_no,total_amount
FROM orders_partitioned
WHERE YEAR(created_at) = 2024;

EXPLAIN ANALYZE
SELECT id,order_no,total_amount
FROM orders_partitioned
WHERE YEAR(created_at) = 2024;

第四类查询只使用分区键之外的 user_id:

EXPLAIN FORMAT=TRADITIONAL
SELECT id,order_no,created_at
FROM orders_partitioned
WHERE user_id = 1234;

EXPLAIN ANALYZE
SELECT id,order_no,created_at
FROM orders_partitioned
WHERE user_id = 1234;

结果回填:

查询 预测分区 实际 partitions type/key TRADITIONAL rows ANALYZE 实际读取/输出
2024 年 5 月时间范围 p2024 p2024 ALL/NULL 5123 5123/434
租户、状态和时间范围 p2024 p2024 range/idx_orders_tenant_state_created 28 28/28
YEAR(created_at)=2024 p2024,p2025,p2026,pmax p2024,p2025,p2026,pmax ALL/NULL 10000 10000/5123
user_id=1234 不能仅凭该条件裁剪时间分区 p2024,p2025,p2026,pmax ref/idx_orders_user_created 1 1/1¹

¹ EXPLAIN ANALYZE 的 rows=1 loops=1 表示该索引查找迭代器最终产生 1 行,不代表优化器只探测了一个时间分区;实际 partitions 已明确列出四个分区。

5.4.1 直接时间范围:完成裁剪,但分区内仍是全扫描

第一条 SQL 的 partitions=p2024 证明半开时间范围成功排除了其他三个分区。进入 p2024 后,现有索引没有以 created_at 开头,所以执行器仍然扫描该分区的 5123 行,再由 Filter 输出 434 行。

树形计划中的 cost=520 rows=569 是过滤后的估算输出,不是实际行数;真正的实测结果是 Table scan 的 actual rows=5123 和 Filter 的 actual rows=434。估算 569 比实际 434 高约 31%,但本实验更重要的结论是“裁剪成功”和“分区内没有合适索引”同时成立。

5.4.2 租户、状态和时间范围:裁剪与联合索引同时生效

第二条 SQL 同样只访问 p2024,并使用 (tenant_id,state,created_at,id) 做索引范围扫描。tenant_id=2、state=20 构成等值前缀,随后 created_at 使用范围条件;估算与实际均为 28 行。

Extra=Using index condition 表示使用索引条件下推,不等于覆盖索引。查询还要返回 order_no 和 total_amount,这两列不在该二级索引中,因此仍需读取对应聚簇记录。计划没有出现 Using filesort,说明当前索引顺序可以直接提供 ORDER BY created_at,id 所需顺序。

本次单次执行的实际时间约为 8.72~8.81 ms,反而高于第一条全扫描;这可能受到缓存状态、首次页访问和回表影响。两条 SQL 的过滤条件与返回行数也不同,因此不能用这两个单次时间证明索引范围扫描更慢,性能结论需要相同查询、单变量索引对照和多轮测量。

5.4.3 对分区列使用函数:没有发生分区裁剪

第三条 SQL 的 partitions 包含全部四个分区,执行器扫描 10000 行,再由 YEAR(created_at)=2024 过滤出 5123 行。这里实际结果已经证明:在当前 MySQL 8.4 环境和这条表达式下,优化器没有把 YEAR(created_at)=2024 改写成可裁剪的原始列范围。

如果业务目标是查询 2024 年,应优先写成:

WHERE created_at >= '2024-01-01'
  AND created_at <  '2025-01-01'

这种半开区间既直接表达时间边界,也能与 RANGE COLUMNS(created_at) 的分区边界匹配,并为以 created_at 为有效前缀的 B+Tree 索引保留范围访问机会。

5.4.4 只按用户查询:索引有效,但时间分区没有帮助

第四条 SQL 无法根据 user_id=1234 推导 created_at,所以 partitions 仍列出全部四个分区。优化器可以在各分区的本地 idx_orders_user_created 中执行 ref 查找,最终返回 1 行,因此查询不必全表扫描;但是分区裁剪没有贡献。

分区数量较少时,多分区索引探测的额外成本可能不明显;随着分区数量增加,初始化和探测更多分区的成本也会增加。这个结果说明按时间分区不会自然优化用户维度查询,用户订单列表仍依赖 (user_id,created_at,id) 这类访问路径。

四类查询共同说明:分区裁剪回答“访问哪些分区”,索引回答“进入分区后怎样找行”,两者不能互相替代。阅读计划时,partitions 用于确认是否排除了无关分区,type/key 用于确认保留分区内的访问路径,而 EXPLAIN ANALYZE 的迭代器 rows/loops 才说明本次实际产生和传递了多少行,不能用其中任意一项代替另外两项。

5.5 记录被改变的唯一性语义

orders_partitioned 复用了公共订单字段,但不是公共表的等价替代品:

  • (id,created_at) 只保证这两个值组合唯一,不再由数据库单独保证 id 全局唯一;
  • (order_no,created_at) 允许相同订单号在不同时间出现;
  • 二级索引记录还会携带更宽的复合主键,索引空间与写入成本也可能增加。

这些差异必须写入设计结论。分区副本的作用是验证分区机制,不代表生产订单表已经可以无损改成相同结构。

5.6 本节结论与证据状态

  • OBSERVED:10000 行数据全部进入预期分区,p2024=5123、p2025=4877,合计与源表一致。
  • OBSERVED:直接时间范围只访问 p2024,但由于缺少以 created_at 开头的索引,仍扫描分区内 5123 行并输出 434 行。
  • OBSERVED:增加 tenant_id、state 等值前缀后,只访问 p2024,并通过联合索引范围扫描读取和输出 28 行。
  • OBSERVED:YEAR(created_at)=2024 没有裁剪分区,扫描全部 10000 行后输出 5123 行。
  • OBSERVED:user_id=1234 使用二级索引返回 1 行,但必须探测所有不能排除的时间分区。
  • INFERRED:该表结构适合验证时间生命周期和裁剪机制,却改变了 id、order_no 的全局唯一性语义,不能直接替换公共订单表。

截图、精确分区计数和四组执行计划已经形成 EX-M04-02 的主体证据,因此状态记为 RESULT_RECORDED。待 MDL 补充验证和本文第十节的清理与公共表验收完成后,再统一升级为 PASSED。

5.7 补充验证:访问不同分区仍共享表级 MDL

使用独立实验表

这个实验需要三个会话并会删除 p_history 中的数据,只能在可丢弃的 partition_mdl_lab 上执行。不要复用公共 orders 或前面的结果表,也不要在无法识别连接用途时直接 KILL 会话。

先确认 performance_schema 的 MDL 采集已启用,再创建只含三条记录的独立分区表:

SELECT NAME,ENABLED,TIMED
FROM performance_schema.setup_instruments
WHERE NAME = 'wait/lock/metadata/sql/mdl';

CREATE TABLE partition_mdl_lab (
  id BIGINT UNSIGNED NOT NULL,
  created_at DATETIME(6) NOT NULL,
  payload VARCHAR(32) NOT NULL,
  PRIMARY KEY (id,created_at)
) ENGINE=InnoDB
PARTITION BY RANGE COLUMNS(created_at) (
  PARTITION p_history VALUES LESS THAN ('2025-01-01'),
  PARTITION p_current VALUES LESS THAN ('2026-01-01'),
  PARTITION pmax VALUES LESS THAN (MAXVALUE)
);

INSERT INTO partition_mdl_lab (id,created_at,payload) VALUES
  (1,'2024-06-01 00:00:00','history'),
  (2,'2025-06-01 00:00:00','current'),
  (3,'2026-06-01 00:00:00','future');

如果 MDL instrument 未启用,应先在隔离环境按权限规范启用后再继续;没有 metadata_locks 证据时,不把等待状态写成已观察结论。

会话 A 开启事务,只访问不会被删除的 p_current,然后保持事务打开:

START TRANSACTION;
SELECT CONNECTION_ID() AS session_a;

SELECT *
FROM partition_mdl_lab PARTITION (p_current)
WHERE id = 2;
-- 暂不 COMMIT 或 ROLLBACK

会话 B 设置有界的 MDL 等待时间,再尝试删除另一个分区 p_history:

SET SESSION lock_wait_timeout = 30;
SELECT CONNECTION_ID() AS session_b;

ALTER TABLE partition_mdl_lab
  ALGORITHM=INPLACE,
  LOCK=NONE,
  DROP PARTITION p_history;

lock_wait_timeout 控制元数据锁等待;innodb_lock_wait_timeout 只控制 InnoDB 行锁等待,不能替代它。预期会话 B 等待 A 释放逻辑表上的 MDL。虽然 A 和 B 指定的是不同分区,DDL 的对象仍是 partition_mdl_lab 这张表。

确认 B 已进入等待后,会话 C 再查询未被删除的 p_current:

SET SESSION lock_wait_timeout = 30;
SELECT CONNECTION_ID() AS session_c;

SELECT COUNT(*)
FROM partition_mdl_lab PARTITION (p_current);

回到仍保持事务的会话 A,查询锁和线程状态:

SELECT ml.OBJECT_TYPE,ml.OBJECT_SCHEMA,ml.OBJECT_NAME,
       ml.LOCK_TYPE,ml.LOCK_DURATION,ml.LOCK_STATUS,
       ml.OWNER_THREAD_ID,
       t.PROCESSLIST_ID,t.PROCESSLIST_STATE,t.PROCESSLIST_INFO
FROM performance_schema.metadata_locks AS ml
LEFT JOIN performance_schema.threads AS t
  ON t.THREAD_ID = ml.OWNER_THREAD_ID
WHERE ml.OBJECT_SCHEMA = 'mysql_lab'
  AND ml.OBJECT_NAME = 'partition_mdl_lab'
ORDER BY t.PROCESSLIST_ID,ml.LOCK_STATUS,ml.LOCK_TYPE;

SELECT trx_id,trx_mysql_thread_id,trx_started,trx_state,trx_query
FROM information_schema.innodb_trx
WHERE trx_mysql_thread_id IN (
  /* 替换为会话 A、B、C 的 CONNECTION_ID() */
  101,102,103
);

metadata_locks 是这次验证的主要证据。B、C 如果尚未进入 InnoDB 层,可能不会出现在 innodb_trx 中;不能因为该表只有 A,就判定 B、C 没有等待 MDL。

待回填:

角色 访问对象 预期 MDL/语句状态 手工执行结果
A 长事务 p_current 在逻辑表上持有已授予 MDL 待执行
B 分区 DDL p_history 在同一逻辑表上出现等待中的 MDL 请求 待执行
C 后续查询 p_current B 的排他请求排队后,查询继续等待 待执行

保存证据后在 A 执行 ROLLBACK。如果 B 尚未因 30 秒超时退出,预期 B 随后完成删除,C 再返回 p_current 的计数;若 B 已超时,应记录实际错误并重新创建实验表,不要直接把预测填为实测。最后确认表定义和数据:

SELECT PARTITION_NAME,TABLE_ROWS
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'mysql_lab'
  AND TABLE_NAME = 'partition_mdl_lab'
ORDER BY PARTITION_ORDINAL_POSITION;

SELECT * FROM partition_mdl_lab ORDER BY id;

这个实验只用于证明 MDL 的对象边界。DROP PARTITION 的主要执行阶段是否允许并发 DML、排他锁实际等待多久,以及后续查询是否形成等待链,必须分别记录,不能把“表级 MDL”误写成“整个 DDL 全程排他锁表”。

六、EX-M04-03:分区 InnoDB 表的外键限制

orders_partitioned 已经保留 user_id 索引,而且字段类型与 users.id 一致。先预测下面的 DDL 是否成功,再原样执行:

ALTER TABLE orders_partitioned
  ADD CONSTRAINT fk_orders_partitioned_user
  FOREIGN KEY (user_id) REFERENCES users(id);

失败后查询数据字典,确认约束没有残留:

SELECT CONSTRAINT_NAME,CONSTRAINT_TYPE
FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = 'mysql_lab'
  AND TABLE_NAME = 'orders_partitioned'
  AND CONSTRAINT_NAME = 'fk_orders_partitioned_user';

结果回填:

项目 执行前预测 手工执行结果
外键 DDL 是否成功 否 否
错误码 — 1506
错误消息 — Foreign keys are not yet supported in conjunction with partitioning
数据字典是否存在该约束 不存在 不存在

MySQL 8.4 中,用户定义分区的 InnoDB 表不能包含外键,也不能成为其他 InnoDB 外键引用的目标。这是 MySQL 与 InnoDB 的产品限制,不是“所有数据库的分区表都不能使用外键”。

M01 已基于服务边界、写入路径、复制并行和后续分片需求,选择由业务事务、幂等、受控删除、孤儿扫描和告警维护逻辑关联。分区外键限制只是说明该模型不会在这里额外遇到 DDL 冲突,不能倒过来作为最初移除外键的原因。如果现有系统依赖外键而应用侧没有替代机制,就不能为了分区直接丢掉完整性保护。

错误码、错误消息和数据字典无残留结果已经记录,EX-M04-03 状态为 RESULT_RECORDED;仍需完成统一清理验收后才能记为 PASSED。

七、EX-M04-04:HASH(id) 与 RANGE(created_at) 对照

HASH 分区更适合验证“已知分区键常量时可以定位哪个分区”,但它不能直接提供按时间淘汰历史数据的能力。本实验固定使用 4 个 HASH 分区,与后面的四张业务分片保持相同数量。

7.1 创建 HASH 分区副本

公共表的 PRIMARY KEY(id) 已经包含 HASH 分区键,只需要处理不含 id 的订单号唯一键:

CREATE TABLE orders_hash_partitioned LIKE orders;

ALTER TABLE orders_hash_partitioned
  DROP INDEX uk_orders_order_no,
  ADD UNIQUE KEY uk_orders_order_no_id (order_no,id);

ALTER TABLE orders_hash_partitioned
PARTITION BY HASH(id) PARTITIONS 4;

SHOW CREATE TABLE orders_hash_partitioned\G

INSERT INTO orders_hash_partitioned
  (id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at)
SELECT id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at
FROM orders
ORDER BY id;

ANALYZE TABLE orders_hash_partitioned;

核对精确分布:

SELECT 'p0' AS partition_name,COUNT(*) AS exact_rows
FROM orders_hash_partitioned PARTITION (p0)
UNION ALL
SELECT 'p1',COUNT(*) FROM orders_hash_partitioned PARTITION (p1)
UNION ALL
SELECT 'p2',COUNT(*) FROM orders_hash_partitioned PARTITION (p2)
UNION ALL
SELECT 'p3',COUNT(*) FROM orders_hash_partitioned PARTITION (p3);

公共 ID 连续覆盖 1~10000,因此预测四个分区各有 2500 行:

分区 预测行数 实际行数
p0 2500 2500
p1 2500 2500
p2 2500 2500
p3 2500 2500

7.2 主键点查对照

在 RANGE 和 HASH 副本上执行完全相同的主键查询:

EXPLAIN FORMAT=TRADITIONAL
SELECT * FROM orders_partitioned WHERE id = 1234;

EXPLAIN ANALYZE
SELECT * FROM orders_partitioned WHERE id = 1234;

EXPLAIN FORMAT=TRADITIONAL
SELECT * FROM orders_hash_partitioned WHERE id = 1234;

EXPLAIN ANALYZE
SELECT * FROM orders_hash_partitioned WHERE id = 1234;

1234 MOD 4 = 2,因此预测 HASH 表只访问 p2。RANGE 表的分区键是 created_at,仅有 id 时不能计算目标时间分区,即使每个分区内部都能使用主键前缀点查,也仍需检查不能被排除的分区。

7.3 时间范围对照

再对两张表执行相同的时间查询:

EXPLAIN FORMAT=TRADITIONAL
SELECT id,order_no,total_amount
FROM orders_partitioned
WHERE created_at >= '2024-05-01'
  AND created_at <  '2024-06-01';

EXPLAIN ANALYZE
SELECT id,order_no,total_amount
FROM orders_partitioned
WHERE created_at >= '2024-05-01'
  AND created_at <  '2024-06-01';

EXPLAIN FORMAT=TRADITIONAL
SELECT id,order_no,total_amount
FROM orders_hash_partitioned
WHERE created_at >= '2024-05-01'
  AND created_at <  '2024-06-01';

EXPLAIN ANALYZE
SELECT id,order_no,total_amount
FROM orders_hash_partitioned
WHERE created_at >= '2024-05-01'
  AND created_at <  '2024-06-01';

预测 RANGE 表只访问 p2024,HASH 表则需要访问全部四个分区,因为时间条件不能推导 id MOD 4。

7.4 汇总取舍

RANGE 与 HASH 是两张独立的实验表,必须分别记录访问路径和实际读取量,不能共用一个“实际扫描/输出行数”列:

查询 RANGE 实际分区 RANGE 访问路径(type/key) RANGE 实际读取/输出 HASH 实际分区 HASH 访问路径(type/key) HASH 实际读取/输出
id=1234 p2024,p2025,p2026,pmax ref/PRIMARY 1/1¹ p2 const/PRIMARY 1/1²
2024 年 5 月时间范围 p2024 ALL/NULL 5123/434 p0,p1,p2,p3 ALL/NULL 10000/434

¹ RANGE 表的 actual rows=1 是索引查找迭代器最终产生的行数,不表示只检查了一个分区。由于条件中没有 created_at,partitions 仍列出四个不能排除的时间分区。

² HASH 表的 Rows fetched before execution 表示优化器把 const 表的唯一一行提前读取,并不是查询命中了某种结果缓存。id=1234 可以直接计算到 p2,因此只需在该分区执行主键查找。

FORMAT=TRADITIONAL 中,主键点查的 RANGE、HASH rows 均估算为 1;时间范围查询则分别估算为 5123 和 10000。这些是优化器估算值。表中的“实际读取/输出”来自 EXPLAIN ANALYZE:底层 Table scan 的 actual rows 表示读取行数,上层 Filter 的 actual rows 表示过滤后的输出行数。

主键点查最终都只输出 1 行,但访问范围并不相同:RANGE 表不能根据 id 判断时间分区,只能在所有候选分区的主键索引中查找;HASH 表可以根据 id 直接裁剪到 p2。因此,不能只看最终输出行数判断分区裁剪是否有效。

时间范围查询最终都输出相同的 434 行。RANGE 表先裁剪到 p2024,实际读取 5123 行;HASH 表无法根据时间条件推导 id MOD 4,需要扫描四个分区的全部 10000 行。两张表都没有以 created_at 开头的索引,所以差异主要来自分区裁剪,而不是不同的分区内索引路径。

HASH(id) 的均匀分布只说明这批连续 ID 被平均放置,不代表它天然提高所有查询性能。它有利于已知 ID 的分区裁剪,却不能按时间快速删除旧分区。相反,RANGE(created_at) 服务时间裁剪和生命周期管理,但仅有 ID 时无法直接定位时间分区。

本组实验说明,分区设计的收益必须结合主要访问条件判断:HASH(id) 缩小主键点查的分区范围,RANGE(created_at) 缩小时间查询和生命周期操作的范围。两者都不能替代合适的分区内索引。

uk_orders_order_no_id(order_no,id) 同样改变了公共表原有的唯一性语义:它不能单独阻止相同 order_no 配合不同 id 重复出现。分区键应由主要管理和访问维度决定,而不是只追求表面均匀或为了通过唯一键检查。

八、EX-M04-05:四张业务分片与最小一致性验收

这个实验在同一个实例中用四张普通 InnoDB 表模拟业务分片:

shard_id = MOD(user_id,4)
physical table = orders_00 ... orders_03

它只验证确定性路由和拆分前后的结果一致性,不验证跨实例网络、独立故障域、分布式事务或真实扩容吞吐。

8.1 创建并填充四张分片表

四张表都从公共 orders 派生,保留原字段、主键、订单号唯一键和查询索引:

CREATE TABLE orders_00 LIKE orders;
CREATE TABLE orders_01 LIKE orders;
CREATE TABLE orders_02 LIKE orders;
CREATE TABLE orders_03 LIKE orders;

INSERT INTO orders_00
  (id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at)
SELECT id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at
FROM orders WHERE MOD(user_id,4) = 0;

INSERT INTO orders_01
  (id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at)
SELECT id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at
FROM orders WHERE MOD(user_id,4) = 1;

INSERT INTO orders_02
  (id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at)
SELECT id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at
FROM orders WHERE MOD(user_id,4) = 2;

INSERT INTO orders_03
  (id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at)
SELECT id,order_no,tenant_id,user_id,state,total_amount,created_at,updated_at
FROM orders WHERE MOD(user_id,4) = 3;

ANALYZE TABLE orders_00,orders_01,orders_02,orders_03;

当前公共数据恰好是一名用户一笔订单,且用户 ID 连续覆盖 1~10000,因此四张表预计各有 2500 行。这只是人工数据分布带来的现象,不是生产系统必须满足的一致性条件;验收重点应是总量守恒、路由正确和聚合一致。

8.2 最小一致性验收

先把四个分片合并,同时检查总行数、不同主键数、不同订单号数和错路由数。仅比较四张表的总行数不够,因为重复写入和数据遗漏可能互相抵消:

WITH sharded_orders AS (
  SELECT 0 AS shard_id,id,order_no,user_id FROM orders_00
  UNION ALL
  SELECT 1,id,order_no,user_id FROM orders_01
  UNION ALL
  SELECT 2,id,order_no,user_id FROM orders_02
  UNION ALL
  SELECT 3,id,order_no,user_id FROM orders_03
)
SELECT (SELECT COUNT(*) FROM orders) AS source_rows,
       COUNT(*) AS sharded_rows,
       COUNT(DISTINCT id) AS distinct_ids,
       COUNT(DISTINCT order_no) AS distinct_order_nos,
       SUM(shard_id <> MOD(user_id,4)) AS misrouted_rows
FROM sharded_orders;

再让每个分片按状态完成局部聚合,由协调层合并结果,并与公共表的同口径聚合对照:

SELECT state,
       SUM(order_count) AS order_count,
       SUM(total_amount) AS total_amount
FROM (
  SELECT state,COUNT(*) AS order_count,SUM(total_amount) AS total_amount
  FROM orders_00 GROUP BY state
  UNION ALL
  SELECT state,COUNT(*),SUM(total_amount)
  FROM orders_01 GROUP BY state
  UNION ALL
  SELECT state,COUNT(*),SUM(total_amount)
  FROM orders_02 GROUP BY state
  UNION ALL
  SELECT state,COUNT(*),SUM(total_amount)
  FROM orders_03 GROUP BY state
) AS shard_aggregate
GROUP BY state
ORDER BY state;

SELECT state,COUNT(*) AS order_count,SUM(total_amount) AS total_amount
FROM orders
GROUP BY state
ORDER BY state;

结果回填:

验收项 预测 手工执行结果
公共表/四表合计行数 10000/10000 10000/10000
不同 id/order_no 数 10000/10000 10000/10000
错路由行数 0 0
状态聚合与公共表一致 是 是

8.3 验收结论与边界

这些验收分别回答:数据是否完整落入分片、业务键是否重复、每行是否符合当前路由规则,以及跨片计算能否还原公共表结果。全部符合预期后,才能说明这次静态拆分在当前样本上保持了一致性;它们不能证明并发写入、故障重试和扩容切换同样安全。

生产写入必须由统一的应用或中间层计算路由,并记录路由算法版本。不同语言实现还要统一 user_id 的整数宽度、溢出处理和取模语义,避免同一个 ID 被算到不同分片。

每张分片表中的 uk_orders_order_no 只保证单表内唯一;相同订单号仍可能同时出现在不同分片。全局唯一订单号必须由生成规则、全局唯一性服务或路由索引另行保证,不能从“每张表都有唯一键”推导出来。

路由、键数量和状态聚合结果已经记录,EX-M04-05 状态为 RESULT_RECORDED;这仍只是单实例静态拆分证据,不是跨实例生产能力证明。

九、分片后的三个难题

9.1 订单号点查

如果路由键是 user_id,仅有 order_no 时需要:

  • 订单号编码分片信息;或
  • 维护订单号到分片的路由索引;或
  • 广播查询并承担尾延迟与资源成本。

将分片位编码进订单号可以无额外查询路由,但会把拓扑暴露给外部协议,扩容时很难改变历史编码。全局路由表解耦了订单号和分片算法,却成为一个高可用的额外索引系统。广播查询实现最简单,但分片数增长后会同时放大连接、CPU 和尾延迟。应在接口设计时决定,不要在查询超时后才加一层循环。

9.2 跨分片分页

每个分片局部 ORDER BY ... LIMIT 后,应用还要做全局归并。深分页会让每个分片读取更多候选行,应优先考虑稳定游标而非全局 offset。

游标需要使用全局稳定的排序键,例如 (created_at,id)。应用向每个分片请求“小于上一页末尾游标的前 N 条”,再用最小堆或多路归并取全局 N 条。需同时定义分页期间新写入的可见性;如果要求一致快照,只传游标不足以解决跨实例快照。

9.3 从 4 扩到 8

简单 % N 会让大量 key 改变目标。对当前连续的 user_id=1~10000,从 %4 改成 %8 时,余数为 4~7 的 5000 个用户会改变目标分片,迁移比例恰好是 50%。扩容不应是一次“改 4 为 8”的发布,而应是可回滚的迁移状态机:

PREPARE    创建新分片,发布带版本路由层
COPY       按范围搬迁历史数据,记录水位
CATCH_UP   通过双写或 CDC 同步增量
VERIFY     对比行数、业务键、聚合和抽样哈希
SWITCH     切换读路由,保留快速回退窗口
CLEANUP    停止旧写入,延迟删除旧数据

双写不是天然原子的;部分成功时必须通过幂等重试和对账修复。CDC 可以减少业务双写逻辑,但需要处理日志位点、延迟、重复事件和 schema 变更。切换前要冻结路由规则版本,否则不同应用实例可能把同一用户写到两个目标。

十、实验清理与选型顺序

10.1 清理所有实验副本

五个 M04 实验和 MDL 补充验证全部记录完成、三个会话均已退出后,删除分区与分片副本:

DROP TABLE IF EXISTS
  orders_partition_bad,
  orders_partitioned,
  orders_hash_partitioned,
  partition_mdl_lab,
  orders_00,orders_01,orders_02,orders_03;

SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'mysql_lab'
  AND TABLE_NAME IN (
    'orders_partition_bad',
    'orders_partitioned',
    'orders_hash_partitioned',
    'partition_mdl_lab',
    'orders_00','orders_01','orders_02','orders_03'
  )
ORDER BY TABLE_NAME;

SELECT COUNT(*) AS orders_count,
       MIN(id) AS min_id,
       MAX(id) AS max_id
FROM orders;

SHOW INDEX FROM orders;

实验表查询应为空,公共 orders 仍应为 10000 行,主键、订单号唯一键以及两个查询索引保持不变。只有失败 DDL、执行计划、分区分布、路由一致性、MDL 等待链和清理结果全部保存后,才能把已记录实验从 RESULT_RECORDED 更新为 PASSED,并把补充验证从 NOT_STARTED 更新为 PASSED。

10.2 固定选型顺序

先确认问题是查询、生命周期还是单节点容量
→ 优化 SQL、索引、归档和冷热数据管理
→ 用容量与吞吐数据证明单节点边界
→ 生命周期与分区键一致,且能接受唯一键、外键与 DDL/MDL 约束时评估原生分区
→ 单节点边界明确后再设计业务分片
→ 同时设计路由、全局查询、扩容、回退和一致性校验

原生分区仍共享单实例的 CPU、内存、日志和故障域,不能解决真正的单节点资源上限。业务分片虽然能分散容量和负载,却会把全局唯一性、跨片查询、事务、扩容和故障处理交给应用或中间层。两者都不是“表很大”的自动答案。

十一、综合设计:同时支持用户订单列表与订单号点查

假设订单系统以 user_id 为主分片键,并同时提供两类接口:

接口 A:按 user_id 查询最近订单
接口 B:只提供 order_no 查询订单详情

接口 A 已知分片键,可以直接路由;接口 B 缺少 user_id,必须增加反向定位能力。这里的“路由”由应用或中间层完成,不是 MySQL 优化器的分区裁剪。

11.1 固定逻辑桶,不把物理分片数写进订单号

生产路由不宜长期使用 MOD(user_id,4) 直接绑定物理表。可以先计算数量固定的逻辑桶,再通过带版本的映射表找到物理分片:

bucket_id = MOD(user_id,1024)
(bucket_id, route_version) -> shard_id

初始阶段可令 shard_id=MOD(bucket_id,4),与当前四分片结果一致;扩容时只迁移部分桶,不需要改变 user_id,也不用重写订单号。1024 只是示例,桶数量应在上线前根据预计规模确定。

订单号建议保持不透明,可按“时间片 + 受租约保护的节点号 + 单调序列”生成,但不要编码永久物理分片号。生成算法负责避免常规碰撞,全局路由索引上的主键负责最终拒绝重复值。

11.2 两条读取路径

用户订单列表继续使用普通非唯一索引 idx_orders_user_created(user_id,created_at,id)。同一用户可以有多笔订单,因此这里不应建立唯一索引;索引中同一 user_id 的记录继续按 created_at,id 排列,倒序列表可以反向扫描,id 则用于打破相同时间的排序并构造稳定游标:

SELECT id,order_no,state,total_amount,created_at
FROM orders_XX
WHERE user_id = ?
  AND (created_at,id) < (?,?)
ORDER BY created_at DESC,id DESC
LIMIT 20;

第一页省略游标条件。路由层根据 user_id 得到逻辑桶和当前物理分片,SQL 只在一个目标分片执行。

订单号点查使用独立的全局路由索引。路由记录保存 order_no → user_id/bucket_id,再由桶映射解析当前物理分片;这样移动逻辑桶时不必更新其中每一笔订单的物理分片号:

CREATE TABLE order_route (
  order_no     VARCHAR(32) NOT NULL,
  user_id      BIGINT UNSIGNED NOT NULL,
  bucket_id    SMALLINT UNSIGNED NOT NULL,
  state        TINYINT UNSIGNED NOT NULL
               COMMENT '10=preparing,20=active,30=cancelled',
  created_at   DATETIME(6) NOT NULL,
  updated_at   DATETIME(6) NOT NULL,
  PRIMARY KEY (order_no),
  KEY idx_order_route_user (user_id)
) ENGINE=InnoDB;

读取时只接受 state=20 的路由记录,再到目标分片使用本地 uk_orders_order_no 点查。路由索引本身也需要高可用和容量规划;当它继续拆分时,应按 order_no 做确定性路由,避免重新引入广播查询。

11.3 写入顺序、幂等与补偿

路由索引与订单分片可能位于不同实例,不能假设一个本地事务可以同时提交两边。可采用带状态的写入流程:

1. 生成 order_no,并以 order_no 作为全链路幂等键
2. INSERT order_route(state=10),利用主键抢占全局订单号
3. 在目标分片的本地事务中写入 orders、order_items 等订单数据
4. UPDATE order_route SET state=20,订单开始对订单号点查可见

步骤 2 失败表示订单号冲突,应重新生成;步骤 3 失败时保留 preparing 记录并幂等重试,超过时限后标记 cancelled;步骤 3 成功而步骤 4 失败时,后台任务根据 order_no 检查目标分片并补记 active。查询不能把 preparing 当成不存在,应返回“处理中”或短暂重试。

单个分片内的订单主表和明细表仍由本地事务保证原子性;路由索引与订单分片之间依靠唯一键、状态机、幂等重试和定期对账达到最终一致。这里不应宣称拥有跨实例强事务。

11.4 广播降级与 4→8 扩容

订单号广播只适合运维排障或路由索引异常时的受控降级,不应成为正常用户请求路径。必须设置总超时、单分片超时、最大并发、QPS 限流和熔断。例如在当前四分片实验中,可以把并发上限设为 4、单分片超时设为 100 ms、总预算设为 200 ms;这些只是初始保护值,必须通过压测修订,分片扩展后不能自动放大并发。

扩容沿用第九章的状态机,但迁移单位改为逻辑桶:

PREPARE   创建新分片,发布新桶映射但暂不承担读写
COPY      按桶复制历史订单,并记录快照水位
CATCH_UP  使用 CDC 同步水位后的增量,旧分片仍是唯一写入方
VERIFY    校验行数、订单号、金额聚合、抽样哈希和复制延迟
SWITCH    原子提升桶映射版本,新分片取得写入防护令牌
CLEANUP   保留回退窗口,确认旧分片无新写入后再删除旧副本

路由服务向请求附带 route_version 和防护令牌(fencing token),目标分片只接受当前所有者的写入;版本过期的请求重新获取路由。这样即使旧应用实例尚未刷新缓存,也不能继续向旧目标写入。切换失败时,在旧数据尚未清理且旧路由仍可恢复的前提下回退映射版本。

11.5 设计验收清单

场景 路由依据 本地访问路径 一致性要求
用户最近订单 user_id → bucket → shard (user_id,created_at,id) 单分片事务
订单号点查 order_no → bucket → shard uk_orders_order_no 路由状态与订单最终一致
新建订单 全局订单号 + 用户桶 本地订单事务 幂等、补偿、对账
桶迁移 版本化桶映射 新旧分片校验 单写、追增量、可回退

验收时至少验证:重复请求只产生一笔订单;任一中间步骤失败都能重试或补偿;路由为 active 的记录必能找到订单;切换后旧防护令牌无法写入;回退窗口结束前没有删除唯一副本。

十二、本章结论

12.1 先区分三类能力

能力 解决的问题 不能替代的能力
分区裁剪 排除与谓词不相交的分区 分区内索引、SQL 优化
原生分区管理 在单个实例内按时间或键组织数据 跨节点扩容、独立故障域
业务水平分片 把容量和负载分散到多个目标 全局约束、跨片事务与查询治理

MySQL 原生分区仍是一张逻辑表,共享表定义、MDL 和实例资源。业务分片可以跨实例,却要求应用或中间层承担路由、全局唯一性、聚合、迁移和故障恢复。两者解决的问题不同,不能因为都把数据“分开存放”就互相替换。

12.2 官方规则确定的边界

  • DOCUMENTED:分区表达式使用的全部列必须出现在每一个唯一键中,包括主键;为了满足规则扩展唯一键,可能改变原有业务唯一性。
  • DOCUMENTED:用户定义分区的 InnoDB 表不能包含外键,也不能被其他 InnoDB 外键引用。
  • DOCUMENTED:分区裁剪只排除不可能包含结果的分区;进入保留分区后,仍由分区内索引和其他执行算子决定工作量。
  • DOCUMENTED:分区 DDL 的 Online DDL 能力因操作而异;LOCK=NONE 允许主要执行阶段并发 DML,不代表没有表级 MDL。
  • DOCUMENTED:Online DDL 提交表定义时需要排他 MDL,访问任一分区的长事务都可能使其等待,等待中的排他请求还可能阻塞后续表访问。

12.3 当前实验已经证明什么

实验 已观察结果 可以得到的结论
EX-M04-01 不含 created_at 的主键和唯一键导致分区 DDL 失败 分区键进入唯一键可能改变业务约束,不能只为通过 DDL 机械加列
EX-M04-02 时间范围裁剪到 p2024,读取 5123 行并输出 434 行 裁剪成功不等于分区内存在合适索引
EX-M04-02 补齐联合索引等值前缀后范围读取并输出 28 行 分区裁剪和 B+Tree 访问路径可以同时生效
EX-M04-02 YEAR(created_at)=2024 读取全部 10000 行 当前表达式没有触发裁剪,应优先使用原始列半开区间
EX-M04-04 id=1234 在 HASH 表裁剪到 p2,在 RANGE 表保留四个分区 相同输出行数不代表访问的分区范围相同
EX-M04-04 时间范围在 RANGE/HASH 表分别读取 5123/10000 行,均输出 434 行 分区键必须与高价值查询或生命周期一致

这些是当前 MySQL 8.4 环境、固定数据和给定 SQL 下的结果。它们证明了访问机制,不足以证明生产吞吐、尾延迟、热点分布或故障恢复能力。

12.4 选型结论

已确认的问题 优先方案 引入前必须确认
核心订单点查与用户订单列表 普通 InnoDB 表、合适索引 不按 created_at 分区破坏全局唯一语义或增加多分区探测
历史/归档数据按时间淘汰 满足条件时使用 RANGE COLUMNS(created_at) 唯一键、外键、未来分区、表级 MDL 和恢复流程
单条 SQL 访问过多数据 先优化谓词和联合索引 EXPLAIN ANALYZE 的读取行数与排序、回表成本
单节点容量或吞吐达到边界 业务水平分片 路由、全局唯一性、跨片查询、迁移与回退
需要独立资源或故障域 跨实例/集群部署 同实例分表与原生分区都不能提供该能力

分区均匀或每个分片恰好 2500 行,只说明当前人工数据符合取模规则,不代表生产负载没有热点。是否引入分片,应由单节点容量、吞吐和延迟证据驱动,而不是由表行数单独决定。

12.5 证据状态与下一步

EX-M04-01~05 已回填手工结果,当前为 RESULT_RECORDED;分区表 MDL 补充验证仍为 NOT_STARTED:

  1. 执行 5.7 的三会话验证,保存连接 ID、逻辑表上的 MDL 类型与状态、等待语句以及释放顺序;
  2. 核对 DROP PARTITION 的目标与结果,区分“允许并发 DML”和“仍可能等待表级 MDL”;
  3. 执行本文第十节的清理,确认实验表全部删除且公共 orders 的 10000 行和索引保持不变;
  4. 证据和清理均完成后,再把 M04 实验和补充验证更新为 PASSED。

最终选型应能明确回答:当前瓶颈是什么,分区键或分片键与哪个高价值访问或生命周期一致,以及为了这项收益需要承担哪些约束、一致性、扩容和故障处理成本。

本篇的四表实验只验证应用路由模型。后续《MySQL 8.4 实战(十一):ShardingSphere-Proxy 分库分表、路由与故障边界》将继续观察独立代理如何解析 SQL、路由到两个 MySQL 实例、归并跨片结果,以及一个后端不可用时怎样扩大故障面。中间件不会消除物理表的 DDL/MDL;它还要求逐片治理 schema 一致性和部分失败。

参考资料