数据库基础体系 · 第 109/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Oracle 分区与大表治理:Range、List、Hash、交换和裁剪

Oracle 分区表(partitioned table)不是把一张逻辑表拆成多张需要应用自行维护的表,而是由 Oracle 维护一张统一的逻辑表,并将数据、索引和部分元数据按分区组织。应用仍然查询原表名,优化器和执行引擎根据谓词决定需要访问哪些分区。

分区解决的主要问题是:

  • 将大表按业务维度组织,降低单次维护的数据范围;
  • 让查询能够跳过不相关的数据,这称为分区裁剪(partition pruning);
  • 让历史数据归档、删除、装载可以按分区完成;
  • 为分区级索引、分区交换、分区级统计信息和并行处理提供基础。

分区并不会自动解决所有性能问题。一个没有合适谓词的查询,仍然可能扫描整张分区表;一个设计不当的分区键,也可能导致数据倾斜、分区数量失控或维护复杂度增加。


一、分区的基本模型

1.1 逻辑表与物理分区

假设有一张订单表:

CREATE TABLE orders (
    order_id    NUMBER        NOT NULL,
    order_date  DATE          NOT NULL,
    customer_id NUMBER        NOT NULL,
    region      VARCHAR2(10)  NOT NULL,
    amount      NUMBER(18, 2)  NOT NULL
)
PARTITION BY RANGE (order_date)
(
    PARTITION p2024q1 VALUES LESS THAN (DATE '2024-04-01'),
    PARTITION p2024q2 VALUES LESS THAN (DATE '2024-07-01'),
    PARTITION p2024q3 VALUES LESS THAN (DATE '2024-10-01'),
    PARTITION p2024q4 VALUES LESS THAN (DATE '2025-01-01'),
    PARTITION pmax   VALUES LESS THAN (MAXVALUE)
);

这里:

  • orders 是逻辑表;
  • order_date 是分区键;
  • p2024q1pmax 是分区;
  • 每一行根据 order_date 被放入且只能放入一个分区;
  • MAXVALUE 表示大于此前所有边界的值。

对于 Range 分区,分区边界通常使用半开区间理解:

p2024q1:  order_date <  2024-04-01
p2024q2:  2024-04-01 <= order_date < 2024-07-01
p2024q3:  2024-07-01 <= order_date < 2024-10-01
p2024q4:  2024-10-01 <= order_date < 2025-01-01
pmax:     order_date >= 2025-01-01

因此下面的插入会进入不同分区:

INSERT INTO orders VALUES
    (1, DATE '2024-03-31', 101, 'CN', 100.00);

INSERT INTO orders VALUES
    (2, DATE '2024-04-01', 102, 'CN', 200.00);

INSERT INTO orders VALUES
    (3, DATE '2025-02-01', 103, 'US', 300.00);

结果分别是:

1 -> p2024q1
2 -> p2024q2
3 -> pmax

分区键不是普通索引列。它决定的是行的物理归属。这也是分区键设计与索引设计的根本区别:索引主要改变访问路径,分区改变数据组织边界。

1.2 分区键与跨分区更新

如果更新一行后,分区键值使它应该进入另一个分区,Oracle 需要执行一次跨分区移动。

默认情况下,直接执行这类更新可能失败:

UPDATE orders
SET order_date = DATE '2024-07-01'
WHERE order_id = 2;

如果该行原本在 p2024q2,更新后应该进入 p2024q3,Oracle 可能报告:

ORA-14402: updating partition key column would cause a partition change

可以显式启用行移动:

ALTER TABLE orders ENABLE ROW MOVEMENT;

启用后,Oracle 可以将行从旧分区移动到新分区,但这不是零成本操作:

  1. 删除旧分区中的行;
  2. 在新分区中插入行;
  3. 维护相关索引;
  4. 产生相应的重做和撤销信息;
  5. 可能改变行的位置和 ROWID

因此,ENABLE ROW MOVEMENT 不是性能优化开关。只有在确实允许分区键更新,且应用能够接受行移动语义时才应启用。


二、Range 分区:按有序边界组织数据

2.1 Range 的形式化定义

设分区键值为 xx,边界为:

b1<b2<<bnb_1 < b_2 < \cdots < b_n

则第 ii 个分区通常表示:

Pi={xbi1x<bi}P_i = \{x \mid b_{i-1} \le x < b_i\}

第一个分区可以理解为:

P1={xx<b1}P_1 = \{x \mid x < b_1\}

最后使用 MAXVALUE 时,表示剩余的上界空间。

Range 分区适合分区键具有自然顺序的场景:

  • 时间;
  • 自增编号区间;
  • 金额区间;
  • 租户编号区间;
  • 地理编码或版本号区间。

最常见的是时间分区,因为数据通常按时间增长,旧数据也有明确的生命周期。

2.2 时间分区的边界陷阱

Oracle 的 DATE 包含日期和时间。下面的条件:

DATE '2024-04-01'

表示:

2024-04-01 00:00:00

所以:

WHERE order_date < DATE '2024-04-01'

能够覆盖整个 3 月数据,而:

WHERE order_date >= DATE '2024-04-01'
  AND order_date <  DATE '2024-05-01'

覆盖的是 4 月整月。

不建议用下面这种闭区间表达月末:

WHERE order_date BETWEEN
      DATE '2024-04-01'
  AND DATE '2024-04-30'

原因是它只到 2024-04-30 00:00:00,并不包含 4 月 30 日当天的其他时间。时间范围更可靠的写法是:

WHERE order_date >= DATE '2024-04-01'
  AND order_date <  DATE '2024-05-01'

这也正好符合 Range 分区的半开区间模型。

2.3 手工 Range 与 Interval Range

手工创建的分区需要提前建立边界:

ALTER TABLE orders
ADD PARTITION p2025q1 VALUES LESS THAN (DATE '2025-04-01');

如果数据会持续进入新的时间范围,可以使用 Interval 分区自动创建后续分区。例如:

CREATE TABLE event_log (
    event_id   NUMBER       NOT NULL,
    event_time TIMESTAMP     NOT NULL,
    payload    VARCHAR2(4000)
)
PARTITION BY RANGE (event_time)
INTERVAL (NUMTOYMINTERVAL(1, 'MONTH'))
(
    PARTITION p_before_2024
    VALUES LESS THAN (TIMESTAMP '2024-01-01 00:00:00')
);

其语义是:

  • 小于 2024-01-01 的数据进入初始分区;
  • 之后每个月需要新分区时,Oracle 根据间隔自动创建;
  • 分区命名、权限、存储属性和运维监控需要在目标版本上仔细验证;
  • 自动创建分区不能替代生命周期管理,旧分区仍需要归档或删除策略。

Interval 分区适合连续时间流,但也有边界:如果业务产生了远未来的异常时间值,Oracle 可能自动创建大量中间分区。生产环境必须校验时间输入,并监控分区数量。

2.4 Range 的反例

如果数据的主要访问条件是:

WHERE customer_id = :id

却按照 order_date 划分 Range,查询可能需要访问所有时间分区。虽然每个分区内部可以使用 customer_id 索引,但分区本身没有帮助定位客户数据。

反过来,如果按照 customer_id 做大量 Range 分区,而业务主要是按时间清理历史数据,则历史删除会失去天然的时间边界。

分区键应同时考虑:

  1. 主要过滤条件;
  2. 数据生命周期;
  3. 数据增长方向;
  4. 分区数量;
  5. 数据分布是否均匀;
  6. 分区维护动作是否自然。

三、List 分区:按离散集合组织数据

3.1 List 的定义

List 分区不是比较大小,而是将分区键值映射到一个离散集合。

例如:

CREATE TABLE customer_profile (
    customer_id NUMBER        NOT NULL,
    region      VARCHAR2(10)  NOT NULL,
    name        VARCHAR2(100) NOT NULL
)
PARTITION BY LIST (region)
(
    PARTITION p_cn   VALUES ('CN'),
    PARTITION p_us   VALUES ('US'),
    PARTITION p_eu   VALUES ('DE', 'FR', 'GB'),
    PARTITION p_other VALUES (DEFAULT)
);

其集合关系为:

p_cn:     region ∈ {'CN'}
p_us:     region ∈ {'US'}
p_eu:     region ∈ {'DE', 'FR', 'GB'}
p_other:  region 不属于上述集合

List 分区适合:

  • 国家或区域;
  • 业务类型;
  • 状态;
  • 产品线;
  • 租户组;
  • 有限且有业务含义的枚举值。

3.2 DEFAULT 分区的作用与限制

DEFAULT 分区可以接收尚未显式列出的值:

PARTITION p_other VALUES (DEFAULT)

它不是“自动创建一个新分区”,而是将所有未匹配值放入同一个分区。

例如插入:

INSERT INTO customer_profile
VALUES (1, 'CN', 'Alice');

INSERT INTO customer_profile
VALUES (2, 'JP', 'Bob');

结果:

'CN' -> p_cn
'JP' -> p_other

如果以后要增加日本分区:

ALTER TABLE customer_profile
SPLIT PARTITION p_other
VALUES ('JP')
INTO (
    PARTITION p_jp,
    PARTITION p_other
);

执行前需要确认 p_other 中确实存在可被拆分的数据,并评估:

  • 拆分是否需要扫描数据;
  • 分区索引和全局索引如何维护;
  • DDL 锁等待;
  • 业务高峰期是否会产生明显影响。

如果不需要保留 DEFAULT 分区,可以将所有值拆入新分区;如果仍要接收其他值,则必须保留剩余分区。

3.3 NULL 值

分区键允许为空时,必须明确考虑 NULL 的归属:

  • Range 分区中,空值在排序意义上通常会进入最小边界分区;
  • List 分区可以显式列出 NULL,也可以由 DEFAULT 接收;
  • Hash 分区会对包括空值在内的键值执行哈希分布。

工程上更推荐对作为分区键的业务字段使用 NOT NULL。否则,空值集中到某个 Range 或 List 分区,可能与业务对数据生命周期的预期不一致。

3.4 List 的反例

List 分区不适合高基数字段。例如用用户 ID 做 List 分区:

p_1 VALUES (1)
p_2 VALUES (2)
p_3 VALUES (3)
...

这会导致:

  • 分区数量随用户增长;
  • DDL、统计信息和字典管理成本增加;
  • 分区裁剪收益并不一定高于普通索引;
  • 分区维护和权限管理变得困难。

对于高基数、需要均匀分散数据的键,Hash 通常更合适。


四、Hash 分区:按哈希值均匀分布数据

4.1 Hash 的定义

Hash 分区先对分区键计算内部哈希值,再根据分区数量选择目标分区:

partition=h(key)modNpartition = h(key) \bmod N

其中:

  • h(key)h(key) 是 Oracle 使用的哈希映射;
  • NN 是分区数量;
  • key 是分区键。

实际分区归属使用 Oracle 内部语义决定,应用不应自行复制这个哈希算法。

示例:

CREATE TABLE access_log (
    log_id      NUMBER        NOT NULL,
    user_id     NUMBER        NOT NULL,
    access_time TIMESTAMP     NOT NULL,
    operation   VARCHAR2(30)  NOT NULL
)
PARTITION BY HASH (user_id)
PARTITIONS 8;

Hash 分区的主要目标是均匀分布,而不是表达时间顺序或业务集合。

适合的场景包括:

  • 按高基数 ID 分散热点;
  • 让并行访问尽量分布到多个分区;
  • 为分区级并行处理或分区级连接提供组织基础;
  • 没有自然范围边界,但希望避免单个数据段过大的场景。

4.2 Hash 不等于分片

Hash 分区仍然位于同一个 Oracle 数据库逻辑实例和数据库存储体系中。它不是:

  • 跨数据库节点的分片;
  • 自动跨服务器的数据分布;
  • 自动解决跨分区事务的机制;
  • 自动解决热点索引块的机制。

如果所有插入都集中更新同一个全局索引根或某个业务热点,即使表做了 Hash 分区,也可能仍存在竞争。

4.3 Hash 裁剪的边界

对 Hash 分区表执行:

SELECT *
FROM access_log
WHERE user_id = :user_id;

优化器通常可以根据等值条件确定需要访问的 Hash 分区,或者将访问范围缩小到少数分区。

但下面的范围条件:

SELECT *
FROM access_log
WHERE user_id BETWEEN :a AND :b;

通常无法像 Range 分区那样通过数值连续性排除大量分区。因为数值相邻的 user_id 不代表哈希值相邻。

因此:

Range + 时间条件       -> 天然适合按时间裁剪
Hash + 等值条件        -> 可能精确定位分区
Hash + 数值范围条件    -> 通常难以裁剪

Hash 分区的收益主要是均匀分布和等值访问,而不是范围查询。

4.4 Hash 分区数量的考虑

分区数量不是越多越好:

  • 太少:单个分区仍然很大,平行度和维护粒度有限;
  • 太多:数据字典、统计信息、分区索引和维护操作成本增加;
  • 数据量增长后调整分区数量可能需要拆分、合并或重组;
  • 现有数据分布不均时,增加分区不一定立即修复历史倾斜。

应根据数据规模、并发模式、维护操作和目标版本能力确定,而不是仅根据 CPU 核数机械设置。


五、组合分区:先按业务边界,再按分布方式拆分

Range、List 和 Hash 可以组合使用。例如先按月份划分,再在每个月内部按客户 ID Hash:

CREATE TABLE monthly_orders (
    order_id    NUMBER        NOT NULL,
    order_date  DATE          NOT NULL,
    customer_id NUMBER        NOT NULL,
    amount      NUMBER(18, 2)  NOT NULL
)
PARTITION BY RANGE (order_date)
SUBPARTITION BY HASH (customer_id)
SUBPARTITIONS 4
(
    PARTITION p2024_01 VALUES LESS THAN (DATE '2024-02-01'),
    PARTITION p2024_02 VALUES LESS THAN (DATE '2024-03-01'),
    PARTITION p2024_03 VALUES LESS THAN (DATE '2024-04-01'),
    PARTITION pmax    VALUES LESS THAN (MAXVALUE)
);

这形成两层组织:

一级分区:按 order_date 选择月份
二级分区:在该月份内部按 customer_id 分散

例如:

WHERE order_date >= DATE '2024-02-01'
  AND order_date <  DATE '2024-03-01'
  AND customer_id = :id

优化器可能先裁剪到 p2024_02,再在其子分区中定位 Hash 分区。

组合分区增加了维护复杂度:

  • 每个一级分区都包含多个子分区;
  • 本地索引可能需要保持相同的分区层级;
  • 统计信息和分区级操作更加复杂;
  • 分区数量是一级分区数乘以子分区数。

组合分区只有在两个维度都具有明确收益时才值得采用。


六、分区裁剪:优化器为什么能够跳过分区

6.1 裁剪的形式化条件

设分区表被划分为:

T=P1P2PnT = P_1 \cup P_2 \cup \cdots \cup P_n

查询谓词定义了候选数据集合 SS。如果能够证明:

PiS=P_i \cap S = \varnothing

则分区 PiP_i 不可能产生结果,可以跳过。

对于 Range 分区:

P1: [2024-01-01, 2024-02-01)
P2: [2024-02-01, 2024-03-01)
P3: [2024-03-01, 2024-04-01)

查询:

WHERE order_date >= DATE '2024-02-10'
  AND order_date <  DATE '2024-02-20'

候选集合为:

[2024-02-10, 2024-02-20)

P1P3 的交集为空,因此只需访问 P2

这就是分区裁剪的逻辑基础,而不是“分区表自动更快”。

6.2 静态裁剪与动态裁剪

静态裁剪在生成执行计划时就能确定分区。例如:

SELECT COUNT(*)
FROM orders
WHERE order_date >= DATE '2024-04-01'
  AND order_date <  DATE '2024-07-01';

边界是常量,优化器可以在计划阶段确定目标分区。

动态裁剪在执行过程中才能确定分区。例如:

SELECT o.order_id
FROM orders o
JOIN customer_filter f
  ON f.customer_id = o.customer_id
WHERE o.order_date >= :start_date
  AND o.order_date <  :end_date;

目标分区可能依赖绑定变量或连接输入。执行计划中可能出现动态分区访问边界,具体表现依赖优化器版本、谓词形式和统计信息。

6.3 查看裁剪结果

先收集统计信息:

BEGIN
    DBMS_STATS.GATHER_TABLE_STATS(
        ownname          => USER,
        tabname          => 'ORDERS',
        estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
        method_opt       => 'FOR ALL COLUMNS SIZE AUTO'
    );
END;
/

然后查看计划:

EXPLAIN PLAN FOR
SELECT COUNT(*)
FROM orders
WHERE order_date >= DATE '2024-04-01'
  AND order_date <  DATE '2024-07-01';

SELECT *
FROM TABLE(DBMS_XPLAN.DISPLAY);

重点观察计划中的分区信息,例如:

Pstart  Pstop
------  -----
2       2

或者:

Pstart  Pstop
------  -----
KEY     KEY

一般可以这样理解:

  • Pstart = Pstop = 2:只访问一个分区;
  • Pstart = 2, Pstop = 4:访问一个连续分区范围;
  • KEY:分区边界在执行期间根据值决定;
  • 1, 1048575 或类似全范围信息:可能表示访问所有分区,具体显示取决于版本和计划格式。

仅看到索引扫描并不代表发生了有效裁剪,必须同时检查 PSTARTPSTOP

6.4 使裁剪失效或变弱的写法

下面的写法可能妨碍基于 order_date 的裁剪:

WHERE TRUNC(order_date) = DATE '2024-04-01'

更适合改写为:

WHERE order_date >= DATE '2024-04-01'
  AND order_date <  DATE '2024-04-02'

另一个常见问题是隐式类型转换:

WHERE order_date = '2024-04-01'

这依赖会话的 NLS_DATE_FORMAT,可能产生错误或隐式函数转换。应使用日期字面量或显式转换:

WHERE order_date = DATE '2024-04-01'

或者:

WHERE order_date = TO_DATE(
    :date_text,
    'YYYY-MM-DD',
    'NLS_DATE_LANGUAGE=American'
)

但即使使用了函数,如果函数作用在绑定变量而不是分区键列上,通常更容易保持可裁剪形式。关键原则是:不要无必要地把分区键包在表达式中。

6.5 OR、绑定变量与裁剪

下面的查询:

WHERE order_date < DATE '2024-04-01'
   OR order_date >= DATE '2024-10-01'

逻辑上只需要访问早期分区和晚期分区,但最终计划是否能精确表达这种裁剪,取决于优化器改写、统计信息和谓词复杂度。

绑定变量本身并不必然阻止裁剪。Oracle 可以在执行阶段使用绑定值完成动态裁剪,但如果应用使用了复杂表达式、隐式转换,或者计划被固定为不适合当前数据分布的形式,裁剪效果可能下降。

因此诊断时应查看真实执行计划,而不是只看 SQL 文本。


七、分区与索引:本地索引、全局索引和可用性

7.1 本地索引

本地索引(local index)按表分区建立索引分区:

CREATE INDEX orders_lix_customer
ON orders (customer_id)
LOCAL;

每个表分区都有对应的索引分区。其重要特征是索引分区与表分区具有明确的对应关系。

优点:

  • 删除或截断一个表分区时,相关索引维护边界清晰;
  • 索引分区通常容易与表分区一起管理;
  • 分区生命周期管理更自然;
  • 适合按时间删除历史数据的表。

缺点:

  • 不能直接把所有分区中的索引键组织成一个全局有序结构;
  • 某些跨分区排序、唯一性和访问模式不一定适合本地索引;
  • 本地唯一索引通常要求包含分区键,否则无法在单个索引分区内保证全表唯一性。

例如:

CREATE UNIQUE INDEX orders_lux_order_id
ON orders (order_id)
LOCAL;

是否可行,需要结合分区键和 Oracle 对本地唯一索引的约束规则验证。常见做法是:

CREATE UNIQUE INDEX orders_lux_order_id_date
ON orders (order_id, order_date)
LOCAL;

但这改变了索引键的唯一性语义:它保证的是 (order_id, order_date) 的唯一,而不一定是 order_id 单列全局唯一。

7.2 全局索引

全局索引(global index)可以跨越多个表分区:

CREATE INDEX orders_gix_customer
ON orders (customer_id);

全局索引适合:

  • 频繁按非分区键全局查找;
  • 需要一个跨分区的统一索引结构;
  • 某些无法或不适合使用本地索引的唯一性约束。

但分区维护时,全局索引是重要风险点。比如:

ALTER TABLE orders
TRUNCATE PARTITION p2024q1;

如果直接移除分区数据,相关全局索引条目可能失效或需要维护,具体行为取决于操作、索引类型和使用的子句。生产操作应明确选择并验证 UPDATE GLOBAL INDEXES 等维护选项:

ALTER TABLE orders
TRUNCATE PARTITION p2024q1
UPDATE GLOBAL INDEXES;

代价是截断操作可能需要更多索引维护工作。另一种策略是允许全局索引暂时不可用,再重建:

ALTER INDEX orders_gix_customer REBUILD;

但这会引入一个索引不可用窗口,并可能需要额外空间和较长 I/O 时间。

7.3 分区表不等于自动使用索引

下面两个问题必须分开:

  1. 查询是否裁剪了分区;
  2. 每个目标分区内部是否使用了索引。

一个查询可以:

只访问一个分区,但在该分区全表扫描;
访问多个分区,但每个分区使用索引;
访问所有分区,并在每个分区使用索引。

是否高效取决于:

  • 过滤选择性;
  • 分区大小;
  • 索引聚簇因子;
  • 统计信息;
  • 访问列是否覆盖;
  • 并行度;
  • 实际数据分布。

因此不能用“看到分区表”或“看到索引扫描”替代执行计划分析。


八、分区交换:用元数据操作完成大批量迁移

8.1 交换的核心语义

分区交换(partition exchange)将一个分区与一张普通表交换数据段和相关元数据的归属。

先创建结构相容的中间表:

CREATE TABLE orders_stage
AS
SELECT *
FROM orders
WHERE 1 = 0;

然后装载数据:

INSERT INTO orders_stage (
    order_id,
    order_date,
    customer_id,
    region,
    amount
)
SELECT
    order_id,
    order_date,
    customer_id,
    region,
    amount
FROM source_orders
WHERE order_date >= DATE '2024-04-01'
  AND order_date <  DATE '2024-07-01';

COMMIT;

执行交换:

ALTER TABLE orders
EXCHANGE PARTITION p2024q2
WITH TABLE orders_stage
INCLUDING INDEXES
WITH VALIDATION;

交换后:

orders.p2024q2  得到原 orders_stage 中的数据
orders_stage    得到原 p2024q2 中的数据

orders_stage 原本为空,因此交换后它会包含原来 p2024q2 的数据。此时可以:

  • orders_stage 做校验;
  • 导出或归档;
  • 删除或重新利用;
  • 对外部数据执行后续处理。

交换的价值在于:数据不必通过 INSERT INTO orders SELECT ... 再复制一次。对于满足结构和约束条件的场景,交换主要改变数据段和元数据归属,通常比逐行迁移更适合大批量装载和归档。

8.2 WITH VALIDATIONWITHOUT VALIDATION

WITH VALIDATION 会验证中间表中的数据是否属于目标分区。

例如目标分区是:

2024-04-01 <= order_date < 2024-07-01

如果 orders_stage 中存在:

order_date = 2024-08-01

验证就应失败,因为这行不属于 p2024q2

验证的优点是保护分区约束,缺点是可能需要扫描中间表,在大批量数据上产生明显开销。

WITHOUT VALIDATION 则跳过这一步:

ALTER TABLE orders
EXCHANGE PARTITION p2024q2
WITH TABLE orders_stage
INCLUDING INDEXES
WITHOUT VALIDATION;

它不是“更快且等价”的验证方式,而是把正确性责任交给操作者。只有在已经通过独立方法证明以下条件时才安全:

SELECT COUNT(*)
FROM orders_stage
WHERE order_date <  DATE '2024-04-01'
   OR order_date >= DATE '2024-07-01';

结果必须为:

0

还应检查空值:

SELECT COUNT(*)
FROM orders_stage
WHERE order_date IS NULL;

如果分区键不允许为空,则应为 0

如果错误地使用 WITHOUT VALIDATION

错误数据进入错误分区
        ↓
后续查询按分区裁剪
        ↓
本应返回的行被跳过
        ↓
结果错误,而且错误可能很难从普通 SQL 中发现

因此交换验证是数据正确性问题,不只是性能问题。

8.3 交换的结构前提

中间表通常需要与目标表具有相容结构,包括:

  • 列数和列顺序;
  • 数据类型和长度;
  • 相关虚拟列、隐藏列等;
  • 分区键对应列;
  • 约束语义;
  • 索引结构,尤其是使用 INCLUDING INDEXES 时;
  • 压缩、LOB、分区和存储属性等。

不能仅凭:

CREATE TABLE orders_stage AS SELECT * FROM orders WHERE 1 = 0;

就假设所有生产属性已经完整复制。CTAS 常常不会复制所有索引、约束、触发器、权限和存储属性。交换前应通过数据字典比对:

SELECT table_name, column_id, column_name, data_type,
       data_length, nullable
FROM user_tab_columns
WHERE table_name IN ('ORDERS', 'ORDERS_STAGE')
ORDER BY table_name, column_id;

索引状态也要检查:

SELECT index_name, table_name, status, locality
FROM user_indexes
WHERE table_name IN ('ORDERS', 'ORDERS_STAGE');

如果中间表的索引、约束或统计信息不符合预期,交换可能失败,或交换成功后产生维护与性能问题。

8.4 交换与索引

INCLUDING INDEXES 表示同时处理相关索引结构,但它并不意味着所有索引都可以无条件交换。索引必须满足 Oracle 对结构、分区方式和状态的要求。

交换前应检查:

  • 中间表是否建立了需要交换的索引;
  • 索引列定义是否相容;
  • 本地索引分区是否可对应;
  • 全局索引是否会被标记不可用;
  • 是否需要使用全局索引维护选项;
  • 交换后两张表的索引状态是否符合预期。

一个稳妥的验证动作是:

SELECT index_name, status
FROM user_indexes
WHERE table_name = 'ORDERS';

SELECT index_name, partition_name, status
FROM user_ind_partitions
WHERE index_name IN (
    SELECT index_name
    FROM user_indexes
    WHERE table_name = 'ORDERS'
);

实际列名和可查询视图需要根据目标 Oracle 版本、索引类型和对象权限确认。

8.5 交换不是普通事务内的可回滚更新

交换是 DDL。Oracle 执行 DDL 时会发生隐式提交:

提交当前事务
执行 DDL
DDL 完成后再次提交

因此不能这样理解:

BEGIN
    INSERT INTO orders_stage ...;
    ALTER TABLE orders EXCHANGE PARTITION ...;
    ROLLBACK;
END;
/

ROLLBACK 不能把已经完成的 DDL 当作普通 DML 一起回滚。装载阶段可以独立提交,交换阶段需要按照可恢复发布设计执行。

如果交换后发现数据错误,恢复通常不是对原 DDL 执行 ROLLBACK,而是:

  1. 确认目标分区和中间表当前内容;
  2. 修复或重建中间表;
  3. 在正确的锁和校验条件下执行反向交换,或重新装载;
  4. 必要时从备份、闪回或其他恢复机制处理。

8.6 交换期间的并发和锁

交换虽然避免了逐行复制,但不是没有锁风险。它需要修改表和分区的字典元数据,并可能与以下操作冲突:

  • 对目标表的 DML;
  • 长事务;
  • 查询持有的相关锁;
  • 索引维护;
  • 统计信息收集;
  • 并发分区维护操作。

生产执行前应设置明确的锁等待策略,例如:

ALTER SESSION SET DDL_LOCK_TIMEOUT = 60;

这表示 DDL 等待相关 DDL 锁最多约 60 秒,具体等待行为仍取决于锁类型和会话状态。

验证锁和长事务可以从数据字典、动态性能视图或企业监控系统入手。出现等待时,需要区分:

DDL 正在等待业务事务结束
业务事务正在等待 DDL
DDL 已完成但后续索引维护仍在运行

不要把“交换是元数据操作”误解为“不会阻塞任何业务”。


九、分区级维护:删除、截断、拆分、合并和移动

9.1 TRUNCATE PARTITIONDROP PARTITION

删除历史数据时常见两种操作。

截断分区:

ALTER TABLE orders
TRUNCATE PARTITION p2024q1
UPDATE GLOBAL INDEXES;

特点:

  • 删除分区中的数据;
  • 保留分区定义;
  • 适合分区仍将继续使用的场景;
  • 不是普通 DML,不能按普通事务回滚;
  • 全局索引需要单独评估。

删除分区:

ALTER TABLE orders
DROP PARTITION p2024q1
UPDATE GLOBAL INDEXES;

特点:

  • 删除数据;
  • 同时删除该分区定义;
  • 适合永久移除历史范围;
  • 可能影响分区边界连续性和后续装载策略。

如果删除的是包含唯一性约束或关联关系的重要分区,还要检查:

  • 外键约束;
  • 全局索引;
  • 本地索引;
  • 物化视图;
  • 统计信息;
  • 备份和归档要求。

9.2 SPLIT PARTITION

List 的 DEFAULT 分区或 Range 的宽分区都可能需要拆分。

Range 示例:

ALTER TABLE orders
SPLIT PARTITION pmax
AT (DATE '2025-04-01')
INTO (
    PARTITION p2025q1,
    PARTITION pmax
);

原来的 pmax 被拆为:

p2025q1: order_date < 2025-04-01 且属于原 pmax 范围
pmax:    order_date >= 2025-04-01

拆分通常需要处理现有数据、索引和统计信息,不应简单等同于修改一个边界数字。

9.3 MERGE PARTITIONS

如果两个相邻 Range 分区不再需要独立维护,可以合并:

ALTER TABLE orders
MERGE PARTITIONS p2024q3, p2024q4
INTO PARTITION p2024_h2;

合并可能需要重组数据和索引,执行时间取决于数据量、索引和存储条件。它适合减少过度细分的分区,不适合作为高频业务操作。

9.4 MOVE PARTITION

分区移动常用于:

  • 迁移到其他表空间;
  • 改变压缩属性;
  • 进行存储重组;
  • 处理空间碎片。

示意:

ALTER TABLE orders
MOVE PARTITION p2024q2
TABLESPACE users;

移动会影响相关索引状态。执行后必须检查:

SELECT index_name, status
FROM user_indexes
WHERE table_name = 'ORDERS';

以及索引分区状态。某些版本和具体操作支持在线移动或在线维护能力,但是否可用、对哪些索引和对象生效,必须以目标版本官方文档和实际测试为准,不能把 ONLINE 当成所有场景都适用的无锁保证。


十、用分区交换治理大表迁移

10.1 为什么不直接 INSERT ... SELECT

如果将数亿行数据直接插入目标大表:

INSERT INTO orders
SELECT ...
FROM source_orders;

可能带来:

  • 大量重做和撤销;
  • 长事务;
  • 长时间持有业务资源;
  • 索引逐行维护;
  • 失败后重试成本高;
  • 归档、校验和切换边界难以控制。

分区交换适合将“批量准备”和“短时间切换”分开:

阶段 1:在中间表准备数据
阶段 2:独立校验数据
阶段 3:短暂执行交换
阶段 4:验证目标分区和索引状态
阶段 5:清理或归档旧数据

这与 Expand-Contract 的思想一致:先扩展出可并行准备的结构,再进行受控切换,最后收缩旧路径。

10.2 一个完整的迁移示例

假设目标是将 2024 年第二季度数据导入 orders.p2024q2

第一步:确认目标边界

SELECT partition_name, high_value
FROM user_tab_partitions
WHERE table_name = 'ORDERS'
ORDER BY partition_position;

需要确认:

p2024q2 的边界确实是:
2024-04-01 <= order_date < 2024-07-01

HIGH_VALUE 在数据字典中通常以表达式形式保存,不能只依赖字符串比较,必要时应结合 DDL 和人工复核。

第二步:建立中间表

CREATE TABLE orders_stage
AS
SELECT *
FROM orders
WHERE 1 = 0;

然后补齐需要的约束和索引。比如:

CREATE INDEX orders_stage_customer_ix
ON orders_stage (customer_id);

如果使用 INCLUDING INDEXES,中间表索引结构必须与交换要求相容。

第三步:装载并提交

INSERT /*+ APPEND */ INTO orders_stage (
    order_id,
    order_date,
    customer_id,
    region,
    amount
)
SELECT
    order_id,
    order_date,
    customer_id,
    region,
    amount
FROM source_orders
WHERE order_date >= DATE '2024-04-01'
  AND order_date <  DATE '2024-07-01';

COMMIT;

APPEND 可能使用直接路径插入,减少部分传统插入开销,但它会影响并发、空间使用和事务行为。是否使用应根据装载窗口、日志模式、约束和触发器条件验证。

第四步:校验分区边界

SELECT COUNT(*) AS invalid_rows
FROM orders_stage
WHERE order_date <  DATE '2024-04-01'
   OR order_date >= DATE '2024-07-01';

预期:

INVALID_ROWS = 0

检查空值:

SELECT COUNT(*) AS null_key_rows
FROM orders_stage
WHERE order_date IS NULL;

如果目标表的分区键为 NOT NULL,预期同样为 0

检查业务唯一性:

SELECT order_id, COUNT(*)
FROM orders_stage
GROUP BY order_id
HAVING COUNT(*) > 1;

如果目标表要求 order_id 全局唯一,还必须与目标表中其他分区的数据一起校验,而不能只检查中间表。

第五步:收集中间表统计信息

BEGIN
    DBMS_STATS.GATHER_TABLE_STATS(
        ownname          => USER,
        tabname          => 'ORDERS_STAGE',
        estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
        method_opt       => 'FOR ALL COLUMNS SIZE AUTO'
    );
END;
/

交换后的统计信息是否自动继承、是否需要重新收集,取决于操作和对象配置。切换后应检查实际统计信息状态,不能假设装载前收集一次就永久正确。

第六步:执行交换

ALTER SESSION SET DDL_LOCK_TIMEOUT = 60;

ALTER TABLE orders
EXCHANGE PARTITION p2024q2
WITH TABLE orders_stage
INCLUDING INDEXES
WITH VALIDATION;

如果数据已经通过可靠的独立校验,并且锁窗口极短,可以评估 WITHOUT VALIDATION。但这必须是有证据的取舍,而不是默认配置。

第七步:验证切换结果

先检查行数:

SELECT COUNT(*)
FROM orders PARTITION (p2024q2);

SELECT COUNT(*)
FROM orders_stage;

其中:

orders.p2024q2 的行数

应等于交换前 orders_stage 的行数,而:

orders_stage 的行数

应等于交换前 orders.p2024q2 的行数。

再检查分区边界数据:

SELECT COUNT(*)
FROM orders PARTITION (p2024q2)
WHERE order_date <  DATE '2024-04-01'
   OR order_date >= DATE '2024-07-01';

预期为 0

最后检查索引:

SELECT index_name, status
FROM user_indexes
WHERE table_name = 'ORDERS';

如果存在分区索引,还要查询索引分区状态。任何 UNUSABLE 都必须在允许的维护窗口内处理,并确认应用查询是否依赖该索引。


十一、分区裁剪与统计信息、执行计划的关系

分区边界提供了“可能访问哪些分区”的结构信息,但优化器仍需要决定:

  • 是否使用分区裁剪;
  • 在目标分区中使用索引还是全表扫描;
  • 是否并行;
  • 是否进行分区级连接;
  • 是否使用全局索引;
  • 是否采用连接顺序和连接算法。

统计信息错误时,可能出现这种计划:

实际只返回几十行
优化器估计返回数百万行
于是选择全表扫描或错误的连接顺序

或者:

谓词可以裁剪到一个分区
但计划选择了全局索引
导致跨多个分区访问索引和表

诊断时应同时查看:

SELECT *
FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(
    NULL,
    NULL,
    'ALLSTATS LAST +PARTITION +PREDICATE'
));

关注:

  • PSTARTPSTOP:实际或计划访问的分区范围;
  • E-Rows:估算行数;
  • A-Rows:实际行数;
  • Predicate Information:谓词是否作用于分区键;
  • 是否出现全分区访问;
  • 是否存在估算与实际的数量级偏差。

EXPLAIN PLAN 只表示优化器预生成的计划,不一定等于真实执行计划。绑定变量、动态采样、自适应行为和运行时信息都可能造成差异。

分区统计信息还涉及两个层次:

分区级统计信息:各分区内部的数据分布
全局统计信息:整张表的总体分布

只更新某一个新分区的统计信息,并不自动意味着全局统计信息已经准确。对于持续增加的时间分区,应根据数据变化和目标版本能力评估分区级、全局级以及增量统计信息策略。


十二、常见误解和失败表现

12.1 “分区表一定比普通表快”

错误。

没有分区键谓词的查询可能访问所有分区:

SELECT SUM(amount)
FROM orders;

这类全表聚合通常需要处理所有分区。分区可能有利于并行和分区级访问,但不会凭空减少需要读取的数据。

12.2 “有分区键条件就一定发生裁剪”

也错误。

下面的查询虽然逻辑上有日期条件,但表达式可能使优化器难以识别边界:

WHERE TO_CHAR(order_date, 'YYYY-MM-DD') = :day

应尽量改成原始列的范围条件:

WHERE order_date >= TO_DATE(:day, 'YYYY-MM-DD')
  AND order_date <  TO_DATE(:day, 'YYYY-MM-DD') + 1;

同时需要检查实际计划,而不是仅凭 SQL 形式判断。

12.3 “Hash 分区可以优化所有范围查询”

不能。

Hash 破坏了业务值的顺序关系。user_id 为 100 和 101,不意味着它们会相邻存放。Hash 更适合等值定位和均匀分布。

12.4 “交换一定是瞬时且无阻塞”

不准确。

交换避免了大批量逐行复制,但仍需:

  • 获取 DDL 相关锁;
  • 检查结构;
  • 可能验证数据;
  • 处理本地和全局索引;
  • 产生数据字典变更;
  • 遵守 DDL 隐式提交语义。

WITH VALIDATION 尤其可能扫描中间表。

12.5 “WITHOUT VALIDATION 只是跳过一个多余检查”

这是危险误解。

WITHOUT VALIDATION 可能让不属于目标范围的行进入分区。分区裁剪会依据分区边界跳过该分区,从而产生逻辑错误结果。

它只能建立在外部校验已经证明分区约束成立的前提上。

12.6 “删除分区后索引自然没问题”

需要分情况:

  • 本地索引通常更容易随分区维护;
  • 全局索引可能失效或需要维护;
  • 分区索引本身也可能处于 UNUSABLE
  • 唯一性和外键约束可能阻止操作或改变风险。

每次分区维护后都应检查索引状态和关键查询计划。


十三、生产治理中的边界与取舍

13.1 分区键应服务于主要维护动作

如果业务每月归档一次,按月或按日进行 Range 分区通常有清晰边界:

新数据进入当前分区
历史数据进入已关闭分区
归档通过交换或分区级操作完成
过期数据通过 DROP/TRUNCATE PARTITION 清理

如果业务没有自然的时间生命周期,却频繁按用户 ID 等值访问,Hash 或普通索引可能更符合需求。

13.2 分区数量需要受控

分区越多,管理对象越多。需要考虑:

  • 字典视图查询;
  • 统计信息收集;
  • 本地索引分区数量;
  • 分区级备份和恢复;
  • DDL 编排;
  • 监控和告警;
  • SQL 计划复杂度。

按天分区并不天然比按月分区好。只有当按天维护、裁剪或归档确实需要更细粒度时,才应承担额外成本。

13.3 维护操作必须可验证

一次分区治理操作至少需要验证:

结构:分区边界、表属性、索引属性
数据:行数、边界、空值、重复值
计划:PSTART/PSTOP、估算与实际行数
并发:DDL 锁、长事务、业务延迟
恢复:失败后如何恢复或反向交换

特别是以下操作不能仅通过“命令执行成功”判断成功:

  • EXCHANGE PARTITION
  • SPLIT PARTITION
  • MERGE PARTITIONS
  • MOVE PARTITION
  • DROP/TRUNCATE PARTITION

命令成功只代表 DDL 被 Oracle 接受,不代表业务数据、索引状态和查询计划都符合预期。


十四、Oracle 与 SQLite 的边界

Oracle 原生提供表分区、分区索引、分区交换和分区裁剪等能力。

SQLite 没有 Oracle 意义上的原生分区表体系。常见模拟方式是:

  • 按月份建立多张普通表;
  • 使用 VIEW 合并查询;
  • 使用触发器将写入路由到不同表;
  • 由应用或迁移脚本维护表集合。

但这种模拟不会自动获得 Oracle 的完整语义:

没有统一的原生分区元数据
没有 Oracle 式 EXCHANGE PARTITION
没有同等的优化器分区裁剪机制
没有相同的本地/全局索引模型
DDL、事务和锁行为也不同

因此,从 Oracle 分区表迁移到 SQLite 时,不能直接把 PARTITION BY RANGE/LIST/HASH 语句机械翻译过去。需要重新设计表、视图、触发器、迁移流程和查询路由,并明确 SQLite 的单库事务和锁边界。

分区治理的核心不是记住几条 ALTER TABLE 语句,而是建立一条可验证的数据组织链路:

分区键定义边界
        ↓
行被路由到唯一分区
        ↓
谓词决定可排除的分区
        ↓
索引和统计信息决定分区内部访问路径
        ↓
交换和分区级 DDL 完成批量迁移与生命周期管理
        ↓
通过数据、计划、锁和恢复验证治理结果

只有当这些环节同时成立时,分区才真正成为大表治理工具,而不只是表定义中的一个选项。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。