《SQL 反模式》学习笔记
06 Mar 2015- 第一章
- 第一部分 逻辑型数据库设计反模式
第一章
CAP 组合
- 一致性(Consistency)
- 可用性(Availability)
- 分区容忍性(Partition)
- CAP 原理认为这三要素最多能同时实现两点,不可能三者兼顾
反模式分类
(1)逻辑数据库设计反模式
在开始编码之前,需要决定数据库中存储什么信息以及最佳的数据组织方式和内在关联方式。
这包含了如何设计数据库的表、字段和关系。
(2)物理数据库设计反模式
在确定了需要存储哪些数据之后,使用你所知的RDBMS关系型数据库技术特性尽可能高效地实现数据库管理。
这包含了定义表和索引,以及选择数据类型。也需要是要SQL的“数据定义语言”,比如Create Table语句。
(3)查询反模式
SQL的查询是使用“数据操作语言”来完成,比如:Insert、Select、Update和Delete语句。
(4)应用程序开发反模式
SQL应该会用在Java、.Net、C++、Php等语言构建的应用程序中,在应用程序中使用SQL的方式有好有坏。
第一部分 逻辑型数据库设计反模式
第二章:乱穿马路
- 程序员通常使用逗号分隔的列表来避免在多对多的关系中创建交叉表,我将这种合计方式定义为一种反模式,称为乱穿马路(Jaywalking)
目标:
存储多值属性,即实现多对一的关系
反模式:格式化的逗号分隔列表
反模式:将多个值以格式化的逗号分隔符存储在一个字段中
反模式导致的问题:
- 所有外键合并在一个字段内,查询会变得异常困难。需要使用正则表达式模式匹配,无法使用索引,各个数据库中的语法不兼容
- 关联查询:查询指定 产品的账号信息。简单的sql语句无法实现,而且无法使用索引,查询效率极低。
- 聚合查询如 COUNT(),SUM() 等等是针对分组行而设计,逗号分隔的列表,需要用非正常方法实现
- 删除一个条目,需要先提取老的列表,更新后并存储,过于复杂
- 需要验证防止非法字段,合法数据无法保证
- 需要选择合适的分隔符
- 字段的长度限制会影响分类数量。
如何识别反模式
- 字段存储的值有长度限制
- 需要使用正则表达式来提取字符串中的组成部分,意味着你应该把这些数据分开存储
- 内容中出现了令人困惑的分割符号
合理使用反模式
- 处于性能优化的考虑,可能需要使用这种反规范化的模式
- 只需要存储而不需要修改时,可以使用逗号分隔的存储格式
解决方案:
- 创建一张交叉表
- 每个值都应该存储在各自的行与列中
第三章:单纯的树
- 树是一种存在递归关系的数据
- 树形结构中,实例被称为
节点(node)
,每个节点有多个子节点和一个父节点 - 最上层的节点叫
根(root)
节点,它没有父节点 - 最底层的没有子节点的节点叫做
叶(leaf)
- 中间的节点简单的称为
非叶(noleaf)
目标:分层存储与查询
- 层级数据中,可能需要查询与整个集合或者子集相关的特定对象,比如组织架构图、线程化评论等
反模式:总是依赖父节点
邻接表: 表中添加 parent_id
字段,引用同一张表中的其它回复。可以建一个外键来维护这种关系
邻接表 ERD:
- 树的特性是可以无限制的任意深度扩展,需要有办法获取任意深度的数据
- SQL 查询中联结的次数是有上限的
- 邻接表插入一个节点非常的简单,但是删除一个节点会异常复杂
如何识别反模式
- 只支持查询有限层级的数据
- 数结构管理维护非常复杂
- 需要定期清理树中的孤立节点数据
解决方案:使用其他树模型
路径枚举
- 通过将所有祖先的信息联合成一个字段 path,并保存为每个节点的一个属性
优点:
- 查询方便
- 插入一个叶子节点也可以非常的方便
缺点:
- 数据库不能确保路径的格式总是正确或者路径中的结点确实存在
- 存在长度限制,并不能无限扩展
实现代码 mysql:
-- 创建路径枚举
CREATE TABLE Comments (
comment_id SERIAL PRIMARY KEY,
path VARCHAR(1000),
author VARCHAR(1000),
comment TEXT NOT NULL
);
INSERT INTO Comments (comment_id, path, author, comment) VALUES(1, "1/", "Fran", "这个Bug的成因是什么");
INSERT INTO Comments (comment_id, path, author, comment) VALUES(2, "1/2/", "Ollie", "我觉得是一个空指针");
INSERT INTO Comments (comment_id, path, author, comment) VALUES(3, "1/2/3/", "Fran", "不,我查过了");
INSERT INTO Comments (comment_id, path, author, comment) VALUES(4, "1/4/", "Kukla", "我们需要查无效输入");
INSERT INTO Comments (comment_id, path, author, comment) VALUES(5, "1/4/5/", "Fran", "是的,那是个问题");
INSERT INTO Comments (comment_id, path, author, comment) VALUES(6, "1/4/6/", "Frank", "好,查一下吧");
INSERT INTO Comments (comment_id, path, author, comment) VALUES(7, "1/4/6/7/", "Frank", "解决了");
-- ancestors.sql
SELECT *
FROM Comments AS c
WHERE '1/4/6/7/' LIKE c.path || '%'; -- mysql 不能用,语法不同
-- descendants.sql
SELECT *
FROM Comments AS c
WHERE c.path LIKE '1/4/' || '%';
-- count.sql
SELECT COUNT(*)
FROM Comments AS c
WHERE c.path LIKE '1/4/' || '%'
GROUP BY c.author;
-- Insert.sql
INSERT INTO Comments (author, comment) VALUES ('Ollie', 'Good job!');
SELECT @PATH:=CONCAT((SELECT path FROM Comments WHERE comment_id = 7), LAST_INSERT_ID(), '/');
UPDATE Comments SET path=@PATH WHERE comment_id = LAST_INSERT_ID();
嵌套集
使用 nsleft 和 nsright 两个数字来编码每个节点, nsleft 的数值小于该节点所有后代的 ID,同 时 nsright 的值大于该节点所有后代的 ID
优点:
- 删除方便,原非叶节点被删除,后代会自动代替被删除节点
缺点:
- 获取直接父亲或直接后代的查询非常不便,需要加入 depth(层级) 来弥补缺陷
- 插入和移动节点比较复杂
实现代码 mysql:
CREATE TABLE Comments (
comment_id SERIAL PRIMARY KEY,
nsleft INTEGER NOT NULL,
nsright INTEGER NOT NULL,
author VARCHAR(1000),
comment TEXT NOT NULL
);
-- 插入
-- 根据“嵌套集合模型”的数学原理,子节点的左界是当前节点的右界,子节点的右界是当前节点的右界加1,并且所有在当前节点右侧的节点的左右界都加 2
INSERT INTO Comments (comment_id, depth, nsleft, nsright, author, comment) VALUES (1, 1, 1, 2, "Fran", "这个 Bug 的成因是什么");
UPDATE Comments SET nsleft=nsleft+2 WHERE nsleft>2;
UPDATE Comments SET nsright=nsright+2 WHERE nsright>=2;
INSERT INTO Comments (comment_id, depth, nsleft, nsright, author, comment) VALUES (2, 1+1, 2, 2+1, "Ollie", "我觉得是一个空指针");
UPDATE Comments SET nsleft=nsleft+2 WHERE nsleft>3;
UPDATE Comments SET nsright=nsright+2 WHERE nsright>=3;
INSERT INTO Comments (comment_id, depth, nsleft, nsright, author, comment) VALUES (3, 2+1, 3, 3+1, "Fran", "不,我查过了");
UPDATE Comments SET nsleft=nsleft+2 WHERE nsleft>6;
UPDATE Comments SET nsright=nsright+2 WHERE nsright>=6;
INSERT INTO Comments (comment_id, depth, nsleft, nsright, author, comment) VALUES (4, 1+1, 6, 6+1, "Kukla", "我们需要查无效输入");
UPDATE Comments SET nsleft=nsleft+2 WHERE nsleft>7;
UPDATE Comments SET nsright=nsright+2 WHERE nsright>=7;
INSERT INTO Comments (comment_id, depth, nsleft, nsright, author, comment) VALUES (5, 2+1, 7, 7+1, "Ollie", "是的,那是个问题");
UPDATE Comments SET nsleft=nsleft+2 WHERE nsleft>9;
UPDATE Comments SET nsright=nsright+2 WHERE nsright>=9;
INSERT INTO Comments (comment_id, depth, nsleft, nsright, author, comment) VALUES (6, 2+1, 9, 9+1, "Fran", "好,查一下吧");
UPDATE Comments SET nsleft=nsleft+2 WHERE nsleft>10;
UPDATE Comments SET nsright=nsright+2 WHERE nsright>=10;
INSERT INTO Comments (comment_id, depth, nsleft, nsright, author, comment) VALUES (7, 3+1, 10, 10+1, "Kukla", "解决了");
-- 查询所有叶节点
-- 根据此模型,叶节点就是右界比左界大1的那些节点,SQL语句很简单。
SELECT * FROM Comments WHERE nsright = nsleft + 1;
-- 取得单条路径
SELECT parent.comment_id, parent.comment FROM Comments AS node, Comments AS parent
WHERE parent.nsleft <= node.nsleft AND node.nsleft <= parent.nsright AND node.comment_id = 7
ORDER BY parent.nsleft;
-- 查询节点的深度
SELECT depth FROM Comments WHERE comment_id=7;
-- 整棵树及其深度
SELECT depth, CONCAT( REPEAT('- ', depth - 1), comment) AS name FROM Comments ORDER BY nsleft;
-- 子树及深度
SELECT depth, CONCAT( REPEAT('- ', depth - 1), comment) AS comment FROM Comments WHERE nsleft>=2 AND nsright<=11 ORDER BY nsleft;
-- 直接的子节点
-- 查询根节点的直接子节点
SELECT comment_id, depth, comment FROM Comments WHERE nsleft>1 AND nsright<14 AND depth=1+1 ORDER BY nsleft;
-- 直接的父节点
SELECT parent.comment_id, parent.comment FROM Comments AS node, Comments AS parent
WHERE parent.nsleft < node.nsleft AND node.nsleft < parent.nsright AND node.comment_id = 7
ORDER BY parent.nsleft DESC LIMIT 1;
闭包表
Closure Tree 闭包表,额外创建了一张 TreePaths 的表,以空间换取时间的思路,它包含两列,每一列都是一个指向 Comments 中的 CommentId 的外键,明晰表示两点间的关系
实现代码 mysql:
-- 创建评论表
CREATE TABLE Comments (
id SERIAL PRIMARY KEY,
comment TEXT NOT NULL,
author VARCHAR(1000)
) DEFAULT CHARSET = utf8;
-- 创建关系表
CREATE TABLE comments_closure (
ancestor INT NOT NULL, -- 祖先节点
descendant INT NOT NULL, -- 后代节点
depth INT NOT NULL, -- 层级
PRIMARY KEY (ancestor, descendant)
)
-- 插入数据
INSERT INTO Comments (id, author, comment) VALUES (1, "Fran", "这个 Bug 的成因是什么");
INSERT INTO comments_closure (ancestor, descendant)
SELECT t.ancestor, 1 FROM comments_closure AS t WHERE t.descendant=1 UNION ALL SELECT 1,1;
INSERT INTO Comments (id, author, comment) VALUES (2, "Ollie", "我觉得是一个空指针");
INSERT INTO comments_closure (ancestor, descendant)
SELECT t.ancestor, 2 FROM comments_closure AS t WHERE t.descendant=1 UNION ALL SELECT 2,2;
INSERT INTO Comments (id, author, comment) VALUES (3, "Fran", "不,我查过了");
INSERT INTO comments_closure (ancestor, descendant)
SELECT t.ancestor, 3 FROM comments_closure AS t WHERE t.descendant=2 UNION ALL SELECT 3,3;
INSERT INTO Comments (id, author, comment) VALUES (4, "Kukla", "我们需要查无效输入");
INSERT INTO comments_closure (ancestor, descendant)
SELECT t.ancestor, 4 FROM comments_closure AS t WHERE t.descendant=1 UNION ALL SELECT 4,4;
INSERT INTO Comments (id, author, comment) VALUES (5, "Ollie", "是的,那是个问题");
INSERT INTO comments_closure (ancestor, descendant)
SELECT t.ancestor, 5 FROM comments_closure AS t WHERE t.descendant=4 UNION ALL SELECT 5,5;
INSERT INTO Comments (id, author, comment) VALUES (6, "Fran", "好,查一下吧");
INSERT INTO comments_closure (ancestor, descendant)
SELECT t.ancestor, 6 FROM comments_closure AS t WHERE t.descendant=4 UNION ALL SELECT 6,6;
INSERT INTO Comments (id, author, comment) VALUES (7, "Kukla", "解决了");
INSERT INTO comments_closure (ancestor, descendant)
SELECT t.ancestor, 7 FROM comments_closure AS t WHERE t.descendant=6 UNION ALL SELECT 7,7;
-- 查询后代
-- 查询 #4 的后代
SELECT c.* FROM Comments AS c JOIN comments_closure AS t ON c.id=t.descendant WHERE t.ancestor=4;
-- 查询祖先,#6 的祖先
SELECT c.* FROM Comments AS c JOIN comments_closure AS t ON c.id=t.ancestor WHERE t.descendant=6;
-- 删除一个叶子节点, #7
DELETE FROM comments_closure WHERE descendant=7;
-- 删除一颗完整的树,比如 #4 he 它的所有后代
DELETE FROM comments_closure WHERE descendant IN (SELECT descendant FROM comments_closure WHERE ancestor=4);
第 4 章:需要 ID
4.1 目标:建立主键规范
- 主键是数据库确保数据行在整张表中唯一性的保障,它是定位到一条记录并且确保不会重复存储的逻辑机制。
- 主键也同时可以被外键引用来建立表与表之间的关系。
主键约束是很重要的情况:
- 确保一张表中的数据不会出现重复行;
- 在查询中引用单独的一行记录;
- 支持外键。
数据库伪主键实现
一个对于表的域模型无意义的新列来存储一个伪值,这种类型的主键列我们通常称其为伪主键或者代理键。
特性 | 支持的数据库 |
---|---|
AUTO_INCREMENT | MySQL |
GENERATOR | Firebird, InterBase |
IDENTITY | DB2, Derby, Microsoft SQL Server, Sybase |
ROWID | SQLite |
SEQUENCE | DB2, Firebird, Informix, Ingres, Oracle,PostgreSQL |
SERIAL | MySQL, PostgreSQL |
反模式:以不变应万变
- 主键的列名叫做id;
- 数据类型是32位或者64位整型;
- 主键的值是自动生成来确保唯一的。
允许重复项
id 这一列作为主键,约束就不再是bug_id和product_id的组合必须唯一
CREATE TABLE BugsProducts (
id SERIAL PRIMARY KEY,
bug_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id),
FOREIGN KEY (product_id) REFERENCES Products(product_id)
);
INSERT INTO BugsProducts (bug_id, product_id)
VALUES (1234, 1), (1234, 1), (1234, 1); -- 重复项也是可以输入的
但是,当你在bug_id和product_id这两列上应用了唯一性约束,id这一列就会变成多余的
CREATE TABLE BugsProducts (
id SERIAL PRIMARY KEY,
bug_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
UNIQUE KEY (bug_id, product_id),
FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id),
FOREIGN KEY (product_id) REFERENCES Products(product_id)
);
使用USING关键字
SQL 支持简洁的表达式来表示两张表的联结。如果两张表都有同样的列名,就可以用如下的表达式查询:
SELECT * FROM Bugs JOIN BugsProducts USING (bug_id);
使用组合键之难
- 序列通过将运算和事务在逻辑上分离来解决并发问题。
- 序列确保即使在多并发下,每次调用都会返回不同的值
内置函数来获取一个序列生成的最后一个值:
- MySQL 中的这个函数叫做 LAST_INSERT_ID();
- Microsoft SQL Server 使用叫做S COPE_IDENTITY() 的函数
- Oracle 中称为 SequenceName.CURRVAL()
其它
如果使用框架的话,比如 rails 之类的,后面的就不要看了,虽然组合键作为主键是可以的,但是框架也可以帮我们做重复项的约束,所以使用伪主键也是没问题的
第 5 章: 不用钥匙的入口
MySQL默认的存储引擎是MyISAM,并不支持外键约束,数据库的设计中有很多的逻辑关系,但无法保证引用完整性
目标:简化数据库架构
不使用外键的原因:
- 数据更新有可能和约束冲突。
- 当前的数据库设计如此灵活,以致于不支持引用完整性约束。
- 数据库为外键建立的索引会影响性能。
- 当前使用的数据库不支持外键。
- 定义外键的语法并不简单,还需要查阅。
反模式:无视约束
省略外键约束能使得数据库设计更加简单、灵活,或者执行更加高效, 但是,必须增加额外的代码来手动维护引用完整性
合理使用反模式
被迫使用不支持外键约束的数据库产品:
- MySQL的MyISAM存储引擎
- 或者 SQLite 3.6.19 早的版本
5.5 解决方案:声明约束
第6章 实体—属性—值
按日期来统计记录条数:
SELECT date_reported, COUNT(*)
FROM Bugs
GROUP BY date_reported;
目标:支持可变的属性
Bug和FeatureRequest有一些公共属性,我们将其提炼为一个基类,称为Issue
反模式:使用泛型属性表
EAV(Entity,Attribute,Value)模型
好处:
- 这两张表的列都很少。
- 新增的属性不会对现有的表结构造成影响,不需要新增列。
- 避免了由于空值而造成的表内容混乱。
缺点:
- EVA 模型设计上的简单化并不足以弥补其造成的使用上的难度
- 查询操作更加啰唆,而且不够清晰
- 无法声明强制属性
- 无法使用SQL的数据类型
- 无法确保引用完整性
- 无法配置属性名
- 必须使用外联结来进行查询,随着属性的数量不断增多,联结的数量也不断增长,查询的开销也成指数级地增长
合理使用反模式
- 最好不要用 EAV 模型
- 如果你有非关系数据管理的需求,最好的答案是使用非关系技术 Redis, Berkeley DB, CouchDB, Hadoop和HBase 等等
解决方案:模型化子类型
单表继承
将所有相关的类型都存在一张表中,为所有类型的所有属性都保留一列。同时,使用一个属性来定义每一行表示的子类型。
实体表继承
为每个子类型创建一张独立的表。每个表包含那些属于基类的共有属性,同时也包含子类型特殊化的属性
类表继承
模拟了继承,把表当成面向对象里的类。创建一张基类表,包含所有子类型的公共属性。对于每个子类型,创建一个独立的表,通过外键和基类表相连。
半结构化数据模型
- 使用一个BLOB列来存储
- 数据,用XML或者JSON格式——同时包含了属性的名字和值。Martin Fowler称这个模式为:序列化大对象块(Serialized LOB)。
- 例如:rails 的序列化 hash 以及利用 pg 的
jsonb
第 7 章:多态关联
评论有两张表,Bugs 和 FeatureRequests 是类似的实体,想要按行联结不同的表
目标:引用多个父表
- Comments表中的一条记录即可能匹配Bugs表中的某条记录,
- 也可能匹配于FeatureReqeuests表中的某条记录
反模式:
- 使用多用途外键。这种设计也叫做多态关联,或者杂乱关联。
- 多态关联和EAV有着相似的特征:元数据对象的名字是存储在字符串中的。
- 在多态关联中,父表的名字是存储在issue_type列中的。有时这样的设计被称为:混合数据与元数据
查询示例:
SELECT *
FROM Bugs AS b JOIN Comments AS c
ON (b.issue_id = c.issue_id AND c.issue_type = 'Bugs')
WHERE b.issue_id = 1234;
如何识别反模式:当出现以下情况时,可能是反模式
(1)这种标记框架可以让你将标记(或者其他属性)和数据库中的任何其他资源联系起来。就像EAV的设计一样,应该怀疑任何生成有无线扩展性的设计。 (2)不能在数据库中声明外键。 (3)使用混合数据与元数据设计,父表的名字是存储在 issue_type 这样的列中
合理使用反模式
- 尽可能地避免使用多态关联——应该使用外键约束等来确保引用完整性,多态关联通常过度依赖上层程序代码而不是数据库的元数据
- 使用一个成熟的框架,框架通过良好的逻辑封装来减少使用多态关联的风险
解决方案:让关系变得简单
- 解决方案将变得异常的简单:多态关联是一个反向关联
- 为每个父表创建一张独立的交叉表
- 创建共用的超级表, 通过创建一个基类表,并让所有的父表都从这个基类表扩展出来的方法来解决这个问题
第 8 章:多列属性
目标:存储多值属性
反模式:创建多个列
- 比如用户有多个电话、手机、email 地址等
- 在多个列中查询需要使用
IN
或OR
- 添加或删除值需要繁琐的查询
- 确保唯一性比较困难
- 字段列数无法预计,增加列非常麻烦
如何识别反模式
- 乱穿马路和多值属性,这两个反模式都是解决同一个目标的解决方案——存储一个具有多个值的属性
- 乱穿马路是多对多关系的存储
当出现以下情况时,可能是反模式:
- 你需要决定为标签这样的多值属性定义多少列?
- 如何在SQL查询中同时搜索多列?
合理使用反模式
- 一个属性可能有固定数量的候选值,并且对应的存储位置和顺序都是固定的
解决方案:创建从属表
- 最好的解决方案是创建一张从属表,仅使用一列来存储多值属性。将多个值存在多行中而不是多列里
- 在从属表中定义一个外键,将这个值和目标表中的主键Id关联起来
第 9 章:元数据分裂
目标:支持可扩展性
- 数据量增长,数据库的查询性能下降,索引作用有限
- 需要优化数据库的结构来提升查询性能,以及需要支持表的平滑扩展
反模式:克隆表与克隆列
- 将一张很长的表拆分成多张较小的表,使用表中某一个特定的数据字段来给这些拆分出来 的表命名。
- 将一个列拆分成多个子列,使用别的列中的不同值给拆分出来的列命名
- 通过将年份追加在基本表名之后,我们其实是将数据和元数据标识合并在了一起
- EAV和多态关联反模式中看到的混合数据和元数据的方式正好相反, 将元数据标识(列名和表名)当做字符串存储
缺点:
- 不断产生的新表
- 无法管理数据完整性
- 同步数据,需要复杂的操作来修改数据
- 无法确保唯一性,数据存在不同的表中
- 跨表查询麻烦
- 同步元数据:拆表后,改变一个表的结构,需要同时修改所有的
- 不能用简单的外键来管理引用完整性
- 标识元数据分裂列
如何识别反模式
元数据分裂反模式在你的数据库中繁衍生长的暗示:
- 我们需要每……创建一张表(或者列)
- 数据库所支持的最大数量的表(或者列)是多少
- 将数据表根据年份创建一张新表,导致查询和添加失败
- “我要怎样同时查询很多张表?每张表的列都是一样的。” 如果你需要查询很多结构一样的表,就应该将数据全都存在一个表中,使用一个额外的属 性列来分组数据
- 我要怎样将表名作为一个变量传递?我在查询时需要根据年份动态地生成这些表名
合理使用反模式
- 手动分割表的一个合理使用场景是归档数据——将历史数据从日常使用的数据中移除,当然除非你不需要再使用或者查询数据了
解决方案:分区及标准化
- 除了手动拆分这张表,还有更好的办法来提升查询性能,这些方法就包括了水平分区、垂直分区以及使用关联表
- 水平分区:使用水平分区或者分片的数据库特性来分割大数据量的表,没有手动分割表的缺陷,可以使用简单的 SQL 查询,比如,使用 MySQL5.1 所支持的分区特性
- 垂直分区: 垂直分区就是根据列来对表进行拆分。当某些列非常庞大或者很少使用的时候,对表进行按列拆分会比较有优势。
- 多列属性中使用的解决方案与垂直分区类似,解决元数据分裂的改进方案就是创建关联 表。
- 别让数据繁衍元数据