ITPub博客

首页 > 数据库 > 数据库开发技术 > MySQL性能优化(第一部分)

MySQL性能优化(第一部分)

原创 数据库开发技术 作者:BUAA_dxy 时间:2023-09-13 21:22:24 0 删除 编辑

本文是MySQL性能优化的第一部分:库表结构优化。

配套视频:https://www.bilibili.com/video/BV16M4y1C7fH/

1.1 数据库设计

不规范的数据库设计存在数据冗余以及插入、更新、删除异常问题。

规范化(Normalization)是数据库设计的一系列原理和技术,主要用于减少表中数据的冗余,增加完整性和一致性,同时使得数据库易于维护和扩展。

对于大多数的数据库系统而言,到达第三范式就已经足够了。也就是说,表需要定义主键,表中的字段都是不可再分的单一属性。非主键字段必须完全依赖于主键,不能只依赖于主键的一部分。属性不依赖于其它的非主属性。

对于前三个范式而言,只需要将不同的实体/对象单独存储到一张表中,并且通过外键建立它们之间的联系即可满足。

规范化可能导致连接查询(JOIN)过多,从而降低数据库的性能。因此,有时候为了提高某些查询或者应用的性能而故意降低规范反的程度,也就是反规范化。

常用的反规范化方法包括:

  • 增加冗余字段;

  • 增加计算列;

  • 将小表合成大表等。

例如,想要知道每个部门的员工数量,需要同时连接部门表和员工表;可以在部门表中增加一个字段(emp_numbers),查询时就不需要再连接员工表,但是每次增加或者删除员工时需要更新该字段。

反规范化可能带来数据完整性的问题;因此,通常我们应该先进行规范化设计,再根据实际情况考虑是否需要反规范化。一般来说,数据仓库(Data Warehouse)和在线分析系统(OLAP)会使用到反规范化的技术,因为它们以复杂查询和报表分析为主。

📚推荐图书:《数据库系统概念(第七版)》。

1.2 选择数据类型

我们在选择字段的数据类型时,首先应该满足存储业务数据的要求,其次还需要考虑性能和使用的便捷性。

一般来说,我们可以先确定基本的类型:

  • 文本数据使用字符串类型进行存储。

  • 数值数据,尤其是需要进行算术运算的数据,使用数字类型。

  • 日期和时间信息最好使用原生的日期时间类型。

  • 文档、图片、音频和视频等使用二进制类型。推荐存储在文件服务器上,数据库中存储文件的路径。

然后进一步确定具体的数据类型。

在满足数据存储和扩展的前提下,尽量使用更小的数据类型。这样可以节省一些存储,通常性能也会更好。例如,对于一个小型公司而言,员工人数通常不会超过几百,可以使用SMALLINT类型存储员工编号。

尽量避免使用NULL属性。NULL需要更多的存储和额外的处理,尽量使用NOT NULL加上默认值。

如果一个字段同时出现在多个表中,我们应该使用相同的数据类型。例如,员工表中的部门编号(dept_id)字段与部门表的编号(dept_id)字段应该保持名称和类型一致。

1.3 数字类型

1.3.1 整数类型

MySQL支持TINYINT、SMALLINT、MEDIUMINT、INT(INTEGER)以及BIGINT整数类型。如果为整数类型指定了UNSIGNED属性,可以存储的正整数范围将会扩大一倍。

数字类型 存储(字节) 有符号类型最小值 有符号类型最大值 无符号类型最小值 无符号类型最大值
TINYINT 1 -128 127 0 255
SMALLINT 2 -32768 32767 0 65535
MEDIUMINT 3 -8388608 8388607 0 16777215
INT 4 -2147483648 2147483647 0 4294967295
BIGINT 8 263 263-1 0 264-1

⚠️MySQL 8.0.17开始,整数类型的显示宽度(例如INT(10))和ZEROFILL选项已经被弃用,将来的版本中会删除。

1.3.2 实数类型

MySQL提供了精确数字类型DECIMAL,也支持浮点数类型FLOAT和DOUBLE。

DECIMAL(p, s)用于存储对精度要求严格的数据,例如财务。其中精度p表示总的有效位数,刻度s表示小数点后允许的位数。例如,123.04的精度为5,刻度为2。

DECIMAL使用二进制格式存储,每9个数字使用4字节表示。NUMERIC是DECIMAL的同义词。

FLOAT是单精度浮点数,需要4字节存储空间;DOUBLE是双精度浮点数,需要8字节存储空间。浮点数使用近似运算,速度比DECIMAL更快但可能丢失精度。

CREATE TABLE t(d1 DOUBLE, d2 DOUBLE);
INSERT INTO t(d1, d2) VALUES (101.40, 80.0);

SELECT *
FROM t
WHERE d1-d2=21.4; -- 101.40-80.0
d1|d2|
--+--+

💡一种折衷的方案是使用BIGINT替代DECIMAL存储财务数据。例如要存储精确到万分之一分的金额,可以将数据乘以100万倍之后存储到BIGINT,可以减少存储并优化计算性能,不过应用程序可能需要增加额外的处理。

1.4 字符串类型

MySQL字符串类型用于存储字符和字符串数据,包括二进制数据,例如图片或者文件。字符串数据可以支持比较运算符和模式匹配运算符,例如LIKE、正则表达式匹配以及全文检索。

MySQL支持的字符串类型包括CHAR、VARCHAR、BINARY、VARBINARY、BLOB、TEXT、ENUM以及SET。其中 CHAR、VARCHAR、TEXT、ENUM 以及SET包含字符集和排序规则属性,默认继承表的字符集和排序规则。MySQL 8.0默认使用utf8mb4字符集。

1.4.1 CHAR与VARCHAR

CHAR(n)类型表示长度固定的字符串,n表示字符的最大数量,取值范围从0到255。如果输入的字符串长度不够,将会使用空格进行填充。默认情况下没有设置SQL模式PAD_CHAR_TO_FULL_LENGTH,MySQL读取CHAR字段时自动截断了尾部的空格。

CREATE TABLE t (c1 CHAR, c5 CHAR(5));
INSERT INTO t VALUES ('a','a');

mysql> select concat(c1, '!'), concat(c5, '!') from t;
+-----------------+-----------------+
| concat(c1, '!') | concat(c5, '!') |
+-----------------+-----------------+
| a! | a! |
+-----------------+-----------------+

mysql> SET sql_mode = 'PAD_CHAR_TO_FULL_LENGTH';
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> select concat(c1, '!'), concat(c5, '!') from t;
+-----------------+-----------------+
| concat(c1, '!') | concat(c5, '!') |
+-----------------+-----------------+
| a! | a ! |
+-----------------+-----------------+

mysql> SET sql_mode = default;

MySQL使用比较运算符(=、<>、>、< 等)和LIKE操作符比较和匹配CHAR数据时不考虑尾部空格。例如:

mysql> select c5 from t where c5='a';
+------+
| c2 |
+------+
| a |
+------+

mysql> select c5 from t where c5 like 'a ';
Empty set (0.00 sec)

⚠️通常来说,只有存储固定长度的数据时,才会考虑使CHAR类型。例如18位身份证,6位邮政编码等。

VARCHAR(n)存储可变长度的字符串,n表示字符的最大数量,取值范围从0到65535。VARCHAR需要额外的1或者2字节存储字符串的长度,最大长度小于等于255时额外需要1字节,否则需要2字节。

VARCHAR字段的实际最大长度受限于最大的行大小(65536字节,所有字段长度之和)以及字符集。例如,utf8mb4字符集中的一个字符最多占用4个字节,因此这种字符集的VARCHAR字段可以声明的最大长度为16383。

DROP TABLE IF EXISTS t;
CREATE TABLE t (c VARCHAR(16384));
SQL Error [1074] [42000]: Column length too big for column 'c' (max = 16383); use BLOB or TEXT instead

VARCHAR存储实际的内容,比CHAR更节省空间,但是可能会因为更新导致页分裂。

💡对于文本数据,优先使用VARCHAR类型。例如名字、电子邮箱、产品描述等。

1.4.2 BINARY与VARBINARY

BINARY(M)和VARBINARY(M)类型与CHAR和VARCHAR类型类似,但是存储的内容为二进制字节串,而不是普通字符串。其中M表示最大的字节长度,分别为255和65535。这两种类型使用binary字符集和排序规则,基于字节数值进行比较和排序。

存储BINARY数据时,在尾部使用0x00(字节0)填充到指定长度,查询时不会删除尾部的0字节。所有的字节对应比较操作都有意义,包括ORDER BY和DISTINCT操作,0x00和空格比较的结果不相等,0x00的排序在空格前面。

DROP TABLE IF EXISTS t;
CREATE TABLE t (c BINARY(3));
INSERT INTO t VALUES ('a');

mysql> SELECT * FROM t where c='a';
Empty set (0.00 sec)

mysql> SELECT * FROM t where c='a\0\0';
+------------+
| c |
+------------+
| 0x610000 |
+------------+

存储VARBINARY数据时,不会使用0x00(字节0)填充,查询时不会删除尾部的0字节。所有的字节对应比较操作都有意义,包括ORDER BY和DISTINCT操作,0x00和空格比较的结果不相等,0x00的排序在空格前面。

BINARY(M)和VARBINARY(M)类型与CHAR和VARCHAR类型的存储需求也类似,但是以字节为单位。比较时也是按照字节进行,速度更快。

1.4.3 TEXT与BLOB

TEXT类型可以用于存储长文本字符串,长度支持1字节到4GB。MySQL不会在服务器内存中缓存TEXT数据,而是从磁盘中读取,所有访问时比CHAR和VARCHAR类型更慢一些。MySQL插入或者查询时不会对TEXT数据尾部空格进行任何处理。

MySQL提供了4种形式的TEXT类型:TINYTEXT、TEXT、MEDIUMTEXT以及LONGTEXT。

  • TINYTEXT,最大长度为255个字节,类似于VARCHAR(255)。每个TINYTEXT值需要1字节额外的存储表示长度;

  • TEXT(SAMLLTEXT),最大长度为64KB,类似于VARCHAR(65535)。每个TEXT值需要2字节额外的存储表示长度;

  • MEDIUMTEXT,最大长度为16MB。每个MEDIUMTEXT值需要3字节额外的存储表示长度;

  • LONGTEXT,最大长度为4GB。每个LONGTEXT值需要4字节额外的存储表示长度。

💡TEXT数据类型一般用于存储文章内容、产品文档等信息。只有在普通字符串类型的长度无法满足时才会考虑使用TEXT类型,推荐单独使用一个表存储这种字段。

BLOB类型可以用于存储二进制大对象,长度支持1字节到4GB;BLOB类型使用binary字符集和排序规则,基于字节数值进行比较和排序。MySQL不会在服务器内存中缓存BLOB数据,而是从磁盘中读取,所有访问时比BINARY和VARBINARY类型更慢一些。MySQL插入或者查询时不会对BLOB数据尾部空格进行任何处理。

MySQL提供了4种形式的BLOB类型:TINYBLOB、BLOB、MEDIUMBLOB以及LONGBLOB。

  • TINYBLOB,最大长度为255个字节,类似于VARBINARY(255)。每个TINYBLOB值需要1字节额外的存储表示长度;

  • BLOB(SMALLBLOB),最大长度为64KB,类似于VARBINARY(65535)。每个BLOB值需要2字节额外的存储表示长度;

  • MEDIUMBLOB,最大长度为16MB。每个MEDIUMBLOB值需要3字节额外的存储表示长度;

  • LONGBLOB,最大长度为4GB。每个LONGBLOB值需要4字节额外的存储表示长度。

MySQL使用独立的“外部存储”处理TEXT和BLOB数据,而且只对数据的max_sort_length字节进行排序,不支持完整数据的索引和排序。

⚠️BLOB 数据类型一般用于存储图片、文档、视频等信息,但是不建议这样使用。推荐使用单独的对象存储二进制内容,并且在数据库中保存文件路径。

1.4.4 ENUM类型

ENUM(‘value1’,‘value2’,…)类型定义了一个枚举,即取值限定为‘value1’、‘value2’、…、NULL或者‘’之一的字符串对象。ENUM数据在内部使用整数表示,最多包含65535个不同的值。

每个枚举元素最大的长度为M<=255并且(M*w) <=1020,其中M是元素的字面长度,w是字符集中字符可能占用的最大字节数。

使用枚举类型的优势在于:

  • 在字段的取值有限时提供紧凑的数据存储,枚举在内部使用整数表示,需要1字节或2字节存储;

  • 查询结果的可读性,内部整数在查询结果中显示为相应的字符串。

CREATE TABLE shirts (
name VARCHAR(40),
size ENUM('x-small', 'small', 'medium', 'large', 'x-large')
);
INSERT INTO shirts (name, size) VALUES ('dress shirt','large'), ('t-shirt','medium'), ('polo shirt','small');

SELECT name, size FROM shirts WHERE size = 'medium';
+---------+--------+
| name | size |
+---------+--------+
| t-shirt | medium |
+---------+--------+

如果插入100万条‘medium’数据,需要100万字节存储;如果直接使用VARCHAR类型,需要6倍存储。

使用枚举类型时需要注意枚举值的排序使用内部的索引数字,而不是字符串。例如,对于ENUM(‘b’, ‘a’)字符b排在a之前。枚举类型和字符串类型的连接查询性能更慢。

SELECT name, size, size+0 FROM shirts
ORDER BY size;
+----------+--------+--------+
name | size | size+0 |
+----------+--------+--------+
polo shirt | small | 2 |
t-shirt | medium | 3 |
dress shirt| large | 4 |
+----------+--------+--------+

⚠️不推荐使用ENUM类型,可以使用查找表。MySQL 8.0支持CHECK约束,也可以实现相同的效果。

1.4.5 SET类型

SET(‘value1’,‘value2’,…)类型定义了一个集合,即取值限定为‘value1’、‘value2’、…中零个或多个的字符串对象。SET数据在内部使用整数表示,最多包含64个不同的成员。

每个集合元素最大的长度为M<=255并且(M*w)<=1020,其中M是元素的字面长度,w是字符集中字符可能占用的最大字节数。

DROP TABLE IF EXISTS t;
CREATE TABLE t (c SET('read','write'));
INSERT INTO t VALUES (''),('read'),('write'),('read,write');

mysql> SELECT c+0 FROM t;
+------+
| c+0 |
+------+
| 0 |
| 1 |
| 2 |
| 3 |
+------+

SET对象的存储空间由集合成员的个数决定;如果个数为N,对象占用(N+7)/8字节,向上取整为1、2、3、4或者8字节。

MySQL提供了FIND_IN_SET、FIELD等函数,方便了SET数据类型的使用。

💡SET类型的一个典型应用时访问控制列表(ACL)。

1.5 日期时间类型

MySQL 提供了以下存储日期时间值的数据类型:DATE、TIME、DATETIME、TIMESTAMP以及YEAR。其中,TIME、DATETIME、TIMESTAMP支持小数秒,最多6位小数(微秒)。

1.5.1 日期类型

DATE表示日期类型,支持的范围从 ‘1000-01-01’ 到 ‘9999-12-31’,占用3个字节。DATE数据的显示格式为 ‘YYYY-MM-DD’。例如:

DROP TABLE IF EXISTS t;
CREATE TABLE t(birth_date date);
INSERT INTO t VALUES ('2023-04-01');

mysql> SELECT * FROM t;
+------------+
| birth_date |
+------------+
| 2023-04-01 |
+------------+

MySQL使用4位数字存储日期数据中的年份,如果输入2位年份,将会使用以下规则:

  • 00-69之间的年份转换为2000-2069;

  • 70-99之间的年份转换为1970-1999。

例如:
INSERT INTO t VALUES ('01-10-31'), ('81-10-31');

mysql> SELECT * FROM t;
+------------+
| birth_date |
+------------+
| 2023-04-01 |
| 2001-10-31 |
| 1981-10-31 |
+------------+
3 rows in set (0.00 sec)

以上规则同样适用于其他数据类型中的年份信息,包括DATETIME、TIMESTAMP以及YEAR。

如果只需要存储年份信息,可以使用YEAR类型。YEAR类型占用1个字节,显示格式位 ‘YYYY’,范围从1901到2155,以及0000。

1.5.2 时间类型

MySQL使用TIME类型表示一天中的时间,格式为’HH:MM:SS’,范围小于24小时。另外,也可以使用TIME表示两个事件之间的时间间隔,格式为’hhh:mm:ss’,范围从’-838:59:59’ 到 ‘838:59:59’。TIME类型需要3字节存储空间。

MySQL使用TIME(N)表示包含小数部分的时间,最多包含6位小数(微秒),默认为0位。如果包含了小数秒,TIME需要额外的存储,TIME(1)和TIME(2)需要4字节,TIME(3)和TIME(4)需要5字节,TIME(5) 和TIME(6)需要6字节存储。

1.5.3 时间戳类型

DATETIME(N)和TIMESTAMP(N)类型可以同时存储日期(DATE)和时间(TIME)信息,也就是时间戳。

DATETIME类型使用 ‘YYYY-MM-DD hh:mm:ss[.fraction]’ 格式显示,支持范围’1000-01-01 00:00:00.000000’ 到’9999-12-31 23:59:59.999999’,默认0位小数秒,需要5字节存储。如果支持小数秒,额外的存储和TIME(N)类似。

TIMESTAMP类型使用UTC时区进行存储,支持范围’1970-01-01 00:00:01.000000’ UTC到’2038-01-19 03:14:07.999999’ UTC,默认0位小数秒,需要4字节存储。如果支持小数秒,额外的存储和TIME(N)类似。

例如:

DROP TABLE IF EXISTS t;
CREATE TABLE t (dt DATETIME, ts TIMESTAMP);

SET time_zone = '+00:00';
INSERT INTO t VALUES (now(), now());

mysql> SELECT * FROM t;
+---------------------+---------------------+
| dt | ts |
+---------------------+---------------------+
| 2023-04-15 14:09:57 | 2023-04-15 14:09:57 |
+---------------------+---------------------+
1 row in set (0.00 sec)

两者在UTC时区相同,然后修改会话的时区:

mysql> SET time_zone = '+08:00';
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM t;
+---------------------+---------------------+
| dt | ts |
+---------------------+---------------------+
| 2023-04-15 14:09:57 | 2023-04-15 22:09:57 |
+---------------------+---------------------+
1 row in set (0.00 sec)

结果显示,TIMESTAMP类型会随着当前时区进行调整。

DATETIME和TIMESTAMP类型支持自动初始化或者更新为当前日期时间,在字段定义时分别使用DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP属性进行设置。例如:

DROP TABLE IF EXISTS t;
CREATE TABLE t (
id int,
dt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
INSERT INTO t(id) VALUES (1);

mysql> SELECT * FROM t;
+------+---------------------+---------------------+
| id | dt | ts |
+------+---------------------+---------------------+
| 1 | 2020-09-10 14:33:25 | 2020-09-10 22:33:25 |
+------+---------------------+---------------------+
1 row in set (0.00 sec)

另一种存储时间戳的方式是使用UNIX时间戳,即当前时间距离1970年1月1日的秒数来表示时间。例如,使用INT可以支持到2038年,无符号的INT可以支持到2106年。

这种存储方式的缺点就是可读性不强。MySQL提供了FORM_UNIXTIME和UNIX_TIMESATMP函数处理整数和时间戳的转换,也可以依赖应用程序进行格式处理。

1.6 JSON类型

MySQL 5.7.8开始支持原生JSON数据类型,可以支持更加高效的JSON文档存储和管理。原生JSON数据类型提供了自动的格式验证以及优化的存储格式,可以快速访问文档中的元素节点。例如:

CREATE TABLE employee_json(
emp_id INTEGER NOT NULL PRIMARY KEY,
emp_info JSON NOT NULL
);

INSERT INTO employee_json
VALUES (1, '{"emp_name": "刘备", "sex": "男", "dept_id": 1, "manager": null, "hire_date": "2000-01-01", "job_id": 1, "income": [{"salary":30000}, {"bonus": 10000}], "email": "liubei@shuguo.com"}');

SELECT emp_id,
emp_info->>'$.emp_name' emp_name,
emp_info->>'$.income[0].salary' salary
FROM employee_json
WHERE emp_info->>'$.emp_name' = '刘备';
emp_id|emp_name|salary|
------+--------+------+
1|刘备 |30000 |

JSON文档的最大长度不能超过系统变量max_allowed_packet(默认64MB)的限制。

除了JSON数据类型之外,MySQL还提供了许多JSON处理函数和操作符,例如:

  • 构造JSON对象的JSON_OBJECT、JSON_ARRAY;

  • 查询指定元素的->(JSON_EXTRACT)、->>(JSON_UNQUOTE + JSON_EXTRACT);

  • 将JSON数据转换为SQL数据的JSON_TABLE;

  • 更新JSON数据的JSON_SET、JSON_INSERT、JSON_REPLACE、JSON_REMOVE

  • 格式验证的JSON_VALID函数。

一般来说,JSON字段所需的存储和LONGBLOB或者LONGTEXT差不多。不过,JSON文档的二进制编码需要额外的存储,包括元数据和字典信息。举例来说,JSON文档中的字符串需要额外的4到10个字节存储。

JSON数据类型的优势是结构更灵活,不需要预定义字段。MySQL支持基于虚拟列的索引,可以为JSON节点创建索引,优化查询性能。

ALTER TABLE employee_json 
ADD COLUMN dept_id INT GENERATED ALWAYS AS (emp_info->"$.dept_id");

CREATE INDEX idx_employee_dept_id ON employee_json(dept_id);

-- 查看执行计划
EXPLAIN
SELECT *
FROM employee_json
WHERE dept_id = 1;
Name |Value |
-------------+--------------------+
id |1 |
select_type |SIMPLE |
table |employee_json |
partitions | |
type |ref |
possible_keys|idx_employee_dept_id|
key |idx_employee_dept_id|
key_len |5 |
ref |const |
rows |1 |
filtered |100.0 |
Extra | |

MySQL 8.0.17开始支持多值索引,可以基于JSON数组字段创建索引。

1.7 选择主键类型

  • 主键字段最好没有业务意义,避免变更可能带来的问题。

  • 对于InnoDB而言,整数类型是最优的主键类型,推荐使用自增(AUTO_INCREMENT)属性。

  • 尽量避免使用字符串作为主键类型,它们占用更多存储,性能不如整数类型。尤其需要注意随机字符串,例如UUID函数,这种方式会导致插入数据和查询性能问题。可以考虑使用UUID_TO_BIN函数将UUID转换为16字节数字,存储在BINARY(16)类型中,查询时使用HEX函数转换。

1.8 常见问题

设计MySQL数据库时,应该尽量避免以下问题:

  • 混合使用了不同的存储引擎,例如一些表使用InnoDB,一些表使用MyISAM。建议统一使用InnoDB存储引擎。

  • 使用VARCHAR存储IP地址。IP地址应该使用32位无符号整数,节省空间。可以利用INET_ATON和INET_NTOA函数进行转换。

  • 使用VARCHAR存储日期和时间。这种方式需要占用更多存储,而且无法支持算术运算,例如返回两个日期之间的时间间隔。

  • 为了避免扩展问题使用TEXT或者超长的VARCHAR。

原文作者:SQL编程思想;如有侵权,请联系管理员删除。

下一篇: PostgreSQL消息队列
请登录后发表评论 登录
全部评论
《SQL编程思想》作者,专注于数据库领域知识的分享。

注册时间:2021-08-26

  • 博文量
    443
  • 访问量
    546942