備注:測試數據庫版本為MySQL 8.0
一.Schema與數據類型優化概述
良好的邏輯設計和物理設計是高性能的基石,應該根據系統將要執行的查詢語句來設計schema,這往往需要權衡各種因素。
schema設計不佳,后期調整會非常的困難,筆者曾經遇到過一些設計問題:
- 日志表主鍵設為int類型,數據量達到2147483647的時候,insert數據直接報錯,導致生產環境不可用。
- 訂單主表反范式設計,多達100多列,導致生產環境鎖非常多。
- 建表的時候未指定not null,存儲了過多且不必要的null值。
- 整數類型一律用int,浪費諸多存儲空間。
......
二.選擇優化的數據類型
MySQL數據類型概述可以參考下面筆者的博客:
MySQL 8.0 數據類型小結
2.1 整數類型
類型 | 存儲(字節) | 最小(有符號) | 最大(有符號) | 最小(無符號) | 最大(無符號) | 描述 |
---|---|---|---|---|---|---|
BIT(M) | (m+7)/8 | --- | --- | --- | --- | 位值類型。M表示每個值的位數,從1到64.如果M省略,默認是1。比如bit(8)存儲888變為00000111 |
TINYINT(M) | 1 | -128 | 127 | 0 | 255 | |
SMALLINT(M) | 2 | -32768 | 32767 | 0 | 65535 | |
MEDIUMINT(M) | 3 | -8388608 | 8388607 | 0 | 16777215 | |
INT,INTEGER(M) | 4 | -2147483648 | 2147483647 | 0 | 4294967295 | |
BIGINT(M) | 8 | -2^63 | 2^63 -1 | 0 | 2^64 | |
DECIMAL | 變長(0-4個字節) | M為總位數(精度),D為小數點后的位數(刻度)。如果D為0,則值沒有小數部分。最大(M)是65。最大(D)為30.如果省略D,D的默認值為0,。如果省略M,M的默認值為10. NUMBERIC的實現是DECIMAL | ||||
NUMBERIC | 變化 | 同上 | ||||
FLOAT(M,D) | 4 | M是總位數,D是小數點后面的位數。如果M和D省略,則將值存儲到硬件允許的限制。單精度浮點精確到7位小數。 正區間- [ –3.402823466E38 , –1.175494351E-38 ] 負區間-[ 1.175494351E-38 , 3.402823466E38] |
||||
DOUBLE(M,D) | 8 | M是總位數,D是小數點后面的位數。如果M和D省略,則將值存儲到硬件允許的限制。單精度浮點精確到15位小數。 正區間-[ –1.7976931348623157E308,–2.2250738585072014E-308 ] 負區間-[ 2.2250738585072014E-308 , 1.7976931348623157E308 ] |
||||
BOOL,BOOLEAN | 1 | TINYINT(1)的同義詞 |
有兩種類型的數字:整數(whole number)和實數(real number)。如果存儲整數,可以使用這幾種整數類型:TINYINT,SMALLINT,MEDIUMINT,INT,BIGINT。分別使用1字節、2字節、3字節、4字節、8字節。
整數類型有可選的UNSIGNED屬性,表示不允許負值,這大致可以使正數的上限提高一倍。例如TINYINT UNSIGNED可以存儲的范圍是0~255,而TINYINT的存儲范圍是?128~127。
更小的通常更好
例如枚舉類的, 選擇 tinyint、smallint即可,節省磁盤空間就是優化。
其它的業務相關表,例如用戶表、訂單表 可以選擇用 int類型。
雖然int類型不支持小數,但是例如金額這個,可以通過調整單位,例如單位為分,這樣就可以存小數金額了
對于一些大的日志表、分布式ID之類的,可以選擇bigint類型
2.2 實數類型
實數是帶有小數部分的數字。然而,它們不只是為了存儲小數部分;也可以使用DECIMAL存儲比BIGINT還大的整數。MySQL既支持精確類型,也支持不精確類型。
FLOAT和DOUBLE類型支持使用標準的浮點運算進行近似計算。如果需要知道浮點運算是怎么計算的,則需要研究所使用的平臺的浮點數的具體實現。
DECIMAL類型用于存儲精確的小數。
2.3 字符類型
VARCHAR和CHAR類型
類型 | 存儲(字節) | 范圍 | 用途 |
---|---|---|---|
CHAR(M) | M | 0 - 255 | 存儲定長的字符 |
VARCHAR(M) | VARCHAR(10) 實際存儲3個字符,1個字節來存儲長度,總共占4字節 VARCHAR(1000) 實際存儲3個字符,2個字節來存儲長度,總共占5字節 不同的存儲引擎可能存在一定的差異 |
0-65536 | 存儲可變長度的字符串 |
1.類型選擇問題
很多時候,開發同事為了方便,直接用varchar(200) 來存儲字符,不考慮實際需求。
這樣做,存在諸多弊端。
如果是md5密碼這樣的定長字段,如果用varchar類型,會浪費一定的存儲空間。
如果存儲的字符只有5個,而這時都用varchar(200),感覺存儲空間是一樣的。但是程序端讀取的時候,varchar(200)會消耗更多的內存。
2.變長字符的更新問題
InnoDB存儲引擎
varchar由于是變長,遇到更新的時候,如果比原先的長度長很多,這個時候頁的空間不夠,會分裂頁,此時會比較消耗性能
BLOB和TEXT類型
類型 | 描述 |
---|---|
TINYBLOB | 最大長度255(2^8-1),使用1字節前綴存儲長度信息 |
BLOB | 最大長度65,535(2^16-1),使用2字節前綴存儲長度信息 |
MEDIUMBLOB | 最大長度16,777,215(2^24-1),使用3字節前綴存儲長度信息 |
LONGBLOB | 最大長度(2^32-1)或4GB,使用4字節前綴存儲長度信息 |
TINYTEXT | 最大長度255(2^8-1),使用1字節前綴存儲長度信息 |
TEXT | 最大長度65,535(2^16-1),使用2字節前綴存儲長度信息 |
MEDIUMTEXT | 最大長度16,777,215(2^24-1),使用3字節前綴存儲長度信息 |
LONGTEXT | 最大長度(2^32-1)或4GB,使用4字節前綴存儲長度信息 |
BLOB是SMALLBLOB的同義詞
TEXT是SMALLTEXT的同義詞
MySQL把每個BLOB和TEXT當做一個獨立的對象處理。
當BLOB和TEXT值太大時,InnoDB會使用專門的外部存儲區域來進行存儲,此時每個值在行內需要1-4個值存儲一個指針,然后在外部存儲區域存儲實際的值
BLOB和TEXT家族之間僅有的不同是BLOB類型存儲的是二進制數據,沒有排序規則或字符集,而TEXT類型有字符集和排序規則。
2.4 日期和時間類型
類型 | 存儲(字節) | 范圍 | 格式 | 用途 |
---|---|---|---|---|
DATE | 3 | 1000-01-01/9999-12-31 | YYYY-MM-DD | 日期值 |
TIME | 3 | '-838:59:59'/'838:59:59' | HH:MM:SS | 時間值或持續時間 |
YEAR | 1 | 1901/2155 | YYYY | 年份值 |
DATETIME | 8 | 1000-01-01 00:00:00/9999-12-31 23:59:59 | YYYY-MM-DD HH:MM:SS | 混合日期和時間值 |
TIMESTAMP | 4 | 1970-01-01 00:00:00/2038 | YYYYMMDD HHMMSS | 混合日期和時間值,時間戳 |
關于時間類型的選擇:
1.如果只存年,用YEAR類型
2.如果只存年月日,用DATE
3.如果需要存年月日時分秒,用TIMESTAMP
不要被這個2038年給嚇到了,而不用TIMESTAMP,其實更節約存儲空間,且能容納時區信息
4.不用將TIMESTAMP轉換為數值
FROM_UNIXTIME() -- 把數值轉換為時間戳
UNIX_TIMESTAMP() -- 把時間戳轉換為數值
轉換感覺是節省了空間,不過處理起來非常的不方便,不推薦使用
2.5 其它類型
一個例子是一個IPv4地址。人們經常使用VARCHAR(15)列來存儲IP地址。然而,它們實際上是32位無符號整數,不是字符串。用小數點將地址分成四段的表示方法只是為了讓人們閱讀容易。所以應該用無符號整數存儲IP地址。MySQL提供INET_ATON()和INET_NTOA()函數在這兩種表示方法之間轉換。
一個例子是枚舉ENUM和SET類型,實際生產中使用較少,暫不考慮。
一個例子是位BIT類型,可以使用BIT列在一列中存儲一個或多個true/false值。BIT(1)定義一個包含單個位的字段,BIT(2)存儲2個位,依此類推。BIT列的最大長度是64個位。
三.范式和反范式
MySQL的OLAP會弱于傳統的Oracle、Postgresql,所以很多時候設計的時候,需要考慮使用反范式,減少表之間的連接,但是凡事都有個度,過而不及。筆者就見過為了查詢方便,開發設計的業務表都是反范式的,不但冗余多,遇到并發上來之后,鎖表現象也頻繁發生。
范式的優點和缺點:
優點:
- 范式化的更新操作通常比反范式化要快。
- 當數據較好地范式化時,就只有很少或者沒有重復數據,所以只需要修改更少的數據。
- 范式化的表通常更小,可以更好地放在內存里,所以執行操作會更快。
- 很少有多余的數據意味著檢索列表數據時更少需要DISTINCT或者GROUP BY語句。
缺點:
范式化設計的schema的缺點是通常需要關聯。稍微復雜一些的查詢語句在符合范式的schema上都可能需要至少一次關聯,也許更多。這不但代價昂貴,也可能使一些索引策略無效。例如,范式化可能將列存放在不同的表中,而這些列如果在一個表中本可以屬于同一個索引。
反范式的優點和缺點
優點:
反范式化的schema因為所有數據都在一張表中,可以很好地避免關聯。
缺點:
- 數據冗余
- 更新操作慢
混用范式化和反范式化
范式化和反范式化的schema各有優劣,怎么選擇最佳的設計?
事實是,完全的范式化和完全的反范式化schema都是實驗室里才有的東西:在真實世界中很少會這么極端地使用。在實際應用中經常需要混用,可能使用部分范式化的schema、緩存表,以及其他技巧。
例如有兩張表,一個是申請表,一個是申請的流程日志表,我們需要知道申請單最后一個審批的人,那么每次都需要在申請流程日志表中進行group by然后求最后一個審批記錄。這樣不但sql復雜,且性能慢。比較好的方法是申請表在滿足范式的情況下,新增一列最后審批人字段,通過反范式進行冗余。
四.計數器表
這個案例來自《高性能MySQL》 ,真的太厲害了,之前遇到類似的問題,都不知道如何優化。
如果應用在表中保存計數器,則在更新計數器時可能碰到并發問題。計數器表在Web應用中很常見。可以用這種表緩存一個用戶的朋友數、文件下載次數等。創建一張獨立的表存儲計數器通常是個好主意,這樣可使計數器表小且快。使用獨立的表可以幫助避免查詢緩存失效,并且可以使用本節展示的一些更高級的技巧。
應該讓事情變得盡可能簡單,假設有一個計數器表,只有一行數據,記錄網站的點擊次數:
mysql> CREATE TABLE hit_counter (
-> cnt int unsigned not null
-> ) ENGINE=InnoDB;
網站的每次點擊都會導致對計數器進行更新:
mysql> UPDATE hit_counter SET cnt = cnt + 1;
問題在于,對于任何想要更新這一行的事務來說,這條記錄上都有一個全局的互斥鎖(mutex)。這會使得這些事務只能串行執行。要獲得更高的并發更新性能,也可以將計數器保存在多行中,每次隨機選擇一行進行更新。這樣做需要對計數器表進行如下修改:
mysql> CREATE TABLE hit_counter (
-> slot tinyint unsigned not null primary key,
-> cnt int unsigned not null
-> ) ENGINE=InnoDB;
然后預先在這張表增加100行數據。現在選擇一個隨機的槽(slot)進行更新:
mysql> UPDATE hit_counter SET cnt = cnt + 1 WHERE slot = ceil(RAND() * 100);
要獲得統計結果,需要使用下面這樣的聚合查詢:
mysql> SELECT SUM(cnt) FROM hit_counter;
一個常見的需求是每隔一段時間開始一個新的計數器(例如,每天一個)。如果需要這么做,則可以再簡單地修改一下表設計:
mysql> CREATE TABLE daily_hit_counter (
-> day date not null,
-> slot tinyint unsigned not null,
-> cnt int unsigned not null,
-> primary key(day, slot)
-> ) ENGINE=InnoDB;
在這個場景中,可以不用像前面的例子那樣預先生成行,而用ON DUPLICATE KEY UPDATE代替:
mysql> INSERT INTO daily_hit_counter(day, slot, cnt)
-> VALUES(CURRENT_DATE, ceil(RAND() * 100), 1)
-> ON DUPLICATE KEY UPDATE cnt = cnt + 1;
如果希望減少表的行數,以避免表變得太大,可以寫一個周期執行的任務,合并所有結果到0號槽,并且刪除所有其他的槽:
mysql> UPDATE daily_hit_counter as c
-> INNER JOIN (
-> SELECT day, SUM(cnt) AS cnt, MIN(slot) AS mslot
-> FROM daily_hit_counter
-> GROUP BY day
-> ) AS x USING(day)
-> SET c.cnt = IF(c.slot = x.mslot, x.cnt, 0),
-> c.slot = IF(c.slot = x.mslot, 0, c.slot);
mysql> DELETE FROM daily_hit_counter WHERE slot <> 0 AND cnt = 0;
五.加快ALTER TABLE操作的速度
MySQL的ALTER TABLE操作的性能對大表來說是個大問題。MySQL執行大部分修改表結構操作的方法是用新的結構創建一個空表,從舊表中查出所有數據插入新表,然后刪除舊表。這樣操作可能需要花費很長時間,如果內存不足而表又很大,而且還有很多索引的情況下尤其如此。許多人都有這樣的經驗,ALTER TABLE操作需要花費數個小時甚至數天才能完成。
雖然從MySQL 5.6開始支持online DDL,不需要停機,但是每次版本發布的時候,一個團隊的人員都在等著DDL的完成,然后驗證。
那么有沒有什么方法能加快ALTER TABLE操作的速度呢?
辦法當然是有,一般有如下三種方法:
- 預留列
- 更改表定義文件
- MySQL 8.0 快速加列
5.1 預留列
對于一些主表,例如訂單表、客戶表等,可以在create table或Online DDL的時候,直接新增2-3個預留列,字段類型最好選擇varchar類型,這樣無論是存儲數值、字符、時間類型,都是可行的。當后面的變更需要新增列的時候,可以將預留列進行改名,直接使用。
代碼:
create table t1(id int not null,name varchar(100) not null,reserved1 varchar(200),reserved2 varchar(200));
-- 遇到變更,需要新增列身份證號
alter table t1 change reserved1 idcard varchar(200);
測試記錄:
mysql> create table t1(id int not null,name varchar(100) not null,reserved1 varchar(200),reserved2 varchar(200));
Query OK, 0 rows affected (0.02 sec)
mysql>
mysql> desc t1;
+-----------+--------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+-----------+--------------+------+-----+---------+-------+
| id | int(11) | NO | | NULL | |
| name | varchar(100) | NO | | NULL | |
| reserved1 | varchar(200) | YES | | NULL | |
| reserved2 | varchar(200) | YES | | NULL | |
+-----------+--------------+------+-----+---------+-------+
4 rows in set (0.00 sec)
mysql> alter table t1 change reserved1 idcard varchar(200);
Query OK, 0 rows affected (0.00 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> desc t1;
+-----------+--------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+-----------+--------------+------+-----+---------+-------+
| id | int(11) | NO | | NULL | |
| name | varchar(100) | NO | | NULL | |
| idcard | varchar(200) | YES | | NULL | |
| reserved2 | varchar(200) | YES | | NULL | |
+-----------+--------------+------+-----+---------+-------+
4 rows in set (0.00 sec)
mysql>
5.2 更改表定義文件
對于一些需要修改列的屬性,例如 由varchar(100)增加到varchar(200),通過ALTER語句耗時非常久,此時可以修改表定義文件快速完成。
步驟如下:
- 創建一個新的空表,表結構同需要變更的表
- 對新表進行alter操作
- flush tables with read lock;
- 替換新表與需要變更的表的表定義文件
- unlock tables;
我們先來看看,給一個大表進行DDL需要多長時間。
從下面的測試我們可以看到,給一個7億多條數據的表進行DDL操作,耗時一個小時4分鐘。
mysql> select count(*) from fact_sale;
+-----------+
| count(*) |
+-----------+
| 767830000 |
+-----------+
1 row in set (2 min 29.23 sec)
mysql>
mysql> alter table fact_sale modify prod_name varchar(100) not null;
Query OK, 767830000 rows affected (1 hour 4 min 0.83 sec)
Records: 767830000 Duplicates: 0 Warnings: 0
下面我們使用修改表定義的方法
代碼:
CREATE TABLE fact_sale_new like fact_sale;
alter table fact_sale_new modify prod_name varchar(200) not null;
flush tables with read lock;
-- os層操作
mv fact_sale.frm fact_sale.frm.bak
mv fact_sale_new.frm fact_sale.frm
mv fact_sale.frm.bak fact_sale_new.frm
unlock tables;
測試記錄:
mysql>
mysql> CREATE TABLE `fact_sale_new` (
-> `id` bigint(8) NOT NULL AUTO_INCREMENT,
-> `sale_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-> `prod_name` varchar(200) NOT NULL,
-> `sale_nums` int(11) DEFAULT NULL,
-> PRIMARY KEY (`id`)
-> ) ENGINE=InnoDB AUTO_INCREMENT=787621598 DEFAULT CHARSET=utf8;
Query OK, 0 rows affected (0.01 sec)
mysql>
mysql> flush tables with read lock;
Query OK, 0 rows affected (0.00 sec)
mysql>
mysql> unlock tables;
Query OK, 0 rows affected (0.00 sec)
mysql>
mysql> show create table fact_sale\G
*************************** 1. row ***************************
Table: fact_sale
Create Table: CREATE TABLE `fact_sale` (
`id` bigint(8) NOT NULL AUTO_INCREMENT,
`sale_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`prod_name` varchar(200) NOT NULL,
`sale_nums` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=787621598 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
mysql> show create table fact_sale_new\G
*************************** 1. row ***************************
Table: fact_sale_new
Create Table: CREATE TABLE `fact_sale_new` (
`id` bigint(8) NOT NULL AUTO_INCREMENT,
`sale_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`prod_name` varchar(100) NOT NULL,
`sale_nums` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=787621598 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
mysql>
5.3 MySQL 8.0 快速加列
5.3.1 快速加列支持類型
官方文檔列出了一些可以快速DDL的操作,大體包括:
修改索引類型
Add column
當一條alter語句中同時存在不支持instant的ddl時,則無法使用
只能順序加列
不支持壓縮表、不支持包含全文索引的表
不支持臨時表,臨時表只能使用copy的方式執行DDL
不支持那些在數據詞典表空間中創建的表
修改/刪除列的默認值、修改索引類型
修改ENUM/SET類型的定義
存儲的大小不變時
向后追加成員
增加或刪除類型為virtual的generated column
RENAME TABLE操作
5.3.2 立刻加列的限制
雖然立刻加列這一特性十分好用,但也存在著一些限制:
1、當一條alter語句中同時存在不支持instant的ddl時,則無法使用
2、只能順序加列
3、不支持壓縮表、不支持包含全文索引的表,不支持臨時表
4、不支持那些在數據詞典表空間中創建的表
5、修改ENUM/SET類型的定義時,存儲的大小不變,向后追加成員
5.3.3 立刻加列的實現
立刻加列時,只會變更數據字典中的內容:在列定義中增加新列的定義,增加新列的默認值。(information_schema.INNODB_TABLES,information_schema.INNODB_COLUMNS)
立刻加列后,當要讀取表中的數據時:由于立刻加列沒有變更行數據,讀取的行數據為原列數對應的數據;MySQL會將新增的列的默認值,追加到讀取的數據后面。
當讀取數據行時,通過判斷數據行的頭信息中的instant 標志位,可以知道該行的格式是 “新格式”:該行頭信息后有一個新字段 "列數"通過讀取數據行的 “列數” 字段,可以知道該行數據中多少列有"真實"的數據,從而按列數讀取數據。
快速加列特性,在增加列時,實際上只是修改了元數據,原來存儲在文件中的行記錄并沒有被修改。當行格式為redundent類型時,記錄解析是不依賴元數據的,可以自解析,但如果行格式是dynamic或者compact類型,由于行內不存儲元數據,尤其是列的個數信息,其記錄的解析需要依賴元數據的輔助。因此為了支持動態加列功能,會對行格式做一定的修改。
大體思路如下:
如果表上從未發生過instant add column, 則行格式維持不變;如果發生過instant ddl, 那么所有新的記錄上都被特殊標記了一個flag, 同時在行內存儲了列的個數;由于只支持往后順序加列,通過列的個數就可以知道這個行記錄中包含了哪些列的信息。
MySQL 5.7
mysql> select count(*) from fact_sale;
+-----------+
| count(*) |
+-----------+
| 767830000 |
+-----------+
1 row in set (2 min 28.01 sec)
mysql>
mysql> show create table fact_sale\G
*************************** 1. row ***************************
Table: fact_sale
Create Table: CREATE TABLE `fact_sale` (
`id` bigint(8) NOT NULL AUTO_INCREMENT,
`sale_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`prod_name` varchar(200) NOT NULL,
`sale_nums` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=787621598 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
mysql> alter table fact_sale add column reserverd1 varchar(100);
Query OK, 0 rows affected (15 min 33.13 sec)
Records: 0 Duplicates: 0 Warnings: 0
MySQL 8.0:
mysql> select count(*) from fact_sale;
+-----------+
| count(*) |
+-----------+
| 767830000 |
+-----------+
1 row in set (1 min 4.25 sec)
mysql>
mysql>
mysql>
mysql> show create table fact_sale\G
*************************** 1. row ***************************
Table: fact_sale
Create Table: CREATE TABLE `fact_sale` (
`id` bigint NOT NULL AUTO_INCREMENT,
`sale_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`prod_name` varchar(200) NOT NULL,
`sale_nums` int DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=787621598 DEFAULT CHARSET=utf8mb3
1 row in set (0.01 sec)
mysql>
mysql> alter table fact_sale add column reserverd1 varchar(100);
Query OK, 0 rows affected (0.04 sec)
Records: 0 Duplicates: 0 Warnings: 0