CategoryResourceRepost/极客时间专栏/SQL必知必会/第二章:SQL性能优化篇/22丨反范式设计:3NF有什么不足,为什么有时候需要反范式设计?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

201 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="22丨反范式设计3NF有什么不足为什么有时候需要反范式设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e0/4f/e03c1e077cc21e8e723c2bf04bdf754f.mp3"></audio>
上一篇文章中,我们介绍了数据表设计的三种范式。作为数据库的设计人员,理解范式的设计以及反范式优化是非常有必要的。
为什么这么说呢?了解以下几个方面的内容之后你就明白了。
1. 3NF有什么不足除了3NF我们为什么还需要BCNF
1. 有了范式设计,为什么有时候需要进行反范式设计?
1. 反范式设计适用的场景是什么?又可能存在哪些问题?
## BCNF巴斯范式
如果数据表的关系模式符合3NF的要求就不存在问题了吗我们来看下这张仓库管理关系warehouse_keeper表
<img src="https://static001.geekbang.org/resource/image/8b/17/8b543855d7c005b3e1b0ee3fbb308b17.png" alt=""><br>
在这个数据表中,一个仓库只有一个管理员,同时一个管理员也只管理一个仓库。我们先来梳理下这些属性之间的依赖关系。
仓库名决定了管理员,管理员也决定了仓库名,同时(仓库名,物品名)的属性集合可以决定数量这个属性。
这样,我们就可以找到数据表的候选键是(管理员,物品名)和(仓库名,物品名),
然后我们从候选键中选择一个作为主键,比如(仓库名,物品名)。
在这里,主属性是包含在任一候选键中的属性,也就是仓库名,管理员和物品名。非主属性是数量这个属性。
如何判断一张表的范式呢?我们需要根据范式的等级,从低到高来进行判断。
首先数据表每个属性都是原子性的符合1NF的要求其次数据表中非主属性”数量“都与候选键全部依赖仓库名物品名决定数量管理员物品名决定数量因此数据表符合2NF的要求最后数据表中的非主属性不传递依赖于候选键。因此符合3NF的要求。
既然数据表已经符合了3NF的要求是不是就不存在问题了呢我们来看下下面的情况
1. 增加一个仓库,但是还没有存放任何物品。根据数据表实体完整性的要求,主键不能有空值,因此会出现插入异常;
1. 如果仓库更换了管理员,我们就可能会修改数据表中的多条记录;
1. 如果仓库里的商品都卖空了,那么此时仓库名称和相应的管理员名称也会随之被删除。
你能看到即便数据表符合3NF的要求同样可能存在插入更新和删除数据的异常情况。
这种情况下该怎么解决呢?
首先我们需要确认造成异常的原因主属性仓库名对于候选键管理员物品名是部分依赖的关系这样就有可能导致上面的异常情况。人们在3NF的基础上进行了改进提出了**BCNF也叫做巴斯-科德范式它在3NF的基础上消除了主属性对候选键的部分依赖或者传递依赖关系**。
根据BCNF的要求我们需要把仓库管理关系warehouse_keeper表拆分成下面这样
仓库表:(仓库名,管理员)
库存表:(仓库名,物品名,数量)
这样就不存在主属性对于候选键的部分依赖或传递依赖上面数据表的设计就符合BCNF。
## 反范式设计
尽管围绕着数据表的设计有很多范式,但事实上,我们在设计数据表的时候却不一定要参照这些标准。
我们在之前已经了解了越高阶的范式得到的数据表越多,数据冗余度越低。但有时候,我们在设计数据表的时候,还需要为了性能和读取效率违反范式化的原则。反范式就是相对范式化而言的,换句话说,就是允许少量的冗余,通过空间来换时间。
如果我们想对查询效率进行优化,有时候反范式优化也是一种优化思路。
比如我们想要查询某个商品的前1000条评论会涉及到两张表。
商品评论表product_comment对应的字段名称及含义如下
<img src="https://static001.geekbang.org/resource/image/4c/b1/4c08f4fe07414ef26a48c1c0c52590b1.png" alt="">
用户表user对应的字段名称及含义如下
<img src="https://static001.geekbang.org/resource/image/7d/f2/7daa1d6b0f5b42cd79e024d49ecf91f2.png" alt=""><br>
下面,我们就用这两张表模拟一下反范式优化。
## 实验数据:模拟两张百万量级的数据表
为了更好地进行SQL优化实验我们需要给用户表和商品评论表随机模拟出百万量级的数据。我们可以通过存储过程来实现模拟数据。
下面是给用户表随机生成100万用户的代码
```
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
```
我用date_start变量来定义初始的注册时间时间为2017年1月1日0点0分0秒然后用date_temp变量计算每个用户的注册时间新的注册用户与上一个用户注册的时间间隔为60秒内的随机值。然后使用REPEAT … UNTIL … END REPEAT循环对max_num个用户的数据进行计算。在循环前我们将autocommit设置为0这样等计算完成再统一插入执行效率更高。
然后我们来运行call insert_many_user(10000, 1000000);调用存储过程。这里需要通过start和max_num两个参数对初始的user_id和要创建的用户数量进行设置。运行结果
<img src="https://static001.geekbang.org/resource/image/e7/36/e716bfa9153fea2c4bc524946796a036.png" alt=""><br>
你能看到在MySQL里创建100万的用户数据用时1分37秒。
接着我们再来给商品评论表product_comment随机生成100万条商品评论。这里我们设置为给某一款商品评论比如product_id=10001。评论的内容为随机的20个字母。以下是创建随机的100万条商品评论的存储过程
```
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
```
同样的我用date_start变量来定义初始的评论时间。这里新的评论时间与上一个评论的时间间隔还是60秒内的随机值商品评论表中的user_id为随机值。我们使用REPEAT … UNTIL … END REPEAT循环来对max_num个商品评论的数据进行计算。
然后调用存储过程,运行结果如下:
<img src="https://static001.geekbang.org/resource/image/23/c1/2346454ce5be3e50e1419960b15b60c1.png" alt=""><br>
MySQL一共花了2分7秒完成了商品评论数据的创建。
## 反范式优化实验对比
如果我们想要查询某个商品ID比如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
```
运行结果1000条数据行
<img src="https://static001.geekbang.org/resource/image/18/ae/18b242e516e0e9ebd68ca7b7d1e1d9ae.png" alt=""><br>
运行时长为0.395秒,查询效率并不高。
这是因为在实际生活中我们在显示商品评论的时候通常会显示这个用户的昵称而不是用户ID因此我们还需要关联product_comment和user这两张表来进行查询。当表数据量不大的时候查询效率还好但如果表数据量都超过了百万量级查询效率就会变低。这是因为查询会在product_comment表和user表这两个表上进行聚集索引扫描然后再嵌套循环这样一来查询所耗费的时间就有几百毫秒甚至更多。对于网站的响应来说这已经很慢了用户体验会非常差。
如果我们想要提升查询的效率可以允许适当的数据冗余也就是在商品评论表中增加用户昵称字段在product_comment数据表的基础上增加user_name字段就得到了product_comment2数据表。
你可以在[百度网盘](https://pan.baidu.com/s/104t0vIlrA4nypu_PZIXG0w)中下载这三张数据表product_comment、product_comment2和user表密码为n3l8。
这样一来,只需单表查询就可以得到数据集结果:
```
SELECT comment_text, comment_time, user_name FROM product_comment2 WHERE product_id = 10001 ORDER BY comment_id DESC LIMIT 1000
```
运行结果1000条数据
<img src="https://static001.geekbang.org/resource/image/af/2c/af1be5874a9e20414de1ec48775e392c.png" alt=""><br>
优化之后只需要扫描一次聚集索引即可运行时间为0.039秒查询时间是之前的1/10。 你能看到,在数据量大的情况下,查询效率会有显著的提升。
## 反范式存在的问题&amp;适用场景
从上面的例子中可以看出,反范式可以通过空间换时间,提升查询的效率,但是反范式也会带来一些新问题。
在数据量小的情况下,反范式不能体现性能的优势,可能还会让数据库的设计更加复杂。比如采用存储过程来支持数据的更新、删除等额外操作,很容易增加系统的维护成本。
比如用户每次更改昵称的时候,都需要执行存储过程来更新,如果昵称更改频繁,会非常消耗系统资源。
那么反范式优化适用于哪些场景呢?
在现实生活中,我们经常需要一些冗余信息,比如订单中的收货人信息,包括姓名、电话和地址等。每次发生的订单收货信息都属于历史快照,需要进行保存,但用户可以随时修改自己的信息,这时保存这些冗余信息是非常有必要的。
当冗余信息有价值或者能大幅度提高查询效率的时候,我们就可以采取反范式的优化。
此外反范式优化也常用在数据仓库的设计中,因为数据仓库通常存储历史数据,对增删改的实时性要求不强,对历史数据的分析需求强。这时适当允许数据的冗余度,更方便进行数据分析。
我简单总结下数据仓库和数据库在使用上的区别:
1. 数据库设计的目的在于捕获数据,而数据仓库设计的目的在于分析数据;
1. 数据库对数据的增删改实时性要求强,需要存储在线的用户数据,而数据仓库存储的一般是历史数据;
1. 数据库设计需要尽量避免冗余,但为了提高查询效率也允许一定的冗余度,而数据仓库在设计上更偏向采用反范式设计。
## 总结
今天我们讲了BCNF它是基于3NF进行的改进。你能看到设计范式越高阶数据表就会越精细数据的冗余度也就越少在一定程度上可以让数据库在内部关联上更好地组织数据。但有时候我们也需要采用反范进行优化通过空间来换取时间。
范式本身没有优劣之分,只有适用场景不同。没有完美的设计,只有合适的设计,我们在数据表的设计中,还需要根据需求将范式和反范式混合使用。
<img src="https://static001.geekbang.org/resource/image/ac/fb/acbb07c269c85683cc981c7f677d32fb.jpg" alt=""><br>
我们今天举了一个反范式设计的例子,你在工作中是否有做过反范式设计的例子?欢迎你在评论区与我们一起分享,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。