表分区(Table Partitioning)演示教程
表分区演示 (Table Partitioning Demo)
本演示将带你体验 IRIS SQL 全新的表分区(Table Partitioning)功能,并沿途讲解其作用与工作原理。
为了验证概念,我们仅使用几十条数据进行演示。但显而易见,该功能的真正威力在于应对体量高出数个数量级的海量数据集。
💡 想要更简短、高屋建瓴的介绍?
不妨查看在线学习模块:Managing Tables with Partitioning in InterSystems IRIS
ℹ️ 表分区(Table Partitioning)功能已作为实验性功能包含在 IRIS 2026.1 中。
为了获得最佳的表分区体验,请注册 InterSystems Early Access Program (EAP)。加入 EAP 后,你将获得:许可证密钥、更新说明、与表分区团队更直接高效的沟通渠道。你的反馈对我们至关重要:我们越了解你希望如何使用表分区,就越能针对性地改进它。在注册Early Access Programs 的同时,也欢迎关注涵盖其他激动人心新功能的早期访问计划!
什么是表分区 (Table Partitioning)?
表分区允许用户依据特定的逻辑规则,将大表中的数据拆分存储到多个数据库中,从而实现高效的管理。例如,你可以将旧数据迁移至挂载在廉价存储层(低成本磁盘)上的数据库,而将当前频繁访问的热数据保留在高端存储(高性能 SSD)上,以此优化成本与性能。当表的数据量变得非常庞大(超过 10 亿行)时,分区表的数据结构能带来显著的运维便利和查询性能提升。
了解更多关于表分区的概念与机制,请查看本文底部的 [常见问题解答 (FAQ)] 部分。
快速入门
表分区(Table Partitioning) 功能已作为实验性功能包含在 IRIS 2016.1 或 IRIS for Health 2016.1 的安装包或容器中。使用该功能所需的许可证密钥可通过InterSystems Early Access Program (EAP) 获取。如果你已经拥有包含 IRIS Advanced Server 的许可证密钥,也可以直接使用。
安装实例后,你可以使用你喜欢的 SQL 接口(DBeaver、Shell 或 SMP)来运行本教程中描述的命令。
创建测试命名空间 (Namespace)
我喜欢先创建一个测试命名空间和数据库用于演示。我将我的命名为 TESTTP1。如果你觉得麻烦,也可以直接使用内置的 USER 命名空间,效果是一样的。
Home > Menu > Configure Namespaces > Create New Namespace
创建数据库 (Databases)
表分区的主要目标是让管理员能够将表数据拆分到多个数据库中,以简化运维操作。因此,我们首先需要创建几个数据库。为此,我们将使用现有的 CREATE DATABASE command的一个新变体——通过使用 CREATE DATABASE FILE,仅创建本地数据库,而不会像通常那样创建一个完整的命名空间:
CREATE DATABASE FILE "data-2024";
CREATE DATABASE FILE "data-2023";
CREATE DATABASE FILE "archive" ON DIRECTORY '{install dir}/mgr/cheap/archive/'; -- change to match your setup
这将使用默认设置创建三个额外的数据库,但目前还没有任何命名空间将映射指向它们。
创建分区表
在表分区中,我们需要区分“如何分区”与“分区存储位置”。前者定义了底层数据结构(全局下标,参见下文“工作原理”部分),属于表定义的一部分——换句话说,属于代码;后者则更像是一种运行时配置,取决于具体的实例环境,可能因部署位置而异。
“如何分区”是通过分区键(Partition Key)来定义的。分区键指定表中的某个字段,并定义一套规则,根据该字段的值推导出实际的“分区”。可用的分区方案如下:
- 范围分区 (Range partitioning):例如,基于
date_created(创建日期)字段拆分表数据,每个分区对应一个月(范围分区键的区间)。 - 列表分区 (List partitioning):例如,基于
region(地区)字段拆分表数据,每个分区精确对应一个地区。 - 哈希分区 (Hash partitioning):例如,基于
customer_id(客户ID)字段拆分表数据,根据customer_id列的哈希值将数据分布到固定数量的分区中。
此外,复合分区键 (Composite partition keys) 也是支持的,在这种情况下,你可以为每个字段独立选择分区方案。
作为第一个示例,我们将创建一个按日期分区的简单交易日志表:
CREATE TABLE demo.log (
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
log_level VARCHAR(10) DEFAULT 'INFO',
message VARCHAR(1000)
) PARTITION BY RANGE (ts) INTERVAL 1 MONTH;
CREATE BITMAP INDEX levelBIdx ON demo.log(log_level);
完成了!我们已经创建了第一张分区表。此后写入该表的任何数据,都会根据 ts字段自动归入代表当月的分区结构中。
让我们插入几行数据来看看实际效果:
INSERT INTO demo.log (message) VALUES ('this is today''s first message');
INSERT INTO demo.log (message) VALUES ('this is today''s second message');
INSERT INTO demo.log (log_level, message) VALUES ('ERROR', 'this is an error message, sadly');
INSERT INTO demo.log (ts, message) VALUES (DATEADD('month', 6, CURRENT_TIMESTAMP), 'a message from the future!');
INSERT INTO demo.log (ts, log_level, message) VALUES (DATEADD('month', 6, CURRENT_TIMESTAMP), 'FATAL', 'it''s the end of the world as we know it');
INSERT INTO demo.log (ts, log_level, message) VALUES ('2024-08-12', 'INFO', 'Enjoy the Perseid meteor shower!');
我们现在可以查看系统目录(Catalog)来查看分区情况了。你可以选择使用 SMP(系统管理门户) 中表详情部分新增的视图,或者直接查询系统目录表:
SELECT * FROM INFORMATION_SCHEMA.TABLE_PARTITIONS;
This query should return 3 rows -- one for the current month, one for those two future records (as per the DATEADD()function call), and one of the past record from August 2024 -- with the location where the partitions are stored. As you'll see, they're all located in the TESTTP1 database, which is the default for the TESTTP1 namespace. This is because thus far, we've only specified how the table data should be partitioned, and not where to map it to. The where is achieved through a tool called the Extent Mapper.
该查询应返回 3 行记录:一行对应本月,一行对应那两条未来记录(根据 DATEADD()函数调用生成),还有一行对应 2024 年 8 月的过往记录。查询结果还会显示这些分区的存储位置。如你所见,它们目前都位于 TESTTP1 数据库中,这也是 TESTTP1 命名空间的默认数据库。这是因为到目前为止,我们只定义了表数据“如何分区”,而没有定义“映射到何处”。“映射到何处”是通过一个名为 Extent Mapper(区段映射器) 的工具来实现的。
Extent Mapper(区段映射器)
ℹ️ 注意:在我们成功运行下方的 MOVE PARTITION命令之前,需要先进入日志设置 (Journal Settings),确保启用了 “Journal freeze on error” (出错时冻结日志) 选项。
Home > System Administration > Configuration > System Configuration > Journal Settings
Extent Mapper(区段映射器) 可帮助你将分区映射到指定命名空间默认数据库之外的其他数据库。它提供了一组简洁明了的 SQL 命令。通过以下命令,我们将把所有覆盖 2023 年 的分区数据映射到 data-2023 数据库,而早于 2023 年的分区数据则映射到 archive 数据库:
ALTER TABLE demo.log MOVE PARTITION BETWEEN '2023-01-01' AND '2023-12-31' TO "data-2023";
ALTER TABLE demo.log MOVE PARTITION BETWEEN '2000-01-01' AND '2022-12-31' TO "archive";
在上述命令中,由于我们的表分区键使用的是范围分区(range partitioning),因此使用了BETWEEN关键字来指定日期范围。我们传入的数值用于标识需要移动的起始分区和结束分区。关于其他分区方案的特定语法,请参阅相关文档。
如果你基于之前查询的系统目录信息进行操作,也可以直接指定分区 ID(Partition IDs),使用单个值或范围(在使用范围分区时)来进行映射:
ALTER TABLE demo.log MOVE PARTITION ID '202411010000' TO "data-2024";
ALTER TABLE demo.log MOVE PARTITION ID BETWEEN '202401010000' AND '202412010000' TO "data-2024";
如果你再次查询我们之前使用的系统目录表,你会注意到一个重要的变化:PARTITION_ID = 202408010000 这一条记录的 LOCATION 字段现在显示为 DATA-2024。
SELECT * FROM INFORMATION_SCHEMA.TABLE_PARTITIONS;
这展示了 MOVE PARTITION命令中的“移动”环节。该命令首先将分区映射到目标数据库,随后将相关的实际数据从源数据库物理迁移到目标数据库。
不过,你可能也注意到了,目录表中并没有显示任何关于 DATA-2023 数据库或 ARCHIVE 数据库的信息。这是因为只有当分区中存在实际数据时,该分区才会存在。若要查看当前的映射规则本身,还有一个单独的系统目录表可供查询:
SELECT * FROM INFORMATION_SCHEMA.TABLE_PARTITION_MAPPINGS;
现在,让我们为这些时间段添加一些数据,并验证数据确实写入了正确的数据库:
INSERT INTO demo.log (ts, log_level, message) VALUES ('2014-02-27', 'INFO', 'this happened over a decade ago!');
INSERT INTO demo.log (ts, log_level, message) VALUES ('2023-01-01', 'INFO', 'Happy 2023!!');
INSERT INTO demo.log (ts, log_level, message) VALUES ('2024-12-25', 'INFO', 'Merry Christmas!!');
INSERT INTO demo.log (ts, log_level, message) VALUES ('2020-04-12', 'INFO', 'Happy Easter!!');
SELECT * FROM INFORMATION_SCHEMA.TABLE_PARTITIONS;
你现在应该能看到这些记录(分区)是如何最终进入正确数据库的。由于我们尚未为 2025 年和 2026 年的数据指定映射,这些记录将继续写入命名空间的默认数据库。当然,这并不妨碍我们预先为当前或将来的记录指定映射规则。
其他分区级操作
除了使用 Extent Mapper 移动分区外,我们还引入了用于删除整个分区(包括其数据库映射)的命令:
ALTER TABLE demo.log DROP PARTITION ID '201402010000';
-- the 2014 entry "this happened over a decade ago!" is now gone
ALTER TABLE demo.log DROP PARTITION BETWEEN '2000-01-01' AND '2020-12-31';
-- the 2020 entry "Happy Easter!!" is now gone
SELECT * FROM INFORMATION_SCHEMA.TABLE_PARTITIONS;
-- the two ARCHIVE entries (PARTITION_IDs = 201402010000 and 202004010000) are now gone
SELECT * FROM INFORMATION_SCHEMA.TABLE_PARTITION_MAPPINGS;
-- but the mapping for the ARCHIVE is still there
ℹ️ 此 DROP PARTITION命令仅供管理员使用,因为它跳过日志记录(Journaling)、锁定(Locking)、触发器(Triggers),且不执行参照操作(Referential Action)。
查询分区表
查询分区表的方式与普通表无异。当查询的 WHERE子句中包含针对分区键字段的过滤条件时,IRIS SQL 会判断是否可以利用该条件来限制需要扫描的分区数量,从而构建结果集。这一过程称为分区裁剪(Partition Pruning),通常会在查询计划中体现为额外的范围条件或并行化层级。
让我们通过一个示例来观察这一现象。请在 SMP 中使用 “Show Plan” 按钮,或在 Shell 或 JDBC 工具中使用 EXPLAIN 命令,查看以下查询的查询计划:
SELECT * FROM demo.log WHERE ts BETWEEN '2024-01-01 00:00:00' AND '2024-12-31 23:59:59';
你应该会看到类似这样的信息,提示你 “该计划已启用分区裁剪(This plan utilizes partition pruning)”:

我们将查询中对 ts字段的范围条件,转化为了主映射(Master Map)下标结构上的范围条件(稍后在“底层原理”部分会有详细介绍)。乍看之下这似乎没什么特别,毕竟针对索引字段的条件查询总是这样。关键在于,这一次我们根本没有在 ts上定义任何索引。我们只是利用了主映射的分区结构,将一次全表扫描转变为对少量分区的全扫描,从而排除了那些肯定不包含符合 ts条件数据的分区。
让我们再看另一个查询:
SELECT COUNT(*) FROM demo.log WHERE ts BETWEEN '2024-01-01 00:00:00' AND '2024-12-31 23:59:59' AND log_level IN ('FATAL', 'ERROR');
以及它的查询计划:

这里的新变化在于:它利用了我们在上一个示例中针对主映射所使用的分区裁剪技巧,但这次是针对 log_level索引的应用。这是因为我们对索引也应用了相同的分区结构,而这正是实现前文所述的“将索引数据与行数据一同映射”所必需的。
ℹ️ 索引分区 现在同样适用于用于向量搜索(Vector Search)的近似最近邻(Approximate Nearest Neighbor)索引。在该索引中,我们会基于向量数据构建一个复杂的图结构,而随着图的增大,搜索性能会下降。得益于分区表的“分桶”特性,这个图的大小有了上限,我们可以高效地对各个桶进行并行搜索。
底层原理 (Under the Hood)
到目前为止,我们仅探讨了 SQL 层面的内容。假设你对 IRIS 如何在 Globals 中存储表数据有一定了解,让我们深入看看底层发生了什么。如果你对 “Global” 这个词感到陌生,可以直接跳到下一部分:“如何转换现有数据?”
表数据 (Table Data)
默认情况下,IRIS 将表数据存储在一种简单的 Global 结构中:使用整数作为下标代表行 ID(Row ID),并使用 $listbuild包含列数据:
^demo.log( <row-ID> ) = $lb( <column-1>, <column-2>, ... )
ℹ️ 注意:在实际环境中,这个 Global 的名称看起来会略有不同——为了某些底层的效率考虑,我们会对 Schema 和 Table 部分进行哈希处理——但这只会降低本示例的可读性。更多详细信息,请参阅关于extent sets 的文档。
为了实现数据的分区组织,并利用 Extent Mapper 将这些分区映射到不同的数据库,我们需要引入一个额外的下标层级(Subscript Level)。该层级会将分区字段的值编码为一个易于映射的分区 ID(Partition ID)。
以我们的范围分区为例,我们只需将日期编码为一种简单的整数格式。以下结构即可实现这一目标:
^demo.log( <partition-ID>, <row-ID> ) = $lb( <column-1>, <column-2>, ... )
然而,在这种模型下,单个分区仍然可能无限增长,从而引发一些与当前大型非分区表相同的问题——例如锁升级(lock escalation)、索引难以管理等。
出于这些原因,我们将每个分区进一步拆分为桶(Buckets),每个桶都有一个可预测的最大容量(约 200 万行,确切地说是 32 * 64,000)。根据我们的基准测试,这个桶大小在并行机会和开销之间取得了良好的平衡,并且与我们的位图和列式数据结构非常契合。
此外,我们将最后一个下标从全表范围的标识符改为仅在分区内唯一的整数。这确保了每个分区都能达到最快的处理吞吐量。这意味着行 ID(Row ID)变成了一个复合 ID(Composite ID),由分区 ID 与该整数组合而成(我们称之为 PRowID):
^demo.log( <partition-ID>, <bucket-ID>, <P-row-ID> ) = $lb( <column-1>, <column-2>, ... )
如果你导航到表的映射(Maps)和索引(Indices)部分,然后选择主映射全局变量(Master Map Global),或者直接查看该全局变量,你就会看到我们这张表的实际存储结构:
^DvH1.Ccdz.1 = ""
^DvH1.Ccdz.1(202301010000) = 1
^DvH1.Ccdz.1(202301010000,1,1) = $lb(1154594035806846976,"INFO","Happy 2023!!")
^DvH1.Ccdz.1(202412010000) = 1
^DvH1.Ccdz.1(202412010000,1,1) = $lb(1154656589406846976,"INFO","Merry Christmas!!")
^DvH1.Ccdz.1(202501010000) = 3
^DvH1.Ccdz.1(202501010000,1,1) = $lb(1154658438312846976,"INFO","this is today's first message")
^DvH1.Ccdz.1(202501010000,1,2) = $lb(1154658438313846976,"INFO","this is today's second message")
^DvH1.Ccdz.1(202501010000,1,3) = $lb(1154658438314846976,"ERROR","this is an error message, sadly")
^DvH1.Ccdz.1(202507010000) = 2
^DvH1.Ccdz.1(202507010000,1,1) = $lb(1154674076715846976,"INFO","a message from the future!")
^DvH1.Ccdz.1(202507010000,1,2) = $lb(1154674076716846976,"FATAL","it's the end of the world as we know it")
虽然我们只插入了几行数据,所有数据在每个分区中都只占据了一个桶,但这种结构已经做好了准备,足以高效管理数十亿乃至数万亿行的数据。
索引 (Indices)
在此功能发布之前,一些客户曾通过将(传统的)行 ID 范围手动映射到不同数据库来实现某种形式的“分区”(我们不怪他们,要怪就怪我们发布得太晚了!)。这虽然能缓解拆分表数据的最迫切需求,但它无法让你有效控制哪些数据被映射出去(对比随机的行 ID 与按月分区),而且也没有为索引提供解决方案——因为索引根本没有任何可预测的下标结构来支持这种映射。
让我们先来看看,在我们的日志表中,log_level字段上的普通索引目前长什么样:
^demo.log.lvl( <log-level-value>, <row-ID> ) = "" // regular
^demo.log.lvb( <log-level-value>, <chunk> ) = $bit(...) // bitmap
在新的模型下,它会变成这样:
^demo.log.lvl( <partition-ID>, <bucket-ID>, <log-level-value>, <P-row-ID> ) = "" // regular
^demo.log.lvb( <partition-ID>, <bucket-ID>, <log-level-value>, <chunk> ) = $bit(...) // bitmap
而对于我们表中位图索引(Bitmap Index)的实际存储结构:
^DvH1.Ccdz.3(202301010000,1," INFO",1) = /* $bit(2) - PRowIDs: 1 */
^DvH1.Ccdz.3(202412010000,1," INFO",1) = /* $bit(2) - PRowIDs: 1 */
^DvH1.Ccdz.3(202501010000,1," ERROR",1) = /* $bit(4) - PRowIDs: 3 */
^DvH1.Ccdz.3(202501010000,1," INFO",1) = /* $bit(2,3) - PRowIDs: 1,2 */
^DvH1.Ccdz.3(202507010000,1," FATAL",1) = /* $bit(3) - PRowIDs: 2 */
^DvH1.Ccdz.3(202507010000,1," INFO",1) = /* $bit(2) - PRowIDs: 1 */
你可能已经注意到,对于诸如强制执行唯一性这类简单的索引查找,上述结构需要遍历所有分区和桶。这确实是我们为了获得可扩展性而付出的代价,而我们正通过合理使用并行处理来缓解这一问题。显然,当你的索引或唯一性约束同时包含分区键字段时,这种开销就不再存在。
命名空间映射 (Namespace Mappings)
前面我们提到,MOVE PARTITION命令首先会将分区映射到目标数据库。你可以在 Global Mappings(全局映射) 页面中查看这些映射。你应该能看到全局变量根据其分区下标进行了拆分,且每次拆分都被分配到了相应的数据库。
Home > Menu > Configure Namespaces > Global Mappings (for the TESTTP1 row)
类定义 (Class Definition)
虽然我们主要从 SQL 的角度介绍这项新功能,但分区键及其他与分区配置相关的元素都可以通过 UDL(统一数据语言) 来表达。
对于本教程中创建的 demo.log表,其类定义中会生成以下成员(为了可读性,我们删除了一些无关的元素):
Class demo.log Extends %Persistent
{
/* ... */
/// Bucket Id Property, auto-generated for partitioned class
Property "%%BUCKETID" As %Library.BigInt [ Private, SqlComputeCode = {set {*}={x__prowid}\2048000+1}, SqlComputed, SqlFieldName = x__bucketid ];
/// Partition Id 1 property, auto-generated for partitioned class
Property "%%PARTITIONID1" As %Library.Integer [ Private, SqlComputeCode = {set {*}=$$partitionIdFromDateTime^%occPartition({ts},"%Library.PosixTime",1,"MONTH")}, SqlComputed, SqlFieldName = x__partitionid1 ];
/// Partition-RowId property, auto-generated for partitioned class
Property "%%PROWID" As %Library.BigInt [ Private, SqlComputeCode = {set {*}=$sequence(^DvH1.Ccdz.1({x__partitionid1}))}, SqlComputed, SqlFieldName = x__prowid ];
/// Partition IdKey, auto-generated for partitioned class
Index "%%PartitionIdKey1" On ("%%PARTITIONID1", "%%BUCKETID", "%%PROWID") [ IdKey, Unique ];
/// PartitionKey index, auto-generated by DDL CREATE TABLE statement
Index PartitionKey On logts(range(1 month) [ Abstract, SqlName = "%%PartitionKey" ];
/* ... */
}
ℹ️ 正如你所料,这些特定的类成员不应被修改。
如何转换现有数据?
我们可以将未分区的表转换为分区表,反之亦然,也可以将已分区的表取消分区。让我们先重新创建一个 demo.log表,但这次不使用 PARTITION BY子句,使其成为一个普通的非分区表。
CREATE TABLE demo.log2NotPart (
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
log_level VARCHAR(10) DEFAULT 'INFO',
message VARCHAR(1000)
);
并将同样的这批数据插入到这个新表中:
INSERT INTO demo.log2NotPart (log_level, message) VALUES ('ERROR', 'this is an error message, sadly');
INSERT INTO demo.log2NotPart (ts, message) VALUES (DATEADD('month', 6, CURRENT_TIMESTAMP), 'a message from the future!');
INSERT INTO demo.log2NotPart (ts, log_level, message) VALUES (DATEADD('month', 6, CURRENT_TIMESTAMP), 'FATAL', 'it''s the end of the world as we know it');
INSERT INTO demo.log2NotPart (ts, log_level, message) VALUES ('2024-08-12', 'INFO', 'Enjoy the Perseid meteor shower!');
INSERT INTO demo.log2NotPart (ts, log_level, message) VALUES ('2014-02-27', 'INFO', 'this happened over a decade ago!');
INSERT INTO demo.log2NotPart (ts, log_level, message) VALUES ('2023-01-01', 'INFO', 'Happy 2023!!');
INSERT INTO demo.log2NotPart (ts, log_level, message) VALUES ('2024-12-25', 'INFO', 'Merry Christmas!!');
INSERT INTO demo.log2NotPart (ts, log_level, message) VALUES ('2020-04-12', 'INFO', 'Happy Easter!!');
如果我们查看那个好用的系统目录表,会发现其中完全没有提到 demo.log2NotPart。这是预料之中的,因为这张表不是分区表。
SELECT * FROM INFORMATION_SCHEMA.TABLE_PARTITIONS;
现在,让我们使用 CONVERT PARTITION命令来将这个新表转换为分区表:
ALTER TABLE demo.log2NotPart CONVERT PARTITION BY RANGE (ts) INTERVAL 1 MONTH
当我们再次查询系统目录表时,现在应该能看到 demo.log2NotPart对应的 7 个 PARTITION_ID 条目。
我们也可以使用 CONVERT PARTITION OFF命令来取消表的分区:
ALTER TABLE demo.log2NotPart CONVERT PARTITION OFF
再次查询系统目录表,你会发现 demo.log的分区依然存在,而 demo.log2NotPart的分区已经消失了。
当然,分区转换的实际细节比我们演示的要更复杂一些。如果你正在尝试此功能,请参阅常见问题解答(FAQ)中的 “我可以转换现有表吗?” 部分。
自动归档旧分区 — 演示
在本演示的前面部分,我们展示了如何使用 ALTER TABLE ... MOVE PARTITION将分区移动到指定的数据库(例如归档数据库)。我们可以将此功能与 IRIS 任务管理器 ( IRIS Task Manager) 结合使用,以实现旧分区的自动归档。
本文最底部提供了一个示例代码块 demo.task.Archive.cls,展示了其实现方式。让我们开始吧:再次重建我们的 demo.log表,不过这次表中将包含几年跨度的数据。
CREATE TABLE demo.logMultiYear (
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
log_level VARCHAR(10) DEFAULT 'INFO',
message VARCHAR(1000)
) PARTITION BY RANGE (ts) INTERVAL 1 MONTH;
好的,让我们再次创建几个额外的数据库,用于存放不同年份的数据:
CREATE DATABASE FILE "OlderThanTwoYears" ON DIRECTORY '{install dir}/mgr/cheap/archive/'; -- change to match your setup
CREATE DATABASE FILE "OneToTwoYears";
CREATE DATABASE FILE "SixToTwelveMonths";
使用 VS Code 或通过 IRIS 终端执行 $SYSTEM.OBJ.Load(),将该归档任务类导入我们的 TESTTP1 命名空间。
除了标准的任务方法外,该类还包含一个类方法,可以为你的表填充多年的数据。在 IRIS 终端中运行以下命令。它将从过去 1000 天开始,一直填充到未来 100 天,每天插入一条日志记录。
set tSC = ##class(demo.task.Archive).InsertLogEntriesDateRange("demo.logMultiYear",-1000,100)
检查一下 demo.logMultiYear表和我们的“得力助手”——系统目录表。你应该能看到这些日志记录,并且数据分布在几十个分区中。
SELECT TOP 1000 * FROM demo.logMultiYear;
SELECT * FROM INFORMATION_SCHEMA.TABLE_PARTITIONS WHERE TABLE_NAME = 'logMultiYear';
现在,让我们来设置这个自动归档任务:
- 进入 System Management Portal,导航至 Home > System Operation > Task Manager > New Task。
- 给它起个名字,比如
ArchiveDemoLogPartitions。 - 在 "Namespace to run task in" 下拉菜单中,选择 TESTTP1。
- 在 Task Type 中,选择 ArchivePartitions。
- 如果你使用了与本演示相同的表名和数据库名,其余默认值可以保持不变。
- 点击 Next。
- 设置任务执行频率为 "Monthly (by day)",并在 Every 1 month(s) on the First Sunday 运行。
- 设置运行时间为 00:01:00。
- 点击 Finish。
现在,自动归档任务已安排妥当,定于每月第一个星期日的凌晨 1:00 运行。
不过我有点没耐心,我们直接手动运行一下看看效果:
- 进入 Home > System Operation > Task Manager > Task Schedule。
- 点击 ArchiveDemoLogPartitions 这一行。
- 点击 Run,然后点击 Perform Action Now,最后点击 Close。
任务可能需要两分钟才能启动,再花 1 到 4 分钟完成。刷新页面直到看到 "Last Finished" 一栏显示了时间戳。
最后,再次检查 demo.logMultiYear表和我们的系统目录表。你会发现,目录表中的旧分区现在的 LOCATION 已经变成了对应的旧数据库(如 DATA-2023 或 ARCHIVE)。
SELECT TOP 1000 * FROM demo.logMultiYear;
SELECT * FROM INFORMATION_SCHEMA.TABLE_PARTITIONS WHERE TABLE_NAME = 'logMultiYear';
大功告成!你已经成功设置了一个自动归档任务,它会将旧分区移动到可能位于廉价存储上的数据库中。当然,这只是一个演示;在你的生产部署中,具体的约束条件可能与我们的 TESTTP1 场景有所不同。但从 demo.task.Archive类中你可以看出,核心工作完全是由 ALTER TABLE ... MOVE PARTITION命令完成的,我们只需要根据具体的业务目标和限制来构造这些命令即可。
清理环境 (Cleaning up)
一个简单的 DROP TABLE命令会自动移除所有通过 Extent Mapper 创建的数据库映射。因此,要清理我们的表,只需执行以下操作:
DROP TABLE IF EXISTS demo.log;
DROP TABLE IF EXISTS demo.log2NotPart;
DROP TABLE IF EXISTS demo.logMultiYear;
常见问题解答 (Frequently Asked Questions)
谁需要表分区(Table Partitioning)?
大多数数据随时间累积(或初始量就很大)的应用或模式都能从表分区中受益。对于那些因为数据库中仅有一小部分“热数据”需要顶级存储的低延迟访问,而不得不将整个数据库托管在昂贵的高阶存储上而感到困扰的客户来说,这一点尤其重要。
这是否提供全自动的存储分层?
此处描述的表分区功能将作为一个平台特性起步。它为管理员提供了定义信息生命周期策略(包括在数据层之间移动数据和归档旧分区)的工具,但不会神奇地自动执行此过程。这种自动化需要更多关于环境的知识,尤其是关于可用存储层和总体意图的知识。在我们控制环境的云服务中,更有条件在此平台级功能之上提供这种自动化层。
InterSystems IRIS 何时会提供此功能?
它在IRIS 和 IRIS for Health 2026.1中作为experimental feature(实验性功能)提供。目前计划在 IRIS 2027.1 中正式发布(GA)。
你可以使用早期访问计划(Early Access Program)获取表分区的许可证密钥,或者使用包含 IRIS Advanced Server 的许可证密钥。
它是如何工作的?
在技术层面上,表分区依赖于额外的前导下标(leading subscripts)来编码分区键值,这些下标统一用于行数据、索引和其他关联数据结构。在模式设计时,用户可以选择一个分区键(如日期字段或区域),以及该字段的值应如何转换为分区(例如按月日期范围,或每个区域一个分区)。当行被添加到分区表时,SQL(或对象)过滤器将计算每行的正确下标值,并确保数据最终到达正确的位置。使用一个名为 Extent Mapper(可通过简单的 DDL 命令访问)的新工具,用户可以根据逻辑条件(例如将 2021 年的所有分区移动到一个数据库,2022 年的移动到另一个)将分区映射或从一个数据库移动到另一个数据库。
我以前不能用下标级别的映射来做这件事吗?
在某种程度上,是的。事实上,表分区依赖于下标级别的映射将数据连接到正确的位置,但最大的区别在于,它引入了这些映射所基于的额外下标,并允许你用逻辑术语表达映射,这与你定义为分区方案的内容相关。在此之前,用户只能基于 RowID 手动创建映射,而 RowID 除了粗略近似记录年龄外没有任何逻辑意义,并且根本无法有意义地拆分索引 Globals。
我可以将现有表转换为使用分区吗?
可以,我们包含了 ALTER TABLE .. CONVERT ..语法,允许将现有表转换为分区表,反之亦然。请注意,某些转换意味着对 RowID 的更改,可能需要更新具有指向正在转换的表的外键约束的表。
我还应该期待其他什么好处?
除了能够轻松跨数据库组织数据外,更精细的数据结构还提供了两个额外的好处:
- 由于额外的下标层级,大规模数据摄取可能会更快,因为减少锁升级(lock escalation)和整体争用的可能性。
- 当查询优化器检测到涉及分区键字段的谓词时,可以构建更智能的查询计划,例如裁剪(跳过)那些知道不可能包含匹配行的分区。
有什么缺陷吗?
额外的下标层级会导致不包含分区键字段的唯一键产生开销。在写入新行时,检查新行的值是否不存在需要检查每个分区。同样,基于唯一键值的查找将需要遍历所有分区。我们计划添加对未分区全局索引(global indices)的支持以帮助处理真正的唯一字段,但这些索引当然也不能跨数据库拆分。
什么是桶(Buckets)?
你可能并不总是有一个完美的分区键候选,但你希望从额外的下标层级带来的摄取操作优势中受益。或者,也许你只是想将大表的数据“某些部分”移动到另一个数据库,而不使用特定的逻辑约束。对于分区表,我们不仅引入了编码分区键的下标层级,还引入了一个不透明的“桶 ID”,有助于将数据组织成更小的部分,无论你的分区键是细粒度、均衡还是都不是。默认分区表(default-partitioned table)是采用了此外下标层级但未在其上定义逻辑分区键的表,因此为你提供了分区表的一些优势,而无需在设计时做进一步选择。长期而言,我们打算在创建新表时将分桶作为默认行为。
这与分片(Sharding)有什么关系?
表分区与分片完全是正交的(orthogonal)。
虽然两者初听相似(因为都关注非常大的表),但使用分区的理由主要是操作层面的,而分片纯粹关注性能。此外,你选择分区键是为了能够根据分区键值将特定分区映射到数据库,而在分片的情况下,数据在分片间的分布是 100% 不透明的,你选择键只是为了确保经常 JOIN 的表之间的共分片(cosharding)关系。
请注意,将分区与分片结合会增加额外的复杂性,因为你映射到的数据库需要在每个分片上可用。从 IRIS 2026.1 开始,我们不支持同一张表同时进行表分区和分片。未来的版本正在开发中,以使分片更容易与表分区集成。
这与列式存储(Columnar Storage)有什么关系?
表分区与列式存储完全正交,我们希望实施数据仓库和类似场景的客户经常会将它们一起使用。然而,从 IRIS 2026.1 开始,我们不支持具有列式存储的表进行表分区。未来的版本正在开发中,以支持在同一张表中同时使用两者。
那我们的其他数据模型呢?
该功能目前称为表分区( Table Partitioning),但未来我们可能会将这一原则扩展到其他数据模型,那里存在跨多个数据库逻辑组织数据的需求。
如何获得帮助?
如果你对本演示和教程有疑问,欢迎在文章下评论并 @Ben Schlanger。如果你遇到 Bug,可以通过 WRC 报告,但请记住这是一个目前处于实验阶段的功能。InterSystems 可能无法像对待常规平台功能那样提供完整的解决方案。
自动归档旧分区:演示类
/// This task is scheduled to run on the first Sunday of each month, when system activity is otherwise low
/// For the given table, it moves partitions to different databases based on the date range of the partition
Class demo.task.Archive Extends %SYS.Task.Definition
{
Parameter TaskName = "ArchivePartitions";
/// Default archive partitions older than six months
Property PartitionedTable As %String [ InitialExpression = "demo.logMultiYear" ];
/// Default archive for partitions older than two months
Property ArchiveOlderDatabase As %String [ InitialExpression = "OlderThanTwoYears" ];
/// Default archive for partitions between 1-2 years
Property ArchiveTwoYearDatabase As %String [ InitialExpression = "OneToTwoYears" ];
/// Default archive for partitions between 6-12 months years
Property ArchiveOneYearDatabase As %String [ InitialExpression = "SixToTwelveMonths" ];
/// Do multiple types of archiving
/// Partitions for data older than two years get moved to one database
/// Partitions for data between 1-2 years old get moved to another database
/// Partitions for data between 6-12 months old get moved to yet another database
/// Partitions for data more recent than 6 months remain in the primary database
ClassMethod MovePartitionsToArchivesDynamicSQL(pPartitionedTable As %String, pOlderArchive As %String, pTwoYearArchive As %String, pOneYearArchive As %String) As %Status
{
// Define our cutoff dates
set cutoffTwoYear = $SYSTEM.SQL.Functions.DATEADD("year",-2,$ZDATE($HOROLOG,3))
set cutoffOneYear = $SYSTEM.SQL.Functions.DATEADD("year",-1,$ZDATE($HOROLOG,3))
set cutoffSixMonths = $SYSTEM.SQL.Functions.DATEADD("month",-6,$ZDATE($HOROLOG,3))
// Partitions for data older than two years get moved to OlderThanTwoYears
set sql = "ALTER TABLE "_pPartitionedTable_
" MOVE PARTITION BETWEEN '1900-01-01' AND '"_cutoffTwoYear_"' "_
" TO '"_pOlderArchive_"'"
set rset = $SYSTEM.SQL.Execute(sql)
if (rset.%SQLCODE '= 0) {write "%Execute failed:", !, "SQLCODE ", rset.%SQLCODE, ": ", rset.%Message return rset}
// Partitions for data between 1-2 years old get moved to OneToTwoYears
set sql = "ALTER TABLE "_pPartitionedTable_
" MOVE PARTITION BETWEEN '"_cutoffTwoYear_"' AND '"_cutoffOneYear_"' "_
" TO '"_pTwoYearArchive_"'"
set rset = $SYSTEM.SQL.Execute(sql)
if (rset.%SQLCODE '= 0) {write "%Execute failed:", !, "SQLCODE ", rset.%SQLCODE, ": ", rset.%Message return rset}
// Partitions for data between 6-12 months old get moved to SixToTwelveMonths
set sql = "ALTER TABLE "_pPartitionedTable_
" MOVE PARTITION BETWEEN '"_cutoffOneYear_"' AND '"_cutoffSixMonths_"' "_
" TO '"_pOneYearArchive_"'"
set rset = $SYSTEM.SQL.Execute(sql)
if (rset.%SQLCODE '= 0) {write "%Execute failed:", !, "SQLCODE ", rset.%SQLCODE, ": ", rset.%Message return rset}
return $$$OK
}
/// Default purge code
Method OnTask() As %Status
{
return ..MovePartitionsToArchivesDynamicSQL(..PartitionedTable, ..ArchiveOlderDatabase, ..ArchiveTwoYearDatabase, ..ArchiveOneYearDatabase)
}
/// Populate a demo.log* table with entries spanning many days, with one entry per day
/// pTableName: the table you're inserting log entries into
/// pStartDate: With today's date as zero, what date should we start at?
/// To start in the past, use a negative value
/// pEndDate: With today's date as zero, what date should we end at?
/// To end in the past, use a negative value
/// Example: Starting from 1000 days in the past and going until 100 days into the future, add one log entry per day
/// set tSC = ##class(Test.Ben.TBLP.Task.Archive).InsertLogEntriesDateRange("demo.logMultiYear",-1000,100)
ClassMethod InsertLogEntriesDateRange(pTableName As %String = "demo.logMultiYear", pStartDate As %Integer = 1, pEndDate As %Integer = 10) As %Status [ Language = objectscript ]
{
SET stmt = ##class(%SQL.Statement).%New()
set status = stmt.%Prepare("INSERT INTO "_pTableName_"(ts, log_level, message) VALUES (?, ?, ?)")
if $$$ISERR(status) {
do $system.OBJ.DisplayError(status)
return status
}
TSTART
FOR i=pStartDate:1:pEndDate {
SET ts = $ZDATETIME($HOROLOG+i,3)
set logTable = stmt.%Execute(ts, "INFO", "Synthetic log message #" _ i)
if logTable.%SQLCODE < 0 {
write logTable.%SQLCode
return logTable
}
// Commit every 10k rows
IF '(i#10000) {
TCOMMIT
TSTART
}
}
TCOMMIT
return $$$OK
}
}