进行系统设计时,如何进行数据库设计?

分类: 数据库

保留所有版权,请引用而不是转载本文(原文地址 https://yeecode.top/blog/150/ )。

数据库作为系统的数据存储中心,在系统中起着十分重要的作用。数据库设计的好坏,对系统的可靠性、安全性、效率都有着至关重要的影响。

通常我们所说的数据库是指传统的关系型数据库,而为了提升数据库在各个维度的表现,也诞生了很多非传统数据库。这些非传统数据库往往在某一或某几个方面有着传统数据库难以比拟的特性,因此也得到了广泛的应用。

在本篇中,我们来讲数据库的设计。其中,传统关系型数据库的设计与优化问题最为重要,也只有对传统关系型数据库的长处和不足有了清晰的认识,才能取长补短地进行非传统数据库的选择。因此,本篇主要介绍传统数据库,并在最后介绍常用的非传统数据库供选型参考。

数据库设计概述

数据库对一个系统的性能有着至关重要的影响,主要原因有几点:

首先,相对于数值计算、内存读写,数据库操作因为涉及IO操作而耗时较久,因此数据库读写响应时间往往在系统总响应时间中占比较高。根据阿姆达尔定律,优化数据库读写响应时间会对缩短系统总响应时间产生重要作用。直观来看,减少或者优化一个数据库读写操作,可能会给系统总响应时间带来毫秒甚至秒级的降低。

其次,为了实现数据同步等操作,数据库常常被设计为系统的单点,这使得数据库需要承受大量的读写并发,成为整个系统中并发数最高的模块。只要能提升数据库这一瓶颈的性能,系统的整体性能就会得到明显改善。例如,在信息共享的节点集群中,多个节点为了实现信息共享,会接入到同一个数据库上。如果该数据库的性能得到提升,各个节点的性能也都会提升。

再次,系统中的大多数模块的运行压力不会随着运行时间的延长而增加,但数据库不同。随着运行时间的延长,数据库中存储的数据量可能不断增加,这会导致其检索效率、读写效率下降,最终出现性能问题。

因此,设计一套完善、高效、具有前瞻性的数据库子系统,对于提升整个系统的性能至关重要。

数据库设计主要包含以下几方面的内容:

  • 数据库选型:根据项目需求不同,从扩展性、经济性、易用性等各个角度出发选择合适的数据库。如果需要数据库在某些方面有特殊表现,可以选择相应的非传统数据库。
  • 存储引擎选型:同一数据库往往会有多种实现引擎,不同引擎对事务、索引、容量等特性的支持不尽相同,我们在选定数据库之后需要根据需求选定相应的存储引擎。
  • 数据表结构设计:根据项目要求,完成数据库中表、字段、索引等的设计。
  • 数据库的优化:运行项目后,分析数据库的运行情况,有针对性地对某些结构、语句进行设计和优化。

以上四个方面中,数据表结构设计和优化环节最为重要也最为复杂。良好的数据表结构设计能减少数据库读写的次数,出色的数据表优化能缩短数据库读写操作的时间。

在本篇,我们详细介绍数据表结构设计和优化的过程。

关系型数据库设计

目前软件开发过程中,通常使用的是面向对象的编程,面向对象是从软件工程原则(如聚合、封装)的基础上发展而来的。传统数据库是指关系型数据库,它是从数学理论(集合代数等)的基础上发展而来的。因此,面向对象和关系型数据库来自不同的理论,两者不完全匹配,它们之间存在一个转化过程,被称为对象-关系映射。图 1展示了对象-关系映射。

对象-关系映射
图 1:对象-关系映射

因为对象-关系映射的存在,也形成了不同的软件设计习惯:

  • 先设计关系再推导对象:先根据需求设计关系型数据库的结构,然后根据关系型数据库确定对象和对象之间的关系。
  • 先设计对象再推导关系:先根据需求设计对象和对象之间的关系,然后将它们转化为关系型数据库的结构。

以上两种方式从不同的角度切入设计,不分优劣,大家可以根据习惯自行选择。

因为这两种软件设计习惯的存在,在我们进行关系型数据库设计时,已有的输入信息可能是需求(对应先设计关系再推导对象),也可能是对象(对应先设计对象再推导关系),这两种方式都是可以的。

在关系型数据库的设计过程中,不同人会有不同的思路。因此同一个需求、同一组对象,不同的设计人员可能设计出完全不同的关系型数据库结构。然而,软件系统对关系型数据库的要求很高,稍有设计不当便可能引发以下问题:

  • 数据冗余:同一份数据可能会在数据库中存在多份,这会导致增加、修改、删除等操作十分繁杂。
  • 数据不一致:当数据存在冗余时,一部分数据更新而另一部分没有更新,便会引发数据不一致。
  • 插入异常:可能因为某些数据的缺失,导致其他数据无法插入。
  • 删除异常:在删除某些数据时,可能会导致其他信息一并丢失。

为了避免发生以上问题,需要对关系数据库的设计过程进行规范化,或者说标准化。数据库范式就是为数据库规范化确立的一套标准,它用来衡量数据库设计的规范化程度。

数据库范式这一套标准分为很多级别,所设计的数据库满足的范式级别越高,则数据库越标准。只要达到了某个标准,就能够避免掉上述的某些问题。这就像工业领域的标准分级一样,例如,如果设备达到IP45就能防止1mm以上的颗粒进入并能防喷水,如果设备达到IP68就能防止粉尘进入并能防浸泡。

然而,许多架构师在进行数据库设计时,对数据库范式不够重视,或者以反范式为理由进行逃避。这是错误的。因为反范式只是在范式的基础上进行修正,以减少严格遵守范式带来的负面影响,而不是逃避范式的理由。在反范式设计章节我们会专门介绍反范式,便于大家能够在适当的时候适当使用反范式的设计。

设计范式介绍

常用的数据库设计范式一共有六个,这六个范式逐级递增、愈发严格。每一个范式都是前一个范式的升级,都是后一个范式的基础。

接下来我们对这些范式依次介绍。在此之前,我们先介绍关系数据库中的几个基本概念:

  • 属性:又称字段,指数据库中表的列。
  • 记录:指数据库中表的行。
  • 超键:又称超码,在关系中能唯一标识记录的属性集称为关系模式的超键。
  • 候选键:又称候选码,不含有多余属性的超键称为候选键。
  • 主键:又称主码,数据表设计者可以从候选键中任选一个用来标志每个记录,所选中的候选键为主键。
  • 主属性:如果一个属性属于某一个候选码,则该属性为主属性。

关系数据库来源于数学理论中的集合代数等学科,为了能够清晰地表述各个范式的含义,我们也对涉及的数学概念进行简要介绍:

  • 依赖:是数据之间的相互制约关系,是一种语义体现,主要分为函数依赖、多值依赖和连接依赖。
  • 函数依赖:设 $X$、$Y$ 是关系 $R$ 的两个属性集合,当任何时刻 $R$ 中的任意两个元组中的 $X$ 属性值相同时,则它们的 $Y$ 属性值也相同,则称 $X$ 函数决定 $Y$,或 $Y$ 函数依赖于 $X$,记作 $X \rightarrow Y$。
  • 部分函数依赖:设 $X$、$Y$ 是关系 $R$ 的两个属性集合,存在 $X \rightarrow Y$,若 $X’$ 是 $X$ 的真子集,存在 $X’ \rightarrow Y$,则称 $Y$ 部分函数依赖于 $X$。
  • 完全函数依赖:设 $X$、$Y$ 是关系 $R$ 的两个属性集合,$X’$ 是 $X$ 的真子集,存在 $X \rightarrow Y$,但对每一个 $X’$ 都有 $X’ \nrightarrow Y$,则称 $Y$ 完全函数依赖于 $X$。
  • 平凡函数依赖:当关系中属性集合 $Y$ 是属性集合 $X$ 的子集时,存在函数依赖 $X \rightarrow Y$,即一组属性函数决定它的所有子集,这种函数依赖称为平凡函数依赖。
  • 非平凡函数依赖:当关系中属性集合 $Y$ 不是属性集合 $X$ 的子集时,存在函数依赖 $X \rightarrow Y$,则称这种函数依赖为非平凡函数依赖。
  • 传递函数依赖:设 $X$、$Y$、$Z$ 是关系 $R$ 中互不相同的属性集合,存在 $X \rightarrow Y\ (Y \nrightarrow X)$、$Y \rightarrow Z$,则称 $Z$ 传递函数依赖于 $X$。
  • 多值依赖:假设关系数据库中有三个独立属性 $X$、$Y$、$Z$,如果选中某个值 $x$,则总会对应着值 $y$,而不论 $z$ 的任何取值,那么就说存在多值依赖。

如果感觉上述数学概念有些不好理解也没有关系,我们还准备了一个示例表。接下来在讲解每个范式时,都会使用示例表作为例子进行介绍,便于大家从关系型数据库角度进行直观地理解。

示例表是一个用于存储学生信息的表,其相关属性如下:

【版本 1】

Table 1:
学号 | 姓名 | 班级编号 | 学生在班级内序号 | 性别 | 班级人数 | 年龄 | 是否成年 | 紧急联系手机号

以【版本 1】为例,我们再次阐述下其中的数据库概念:

  • 属性:学号、姓名等都是属性。
  • 记录:表中存储的"008008 | 易哥 | 17 | 3 | 男 | 45 | 18 | 是 | 18888888888"就是一条记录。
  • 超键:“学号"这一属性是超键;“班级编号"和"学生在班级内序号"这一属性集是超键;“学号"和"年龄"这一属性集也是超键;任意一个属性和已知超键组成的属性集也是超键。
  • 候选键:“学号"这一属性是候选键;“班级编号"和"学生在班级内序号"这一属性集是候选键。
  • 主键:我们可以从候选键中任选一个作为主键,例如我们选定"学号"为主键。
  • 主属性:“学号”、“班级编号”、“学生在班级内序号”。

在接下来介绍数据库范式的过程中,我们假定【版本 1】中存在如下的合理假设:

  • 学生可能会有重名。
  • 学生在班级内的序号由姓名首字母排序得来。
  • 一个同学可能有多个紧急联系手机号,一个紧急联系手机号也可能关联多个同学。

看到上表后,有经验的读者能很快察觉它的设计有问题。但至于存在哪些问题、如何改正,则需要凭经验进行,且很难确保改正后的数据表设计是完全正确的。数据库设计范式则为我们提供了修正问题的步骤,只要一步步参照范式,我们最终便可以准确地修正【版本 1】中存在的问题。接下来,我们会边讲解范式的概念,边修改【版本 1】。

第一范式

第一范式(1NF)要求每个属性的值都只能是原子的,不能再拆。

我们使用第一范式校验下【版本 1】。

假设我们在使用中,姓名总是以一个整体出现,那"姓名"属性是原子的,示例表也是符合第一范式的;假设在使用中,会出现"李同学"“欧阳同学"等这种使用场景,则说明"姓名"属性不是原子的,示例表便不符合第一范式,需要将"姓名"属性拆分为"学生姓"“学生名"两个属性才可。由此可见,属性原子性的判断不是绝对的,和数据库的使用场景相关。

假设会出现"李同学"“欧阳同学"等这种使用场景,将数据表修改成如下形式以满足第一范式。

【版本 2】

Table 1:
学号 | 学生姓 | 学生名 | 班级编号 | 学生在班级内序号 | 性别 | 班主任 | 年龄 | 是否成年 | 紧急联系手机号

如果数据表不满足第一范式,可能会导致数据表不可用。这是因为使用时必须要把非原子化的属性拆分成原子后再使用,而这种拆分可能是复杂的、甚至无法实现的。因此我们要在设计数据库时遵循第一范式,有前瞻性地拆分属性。

第二范式

第二范式(2NF)要求非主属性必须完全依赖于候选键。

在【版本 2】的Table1中,“班主任"这一属性并没有完全依赖"班级编号"和"学生在班级内序号"这一候选键,而是仅仅部分依赖了候选键中的"班级编号"属性。因此,【版本 2】的Table1不满足第二范式。

数据表不满足第二范式会导致数据冗余、数据不一致等问题。例如,“班主任"这一数据在班级每个同学的记录里都存储了一份,这是冗余的。而如果班级的班主任发生变化,则要修改全班每个同学的信息,遗漏任何一条记录都可能会导致数据不一致(同一个班的两个同学却有不同的班主任)。

为了使其满足第二范式,我们需要对该表进行拆分,得到两个表,如【版本 3】所示。

【版本 3】

Table 1:
学号 | 学生姓 | 学生名 | 班级编号 | 学生在班级内序号 | 性别 | 年龄 | 是否成年 | 紧急联系手机号

Table 2:
班级编号 | 班主任

第三范式

第三范式(3NF)要求任何非主属性不依赖于其他非主属性。

在【版本 3】的Table1中,非主属性"是否成年"依赖于非主属性"年龄”,违背了第三范式。

在这里我们也明确下,“是否成年"这一属性不影响表满足第二范式。首先,“是否成年"这一属性可以由候选键"学号”(或者候选键"班级编号"和"学生在班级内序号”)推导出来。但是,候选键中的部分信息,例如"学生在班级内序号"则无法推导出"是否成年"这一属性。因此,“是否成年"这一属性与数据表的键有完全依赖关系,不影响表满足第二范式。实际上,“是否成年"属性是传递依赖于候选键,而第三范式其实就是要求消除非主属性对候选键的传递依赖。

一个表不满足第三范式也会导致数据冗余和数据不一致。例如,“年龄"能决定"是否成年"属性,则后者是多余的、可以省略的。而且可能会在多条记录中出现"年龄"相同而"是否成年"不同的数据不一致情况。

为了使表满足第三范式,我们增加了一个"年龄-是否成年"关系对照表。修改后,得到了【版本 4】的形式。

【版本 4】

Table 1:
学号 | 学生姓 | 学生名 | 班级编号 | 学生在班级内序号 | 性别 | 年龄 | 紧急联系手机号

Table 2:
班级编号 | 班主任

Table 3:
年龄 | 是否成年

当然,在实际设计项目时,我们往往会选择使用一个函数替代【版本 4】的Table 3,该函数以年龄为输入,给出是否成年的判断值。这样,“是否成年"这一属性便不需要存储。

BCNF

BCNF(Boyce-Codd Normal Form,中文译为巴斯范式)又称为修正的第三范式,它要求每个属性都不传递依赖于非主属性。

第三范式中不允许非主属性依赖另一个非主属性,但是允许主属性依赖非主属性。而BCNF要求包括非主属性和主属性的任何属性都不能依赖非主属性。

【版本 4】已经满足了第三范式,但却不满足BCNF,因为主属性"学生在班级内序号"依赖于非主属性"学生姓”。那这会引发什么问题呢?

首先,实体完整性要求主属性不能为空值,因此每条记录插入时,“学生在班级内序号"属性不能为空值。那在学生信息逐条插入时,就需要为每一条记录指定"学生在班级内序号"属性。而根据我们的合理性假设,“学生在班级内的序号最开始由姓名首字母排序得来”,这意味着在所有记录被插入以前,我们无法给出"学生在班级内序号"属性的值。这种互相制约使得记录的插入无法进行。

为了使得表满足BCNF,我们修改成【版本 5】形式。

【版本 5】

Table 1:
学号 | 学生姓 | 学生名 | 班级编号 | 性别 | 年龄 | 紧急联系手机号

Table 2:
班级编号 | 班主任

Table 3:
年龄 | 是否成年

Table 4:
学号 | 学生在班级内序号

经过修改之后,“学号"成了【版本 5】Table1中唯一的主属性。整个表的设计也满足BCNF了。在使用中,可以先在Table1中逐一插入学生信息数据,待全班学生信息插入完毕后,再根据Table1中的"学号"“学生姓"属性写入Table4。

第四范式

第四范式(4NF)要求属性之间不允许有非平凡且非函数依赖的多值依赖。

因为一个学生可能有多个紧急联系手机号,因此【版本 5】的Table 1不满足第四范式。如果某个学生多增加一个紧急联系手机号,就需要将该学生的所有信息均在数据库中存储一份,这同样导致了数据冗余并可能导致数据不一致。

因此,我们依照第四范式的要求,修改【版本 5】后得到【版本 6】所示的设计。

【版本 6】

Table 1:
学号 | 学生姓 | 学生名 | 班级编号 | 性别 | 年龄

Table 2:
班级编号 | 班主任

Table 3:
年龄 | 是否成年

Table 4:
学号 | 学生在班级内序号

Table 5:
学号 | 紧急联系手机号

第五范式

第五范式(5NF)要求表中的每个连接依赖由且仅由候选键推出。

或者通俗来说,一个表满足第五范式意味着它不能被无损分解为几个更小的候选键不同的表。现在来看【版本 6】,以Table 1为例,可以拆解出多个表,但是每个表的候选键都是"学号”,因此Table1满足第五范式。同样的,【版本 5】中的其他各表也满足第五范式。

为了帮助大家理解第五范式,我们重新定义一个表,如下所示。下表是一个教师职责分配表,例如记录"李明-五年级一班-卫生"表示李明老师要负责五年级一班的卫生。

【职责分配表】

教师 |    班级    | 项目
李明 | 五年级一班 | 卫生
李明 | 五年级二班 | 卫生
刘华 | 五年级一班 | 卫生
刘华 | 五年级一班 | 纪律
刘华 | 五年级二班 | 卫生
刘华 | 五年级二班 | 纪律
易哥 | 五年级一班 | 成绩
易哥 | 五年级二班 | 成绩
齐强 | 五年级三班 | 卫生
齐强 | 五年级三班 | 纪律
齐强 | 五年级三班 | 成绩
齐强 | 五年级三班 | 体育

在职责分配表中,不同老师负责不同的班级,不同班级有不同的项目,不同老师也只负责某些特定的项目。于是,在职责分配表中,三个属性共同组成了主键。同时该表也满足第四范式,因为不存在多值依赖。

如果没有任何规则限制老师的职责分配,那上面所示的职责分配表是不可再拆的。

但是,如果存在下述规则:如果某个老师负责的班级集合为 $C$,负责的项目集合为 $I$,那么当它负责一个新班级 $c_1$ 时,他必须负责该班级的集合 $I$ 中的所有项目。上述表述有些拗口,我们可以使用示例继续说明。刘华老师负责五年级一班、五年级二班的卫生和纪律,则他负责的项目集合为 {卫生,纪律}(这个角色可能是教导主任)。那五年级三班(这个班级可能是个体育特长班)可以不让刘华老师负责,但是只要让他负责,他就会负责其中的卫生、纪律这两项。

如果上述规则是成立的,当决定让刘华老师负责五年级三班时,我们需要插入的数据有两条:

刘华 | 五年级三班 | 卫生
刘华 | 五年级三班 | 纪律

这时我们能感觉到,职责分配表所示的设计存在问题,某些信息是冗余的。这其实是因为职责分配表不满足第五范式,它可以无损地拆分为以下三个表:

Table 1
教师 | 班级

Table 2
班级 | 项目

Table 3
教师 | 项目

经过修改后的职责分配表除去了信息的冗余。当我们需要刘华老师负责五年级三班,仅需要在Table1中插入一条数据:

刘华 | 五年级三班

理解了从第一范式到第五范式这一套修正步骤,我们在做关系型数据库设计时,就有了清晰的"体检清单”:逐层对照,就能把绝大部分冗余、不一致、插入/删除异常消弭在设计的阶段,而不是等到线上出问题再返工。当然,范式也不是越高越好,实际项目里常常会在范式基础上做适当反范式,来换取查询效率。下面就把反范式设计单独拎出来说说。

反范式设计

经过前面多次修改得到的【版本 6】,已经完全符合第五范式,Table1 到 Table5 不重不漏地存了所有信息。

假设我们要查"学号为 008008 的同学的班主任”,步骤是:先通过 Table1 查到他的班级编号 $c$,再通过 Table2 用 $c$ 查到班主任。整个过程要检索两个表。

如果这个查询是高频、且对响应时间很敏感的,那这种连表查询就不合理了。我们可以把"班主任"属性合并进 Table1,得到【版本 7】:

【版本 7】

Table 1:
学号 | 学生姓 | 学生名 | 班级编号 | 性别 | 年龄 | 班主任

Table 2:
年龄 | 是否成年

Table 3:
学号 | 学生在班级内序号

Table 4:
学号 | 紧急联系手机号

这时再查 008008 的班主任,只需一步:直接通过 Table1 查到班主任即可。

显然这减少了数据库查询,在该操作频繁发生时能明显提升系统性能。但【版本 7】的 Table1 不再满足第二范式,存在数据冗余,还可能引发数据不一致。

如果我们愿意为了查询速度付出一点代价——比如更新某个班班主任时,用事务保证全班所有学生记录都被更新——那这种设计也是可以接受的。这就是反范式设计:牺牲编辑性能(用事务更新多条数据,编辑性能下降),换来查询性能(连表查询简化为单表查询)。

反范式设计,是在范式设计的基础上,违反其中的某一条或几条,来达到提升查询效率等效果。使用时一定要记住:范式设计是反范式设计的基础,要在范式基础上根据目的做特定的违反,可不能把反范式当成随意设计的借口。书里的高性能架构实践部分,也会拿真实项目为例,讲怎么在范式基础上用反范式提升目标系统的性能。


在AI编程迅猛发展的今天,编码能力则逐步弱化,而架构能力则成了每个开发者更为核心的能力。数据库设计是系统性能的命门,范式则是这门手艺的底层功法。如果你觉得架构、性能这些话题有意思,强烈推荐一本书《高性能架构之道(第二版)》。

这是一本理论联系实际的架构书,系统讲了怎么从顶层把软件架构好:覆盖分布式、并发编程、数据库调优、缓存、IO、高可用、前端性能优化等众多知识,最后还拿一个真实项目完整走了一遍架构全过程,把书里的方法真正落了地。

高性能架构之道(第二版)

《高性能架构之道(第二版)》

这本书还在台湾地区出了繁体版,叫《巨型服务架构》——繁体版嘛,贵得有点离谱。

就先聊到这儿。

我是软件架构师易哥。可以关注我,我会偶尔冒个泡,分享点编程干货!

可以访问个人知乎阅读更多文章:易哥(https://www.zhihu.com/people/yeecode),欢迎关注。

作者书籍推荐

作者书籍推荐