《姜承尧的MySQL实战宝典》学习笔记
《姜承尧的MySQL实战宝典》学习笔记
- 1 表结构设计
- 1.1 数字类型
- 1.1.1 整形类型
- 1.1.2 浮点类型和高精度型
- 1.1.3 实战——整型类型与自增设计
- 1.1.4 实战——资金字段设计
- 1.1.5 总结
- 1.2 字符串类型
- 1.2.1 CHAR 和 VARCHAR 的定义
- 1.2.2 字符集
- 1.2.3 排序规则
- 1.2.4 正确修改字符集
- 1.2.5 实战——用户性别设计
- 1.2.6 实战——账户密码存储设计
- 1.2.7 总结
- 1.3 日期类型
- 1.3.1 日期类型
- 1.3.2 DATETIME
- 1.3.3 TIMESTAMP
- 1.3.4 实战——DATETIME vs TIMESTAMP vs INT,怎么选?
- 1.3.5 实战——不要忽视 TIMESTAMP 的性能问题
- 1.3.6 实战——表结构设计规范:每条记录都要有一个时间字段
- 1.3.7 总结
- 1.4 非结构存储——JSON
- 1.4.1 JSON 数据类型
- 1.4.2 实战——用户登录设计
- 1.4.3 实战——用户画像设计
- 1.4.4 总结
- 1.5 表结构设计
- 1.5.1 忘记范式准则
- 1.5.2 自增主键设计
- 1.5.3 UUID主键设计
- 1.5.4 业务自定义生成主键
- 1.5.5 消除冗余
- 1.5.6 反范式设计
- 1.5.7总结
- 1.6 表压缩
- 1.6.1 表压缩
- 1.6.2 MySQL 压缩表设计——COMPRESS 页压缩
- 1.6.3 MySQL 压缩表设计——TPC 压缩
- 1.6.4 实战-表压缩在业务上的使用
- 1.6.5 总结
- 1.7 表的访问设计
- 2 索引
- 2.1 索引介绍
- 2.1.1 索引是什么
- 2.1.2 B+树索引结构
- 2.1.3 优化 B+ 树索引的插入性能
- 2.1.4 MySQL 中 B+ 树索引的设计与管理
- 2.1.5 总结
- 2.2 索引组织表
- 2.2.1 索引组织表
- 参考
1 表结构设计
1.1 数字类型
1.1.1 整形类型
MySQL数据库支持的整型类型及其占用空间、取值范围等如下图所示
注意:数据库设计过程中不用刻意用unsigned
类型,因为有时候做数据分析时可能需要应用到非等值连接,如下列SQL语句:
SELECTs1.sale_date, s2.sale_count - s1.sale_count AS diff
FROMsale s1LEFT JOINsale s2 ON DATE_ADD(s2.sale_date, INTERVAL 1 MONTH) = s1.sale_date
ORDER BY sale_date;
在执行的过程中,如果列 sale_count 用到了 unsigned 属性,会抛出这样的结果:
ERROR 1690 (22003): BIGINT UNSIGNED value is out of range in '(`test`.`s2`.`sale_count` - `test`.`s1`.`sale_count`)'
因为MySQL要求 unsigned
数值相减之后依然为 unsigned
,否则就会报错 。
为了避免这个错误,需要对数据库参数 sql_mode 设置为 NO_UNSIGNED_SUBTRACTION,允许相减的结果为 signed :
mysql> SET sql_mode='NO_UNSIGNED_SUBTRACTION';
Query OK, 0 rows affected (0.00 sec)
SELECT
1.1.2 浮点类型和高精度型
MySQL 之前的版本中存在浮点类型 Float 和 Double,但这些类型因为不是高精度,也不是 SQL 标准的类型,所以在真实的生产环境中不推荐使用,否则在计算时,由于精度类型问题,会导致最终的计算结果出错。
在需要高精度的场合,可以使用DECIMAL类型,当声明该类型列时,可以(并且通常必须要)指定精度和标度,例如:
salary DECIMAL(8,2)
其中,8 是精度(精度表示保存值的主要位数),2 是标度(标度表示小数点后面保存的位数)。通常在表结构设计中,类型 DECIMAL 可以用来表示用户的工资、账户的余额等精确到小数点后 2 位的业务。
然而,在海量并发的互联网业务中使用,金额字段的设计并不推荐使用 DECIMAL 类型,而更推荐使用 INT 整型类型(下文就会分析原因)。
1.1.3 实战——整型类型与自增设计
在表结构设计时用自增做主键,希望你特别要注意以下两点,若不注意,可能会对业务造成灾难性的打击:
- 用 BIGINT 做主键,而不是 INT;
- 自增值并不持久化,可能会有回溯现象(MySQL 8.0 版本前)。
INT 的范围最大在 42 亿的级别,在真实的互联网业务场景的应用中,很容易达到最大值。例如一些流水表、日志表,每天 1000W 数据量,420 天后,INT 类型的上限即可达到。
因此,(敲黑板 1)用自增整型做主键,一律使用 BIGINT,而不是 INT。不要为了节省 4 个字节使用 INT,当达到上限时,再进行表结构的变更,将是巨大的负担与痛苦。
如果达到了 INT 类型的上限,数据库的表现又将如何呢?是会重新变为 1?我们可以通过下面的 SQL 语句验证一下:
mysql> CREATE TABLE t (-> a INT AUTO_INCREMENT PRIMARY KEY-> );mysql> INSERT INTO t VALUES (2147483647);
Query OK, 1 row affected (0.01 sec)mysql> INSERT INTO t VALUES (NULL);
ERROR 1062 (23000): Duplicate entry '2147483647' for key 't.PRIMARY'
可以看到,当达到 INT 上限后,再次进行自增插入时,会报重复错误,MySQL 数据库并不会自动将其重置为 1。
第二个特别要注意的问题是,(敲黑板 2)MySQL 8.0 版本前,自增不持久化,自增值可能会存在回溯问题!
mysql> SELECT * FROM t;
+---+
| a |
+---+
| 1 |
| 2 |
| 3 |
+---+
3 rows in set (0.01 sec)mysql> DELETE FROM t WHERE a = 3;
Query OK, 1 row affected (0.02 sec)mysql> SHOW CREATE TABLE t\G
*************************** 1. row ***************************Table: t
Create Table: CREATE TABLE `t` (`a` int NOT NULL AUTO_INCREMENT,PRIMARY KEY (`a`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
1 row in set (0.00 sec)
在删除自增为 3 的这条记录后,下一个自增值依然为 4(AUTO_INCREMENT=4),这里并没有错误,自增并不会进行回溯。但若这时数据库发生重启,那数据库启动后,表 t 的自增起始值将再次变为 3,即自增值发生回溯。具体如下所示:
mysql> SHOW CREATE TABLE t\G
*************************** 1. row ***************************Table: t
Create Table: CREATE TABLE `t` (`a` int NOT NULL AUTO_INCREMENT,PRIMARY KEY (`a`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
1 row in set (0.00 s)
若要彻底解决这个问题,有以下 2 种方法:
-
升级 MySQL 版本到 8.0 版本,每张表的自增值会持久化;
-
若无法升级数据库版本,则强烈不推荐在核心业务表中使用自增数据类型做主键。
其实,在海量互联网架构设计过程中,为了之后更好的分布式架构扩展性,不建议使用整型类型做主键,更为推荐的是字符串类型(这部分内容将在 05 节中详细介绍)。
1.1.4 实战——资金字段设计
(敲黑板3)在海量互联网业务的设计标准中,并不推荐用 DECIMAL 类型,而是更推荐将 DECIMAL 转化为 整型类型 BIG INT。也就是说,资金类型更推荐使用用分单位存储,而不是用元单位存储。如1元在数据库中用整型类型 100 存储。
不使用 DECIMAL 有以下原因:
- DECIMAL 类型是个变长字段,定义金额时,不好确定位数,且存储性能较差;
- DECIMAL 类型是通过二进制实现的一种编码方式,计算效率远不如整型。
注意,在数据库设计中,我们非常强调定长存储,因为定长存储的性能更好。
那么,当使用 BIG INT 存储金额字段的时候,如何表示小数点中的数据呢?其实,这部分完全可以交由前端进行处理并展示。作为数据库本身,只要按分进行存储即可。
1.1.5 总结
总结一下 本节课的重点:
-
不推荐使用整型类型的属性 Unsigned,若非要使用,参数 sql_mode 务必额外添加上选项 NO_UNSIGNED_SUBTRACTION;
-
自增整型类型做主键,务必使用类型 BIGINT,而非 INT,后期表结构调整代价巨大;
-
MySQL 8.0 版本前,自增整型会有回溯问题(未持久化),做业务开发的你一定要了解这个问题;
-
当达到自增整型类型的上限值时,再次自增插入,MySQL 数据库会报重复错误;
-
不要再使用浮点类型 Float、Double,MySQL 后续版本将不再支持上述两种类型(使用 DECIMAL);
-
账户余额字段,设计是用整型类型(BIG INT),而不是 DECIMAL 类型,这样性能更好,存储更紧凑。
1.2 字符串类型
MySQL 数据库的字符串类型有 CHAR、VARCHAR、BINARY、BLOB、TEXT、ENUM、SET。不同的类型在业务设计、数据库性能方面的表现完全不同,其中最常使用的是 CHAR、VARCHAR。今天我就带你深入了解字符串类型 CHAR、VARCHAR 的应用,希望学完这一讲,你能真正用好 MySQL 的字符串类型,从而设计出一个更为优美的业务表结构。
1.2.1 CHAR 和 VARCHAR 的定义
CHAR(N) 用来保存固定长度的字符,N 的范围是 0 ~ 255,请牢记,N 表示的是字符,而不是字节。VARCHAR(N) 用来保存变长字符,N 的范围为 0 ~ 65536, N 表示字符。
在超出 65536 个字符的情况下,可以考虑使用更大的字符类型 TEXT 或 BLOB,两者最大存储长度为 4G,其区别是 BLOB 没有字符集属性,纯属二进制存储。
和 Oracle、Microsoft SQL Server 等传统关系型数据库不同的是,MySQL 数据库的 VARCHAR 字符类型,最大能够存储 65536 个字符,所以在 MySQL 数据库下,绝大部分场景使用类型 VARCHAR 就足够了。
注意:定义字段时CHAR(N) 、VARCHAR(N) 中的N表示字符。
1.2.2 字符集
- 推荐把 MySQL 的默认字符集设置为 UTF8MB4(可以用于存储 emoji 等扩展字符 )
- 从底层存储内核看,在多字节字符集下(GBK中1字符=2字节,UTF8MB4中1字符=4字节),CHAR 和 VARCHAR 底层的实现完全相同,都是变长存储!
- 鉴于目前默认字符集推荐设置为 UTF8MB4,所以在表结构设计时,可以把 CHAR 全部用 VARCHAR 替换,底层存储的本质实现一模一样。
1.2.3 排序规则
排序规则(Collation) 是比较和排序字符串的一种规则,每个字符集都会有默认的排序规则,你可以用以下命令来查看:
mysql> SHOW CHARSET LIKE 'utf8%';
+---------+---------------+--------------------+--------+
| Charset | Description | Default collation | Maxlen |
+---------+---------------+--------------------+--------+
| utf8 | UTF-8 Unicode | utf8_general_ci | 3 |
| utf8mb4 | UTF-8 Unicode | utf8mb4_0900_ai_ci | 4 |
+---------+---------------+--------------------+--------+
2 rows in set (0.01 sec)mysql> SHOW COLLATION LIKE 'utf8mb4%';
+----------------------------+---------+-----+---------+----------+---------+---------------+
| Collation | Charset | Id | Default | Compiled | Sortlen | Pad_attribute |
+----------------------------+---------+-----+---------+----------+---------+---------------+
| utf8mb4_0900_ai_ci | utf8mb4 | 255 | Yes | Yes | 0 | NO PAD |
| utf8mb4_0900_as_ci | utf8mb4 | 305 | | Yes | 0 | NO PAD |
| utf8mb4_0900_as_cs | utf8mb4 | 278 | | Yes | 0 | NO PAD |
| utf8mb4_0900_bin | utf8mb4 | 309 | | Yes | 1 | NO PAD |
| utf8mb4_bin | utf8mb4 | 46 | | Yes | 1 | PAD SPACE |
......
排序规则以 _ci 结尾,表示不区分大小写(Case Insentive),_cs 表示大小写敏感,_bin 表示通过存储字符的二进制进行比较。需要注意的是,比较 MySQL 字符串,默认采用不区分大小的排序规则。
牢记,绝大部分业务的表结构设计无须设置排序规则为大小写敏感!除非你能明白你的业务真正需要。
1.2.4 正确修改字符集
正确修改列字符集的命令应该使用 ALTER TABLE … CONVERT TO…这样才能将之前的列 a 字符集从 UTF8 修改为 UTF8MB4:
mysql> ALTER TABLE emoji_test CONVERT TO CHARSET utf8mb4;
Query OK, 0 rows affected (0.94 sec)
Records: 0 Duplicates: 0 Warnings: 0mysql> SHOW CREATE TABLE emoji_test\G
*************************** 1. row ***************************Table: emoji_test
Create Table: CREATE TABLE `emoji_test` (`a` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,PRIMARY KEY (`a`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
1.2.5 实战——用户性别设计
- 不建议使用
tinyint
表示用户性别,原因如下:
- 表达不清:在具体存储时,0 表示女,还是 1 表示女呢?每个业务可能有不同的规则;
- 脏数据:因为是 tinyint,因此除了 0 和 1,用户完全可以插入 2、3、4 这样的数值,最终表中存在无效数据的可能,后期再进行清理,代价就非常大了。
- 在 MySQL 8.0 版本之前,可以使用 ENUM 字符串枚举类型,只允许有限的定义值插入。但如果将参数 SQL_MODE 设置为严格模式,插入非定义数据就会报错,且报错提醒不友好:
mysql> SHOW CREATE TABLE User\G
*************************** 1. row ***************************Table: User
Create Table: CREATE TABLE `User` (`id` bigint NOT NULL AUTO_INCREMENT,`sex` enum('M','F') COLLATE utf8mb4_general_ci DEFAULT NULL,PRIMARY KEY (`id`)
) ENGINE=InnoDB
1 row in set (0.00 sec)mysql> SET sql_mode = 'STRICT_TRANS_TABLES';
Query OK, 0 rows affected, 1 warning (0.00 sec)mysql> INSERT INTO User VALUES (NULL,'F');
Query OK, 1 row affected (0.08 sec)mysql> INSERT INTO User VALUES (NULL,'A');
ERROR 1265 (01000): Data truncated for column 'sex' at row 1
- MySQL 8.0.16 版本开始,数据库原生提供 CHECK 约束功能,可以方便地进行有限状态列类型的设计:
mysql> SHOW CREATE TABLE User\G
*************************** 1. row ***************************Table: User
Create Table: CREATE TABLE `User` (`id` bigint NOT NULL AUTO_INCREMENT,`sex` char(1) COLLATE utf8mb4_general_ci DEFAULT NULL,PRIMARY KEY (`id`),CONSTRAINT `user_chk_1` CHECK (((`sex` = _utf8mb4'M') or (`sex` = _utf8mb4'F')))
) ENGINE=InnoDB
1 row in set (0.00 sec)mysql> INSERT INTO User VALUES (NULL,'M');
Query OK, 1 row affected (0.07 sec)mysql> INSERT INTO User VALUES (NULL,'Z');
ERROR 3819 (HY000): Check constraint 'user_chk_1' is violated.
1.2.6 实战——账户密码存储设计
一个真正好的密码存储设计,应该是:动态盐 + 非固定加密算法
1.2.7 总结
字符串是使用最为广泛的数据类型之一,但也是设计最初容易犯错的部分,后期业务跑起来再进行修改,代价将会非常巨大。希望你能反复细读本讲的内容,从而在表结构设计伊始,业务就做好最为充分的准备。我总结下本节的重点内容:
-
CHAR 和 VARCHAR 虽然分别用于存储定长和变长字符,但对于变长字符集(如 GBK、UTF8MB4),其本质是一样的,都是变长,设计时完全可以用 VARCHAR 替代 CHAR;
-
推荐 MySQL 字符集默认设置为 UTF8MB4,可以用于存储 emoji 等扩展字符;
-
排序规则很重要,用于字符的比较和排序,但大部分场景不需要用区分大小写的排序规则;
-
修改表中已有列的字符集,使用命令 ALTER TABLE … CONVERT TO …;
-
用户性别,运行状态等有限值的列,MySQL 8.0.16 版本直接使用 CHECK 约束机制,之前的版本可使用 ENUM 枚举字符串类型,外加 SQL_MODE 的严格模式;
-
业务隐私信息,如密码、手机、信用卡等信息,需要加密。切记简单的MD5算法是可以进行暴力破解,并不安全,推荐使用动态盐+动态加密算法进行隐私数据的存储。
1.3 日期类型
几乎每张业务表都带有一个日期列,用于记录每条记录产生和变更的时间。比如用户表会有一个日期列记录用户注册的时间、用户最后登录的时间。又比如,电商行业中的订单表(核心业务表)会有一个订单产生的时间列,当支付时间超过订单产生的时间,这个订单可能会被系统自动取消。
日期类型虽然常见,但在表结构设计中也容易犯错,比如很多开发同学都倾向使用整型存储日期类型,同时也会忽略不同日期类型对于性能可能存在的潜在影响。所以你有必要认真学习这一讲,举一反三,在自己的业务中做好日期类型的设计。
1.3.1 日期类型
MySQL 数据库中常见的日期类型有 YEAR、DATE、TIME、DATETIME、TIMESTAMEP。因为业务绝大部分场景都需要将日期精确到秒,所以在表结构设计中,常见使用的日期类型为DATETIME 和 TIMESTAMP。接下来,我就带你深入了解这两种类型,以及它们在设计中的应用实战。
1.3.2 DATETIME
类型 DATETIME 最终展现的形式为:YYYY-MM-DD HH:MM:SS,固定占用 8 个字节。
从 MySQL 5.6 版本开始,DATETIME 类型支持毫秒,DATETIME(N) 中的 N 表示毫秒的精度。例如,DATETIME(6) 表示可以存储 6 位的毫秒值。同时,一些日期函数也支持精确到毫秒,例如常见的函数 NOW、SYSDATE:
mysql> SELECT NOW(6);
+----------------------------+
| NOW(6) |
+----------------------------+
| 2020-09-14 17:50:28.707971 |
+----------------------------+
1 row in set (0.00 sec)
用户可以将 DATETIME 初始化值设置为当前时间,并设置自动更新当前时间的属性。例如之前已设计的用户表 User,我在其基础上,修改了register_date、last_modify_date的定义:
CREATE TABLE User (id BIGINT NOT NULL AUTO_INCREMENT,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),CHECK (sex = 'M' OR sex = 'F'),PRIMARY KEY(id)
);
在上面的表 User 中,列 register_date 表示注册时间,DEFAULT CURRENT_TIMESTAMP 表示记录插入时,若没有指定时间,默认就是当前时间。
列 last_modify_date 表示当前记录最后的修改时间,DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) 表示每次修改都会修改为当前时间。
这样的设计保证当用户的金钱(money 字段)发生了变更,则 last_modify_date 能记录最后一次用户金钱发生变更时的时间。来看下面的例子:
mysql> SELECT name,money,last_modify_date FROM User WHERE name = 'David';
+-------+-------+----------------------------+
| name | money | last_modify_date |
+-------+-------+----------------------------+
| David | 100 | 2020-09-13 08:08:33.898593 |
+-------+-------+----------------------------+1 row in set (0.00 sec)mysql> UPDATE User SET money = money - 1 WHERE name = 'David';
Query OK, 1 row affected (0.06 sec)
Rows matched: 1 Changed: 1 Warnings: 0mysql> SELECT name,money,last_modify_date FROM User WHERE name = 'David';
+-------+-------+----------------------------+
| name | money | last_modify_date |
+-------+-------+----------------------------+
| David | 99 | 2020-09-14 18:29:17.056327 |
+-------+-------+----------------------------+
1 row in set (0.00 sec)
可以看到,当用户金额发生修改时,所对应的字段 last_modify_date 也修改成发生变更的时间。
1.3.3 TIMESTAMP
除了 DATETIME,日期类型中还有一种 TIMESTAMP 的时间戳类型,其实际存储的内容为‘1970-01-01 00:00:00’到现在的毫秒数。在 MySQL 中,由于类型 TIMESTAMP 占用 4 个字节,因此其存储的时间上限只能到‘2038-01-19 03:14:07’。
同类型 DATETIME 一样,从 MySQL 5.6 版本开始,类型 TIMESTAMP 也能支持毫秒。与 DATETIME 不同的是,若带有毫秒时,类型 TIMESTAMP 占用 7 个字节,而 DATETIME 无论是否存储毫秒信息,都占用 8 个字节。
类型 TIMESTAMP 最大的优点是可以带有时区属性,因为它本质上是从毫秒转化而来。如果你的业务需要对应不同的国家时区,那么类型 TIMESTAMP 是一种不错的选择。比如新闻类的业务,通常用户想知道这篇新闻发布时对应的自己国家时间,那么 TIMESTAMP 是一种选择。
另外,有些国家会执行夏令时。根据不同的季节,人为地调快或调慢 1 个小时,带有时区属性的 TIMESTAMP 类型本身就能解决这个问题。
参数 time_zone 指定了当前使用的时区,默认为 SYSTEM 使用操作系统时区,用户可以通过该参数指定所需要的时区。
如果想使用 TIMESTAMP 的时区功能,你可以通过下面的语句将之前的用户表 User 的注册时间字段类型从 DATETIME(6) 修改为 TIMESTAMP(6):
ALTER TABLE User CHANGE register_date register_date TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6);
这时通过设定不同的 time_zone,可以观察到不同时区下的注册时间:
mysql> SELECT name,regist er_date FROM User WHERE name = 'David';+-------+----------------------------+
| name | register_date |
+-------+----------------------------+
| David | 2018-09-14 18:28:33.898593 |
+-------+----------------------------+
1 row in set (0.00 sec)mysql> SET time_zone = '-08:00';
Query OK, 0 rows affected (0.00 sec)mysql> SELECT name,register_date FROM User WHERE name = 'David';
+-------+----------------------------+
| name | register_date |
+-------+----------------------------+
| David | 2018-09-14 02:28:33.898593 |
+-------+----------------------------+
1 row in set (0.00 sec)
从上述例子中,你可以看到,中国的时区是 +08:00,美国的时区是 -08:00,因此改为美国时区后,可以看到用户注册时间比之前延迟了 16 个小时。当然了,直接加减时区并不直观,需要非常熟悉各国的时区表。在 MySQL 中可以直接设置时区的名字,如:
mysql> SET time_zone = 'America/Los_Angeles';Query OK, 0 rows affected (0.00 sec)mysql> SELECT NOW();+---------------------+| NOW() |+---------------------+| 2020-09-14 20:12:49 |+---------------------+1 row in set (0.00 sec)mysql> SET time_zone = 'Asia/Shanghai';Query OK, 0 rows affected (0.00 sec)mysql> SELECT NOW();+---------------------+| NOW() |+---------------------+| 2020-09-15 11:12:55 |+---------------------+1 row in set (0.00 sec)
1.3.4 实战——DATETIME vs TIMESTAMP vs INT,怎么选?
在做表结构设计时,对日期字段的存储,开发人员通常会有 3 种选择:DATETIME、TIMESTAMP、INT。
INT 类型就是直接存储 ‘1970-01-01 00:00:00’ 到现在的毫秒数,本质和 TIMESTAMP 一样,因此用 INT 不如直接使用 TIMESTAMP。
当然,有些同学会认为 INT 比 TIMESTAMP 性能更好。但是,由于当前每个 CPU 每秒可执行上亿次的计算,所以无须为这种转换的性能担心。更重要的是,在后期运维和数据分析时,使用 INT 存储日期,是会让 DBA 和数据分析人员发疯的,INT的可运维性太差。
也有的同学会热衷用类型 TIMESTEMP 存储日期,因为类型 TIMESTAMP 占用 4 个字节,比 DATETIME 小一半的存储空间。
但若要将时间精确到毫秒,TIMESTAMP 要 7 个字节,和 DATETIME 8 字节差不太多。另一方面,现在距离 TIMESTAMP 的最大值‘2038-01-19 03:14:07’已经很近,这是需要开发同学好好思考的问题。
总的来说,建议使用类型 DATETIME。 对于时区问题,可以由前端或者服务这里做一次转化,不一定非要在数据库中解决。
1.3.5 实战——不要忽视 TIMESTAMP 的性能问题
前面已经提及,TIMESTAMP 的上限值 2038 年很快就会到来,那时业务又将面临一次类似千年虫的问题。另外,TIMESTAMP 还存在潜在的性能问题。
虽然从毫秒数转换到类型 TIMESTAMP 本身需要的 CPU 指令并不多,这并不会带来直接的性能问题。但是如果使用默认的操作系统时区,则每次通过时区计算时间时,要调用操作系统底层系统函数 __tz_convert(),而这个函数需要额外的加锁操作,以确保这时操作系统时区没有修改。所以,当大规模并发访问时,由于热点资源竞争,会产生两个问题:
-
性能不如 DATETIME: DATETIME 不存在时区转化问题。
-
性能抖动: 海量并发时,存在性能抖动问题。
为了优化 TIMESTAMP 的使用,强烈建议你使用显式的时区,而不是操作系统时区。比如在配置文件中显示地设置时区,而不要使用系统时区:
[mysqld]time_zone = "+08:00"
最后,通过命令 mysqlslap 来测试 TIMESTAMP、DATETIME 的性能,命令如下:
# 比较time_zone为System和Asia/Shanghai的性能对比
SET global time_zone='Asia/Shanghai';
Query OK, 0 rows affected (0.00 sec)mysqlslap -uroot -p --create-schema='learn' --number-of-queries=1000000 --concurrency=100 --query='SELECT NOW()';
BenchmarkAverage number of seconds to run all queries: 4.285 secondsMinimum number of seconds to run all queries: 4.285 secondsMaximum number of seconds to run all queries: 4.285 secondsNumber of clients running queries: 100Average number of queries per client: 10000SET global time_zone='System';
Query OK, 0 rows affected (0.00 sec)mysqlslap -uroot -p --create-schema='learn' --number-of-queries=1000000 --concurrency=100 --query='SELECT NOW()';
Enter password:
BenchmarkAverage number of seconds to run all queries: 4.463 secondsMinimum number of seconds to run all queries: 4.463 secondsMaximum number of seconds to run all queries: 4.463 secondsNumber of clients running queries: 100Average number of queries per client: 10000
可以发现,显式指定时区的性能要远远好于直接使用操作系统时区。所以,日期字段推荐使用 DATETIME,没有时区转化。即便使用 TIMESTAMP,也需要在数据库中显式地配置时区,而不是用系统时区。
1.3.6 实战——表结构设计规范:每条记录都要有一个时间字段
在做表结构设计规范时,强烈建议你每张业务核心表都增加一个 DATETIME 类型的 last_modify_date 字段,并设置修改自动更新机制, 即便标识每条记录最后修改的时间。
例如,在前面的表 User 中的字段 last_modify_date,就是用于表示最后一次的修改时间:
CREATE TABLE User (id BIGINT NOT NULL AUTO_INCREMENT,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),CHECK (sex = 'M' OR sex = 'F'),PRIMARY KEY(id)
);
通过字段 last_modify_date 定义的 ON UPDATE CURRENT_TIMESTAMP(6),那么每次这条记录,则都会自动更新 last_modify_date 为当前时间。
这样设计的好处是: 用户可以知道每个用户最近一次记录更新的时间,以便做后续的处理。比如在电商的订单表中,可以方便对支付超时的订单做处理;在金融业务中,可以根据用户资金最后的修改时间做相应的资金核对等。
在后面的内容中,我们也会谈到 MySQL 数据库的主从逻辑数据核对的设计实现,也会利用到last_modify_date 字段。
1.3.7 总结
日期类型通常就是使用 DATETIME 和 TIMESTAMP 两种类型,然而由于类型 TIMESTAMP 存在性能问题,建议你还是尽可能使用类型 DATETIME。总结一下今天的重点内容:
-
MySQL 5.6 版本开始 DATETIME 和 TIMESTAMP 精度支持到毫秒;
-
DATETIME 占用 8 个字节,TIMESTAMP 占用 4 个字节,DATETIME(6) 依然占用 8 个字节,TIMESTAMP(6) 占用 7 个字节;
-
TIMESTAMP 日期存储的上限为 2038-01-19 03:14:07,业务用 TIMESTAMP 存在风险;
-
使用 TIMESTAMP 必须显式地设置时区,不要使用默认系统时区,否则存在性能问题,推荐在配置文件中设置参数 time_zone = ‘+08:00’;
-
推荐日期类型使用 DATETIME,而不是 TIMESTAMP 和 INT 类型;
-
表结构设计时,每个核心业务表,推荐设计一个 last_modify_date 的字段,用以记录每条记录的最后修改时间。
1.4 非结构存储——JSON
关系型的结构化存储存在一定的弊端,因为它需要预先定义好所有的列以及列对应的类型。但是业务在发展过程中,或许需要扩展单个列的描述功能,这时,如果能用好 JSON 数据类型,那就能打通关系型和非关系型数据的存储之间的界限,为业务提供更好的架构选择。
当然,很多同学在用 JSON 数据类型时会遇到各种各样的问题,其中最容易犯的误区就是将类型 JSON 简单理解成字符串类型。 但当你学完今天的内容之后,会真正认识到 JSON 数据类型的威力,从而在实际工作中更好地存储非结构化的数据。
1.4.1 JSON 数据类型
B站相关视频
JSON(JavaScript Object Notation)主要用于互联网应用服务之间的数据交换。MySQL 支持RFC 7159定义的 JSON 规范,主要有JSON 对象和JSON 数组两种类型。下面就是 JSON 对象,主要用来存储图片的相关信息:
{"Image": {"Width": 800,"Height": 600,"Title": "View from 15th Floor","Thumbnail": {"Url": "","Height": 125,"Width": 100},"IDs": [116, 943, 234, 38793]}
}
从中你可以看到, JSON 类型可以很好地描述数据的相关内容,比如这张图片的宽度、高度、标题等(这里使用到的类型有整型、字符串类型)。
JSON对象除了支持字符串、整型、日期类型,JSON 内嵌的字段也支持数组类型,如上代码中的 IDs 字段。
另一种 JSON 数据类型是数组类型,如:
[{"precision": "zip","Latitude": 37.7668,"Longitude": -122.3959,"Address": "","City": "SAN FRANCISCO","State": "CA","Zip": "94107","Country": "US"},{"precision": "zip","Latitude": 37.371991,"Longitude": -122.026020,"Address": "","City": "SUNNYVALE","State": "CA","Zip": "94085","Country": "US"}]
上面的示例演示的是一个 JSON 数组,其中有 2 个 JSON 对象。
到目前为止,可能很多同学会把 JSON 当作一个很大的字段串类型,从表面上来看,没有错。但本质上,JSON 是一种新的类型,有自己的存储格式,还能在每个对应的字段上创建索引,做特定的优化,这是传统字段串无法实现的。JSON 类型的另一个好处是无须预定义字段,字段可以无限扩展。而传统关系型数据库的列都需预先定义,想要扩展需要执行 ALTER TABLE … ADD COLUMN … 这样比较重的操作。
需要注意是,JSON 类型是从 MySQL 5.7 版本开始支持的功能,而 8.0 版本解决了更新 JSON 的日志性能瓶颈。如果要在生产环境中使用 JSON 数据类型,强烈推荐使用 MySQL 8.0 版本。
1.4.2 实战——用户登录设计
在数据库中,JSON 类型比较适合存储一些修改较少、相对静态的数据,比如用户登录信息的存储如下:
DROP TABLE IF EXISTS UserLogin;CREATE TABLE UserLogin (userId BIGINT NOT NULL,loginInfo JSON,PRIMARY KEY(userId)
);
由于当前业务的登录方式越来越多样化,如同一账户支持手机、微信、QQ 账号登录,所以这里可以用 JSON 类型存储登录的信息。
接着,插入下面的数据:
SET @a = '
{"cellphone" : "13918888888","wxchat" : "破产码农","QQ" : "82946772"
}
';INSERT INTO UserLogin VALUES (1,@a);SET @b = '
{"cellphone" : "15026888888"
}
';INSERT INTO UserLogin VALUES (2,@b);
从上面的例子中可以看到,用户 1 登录有三种方式:手机验证码登录、微信登录、QQ 登录,而用户 2 只有手机验证码登录。
而如果不采用 JSON 数据类型,就要用下面的方式建表:
CREATE TABLE UserLogin (userId BIGINT NOT NULL,cellphone VARCHAR(255),wechat VARCHAR(255)QQ VARCHAR(255),PRIMARY KEY(userId)
);
可以看到,虽然用传统关系型的方式也可以完成相关数据的存储,但是存在两个问题:
-
有些列可能是比较稀疏的,一些列可能大部分都是 NULL 值;
-
如果要新增一种登录类型,如微博登录,则需要添加新列,而 JSON 类型无此烦恼。
因为支持了新的JSON类型,MySQL 配套提供了丰富的 JSON 字段处理函数,用于方便地操作 JSON 数据,具体可以见 MySQL 官方文档。
其中,最常见的就是函数 JSON_EXTRACT,它用来从 JSON 数据中提取所需要的字段内容,如下面的这条 SQL 语句就查询用户的手机和微信信息。
SELECTuserId,JSON_UNQUOTE(JSON_EXTRACT(loginInfo,"$.cellphone")) cellphone,JSON_UNQUOTE(JSON_EXTRACT(loginInfo,"$.wxchat")) wxchat
FROM UserLogin;
+--------+-------------+--------------+
| userId | cellphone | wxchat |
+--------+-------------+--------------+
| 1 | 13918888888 | 破产码农 |
| 2 | 15026888888 | NULL |
+--------+-------------+--------------+
2 rows in set (0.01 sec)
当然了,每次写 JSON_EXTRACT、JSON_UNQUOTE 非常麻烦,MySQL 还提供了 ->> 表达式,和上述 SQL 效果完全一样:
SELECTuserId,loginInfo->>"$.cellphone" cellphone,loginInfo->>"$.wxchat" wxchat
FROM UserLogin;
当 JSON 数据量非常大,用户希望对 JSON 数据进行有效检索时,可以利用 MySQL 的函数索引功能对 JSON 中的某个字段进行索引。
比如在上面的用户登录示例中,假设用户必须绑定唯一手机号,且希望未来能用手机号码进行用户检索时,可以创建下面的索引:
ALTER TABLE UserLogin ADD COLUMN cellphone VARCHAR(255) AS (loginInfo->>"$.cellphone");ALTER TABLE UserLogin ADD UNIQUE INDEX idx_cellphone(cellphone);
上述 SQL 首先创建了一个虚拟列 cellphone,这个列是由函数 loginInfo->>"$.cellphone" 计算得到的。然后在这个虚拟列上创建一个唯一索引 idx_cellphone。这时再通过虚拟列 cellphone 进行查询,就可以看到优化器会使用到新创建的 idx_cellphone 索引:
EXPLAIN SELECT * FROM UserLogin
WHERE cellphone = '13918888888'\G
*************************** 1. row ***************************id: 1select_type: SIMPLEtable: UserLoginpartitions: NULLtype: const
possible_keys: idx_cellphonekey: idx_cellphonekey_len: 1023ref: constrows: 1filtered: 100.00Extra: NULL
1 row in set, 1 warning (0.00 sec)
当然,我们可以在一开始创建表的时候,就完成虚拟列及函数索引的创建。如下表创建的列 cellphone 对应的就是 JSON 中的内容,是个虚拟列;uk_idx_cellphone 就是在虚拟列 cellphone 上所创建的索引。
CREATE TABLE UserLogin (userId BIGINT,loginInfo JSON,cellphone VARCHAR(255) AS (loginInfo->>"$.cellphone"),PRIMARY KEY(userId),UNIQUE KEY uk_idx_cellphone(cellphone)
);
1.4.3 实战——用户画像设计
某些业务需要做用户画像(也就是对用户打标签),然后根据用户的标签,通过数据挖掘技术,进行相应的产品推荐。比如:
-
在电商行业中,根据用户的穿搭喜好,推荐相应的商品;
-
在音乐行业中,根据用户喜欢的音乐风格和常听的歌手,推荐相应的歌曲;
-
在金融行业,根据用户的风险喜好和投资经验,推荐相应的理财产品。
在这,我强烈推荐你用 JSON 类型在数据库中存储用户画像信息,并结合 JSON 数组类型和多值索引的特点进行高效查询。假设有张画像定义表:
CREATE TABLE Tags (tagId BIGINT AUTO_INCREMENT,tagName VARCHAR(255) NOT NULL,PRIMARY KEY(tagId)
);INSERT INTO Tags VALUES(1,'70后');
INSERT INTO Tags VALUES(2,'80后');
INSERT INTO Tags VALUES(3,'90后');
INSERT INTO Tags VALUES(4,'00后');
INSERT INTO Tags VALUES(5,'爱运动');
INSERT INTO Tags VALUES(6,'高学历');
INSERT INTO Tags VALUES(7,'小资');
INSERT INTO Tags VALUES(8,'有房');
INSERT INTO Tags VALUES(9,'有车');
INSERT INTO Tags VALUES(10,'常看电影');
INSERT INTO Tags VALUES(11,'爱网购');
INSERT INTO Tags VALUES(12,'爱外卖');SELECT * FROM Tags;
+-------+--------------+
| tagId | tagName |
+-------+--------------+
| 1 | 70后 |
| 2 | 80后 |
| 3 | 90后 |
| 4 | 00后 |
| 5 | 爱运动 |
| 6 | 高学历 |
| 7 | 小资 |
| 8 | 有房 |
| 9 | 有车 |
| 10 | 常看电影 |
| 11 | 爱网购 |
| 12 | 爱外卖 |
+-------+--------------+
可以看到,表 Tags 是一张画像定义表,用于描述当前定义有多少个标签,接着给每个用户打标签,比如用户 David,他的标签是 80 后、高学历、小资、有房、常看电影;用户 Tom,90 后、常看电影、爱外卖。
若不用 JSON 数据类型进行标签存储,通常会将用户标签通过字符串,加上分割符的方式,在一个字段中存取用户所有的标签:
+-------+---------------------------------------+
|用户 |标签 |
+-------+---------------------------------------+
|David |80后 ; 高学历 ; 小资 ; 有房 ;常看电影 |
|Tom |90后 ;常看电影 ; 爱外卖 |
+-------+---------------------------------------+
这样做的缺点是: 不好搜索特定画像的用户,另外分隔符也是一种自我约定,在数据库中其实可以任意存储其他数据,最终产生脏数据。
用 JSON 数据类型就能很好解决这个问题:
DROP TABLE IF EXISTS UserTag;
CREATE TABLE UserTag (userId bigint NOT NULL,userTags JSON,PRIMARY KEY (userId)
);INSERT INTO UserTag VALUES (1,'[2,6,8,10]');
INSERT INTO UserTag VALUES (2,'[3,10,12]');
其中,userTags 存储的标签就是表 Tags 已定义的那些标签值,只是使用 JSON 数组类型进行存储。
MySQL 8.0.17 版本开始支持 Multi-Valued Indexes,用于在 JSON 数组上创建索引,并通过函数 member of、json_contains、json_overlaps 来快速检索索引数据。所以你可以在表 UserTag 上创建 Multi-Valued Indexes:
ALTER TABLE UserTag
ADD INDEX idx_user_tags ((cast((userTags->"$") as unsigned array)));
如果想要查询用户画像为常看电影的用户,可以使用函数 MEMBER OF(数组用MEMBER OF):
EXPLAIN SELECT * FROM UserTag
WHERE 10 MEMBER OF(userTags->"$")\G
*************************** 1. row ***************************id: 1select_type: SIMPLEtable: UserTagpartitions: NULLtype: ref
possible_keys: idx_user_tagskey: idx_user_tagskey_len: 9ref: constrows: 1filtered: 100.00Extra: Using where
1 row in set, 1 warning (0.00 sec)SELECT * FROM UserTag
WHERE 10 MEMBER OF(userTags->"$");
+--------+---------------+
| userId | userTags |
+--------+---------------+
| 1 | [2, 6, 8, 10] |
| 2 | [3, 10, 12] |
+--------+---------------+
2 rows in set (0.00 sec)
如果想要查询画像为 80 后,且常看电影的用户,可以使用函数 JSON_CONTAINS(JSON用JSON_CONTAINS):
EXPLAIN SELECT * FROM UserTag
WHERE JSON_CONTAINS(userTags->"$", '[2,10]')\G
*************************** 1. row ***************************id: 1select_type: SIMPLEtable: UserTagpartitions: NULLtype: range
possible_keys: idx_user_tagskey: idx_user_tagskey_len: 9ref: NULLrows: 3filtered: 100.00Extra: Using where
1 row in set, 1 warning (0.00 sec)SELECT * FROM UserTag
WHERE JSON_CONTAINS(userTags->"$", '[2,10]');
+--------+---------------+
| userId | userTags |
+--------+---------------+
| 1 | [2, 6, 8, 10] |
+--------+---------------+
1 row in set (0.00 sec)
如果想要查询画像为 80 后、90 后,且常看电影的用户,则可以使用函数 JSON_OVERLAP:
EXPLAIN SELECT * FROM UserTag
WHERE JSON_OVERLAPS(userTags->"$", '[2,3,10]')\G
*************************** 1. row ***************************id: 1select_type: SIMPLEtable: UserTagpartitions: NULLtype: range
possible_keys: idx_user_tagskey: idx_user_tagskey_len: 9ref: NULLrows: 4filtered: 100.00Extra: Using where
1 row in set, 1 warning (0.00 sec)SELECT * FROM UserTag
WHERE JSON_OVERLAPS(userTags->"$", '[2,3,10]');
+--------+---------------+
| userId | userTags |
+--------+---------------+
| 1 | [2, 6, 8, 10] |
| 2 | [3, 10, 12] |
+--------+---------------+
2 rows in set (0.01 sec)
1.4.4 总结
JSON 类型是 MySQL 5.7 版本新增的数据类型,用好 JSON 数据类型可以有效解决很多业务中实际问题。最后,我总结下今天的重点内容:
-
使用 JSON 数据类型,推荐用 MySQL 8.0.17 以上的版本,性能更好,同时也支持 Multi-Valued Indexes;
-
JSON 数据类型的好处是无须预先定义列,数据本身就具有很好的描述性;
-
不要将有明显关系型的数据用 JSON 存储,如用户余额、用户姓名、用户身份证等,这些都是每个用户必须包含的数据;
-
JSON 数据类型推荐使用在不经常更新的静态数据存储。
1.5 表结构设计
我们在对一张表进行设计时,还要遵守一些基本的原则,比如你经常听见的“范式准则”。但范式准则过于理论,在真实业务中,你不必严格遵守三范式的要求。而且有时为了性能考虑,你还可以进行反范式的设计,比如在数据仓库领域。这一讲我就会带你了解这些内容,希望你学完这一讲之后,能从更高一层的维度来看待 MySQL 数据库的表结构设计。
1.5.1 忘记范式准则
忘记之前,我们先简单回顾一下三范式:
- 第一范式:确保每列的原子性(强调的是列的原子性,即列不能够再分成其他几列)。反例:地址列可以继续拆分成国家、省份、城市、区县等列。
- 第二范式:在第一范式的基础上更进一层,目标是确保表中的每列都和主键相关(一是表必须有一个主键;二是没有包含在主键中的列必须完全依赖于主键,而不能只依赖于主键的部分)。反例:主键为联合主键,而表中其他列只依赖于联合主键中的一个。
- 第三范式:在第二范式的基础上更进一层,目标是确保每列都和主键列直接相关,而不是间接相关(另外非主键列必须直接依赖于主键,不能存在传递依赖)。反例:B列依赖于主键列,C列依赖于B列,此时C列间接依赖于主键列。
范式设计是非常重要的理论,是通过数学集合概念来推导范式的过程,在理论上,要求表结构设计必须至少满足三范式的要求。
由于完全是数据推导过程,范式理论非常枯燥,但你只要记住几个要点就能抓住其中的精髓:
-
一范式要求所有属性都是不可分的基本数据项;
-
二范式解决部分依赖;
-
三范式解决传递依赖。
虽然我已经提炼了范式设计的精髓,但要想真正理解范式设计,就要抛弃纯理论的范式设计准则,从业务角度出发,设计出符合范式准则要求的表结构。
1.5.2 自增主键设计
主键用于唯一标识一行数据,所以一张表有主键,就已经直接满足一范式的要求了。在 01 讲的整型类型中,我提及可以使用 BIGINT 的自增类型作为主键,同时由于整型的自增性,数据库插入也是顺序的,性能较好。
但你要注意,使用 BIGINT 的自增类型作为主键的设计仅仅适合非核心业务表,比如告警表、日志表等。真正的核心业务表,一定不要用自增键做主键,主要有 6 个原因:
-
自增存在回溯问题;
-
自增值在服务器端产生,存在并发性能问题;
-
自增值做主键,只能在当前实例中保证唯一,不能保证全局唯一;
-
公开数据值,容易引发安全问题,例如知道地址/,很容猜出 User 有 11、12 依次类推的值,容易引发数据泄露;
-
MGR(MySQL Group Replication) 可能引起的性能问题;
-
分布式架构设计问题。
自增存在回溯问题,我在 01 讲中已经讲到,如果你想让核心业务表用自增作为主键,MySQL 数据库版本应该尽可能升级到 8.0 版本。回溯原因:在 MySQL 5.7 中的表的AUTO_INCREMENT是基于内存,不会持久化在磁盘中,每次启动数据库时,会对每张表进行max(auto_increment) + 1重新作为该表下一次的主键ID的自增值。在MySQL8.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 做主键或业务自定义生成主键。
1.5.3 UUID主键设计
UUID(Universally 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 00:00:00.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 INVOKERRETURNUNHEX(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 INVOKERRETURNLCASE(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 相同的数据,这个工作是相当巨大且容易出错的。
1.5.4 业务自定义生成主键
当然了,UUID 虽好,但是在分布式数据库场景下,主键还需要加入一些额外的信息,这样才能保证后续二级索引的查询效率(具体这部分内容将在后面的分布式章节中进行介绍)。现在你只需要牢记:分布式数据库架构,仅用 UUID 做主键依然是不够的。 所以,对于分布式架构的核心业务表,我推荐类似如下的设计,比如:
PK = 时间字段 + 随机码(可选) + 业务信息1 + 业务信息2 ......
电商业务中,订单表是其最为核心的表之一,你可以先打开淘宝 App,查询下自己的订单号,可以查到类似如下的订单信息:
上图是我自己的淘宝订单信息(第一个订单的订单号为1550672064762308113)。
订单号显然是订单表的主键,但如果你以为订单号是自增整型,那就大错特错了。因为如果你仔细观察的话,可以发现图中所有订单号的后 6 位都是相同的,都为308113:
1550672064762308113
1481195847180308113
1431156171142308113
1431146631521308113
所以,我认为淘宝订单号的最后 6 位是用户 ID 相关信息,前 14 位是时间相关字段,这样能保证插入的自增性,又能同时保留业务的相关信息作为后期的分布式查询。
1.5.5 消除冗余
消除冗余也是范式的要求,解决部分依赖和传递依赖,本质就是尽可能减少冗余数据。
所以,在进行表结构设计时,数据只需存放在一个地方,其他表要使用,通过主键关联存储即可。比如订单表中需要存放订单对应的用户信息,则保存用户 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主键设计、业务自定义生成主键、还是消除冗余,本质上都是遵循了范式准则。但是在一些其他业务场景下,也存在反范式设计的情况。
1.5.6 反范式设计
通常我们会在 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] |
+--------+---------------
1.5.7总结
总的来说,范式是偏数据库理论范畴的表结构设计准则,在实际的工程实践上没有必要严格遵循三范式要求,在 MySQL 海量并发的工程实践上,表结构设计应遵循这样几个规范:
-
每张表一定要有一个主键;
-
自增主键只推荐用在非核心业务表,甚至应避免使用;
-
核心业务表推荐使用 UUID 或业务自定义主键;
-
一份数据应尽可能保留一份,通过主键关联进行查询,避免冗余数据;
-
在一些场景下,可以通过 JSON 数据类型进行反范式设计,提升存储效率;
1.6 表压缩
很多同学不会在表结构设计之初就考虑存储的设计,只有当业务发展到一定规模才会意识到问题的严重性。而物理存储主要是考虑是否要启用表的压缩功能,默认情况下,所有表都是非压缩的。
但一些同学一听到压缩,总会下意识地认为压缩会导致 MySQL 数据库的性能下降。这个观点说对也不对,需要根据不同场景进行区分。 这一讲,我们就来看一看表的物理存储设计:不同场景下,表压缩功能的使用。
1.6.1 表压缩
数据库中的表是由一行行记录(rows)所组成,每行记录被存储在一个页中,在 MySQL 中,一个页的大小默认为 16K,一个个页又组成了每张表的表空间。
通常我们认为,如果一个页中存放的记录数越多,数据库的性能越高。这是因为数据库表空间中的页是存放在磁盘上,MySQL 数据库先要将磁盘中的页读取到内存缓冲池,然后以页为单位来读取和管理记录。
一个页中存放的记录越多,内存中能存放的记录数也就越多,那么存取效率也就越高。若想将一个页中存放的记录数变多,可以启用压缩功能。此外,启用压缩后,存储空间占用也变小了,同样单位的存储能存放的数据也变多了。
若要启用压缩技术,数据库可以根据记录、页、表空间进行压缩,不过在实际工程中,我们普遍使用页压缩技术,这是为什么呢?
-
压缩每条记录: 因为每次读写都要压缩和解压,过于依赖 CPU 的计算能力,性能会明显下降;另外,因为单条记录大小不会特别大,一般小于 1K,压缩效率也并不会特别好。
-
压缩表空间: 压缩效率非常不错,但要求表空间文件静态不增长,这对基于磁盘的关系型数据库来说,很难实现。
而基于页的压缩,既能提升压缩效率,又能在性能之间取得一种平衡。
可能很多同学认为,启用表的页压缩功能后,性能有明显损失,因为压缩需要有额外的开销。的确,压缩需要消耗额外的 CPU 指令,但是压缩并不意味着性能下降,或许能额外提升性能,因为大部分的数据库业务系统,CPU 的处理能力是剩余的,而 I/O 负载才是数据库主要瓶颈。
借助页压缩技术,MySQL 可以把一个 16K 的页压缩为 8K,甚至 4K,这样在从磁盘写入或读取时,就能将 I/O 请求大小减半,甚至更小,从而提升数据库的整体性能。
当然,压缩是一种平衡,并非一定能提升数据库的性能。这种性能“平衡”取决于解压缩开销带来的收益和解压缩带来的开销之间的一种权衡。但无论如何,压缩都可以有效整理数据原本的容量,对存储空间来说,压缩的收益是巨大的。
1.6.2 MySQL 压缩表设计——COMPRESS 页压缩
COMPRESS 页压缩是 MySQL 5.7 版本之前提供的页压缩功能。只要在创建表时指定ROW_FORMAT=COMPRESS,并通过选项 KEY_BLOCK_SIZE 设置压缩的比例。
需要牢记的是, 虽然是通过选项 ROW_FORMAT 启用压缩功能,但这并不是记录级压缩,依然是根据页的维度进行压缩。
下面这是一张日志表,ROW_FROMAT 设置为 COMPRESS,表示启用 COMPRESS 页压缩功能,KEY_BLOCK_SIZE 设置为 8,表示将一个 16K 的页压缩为 8K。
CREATE TABLE Log (logId BINARY(16) PRIMARY KEY,......
)
ROW_FORMAT=COMPRESSED
KEY_BLOCK_SIZE=8
COMPRESS 页压缩就是将一个页压缩到指定大小。如 16K 的页压缩到 8K,若一个 16K 的页无法压缩到 8K,则会产生 2 个压缩后的 8K 页,具体如下图所示:
总的来说,COMPRESS 页压缩,适合用于一些对性能不敏感的业务表,例如日志表、监控表、告警表等,压缩比例通常能达到 50% 左右。
虽然 COMPRESS 压缩可以有效减小存储空间,但 COMPRESS 页压缩的实现对性能的开销是巨大的,性能会有明显退化。主要原因是一个压缩页在内存缓冲池中,存在压缩和解压两个页。
如图所示,Page1 和 Page2 都是压缩页 8K,但是在内存中还有其解压后的 16K 页。这样设计的原因是 8K 的页用于后续页的更新,16K 的页用于读取,这样读取就不用每次做解压操作了。
很明显,这样的实现会增加对内存的开销,会导致缓存池能存放的有效数据变少,MySQL 数据库的性能自然出现明显退化。
为了 解决压缩性能下降的问题,从MySQL 5.7 版本开始推出了 TPC 压缩功能。
1.6.3 MySQL 压缩表设计——TPC 压缩
TPC(Transparent Page Compression)是 5.7 版本推出的一种新的页压缩功能,其利用文件系统的空洞(Punch Hole)特性进行压缩。可以使用下面的命令创建 TPC 压缩表:
CREATE TABLE Transaction (transactionId BINARY(16) PRIMARY KEY,.....
)
COMPRESSION=ZLIB | LZ4 | NONE;
要使用 TPC 压缩,首先要确认当前的操作系统是否支持空洞特性。通常来说,当前常见的 Linux 操作系统都已支持空洞特性。
由于空洞是文件系统的一个特性,利用空洞压缩只能压缩到文件系统的最小单位 4K,且其页压缩是 4K 对齐的。比如一个 16K 的页,压缩后为 7K,则实际占用空间 8K;压缩后为 3K,则实际占用空间是 4K;若压缩后是 13K,则占用空间依然为 16K。
TPC 压缩的具体实现如下所示:
上图可以看到,一个 16K 的页压缩后是 8K,接着数据库会对这 16K 的页剩余的 8K 填充0x00,这样当这个 16K 的页写入到磁盘时,利用文件系统空洞特性,则实际将仅占用 8K 的物理存储空间。
空洞压缩的另一个好处是,它对数据库性能的侵入几乎是无影响的(小于 20%),甚至可能还能有性能的提升。
这是因为不同于 COMPRESS 页压缩,TPC 压缩在内存中只有一个 16K 的解压缩后的页,对于缓冲池没有额外的存储开销。
另一方面,所有页的读写操作都和非压缩页一样,没有开销,只有当这个页需要刷新到磁盘时,才会触发页压缩功能一次。但由于一个 16K 的页被压缩为了 8K 或 4K,其实写入性能会得到一定的提升。
上图是 MySQL 官方的 LinkBench 测试结果,可以看到,无压缩的测试结果为 13,432 QPS,传统的 COMPRESS 页压缩性能下降为 10,480 QPS,差不多30%的性能下降。基于TPC压缩的测试结果为 18,882,在未压缩的基础上还能有额外 40% 的性能提升。
1.6.4 实战-表压缩在业务上的使用
总的来说,对一些对性能不敏感的业务表,例如日志表、监控表、告警表等,它们只对存储空间有要求,因此可以使用 COMPRESS 页压缩功能。
在一些较为核心的流水业务表上,我更推荐使用 TPC压缩。因为流水信息是一种非常核心的数据存储业务,通常伴随核心业务。如一笔电商交易,用户扣钱、下单、记流水,这就是一个核心业务的微模型。
所以,用户对流水表有性能需求。此外,流水又非常大,启用压缩功能可更为有效地存储数据。
若对压缩产生的性能抖动有所担心,我的建议:由于流水表通常是按月或天进行存储,对当前正在使用的流水表不要启用 TPC 功能,对已经成为历史的流水表启用 TPC 压缩功能,如下所示:
需要特别注意的是: 通过命令 ALTER TABLE xxx COMPRESSION = ZLIB 可以启用 TPC 页压缩功能,但是这只对后续新增的数据会进行压缩,对于原有的数据则不进行压缩。所以上述ALTER TABLE 操作只是修改元数据,瞬间就能完成。
若想要对整个表进行压缩,需要执行 OPTIMIZE TABLE 命令:
ALTER TABLE Transaction202102 COMPRESSION=ZLIB;
OPTIMIZE TABLE Transaction202102;
1.6.5 总结
在进行表结构设计时,除了进行列的选择外,还需要考虑存储的设计,特别是对于表的压缩功能的设计,总结来说:
-
MySQL 中的压缩都是基于页的压缩;
-
COMPRESS 页压缩适合用于性能要求不高的业务表,如日志、监控、告警表等;
-
COMPRESS 页压缩内存缓冲池存在压缩和解压的两个页,会严重影响性能;
-
对存储有压缩需求,又希望性能不要有明显退化,推荐使用 TPC 压缩;
-
通过 ALTER TABLE 启用 TPC 压缩后,还需要执行命令 OPTIMIZE TABLE 才能立即完成空间的压缩。
1.7 表的访问设计
2 索引
2.1 索引介绍
在模块一中,我们学习了怎么根据合适的类型正确地创建一张表,但创建的表不能立刻用在真正的业务系统上。因为表结构设计只是设计数据库最初的环节之一,我们还缺少数据库设计中最为重要的一个环节——索引设计,只有正确设计索引,业务才能达到上线的初步标准。
所以模块二我会讲索引的设计、业务应用与调优等案例。今天我们先来学习关系型数据库最核心的概念——索引,对索引做一个初步的概述,让你对数据库中的索引有一个体系的认知,并用好 B+ 树索引。
2.1.1 索引是什么
相信你在面试时,通常会被问到“什么是索引?”而你一定要能脱口而出:索引是提升查询速度的一种数据结构。
索引之所以能提升查询速度,在于它在插入时对数据进行了排序(显而易见,它的缺点是影响插入或者更新的性能)。
所以,索引是一门排序的艺术,有效地设计并创建索引,会提升数据库系统的整体性能。在目前的 MySQL 8.0 版本中,InnoDB 存储引擎支持的索引有 B+ 树索引、全文索引、R 树索引。这一讲我们就先关注使用最为广泛的 B+ 树索引。
2.1.2 B+树索引结构
B+ 树索引是数据库系统中最为常见的一种索引数据结构,几乎所有的关系型数据库都支持它。
那为什么关系型数据库都热衷支持 B+树索引呢?因为它是目前为止排序最有效率的数据结构。像二叉树,哈希索引、红黑树、SkipList,在海量数据基于磁盘存储效率方面远不如 B+ 树索引高效。
所以,上述的数据结构一般仅用于内存对象,基于磁盘的数据排序与存储,最有效的依然是 B+ 树索引。
B+树索引的特点是: 基于磁盘的平衡二叉树,但树非常矮,通常为 3~4 层,能存放千万到上亿的排序数据。树矮意味着访问效率高,从千万或上亿数据里查询一条数据,只用 3、4 次 I/O。
又因为现在的固态硬盘每秒能执行至少 10000 次 I/O ,所以查询一条数据,哪怕全部在磁盘上,也只需要 0.003 ~ 0.004 秒。另外,因为 B+ 树矮,在做排序时,也只需要比较 3~4 次就能定位数据需要插入的位置,排序效率非常不错。
B+ 树索引由根节点(root node)、中间节点(non leaf node)、叶子节点(leaf node)组成,其中叶子节点存放所有排序后的数据。当然也存在一种比较特殊的情况,比如高度为 1 的B+ 树索引:
上图中,第一个列就是 B+ 树索引排序的列,你可以理解它是表 User 中的列 id,类型为 8 字节的 BIGINT,所以列 userId 就是索引键(key),类似下表:
CREATE TABLE User (id BIGINT AUTO_INCREMENT PRIMARY KEY,name VARCHAR(128) NOT NULL,sex CHAR(6) NOT NULL,registerDate DATETIME NOT NULL,...
)
所有 B+ 树都是从高度为 1 的树开始,然后根据数据的插入,慢慢增加树的高度。你要牢记:索引是对记录进行排序, 高度为 1 的 B+ 树索引中,存放的记录都已经排序好了,若要在一个叶子节点内再进行查询,只进行二叉查找,就能快速定位数据。
可随着插入 B+ 树索引的记录变多,1个页(16K)无法存放这么多数据,所以会发生 B+ 树的分裂,B+ 树的高度变为 2,当 B+ 树的高度大于等于 2 时,根节点和中间节点存放的是索引键对,由(索引键、指针)组成。
索引键就是排序的列,而指针是指向下一层的地址,在 MySQL 的 InnoDB 存储引擎中占用 6 个字节。下图显示了 B+ 树高度为 2 时,B+ 树索引的样子:
可以看到,在上面的B+树索引中,若要查询索引键值为 5 的记录,则首先查找根节点,查到键值对(20,地址),这表示小于 20 的记录在地址指向的下一层叶子节点中。接着根据下一层地址就可以找到最左边的叶子节点,在叶子节点中根据二叉查找就能找到索引键值为 5 的记录。
那一个高度为 2 的 B+ 树索引,理论上最多能存放多少行记录呢?
在 MySQL InnoDB 存储引擎中,一个页的大小为 16K,在上面的表 User 中,键值 userId 是BIGINT 类型,则:
根节点能最多存放以下多个键值对 = 16K / 键值对大小(8+6) ≈ 1100
再假设表 User 中,每条记录的大小为 500 字节,则:
叶子节点能存放的最多记录为 = 16K / 每条记录大小 ≈ 32
综上所述,树高度为 2 的 B+ 树索引,最多能存放的记录数为:
总记录数 = 1100 * 32 = 35,200
也就是说,35200 条记录排序后,生成的 B+ 树索引高度为 2。在 35200 条记录中根据索引键查询一条记录只需要查询 2 个页,一个根叶,一个叶子节点,就能定位到记录所在的页。
高度为 3 的 B+ 树索引本质上与高度 2 的索引一致,如下图所示,不再赘述:
同理,树高度为 3 的 B+ 树索引,最多能存放的记录数为:
总记录数 = 1100(根节点) * 1100(中间节点) * 32 = 38,720,000
讲到这儿,你会发现,高度为 3 的 B+ 树索引竟然能存放 3800W 条记录。在 3800W 条记录中定位一条记录,只需要查询 3 个页。那么 B+ 树索引的优势是否逐步体现出来了呢?
不过,在真实环境中,每个页其实利用率并没有这么高,还会存在一些碎片的情况,我们假设每个页的使用率为60%,则:
表格显示了 B+ 树的威力,即在 50 多亿的数据中,根据索引键值查询记录,只需要 4 次 I/O,大概仅需 0.004 秒。如果这些查询的页已经被缓存在内存缓冲池中,查询性能会更快。
在数据库中,上述的索引查询请求对应的 SQL 语句为:
SELECT * FROM User WHERE id = ?
用户可以通过命令 EXPLAIN 查看是否使用索引:
mysql> EXPLAIN SELECT * FROM User WHERE id = 1\G
********************** 1. row **********************id: 1select_type: SIMPLEtable: Userpartitions: NULLtype: const
possible_keys: PRIMARYkey: PRIMARYkey_len: 8ref: constrows: 1filtered: 100.00Extra: NULL
在输出的 EXPLIAN 结果中,可以看到列 key 显示 PRIMARY,这表示根据主键索引进行查询。若没有根据索引进行查询,如根据性别进行查询,则会显示类似如下内容:
mysql> EXPLAIN SELECT * FROM User WHERE sex = 'male'\G
********************** 1. row **********************id: 1select_type: SIMPLEtable: Userpartitions: NULLtype: ALL
possible_keys: NULLkey: NULLkey_len: NULLref: NULLrows: 986400filtered: 50.00Extra: Using where
讲到这儿,你应该了解了 B+ 树索引的组织形式,以及为什么在上亿的数据中可以通过B+树索引快速定位查询的记录。但 B+ 树的查询高效是要付出代价的,就是我们前面说的插入性能问题,接下去咱们就来讨论一下。
2.1.3 优化 B+ 树索引的插入性能
B+ 树在插入时就对要对数据进行排序,但排序的开销其实并没有你想象得那么大,因为排序是 CPU 操作(当前一个时钟周期 CPU 能处理上亿指令)。
真正的开销在于 B+ 树索引的维护,保证数据排序,这里存在两种不同数据类型的插入情况。
-
数据顺序(或逆序)插入: 这类数据插入时,B+ 树索引的维护代价非常小。叶子节点都是从左往右进行插入,比较典型的是自增 ID 的插入、时间的插入(若在自增 ID 上创建索引,时间列上创建索引,则 B+ 树插入通常是比较快的)。
-
数据无序插入: B+ 树为了维护排序,需要对页进行分裂、旋转等开销较大的操作,另外,即便对于固态硬盘,随机写的性能也不如顺序写,所以磁盘性能也会收到较大影响。比较典型的是用户昵称,每个用户注册时,昵称是随意取的,若在昵称上创建索引,插入是无序的,索引维护需要的开销会比较大。
你不可能要求所有插入的数据都是有序的,因为索引的本身就是用于数据的排序,插入数据都已经是排序的,那么你就不需要 B+ 树索引进行数据查询了。
所以对于 B+ 树索引,在 MySQL 数据库设计中,仅要求主键的索引设计为顺序,比如使用自增,或使用函数 UUID_TO_BIN 排序的 UUID,而不用无序值做主键。
我们再回顾 05 讲的自增、UUID、UUID 排序的插入性能对比:
可以看到,UUID 由于是无序值,所以在插入时性能比起顺序值自增 ID 和排序 UUID,性能上差距比较明显。
所以,我再次强调: 在表结构设计时,主键的设计一定要尽可能地使用顺序值,这样才能保证在海量并发业务场景下的性能。
以上就是索引查询和插入的知识,接下来我们就分析怎么在 MySQL 数据库中查看 B+ 树索引。
2.1.4 MySQL 中 B+ 树索引的设计与管理
在 MySQL 数据库中,可以通过查询表 mysql.innodb_index_stats 查看每个索引的大致情况:
SELECT
table_name,index_name,stat_name,
stat_value,stat_description
FROM innodb_index_stats
WHERE table_name = 'orders' and index_name = 'PRIMARY';+----------+------------+-----------+------------+------------------+
|table_name| index_name | stat_name | stat_value |stat_description |
+----------+-------------------+------------+------------+----------+
| orders | PRIMARY|n_diff_pfx01|5778522 | O_ORDERKEY |
| orders | PRIMARY|n_leaf_pages|48867 | Number of leaf pages |
| orders | PRIMARY|size |49024 | Number of pages in the index|
+--------+--------+------------+------+-----------------------------+
3 rows in set (0.00 sec)
从上面的结果中可以看到,表 orders 中的主键索引,大约有 5778522 条记录,其中叶子节点一共有 48867 个页,索引所有页的数量为 49024。根据上面的介绍,你可以推理出非叶节点的数量为 49024-48867,等于 157 个页。
另外,我看见网上一些所谓的 MySQL“军规”中写道“一张表的索引不能超过 5 个”。根本没有这样的说法,完全是无稽之谈。
在我看来,如果业务的确需要很多不同维度进行查询,那么就该创建对应多索引,这是没有任何值得商讨的地方。
真正在业务上遇到的问题是: 由于业务开发同学对数据库不熟悉,创建 N 多索引,但实际这些索引从创建之初到现在根本就没有使用过!因为优化器并不会选择这些低效的索引,这些无效索引占用了空间,又影响了插入的性能。
那你怎么知道哪些 B+树索引未被使用过呢?在 MySQL 数据库中,可以通过查询表sys.schema_unused_indexes,查看有哪些索引一直未被使用过,可以被废弃:
SELECT * FROM schema_unused_indexes
WHERE object_schema != 'performance_schema';+---------------+-------------+--------------+
| object_schema | object_name | index_name |
+---------------+-------------+--------------+
| sbtest | sbtest1 | k_1 |
| sbtest | sbtest2 | k_2 |
| sbtest | sbtest3 | k_3 |
| sbtest | sbtest4 | k_4 |
| tpch | customer | CUSTOMER_FK1 |
| tpch | lineitem | LINEITEM_FK2 |
| tpch | nation | NATION_FK1 |
| tpch | orders | ORDERS_FK1 |
| tpch | partsupp | PARTSUPP_FK1 |
| tpch | supplier | SUPPLIER_FK1 |
+---------------+-------------+--------------+
如果数据库运行时间比较长,而且索引的创建时间也比较久,索引还出现在上述结果中,DBA 就可以考虑删除这些没有用的索引。
而 MySQL 8.0 版本推出了索引不可见(Invisible)功能。在删除废弃索引前,用户可以将索引设置为对优化器不可见,然后观察业务是否有影响。若无,DBA 可以更安心地删除这些索引:
ALTER TABLE t1
ALTER INDEX idx_name INVISIBLE/VISIBLE;
2.1.5 总结
这一讲我对索引做了一个较为初步地概述,学完这一讲,我相信你能非常清晰地知道:
-
索引是加快查询的一种数据结构,其原理是插入时对数据排序,缺点是会影响插入的性能;
-
MySQL 当前支持 B+树索引、全文索引、R 树索引;
-
B+ 树索引的高度通常为 3~4 层,高度为 4 的 B+ 树能存放 50 亿左右的数据;
-
由于 B+ 树的高度不高,查询效率极高,50 亿的数据也只需要插叙 4 次 I/O;
-
MySQL 单表的索引没有个数限制,业务查询有具体需要,创建即可,不要迷信个数限制;
-
可以通过表 sys.schema_unused_indexes 和索引不可见特性,删除无用的索引。
总的来讲,关于索引虽然老生常谈,但是它是所有关系型数据库的核心,我希望你反复阅读本文,真正理解 B+ 树索引的实现。
2.2 索引组织表
上一讲,我已经带你了解了 B+ 树索引的基本概念,以及 MySQL 中怎么对 B+ 树索引进行基本的管理。为了让你进一步深入了解 MySQL 的 B+ 树索引的具体使用,这一讲我想和你聊一聊 MySQL InnoDB 存储引擎的索引结构。
InnoDB 存储引擎是 MySQL 数据库中使用最为广泛的引擎,在海量大并发的 OLTP 业务中,InnoDB 必选。它在数据存储方面有一个非常大的特点:索引组织表(Index Organized Table)。
接下来我就带你了解最为核心的概念:索引组织表。希望你学完今天的内容之后能理解 MySQL 是怎么存储数据和索引对象的。
2.2.1 索引组织表
数据存储有堆表和索引组织表两种方式。
堆表中的数据无序存放, 数据的排序完全依赖于索引(Oracle、Microsoft SQL Server、PostgreSQL 早期默认支持的数据存储都是堆表结构)。
从图中你能看到,堆表的组织结构中,数据和索引分开存储。索引是排序后的数据,而堆表中的数据是无序的,索引的叶子节点存放了数据在堆表中的地址,当堆表的数据发生改变,且位置发生了变更,所有索引中的地址都要更新,这非常影响性能,特别是对于 OLTP 业务。
参考
姜承尧的MySQL实战宝典
《姜承尧的MySQL实战宝典》学习笔记
《姜承尧的MySQL实战宝典》学习笔记
- 1 表结构设计
- 1.1 数字类型
- 1.1.1 整形类型
- 1.1.2 浮点类型和高精度型
- 1.1.3 实战——整型类型与自增设计
- 1.1.4 实战——资金字段设计
- 1.1.5 总结
- 1.2 字符串类型
- 1.2.1 CHAR 和 VARCHAR 的定义
- 1.2.2 字符集
- 1.2.3 排序规则
- 1.2.4 正确修改字符集
- 1.2.5 实战——用户性别设计
- 1.2.6 实战——账户密码存储设计
- 1.2.7 总结
- 1.3 日期类型
- 1.3.1 日期类型
- 1.3.2 DATETIME
- 1.3.3 TIMESTAMP
- 1.3.4 实战——DATETIME vs TIMESTAMP vs INT,怎么选?
- 1.3.5 实战——不要忽视 TIMESTAMP 的性能问题
- 1.3.6 实战——表结构设计规范:每条记录都要有一个时间字段
- 1.3.7 总结
- 1.4 非结构存储——JSON
- 1.4.1 JSON 数据类型
- 1.4.2 实战——用户登录设计
- 1.4.3 实战——用户画像设计
- 1.4.4 总结
- 1.5 表结构设计
- 1.5.1 忘记范式准则
- 1.5.2 自增主键设计
- 1.5.3 UUID主键设计
- 1.5.4 业务自定义生成主键
- 1.5.5 消除冗余
- 1.5.6 反范式设计
- 1.5.7总结
- 1.6 表压缩
- 1.6.1 表压缩
- 1.6.2 MySQL 压缩表设计——COMPRESS 页压缩
- 1.6.3 MySQL 压缩表设计——TPC 压缩
- 1.6.4 实战-表压缩在业务上的使用
- 1.6.5 总结
- 1.7 表的访问设计
- 2 索引
- 2.1 索引介绍
- 2.1.1 索引是什么
- 2.1.2 B+树索引结构
- 2.1.3 优化 B+ 树索引的插入性能
- 2.1.4 MySQL 中 B+ 树索引的设计与管理
- 2.1.5 总结
- 2.2 索引组织表
- 2.2.1 索引组织表
- 参考
1 表结构设计
1.1 数字类型
1.1.1 整形类型
MySQL数据库支持的整型类型及其占用空间、取值范围等如下图所示
注意:数据库设计过程中不用刻意用unsigned
类型,因为有时候做数据分析时可能需要应用到非等值连接,如下列SQL语句:
SELECTs1.sale_date, s2.sale_count - s1.sale_count AS diff
FROMsale s1LEFT JOINsale s2 ON DATE_ADD(s2.sale_date, INTERVAL 1 MONTH) = s1.sale_date
ORDER BY sale_date;
在执行的过程中,如果列 sale_count 用到了 unsigned 属性,会抛出这样的结果:
ERROR 1690 (22003): BIGINT UNSIGNED value is out of range in '(`test`.`s2`.`sale_count` - `test`.`s1`.`sale_count`)'
因为MySQL要求 unsigned
数值相减之后依然为 unsigned
,否则就会报错 。
为了避免这个错误,需要对数据库参数 sql_mode 设置为 NO_UNSIGNED_SUBTRACTION,允许相减的结果为 signed :
mysql> SET sql_mode='NO_UNSIGNED_SUBTRACTION';
Query OK, 0 rows affected (0.00 sec)
SELECT
1.1.2 浮点类型和高精度型
MySQL 之前的版本中存在浮点类型 Float 和 Double,但这些类型因为不是高精度,也不是 SQL 标准的类型,所以在真实的生产环境中不推荐使用,否则在计算时,由于精度类型问题,会导致最终的计算结果出错。
在需要高精度的场合,可以使用DECIMAL类型,当声明该类型列时,可以(并且通常必须要)指定精度和标度,例如:
salary DECIMAL(8,2)
其中,8 是精度(精度表示保存值的主要位数),2 是标度(标度表示小数点后面保存的位数)。通常在表结构设计中,类型 DECIMAL 可以用来表示用户的工资、账户的余额等精确到小数点后 2 位的业务。
然而,在海量并发的互联网业务中使用,金额字段的设计并不推荐使用 DECIMAL 类型,而更推荐使用 INT 整型类型(下文就会分析原因)。
1.1.3 实战——整型类型与自增设计
在表结构设计时用自增做主键,希望你特别要注意以下两点,若不注意,可能会对业务造成灾难性的打击:
- 用 BIGINT 做主键,而不是 INT;
- 自增值并不持久化,可能会有回溯现象(MySQL 8.0 版本前)。
INT 的范围最大在 42 亿的级别,在真实的互联网业务场景的应用中,很容易达到最大值。例如一些流水表、日志表,每天 1000W 数据量,420 天后,INT 类型的上限即可达到。
因此,(敲黑板 1)用自增整型做主键,一律使用 BIGINT,而不是 INT。不要为了节省 4 个字节使用 INT,当达到上限时,再进行表结构的变更,将是巨大的负担与痛苦。
如果达到了 INT 类型的上限,数据库的表现又将如何呢?是会重新变为 1?我们可以通过下面的 SQL 语句验证一下:
mysql> CREATE TABLE t (-> a INT AUTO_INCREMENT PRIMARY KEY-> );mysql> INSERT INTO t VALUES (2147483647);
Query OK, 1 row affected (0.01 sec)mysql> INSERT INTO t VALUES (NULL);
ERROR 1062 (23000): Duplicate entry '2147483647' for key 't.PRIMARY'
可以看到,当达到 INT 上限后,再次进行自增插入时,会报重复错误,MySQL 数据库并不会自动将其重置为 1。
第二个特别要注意的问题是,(敲黑板 2)MySQL 8.0 版本前,自增不持久化,自增值可能会存在回溯问题!
mysql> SELECT * FROM t;
+---+
| a |
+---+
| 1 |
| 2 |
| 3 |
+---+
3 rows in set (0.01 sec)mysql> DELETE FROM t WHERE a = 3;
Query OK, 1 row affected (0.02 sec)mysql> SHOW CREATE TABLE t\G
*************************** 1. row ***************************Table: t
Create Table: CREATE TABLE `t` (`a` int NOT NULL AUTO_INCREMENT,PRIMARY KEY (`a`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
1 row in set (0.00 sec)
在删除自增为 3 的这条记录后,下一个自增值依然为 4(AUTO_INCREMENT=4),这里并没有错误,自增并不会进行回溯。但若这时数据库发生重启,那数据库启动后,表 t 的自增起始值将再次变为 3,即自增值发生回溯。具体如下所示:
mysql> SHOW CREATE TABLE t\G
*************************** 1. row ***************************Table: t
Create Table: CREATE TABLE `t` (`a` int NOT NULL AUTO_INCREMENT,PRIMARY KEY (`a`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
1 row in set (0.00 s)
若要彻底解决这个问题,有以下 2 种方法:
-
升级 MySQL 版本到 8.0 版本,每张表的自增值会持久化;
-
若无法升级数据库版本,则强烈不推荐在核心业务表中使用自增数据类型做主键。
其实,在海量互联网架构设计过程中,为了之后更好的分布式架构扩展性,不建议使用整型类型做主键,更为推荐的是字符串类型(这部分内容将在 05 节中详细介绍)。
1.1.4 实战——资金字段设计
(敲黑板3)在海量互联网业务的设计标准中,并不推荐用 DECIMAL 类型,而是更推荐将 DECIMAL 转化为 整型类型 BIG INT。也就是说,资金类型更推荐使用用分单位存储,而不是用元单位存储。如1元在数据库中用整型类型 100 存储。
不使用 DECIMAL 有以下原因:
- DECIMAL 类型是个变长字段,定义金额时,不好确定位数,且存储性能较差;
- DECIMAL 类型是通过二进制实现的一种编码方式,计算效率远不如整型。
注意,在数据库设计中,我们非常强调定长存储,因为定长存储的性能更好。
那么,当使用 BIG INT 存储金额字段的时候,如何表示小数点中的数据呢?其实,这部分完全可以交由前端进行处理并展示。作为数据库本身,只要按分进行存储即可。
1.1.5 总结
总结一下 本节课的重点:
-
不推荐使用整型类型的属性 Unsigned,若非要使用,参数 sql_mode 务必额外添加上选项 NO_UNSIGNED_SUBTRACTION;
-
自增整型类型做主键,务必使用类型 BIGINT,而非 INT,后期表结构调整代价巨大;
-
MySQL 8.0 版本前,自增整型会有回溯问题(未持久化),做业务开发的你一定要了解这个问题;
-
当达到自增整型类型的上限值时,再次自增插入,MySQL 数据库会报重复错误;
-
不要再使用浮点类型 Float、Double,MySQL 后续版本将不再支持上述两种类型(使用 DECIMAL);
-
账户余额字段,设计是用整型类型(BIG INT),而不是 DECIMAL 类型,这样性能更好,存储更紧凑。
1.2 字符串类型
MySQL 数据库的字符串类型有 CHAR、VARCHAR、BINARY、BLOB、TEXT、ENUM、SET。不同的类型在业务设计、数据库性能方面的表现完全不同,其中最常使用的是 CHAR、VARCHAR。今天我就带你深入了解字符串类型 CHAR、VARCHAR 的应用,希望学完这一讲,你能真正用好 MySQL 的字符串类型,从而设计出一个更为优美的业务表结构。
1.2.1 CHAR 和 VARCHAR 的定义
CHAR(N) 用来保存固定长度的字符,N 的范围是 0 ~ 255,请牢记,N 表示的是字符,而不是字节。VARCHAR(N) 用来保存变长字符,N 的范围为 0 ~ 65536, N 表示字符。
在超出 65536 个字符的情况下,可以考虑使用更大的字符类型 TEXT 或 BLOB,两者最大存储长度为 4G,其区别是 BLOB 没有字符集属性,纯属二进制存储。
和 Oracle、Microsoft SQL Server 等传统关系型数据库不同的是,MySQL 数据库的 VARCHAR 字符类型,最大能够存储 65536 个字符,所以在 MySQL 数据库下,绝大部分场景使用类型 VARCHAR 就足够了。
注意:定义字段时CHAR(N) 、VARCHAR(N) 中的N表示字符。
1.2.2 字符集
- 推荐把 MySQL 的默认字符集设置为 UTF8MB4(可以用于存储 emoji 等扩展字符 )
- 从底层存储内核看,在多字节字符集下(GBK中1字符=2字节,UTF8MB4中1字符=4字节),CHAR 和 VARCHAR 底层的实现完全相同,都是变长存储!
- 鉴于目前默认字符集推荐设置为 UTF8MB4,所以在表结构设计时,可以把 CHAR 全部用 VARCHAR 替换,底层存储的本质实现一模一样。
1.2.3 排序规则
排序规则(Collation) 是比较和排序字符串的一种规则,每个字符集都会有默认的排序规则,你可以用以下命令来查看:
mysql> SHOW CHARSET LIKE 'utf8%';
+---------+---------------+--------------------+--------+
| Charset | Description | Default collation | Maxlen |
+---------+---------------+--------------------+--------+
| utf8 | UTF-8 Unicode | utf8_general_ci | 3 |
| utf8mb4 | UTF-8 Unicode | utf8mb4_0900_ai_ci | 4 |
+---------+---------------+--------------------+--------+
2 rows in set (0.01 sec)mysql> SHOW COLLATION LIKE 'utf8mb4%';
+----------------------------+---------+-----+---------+----------+---------+---------------+
| Collation | Charset | Id | Default | Compiled | Sortlen | Pad_attribute |
+----------------------------+---------+-----+---------+----------+---------+---------------+
| utf8mb4_0900_ai_ci | utf8mb4 | 255 | Yes | Yes | 0 | NO PAD |
| utf8mb4_0900_as_ci | utf8mb4 | 305 | | Yes | 0 | NO PAD |
| utf8mb4_0900_as_cs | utf8mb4 | 278 | | Yes | 0 | NO PAD |
| utf8mb4_0900_bin | utf8mb4 | 309 | | Yes | 1 | NO PAD |
| utf8mb4_bin | utf8mb4 | 46 | | Yes | 1 | PAD SPACE |
......
排序规则以 _ci 结尾,表示不区分大小写(Case Insentive),_cs 表示大小写敏感,_bin 表示通过存储字符的二进制进行比较。需要注意的是,比较 MySQL 字符串,默认采用不区分大小的排序规则。
牢记,绝大部分业务的表结构设计无须设置排序规则为大小写敏感!除非你能明白你的业务真正需要。
1.2.4 正确修改字符集
正确修改列字符集的命令应该使用 ALTER TABLE … CONVERT TO…这样才能将之前的列 a 字符集从 UTF8 修改为 UTF8MB4:
mysql> ALTER TABLE emoji_test CONVERT TO CHARSET utf8mb4;
Query OK, 0 rows affected (0.94 sec)
Records: 0 Duplicates: 0 Warnings: 0mysql> SHOW CREATE TABLE emoji_test\G
*************************** 1. row ***************************Table: emoji_test
Create Table: CREATE TABLE `emoji_test` (`a` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,PRIMARY KEY (`a`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
1.2.5 实战——用户性别设计
- 不建议使用
tinyint
表示用户性别,原因如下:
- 表达不清:在具体存储时,0 表示女,还是 1 表示女呢?每个业务可能有不同的规则;
- 脏数据:因为是 tinyint,因此除了 0 和 1,用户完全可以插入 2、3、4 这样的数值,最终表中存在无效数据的可能,后期再进行清理,代价就非常大了。
- 在 MySQL 8.0 版本之前,可以使用 ENUM 字符串枚举类型,只允许有限的定义值插入。但如果将参数 SQL_MODE 设置为严格模式,插入非定义数据就会报错,且报错提醒不友好:
mysql> SHOW CREATE TABLE User\G
*************************** 1. row ***************************Table: User
Create Table: CREATE TABLE `User` (`id` bigint NOT NULL AUTO_INCREMENT,`sex` enum('M','F') COLLATE utf8mb4_general_ci DEFAULT NULL,PRIMARY KEY (`id`)
) ENGINE=InnoDB
1 row in set (0.00 sec)mysql> SET sql_mode = 'STRICT_TRANS_TABLES';
Query OK, 0 rows affected, 1 warning (0.00 sec)mysql> INSERT INTO User VALUES (NULL,'F');
Query OK, 1 row affected (0.08 sec)mysql> INSERT INTO User VALUES (NULL,'A');
ERROR 1265 (01000): Data truncated for column 'sex' at row 1
- MySQL 8.0.16 版本开始,数据库原生提供 CHECK 约束功能,可以方便地进行有限状态列类型的设计:
mysql> SHOW CREATE TABLE User\G
*************************** 1. row ***************************Table: User
Create Table: CREATE TABLE `User` (`id` bigint NOT NULL AUTO_INCREMENT,`sex` char(1) COLLATE utf8mb4_general_ci DEFAULT NULL,PRIMARY KEY (`id`),CONSTRAINT `user_chk_1` CHECK (((`sex` = _utf8mb4'M') or (`sex` = _utf8mb4'F')))
) ENGINE=InnoDB
1 row in set (0.00 sec)mysql> INSERT INTO User VALUES (NULL,'M');
Query OK, 1 row affected (0.07 sec)mysql> INSERT INTO User VALUES (NULL,'Z');
ERROR 3819 (HY000): Check constraint 'user_chk_1' is violated.
1.2.6 实战——账户密码存储设计
一个真正好的密码存储设计,应该是:动态盐 + 非固定加密算法
1.2.7 总结
字符串是使用最为广泛的数据类型之一,但也是设计最初容易犯错的部分,后期业务跑起来再进行修改,代价将会非常巨大。希望你能反复细读本讲的内容,从而在表结构设计伊始,业务就做好最为充分的准备。我总结下本节的重点内容:
-
CHAR 和 VARCHAR 虽然分别用于存储定长和变长字符,但对于变长字符集(如 GBK、UTF8MB4),其本质是一样的,都是变长,设计时完全可以用 VARCHAR 替代 CHAR;
-
推荐 MySQL 字符集默认设置为 UTF8MB4,可以用于存储 emoji 等扩展字符;
-
排序规则很重要,用于字符的比较和排序,但大部分场景不需要用区分大小写的排序规则;
-
修改表中已有列的字符集,使用命令 ALTER TABLE … CONVERT TO …;
-
用户性别,运行状态等有限值的列,MySQL 8.0.16 版本直接使用 CHECK 约束机制,之前的版本可使用 ENUM 枚举字符串类型,外加 SQL_MODE 的严格模式;
-
业务隐私信息,如密码、手机、信用卡等信息,需要加密。切记简单的MD5算法是可以进行暴力破解,并不安全,推荐使用动态盐+动态加密算法进行隐私数据的存储。
1.3 日期类型
几乎每张业务表都带有一个日期列,用于记录每条记录产生和变更的时间。比如用户表会有一个日期列记录用户注册的时间、用户最后登录的时间。又比如,电商行业中的订单表(核心业务表)会有一个订单产生的时间列,当支付时间超过订单产生的时间,这个订单可能会被系统自动取消。
日期类型虽然常见,但在表结构设计中也容易犯错,比如很多开发同学都倾向使用整型存储日期类型,同时也会忽略不同日期类型对于性能可能存在的潜在影响。所以你有必要认真学习这一讲,举一反三,在自己的业务中做好日期类型的设计。
1.3.1 日期类型
MySQL 数据库中常见的日期类型有 YEAR、DATE、TIME、DATETIME、TIMESTAMEP。因为业务绝大部分场景都需要将日期精确到秒,所以在表结构设计中,常见使用的日期类型为DATETIME 和 TIMESTAMP。接下来,我就带你深入了解这两种类型,以及它们在设计中的应用实战。
1.3.2 DATETIME
类型 DATETIME 最终展现的形式为:YYYY-MM-DD HH:MM:SS,固定占用 8 个字节。
从 MySQL 5.6 版本开始,DATETIME 类型支持毫秒,DATETIME(N) 中的 N 表示毫秒的精度。例如,DATETIME(6) 表示可以存储 6 位的毫秒值。同时,一些日期函数也支持精确到毫秒,例如常见的函数 NOW、SYSDATE:
mysql> SELECT NOW(6);
+----------------------------+
| NOW(6) |
+----------------------------+
| 2020-09-14 17:50:28.707971 |
+----------------------------+
1 row in set (0.00 sec)
用户可以将 DATETIME 初始化值设置为当前时间,并设置自动更新当前时间的属性。例如之前已设计的用户表 User,我在其基础上,修改了register_date、last_modify_date的定义:
CREATE TABLE User (id BIGINT NOT NULL AUTO_INCREMENT,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),CHECK (sex = 'M' OR sex = 'F'),PRIMARY KEY(id)
);
在上面的表 User 中,列 register_date 表示注册时间,DEFAULT CURRENT_TIMESTAMP 表示记录插入时,若没有指定时间,默认就是当前时间。
列 last_modify_date 表示当前记录最后的修改时间,DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) 表示每次修改都会修改为当前时间。
这样的设计保证当用户的金钱(money 字段)发生了变更,则 last_modify_date 能记录最后一次用户金钱发生变更时的时间。来看下面的例子:
mysql> SELECT name,money,last_modify_date FROM User WHERE name = 'David';
+-------+-------+----------------------------+
| name | money | last_modify_date |
+-------+-------+----------------------------+
| David | 100 | 2020-09-13 08:08:33.898593 |
+-------+-------+----------------------------+1 row in set (0.00 sec)mysql> UPDATE User SET money = money - 1 WHERE name = 'David';
Query OK, 1 row affected (0.06 sec)
Rows matched: 1 Changed: 1 Warnings: 0mysql> SELECT name,money,last_modify_date FROM User WHERE name = 'David';
+-------+-------+----------------------------+
| name | money | last_modify_date |
+-------+-------+----------------------------+
| David | 99 | 2020-09-14 18:29:17.056327 |
+-------+-------+----------------------------+
1 row in set (0.00 sec)
可以看到,当用户金额发生修改时,所对应的字段 last_modify_date 也修改成发生变更的时间。
1.3.3 TIMESTAMP
除了 DATETIME,日期类型中还有一种 TIMESTAMP 的时间戳类型,其实际存储的内容为‘1970-01-01 00:00:00’到现在的毫秒数。在 MySQL 中,由于类型 TIMESTAMP 占用 4 个字节,因此其存储的时间上限只能到‘2038-01-19 03:14:07’。
同类型 DATETIME 一样,从 MySQL 5.6 版本开始,类型 TIMESTAMP 也能支持毫秒。与 DATETIME 不同的是,若带有毫秒时,类型 TIMESTAMP 占用 7 个字节,而 DATETIME 无论是否存储毫秒信息,都占用 8 个字节。
类型 TIMESTAMP 最大的优点是可以带有时区属性,因为它本质上是从毫秒转化而来。如果你的业务需要对应不同的国家时区,那么类型 TIMESTAMP 是一种不错的选择。比如新闻类的业务,通常用户想知道这篇新闻发布时对应的自己国家时间,那么 TIMESTAMP 是一种选择。
另外,有些国家会执行夏令时。根据不同的季节,人为地调快或调慢 1 个小时,带有时区属性的 TIMESTAMP 类型本身就能解决这个问题。
参数 time_zone 指定了当前使用的时区,默认为 SYSTEM 使用操作系统时区,用户可以通过该参数指定所需要的时区。
如果想使用 TIMESTAMP 的时区功能,你可以通过下面的语句将之前的用户表 User 的注册时间字段类型从 DATETIME(6) 修改为 TIMESTAMP(6):
ALTER TABLE User CHANGE register_date register_date TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6);
这时通过设定不同的 time_zone,可以观察到不同时区下的注册时间:
mysql> SELECT name,regist er_date FROM User WHERE name = 'David';+-------+----------------------------+
| name | register_date |
+-------+----------------------------+
| David | 2018-09-14 18:28:33.898593 |
+-------+----------------------------+
1 row in set (0.00 sec)mysql> SET time_zone = '-08:00';
Query OK, 0 rows affected (0.00 sec)mysql> SELECT name,register_date FROM User WHERE name = 'David';
+-------+----------------------------+
| name | register_date |
+-------+----------------------------+
| David | 2018-09-14 02:28:33.898593 |
+-------+----------------------------+
1 row in set (0.00 sec)
从上述例子中,你可以看到,中国的时区是 +08:00,美国的时区是 -08:00,因此改为美国时区后,可以看到用户注册时间比之前延迟了 16 个小时。当然了,直接加减时区并不直观,需要非常熟悉各国的时区表。在 MySQL 中可以直接设置时区的名字,如:
mysql> SET time_zone = 'America/Los_Angeles';Query OK, 0 rows affected (0.00 sec)mysql> SELECT NOW();+---------------------+| NOW() |+---------------------+| 2020-09-14 20:12:49 |+---------------------+1 row in set (0.00 sec)mysql> SET time_zone = 'Asia/Shanghai';Query OK, 0 rows affected (0.00 sec)mysql> SELECT NOW();+---------------------+| NOW() |+---------------------+| 2020-09-15 11:12:55 |+---------------------+1 row in set (0.00 sec)
1.3.4 实战——DATETIME vs TIMESTAMP vs INT,怎么选?
在做表结构设计时,对日期字段的存储,开发人员通常会有 3 种选择:DATETIME、TIMESTAMP、INT。
INT 类型就是直接存储 ‘1970-01-01 00:00:00’ 到现在的毫秒数,本质和 TIMESTAMP 一样,因此用 INT 不如直接使用 TIMESTAMP。
当然,有些同学会认为 INT 比 TIMESTAMP 性能更好。但是,由于当前每个 CPU 每秒可执行上亿次的计算,所以无须为这种转换的性能担心。更重要的是,在后期运维和数据分析时,使用 INT 存储日期,是会让 DBA 和数据分析人员发疯的,INT的可运维性太差。
也有的同学会热衷用类型 TIMESTEMP 存储日期,因为类型 TIMESTAMP 占用 4 个字节,比 DATETIME 小一半的存储空间。
但若要将时间精确到毫秒,TIMESTAMP 要 7 个字节,和 DATETIME 8 字节差不太多。另一方面,现在距离 TIMESTAMP 的最大值‘2038-01-19 03:14:07’已经很近,这是需要开发同学好好思考的问题。
总的来说,建议使用类型 DATETIME。 对于时区问题,可以由前端或者服务这里做一次转化,不一定非要在数据库中解决。
1.3.5 实战——不要忽视 TIMESTAMP 的性能问题
前面已经提及,TIMESTAMP 的上限值 2038 年很快就会到来,那时业务又将面临一次类似千年虫的问题。另外,TIMESTAMP 还存在潜在的性能问题。
虽然从毫秒数转换到类型 TIMESTAMP 本身需要的 CPU 指令并不多,这并不会带来直接的性能问题。但是如果使用默认的操作系统时区,则每次通过时区计算时间时,要调用操作系统底层系统函数 __tz_convert(),而这个函数需要额外的加锁操作,以确保这时操作系统时区没有修改。所以,当大规模并发访问时,由于热点资源竞争,会产生两个问题:
-
性能不如 DATETIME: DATETIME 不存在时区转化问题。
-
性能抖动: 海量并发时,存在性能抖动问题。
为了优化 TIMESTAMP 的使用,强烈建议你使用显式的时区,而不是操作系统时区。比如在配置文件中显示地设置时区,而不要使用系统时区:
[mysqld]time_zone = "+08:00"
最后,通过命令 mysqlslap 来测试 TIMESTAMP、DATETIME 的性能,命令如下:
# 比较time_zone为System和Asia/Shanghai的性能对比
SET global time_zone='Asia/Shanghai';
Query OK, 0 rows affected (0.00 sec)mysqlslap -uroot -p --create-schema='learn' --number-of-queries=1000000 --concurrency=100 --query='SELECT NOW()';
BenchmarkAverage number of seconds to run all queries: 4.285 secondsMinimum number of seconds to run all queries: 4.285 secondsMaximum number of seconds to run all queries: 4.285 secondsNumber of clients running queries: 100Average number of queries per client: 10000SET global time_zone='System';
Query OK, 0 rows affected (0.00 sec)mysqlslap -uroot -p --create-schema='learn' --number-of-queries=1000000 --concurrency=100 --query='SELECT NOW()';
Enter password:
BenchmarkAverage number of seconds to run all queries: 4.463 secondsMinimum number of seconds to run all queries: 4.463 secondsMaximum number of seconds to run all queries: 4.463 secondsNumber of clients running queries: 100Average number of queries per client: 10000
可以发现,显式指定时区的性能要远远好于直接使用操作系统时区。所以,日期字段推荐使用 DATETIME,没有时区转化。即便使用 TIMESTAMP,也需要在数据库中显式地配置时区,而不是用系统时区。
1.3.6 实战——表结构设计规范:每条记录都要有一个时间字段
在做表结构设计规范时,强烈建议你每张业务核心表都增加一个 DATETIME 类型的 last_modify_date 字段,并设置修改自动更新机制, 即便标识每条记录最后修改的时间。
例如,在前面的表 User 中的字段 last_modify_date,就是用于表示最后一次的修改时间:
CREATE TABLE User (id BIGINT NOT NULL AUTO_INCREMENT,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),CHECK (sex = 'M' OR sex = 'F'),PRIMARY KEY(id)
);
通过字段 last_modify_date 定义的 ON UPDATE CURRENT_TIMESTAMP(6),那么每次这条记录,则都会自动更新 last_modify_date 为当前时间。
这样设计的好处是: 用户可以知道每个用户最近一次记录更新的时间,以便做后续的处理。比如在电商的订单表中,可以方便对支付超时的订单做处理;在金融业务中,可以根据用户资金最后的修改时间做相应的资金核对等。
在后面的内容中,我们也会谈到 MySQL 数据库的主从逻辑数据核对的设计实现,也会利用到last_modify_date 字段。
1.3.7 总结
日期类型通常就是使用 DATETIME 和 TIMESTAMP 两种类型,然而由于类型 TIMESTAMP 存在性能问题,建议你还是尽可能使用类型 DATETIME。总结一下今天的重点内容:
-
MySQL 5.6 版本开始 DATETIME 和 TIMESTAMP 精度支持到毫秒;
-
DATETIME 占用 8 个字节,TIMESTAMP 占用 4 个字节,DATETIME(6) 依然占用 8 个字节,TIMESTAMP(6) 占用 7 个字节;
-
TIMESTAMP 日期存储的上限为 2038-01-19 03:14:07,业务用 TIMESTAMP 存在风险;
-
使用 TIMESTAMP 必须显式地设置时区,不要使用默认系统时区,否则存在性能问题,推荐在配置文件中设置参数 time_zone = ‘+08:00’;
-
推荐日期类型使用 DATETIME,而不是 TIMESTAMP 和 INT 类型;
-
表结构设计时,每个核心业务表,推荐设计一个 last_modify_date 的字段,用以记录每条记录的最后修改时间。
1.4 非结构存储——JSON
关系型的结构化存储存在一定的弊端,因为它需要预先定义好所有的列以及列对应的类型。但是业务在发展过程中,或许需要扩展单个列的描述功能,这时,如果能用好 JSON 数据类型,那就能打通关系型和非关系型数据的存储之间的界限,为业务提供更好的架构选择。
当然,很多同学在用 JSON 数据类型时会遇到各种各样的问题,其中最容易犯的误区就是将类型 JSON 简单理解成字符串类型。 但当你学完今天的内容之后,会真正认识到 JSON 数据类型的威力,从而在实际工作中更好地存储非结构化的数据。
1.4.1 JSON 数据类型
B站相关视频
JSON(JavaScript Object Notation)主要用于互联网应用服务之间的数据交换。MySQL 支持RFC 7159定义的 JSON 规范,主要有JSON 对象和JSON 数组两种类型。下面就是 JSON 对象,主要用来存储图片的相关信息:
{"Image": {"Width": 800,"Height": 600,"Title": "View from 15th Floor","Thumbnail": {"Url": "","Height": 125,"Width": 100},"IDs": [116, 943, 234, 38793]}
}
从中你可以看到, JSON 类型可以很好地描述数据的相关内容,比如这张图片的宽度、高度、标题等(这里使用到的类型有整型、字符串类型)。
JSON对象除了支持字符串、整型、日期类型,JSON 内嵌的字段也支持数组类型,如上代码中的 IDs 字段。
另一种 JSON 数据类型是数组类型,如:
[{"precision": "zip","Latitude": 37.7668,"Longitude": -122.3959,"Address": "","City": "SAN FRANCISCO","State": "CA","Zip": "94107","Country": "US"},{"precision": "zip","Latitude": 37.371991,"Longitude": -122.026020,"Address": "","City": "SUNNYVALE","State": "CA","Zip": "94085","Country": "US"}]
上面的示例演示的是一个 JSON 数组,其中有 2 个 JSON 对象。
到目前为止,可能很多同学会把 JSON 当作一个很大的字段串类型,从表面上来看,没有错。但本质上,JSON 是一种新的类型,有自己的存储格式,还能在每个对应的字段上创建索引,做特定的优化,这是传统字段串无法实现的。JSON 类型的另一个好处是无须预定义字段,字段可以无限扩展。而传统关系型数据库的列都需预先定义,想要扩展需要执行 ALTER TABLE … ADD COLUMN … 这样比较重的操作。
需要注意是,JSON 类型是从 MySQL 5.7 版本开始支持的功能,而 8.0 版本解决了更新 JSON 的日志性能瓶颈。如果要在生产环境中使用 JSON 数据类型,强烈推荐使用 MySQL 8.0 版本。
1.4.2 实战——用户登录设计
在数据库中,JSON 类型比较适合存储一些修改较少、相对静态的数据,比如用户登录信息的存储如下:
DROP TABLE IF EXISTS UserLogin;CREATE TABLE UserLogin (userId BIGINT NOT NULL,loginInfo JSON,PRIMARY KEY(userId)
);
由于当前业务的登录方式越来越多样化,如同一账户支持手机、微信、QQ 账号登录,所以这里可以用 JSON 类型存储登录的信息。
接着,插入下面的数据:
SET @a = '
{"cellphone" : "13918888888","wxchat" : "破产码农","QQ" : "82946772"
}
';INSERT INTO UserLogin VALUES (1,@a);SET @b = '
{"cellphone" : "15026888888"
}
';INSERT INTO UserLogin VALUES (2,@b);
从上面的例子中可以看到,用户 1 登录有三种方式:手机验证码登录、微信登录、QQ 登录,而用户 2 只有手机验证码登录。
而如果不采用 JSON 数据类型,就要用下面的方式建表:
CREATE TABLE UserLogin (userId BIGINT NOT NULL,cellphone VARCHAR(255),wechat VARCHAR(255)QQ VARCHAR(255),PRIMARY KEY(userId)
);
可以看到,虽然用传统关系型的方式也可以完成相关数据的存储,但是存在两个问题:
-
有些列可能是比较稀疏的,一些列可能大部分都是 NULL 值;
-
如果要新增一种登录类型,如微博登录,则需要添加新列,而 JSON 类型无此烦恼。
因为支持了新的JSON类型,MySQL 配套提供了丰富的 JSON 字段处理函数,用于方便地操作 JSON 数据,具体可以见 MySQL 官方文档。
其中,最常见的就是函数 JSON_EXTRACT,它用来从 JSON 数据中提取所需要的字段内容,如下面的这条 SQL 语句就查询用户的手机和微信信息。
SELECTuserId,JSON_UNQUOTE(JSON_EXTRACT(loginInfo,"$.cellphone")) cellphone,JSON_UNQUOTE(JSON_EXTRACT(loginInfo,"$.wxchat")) wxchat
FROM UserLogin;
+--------+-------------+--------------+
| userId | cellphone | wxchat |
+--------+-------------+--------------+
| 1 | 13918888888 | 破产码农 |
| 2 | 15026888888 | NULL |
+--------+-------------+--------------+
2 rows in set (0.01 sec)
当然了,每次写 JSON_EXTRACT、JSON_UNQUOTE 非常麻烦,MySQL 还提供了 ->> 表达式,和上述 SQL 效果完全一样:
SELECTuserId,loginInfo->>"$.cellphone" cellphone,loginInfo->>"$.wxchat" wxchat
FROM UserLogin;
当 JSON 数据量非常大,用户希望对 JSON 数据进行有效检索时,可以利用 MySQL 的函数索引功能对 JSON 中的某个字段进行索引。
比如在上面的用户登录示例中,假设用户必须绑定唯一手机号,且希望未来能用手机号码进行用户检索时,可以创建下面的索引:
ALTER TABLE UserLogin ADD COLUMN cellphone VARCHAR(255) AS (loginInfo->>"$.cellphone");ALTER TABLE UserLogin ADD UNIQUE INDEX idx_cellphone(cellphone);
上述 SQL 首先创建了一个虚拟列 cellphone,这个列是由函数 loginInfo->>"$.cellphone" 计算得到的。然后在这个虚拟列上创建一个唯一索引 idx_cellphone。这时再通过虚拟列 cellphone 进行查询,就可以看到优化器会使用到新创建的 idx_cellphone 索引:
EXPLAIN SELECT * FROM UserLogin
WHERE cellphone = '13918888888'\G
*************************** 1. row ***************************id: 1select_type: SIMPLEtable: UserLoginpartitions: NULLtype: const
possible_keys: idx_cellphonekey: idx_cellphonekey_len: 1023ref: constrows: 1filtered: 100.00Extra: NULL
1 row in set, 1 warning (0.00 sec)
当然,我们可以在一开始创建表的时候,就完成虚拟列及函数索引的创建。如下表创建的列 cellphone 对应的就是 JSON 中的内容,是个虚拟列;uk_idx_cellphone 就是在虚拟列 cellphone 上所创建的索引。
CREATE TABLE UserLogin (userId BIGINT,loginInfo JSON,cellphone VARCHAR(255) AS (loginInfo->>"$.cellphone"),PRIMARY KEY(userId),UNIQUE KEY uk_idx_cellphone(cellphone)
);
1.4.3 实战——用户画像设计
某些业务需要做用户画像(也就是对用户打标签),然后根据用户的标签,通过数据挖掘技术,进行相应的产品推荐。比如:
-
在电商行业中,根据用户的穿搭喜好,推荐相应的商品;
-
在音乐行业中,根据用户喜欢的音乐风格和常听的歌手,推荐相应的歌曲;
-
在金融行业,根据用户的风险喜好和投资经验,推荐相应的理财产品。
在这,我强烈推荐你用 JSON 类型在数据库中存储用户画像信息,并结合 JSON 数组类型和多值索引的特点进行高效查询。假设有张画像定义表:
CREATE TABLE Tags (tagId BIGINT AUTO_INCREMENT,tagName VARCHAR(255) NOT NULL,PRIMARY KEY(tagId)
);INSERT INTO Tags VALUES(1,'70后');
INSERT INTO Tags VALUES(2,'80后');
INSERT INTO Tags VALUES(3,'90后');
INSERT INTO Tags VALUES(4,'00后');
INSERT INTO Tags VALUES(5,'爱运动');
INSERT INTO Tags VALUES(6,'高学历');
INSERT INTO Tags VALUES(7,'小资');
INSERT INTO Tags VALUES(8,'有房');
INSERT INTO Tags VALUES(9,'有车');
INSERT INTO Tags VALUES(10,'常看电影');
INSERT INTO Tags VALUES(11,'爱网购');
INSERT INTO Tags VALUES(12,'爱外卖');SELECT * FROM Tags;
+-------+--------------+
| tagId | tagName |
+-------+--------------+
| 1 | 70后 |
| 2 | 80后 |
| 3 | 90后 |
| 4 | 00后 |
| 5 | 爱运动 |
| 6 | 高学历 |
| 7 | 小资 |
| 8 | 有房 |
| 9 | 有车 |
| 10 | 常看电影 |
| 11 | 爱网购 |
| 12 | 爱外卖 |
+-------+--------------+
可以看到,表 Tags 是一张画像定义表,用于描述当前定义有多少个标签,接着给每个用户打标签,比如用户 David,他的标签是 80 后、高学历、小资、有房、常看电影;用户 Tom,90 后、常看电影、爱外卖。
若不用 JSON 数据类型进行标签存储,通常会将用户标签通过字符串,加上分割符的方式,在一个字段中存取用户所有的标签:
+-------+---------------------------------------+
|用户 |标签 |
+-------+---------------------------------------+
|David |80后 ; 高学历 ; 小资 ; 有房 ;常看电影 |
|Tom |90后 ;常看电影 ; 爱外卖 |
+-------+---------------------------------------+
这样做的缺点是: 不好搜索特定画像的用户,另外分隔符也是一种自我约定,在数据库中其实可以任意存储其他数据,最终产生脏数据。
用 JSON 数据类型就能很好解决这个问题:
DROP TABLE IF EXISTS UserTag;
CREATE TABLE UserTag (userId bigint NOT NULL,userTags JSON,PRIMARY KEY (userId)
);INSERT INTO UserTag VALUES (1,'[2,6,8,10]');
INSERT INTO UserTag VALUES (2,'[3,10,12]');
其中,userTags 存储的标签就是表 Tags 已定义的那些标签值,只是使用 JSON 数组类型进行存储。
MySQL 8.0.17 版本开始支持 Multi-Valued Indexes,用于在 JSON 数组上创建索引,并通过函数 member of、json_contains、json_overlaps 来快速检索索引数据。所以你可以在表 UserTag 上创建 Multi-Valued Indexes:
ALTER TABLE UserTag
ADD INDEX idx_user_tags ((cast((userTags->"$") as unsigned array)));
如果想要查询用户画像为常看电影的用户,可以使用函数 MEMBER OF(数组用MEMBER OF):
EXPLAIN SELECT * FROM UserTag
WHERE 10 MEMBER OF(userTags->"$")\G
*************************** 1. row ***************************id: 1select_type: SIMPLEtable: UserTagpartitions: NULLtype: ref
possible_keys: idx_user_tagskey: idx_user_tagskey_len: 9ref: constrows: 1filtered: 100.00Extra: Using where
1 row in set, 1 warning (0.00 sec)SELECT * FROM UserTag
WHERE 10 MEMBER OF(userTags->"$");
+--------+---------------+
| userId | userTags |
+--------+---------------+
| 1 | [2, 6, 8, 10] |
| 2 | [3, 10, 12] |
+--------+---------------+
2 rows in set (0.00 sec)
如果想要查询画像为 80 后,且常看电影的用户,可以使用函数 JSON_CONTAINS(JSON用JSON_CONTAINS):
EXPLAIN SELECT * FROM UserTag
WHERE JSON_CONTAINS(userTags->"$", '[2,10]')\G
*************************** 1. row ***************************id: 1select_type: SIMPLEtable: UserTagpartitions: NULLtype: range
possible_keys: idx_user_tagskey: idx_user_tagskey_len: 9ref: NULLrows: 3filtered: 100.00Extra: Using where
1 row in set, 1 warning (0.00 sec)SELECT * FROM UserTag
WHERE JSON_CONTAINS(userTags->"$", '[2,10]');
+--------+---------------+
| userId | userTags |
+--------+---------------+
| 1 | [2, 6, 8, 10] |
+--------+---------------+
1 row in set (0.00 sec)
如果想要查询画像为 80 后、90 后,且常看电影的用户,则可以使用函数 JSON_OVERLAP:
EXPLAIN SELECT * FROM UserTag
WHERE JSON_OVERLAPS(userTags->"$", '[2,3,10]')\G
*************************** 1. row ***************************id: 1select_type: SIMPLEtable: UserTagpartitions: NULLtype: range
possible_keys: idx_user_tagskey: idx_user_tagskey_len: 9ref: NULLrows: 4filtered: 100.00Extra: Using where
1 row in set, 1 warning (0.00 sec)SELECT * FROM UserTag
WHERE JSON_OVERLAPS(userTags->"$", '[2,3,10]');
+--------+---------------+
| userId | userTags |
+--------+---------------+
| 1 | [2, 6, 8, 10] |
| 2 | [3, 10, 12] |
+--------+---------------+
2 rows in set (0.01 sec)
1.4.4 总结
JSON 类型是 MySQL 5.7 版本新增的数据类型,用好 JSON 数据类型可以有效解决很多业务中实际问题。最后,我总结下今天的重点内容:
-
使用 JSON 数据类型,推荐用 MySQL 8.0.17 以上的版本,性能更好,同时也支持 Multi-Valued Indexes;
-
JSON 数据类型的好处是无须预先定义列,数据本身就具有很好的描述性;
-
不要将有明显关系型的数据用 JSON 存储,如用户余额、用户姓名、用户身份证等,这些都是每个用户必须包含的数据;
-
JSON 数据类型推荐使用在不经常更新的静态数据存储。
1.5 表结构设计
我们在对一张表进行设计时,还要遵守一些基本的原则,比如你经常听见的“范式准则”。但范式准则过于理论,在真实业务中,你不必严格遵守三范式的要求。而且有时为了性能考虑,你还可以进行反范式的设计,比如在数据仓库领域。这一讲我就会带你了解这些内容,希望你学完这一讲之后,能从更高一层的维度来看待 MySQL 数据库的表结构设计。
1.5.1 忘记范式准则
忘记之前,我们先简单回顾一下三范式:
- 第一范式:确保每列的原子性(强调的是列的原子性,即列不能够再分成其他几列)。反例:地址列可以继续拆分成国家、省份、城市、区县等列。
- 第二范式:在第一范式的基础上更进一层,目标是确保表中的每列都和主键相关(一是表必须有一个主键;二是没有包含在主键中的列必须完全依赖于主键,而不能只依赖于主键的部分)。反例:主键为联合主键,而表中其他列只依赖于联合主键中的一个。
- 第三范式:在第二范式的基础上更进一层,目标是确保每列都和主键列直接相关,而不是间接相关(另外非主键列必须直接依赖于主键,不能存在传递依赖)。反例:B列依赖于主键列,C列依赖于B列,此时C列间接依赖于主键列。
范式设计是非常重要的理论,是通过数学集合概念来推导范式的过程,在理论上,要求表结构设计必须至少满足三范式的要求。
由于完全是数据推导过程,范式理论非常枯燥,但你只要记住几个要点就能抓住其中的精髓:
-
一范式要求所有属性都是不可分的基本数据项;
-
二范式解决部分依赖;
-
三范式解决传递依赖。
虽然我已经提炼了范式设计的精髓,但要想真正理解范式设计,就要抛弃纯理论的范式设计准则,从业务角度出发,设计出符合范式准则要求的表结构。
1.5.2 自增主键设计
主键用于唯一标识一行数据,所以一张表有主键,就已经直接满足一范式的要求了。在 01 讲的整型类型中,我提及可以使用 BIGINT 的自增类型作为主键,同时由于整型的自增性,数据库插入也是顺序的,性能较好。
但你要注意,使用 BIGINT 的自增类型作为主键的设计仅仅适合非核心业务表,比如告警表、日志表等。真正的核心业务表,一定不要用自增键做主键,主要有 6 个原因:
-
自增存在回溯问题;
-
自增值在服务器端产生,存在并发性能问题;
-
自增值做主键,只能在当前实例中保证唯一,不能保证全局唯一;
-
公开数据值,容易引发安全问题,例如知道地址/,很容猜出 User 有 11、12 依次类推的值,容易引发数据泄露;
-
MGR(MySQL Group Replication) 可能引起的性能问题;
-
分布式架构设计问题。
自增存在回溯问题,我在 01 讲中已经讲到,如果你想让核心业务表用自增作为主键,MySQL 数据库版本应该尽可能升级到 8.0 版本。回溯原因:在 MySQL 5.7 中的表的AUTO_INCREMENT是基于内存,不会持久化在磁盘中,每次启动数据库时,会对每张表进行max(auto_increment) + 1重新作为该表下一次的主键ID的自增值。在MySQL8.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 做主键或业务自定义生成主键。
1.5.3 UUID主键设计
UUID(Universally 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 00:00:00.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 INVOKERRETURNUNHEX(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 INVOKERRETURNLCASE(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 相同的数据,这个工作是相当巨大且容易出错的。
1.5.4 业务自定义生成主键
当然了,UUID 虽好,但是在分布式数据库场景下,主键还需要加入一些额外的信息,这样才能保证后续二级索引的查询效率(具体这部分内容将在后面的分布式章节中进行介绍)。现在你只需要牢记:分布式数据库架构,仅用 UUID 做主键依然是不够的。 所以,对于分布式架构的核心业务表,我推荐类似如下的设计,比如:
PK = 时间字段 + 随机码(可选) + 业务信息1 + 业务信息2 ......
电商业务中,订单表是其最为核心的表之一,你可以先打开淘宝 App,查询下自己的订单号,可以查到类似如下的订单信息:
上图是我自己的淘宝订单信息(第一个订单的订单号为1550672064762308113)。
订单号显然是订单表的主键,但如果你以为订单号是自增整型,那就大错特错了。因为如果你仔细观察的话,可以发现图中所有订单号的后 6 位都是相同的,都为308113:
1550672064762308113
1481195847180308113
1431156171142308113
1431146631521308113
所以,我认为淘宝订单号的最后 6 位是用户 ID 相关信息,前 14 位是时间相关字段,这样能保证插入的自增性,又能同时保留业务的相关信息作为后期的分布式查询。
1.5.5 消除冗余
消除冗余也是范式的要求,解决部分依赖和传递依赖,本质就是尽可能减少冗余数据。
所以,在进行表结构设计时,数据只需存放在一个地方,其他表要使用,通过主键关联存储即可。比如订单表中需要存放订单对应的用户信息,则保存用户 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主键设计、业务自定义生成主键、还是消除冗余,本质上都是遵循了范式准则。但是在一些其他业务场景下,也存在反范式设计的情况。
1.5.6 反范式设计
通常我们会在 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] |
+--------+---------------
1.5.7总结
总的来说,范式是偏数据库理论范畴的表结构设计准则,在实际的工程实践上没有必要严格遵循三范式要求,在 MySQL 海量并发的工程实践上,表结构设计应遵循这样几个规范:
-
每张表一定要有一个主键;
-
自增主键只推荐用在非核心业务表,甚至应避免使用;
-
核心业务表推荐使用 UUID 或业务自定义主键;
-
一份数据应尽可能保留一份,通过主键关联进行查询,避免冗余数据;
-
在一些场景下,可以通过 JSON 数据类型进行反范式设计,提升存储效率;
1.6 表压缩
很多同学不会在表结构设计之初就考虑存储的设计,只有当业务发展到一定规模才会意识到问题的严重性。而物理存储主要是考虑是否要启用表的压缩功能,默认情况下,所有表都是非压缩的。
但一些同学一听到压缩,总会下意识地认为压缩会导致 MySQL 数据库的性能下降。这个观点说对也不对,需要根据不同场景进行区分。 这一讲,我们就来看一看表的物理存储设计:不同场景下,表压缩功能的使用。
1.6.1 表压缩
数据库中的表是由一行行记录(rows)所组成,每行记录被存储在一个页中,在 MySQL 中,一个页的大小默认为 16K,一个个页又组成了每张表的表空间。
通常我们认为,如果一个页中存放的记录数越多,数据库的性能越高。这是因为数据库表空间中的页是存放在磁盘上,MySQL 数据库先要将磁盘中的页读取到内存缓冲池,然后以页为单位来读取和管理记录。
一个页中存放的记录越多,内存中能存放的记录数也就越多,那么存取效率也就越高。若想将一个页中存放的记录数变多,可以启用压缩功能。此外,启用压缩后,存储空间占用也变小了,同样单位的存储能存放的数据也变多了。
若要启用压缩技术,数据库可以根据记录、页、表空间进行压缩,不过在实际工程中,我们普遍使用页压缩技术,这是为什么呢?
-
压缩每条记录: 因为每次读写都要压缩和解压,过于依赖 CPU 的计算能力,性能会明显下降;另外,因为单条记录大小不会特别大,一般小于 1K,压缩效率也并不会特别好。
-
压缩表空间: 压缩效率非常不错,但要求表空间文件静态不增长,这对基于磁盘的关系型数据库来说,很难实现。
而基于页的压缩,既能提升压缩效率,又能在性能之间取得一种平衡。
可能很多同学认为,启用表的页压缩功能后,性能有明显损失,因为压缩需要有额外的开销。的确,压缩需要消耗额外的 CPU 指令,但是压缩并不意味着性能下降,或许能额外提升性能,因为大部分的数据库业务系统,CPU 的处理能力是剩余的,而 I/O 负载才是数据库主要瓶颈。
借助页压缩技术,MySQL 可以把一个 16K 的页压缩为 8K,甚至 4K,这样在从磁盘写入或读取时,就能将 I/O 请求大小减半,甚至更小,从而提升数据库的整体性能。
当然,压缩是一种平衡,并非一定能提升数据库的性能。这种性能“平衡”取决于解压缩开销带来的收益和解压缩带来的开销之间的一种权衡。但无论如何,压缩都可以有效整理数据原本的容量,对存储空间来说,压缩的收益是巨大的。
1.6.2 MySQL 压缩表设计——COMPRESS 页压缩
COMPRESS 页压缩是 MySQL 5.7 版本之前提供的页压缩功能。只要在创建表时指定ROW_FORMAT=COMPRESS,并通过选项 KEY_BLOCK_SIZE 设置压缩的比例。
需要牢记的是, 虽然是通过选项 ROW_FORMAT 启用压缩功能,但这并不是记录级压缩,依然是根据页的维度进行压缩。
下面这是一张日志表,ROW_FROMAT 设置为 COMPRESS,表示启用 COMPRESS 页压缩功能,KEY_BLOCK_SIZE 设置为 8,表示将一个 16K 的页压缩为 8K。
CREATE TABLE Log (logId BINARY(16) PRIMARY KEY,......
)
ROW_FORMAT=COMPRESSED
KEY_BLOCK_SIZE=8
COMPRESS 页压缩就是将一个页压缩到指定大小。如 16K 的页压缩到 8K,若一个 16K 的页无法压缩到 8K,则会产生 2 个压缩后的 8K 页,具体如下图所示:
总的来说,COMPRESS 页压缩,适合用于一些对性能不敏感的业务表,例如日志表、监控表、告警表等,压缩比例通常能达到 50% 左右。
虽然 COMPRESS 压缩可以有效减小存储空间,但 COMPRESS 页压缩的实现对性能的开销是巨大的,性能会有明显退化。主要原因是一个压缩页在内存缓冲池中,存在压缩和解压两个页。
如图所示,Page1 和 Page2 都是压缩页 8K,但是在内存中还有其解压后的 16K 页。这样设计的原因是 8K 的页用于后续页的更新,16K 的页用于读取,这样读取就不用每次做解压操作了。
很明显,这样的实现会增加对内存的开销,会导致缓存池能存放的有效数据变少,MySQL 数据库的性能自然出现明显退化。
为了 解决压缩性能下降的问题,从MySQL 5.7 版本开始推出了 TPC 压缩功能。
1.6.3 MySQL 压缩表设计——TPC 压缩
TPC(Transparent Page Compression)是 5.7 版本推出的一种新的页压缩功能,其利用文件系统的空洞(Punch Hole)特性进行压缩。可以使用下面的命令创建 TPC 压缩表:
CREATE TABLE Transaction (transactionId BINARY(16) PRIMARY KEY,.....
)
COMPRESSION=ZLIB | LZ4 | NONE;
要使用 TPC 压缩,首先要确认当前的操作系统是否支持空洞特性。通常来说,当前常见的 Linux 操作系统都已支持空洞特性。
由于空洞是文件系统的一个特性,利用空洞压缩只能压缩到文件系统的最小单位 4K,且其页压缩是 4K 对齐的。比如一个 16K 的页,压缩后为 7K,则实际占用空间 8K;压缩后为 3K,则实际占用空间是 4K;若压缩后是 13K,则占用空间依然为 16K。
TPC 压缩的具体实现如下所示:
上图可以看到,一个 16K 的页压缩后是 8K,接着数据库会对这 16K 的页剩余的 8K 填充0x00,这样当这个 16K 的页写入到磁盘时,利用文件系统空洞特性,则实际将仅占用 8K 的物理存储空间。
空洞压缩的另一个好处是,它对数据库性能的侵入几乎是无影响的(小于 20%),甚至可能还能有性能的提升。
这是因为不同于 COMPRESS 页压缩,TPC 压缩在内存中只有一个 16K 的解压缩后的页,对于缓冲池没有额外的存储开销。
另一方面,所有页的读写操作都和非压缩页一样,没有开销,只有当这个页需要刷新到磁盘时,才会触发页压缩功能一次。但由于一个 16K 的页被压缩为了 8K 或 4K,其实写入性能会得到一定的提升。
上图是 MySQL 官方的 LinkBench 测试结果,可以看到,无压缩的测试结果为 13,432 QPS,传统的 COMPRESS 页压缩性能下降为 10,480 QPS,差不多30%的性能下降。基于TPC压缩的测试结果为 18,882,在未压缩的基础上还能有额外 40% 的性能提升。
1.6.4 实战-表压缩在业务上的使用
总的来说,对一些对性能不敏感的业务表,例如日志表、监控表、告警表等,它们只对存储空间有要求,因此可以使用 COMPRESS 页压缩功能。
在一些较为核心的流水业务表上,我更推荐使用 TPC压缩。因为流水信息是一种非常核心的数据存储业务,通常伴随核心业务。如一笔电商交易,用户扣钱、下单、记流水,这就是一个核心业务的微模型。
所以,用户对流水表有性能需求。此外,流水又非常大,启用压缩功能可更为有效地存储数据。
若对压缩产生的性能抖动有所担心,我的建议:由于流水表通常是按月或天进行存储,对当前正在使用的流水表不要启用 TPC 功能,对已经成为历史的流水表启用 TPC 压缩功能,如下所示:
需要特别注意的是: 通过命令 ALTER TABLE xxx COMPRESSION = ZLIB 可以启用 TPC 页压缩功能,但是这只对后续新增的数据会进行压缩,对于原有的数据则不进行压缩。所以上述ALTER TABLE 操作只是修改元数据,瞬间就能完成。
若想要对整个表进行压缩,需要执行 OPTIMIZE TABLE 命令:
ALTER TABLE Transaction202102 COMPRESSION=ZLIB;
OPTIMIZE TABLE Transaction202102;
1.6.5 总结
在进行表结构设计时,除了进行列的选择外,还需要考虑存储的设计,特别是对于表的压缩功能的设计,总结来说:
-
MySQL 中的压缩都是基于页的压缩;
-
COMPRESS 页压缩适合用于性能要求不高的业务表,如日志、监控、告警表等;
-
COMPRESS 页压缩内存缓冲池存在压缩和解压的两个页,会严重影响性能;
-
对存储有压缩需求,又希望性能不要有明显退化,推荐使用 TPC 压缩;
-
通过 ALTER TABLE 启用 TPC 压缩后,还需要执行命令 OPTIMIZE TABLE 才能立即完成空间的压缩。
1.7 表的访问设计
2 索引
2.1 索引介绍
在模块一中,我们学习了怎么根据合适的类型正确地创建一张表,但创建的表不能立刻用在真正的业务系统上。因为表结构设计只是设计数据库最初的环节之一,我们还缺少数据库设计中最为重要的一个环节——索引设计,只有正确设计索引,业务才能达到上线的初步标准。
所以模块二我会讲索引的设计、业务应用与调优等案例。今天我们先来学习关系型数据库最核心的概念——索引,对索引做一个初步的概述,让你对数据库中的索引有一个体系的认知,并用好 B+ 树索引。
2.1.1 索引是什么
相信你在面试时,通常会被问到“什么是索引?”而你一定要能脱口而出:索引是提升查询速度的一种数据结构。
索引之所以能提升查询速度,在于它在插入时对数据进行了排序(显而易见,它的缺点是影响插入或者更新的性能)。
所以,索引是一门排序的艺术,有效地设计并创建索引,会提升数据库系统的整体性能。在目前的 MySQL 8.0 版本中,InnoDB 存储引擎支持的索引有 B+ 树索引、全文索引、R 树索引。这一讲我们就先关注使用最为广泛的 B+ 树索引。
2.1.2 B+树索引结构
B+ 树索引是数据库系统中最为常见的一种索引数据结构,几乎所有的关系型数据库都支持它。
那为什么关系型数据库都热衷支持 B+树索引呢?因为它是目前为止排序最有效率的数据结构。像二叉树,哈希索引、红黑树、SkipList,在海量数据基于磁盘存储效率方面远不如 B+ 树索引高效。
所以,上述的数据结构一般仅用于内存对象,基于磁盘的数据排序与存储,最有效的依然是 B+ 树索引。
B+树索引的特点是: 基于磁盘的平衡二叉树,但树非常矮,通常为 3~4 层,能存放千万到上亿的排序数据。树矮意味着访问效率高,从千万或上亿数据里查询一条数据,只用 3、4 次 I/O。
又因为现在的固态硬盘每秒能执行至少 10000 次 I/O ,所以查询一条数据,哪怕全部在磁盘上,也只需要 0.003 ~ 0.004 秒。另外,因为 B+ 树矮,在做排序时,也只需要比较 3~4 次就能定位数据需要插入的位置,排序效率非常不错。
B+ 树索引由根节点(root node)、中间节点(non leaf node)、叶子节点(leaf node)组成,其中叶子节点存放所有排序后的数据。当然也存在一种比较特殊的情况,比如高度为 1 的B+ 树索引:
上图中,第一个列就是 B+ 树索引排序的列,你可以理解它是表 User 中的列 id,类型为 8 字节的 BIGINT,所以列 userId 就是索引键(key),类似下表:
CREATE TABLE User (id BIGINT AUTO_INCREMENT PRIMARY KEY,name VARCHAR(128) NOT NULL,sex CHAR(6) NOT NULL,registerDate DATETIME NOT NULL,...
)
所有 B+ 树都是从高度为 1 的树开始,然后根据数据的插入,慢慢增加树的高度。你要牢记:索引是对记录进行排序, 高度为 1 的 B+ 树索引中,存放的记录都已经排序好了,若要在一个叶子节点内再进行查询,只进行二叉查找,就能快速定位数据。
可随着插入 B+ 树索引的记录变多,1个页(16K)无法存放这么多数据,所以会发生 B+ 树的分裂,B+ 树的高度变为 2,当 B+ 树的高度大于等于 2 时,根节点和中间节点存放的是索引键对,由(索引键、指针)组成。
索引键就是排序的列,而指针是指向下一层的地址,在 MySQL 的 InnoDB 存储引擎中占用 6 个字节。下图显示了 B+ 树高度为 2 时,B+ 树索引的样子:
可以看到,在上面的B+树索引中,若要查询索引键值为 5 的记录,则首先查找根节点,查到键值对(20,地址),这表示小于 20 的记录在地址指向的下一层叶子节点中。接着根据下一层地址就可以找到最左边的叶子节点,在叶子节点中根据二叉查找就能找到索引键值为 5 的记录。
那一个高度为 2 的 B+ 树索引,理论上最多能存放多少行记录呢?
在 MySQL InnoDB 存储引擎中,一个页的大小为 16K,在上面的表 User 中,键值 userId 是BIGINT 类型,则:
根节点能最多存放以下多个键值对 = 16K / 键值对大小(8+6) ≈ 1100
再假设表 User 中,每条记录的大小为 500 字节,则:
叶子节点能存放的最多记录为 = 16K / 每条记录大小 ≈ 32
综上所述,树高度为 2 的 B+ 树索引,最多能存放的记录数为:
总记录数 = 1100 * 32 = 35,200
也就是说,35200 条记录排序后,生成的 B+ 树索引高度为 2。在 35200 条记录中根据索引键查询一条记录只需要查询 2 个页,一个根叶,一个叶子节点,就能定位到记录所在的页。
高度为 3 的 B+ 树索引本质上与高度 2 的索引一致,如下图所示,不再赘述:
同理,树高度为 3 的 B+ 树索引,最多能存放的记录数为:
总记录数 = 1100(根节点) * 1100(中间节点) * 32 = 38,720,000
讲到这儿,你会发现,高度为 3 的 B+ 树索引竟然能存放 3800W 条记录。在 3800W 条记录中定位一条记录,只需要查询 3 个页。那么 B+ 树索引的优势是否逐步体现出来了呢?
不过,在真实环境中,每个页其实利用率并没有这么高,还会存在一些碎片的情况,我们假设每个页的使用率为60%,则:
表格显示了 B+ 树的威力,即在 50 多亿的数据中,根据索引键值查询记录,只需要 4 次 I/O,大概仅需 0.004 秒。如果这些查询的页已经被缓存在内存缓冲池中,查询性能会更快。
在数据库中,上述的索引查询请求对应的 SQL 语句为:
SELECT * FROM User WHERE id = ?
用户可以通过命令 EXPLAIN 查看是否使用索引:
mysql> EXPLAIN SELECT * FROM User WHERE id = 1\G
********************** 1. row **********************id: 1select_type: SIMPLEtable: Userpartitions: NULLtype: const
possible_keys: PRIMARYkey: PRIMARYkey_len: 8ref: constrows: 1filtered: 100.00Extra: NULL
在输出的 EXPLIAN 结果中,可以看到列 key 显示 PRIMARY,这表示根据主键索引进行查询。若没有根据索引进行查询,如根据性别进行查询,则会显示类似如下内容:
mysql> EXPLAIN SELECT * FROM User WHERE sex = 'male'\G
********************** 1. row **********************id: 1select_type: SIMPLEtable: Userpartitions: NULLtype: ALL
possible_keys: NULLkey: NULLkey_len: NULLref: NULLrows: 986400filtered: 50.00Extra: Using where
讲到这儿,你应该了解了 B+ 树索引的组织形式,以及为什么在上亿的数据中可以通过B+树索引快速定位查询的记录。但 B+ 树的查询高效是要付出代价的,就是我们前面说的插入性能问题,接下去咱们就来讨论一下。
2.1.3 优化 B+ 树索引的插入性能
B+ 树在插入时就对要对数据进行排序,但排序的开销其实并没有你想象得那么大,因为排序是 CPU 操作(当前一个时钟周期 CPU 能处理上亿指令)。
真正的开销在于 B+ 树索引的维护,保证数据排序,这里存在两种不同数据类型的插入情况。
-
数据顺序(或逆序)插入: 这类数据插入时,B+ 树索引的维护代价非常小。叶子节点都是从左往右进行插入,比较典型的是自增 ID 的插入、时间的插入(若在自增 ID 上创建索引,时间列上创建索引,则 B+ 树插入通常是比较快的)。
-
数据无序插入: B+ 树为了维护排序,需要对页进行分裂、旋转等开销较大的操作,另外,即便对于固态硬盘,随机写的性能也不如顺序写,所以磁盘性能也会收到较大影响。比较典型的是用户昵称,每个用户注册时,昵称是随意取的,若在昵称上创建索引,插入是无序的,索引维护需要的开销会比较大。
你不可能要求所有插入的数据都是有序的,因为索引的本身就是用于数据的排序,插入数据都已经是排序的,那么你就不需要 B+ 树索引进行数据查询了。
所以对于 B+ 树索引,在 MySQL 数据库设计中,仅要求主键的索引设计为顺序,比如使用自增,或使用函数 UUID_TO_BIN 排序的 UUID,而不用无序值做主键。
我们再回顾 05 讲的自增、UUID、UUID 排序的插入性能对比:
可以看到,UUID 由于是无序值,所以在插入时性能比起顺序值自增 ID 和排序 UUID,性能上差距比较明显。
所以,我再次强调: 在表结构设计时,主键的设计一定要尽可能地使用顺序值,这样才能保证在海量并发业务场景下的性能。
以上就是索引查询和插入的知识,接下来我们就分析怎么在 MySQL 数据库中查看 B+ 树索引。
2.1.4 MySQL 中 B+ 树索引的设计与管理
在 MySQL 数据库中,可以通过查询表 mysql.innodb_index_stats 查看每个索引的大致情况:
SELECT
table_name,index_name,stat_name,
stat_value,stat_description
FROM innodb_index_stats
WHERE table_name = 'orders' and index_name = 'PRIMARY';+----------+------------+-----------+------------+------------------+
|table_name| index_name | stat_name | stat_value |stat_description |
+----------+-------------------+------------+------------+----------+
| orders | PRIMARY|n_diff_pfx01|5778522 | O_ORDERKEY |
| orders | PRIMARY|n_leaf_pages|48867 | Number of leaf pages |
| orders | PRIMARY|size |49024 | Number of pages in the index|
+--------+--------+------------+------+-----------------------------+
3 rows in set (0.00 sec)
从上面的结果中可以看到,表 orders 中的主键索引,大约有 5778522 条记录,其中叶子节点一共有 48867 个页,索引所有页的数量为 49024。根据上面的介绍,你可以推理出非叶节点的数量为 49024-48867,等于 157 个页。
另外,我看见网上一些所谓的 MySQL“军规”中写道“一张表的索引不能超过 5 个”。根本没有这样的说法,完全是无稽之谈。
在我看来,如果业务的确需要很多不同维度进行查询,那么就该创建对应多索引,这是没有任何值得商讨的地方。
真正在业务上遇到的问题是: 由于业务开发同学对数据库不熟悉,创建 N 多索引,但实际这些索引从创建之初到现在根本就没有使用过!因为优化器并不会选择这些低效的索引,这些无效索引占用了空间,又影响了插入的性能。
那你怎么知道哪些 B+树索引未被使用过呢?在 MySQL 数据库中,可以通过查询表sys.schema_unused_indexes,查看有哪些索引一直未被使用过,可以被废弃:
SELECT * FROM schema_unused_indexes
WHERE object_schema != 'performance_schema';+---------------+-------------+--------------+
| object_schema | object_name | index_name |
+---------------+-------------+--------------+
| sbtest | sbtest1 | k_1 |
| sbtest | sbtest2 | k_2 |
| sbtest | sbtest3 | k_3 |
| sbtest | sbtest4 | k_4 |
| tpch | customer | CUSTOMER_FK1 |
| tpch | lineitem | LINEITEM_FK2 |
| tpch | nation | NATION_FK1 |
| tpch | orders | ORDERS_FK1 |
| tpch | partsupp | PARTSUPP_FK1 |
| tpch | supplier | SUPPLIER_FK1 |
+---------------+-------------+--------------+
如果数据库运行时间比较长,而且索引的创建时间也比较久,索引还出现在上述结果中,DBA 就可以考虑删除这些没有用的索引。
而 MySQL 8.0 版本推出了索引不可见(Invisible)功能。在删除废弃索引前,用户可以将索引设置为对优化器不可见,然后观察业务是否有影响。若无,DBA 可以更安心地删除这些索引:
ALTER TABLE t1
ALTER INDEX idx_name INVISIBLE/VISIBLE;
2.1.5 总结
这一讲我对索引做了一个较为初步地概述,学完这一讲,我相信你能非常清晰地知道:
-
索引是加快查询的一种数据结构,其原理是插入时对数据排序,缺点是会影响插入的性能;
-
MySQL 当前支持 B+树索引、全文索引、R 树索引;
-
B+ 树索引的高度通常为 3~4 层,高度为 4 的 B+ 树能存放 50 亿左右的数据;
-
由于 B+ 树的高度不高,查询效率极高,50 亿的数据也只需要插叙 4 次 I/O;
-
MySQL 单表的索引没有个数限制,业务查询有具体需要,创建即可,不要迷信个数限制;
-
可以通过表 sys.schema_unused_indexes 和索引不可见特性,删除无用的索引。
总的来讲,关于索引虽然老生常谈,但是它是所有关系型数据库的核心,我希望你反复阅读本文,真正理解 B+ 树索引的实现。
2.2 索引组织表
上一讲,我已经带你了解了 B+ 树索引的基本概念,以及 MySQL 中怎么对 B+ 树索引进行基本的管理。为了让你进一步深入了解 MySQL 的 B+ 树索引的具体使用,这一讲我想和你聊一聊 MySQL InnoDB 存储引擎的索引结构。
InnoDB 存储引擎是 MySQL 数据库中使用最为广泛的引擎,在海量大并发的 OLTP 业务中,InnoDB 必选。它在数据存储方面有一个非常大的特点:索引组织表(Index Organized Table)。
接下来我就带你了解最为核心的概念:索引组织表。希望你学完今天的内容之后能理解 MySQL 是怎么存储数据和索引对象的。
2.2.1 索引组织表
数据存储有堆表和索引组织表两种方式。
堆表中的数据无序存放, 数据的排序完全依赖于索引(Oracle、Microsoft SQL Server、PostgreSQL 早期默认支持的数据存储都是堆表结构)。
从图中你能看到,堆表的组织结构中,数据和索引分开存储。索引是排序后的数据,而堆表中的数据是无序的,索引的叶子节点存放了数据在堆表中的地址,当堆表的数据发生改变,且位置发生了变更,所有索引中的地址都要更新,这非常影响性能,特别是对于 OLTP 业务。
参考
姜承尧的MySQL实战宝典