本小节主要内容:
范式(NF),可以理解成是一张数据表在设计时需要满足的某种设计标准的级别。
目前,关系型数据库一共有六种范式,按照范式级别,从低到高依次是:
数据库的范式设计越高阶,冗余度就越低 ,同时,高阶范式一定满足低阶范式的要求,比如,满足2NF的一定满足1NF。
一般来讲,在关系型数据库里,数据表的设计应该尽量满足3NF,但是并不绝对,很多时候,出于提升查询性能的需要,我们会反范式设计,主动冗余一些字段。
超键:只要是能唯一标识元组的属性集,都叫做超键。
候选键:是不含多余属性的超键,可以理解成是最小超键,从它的集合里找不到一个子集也是超键;
主键:从候选键中选择一个作为主键;
外键:不介绍了
主属性:包含在任一候选键里的属性,称为主属性;
非主属性:与主属性相对,不包含在任何一个候选键里的属性。
举个例子,以球员表为例,一个球员表包含球员的编号、姓名、证件号、年龄和球队编号。
而超键,就是包含球员编号或者证件号的任意组合,比如说(球员编号)、(证件号)、(证件号、姓名)、(证件号、姓名、年龄)等。
而候选键就是最小超键,即(球员编号)或者(证件号)。我们可以从它俩里选一个做主键。
外键就是球队编号。
主属性就是球员编号、证件号,剩下的都是非主属性。
1NF是指数据库表中的任何属性都是原子性的,不可再分。事实上,任何DBMS都会满足第一范式的要求,不会将字段进行拆分。
2NF,指数据库表中的非主属性,都要和这个数据表的候选键有完全依赖关系。这里的完全依赖是指,依赖候选键的全部属性。
简单的说呢,就是针对概念上的联合主键,非主属性依赖于联合主键的每一个字段,而非部分字段。
接下来,我们介绍一个没有满足2NF的例子。
我们设计了一张球员比赛表,其包含球员编号、姓名、年龄、比赛编号、比赛时间和比赛场地这些属性(字段)。
按照上一小节介绍的,这张表的候选键和主键都是(球员编号,比赛编号),因此我们可以得到以下关系:
(球员编号,比赛编号)决定了(姓名、年龄、比赛时间、比赛场地)
这个是不满足2NF的。
为啥这么说呢?
因为非主属性(姓名、年龄、比赛时间、比赛场地)并没有完全依赖主属性。
这张表里还存在以下关系:
(球员编号) 决定了 (姓名,年龄)
(比赛编号) 决定了 (比赛时间,比赛场地)
候选键的某个字段就可以决定非主属性。
这会产生什么问题呢?
为了避免以上情况,我们可以按照2NF,将球员比赛表设计为三张表:
所以,从某种程度上来讲,2NF是1NF的升级。1NF只告诉我们字段属性是独立的,而2NF告诉我们一张表就是一个独立的对象,即==一张表只表达一个意思==。
3NF在满足2NF的同时,要求任何非主属性都不传递依赖于候选键。也就是说,不能存在非主属性A依赖于非主属性B,而B依赖于候选键的情况。
在3NF里,每个非主属性,必须且只能直接依赖于全部候选键。可以理解成,非主属性只能直接依赖于全部主键字段。
再举一个例子,假设我们有这么一张球员表,其包含了球员编号、球员姓名、球队名称和球队教练。
这些字段的依赖关系如下图:
可以看到,球队教练这个属性,是传递依赖于球员编号的,这个就不符合3NF的要求。
如果要按照3NF来设计的话,上面应该分成两个表:
举一个例子,假设我们有一张仓库管理表,包含以下字段:
在这个表里,我们指定一个仓库只能有一个管理员,且一个管理员只能管理一个仓库,即仓库和管理员严格一对一。
这样的话,仓库名决定了管理员,而管理员也决定了仓库名,同时,(仓库名,物品名)或者是(管理员,物品名)又决定了数量这个属性。
所以,这张表的候选键就是(仓库名,物品名)、(管理员,物品名)。我们随便挑一个候选键作为主键,比如(仓库名,物品名)。
于是,主属性就是仓库名、物品名、管理员,非主属性就是数量。
接下来,我们判断一下这张表的所属范式。
数据表每个属性都是原子性的,符合1NF的要求;
非主属性"数量"与候选键全部依赖,不存在仅使用候选键的某个子集就可以决定数量的情况。比如说仓库名或者物品名都无法决定数量。所以符合2NF的要求;
非主属性"数量"不传递依赖于候选键,而且也没有其他非主属性让它去传递依赖。所以符合3NF的要求。
综上,数据表符合3NF的要求,那是不是在设计上就没什么问题了呢?
不是,还是有很多问题:
可以看到,即使一张表满足了3NF的要求,同样还是存在插入、更新和删除的问题。
这是为什么呢?
归根结底的原因是,主属性"仓库名"对候选键(管理员,物品名)是部分依赖的关系。“管理员"可以单独决定"仓库名”。
于是,人们在3NF的基础上进行了改进,提出了BCNF。
BCNF在3NF的基础上,进一步要求,主属性也不能对候选键有部分依赖或者传递依赖。
因此,根据BCNF,我们可以将上面的仓库管理表进一步细分成两张表:
越高阶的范式,设计出来的表数量就越多,数据冗余度就越低。但是这其实并不完全是好事,因为分化出来的表越多,意味着我们要做一个业务查询的时候,需要关联的表就越多。
因此,我们在实际生产中,经常为了性能和读取效率而做反范式的设计。即允许少量冗余字段的存在,以空间来换时间。
反范式,也是一种对查询效率的优化思路。
接下来,举一下教程里的例子,通过实验来模拟一下反范式的优化效果。
我们需要两张表
商品评论表,product_comment,对应字段如下:
用户表,user,对应的字段如下:
然后我们分别给两张表模拟出百万量级的数据,可以通过存储过程来实现。
给用户表随机生成100w用户:
CREATE DEFINER=`root`@`localhost` PROCEDURE `insert_many_user`(IN start INT(10), IN max_num INT(10))
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE date_start DATETIME DEFAULT ('2017-01-01 00:00:00');
DECLARE date_temp DATETIME;
SET date_temp = date_start;
SET autocommit=0;
REPEAT
SET i=i+1;
SET date_temp = date_add(date_temp, interval RAND()*60 second);
INSERT INTO user(user_id, user_name, create_time)
VALUES((start+i), CONCAT('user_',i), date_temp);
UNTIL i = max_num
END REPEAT;
COMMIT;
END
在循环前,我们将 autocommit 设置为 0,这样等计算完成再统一插入,执行效率更高。
然后调用call insert_many_user(10000, 1000000);
,生成编号从10000开始的百万用户数据,教程里是消耗了1分37秒。
接着给商品评论表随机生成100w条评论,内容为20个随机字母,存储过程如下:
CREATE DEFINER=`root`@`localhost` PROCEDURE `insert_many_product_comments`(IN START INT(10), IN max_num INT(10))
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE date_start DATETIME DEFAULT ('2018-01-01 00:00:00');
DECLARE date_temp DATETIME;
DECLARE comment_text VARCHAR(25);
DECLARE user_id INT;
SET date_temp = date_start;
SET autocommit=0;
REPEAT
SET i=i+1;
SET date_temp = date_add(date_temp, INTERVAL RAND()*60 SECOND);
SET comment_text = substr(MD5(RAND()),1, 20);
SET user_id = FLOOR(RAND()*1000000);
INSERT INTO product_comment(comment_id, product_id, comment_text, comment_time, user_id)
VALUES((START+i), 10001, comment_text, date_temp, user_id);
UNTIL i = max_num
END REPEAT;
COMMIT;
END
然后调用call insert_many_product_comments(10000, 1000000)
,教程里是花了2分7秒。
如果我们现在接到一个需求,需要查询产品10001的前1000条评论,并且需要显示出对应的评论人姓名,需要写成:
SELECT p.comment_text, p.comment_time, u.user_name FROM product_comment AS p
LEFT JOIN user AS u
ON p.user_id = u.user_id
WHERE p.product_id = 10001
ORDER BY p.comment_id DESC LIMIT 1000
运行时长为0.395s。
效率不高。这是因为两个表都是百万量级的大表,关联的时候要做嵌套查询,消耗自然就上去了。
如果我们想提升查询的效率,就可以允许适当的字段冗余,比如说在商品评论表里增加用户名这个字段,这样一来,只需要单表查询就可以完成预定的需求,如:
SELECT comment_text, comment_time, user_name FROM product_comment2 WHERE product_id = 10001 ORDER BY comment_id DESC LIMIT 1000
消耗时间仅为0.039s。
综上,反范式可以通过空间来换时间,从而提升查询效率。
但是在数据量小的情况下,反范式并体现不出优势,反而会把数据库的搞得更加复杂,比如说增加了更新的难度,可能要单独编写触发器之类的,自动更新。
反范式经常被用在数据仓库的设计之中。因为数仓通常是存储历史数据,以OLAP为主,对增删改的实时要求不高,反而是对历史数据的分析需求强。这时候适当增加数据的冗余度,更方便进行数据分析。
教程里,对下数据仓库和数据库在使用上的区别,是这么总结的:
数据库设计的目的在于捕获数据,而数据仓库设计的目的在于分析数据;
数据库对数据的增删改实时性要求强,需要存储在线的用户数据,而数据仓库存储的一般是历史数据;
数据库设计需要尽量避免冗余,但为了提高查询效率也允许一定的冗余度,而数据仓库在设计上更偏向采用反范式设计。
其实上面的总结里还是保守了,数仓对反范式的应用是很广泛的。比如说数仓中,为了方便分析,经常会加工一些"宽表",冗余大量字段,来实现单表支撑分析的功能。另外,在数仓的分层里,从ODS到ADS,上层其实都是对下层的冗余。
可以看到,从1NF到BCNF,突出的都是一个去冗余化,主打的就是一个精简,就如同"相同的代码不能出现两次",在范式设计的思想下,会重复出现的字段都应该抽离出来单独成表。
或者可以换个角度去思考,以BCNF一节中的例子来说,这个表里其实是有3个实体的,分别是仓库、管理员和物品。其中仓库:管理员是1:1,而仓库:物品是N:1,管理员:物品同样是N:1。
三个实体,且三个实体间是1:1:N。这就不符合范式设计的要求了,在范式设计下,单表里最好只保留两个及以下的实体,不同实体之间的关系最好是1:1或者1:N。因此优化后的例子里,把1:1:N拆分成了1:1和1:N两张表。
当然,实际设计数据表的时候,未必要符合这些原则,尤其是是在分布式的OLAP应用场景下。
一方面是这些范式本身就存在一些问题,在插入、更新和删除的时候可能会带来一些异常。另外,它们也会降低查询的效率,这是因为范式等级越高,设计出来的数据表就越多,实际做查询的时候就需要关联很多很多张表,从而降低查询效率。
说句题外话,我在教程的下面评论里看到一条极其有才的评论:
范式与反范式,正如传统与解构,规则与务实,稳定与突破,守成与创新,是阴阳动静的矛盾关系,两者一而二,二而一,即和而不同、求同存异,落脚点是务实,也就是应用场景和业务需求。
所以说,这已经不单是数据库设计的问题,而中国哲学体系在互联网商业中实践指导。
数据库设计提出范式的同时存在反范式的要求,符合否定之否定的螺旋上升轨迹,是数据库也是SQL语言保持强壮生命力而经久不衰的重要原因,是现实生存逻辑的映射。
看完当场下跪,这就是互联网的发言吗。