learn-tech/专栏/MySQL实战宝典/05表结构设计:忘记范式准则.md
2024-10-16 06:37:41 +08:00

352 lines
13 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.

因收到Google相关通知网站将会择期关闭。相关通知内容
05 表结构设计:忘记范式准则
前面几讲我虽然带你了解了数字类型、字符串、日期类型,以及非结构化的 JSON 类型,但也只是每条记录每个字段的选择。
而我们在对一张表进行设计时,还要遵守一些基本的原则,比如你经常听见的“范式准则”。但范式准则过于理论,在真实业务中,你不必严格遵守三范式的要求。而且有时为了性能考虑,你还可以进行反范式的设计,比如在数据仓库领域。这一讲我就会带你了解这些内容,希望你学完这一讲之后,能从更高一层的维度来看待 MySQL 数据库的表结构设计。
忘记范式准则
相信你在大学学习《数据库系统概论》时肯定学习过关系数据库的设计规范比如第一范式、第二范式、第三范式BC 范式等,它们是《数据库系统概论》考试中重要的考点。
范式设计是非常重要的理论,是通过数学集合概念来推导范式的过程,在理论上,要求表结构设计必须至少满足三范式的要求。
由于完全是数据推导过程,范式理论非常枯燥,但你只要记住几个要点就能抓住其中的精髓:
一范式要求所有属性都是不可分的基本数据项;
二范式解决部分依赖;
三范式解决传递依赖。
虽然我已经提炼了范式设计的精髓,但要想真正理解范式设计,就要抛弃纯理论的范式设计准则,从业务角度出发,设计出符合范式准则要求的表结构。
工程上的表结构设计实战
真实的业务场景是工程实现,表结构设计做好以下几点就已经足够:
每张表一定要有一个主键方法有自增主键设计、UUID 主键设计、业务自定义生成主键);
消除冗余数据存在的可能。
我想再次强调一下,你不用过于追求所谓的数据库范式准则,甚至有些时候,我们还会进行反范式的设计。
自增主键设计
主键用于唯一标识一行数据,所以一张表有主键,就已经直接满足一范式的要求了。在 01 讲的整型类型中,我提及可以使用 BIGINT 的自增类型作为主键,同时由于整型的自增性,数据库插入也是顺序的,性能较好。
但你要注意,使用 BIGINT 的自增类型作为主键的设计仅仅适合非核心业务表,比如告警表、日志表等。真正的核心业务表,一定不要用自增键做主键,主要有 6 个原因:
自增存在回溯问题;
自增值在服务器端产生,存在并发性能问题;
自增值做主键,只能在当前实例中保证唯一,不能保证全局唯一;
公开数据值容易引发安全问题例如知道地址http://www.example.com/User/10/,很容猜出 User 有 11、12 依次类推的值,容易引发数据泄露;
MGRMySQL Group Replication 可能引起的性能问题;
分布式架构设计问题。
自增存在回溯问题,我在 01 讲中已经讲到如果你想让核心业务表用自增作为主键MySQL 数据库版本应该尽可能升级到 8.0 版本。
又因为自增值是在 MySQL 服务端产生的值,需要有一把自增的 AI 锁保护,若这时有大量的插入请求,就可能存在自增引起的性能瓶颈。比如在 MySQL 数据库中,参数 innodb_autoinc_lock_mode 用于控制自增锁持有的时间。假设有一 SQL 语句,同时插入 3 条带有自增值的记录:
INSERT INTO ... VALUES (NULL,...),(NULL,...),(NULL,...);
则参数 innodb_autoinc_lock_mode 的影响如下所示:
从表格中你可以看到,一条 SQL 语句插入 3 条记录,参数 innodb_autoinc_lock_mode 设置为 1自增锁在这一条 SQL 执行完成后才释放。
如果参数 innodb_autoinc_lock_mode 设置为2自增锁需要持有 3 次,每插入一条记录获取一次自增锁。
这样设计好处是: 当前插入不影响其他自增主键的插入,可以获得最大的自增并发插入性能。
缺点是: 一条 SQL 插入的多条记录并不是连续的,如结果可能是 1、3、5 这样单调递增但非连续的情况。
所以,如果你想获得自增值的最大并发性能,把参数 innodb_autoinc_lock_mode 设置为2。
虽然,我们可以调整参数 innodb_autoinc_lock_mode获得自增的最大性能但是由于其还存在上述 5 个问题。因此,在互联网海量并发架构实战中,我更推荐 UUID 做主键或业务自定义生成主键。
UUID主键设计
UUIDUniversally Unique Identifier代表全局唯一标识 ID。显然由于全局唯一性你可以把它用来作为数据库的主键。
MySQL 数据库遵循 DRFC 4122 命名空间版本定义的 Version 1规范可以通过函数 UUID自动生成36字节字符。如
mysql> SELECT UUID();
+--------------------------------------+
| UUID() |
+--------------------------------------+
| e0ea12d4-6473-11eb-943c-00155dbaa39d |
+--------------------------------------+
根据 Version 1的规范MySQL中的 UUID 由以下几个部分组成:
UUID = 时间低4字节- 时间中高+版本4字节- 时钟序列 - MAC地址
前 8 个字节中60 位用于存储时间4 位用于 UUID 的版本号,其中时间是从 1582-10-15 000000.00 到现在的100ns 的计数。
60 位的时间存储中,其存储分为:
时间低位time-low占用 12 位;
时间中位time-mid占用 2 字节16 位;
时间高位time-high占用 4 字节32 位;
需要特别注意的是在存储时间时UUID 是根据时间位逆序存储, 也就是低时间低位存放在最前面,高时间位在最后,即 UUID 的前 4 个字节会随着时间的变化而不断“随机”变化,并非单调递增。而非随机值在插入时会产生离散 IO从而产生性能瓶颈。这也是 UUID 对比自增值最大的弊端。
为了解决这个问题MySQL 8.0 推出了函数 UUID_TO_BIN它可以把 UUID 字符串:
通过参数将时间高位放在最前,解决了 UUID 插入时乱序问题;
去掉了无用的字符串”-“,精简存储空间;
将字符串其转换为二进制值存储,空间最终从之前的 36 个字节缩短为了 16 字节。
下面我们将之前的 UUID 字符串 e0ea12d4-6473-11eb-943c-00155dbaa39d 通过函数 UUID_TO_BIN 进行转换,得到二进制值如下所示:
SELECT UUID_TO_BIN('e0ea12d4-6473-11eb-943c-00155dbaa39d',TRUE) as UUID_BIN;
+------------------------------------+
| UUID_BIN |
+------------------------------------+
| 0x11EB6473E0EA12D4943C00155DBAA39D |
+------------------------------------+
1 row in set (0.00 sec)
除此之外MySQL 8.0 也提供了函数 BIN_TO_UUID支持将二进制值反转为 UUID 字符串。
当然了MySQL 8.0版本之前没有函数 UUID_TO_BIN/BIN_TO_UUID但是你还是可以通过用户义函数UDF的方式解决如创建下面的函数
CREATE FUNCTION MY_UUID_TO_BIN(_uuid BINARY(36))
RETURNS BINARY(16)
LANGUAGE SQL DETERMINISTIC CONTAINS SQL SQL SECURITY INVOKER
RETURN
UNHEX(CONCAT(
SUBSTR(_uuid, 15, 4),
SUBSTR(_uuid, 10, 4),
SUBSTR(_uuid, 1, 8),
SUBSTR(_uuid, 20, 4),
SUBSTR(_uuid, 25) ));
CREATE FUNCTION MY_BIN_TO_UUID(_bin BINARY(16))
RETURNS CHAR(36)
LANGUAGE SQL DETERMINISTIC CONTAINS SQL SQL SECURITY INVOKER
RETURN
LCASE(CONCAT_WS('-',
HEX(SUBSTR(_bin, 5, 4)),
HEX(SUBSTR(_bin, 3, 2)),
HEX(SUBSTR(_bin, 1, 2)),
HEX(SUBSTR(_bin, 9, 2)),
HEX(SUBSTR(_bin, 11)) ));
因此,对于 04 讲创建的表 User可以将其主键修改为 BINARY(16),用于存储排序后的 16 字节的 UUID 值。其表结构修如下:
CREATE TABLE User (
id BINARY(16) NOT NULL,
name VARCHAR(255) NOT NULL,
sex CHAR(1) NOT NULL,
password VARCHAR(1024) NOT NULL,
money INT NOT NULL DEFAULT 0,
register_date DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
last_modify_date DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
uuid CHAR(36) AS (BIN_TO_UUID(id)),
CONSTRAINT chk_sex CHECK (sex = 'M' OR sex = 'F'),
PRIMARY KEY(id)
);
现在,你可以在客户端通过以下 SQL 命令插入数据,如:
INSERT INTO User VALUES (UUID_TO_BIN(UUID(),TRUE),......);
当然,很多同学也担心 UUID 的性能和存储占用的空间问题,这里我也做了相关的插入性能测试,结果如下表所示:
可以看到MySQL 8.0 提供的排序 UUID 性能最好甚至比自增ID还要好。此外由于UUID_TO_BIN转换为的结果是16 字节,仅比自增 ID 增加 8 个字节,最后存储占用的空间也仅比自增大了 3G。
而且由于 UUID 能保证全局唯一,因此使用 UUID 的收益远远大于自增ID。可能你已经习惯了用自增做主键但在海量并发的互联网业务场景下更推荐 UUID 这样的全局唯一值做主键。
比如,我特别推荐游戏行业的用户表结构设计,使用 UUID 作为主键,而不是用自增 ID。因为当发生合服操作时由于 UUID 全局唯一,用户相关数据可直接进行数据的合并,而自增 ID 却需要额外程序整合两个服务器 ID 相同的数据,这个工作是相当巨大且容易出错的。
业务自定义生成主键
当然了UUID 虽好,但是在分布式数据库场景下,主键还需要加入一些额外的信息,这样才能保证后续二级索引的查询效率(具体这部分内容将在后面的分布式章节中进行介绍)。现在你只需要牢记:分布式数据库架构,仅用 UUID 做主键依然是不够的。 所以,对于分布式架构的核心业务表,我推荐类似如下的设计,比如:
PK = 时间字段 + 随机码(可选) + 业务信息1 + 业务信息2 ......
电商业务中,订单表是其最为核心的表之一,你可以先打开淘宝 App查询下自己的订单号可以查到类似如下的订单信息
上图是我自己的淘宝订单信息第一个订单的订单号为1550672064762308113
订单号显然是订单表的主键,但如果你以为订单号是自增整型,那就大错特错了。因为如果你仔细观察的话,可以发现图中所有订单号的后 6 位都是相同的都为308113
1550672064762308113
1481195847180308113
1431156171142308113
1431146631521308113
所以,我认为淘宝订单号的最后 6 位是用户 ID 相关信息,前 14 位是时间相关字段,这样能保证插入的自增性,又能同时保留业务的相关信息作为后期的分布式查询。
消除冗余
消除冗余也是范式的要求,解决部分依赖和传递依赖,本质就是尽可能减少冗余数据。
所以,在进行表结构设计时,数据只需存放在一个地方,其他表要使用,通过主键关联存储即可。比如订单表中需要存放订单对应的用户信息,则保存用户 ID 即可:
CREATE TABLE Orders (
order_id VARCHRA(20),
user_id BINARY(16),
order_date datetime,
last_modify_date datetime
...
PRIMARY KEY(order_id),
KEY(user_id,order_date)
KEY(order_date),
KEY(last_modify_date)
)
当然了无论是自增主键设计、UUID主键设计、业务自定义生成主键、还是消除冗余本质上都是遵循了范式准则。但是在一些其他业务场景下也存在反范式设计的情况。
反范式设计
通常我们会在 OLAP 数据分析场景中使用反范式设计,但随着 JSON 数据类型的普及MySQL 在线业务也可以进行反范式的设计。
在 04 讲中我讲了表 UserTag就是通过 JSON 数据类型进行了反范式的设计,如果通过范式设计,则表 UserTag 应该设计为:
CREATE TABLE UserTag (
userId BIGINT NOT NULL,
userTag INT NOT NULL,
PRIMARY KEY(userId,userTag)
);
SELECT * FROM UserTag;
+--------+---------+
| userId | userTag |
+--------+---------+
| 1 | 2 |
| 1 | 6 |
| 1 | 8 |
| 1 | 10 |
| 2 | 3 |
| 2 | 10 |
| 2 | 12 |
+--------+---------+
你对比后可以发现,范式设计并没有使用 JSON 数据类型来得更为有效,使用 JSON 数据类型userID 只需保存一次,从一定程度上减少了数据的冗余:
+--------+---------------+
| userId | userTags |
+--------+---------------+
| 1 | [2, 6, 8, 10] |
| 2 | [3, 10, 12] |
+--------+---------------
总结
总的来说,范式是偏数据库理论范畴的表结构设计准则,在实际的工程实践上没有必要严格遵循三范式要求,在 MySQL 海量并发的工程实践上,表结构设计应遵循这样几个规范:
每张表一定要有一个主键;
自增主键只推荐用在非核心业务表,甚至应避免使用;
核心业务表推荐使用 UUID 或业务自定义主键;
一份数据应尽可能保留一份,通过主键关联进行查询,避免冗余数据;
在一些场景下,可以通过 JSON 数据类型进行反范式设计,提升存储效率;