关系型数据库设计小结

前段时间因为项目需要搭建一个web服务器,后端Web框架我调研了几个,比如Python的Flask,Django, NodeJs的Express,JavaEE的Spring,以及C++的CppCMS, 经过权衡拓展性开发效率,最后选择了Django。 也许Python不是最好的选择,但至少目前来看工作的还挺顺利。

但是当时对于数据库的选择,却走了一点弯路。因为平时对于数据库接触不多,所以一开始选择了MongoDB 作为数据库。这在初期是没啥问题的,但后来随着项目推进,产品经理不断地增加需求(我要…我要…我还要…), 数据库中各个collection的耦合度也越来越高,甚至同一个collection也有大量的冗余数据。虽然也有办法进行优化, 但是我经过查阅资料和进度考量,最后还是决定趁着项目尚未release,将NoSQL替换为关系型的数据库。

关系数据库简介

关系数据库由由埃德加·科德(IBM)在1969年左右提出。自推出后就成为商业应用的主要数据库模型(与其他数据库模型, 如分级,网络或对象模型相比)。如今已有许多商业关系数据库管理系统(RDBMS),如Oracle,IBM DB2和Microsoft SQL Server等。 也有许多免费的开源关系数据库,如MySQL,mSQL(mini-SQL)和嵌入式JavaDB(Apache Derby)等。

关系数据库将数据存储在表(table)中。一个表由行和列组成。行称为记录(record)或元组(tuple),列称为字段(field)或属性(attribute)。 数据库的表类似于电子表格。不过关系数据库可以在这些表格中产生关联,使得可以有效地存储大量的数据,以及高效地检索数据。

SQL(结构化查询语言)通常用来对关系数据库进行操作。

关系数据库设计步骤

数据库的设计对经验的要求比理论要高,因为你必须作出许多选择。数据库通常是为了某种应用的需求而高度定制的,因此, 通常在数据库设计的指导里,通常都是指出不要做什么而不是做什么,但最后的决定权还是在设计者的手中。

尽可能地收集需求,以及定义你的数据库的最终目的。 比如要开发书店查询应用,就要先知道应用有什么需求, 如如何添加书籍,如何查询现有书籍,如何查询订单,生成的报告格式如何,等等。 在这个阶段的分析中,在纸上画出输入表单,以及查询和报告的草图,通常会有不少帮助。

一旦需求明确,接下来就要确定有哪些数据需要存储到数据库中。通常我们都是将数据基于分类存储到不同的表中。 比如设计一个书店的数据库,就需要对书本,作者,出版社,顾客,订单等分类进行分表;而对每个表, 则要定义好需要哪些列(记录),以书本为例,需要有标题,作者,出版社,出版日期,ISBN,价格等信息。 对于每一个表,我们需要选择一列(或者多列)作为主键(primary key)

关于主键

在关系模型中,表不可以含有重复的行,否则会导致检索出现歧义。为保证唯一性,每个表都有某一列(或者多列)作为主键, 其目的是可以唯一区分每一行。如果主键只由某列构成,则被成为简单键(simple key),若由多列组成则成为组合键(composite key)。 大多数商业数据库都基于主键来生成索引以提高查询的速度。另外,主键还被用来被其他表用作关系引用(详见下文)。

主键的选择由库的设计者来决定,要遵循以下原则:

  • 主键的值必须是唯一的(即不可重复)
  • 主键不能为空

除此之外,对于主键的选取还有一些best practice:

  • 主键的值不可修改。因为主键可能会在其他表中用来引用,如果改了主键的值,就需要把其他表的引用都更新。
  • 主键可以是任何类型,但最好是整数(效率原因)
  • 主键最好用简单键,如果一定要用组合键,要尽量用最少的列

目前的数据库大都可以不主动指定主键,而是由于数据库自己添加额外的一列类型为自增整数(AutoNumber)并指定为主键。

在关系数据库中包含独立且不相关的表格通常没有太大意义,如果真是这种情况你可以考虑使用NoSQL或者电子表格来存储这些内容。 关系数据库的魅力所在就是“关系”二字,甚至可以说设计关系数据库的成败所在就是明确各个表之间的关系。表间关系的类型有如下三种:

  • 一对多(one-to-many)
  • 多对多(many-to-many)
  • 一对一(one-to-one)

考虑一个族谱关系的例子,一个母亲可能会有0个或多个小孩,但是任意一个小孩都有且只有一个母亲。这样的关系便称为一对多。 一对多的关系不能只用一个表来保存。为什么?以前面的例子来说,我们一开始可能会考虑建立一个名为Mothers的表, 其中保存了母亲的信息如年龄,姓名,血型等,对于其下的小孩,可以创建不同的列,如老大,老二,老三… 但这样我们会面临一个问题,即列的数量是不确定的。换个方向来说,我们可以建立名为Children的表,其中存储小孩的基本信息, 以及其母亲的信息。这样看似能满足要求,但是由于不同的小孩可能会有相同的母亲,因此表中的重复数据是很多的。

因此,考虑支持一对多的数据库关系,我们应该建立两个表,分别为Mothers和Children,只保存各自的属性,并且设置分别的主键为 MotherID和ChildrenID。然后我们可以通过在Children新建一列包含MotherID建立一对多的关系,如下图所示:

one2many

其中Children表里的MotherID列又被称为约束或外键(Foreign Key),用SQL来描述如下:

sql

CREATE TABLE `Mothers` ( 
`MotherID` INTEGER NOT NULL AUTO_INCREMENT,
`Name` VARCHAR(100) NOT NULL,
`Age`  SMALLINT NOT NULL,
`BloodType` VARCHAR(2) NOT NULL,
PRIMARY KEY (`MotherID`)
);
CREATE TABLE `Children` ( 
`ChildrenID` INTEGER NOT NULL AUTO_INCREMENT,
`MotherID` INTEGER NOT NULL,
`Name` VARCHAR(100) NOT NULL,
`Age`  SMALLINT NOT NULL,
`Sex` VARCHAR(50) NOT NULL,
`BloodType` VARCHAR(2) NOT NULL,
PRIMARY KEY (`ChildrenID`),
FOREIGN KEY (`MotherID`) REFERENCES `Mothers` (`MotherID`)
);

考虑一个“产品销售”数据库的例子,某个客户的订单包含一个或者多个产品,而某个产品又可能出现在多个订单之中, 这样的关系便称为是多对多的。为了构建这样的关系,我们先从两个表订单Orders和产品Products开始看。 表Products含有关于产品的信息(如名称,介绍,库存)以及一个主键ProductID;表Orders则包含订单信息 (如客户ID,订单日期,订单状态)以及主键OrderID。同样地,我们没法简单地将所有购买的产品保存在订单表里, ,因为订单所包含的产品记录是不固定的;同理,也没法将所有关联订单保存在产品表里。

因此,为了支持这种多对多的关系,我们需要第三个表。在本例子中,姑且将其命名为OrderDetails, 其中每一行都包含了特定的订单信息,对于这个表,主键应为组合键,包含两列信息, 分别为OrderIDProductID, 而这两列也是对应OrdersProducts表的Foreign Key,如下图所示:

many2many

SQL描述如下:

sql

CREATE TABLE `Orders` (
`OrderID` INTEGER NOT NULL AUTO_INCREMENT,
`OrderDate` DATETIME DEFAULT CURRENT_TIMESTAMP,
`CustomerID` INTEGER NOT NULL,
PRIMARY KEY (`OrderID`)
);

CREATE TABLE `Products` (
`ProductID` INTEGER NOT NULL AUTO_INCREMENT, 
`Name` VARCHAR(100) NOT NULL,
`Stock` INTEGER DEFAULT 0,
PRIMARY KEY (`ProductID`)
);

CREATE TABLE `OrderDetails` (
`OrderID` INTEGER NOT NULL,
`ProductID` INTEGER NOT NULL, 
FOREIGN KEY (`OrderID`) REFERENCES `Orders` (`OrderID`),
FOREIGN KEY (`ProductID`) REFERENCES `Products` (`ProductID`),
PRIMARY KEY (`OrderID`,`ProductID`)
);

事实上,多对多的关系是以两组一对多的关系来实现的,额外引入的表被称为junction table即连接表。 从上面的例子可以看到,每个产品(product)都会在OrderDetails表里出现多次,但OrderDetails 里的每一行都只包含一个产品,若每个订单有多个产品则用多行来表示。相应地对订单(Order)也是类似。

考虑一个“产品信息”数据库,其中除了产品名称,产品数量等基本信息外,还需要保存产品图片,产品详细等富文本详情信息, 一个产品只有0个或者一个详情,一个详情有且只对应一个产品,因此这类关系就可以归类为一对一关系。 有些数据库限制了列的数量,或者我们需要将部分敏感信息用另外的表保存,这些情况都可以引进一对一的关系。

回到前面的例子,我们需要分裂出一个称为ProductDetails的表,与Products构成一对一的关系。图我就不画了, 用SQL描述如下:

sql

CREATE TABLE `Products` (
`ProductID` INTEGER NOT NULL AUTO_INCREMENT,
`Name` VARCHAR(50) NOT NULL,
PRIMARY KEY (`ProductID`)
);
CREATE TABLE `ProductDetails` (
`ProductID` INTEGER NOT NULL AUTO_INCREMENT,
`DetailInfo` VARCHAR(65535),
FOREIGN KEY (`ProductID`) REFERENCES `Products` (`ProductID`),
PRIMARY KEY (`ProductID`)
);

可以看到在ProductDetails表中,主键和外键都为同一列, 这保证了一对一的正确性。值得一提的是,这里保证了Products 可以对应0个或1个ProductDetails,但ProductDetails必须对应一个Products,如果后者对前者不是强关联,如“丈夫-妻子” 的关系,那么后者可以不以主键作为外键,而是以另外一列声明为UNIQUE的属性作为外键即可。

精炼及规格化

当设计好一个数据库或者拿到已有的数据库时,我们可能会想要:

  • 增加更多的列
  • 为某个表中的可选数据创建一个新表并建立一对一关系
  • 将一个大表分裂为两个小表

在进行这些操作时,下列的规则就可以作为参考。

范式(Normal Form),指的是符合某一种级别的关系模式的集合,表示一个关系内部各属性之间的联系的合理化程度, 可以在某种程度上认为是一张数据表的表结构所符合的某种设计标准的级别。常见的范式有第一范式、第二范式、…第六范式, 其严格程度依次上升,一般设计上满足第三范式即可满足日常使用。

第一范式又称为1NF(First Normal Form),是对关系模式的基本要求,不满足第一范式的数据库就不是关系数据库。 数据库表中的字段都是单一属性的,不可再分。这个单一属性由基本类型构成,包括整型、实数、字符型、逻辑型、日期型等。 同一列中不能有多个值,即实体中的某个属性不能有多个值或者不能有重复的属性。 如果出现重复的属性, 就可能需要定义一个新的实体,新的实体由重复的属性构成,新实体与原实体之间为一对多关系。

简而言之,第一范式就是没有重复的列。

第二范式(2NF)是在第一范式(1NF)的基础上建立起来的,即满足第二范式必须先满足第一范式(1NF)。 第二范式要求数据库表中的每个实例或行必须可以被唯一地区分。为实现区分通常需要为表加上一个列,以存储各个实例的惟一标识。 例如员工信息表中加上了员工编号(EmployeeID)列,因为每个员工的员工编号是惟一的,因此每个员工可以被惟一区分。 这个唯一属性列也就是我们之前提到过的主键

第二范式也要求实体的属性完全依赖于主键。所谓完全依赖是指不能存在仅依赖主关键字一部分的属性, 例如含有多列的主键,如前文提到的OrderDetails,主键为ProductIDOrderID,若含有一列为产品单价ProductPrice, 则不符合2NF,因为ProductPrice只依赖于ProductID而不依赖于OrderID,因此此属性应该保存在Products表中。

简而言之,第二范式就是属性应完全依赖于其主键。

满足第三范式(3NF)必须先满足第二范式(2NF)。第三范式要求数据表中如果不存在非关键字段对任一候选关键字段的传递函数依赖。 所谓传递函数依赖,指的是如果存在"A → B → C"的决定关系,则C传递函数依赖于A。 例如,存在一个部门信息表,其中每个部门有部门编号(DepartmentID)、部门名称(DepartmentName)、部门简介等信息。 这样一个表就不是3NF的,因为存在传递依赖(EmplyeeID->DepartmentID->DepartmentName),因此在员工信息表中列出部门编号后就不应再将部门名称、 部门简介等与部门有关的信息再加入员工信息表中,而是将这部分数据保存在部门信息表中,如果不存在部门信息表, 则根据第三范式也应该构建它,否则就会有数据冗余,并且容易产生更新、插入的异常。

简而言之,第三范式就是任一非主键属性不应依赖于其它任何非主键属性。

BCNF(Boyce Codd Normal Form)又称为3.5NF,其出现的目的是为了弥补3NF的缺陷。在满足3NF的前提下, 如果数据库表中如果不存在任何字段对任一候选关键字段的传递函数依赖则称为符合BCNF。 只有少部分情况下满足3NF而不满足BCNF,这里以今日会议室预订表为例。考虑有以下表格:

会议室编号 开始时间 结束时间 会议类型
1 09:30 10:30 A1
1 10:00 11:30 A2
2 09:00 09:30 B1
2 14:30 17:30 B2

其中会议类型的定义如下:

  • A1, 在会议室1里的一般会议
  • A2,在会议室1里的管理层会议
  • B1,在会议室2里的一般会议
  • B2,在会议室2里的管理层会议

这个表里所有字段都是候选关键字段,因此显然是满足3NF的,但同时存在以下决定关系:

  • (会议室编号)-> (会议类型)
  • (会议类型) -> (会议室编号)

即关键字段影响关键字段的情况,因此不满足BCNF。

简而言之,BCNF就是任一属性不应依赖于其它非主键属性。

其他更严格的范式超出了本文的范围,因此不再赘述。

除了设计范式,我们也可以通过完整性规则(Integrity rules)来检查自己的设计。常见的完整性规则如下:

实体完整性指表中行的完整性。主要用于保证操作的数据(记录)非空、唯一且不重复。即实体完整性要求每个关系(表) 有且仅有一个主键,每一个主键值必须唯一,而且不允许为“空”(NULL)或重复。

参照完整性属于表间规则。对于永久关系的相关表,在更新、插入或删除记录时,如果只改其一,就会影响数据的完整性。 如删除父表的某记录后,子表的相应记录未删除,致使这些记录称为孤立记录。对于更新、插入或删除表间数据的完整性, 统称为参照完整性。通常,在客观现实中的实体之间存在一定联系,在关系模型中实体及实体间的联系都是以关系进行描述, 因此,操作时就可能存在着关系与关系间的关联和引用。

域完整性是指数据库表中的列必须满足某种特定的数据类型或约束。其中约束又包括取值范围、精度等规定。表中的CHECK、 FOREIGN KEY 约束和DEFAULT、 NOT NULL定义都属于域完整性的范畴。

又叫业务逻辑完整性(Business logic Integrity),是对数据表中字段属性的约束,用户定义完整性规则(User-defined integrity) 也称域完整性规则。包括字段的值域、字段的类型和字段的有效规则(如小数位数)等约束,是由确定关系结构时所定义的字段的属性决定的。 如百分制的考试成绩取值范围在0-100之间,订单数量应该小于等于库存量等。

通常我们可以通过对指定的列创建索引来加快数据库的读取和查询速度。在实现上,索引通常是一个结构化文件,可以提高SELECT的速度, 却会对INSERT, UPDATEDELETE的速度有一定负面影响。如果没有索引,进行一次条件查询(比如SELECT * FROM Customers WHERE name="Sam") 就需要对整个数据库进行一次线性查找和比较。而在带索引的结构中(如B树),查询的时间就能减少到对数级别。当然在这种情况下, 插入和删除的时间也从常数上升到对数级别,不过在实践中由于查找的频率远远大于插入和删除,因此索引带来的好处也是很明显的。

对于特定的表来说,索引可以是1列,多列组合(称为组合索引,Concatenated Index)或者是某列的部分内容(称为部分索引,Partial Index)。 在一个表里我们也可以建立多个索引,例如需要经常通过电话号码或者名字来查询某个客户,就可以在这两列建立对应的索引。 索引最终还是根据实际需要自行选择,值得一提的是大多数RDBMS都会自动基于主键建立索引。

后记

总结一下,在关系数据库设计中,我们首先要明确设计的最终目标,再根据目标决定哪些数据要持久化存储; 对于这些数据, 要按照功能和逻辑来进行拆分,并且存放在不同的表中,并且明确之间的关系; 对于设计好的表,要进行重构, 根据设计范式对大表进行拆分和优化; 对于每个表要增加对应的完整性检查,关键是实体完整性和参照完整性; 最后在实际使用中,对于高频查询的记录构建索引提升效率,以及其他因地制宜的优化。