随着信息系统需求的不断升级,传统关系数据库模型在应对高复杂度数据和丰富语义结构时逐步暴露出局限性。例如,对于金融账户等结构相对扁平的场景,关系模型能够高效建模与查询;但当面临如CAD/CAE软件中的复杂图形对象、地理信息系统(GIS)中的多层嵌套空间数据,关系模型在表达力和操作性上远不能满足需求。
实际的软件工程开发中,开发者常在C++、Java等面向对象语言中构造高度结构化的数据对象。这些对象可能包含层级嵌套、不可变属性、集合引用等复杂关系。例如,学生管理系统中的Student对象,可能嵌套Address(街道、城市、邮编等组成)和List<Course>(课程动态集合)等属性。这类复杂对象结构在传统关系数据库中,往往需要拆解为多个关系表、通过外键进行显式关联。
这种关系模型下的“拆分-重组”模式不仅加重了设计与维护负担,同时削弱了应用层对象的一致性和封装性,还增加了ORM映射和类型转换的复杂性,容易导致数据一致性和完整性问题的产生。

对象数据库的出现,正是为了解决编程语言中的复杂数据结构与数据库存储之间的“阻抗不匹配”问题。
传统的数据库应用场景相对简单,比如银行的存取款记录、企业的员工薪酬管理等,这些应用中的数据结构都比较直观:每个字段都是原子性的(不可再分割的),一个员工的姓名就是一个字符串,工资就是一个数字,雇佣日期就是一个日期。这种简单的数据结构非常适合传统的关系数据模型。 但是,当数据库的应用范围扩展到更复杂的领域时,我们就会遇到一些挑战。让我们通过几个具体的例子来理解这个问题。
考虑一个简单的地址信息。从表面上看,我们可以将地址作为一个字符串存储:“北京市海淀区中关村大街1号”。但这样做会隐藏很多有用的细节信息,比如省份、城市、区县、街道、门牌号等。如果我们需要按城市查询用户,或者按区县统计分布情况,就需要对这个字符串进行复杂的解析。
另一种做法是将地址拆分成多个字段:省份、城市、区县、街道、门牌号。但这样做又会让查询变得复杂,每次涉及地址的查询都需要操作多个字段。 更好的解决方案是将地址作为一个复合数据类型,既保持了结构的完整性,又允许我们访问其内部的组成部分。
再考虑一个图书管理系统。一本书可能有多个作者,比如《数据库系统概念》这本书就有三个作者。在传统的关系模型中,我们有两种处理方式:
第一种是将所有作者名字连接成一个字符串:“张三,李四,王五”。但这样我们就无法轻易地查询某个特定作者的所有著作。 第二种是创建一个单独的作者表,通过外键关联。虽然这种方式在功能上是完整的,但对于这样一个简单的多值属性来说,显得过于复杂和人工化。
在关系数据库理论中,第一范式(1NF)要求所有属性都必须是原子性的,也就是说,每个属性的值都应该是不可再分割的单一值。这个要求在简单的应用场景中工作得很好,但在复杂应用中就成了一个限制。 让我们用一个电商网站的商品信息来说明这个问题。一个商品可能具有以下属性:
在严格的第一范式下,我们需要将这些信息拆分成多个表。商品表用来存储基本信息,规格表存储每个规格选项,图片表存储每张图片的路径,标签表存储每个标签。
这样的设计虽然符合理论要求,但在实际使用中会带来很多不便。首先是查询变得复杂,获取一个商品的完整信息需要连接多个表。其次是维护困难,添加或删除一个商品需要操作多个表,容易出现数据不一致的问题。最后是性能开销,多表连接会增加查询的计算成本,特别是在数据量较大的情况下。
虽然规范化设计有其优势,但过度规范化可能会让简单的问题变得复杂。合理使用复杂数据类型可以在某些场景下提供更好的解决方案。
如果我们允许使用非原子的数据类型,上面的商品信息就可以更自然地表示。比如:
|-- 伪代码,展示概念 CREATE TABLE 商品 ( 商品ID INTEGER, 商品名称 VARCHAR(100), 规格选项 ARRAY<VARCHAR(50)>, 商品图片 ARRAY<VARCHAR(200)>, 用户标签 SET<VARCHAR(30)> );
这种设计更接近程序员和最终用户对数据的直观理解。查询某个商品的所有信息只需要访问一个表,而且可以直接对数组和集合进行操作。 当然,非第一范式的设计也有其代价。它可能会导致数据冗余,并且需要数据库系统提供更复杂的查询操作支持。因此,选择使用简单的规范化设计还是复杂的非规范化设计,需要根据具体的应用场景来权衡。
在某些情况下,比如需要频繁进行复杂分析查询的数据仓库场景,规范化的设计可能更适合。而在需要快速获取完整对象信息的应用场景中,非规范化的复杂数据类型可能是更好的选择。
在SQL发展的早期阶段,支持的数据类型相对简单:整数、浮点数、字符串、日期等基本类型。这些类型虽然能够满足大多数传统业务应用的需求,但随着应用复杂度的增加,显然需要更丰富的类型系统。从SQL:1999开始,SQL标准引入了扩展的类型系统,包括结构化类型和类型继承机制。

结构化类型让我们能够直接在数据库中表示复合属性。比如,我们可以为人员信息中的姓名定义一个结构化类型:
|CREATE TYPE 姓名 AS ( 姓 VARCHAR(10), 名 VARCHAR(20) ) FINAL;
这里的FINAL关键字表示这个类型不能被继承,我们稍后会详细讨论类型继承。
类似地,我们可以为地址信息定义一个更复杂的结构化类型:
|CREATE TYPE 地址 AS ( 街道 VARCHAR(50), 城市 VARCHAR(20), 邮政编码 VARCHAR(10) ) NOT FINAL;
NOT FINAL表示这个类型允许被其他类型继承,这为将来的扩展留下了空间。
有了这些自定义类型,我们就可以在创建表时使用它们:
|CREATE TABLE 员工 ( 员工编号 INTEGER, 姓名 姓名, 工作地址 地址, 入职日期 DATE );
这样的设计让数据结构更接近现实世界中的概念模型。一个员工的姓名不再是简单的字符串,而是包含姓和名两个组成部分的复合结构。
要访问复合属性的组成部分,我们使用点号表示法。比如,查询所有员工的姓氏:
|SELECT 姓名.姓 FROM 员工;
或者查询工作在北京的员工:
|SELECT 姓名.姓, 姓名.名 FROM 员工 WHERE 工作地址.城市 = '北京';
这种访问方式既保持了数据的结构完整性,又允许我们灵活地操作其内部组件。
除了在表中使用结构化类型作为属性外,我们还可以创建完全基于某个结构化类型的表:
|CREATE TYPE 员工类型 AS ( 员工编号 INTEGER, 姓名 姓名, 工作地址 地址, 入职日期 DATE ) NOT FINAL; CREATE TABLE 员工表 OF 员工类型;
这种方式创建的表,其每一行都是指定类型的实例,提供了更强的类型安全性。
结构化类型不仅可以包含数据,还可以定义操作这些数据的方法。比如,我们可以为员工类型定义一个计算工龄的方法:
|CREATE TYPE 员工类型 AS ( 员工编号 INTEGER, 姓名 姓名, 工作地址 地址, 入职日期 DATE ) NOT FINAL METHOD 计算工龄(查询日期 DATE) RETURNS INTEGER;
然后单独创建方法的实现:
|CREATE INSTANCE METHOD 计算工龄(查询日期 DATE) RETURNS INTEGER FOR 员工类型 BEGIN RETURN EXTRACT(YEAR FROM 查询日期) - EXTRACT(YEAR FROM SELF.入职日期); END;
这里的SELF关键字指向当前的员工实例,类似于面向对象编程中的this指针。
为了创建结构化类型的实例,我们需要使用构造函数。SQL会自动为每个结构化类型提供一个默认的无参构造函数,但我们也可以定义自己的构造函数:
|CREATE FUNCTION 姓名(姓 VARCHAR(10), 名 VARCHAR(20)) RETURNS 姓名 BEGIN SET SELF.姓 = 姓; SET SELF.名 = 名; END;
使用构造函数插入数据:
|INSERT INTO 员工 VALUES ( 1001, NEW 姓名('李', '小明'), NEW 地址('中关村大街1号', '北京', '100080'), DATE '2020-03-15' );
类型继承是面向对象编程的核心概念之一,它允许我们基于已有类型创建新类型,新类型会自动继承父类型的所有属性和方法。

假设我们有一个基本的人员类型:
|CREATE TYPE 人员 AS ( 姓名 VARCHAR(50), 身份证号 VARCHAR(18) ) NOT FINAL;
现在我们可以基于这个基本类型创建更专门的类型。比如学生类型:
|CREATE TYPE 学生 UNDER 人员 AS ( 学号 VARCHAR(20), 专业 VARCHAR(50) ) NOT FINAL;
教师类型:
|CREATE TYPE 教师 UNDER 人员 AS ( 工号 VARCHAR(20), 职称 VARCHAR(30) ) NOT FINAL;
在这个例子中,学生和教师都是人员的子类型,它们自动继承了姓名和身份证号属性,同时添加了各自特有的属性。
类型继承让我们能够建立层次化的类型体系,避免重复定义通用属性,同时保持类型之间的逻辑关系。
子类型不仅继承父类型的属性,也继承其方法。如果子类型需要提供不同的方法实现,可以使用方法重写:
|CREATE TYPE 学生 UNDER 人员 AS ( 学号 VARCHAR(20), 专业 VARCHAR(50) ) NOT FINAL OVERRIDING METHOD 获取信息() RETURNS VARCHAR(200);
这样,学生类型就可以提供自己特有的信息获取逻辑,比如包含学号和专业信息。
在SQL的类型系统中,每个值都有一个最具体的类型。比如,如果一个实体既是人员类型也是学生类型,那么它的最具体类型就是学生,因为学生是人员的子类型。 这个概念很重要,因为它决定了系统如何选择合适的方法实现。当我们调用一个方法时,系统会根据对象的最具体类型来选择对应的方法实现。
需要注意的是,SQL标准不支持多重继承。也就是说,一个类型不能同时继承自多个父类型。这个限制是为了避免复杂的继承冲突问题。 比如,如果我们想定义一个既是学生又是教师的助教类型,在不支持多重继承的情况下,我们需要选择一个主要的继承路径,然后通过其他方式来表达复合身份。
有了类型继承的概念基础,我们现在可以进一步探讨表继承。表继承是将类型继承的概念应用到数据库表级别,它对应了实体-关系模型中的特化和泛化概念。

假设我们已经定义了前面提到的人员、学生、教师类型,我们可以基于这些类型创建相应的表结构:
|CREATE TABLE 人员表 OF 人员;
然后创建继承自人员表的子表:
|CREATE TABLE 学生表 OF 学生 UNDER 人员表; CREATE TABLE 教师表 OF 教师 UNDER 人员表;
这种表结构的建立方式体现了现实世界中的“是一种”(is-a)关系。学生是一种人员,教师也是一种人员,因此学生表和教师表都继承自人员表。
表继承有一个重要特性:子表中的每一行数据都会自动出现在父表的查询结果中。这意味着当我们查询人员表时,不仅会得到直接插入人员表的记录,还会得到所有学生表和教师表中的记录。 比如,我们分别在不同表中插入数据:
|-- 在人员表中插入一般人员 INSERT INTO 人员表 VALUES ('张三', '110101199001011234'); -- 在学生表中插入学生 INSERT INTO 学生表 VALUES ('李四', '110101199501011234', 'S2020001', '计算机科学'); -- 在教师表中插入教师 INSERT INTO 教师表 VALUES ('王五', '110101198001011234', 'T001', '教授');
当我们查询人员表时:
|SELECT * FROM 人员表;
查询结果会包含所有三条记录,包括直接插入人员表的张三,以及从子表“继承”过来的李四和王五。但是,通过人员表的查询只能访问到人员类型的属性(姓名和身份证号),无法访问学生或教师特有的属性。
表继承的这种特性让我们可以从不同的抽象层次来查看数据。有时我们需要查看所有人员信息,有时只需要查看特定类型的人员信息。
如果我们只想查询直接存储在父表中的数据,不包括从子表继承过来的数据,可以使用ONLY关键字:
|SELECT * FROM ONLY 人员表;
这个查询只会返回直接插入人员表的记录(张三),不会包括学生表和教师表中的数据。
ONLY关键字在删除和更新操作中也很有用。比如:
|DELETE FROM 人员表 WHERE 姓名 = '李四';
这个删除操作会从学生表中删除李四的记录,因为李四虽然通过表继承出现在人员表的查询结果中,但实际上是存储在学生表中的。 而如果我们使用:
|DELETE FROM ONLY 人员表 WHERE 姓名 = '李四';
这个操作不会影响学生表中的李四记录,因为ONLY限制了操作范围只针对直接存储在人员表中的数据。
表继承支持多层继承结构。比如,我们可以在学生表的基础上创建研究生表:
|CREATE TYPE 研究生 UNDER 学生 AS ( 导师姓名 VARCHAR(50), 研究方向 VARCHAR(100) ) NOT FINAL; CREATE TABLE 研究生表 OF 研究生 UNDER 学生表;
这样,研究生表就继承了学生表的所有属性,而学生表又继承了人员表的属性。查询人员表时,会包含研究生表中的数据;查询学生表时,也会包含研究生表中的数据。
首先,父表中的每条记录最多只能对应每个直接子表中的一条记录。比如,人员表中的一个人不能同时在学生表中出现两次。 其次,SQL要求所有相互对应的记录必须来源于单一的插入操作。这意味着一个人不能既作为学生又作为教师出现在系统中,除非存在一个同时继承学生和教师类型的子类型。
这个限制阻止了重叠特化的实现。在现实世界中,一个人完全可能既是学生(比如在读博士)又是教师(比如教授本科课程),但在SQL的表继承体系中,这种情况很难直接表达。
由于SQL表继承的限制,在需要表达重叠特化的场景中,我们通常采用传统的关系设计方法:
创建独立的人员表、学生表、教师表,其中学生表和教师表包含人员表的主键作为外键。这样,同一个人可以同时在学生表和教师表中有记录,通过外键关联到人员表。 虽然这种方法需要更多的连接操作,但它提供了更大的灵活性,能够处理复杂的现实世界关系。
|-- 传统方式 CREATE TABLE 人员 ( 人员ID INTEGER PRIMARY KEY, 姓名 VARCHAR(50), 身份证号 VARCHAR(18) ); CREATE TABLE 学生 ( 人员ID INTEGER REFERENCES 人员(人员ID), 学号 VARCHAR(20), 专业 VARCHAR(50) );
这种设计允许一个人员ID同时出现在学生表和教师表中,从而支持重叠特化。

在前面的讨论中,我们已经看到了复杂数据类型如何解决现实世界建模的问题。现在让我们深入探讨两种重要的集合类型:数组和多重集。这两种类型让我们能够在单个属性中存储多个值,这在许多实际应用中都非常有用。
在开始具体应用之前,我们需要理解数组和多重集的本质区别。
让我们通过一个具体的例子来看看如何使用这些集合类型。
假设我们要为一个在线教育平台设计课程信息表。每门课程可能有多个授课教师(有先后顺序),也可能有多个知识标签(无顺序要求):
|CREATE TYPE 课程信息 AS ( 课程编号 VARCHAR(20), 课程名称 VARCHAR(100), 授课教师 ARRAY[VARCHAR(50)] ARRAY[5], -- 最多5位教师,有顺序 知识标签 MULTISET[VARCHAR(30)], -- 无序标签集合 开课时间 DATE ); CREATE TABLE 课程表 OF 课程信息;
在这个定义中,授课教师是一个最多包含5个元素的字符串数组,而知识标签是一个字符串多重集。
创建数组和多重集的值有专门的语法。对于数组,我们使用方括号并按顺序列出元素:
|INSERT INTO 课程表 VALUES ( 'CS101', '计算机科学导论', ARRAY['张教授', '李教授', '王教授'], MULTISET['编程', '算法', '数据结构', '编程'], DATE '2024-09-01' );
注意在这个例子中,知识标签的多重集包含了重复的编程标签,这在多重集中是允许的。
要访问数组类型中的某个具体元素,我们可以通过索引方式实现。需要注意的是,在SQL的集合类型中,数组的下标通常是从1开始计数的(而不是很多编程语言中的从0开始)。例如,如果我们要获取课程授课教师数组中的第一位教师(通常也是主讲教师),可以使用以下语法:
|SELECT 授课教师[1] FROM 课程表 WHERE 课程编号 = 'CS101';
同理,如果要访问第三位教师,只需将索引修改为3:
|SELECT 授课教师[3] FROM 课程表 WHERE 课程编号 = 'CS101';
如果索引超过实际元素数量,则通常会返回NULL。使用这种下标访问方式,可以灵活地定位和提取数组中的任意元素。
检查某个值是否在集合中是一个常见的需求。我们可以使用IN操作符结合UNNEST函数:
|-- 查找标签中包含"算法"的课程 SELECT 课程名称 FROM 课程表 WHERE '算法' IN (UNNEST(知识标签));
UNNEST是处理集合类型的核心函数,它将集合展开为表格形式,这样我们就可以像处理普通关系一样处理集合数据。
比如,我们想要获取所有课程的教师名单,每个教师占一行:
|SELECT 课程名称, T.教师姓名 FROM 课程表 AS C, UNNEST(C.授课教师) AS T(教师姓名);
对于数组,如果我们还想保留元素的位置信息,可以使用WITH ORDINALITY:
|SELECT 课程名称, T.教师姓名, T.排序 FROM 课程表 AS C, UNNEST(C.授课教师) WITH ORDINALITY AS T(教师姓名, 排序);
这样我们就能知道每位教师在教师列表中的位置。
有时我们需要将包含多个集合属性的表完全展开为传统的关系形式。比如,将课程表展开为每个(课程,教师,标签)组合占一行:
|SELECT 课程编号, 课程名称, T.教师姓名, K.标签名称 FROM 课程表 AS C, UNNEST(C.授课教师) AS T(教师姓名), UNNEST(C.知识标签) AS K(标签名称);
这种展开可能会产生笛卡尔积。如果一门课程有3位教师和4个标签,展开后会产生12行数据。
完全展开集合属性时要注意数据爆炸问题。如果多个集合属性都包含大量元素,展开后的结果可能会非常庞大。
与展开相反的操作是聚合,即将平坦的关系数据重新组织成包含集合的嵌套形式。假设我们有一个展开后的课程教师关系表:
我们可以使用聚合函数将其重新组织:
|SELECT 课程编号, 课程名称, ARRAY_AGG(教师姓名 ORDER BY 教师排序) AS 授课教师数组 FROM 展开课程教师表 GROUP BY 课程编号, 课程名称;
这里ARRAY_AGG函数将分组后的教师姓名聚合成一个数组,ORDER BY确保数组中的元素顺序正确。
我们也可以在子查询中构造集合。比如,根据另一个表的数据动态构造数组:
|SELECT 课程编号, 课程名称, ARRAY(SELECT 教师姓名 FROM 课程教师关系 WHERE 课程教师关系.课程编号 = 课程表.课程编号 ORDER BY 授课顺序) AS 授课教师, MULTISET(SELECT 标签名称 FROM 课程标签关系 WHERE 课程标签关系.课程编号 = 课程表.课程编号) AS 知识标签 FROM 课程表;
这种方式特别适合将规范化的关系数据转换为包含集合的非规范化格式。
SQL还提供了一些专门用于集合操作的函数。比如:
|-- 删除多重集中的重复元素 SELECT 课程名称, SET(知识标签) AS 去重标签 FROM 课程表; -- 检查一个多重集是否是另一个的子集 SELECT 课程名称 FROM 课程表 WHERE MULTISET['编程', '算法'] SUBMULTISET OF 知识标签;
通过这些集合类型和相关操作,我们能够更自然地表达复杂的数据关系,减少因为过度规范化而带来的查询复杂性。
假设我们正在为一个在线书店设计数据库。创建一个图书的结构化类型,包含以下字段:
然后基于这个类型创建图书表,并插入一条测试数据。
|-- 创建作者信息结构化类型 CREATE TYPE 作者信息 AS ( 姓名 VARCHAR(50), 国籍 VARCHAR(30) ) NOT FINAL; -- 创建出版信息结构化类型 CREATE TYPE 出版信息 AS ( 出版社 VARCHAR(50), 出版年份 INTEGER, ISBN VARCHAR(20) )
基于上一题的图书类型,创建一个继承自图书类型的电子书类型,添加以下特有字段:
然后创建电子书表并插入数据。
|-- 创建电子书类型继承自图书类型 CREATE TYPE 电子书类型 UNDER 图书类型 AS ( 文件格式 VARCHAR(10), 文件大小_MB INTEGER, 下载链接 VARCHAR(200) ) NOT FINAL; -- 创建电子书表 CREATE TABLE 电子书表 OF 电子书类型 UNDER 图书表; -- 插入电子书数据 INSERT INTO 电子书表 VALUES ( 2001, 'Python编程入门',
使用以下表结构进行查询练习:
人员表 (继承根表):
学生表 (继承自人员表):
教师表 (继承自人员表):
写出查询语句:
|-- 1. 查询所有人员信息(包括继承的子表数据) SELECT * FROM 人员表; -- 2. 查询只有直接存储在人员表中的人员(不包括子表) SELECT * FROM ONLY 人员表; -- 3. 查询所有学生的姓名和专业 SELECT 姓名, 专业 FROM 学生表;
创建一个课程表,包含以下字段:
插入几条测试数据,然后写出查询语句:
|-- 创建课程表 CREATE TYPE 课程类型 AS ( 课程ID INTEGER, 课程名称 VARCHAR(100), 授课教师 ARRAY[VARCHAR(50)] ARRAY[3], 课程标签 MULTISET[VARCHAR(30)], 学分 INTEGER ) NOT FINAL; CREATE TABLE 课程表 OF 课程类型; -- 插入测试数据 INSERT INTO 课程表 VALUES ( 101, '数据库系统原理'
基于习题4的课程表,完成以下操作:
|-- 1. 将课程标签展开为单独的行 SELECT 课程名称, K.标签 AS 课程标签 FROM 课程表 AS C, UNNEST(C.课程标签) AS K(标签); -- 2. 使用聚合函数重新构造包含标签数组的课程信息 SELECT 课程ID, 课程名称, 学分, ARRAY_AGG(T.教师姓名 ORDER BY T.序号) AS 授课教师, MULTISET_AGG(K.标签) AS 课程标签 FROM