优秀数据库设计的艺术就像游泳。入手相对容易,精通则很困难。若是你想学习设计数据库,必定得有一些理论背景,好比关于数据库设计范式和事务隔离级别的知识。但你还应该尽量地多加练习,由于可悲的事实就是,咱们在犯错中学习得更多。程序员
本文中,经过展现在设计数据库时常犯的一些错误,咱们尝试把学习数据库设计变得容易一点。web
注意,咱们假定读者了解数据库范式并知道一点关系数据库的基础知识,于是不会去讨论数据库规范化。只要有可能,文中所涵盖的主题都将使用 Vertabelo 建模和实例来讲明。sql
本文涵盖了设计数据库的各个方面,但着重于Web应用,所以有些例子多是特定于web应用程序的。数据库
假设咱们想要为一个在线书城设计数据库。该系统应当容许用户执行如下活动:缓存
那么最开始的数据库模型可能以下所示:安全
为了测试该模型,咱们使用Vertabelo为其生成SQL,而且在PostgreSQL RDBMS中建立一个新的数据库。数据库设计
该数据库有8张表,其中没有数据。咱们已经往里面填充了一些人工生成的测试数据。如今数据库里包含了一些示范数据,准备好开始模型检查了,包括识别那些如今不可见但未来在真实用户使用时会出现的潜在问题。编辑器
你能够在上面的模型中看到咱们用“order”命名了一张表。不过,或许你还记得,“order”在SQL中是保留字! 所以若是你试图发起一个SQL查询:post
MySQL性能
SELECT * FROM ORDER ORDER BY ID
1 |
SELECT * FROM ORDER ORDER BY ID |
数据库管理系统将会抗议。很幸运,在PostgreSQL中用双引号把表名包裹起来就好了,语句仍能够执行:
MySQL
SELECT * FROM "order" ORDER BY ID
1 |
SELECT * FROM "order" ORDER BY ID |
等等,但是这里的“order”是小写!
没错,这值得深究。若是你在SQL中用双引号把什么包了起来,它就变成分隔标识符,大多数数据库将以区分大小写的方式解释它。因为“order” 是SQL中的保留字,Vertabelo生成SQL会自动把order用双引号包起来:
MySQL
CREATE TABLE "order" ( id int NOT NULL, customer_id int NOT NULL, order_status_id int NOT NULL, CONSTRAINT order_pk PRIMARY KEY (id) );
1 2 3 4 5 6 |
CREATE TABLE "order" ( id int NOT NULL, customer_id int NOT NULL, order_status_id int NOT NULL, CONSTRAINT order_pk PRIMARY KEY (id) ); |
可是因为标识符被双引号包裹且是小写,表名仍然是小写。如今若是你但愿事情变得更复杂,我能够建立另外一个表,此次把它名为ORDER(大写),PostgreSQL不会检测到命名冲突:
MySQL
CREATE TABLE "ORDER" ( id int NOT NULL, customer_id int NOT NULL, order_status_id int NOT NULL, CONSTRAINT order_pk2 PRIMARY KEY (id) );
1 2 3 4 5 6 |
CREATE TABLE "ORDER" ( id int NOT NULL, customer_id int NOT NULL, order_status_id int NOT NULL, CONSTRAINT order_pk2 PRIMARY KEY (id) ); |
若是一个标识符没有被双引号包裹,它就被称做“普通标识符”,在被使用前自动被转成大写——这是SQL 92标准所要求的。可是标识符若是被双引号包裹
——就被称做“分隔标识符”——要求被保持原样。
底线就是——不要使用关键字来当作对象名称。永远不要。
你知道Oracle中名称长度上限是30个字符吗?
关于给表以及数据库其余元素命好名——这里命好名的意思不只是“不与SQL关键字冲突”,还包括是自解释的且容易记住——这一点经常被严重低估。在一个小型数据库中,好比咱们这个,命名其实并非件很是重要的事。可是当你的数据库增加到100、200或者500张表,你就会知道在项目的生命周期中为保证模型的可维护性,一致和直观的命名相当重要。
记住你不光是给表和列命名,还包括索引、约束和外键。你应当创建命名约定来给这些数据库对象命名。记住名字的长度也是有限制的。若是你给索引命名太长,数据库也会抗议。
提示:
如下是把order表重命名为purchase后的模型:
模型中的改变以下:
让咱们进一步来看这个模型。如咱们所看到的,在book_comment表中,comment列的类型是1000个之内的字符。这意味着什么?
假设这个字段将是GUI(用户只能输入非格式化的评论)中的纯文本,那么它简单地意味着该字段能够存储最多1000个文本字符。若是是这样的话——这里没有错误。
可是若是这个字段容许一些格式化的动做,好比bbcode或者HTML,那么用户实际上输入进去的字符数量是未知的。假如他们输入一个简单的评论,以下:
XHTML
I like that book!
1 |
I like that book! |
那么它会只占用17个字符。然而若是他们使用粗体格式化它,像这样:
XHTML
I <b>like</b> that book!
1 |
I <b>like</b> that book! |
这就须要24个字符的存储空间,而用户在GUI上只会看到17个。
所以若是书城的用户可使用某种像所见即所得的编辑器来格式化评论内容,那么限制”comment”字段的大小是存在潜在危险的。由于当用户超过了最大评论长度(1000个原始HTML字符),他们在GUI上所看到的仍然会低于1000。这种状况下就应当修改类型为text而不要在数据库中限制长度了。
然而,当设置了文本字段的限制,你应当始终谨记文本的编码方式。
varchar(100)类型在PostgreSQL中表明100个字符,而在Oracle中表明100字节。
避免笼统地解释,咱们来看一个例子。在Oracle中,varchar类型被限制到4000个字节,那么这就是一个强限制——没有任何方法能够超过它。所以若是你定义了一个列是varchar(3000 char),那它意味着你能够存储3000个字符,但只有在它不会使用到磁盘上超过4000个字节的状况下。为什么一个3000个字符的文本在磁盘上会超过4000个字节呢?英文字符的状况下是不会发生的,可是其它语言中就可能出现。举个例子,若是你尝试用中文的方式存储”mother”——母亲,且数据库使用UTF-8的方式编码,那么这个字符串会占用磁盘上2个字符可是6个字节。
BMP(Basic Multilingual Plane,基本多语言平面,Unicode零号平面)是一个字符集,支持用UTF-16让每一个字符用2个字节进行编码。幸运地是,它覆盖了世界上大多数使用的字符。
注意,不一样数据库对于可变长的字符和文本字段会有不一样的限制。举些例子:
提示:
下面是把book_comment的评论类型修改成text后的模型:
模型中修改的地方以下图:
有一个说法是“伟大是实现的,而不是被赠与的”。这个说法一样能够用在性能上——经过精心设计数据库模型,优化数据库参数以及优化数据库应用查询来实现。固然这里咱们关注的是模型设计。
在例子中,咱们假定书城的GUI设计者决定在首页显示最新的30条评论。为了查询这些评论,咱们将使用以下的语句:
MySQL
select comment, send_ts from book_comment order by send_ts desc limit 30;
1 |
select comment, send_ts from book_comment order by send_ts desc limit 30; |
这个查询运行起来有多快?在个人笔记本上花费不到70毫秒。可是若是咱们但愿应用可以按比例变化(在高负载下快速运行),须要在更大的数据上检测。因此我在book_comment表中插入了更多的记录。为此我将使用一个很长的单词列表,而后使用一个简单的Perl命令将其转成SQL。
如今我要把这个SQL导入到PostgreSQL数据库。一旦导入开始,我就会检测以前那个查询的执行时间。统计结果在以下的表格中:
如你所见,随着 book_comment 中行数的增长,要获取最新30行所花费的查询时间也在成比例地增长。为什么耗费时间增加?咱们看看这个查询计划:
MySQL
db=# explain select comment, send_ts from book_comment order by send_ts desc limit 30; QUERY PLAN ------------------------------------------------------------------- Limit (cost=28244.01..28244.09 rows=30 width=17) -> Sort (cost=28244.01..29751.62 rows=603044 width=17) Sort Key: send_ts -> Seq Scan on book_comment (cost=0.00..10433.44 rows=603044 width=17)
1 2 3 4 5 6 7 |
db=# explain select comment, send_ts from book_comment order by send_ts desc limit 30; QUERY PLAN ------------------------------------------------------------------- Limit (cost=28244.01..28244.09 rows=30 width=17) -> Sort (cost=28244.01..29751.62 rows=603044 width=17) Sort Key: send_ts -> Seq Scan on book_comment (cost=0.00..10433.44 rows=603044 width=17) |
这个查询计划告诉咱们数据库如何处理查询及计算结果的大体时间成本。这里PostgreSQL告诉咱们将进行“Seq Scan on book_comment”,这意味着它将逐个检查 book_comment 表的全部记录,以此对send_ts列的值进行排序。貌似PostgreSQL尚未聪明到在不去对全部的600,000条进行排序的条件下查询30个最新记录。
幸运地是,咱们能够经过告知PostgreSQL根据send_ts进行排序并保存结果来帮助它。为此,咱们先在该列上建立一个索引:
MySQL
create index book_comment_send_ts_idx on book_comment(send_ts);
1 |
create index book_comment_send_ts_idx on book_comment(send_ts); |
如今咱们的查询语句从600,000条记录中查询出最新30条所花费的时间又是67毫秒了。查询计划差异很是大:
MySQL
db=# explain select comment, send_ts from book_comment order by send_ts desc limit 30; QUERY PLAN -------------------------------------------------------------------- Limit (cost=0.42..1.43 rows=30 width=17) -> Index Scan Backward using book_comment_send_ts_idx on book_comment (cost=0.42..20465.77 rows=610667 width=17)
1 2 3 4 5 |
db=# explain select comment, send_ts from book_comment order by send_ts desc limit 30; QUERY PLAN -------------------------------------------------------------------- Limit (cost=0.42..1.43 rows=30 width=17) -> Index Scan Backward using book_comment_send_ts_idx on book_comment (cost=0.42..20465.77 rows=610667 width=17) |
“Index Scan”指不是逐行扫描book_comment表,而是数据库会扫描咱们刚刚建立的索引。估计查询成本小于1.43,低于以前的2.8万倍。
你遇到了性能问题?第一次尝试解决就应当是找到运行时间最长的查询,让你的数据库来解释它们,而且寻找全表扫描。若是你找到了,也许增长一些索引能够快速提高速度。
不过,数据库性能设计是一个庞大的主题,超出了本文的范围。
咱们在以下提示中列出一些重要的方面。
提示:
在book_comment.send_ts列上带有索引的模型以下:
4 ——没有考虑到可能的数据量或流量
一般你能够获得有关可能的数据量的附加信息。若是你正在构建的系统是另外一个已存在项目的迭代,你能够经过查看老系统的数据量来计算出系统数据的预期大小。
若是你的书城很是成功,purchase表的数据量可能会很是大。你卖得越多,purchase表里的数据行数增长越多。假如你事先知道这一点,你能够把当前已处理的订单与完成的订单分开。你能够用两个表:purchase表记录当前的订单,archived_purchase表记录完成的订单,而不是用一张单一的purchase表。由于当前的订单一直在被检索:它们的状态在被更新,因为客户常常查看订单的信息。另外一方面,完成的订单只会被做为历史数据保存。它们不多被更新或者检索,因此能够在这张表上安排更长的访问时间。订单分离以后,常用的表能保持比较小,但咱们仍然保存着全部数据。
相似地,你应当优化频繁更新的数据。想象一个系统的部分用户信息常常由另外一个外部系统(例如,该外部系统计算同一类的奖励积分)更新。在咱们的user表中也有其它信息,如他们的登录帐号、密码和全名。这些基本信息也常常被检索。频繁更新下降了获取用户基本信息的速度。最简单的解决方案就是把这些数据分离到两个表里面:一个记录基本信息(常常被读取),另外一个记录奖励积分相关的信息(频繁被更新)。这样更新操做不会减缓读的操做。
分离频繁和不频繁使用的数据到多个表中不是处理大数据量的惟一方法。例如,若是你但愿书的描述(description字段)很是长,你可使用应用级别的缓存,这样你不用常常检索这个重量级的数据。书的描述极可能保持不变,因此这是一个很好的可被缓存的候选对象。
提示:
如下是修改后的书城模型:
若是书城是面向全世界的呢?客户来自世界各地而且使用不一样的时区。管理时区的date和datetime字段算是跨国系统中一个重要的问题。
系统必须始终为用户呈现准确的日期和时间,最好是以他们本身的时区。
举例,特殊供应的过时时间(这是任何商城中最重要的功能)必须让全部用户理解一致。若是你只是说“促销于12月24日结束”,他们会假定是在本身时区的12月24日半夜12点结束。若是你是指本身所在时区的圣诞前夜午夜12点,你必须说“12月24日,23.59 UTC”(即不管你的时区是什么)。对于某些用户,它将是“December 24, 19.59”,对另一些用户则是“December 25, 4.49”。用户必须看到以他们所在时区为准的促销时间。
在一个跨时区系统中日期列类型是不会有效存在的。它应当一直是一个timestamp类型。
当登陆事件在跨时区系统中发生时,能够采起相似的方式。事件的时间应该老是以某个选中的时区为准的标准化方式记录的,例如UTC,所以你可以毫无疑问地将时间从老到新排序。
数据库必须与应用代码合做以备处理时区问题。各类数据库存储日期和时间的数据类型有所不一样。某些类型存储时间时带有时区信息,而有些则没有。程序员应当在系统中开发标准化的组件来自动处理时区问题。
提示:
若是有人删除或者修改了咱们书城中的一些重要数据,可咱们在3个月以后才发现,发生了什么事情?我认为咱们遇到了严重的问题。
也许咱们有3个月前的一个备份,因此能够恢复备份到一些新的数据库以访问到数据。此后咱们将有一个契机来恢复这些数据避免损失。可是为完成这个过程,必须知足许多因素
当咱们最终恢复了数据(但肯定这就是最正确的版本吗?),就面临第二个问题——谁干的?谁在三个月前毁掉了数据?他们的IP/用户名是多少?咱们如何核实?为了肯定这一点,咱们须要:
这无疑会花费大量时间,并且没有多大成功的胜算。
咱们的模型所缺失的,就是某种意义上的审计跟踪。有许多方式来达到这个目标:
按照惯例,保持黄金分割是最好的方式。你应当在数据安全和模型简易性中找到平衡。保存版本和记录事件使得数据库更复杂。忽略数据安全可能致使意外的数据丢失或者恢复丢失数据的高成本。
提示:
这是对purchase和archived_purchase表加了基本审计跟踪功能的书城模型。
模型中的修改以下(以purchase表为例):
最后的错误是一个棘手的问题,由于它只出如今一些系统中,主要是在多语种系统里。将它添加在这里,是由于咱们常常遇到它,但它彷佛并不广为人知。
一般来讲,根据字母在字母表中的顺序,咱们假定在一种语言中对单词排序与逐字排序同样容易。可是这里有两种陷阱:
咱们将在这个法文的简单SQL查询中举例说明:
MySQL
db=# select title from book where id between 1 and 4 order by title collate "POSIX"; title ------- cote coté côte côté
1 2 3 4 5 6 7 |
db=# select title from book where id between 1 and 4 order by title collate "POSIX"; title ------- cote coté côte côté |
这是逐字排序的结果,从左到右。
可是这些单词是法语,因此这才是正确的:
MySQL
db=# select title from book where id between 1 and 4 order by title collate "en_GB"; title ------- cote côte coté côté
1 2 3 4 5 6 7 |
db=# select title from book where id between 1 and 4 order by title collate "en_GB"; title ------- cote côte coté côté |
这两个结果不一样,由于正确的单词顺序由排序规则决定——法语中的排序规则是在给定的单词中最后一个重音决定顺序。这是该特殊语言的一个特色。所以—— 语言的内容能够影响排序结果,而忽略语言会致使意想不到的排序结果。
提示:
在单一语言的应用中,初始化数据库老是要用合适的区域设置,
在多语言应用中,用默认的区域设置初始化数据库,在每个须要排序的地方决定在SQL查询中该使用哪一种排序规则:
也许你应当使用针对当前用户的排序规则,
有时你可能但愿使用特定于被浏览数据的语言。
若是能够,应用排序规则到列和表——看文章了解更多。
这是咱们的书城最终的版本: