打开一个订单后台,我们看到的往往是客户表、订单表、商品表和订单明细表。关系模型做的事情,是把这种表格直觉变成一套精确规则:一行是什么,一列能放什么,怎样唯一找到一行,表与表怎样保持一致,以及怎样把多个小操作组合成查询。
这套规则有一个很实用的特点:查询的输入是关系,输出仍然是关系。因此,一个查询结果可以继续交给下一个操作。只要先把“关系、键、模式”这些基本概念摆正,后面的连接、集合运算和查询优化就会顺着同一条逻辑展开。
关系数据库由一组名称互不重复的关系组成。日常交流中,我们常把关系叫作“表”,把元组叫作“行”,把属性叫作“列”。这两套词可以对应起来,但关系模型还附带了集合、域和约束等精确定义,不能把它简单理解成一张带网格的电子表格。
以一个小型电商系统为例,订单 关系可以写成下面这样:
其中,整个表是一个关系;每一行是一个元组;订单号、客户号、状态、金额 是属性。一个四属性的元组可以写成 (O24001, C018, 已支付, 268.00)。从数学上看,它是一组按属性位置组织的值,表达“某个订单号、某个客户、某种状态和某个金额彼此关联”这一事实。

同一张关系可以从整体、行、列和值集合四个层次阅读。
关系中的元组没有固有顺序。把订单按金额降序显示、按订单号升序显示,或者采用任意物理存储顺序,只要包含的元组相同,关系就没有改变。排序是展示结果的一种安排,不是关系本身的定义。
形式化关系把元组看成集合元素,所以不保留完全相同的重复元组。如果两行所有属性值都相同,它们无法表达两个可区分的事实。实际数据库产品有时允许普通查询结果出现重复行,那是工程实现对“集合”语义的扩展;学习关系代数时,我们先按集合语义推理。
关系的度是属性个数,关系的基数是当前元组个数。上面的 订单 关系度为 4,当前基数为 3。度属于结构,通常较稳定;基数属于当前数据,会随插入和删除改变。
每个属性都有一个允许取值的集合,叫作该属性的域。例如:
金额 的域可以限定为不小于 0、最多保留两位小数的十进制数;状态 的域可以限定为由“待支付、已支付、已发货、已取消”组成的集合;客户号 的域可以限定为满足特定格式的字符串。域不只是“文本、整数、日期”这样的粗粒度类型。业务范围、格式和单位也会影响一个值是否合法。99.00 对金额可能合法,对订单状态显然不合法;-20.00 虽然能被十进制类型保存,却可能违反金额域的业务限制。
关系模型要求属性域是原子的,也就是把每个属性值当作不可再分的单位。原子性不是由字符串长短决定的,而是由数据库要怎样解释和操作这个值决定的。
假设 联系电话 保存 13800001111。如果系统只需要整串比较、显示和拨号,它可以被当成一个原子值。如果查询需要独立筛选国家码、区号和本地号码,那么继续把三部分塞在一个属性里,就等于把一个可分结构伪装成原子值。此时应拆成多个属性,或建立独立的联系方式关系。
更明显的反例是把 13800001111;021-66889900 放进一个单元格。它其实是电话号码集合,单个单元格内部已经藏了一张小表。这样的设计难以约束每个号码的格式,也不便于查询“拥有某个号码的客户”。

值是否原子,要看后续操作是否需要识别它的内部组成。
NULL 是特殊标记,表示某个值未知,或者这个属性对当前元组不适用。它不等于数字 0,不等于空字符串,也不等于字符串 "NULL"。例如,订单尚未发货时,物流单号 可以是 NULL;金额为 0 则表示金额已知且确实为零。
NULL 会让比较和逻辑判断变复杂。物流单号 = NULL 不能按普通相等关系理解,因为“未知值是否等于另一个未知值”仍然未知。设计模式时应先判断缺失是否具有明确业务含义,并尽量通过合理拆表、默认流程或非空约束减少不必要的 NULL。
不要为了消除 NULL 就随意填入 0、空串或“暂无”。占位值会伪装成真实数据,统计和连接时更难识别。正确做法是先区分“未知”“尚未发生”和“不适用”,再决定是否需要拆分关系或保留 NULL。
下面的检查器把域、原子性和空值放在一起。修改值并选择属性语义,观察同一段文本为什么可能得到不同判断。
谈数据库时,需要区分数据库模式和数据库实例。模式是数据库的逻辑设计,说明有哪些关系、各关系有哪些属性、属性来自什么域,以及要满足哪些约束。实例是某一时刻数据库里实际存在的全部元组。
一个关系模式常写成:
订单(订单号,客户号,下单时间,状态,金额)这行定义只告诉我们结构,没有列出任何具体订单。若在上午 10 点查看数据库,可能有 1200 个订单元组;下午 4 点又插入 300 个并更新若干状态,实例已经变化,模式通常仍保持不动。

模式像类型定义,实例像变量在某一时刻保存的值。
可以借用程序设计中的概念来理解:关系模式近似于类型定义,关系本身近似于变量,关系实例近似于变量当前的值。变量的值可以频繁改变,类型定义不会随着每次赋值变化。同理,订单插入、删除和更新都在改变实例,不是在重写模式。
模式写得更完整时,还会给出域和约束:
订单(
订单号:订单编号域,
客户号:客户编号域,
下单时间:时间戳域,
状态:订单状态域,
金额:非负金额域
)日常表达常用同一个名称 订单 同时指模式和实例。判断具体含义要看上下文:“给订单增加渠道属性”是在改模式;“订单新增一行”是在改实例。
不同关系可以通过语义一致的属性关联。比如:
客户(客户号,姓名,等级)
订单(订单号,客户号,下单时间,状态,金额)客户号 同时出现在两个模式中,让我们能够从订单找到下单客户。不过,仅仅同名还不够。两边属性必须表达同一类对象,域应当兼容,并且通过外键之类的约束保证引用有效。把 订单.客户号 和 仓库.仓库号 都缩写成 编号,不会产生有意义的关联。
模式描述“允许出现什么样的数据”,实例描述“此刻实际出现了什么数据”。检查某一行是否合法,需要同时查看模式中的域与约束;统计当前有多少行,则是在观察实例。
关系是元组的集合,我们需要能区分任意两个元组的属性集合。键不是某一行临时表现出来的巧合,而是对所有合法实例都要成立的约束。今天恰好没有客户重名,不能据此宣布 姓名 是键;只有业务规则保证它始终唯一,才有资格参与键的定义。
设关系模式 订单 的属性集合为 。若属性集合 能保证任意两个不同元组在 上的取值不同,那么 是超键:
假设 订单号 全局唯一,那么只含订单号的集合是超键,含订单号与客户号的集合也是超键。后者虽然能唯一定位订单,却多带了一个不需要的属性。只要 是超键,包含 的任何属性超集也都是超键。
候选键是最小超键:它本身能唯一标识元组,删除其中任一属性后都不再能保证唯一。最小指集合包含关系上的最小,不是字符最短,也不是数值最小。
一个关系可以有多个候选键。例如支付记录可能同时规定 支付流水号 唯一、支付平台+平台交易号 的组合也唯一,那么二者都是候选键。

候选键去除了超键中的多余属性,主键再从候选键中选出一个主要标识。
设计者从候选键中选出一个作为主键。主键优先选择取值稳定、结构简洁且很少改变的属性。姓名、地址、手机号等自然属性常随业务变化,不适合作为长期主标识;系统生成的订单号通常更稳定。
主键可以由多个属性组成。订单明细(订单号,行号,商品号,数量,成交单价) 中,同一订单内行号不重复,但不同订单都可能有第 1 行,所以只含行号的集合不能唯一标识明细。订单号与行号合在一起才是复合主键。
若 订单.客户号 的每个非空取值都必须出现在 客户.客户号 中,那么 订单.客户号 是引用 客户 的外键。订单 是引用关系,客户 是被引用关系。这条约束禁止创建一个指向不存在客户的订单。
形式上,从关系 的属性集合 到关系 主键 的外键约束要求:对于 的每个合法元组,其 值都能在 某个元组的 值中找到。
外键可以是复合属性。例如 订单明细.订单号 引用 订单.订单号;若另一个关系要引用某条明细,就必须同时携带 订单号 与 行号,因为被引用主键是复合的。

外键箭头从保存引用值的一方指向提供合法主键值的一方。
更一般的参照完整性约束允许引用目标不是主键,只要求引用值能在目标关系的指定属性中出现。外键是其中更严格、也最常由数据库直接支持的一类:目标属性必须是被引用关系的主键。工程实践中,有的系统还允许引用具有唯一约束的候选键,但建模时仍应明确区分主键外键与更一般的参照要求。
下面的推理器使用一组简化业务规则检查属性集合。你可以切换组合,观察“能唯一标识”与“已经最小”为什么是两道不同的判断。
当关系数量增加,只看一串模式定义很难追踪引用。模式图把每个关系画成一个框,顶部写关系名,框内列属性。主键属性通常用下划线或钥匙标记,外键用箭头从引用属性指向被引用关系的主键。
先找关系框,确认系统保存了哪些不同种类的事实。订单 保存交易头信息,订单明细 保存每个订单中的商品行,商品 保存可复用的商品资料。把它们拆开是为了让每类事实只维护一处。
再看主键。若 订单明细 的 订单号 与 行号 同时带主键标记,说明它们共同标识一条明细,不能只拿行号查找全库唯一明细。
最后沿外键箭头走。订单明细.商品号 → 商品.商品号 表示每条明细引用一个已经存在的商品。反向阅读则是“一种商品可以被多条订单明细引用”。箭头表达的是参照约束,不等同于程序执行时的数据流方向。
两种图都可能包含方框和连线,但表达重点不同。模式图直接展示关系模式、属性、主键与外键;概念建模阶段的实体关系图更关注实体、联系和基数。看到相似外观时,应先检查图例和符号含义,不要把两套记法混用。
如果一条连线表达的是一般参照完整性,而目标属性并非主键,应在图例中使用不同箭头或文字注释。没有图例时,不要仅凭线条形状猜测约束类型。
查询语言让用户从数据库请求信息,它通常比通用编程语言更接近数据层。按表达计算的方式,可以区分命令式、函数式和声明式三类思路。
命令式查询描述一串有先后顺序的操作,并允许中间状态随执行更新。表达重点是“先做什么,再做什么”。
函数式查询把计算写成函数求值:函数接收关系或其他函数的结果,再返回新的关系,不通过副作用更新程序状态。关系代数属于这种思路,因为每个运算符都把输入关系映射成结果关系。
声明式查询只描述想得到什么,不规定具体执行步骤。数据库系统再根据索引、数据量和统计信息选择执行方式。元组关系演算和域关系演算是形式化的声明式语言;SQL 在使用体验上主要是声明式的,同时也吸收了另外两类语言的能力。
这三类不是商业语言之间互不相交的盒子。实际语言可能同时有声明式查询、函数式表达式和命令式过程扩展。分类的价值在于看清“用户承担多少执行细节”。
关系代数是一组闭合运算:一个或两个关系作为输入,结果仍是关系。只接收一个关系的运算叫一元运算,例如选择、投影和重命名;接收两个关系的运算叫二元运算,例如并、差、笛卡尔积和连接。

闭包性质让一个操作的结果可以直接成为下一个操作的输入。
下面继续使用三个关系:
客户(客户号,姓名,城市)
订单(订单号,客户号,状态,金额)
订单明细(订单号,行号,商品号,数量)选择用小写希腊字母 表示,谓词写在下标,输入关系写在括号里。找出已支付订单:
选择不改变属性集合,只减少或保持元组数量。谓词可以使用 ,也可以用 、、 组合多个条件:
谓词也能比较同一元组中的两个属性。例如折扣关系中同时保存 原价 与 成交价,要找异常的“成交价高于原价”记录,可以写属性间比较,而不必把常量写死。
投影用大写希腊字母 表示,下标列出结果需要的属性:
投影减少或保持属性数量。在严格集合语义中,删除某些列后如果产生相同元组,重复项会合并。例如两个不同订单可能来自同一客户且金额相同,投影到客户号与金额后只能保留一个相同二元组。
广义投影允许在属性列表中写表达式。若想显示以“元”为单位的金额和按 100 个积分兑换 1 元计算的积分值,可以在投影中加入算术表达式,并在需要时重命名结果属性。
找出已支付订单的订单号与金额,可以先选择,再投影:
这里投影的输入不是永久关系名,而是选择表达式产生的临时关系。正因为输入和输出类型相同,关系代数表达式才能像算术表达式一样嵌套。读复杂表达式时,先从最内层开始,逐层写出“当前关系有哪些属性、可能有哪些元组”,比死记符号可靠。
二元运算把两个关系交给同一个运算符。它们解决两类问题:一类是在不同关系间配对信息,另一类是对结构兼容的关系做集合运算。
笛卡尔积写作 。若 有 个元组、 有 个元组,结果有 个元组。每个结果元组由 的一行与 的一行拼接而成。
设 客户 有 3 行,订单 有 4 行,客户 × 订单 就有 12 行。其中大多数配对不是“这个客户下了这个订单”,只是机械枚举。笛卡尔积本身不判断业务关系。
若两个输入都有 客户号 属性,结果必须用 客户.客户号 和 订单.客户号 这样的限定名消除歧义。参与笛卡尔积的关系也需要可区分的名称;同一关系与自身相乘时,必须先用重命名给两次出现分配不同名称。
连接把笛卡尔积与选择合成一个运算。 连接的定义是:
客户与订单按客户号连接:
这会保留客户号相等的配对,去掉无关组合。结果仍可能同时包含左右两份客户号,可再用投影去掉冗余列。

笛卡尔积负责配对,连接条件负责说明哪些配对有业务意义。
自然连接会自动要求两个模式中的同名属性相等,写起来更短,但也更依赖属性命名。若以后两个关系新增了恰好同名却不该比较的属性,旧查询的含义可能悄悄变化。需要长期复用的查询,显式写连接条件通常更容易审查。
普通内连接不会保留没有匹配项的元组。若某位客户还没有订单,他不会出现在客户与订单的内连接结果里。外连接可以保留这类元组,并用 NULL 补齐缺失一侧的属性,但这也会重新引入空值语义。
常见的关系代数扩展还包括聚合。聚合可以对一组值计算计数、总和、平均值、最小值或最大值,也可以先按城市、状态等属性分组,再分别计算。例如“每个城市的已支付订单总额”会先形成城市分组,再对各组金额求和。聚合会改变结果模式,因此要明确分组属性和新产生的统计属性名称。
并 返回出现在任一输入中的元组;交 返回同时出现在两个输入中的元组;差 返回在 中但不在 中的元组。差有方向,通常 。
集合运算要求两个输入相容:
设:
那么 是“至少有已支付或已取消订单的客户”, 是“两类订单都出现过的客户”, 是“有已支付订单但没有已取消订单的客户”。投影先把两个输入都整理成单属性的客户号关系,因此集合运算才具有明确含义。
下面的可视化器内置客户和订单样例。切换操作后,观察选择、投影、笛卡尔积与连接如何改变结果的行数和列数。
当表达式变长时,可以把中间结果交给临时关系变量。赋值运算用 表示:
前两行只给中间关系命名,最后一行才产生要展示的结果。赋值没有增强关系代数能表达的查询范围,它只是把嵌套表达式拆成容易检查的步骤。这里赋给的是临时关系变量;若把结果写回永久关系,那已经是数据库修改,不是单纯查询。
关系代数表达式的结果没有天然名称。重命名运算 可以给结果关系命名:
它也能同时改关系名和属性名。若表达式 有 个属性,可以写成:
属性重命名对于广义投影尤其有用。计算出的 金额×0.01 若没有清楚名称,后续表达式很难引用它。
假设要找“金额高于订单 O24002 的所有订单”。查询既要扫描一次 订单 作为候选,又要扫描一次 订单 找到基准金额。如果两边都叫 订单.金额,比较条件会产生歧义。先重命名:
再做笛卡尔积、比较两边金额并投影需要的列:
关系可以按位置编号引用属性,但位置编号不如语义名称清楚,模式一变还容易引用错列。重命名是更稳妥的写法。
同一个结果常有多种关系代数表达式。考虑“找出杭州客户的订单”。一种写法先连接全部客户与订单,再筛选城市:
另一种写法先筛出杭州客户,再连接订单:
只要选择条件只引用 客户 的属性,这两个表达式对任何合法实例都给出相同结果,所以它们等价。第二种通常先减少客户元组,再参与连接,中间结果可能更小。数据库优化器正是利用这类代数等价规则,在不改变结果的前提下寻找成本更低的执行方案。

代数表达式规定结果,优化器可以改用更省工作的等价执行顺序。
检查复杂查询时可以沿着一条固定路线:先写每个输入关系的属性,确认选择条件引用哪些关系;再检查投影是否过早删除了后续需要的连接属性;接着核对集合运算是否相容;最后才考虑怎样用等价变换减少中间结果。