《姜承尧的MySQL实战宝典》学习笔记


1 表结构设计


1.1 数字类型


1.1.1 整形类型

MySQL数据库支持的整型类型及其占用空间、取值范围等如下图所示
整型类型
注意:数据库设计过程中不用刻意用unsigned类型,因为有时候做数据分析时可能需要应用到非等值连接,如下列SQL语句:

SELECT
    s1.sale_date, s2.sale_count - s1.sale_count AS diff
FROM
    sale s1
        LEFT JOIN
    sale 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。
第二个特别要注意的问题是,(敲黑板 2MySQL 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 有以下原因:

  1. DECIMAL 类型是个变长字段,定义金额时,不好确定位数,且存储性能较差;
  2. 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 字符集

  1. 推荐把 MySQL 的默认字符集设置为 UTF8MB4(可以用于存储 emoji 等扩展字符 )
    在这里插入图片描述
  2. 从底层存储内核看,在多字节字符集下(GBK中1字符=2字节,UTF8MB4中1字符=4字节),CHAR 和 VARCHAR 底层的实现完全相同,都是变长存储!
    在这里插入图片描述
  3. 鉴于目前默认字符集推荐设置为 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: 0

mysql> 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 实战——用户性别设计

  1. 不建议使用tinyint表示用户性别,原因如下:
  • 表达不清:在具体存储时,0 表示女,还是 1 表示女呢?每个业务可能有不同的规则;
  • 脏数据:因为是 tinyint,因此除了 0 和 1,用户完全可以插入 2、3、4 这样的数值,最终表中存在无效数据的可能,后期再进行清理,代价就非常大了。
  1. 在 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
  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: 0

mysql> 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()';
Benchmark
	Average number of seconds to run all queries: 4.285 seconds
	Minimum number of seconds to run all queries: 4.285 seconds
	Maximum number of seconds to run all queries: 4.285 seconds
	Number of clients running queries: 100
	Average number of queries per client: 10000

SET 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: 
Benchmark
	Average number of seconds to run all queries: 4.463 seconds
	Minimum number of seconds to run all queries: 4.463 seconds
	Maximum number of seconds to run all queries: 4.463 seconds
	Number of clients running queries: 100
	Average 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": "http://www.example.com/image/481989943",
     "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 语句就查询用户的手机和微信信息。

SELECT
    userId,
    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 效果完全一样:

SELECT
    userId,
    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: 1
  select_type: SIMPLE
        table: UserLogin
   partitions: NULL
         type: const
possible_keys: idx_cellphone
          key: idx_cellphone
      key_len: 1023
          ref: const
         rows: 1
     filtered: 100.00
        Extra: 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: 1
  select_type: SIMPLE
        table: UserTag
   partitions: NULL
         type: ref
possible_keys: idx_user_tags
          key: idx_user_tags
      key_len: 9
          ref: const
         rows: 1
     filtered: 100.00
        Extra: 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: 1
  select_type: SIMPLE
        table: UserTag
   partitions: NULL
         type: range
possible_keys: idx_user_tags
          key: idx_user_tags
      key_len: 9
          ref: NULL
         rows: 3
     filtered: 100.00
        Extra: 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: 1
  select_type: SIMPLE
        table: UserTag
   partitions: NULL
         type: range
possible_keys: idx_user_tags
          key: idx_user_tags
      key_len: 9
          ref: NULL
         rows: 4
     filtered: 100.00
        Extra: 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 个原因:

  • 自增存在回溯问题;

  • 自增值在服务器端产生,存在并发性能问题;

  • 自增值做主键,只能在当前实例中保证唯一,不能保证全局唯一

  • 公开数据值,容易引发安全问题,例如知道地址http://www.example.com/User/10/,很容猜出 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 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 相同的数据,这个工作是相当巨大且容易出错的。


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: 1
  select_type: SIMPLE
        table: User
   partitions: NULL
         type: const
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 8
          ref: const
         rows: 1
     filtered: 100.00
        Extra: NULL

在输出的 EXPLIAN 结果中,可以看到列 key 显示 PRIMARY,这表示根据主键索引进行查询。若没有根据索引进行查询,如根据性别进行查询,则会显示类似如下内容:

mysql> EXPLAIN SELECT * FROM User WHERE sex = 'male'\G
********************** 1. row **********************
           id: 1
  select_type: SIMPLE
        table: User
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 986400
     filtered: 50.00
        Extra: 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实战宝典

  • 5
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
nside君的MySQL网络培训班课程特点: 业界最权威的MySQL数据库培训师姜承尧老师(也就是Inside君本人啦)亲授.姜承尧老师出版了《MySQL技术内幕:InnoDB存储引擎》、《MySQL内核:InnoDB存储引擎》等Mysql书籍。 课程紧密结合互联网公司实践,学员能够领略到BAT、网易等大公司的数据库架构与应用案例 课纲结合最新的MySQL 5.6、5.7版本,使得学员学到的都是最新的内容 充分掌握课程内容的学员年薪至少在25W起,第1期的学员已经证明了培训的价值 优秀学员可以获得姜老师的BAT等大型互联网公司的内推 面试技巧与简历模板(新增),帮助学员拿到更好的offer MySQL 安装与引擎 day001-MySQL 5.7介绍和安装 day002-MySQL 5.7安装多实例 day003-MySQL升级 参数 连接 权限 day004-MySQL权限拾 遗Role模拟 Workbench 体系结构 day005-slow_log generic_log audit 存储引擎一 day006-存储引擎二 多实例安装上 day007-MySQL 多实例下 SSL MySQL 数据类型和SQL查询 开发 day008-MySQL 数据类型 day009-精通JSON类型 day010-Employees 临时表的创建 外键约束 day011-SQL语法之SELECT day012-子查询 INSERT UPDATE DELETE REPLACE day013-作业讲解一 Rank 视图 UNION 触发器上 day014-触发器下 存储过程 自定义函数 MySQL 执行计划与优化器 day015-索引 B+树 上 day016-索引 B+树 下 Explain 1 day017-Explain 2 MySQL innodb引擎优化 day018-磁盘 day019-磁盘测试 day020-InnoDB_1 表空间 General day021-InnoDB_2 SpaceID.PageNumber 压缩表) day022-InnoDB_3 透明表空间压缩 索引组织表 day023-InnoDB_4 页(2) 行记录 day024-InnoDB_5 – heap_number Buffer Poo day025-InnoDB_6 Buffer Pool与压缩页 CheckPoint LSN day026-InnoDB_7 doublewrite ChangeBuffer AHI FNP MySQL 索引与innodb锁机制 day027-Secondary Index day028-join算法锁_1 day029-锁_2 day030-锁_3 day031-锁_4 day032-锁_5 day032-锁5标清 day033-锁_6 事物_1 day033-锁_6 事物1标清 day034-事物_2 MySQL 性能衡量 day035-redo_binlog_xa day036-undo_sysbench day036-undosysbench标清 day037-tpcc_mysqlslap MySQL 备份与恢复 day038-purge死锁举例_MySQL backup备份_1 day039-MySQL backup备份恢复_2 MySQL 复制技术与高可用 day040-MySQL 备份恢复backup_3_replication_1 day041-backup_4-replication_2 day042-replication_3 day043-replication_4-GTID 1 day044-replication_5-GTID 2
姜承尧是一位有经验的MySQL数据库管理员,他花了49天的时间学习完整MySQLMySQL是一种广泛应用于各个行业的开源关系型数据库管理系统。学习完整MySQL需要包括学习如何安装、配置和管理MySQL数据库,了解SQL语言和数据库设计原理,以及学习如何使用MySQL进行数据查询、插入和更新等操作。 在学习过程中,姜承尧首先学习MySQL的安装和配置。他熟悉了MySQL的安装步骤,并学会了如何设置数据库的参数和权限。接着,他深入学习MySQL的架构和原理,包括存储引擎、索引、查询优化等方面的知识。他通过阅读官方文档、参考书籍以及在线教程,逐步掌握了这些知识。 随后,姜承尧开始学习SQL语言和数据库设计。他通过编写SQL语句来实践查询、插入、更新和删除等操作,并学习了如何设计合理的数据库结构和模式。他还学会了如何创建表、定义字段、设置约束和索引等。通过不断的练习和实践,他逐渐熟练掌握了SQL语言和数据库设计的技巧。 最后,姜承尧学习了如何使用MySQL工具和命令行界面进行数据库管理。他掌握了常用的命令和工具,如mysqlmysqldump和mysqladmin等,可以利用这些工具进行数据库的备份、还原和监控等操作。 总的来说,姜承尧通过49天的时间学习完整MySQL,掌握了MySQL的安装和配置、SQL语言和数据库设计、以及MySQL工具和命令行界面的使用等方面的知识。他通过不断的学习和实践,为以后的数据库管理工作打下了坚实的基础。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值