工作中,需要設計一個數據庫存儲,項目的需求大致如下:
(1)對於每個用戶,需要存儲一個或多個庫, 每個庫, 由一個用戶標識來標識,這里成為clientFlag.
(2) 對於每一個庫,結構如下:
1) 一個clientFlag對應多個組,組包括組名和組的描述一類的信息
2)一個組中有多個成員,每個成員包括成員名和成員描述一類的信息
3)一個成員包括若干張自己喜歡的圖片,圖片有圖片的文件ID和圖片的描述信息
4)每張圖片對應於多個版本,每個版本下存儲使用深度學習引擎生成的特征
這個需求的目的是,給出一張圖片,找出最有可能喜歡這張圖片的人。
這是一個邏輯很簡單的數據庫,應該不難設計。首先,我們需要考慮的是不同用戶的信息是存儲在一張表上,還是以clientFlag做分割。從減少數據競爭考慮,使用clientFlag作為表名的一部分來實現數據分割,為什么是一部分,因為這樣可以使clientFlag不會與其他表意外重名,這點需要注意。我們沒有考慮繼續進行分割,例如使用clientFlag-groupName作為表名的一部分,這里考慮了mysql的特性,mysql在文件特別多的情況下,表示並不是太好,如果以clientFlag-groupName作為表名的一部分,在mysql的服務器配置參數innodb_file_per_table為ON時,很容易出現幾千幾萬個文件,這種情況下mysql運行效果並不好. 如果真的使用了很多的文件,可以考慮增大table_open_cache的值。具體可以參考網頁: https://dev.mysql.com/doc/refman/5.6/en/table-cache.html。修改其中的5.6可以查看其他版本。
分割的缺點:
(1)在寫數據庫操作的代碼時,寫數據庫語句變得更加困難,需要更多的拼接,出錯的可能性和代碼維護,都變得比固定表名的數據庫苦難,增加了字符串拼接的時間。
(2)因為在表格層次進行分割,所以沒有辦法使用預處理語句 (prepared statement),這里可能會導致一定程度的性能下降。
表格設計:
表格設計,考慮到組有組描述,成員有成員描述,圖片有圖片描述,為了減少冗余,滿足第二范式,可以如下設計:
(1) 組表包括:
1)組名 2) 組描述 (第一列唯一索引)
(2) 成員包括:
1) 組名,成員名,用戶描述 (前兩列唯一索引)
(3) 圖像表包括:
1)組名,成員名,圖像ID,圖像描述 (前三列唯一索引)
(4) 每一個特征表(一個clientFlag,一個特征對應一張表)包括:
1)組名,成員名,圖像ID,圖像特征 (前三列唯一索引)
上面設計的好處如下:
(1)不需要存儲冗余的組信息,成員信息,圖像描述信息
(2)在查找數據的時候,如果是按照先獲取所有組,獲取一個組的所有成員,獲取成員的所有圖片,獲取圖片的特征的方式查找,不需要進行表的關聯,代碼運行速度比較快,而且邏輯簡單
(3)相關的數據會出現在一起,對於某些語句很友好
上面設計的缺點如下:
(1)組名,成員名,圖片ID重復出現,對組名,用戶名,圖像ID的修改代價比較大 (在很多設計中,可能會禁止修改組名)
(2)組名,成員名,圖片ID一般都是使用字符串表示,在表中占用比較大,而且為了保證唯一性,需要在上述字段上面添加唯一索引,導致索引占用也比較大
我的設計采取了下面的設計:
(1)組表包括:
1) 自增ID 2) 組名 3)組描述 (第一列主鍵,第二列唯一索引)
(2)成員表包括:
1)自增ID 2)外建關聯 組ID 3) 成員名 4)成員描述 (第一列主鍵,第二三列唯一索引)
(3)圖像表包括:
1)自增ID 2) 外健關聯 成員ID 3)文件ID 4) 文件描述 (第一列主鍵,第二三列唯一索引)
(4) 每一個特征表(一個clientFlag,一個特征對應一張表)包括:
1)外鍵關聯 圖像ID 2) 特征 (第一列主鍵)
優點:
(1)不需要存儲冗余的組信息,成員信息,圖像描述信息
(2)組名,成員名,圖片ID 不重復出現,修改組名,成員名等只需要修改一處
(3)表格大小更小,索引大小也更小
(4) 對於插入來說, 因為使用了自增ID, 為順序插入, 插入速度較快, 而且數據內存占用(或者硬盤)占用會比較小
缺點:
(1)在查找數據的時候,經常需要使用join或者子查詢,因為唯一索引的存在,子查詢可以轉化為常量表達式,性能上沒有問題,代碼會變得比較難寫(對於高並發場景, 幾乎所有的查找操作, 插入操作都會需查詢組表, 組表可能會面臨比較多的鎖爭用)
(2)如果數據為隨機並發插入,那么數據存儲不存在聚集現象,對有些數據的查找不利,尤其是硬盤查找
表格設計的一個注意事項:
(1)合理選擇每一列的類型和大小, 盡量選擇能夠實現功能的最小最簡單的類型.
(2)避免NULL類型,例如對於描述而言,空字符串應該是合理的默認值。
測試數據:
關於測試數據, 這里使用英文數據進行測試,對於組名,可以考慮使用隨機不重復的英文字符串,對於描述信息,可以使用更長的隨機不重復的英文字符串,對於特征,使用字節數據,這里使用2000byte的字節數組。
(1)想要獲取不重復字符串,可以考慮使用uuid,關於長度, 如果需要的長度較短,可以將生成的uuid字符串截斷,得到需要的長度。如果需要的長度更長,有兩種方式,第一種,重復獲取多個uuid字符串,然后拼接得到需要的長度,第二種是在后面隨便添加一些字符數組,重復的應該也沒有什么問題,因為uuid已經可以保證唯一性了。在我的最初測試數據中,需要有1000個組,每個組有1000個成員,每個成員有10張圖片。對於這么大量級的數據,按照我的測試來看,沒有出現過uuid沖突的情況。我的名字基本為20個字符,描述大約為50或者60個字符。
(2)對於特征數據,這里有兩種方式,第一個使用split截斷一個比較大的二進制文件,然后每一個文件當做一個特征。第二種是在讀取一個大文件的時候,在代碼中進行分割,從我的實際操作來看,第二種方式比較好。實際上並不需要有一千萬個不一樣的特征,因為mysql並不精確比較不同的行的數據是否一致,所以,有數十萬個應該就足夠了。
壓測的用處:
(1) 可以測試硬件的效果,這不是這次測試的目的
(2)可以測試不同服務器參數的影響
(3) 可以測試在測試量級下,服務器的性能
(4)可以使用測試數據,檢測所寫語句的性能,在這里,我對我的幾乎所有的語句使用了explain查看了語句的性能,防止在寫的時候,犯一些低級的錯誤,例如將可以索引查找的變成表掃描一類的
(5)分析在運行中出現的各種問題,例如在使用事務通過先刪除,后插入的方式實現對所有圖片和特征替換的時候,會發現死鎖的出現,在使用percona toolkit的pt-deadlock-logger分析,可以看出這種死鎖來源於插入和刪除的沖突, 查看innodb status的輸出,可以看到間隙鎖,應該是導致出現出現這種問題的原因。
(6)糾正一些自己的錯誤。
測試過程中,考慮使用的助手:
(1)高性能mysql中的狀態記錄:
#!/bin/sh INTERVAL=5 PREFIX=$INTERVAL-sec-status RUNFILE=/home/sun/benchmarks/running mysql -e 'SHOW GLOBAL VARIABLES' >> mysql-variables while test -e $RUNFILE; do file=$(date +%F_%I) sleep=$(date +%s.%N | awk "{print $INTERVAL - (\$1 % $INTERVAL)}") sleep $sleep ts="$(date +"TS %s.%N %F %T")" loadavg="$(uptime)" echo "$ts $loadavg" >> $PREFIX-${file}-status mysql -e 'SHOW GLOBAL STATUS' >> $PREFIX-${file}-status & echo "$ts $loadavg" >> $PREFIX-${file}-innodbstatus mysql -e 'SHOW ENGINE INNODB STATUS\G' >> $PREFIX-${file}-innodbstatus & echo "$ts $loadavg" >> $PREFIX-${file}-processlist mysql -e 'SHOW FULL PROCESSLIST\G' >> $PREFIX-${file}-processlist & echo $ts done echo Exiting because $RUNFILE does not exist
注: 上述腳本中的date +%F_%I使用的是12小時制, 也就是說1點和13點的記錄會存在同一個文件中,如果想用24小時制, 可以改為 date +%F_%H.
這段腳本,對於分析mysql狀態來說,非常有益。另外,下面分析用的腳本,同樣很重要:
#!/bin/bash # This script converts SHOW GLOBAL STATUS into a tabulated format, one line # per sample in the input, with the metrics divided by the time elapsed # between samples. awk ' BEGIN { printf "#ts date time load QPS"; fmt = " %.2f"; } /^TS/ { # The timestamp lines begin with TS. ts = substr($2, 1, index($2, ".") - 1); load = NF - 2; diff = ts - prev_ts; prev_ts = ts; printf "\n%s %s %s %s", ts, $3, $4, substr($load, 1, length($load)-1); } /Queries/ { printf fmt, ($2-Queries)/diff; Queries=$2 } ' "$@"
對於上面的bash腳本,有一點要特別主要, /Queries/ 可能需要替換成$1~/^Com_insert$/, $1~/^Com_select$/或者$1~/^Questions$/等, Queries在之后的出現,也做相應的替換,這樣才是你需要的QPS,具體是使用Com_insert, Com_select,還是Questions,查看一下mysql官方文檔。對於我的測試操作測試來看,我使用的go語言的客戶端prepare語句,Queries得出的數值大約是Com_insert的四倍,而Com_insert得到的數值才是我需要的QPS。
(2)慢日志查詢
這里可以考慮啟用表格版的慢日志查詢,文件版本更加精確,但是查看需要先使用腳本一類的分析比較好。啟用表格版本的慢日志查詢如下:
(1) 將log_output設置為TABLE, 設置如下 set global log_output="TABLE"; (2) 將slow_query_log設置為ON, 設置如下 set global slow_query_log=ON; (3) 設置合適的long_query_time set global long_query_time=0.5; show global variables like 'long_query_time';
查詢慢日志中的內容,可以使用如下語句:
select * from mysql.slow_log;
如果用於測試,注意其中的global標志,因為基本是在另外一個連接中啟用的慢日志查詢,所以global標志是必要的,而且show global variables like 'long_query_time' 和show variables like 'long_query_time'得到的結果可能是不同的。
慢日志查詢的作用是告訴你,哪條語句執行比較慢,從而給你一定的指導,免得在優化的路上走錯了方向。我在寫插入語句的過程中,使用了先獲取組列表,然后獲取成員列表,然后獲取圖片表中的自增ID,然后執行插入特征, 在innodb_buffer_pool_size足夠大的情況下,執行時間比較長的基本都是插入特征的語句,根據這個,我可以判斷對於這種操作來說,我需要優化的主要是插入特征這個部分。
(3)explain語句或者explain extended,這里我簡單介紹一下這個語法。
先給出查詢語句:
explain insert into portrait_123(person_id, file_id, description) values ((select person_123.id from person_123 inner join group_123 where person_123.name = 'xxxx' and group_123.name = 'xxxxx'), 'xxxx', 'xxxx');
輸出結果為:
*************************** 1. row *************************** id: 1 select_type: SIMPLE table: group_123 type: const possible_keys: name key: name key_len: 22 ref: const rows: 1 Extra: Using index *************************** 2. row *************************** id: 1 select_type: SIMPLE table: person_123 type: index possible_keys: NULL key: group_id key_len: 26 ref: NULL rows: 921849 Extra: Using where; Using index 2 rows in set (0.00 sec)
這里,我們首先需要關注rows,也就是需要掃描的估計行數,如果這個值和預計的一致,基本可以確定這個語句是合理的。對於這個案例來說,我們可以使用組名(groupName)的唯一索引從組表中獲取組的ID,然后使用(組ID,用戶名)的唯一索引獲取用戶的ID,也就是說明,上述兩個row中,每一個,我們都應該是可以只掃描一行就得到結果的。而第二個結果中,可以看出,基本掃描了整個表格,才得到結果。下面,簡單描述一下各個字段,其實,我覺得,主要關注掃描的行數,基本可以判斷語句是否合理。
id: select語句的標識符,用來標識是第幾個select語句。如果這一行是其他行的union結果,這個值為NULL. 在這種情況下,table列顯示為類似於<unionM, N>來表示id值為M和N的行的union.
select_type: select的類型,有如下值:
(1)simple 簡單select查詢(不使用union或者子查詢)
(2)primary 最外層的select查詢
(3)union union中第二個及以后的查詢語句
(4)dependent union 依賴於外層查詢的union中的第二個及以后的查詢語句
(5)union result union的結果
(6)subquery 子查詢中的第一個select
(7)dependent subquery 依賴於外層查詢的子查詢中的第一個查詢
(8)derived 派生表
(9)materialized 物化子查詢
(10)uncacheable subquery 對於外層查詢的每一行,都需要重新計算的子查詢
(11)uncacheable union uncacheable subquery包含的union語句中的第二個及以后的查詢
table: 掃描行(結果行)對應的表格。如果為union查詢,派生表查詢或者子查詢,可能會顯示為 <unionM, N>, <derivedN>或者<subqueryN>。
type: 官方描述是 "join type", 更合理的說法應該是“access type",我覺得你可以理解為掃描類型,也就是說,mysql如何查找對應的行。從好到壞依次是:
1)system 當訪問的表只有一行時(系統表),特殊類型的const。
2)const 最多只有一行匹配。當mysql查找過程可以使用主鍵索引或者其他唯一索引匹配一個常量時,就會出現const。
3)eq_ref 當一個主鍵或者非空唯一鍵和從其他表中獲取的常量或表達式做相等比較時。
4)ref 使用=或者<=>做相等比較,這里的鍵不為唯一鍵,或者只是唯一鍵的前綴,含義與(3)相同。
5)ref_or_null 與ref基本相同,只是可以匹配NULL值。
6) index_merge 使用index merge優化進行查詢。
7)unique_subquery 這種類型使用eq_ref來處理in子查詢。
8)index_subquery 這種類型可以理解為使用ref來處理in子查詢。
9)range 某個鍵在指定范圍內的值。
10) index 使用索引進行掃描。
11)ALL 全表掃描
注:從好到壞的順序基本是大致的,並不是從上到下嚴格變壞。例如:對於小表來說,全表掃描的性能很多時候優於index方式,甚至range等方式。對於index和ALL兩種方式,如果index方式可以使用覆蓋掃描,那么index方式大多時候更優(當Extra列有using index顯示),其他時候,很有可能全表掃描更快,因為全表掃描不需要回表查詢。這些顯示的類型,最好參照掃描行數一起查看。
possible_keys: mysql可能選擇的用來查找表格中對應行的索引。這一行顯示的結果與explain輸出中顯示的表格順序無關,也就是說,在實際中,對於顯示的表格順序,possible_keys中的某些索引可能不能夠使用。如果這個值為空,可以考慮檢查語句,然后創建一個合適的索引。
key: mysql實際決定使用的索引。對於key中顯示的名字,可以參考show index得到的結果,show index的結果中,有比較詳細的索引說明信息。當possible_keys中沒有合適的索引時,mysql也會使用某些索引來實現覆蓋索引的效果。
key_len: 索引大小。對於innodb來說,如果使用二級索引,計算時,不會考慮主鍵的長度。 key_len的用處在於,對於一個復合索引,可以根據key_len來確定使用的索引的全部,還是某個特定前綴。關於key_len的計算,對於基本類型,如int,為4個字節。但是,對於字符型,尤其是varchar(n)的計算方式如下:對於utf8(或者說utf8mb3),計算索引長度的時候為3×n+2,對於utf8mb4,計算索引長度為4×n+2,對於其他的字符型,可以簡單構建一個表格進行測試,或者查詢相關文檔。
ref: 顯示與key列中的索引進行對比的是哪一列,或者說是一個常數。如果值是一個const,那么對比對象應該是一個常量(或者type 為const的select結果), 如果值是一個func,那么對比對象應該是某個函數的結果。
rows: mysql認為在執行這個語句過程中必須要檢查的行數。我覺得這個數值非常重要,是最終要的衡量語句好壞的指示器。對於innodb來說,這個數值可能不是精確值,如果與精確值偏差很大,可以考慮執行analyze table,來更新innodb關於表的統計信息,不過,就算是這樣,也不能保證獲取的值足夠精確。這個值在很多時候,已經足夠我們確定所寫語句是否足夠優秀。
Extra: 這一列顯示mysql如何處理查詢的額外信息。重要的一些如下:
1)Using filesort, Using temporary
這個標識獲取結果的時候需要使用臨時表排序,可以考慮對索引進行優化(調整索引順序,添加必要的索引),或者對order by條件進行修改(例如,對於一些不展示的獲取來說,不一定需要按照名字排序),如果實在需要使用臨時表排序,考慮一下tmp_table_size和max_heap_table_size是否合理,如果你想知道內存臨時表和硬盤臨時表的信息,可以查看Created_tmp_disk_tables和Created_tmp_tables的數目(使用show status和show global status)。
2)Using index
這個標識是覆蓋索引掃描的標識。說明查詢過程中只需要檢測索引內容就可以,而不用回表查詢。
(4)set profiling=1; show profiles; show profile for query N; set profiling=0;
或者:使用performance shema來進行查詢剖析。
我們先介紹set profiling相關的語句,這個語句是Session級別的,也就是說,單個連接有效,如果想要在一個Session中記錄profiling信息,必須要在這個連接中啟用profiling, 這種方式,有自己的便捷性,尤其適合使用mysql提供的客戶端進行一些簡單的profiling,而對於代碼實現的一些調用來說,往往不那么友好。具體使用方法如下:
set profiling = 1; select count(*) from group_123; show profiles\G
*************************** 1. row *************************** Query_ID: 1 Duration: 0.00122225 Query: select count(*) from group_123 1 row in set, 1 warning (0.00 sec)
show profile for query 1;
+--------------------------------+----------+ | Status | Duration | +--------------------------------+----------+ | starting | 0.000133 | | Executing hook on transaction | 0.000017 | | starting | 0.000020 | | checking permissions | 0.000017 | | Opening tables | 0.000073 | | init | 0.000022 | | System lock | 0.000027 | | optimizing | 0.000017 | | statistics | 0.000041 | | preparing | 0.000039 | | executing | 0.000013 | | Sending data | 0.000699 | | end | 0.000016 | | query end | 0.000009 | | waiting for handler commit | 0.000019 | | closing tables | 0.000017 | | freeing items | 0.000028 | | cleaning up | 0.000019 | +--------------------------------+----------+ 18 rows in set, 1 warning (0.00 sec)
set profiling=0;
通過上述表格輸出,可以看到調用這條語句需要總的時間,已經在每個步驟中需要的時間,可以針對性的進行優化。如果希望可以直接看到耗時最多一些步驟,也可以考慮使用如下語句,按照耗時從多到少顯示:
set @query_id = 1; SELECT STATE, SUM(DURATION) as Total_R, ROUND( 100 * SUM(DURATION)/ (select SUM(DURATION) from INFORMATION_SCHEMA.PROFILING where QUERY_ID = @query_id), 2) AS Pct_R, COUNT(*) AS Calls, SUM(DURATION) / COUNT(*) AS "R/Call" FROM INFORMATION_SCHEMA.PROFILING where QUERY_ID = @query_id group by STATE ORDER BY Total_R DESC;
輸出結果如下:
+--------------------------------+----------+-------+-------+--------------+ | STATE | Total_R | Pct_R | Calls | R/Call | +--------------------------------+----------+-------+-------+--------------+ | Sending data | 0.000699 | 57.01 | 1 | 0.0006990000 | | starting | 0.000153 | 12.48 | 2 | 0.0000765000 | | Opening tables | 0.000073 | 5.95 | 1 | 0.0000730000 | | statistics | 0.000041 | 3.34 | 1 | 0.0000410000 | | preparing | 0.000039 | 3.18 | 1 | 0.0000390000 | | freeing items | 0.000028 | 2.28 | 1 | 0.0000280000 | | System lock | 0.000027 | 2.20 | 1 | 0.0000270000 | | init | 0.000022 | 1.79 | 1 | 0.0000220000 | | waiting for handler commit | 0.000019 | 1.55 | 1 | 0.0000190000 | | cleaning up | 0.000019 | 1.55 | 1 | 0.0000190000 | | optimizing | 0.000017 | 1.39 | 1 | 0.0000170000 | | closing tables | 0.000017 | 1.39 | 1 | 0.0000170000 | | Executing hook on transaction | 0.000017 | 1.39 | 1 | 0.0000170000 | | checking permissions | 0.000017 | 1.39 | 1 | 0.0000170000 | | end | 0.000016 | 1.31 | 1 | 0.0000160000 | | executing | 0.000013 | 1.06 | 1 | 0.0000130000 | | query end | 0.000009 | 0.73 | 1 | 0.0000090000 | +--------------------------------+----------+-------+-------+--------------+ 17 rows in set, 18 warnings (0.01 sec)
下面介紹使用performance schema來進行查詢剖析:
peformance_schema中,存儲相關信息的表格是events_statements_history_long和events_stages_history_long。開啟方式如下:
1)確定相關語句和階段測量已經開啟,開啟方式是通過setup_instruments表格。一些測試會被默認開啟。
update performance_schema.setup_instruments set ENABLED = 'YES', TIMED = 'YES' where name like '%statement/%'; update performance_schema.setup_instruments set ENABLED = 'YES', TIMED = 'YES' where name like '%stage/%';
2) 確保events_statements_*和events_stages_*消費者已經被啟用。一些消費者會默認開啟。
update performance_schema.setup_consumers set ENABLED = 'YES' where name like '%events_statements_%'; update performance_schema.setup_consumers set ENABLED = 'YES' where name like '%events_stages_%';
(3) 運行你打算profile的語句,這里開始的profiling是全局模式的,所以,你可以在其他連接中調用語句。例如調用如下語句:
select count(*) from group;
(4)使用performance_schema中的表格進行查詢,注意其中的EVENT_ID,這個字段和Query_ID類似,這里的時間單位是皮秒,轉化成秒,需要除以10的12次方。
select event_id, truncate(timer_wait/1000000000000,6) as Duration, SQL_TEXT From performance_schema.events_statements_history_long;
+----------+----------+------------------------------------------------------------+ | event_id | Duration | SQL_TEXT | +----------+----------+------------------------------------------------------------+ | 312 | 0.003801 | truncate performance_schema.events_statements_history_long | | 325 | 0.000861 | select count(*) from group_123 | +----------+----------+------------------------------------------------------------+
2 rows in set (0.00 sec)
SELECT event_name AS Stage, TRUNCATE(TIMER_WAIT/1000000000000,6) AS Duration FROM performance_schema.events_stages_history_long WHERE NESTING_EVENT_ID=325;
+------------------------------------------------+----------+ | Stage | Duration | +------------------------------------------------+----------+ | stage/sql/starting | 0.000122 | | stage/sql/Executing hook on transaction begin. | 0.000001 | | stage/sql/starting | 0.000009 | | stage/sql/checking permissions | 0.000006 | | stage/sql/Opening tables | 0.000061 | | stage/sql/init | 0.000007 | | stage/sql/System lock | 0.000016 | | stage/sql/optimizing | 0.000006 | | stage/sql/statistics | 0.000030 | | stage/sql/preparing | 0.000025 | | stage/sql/executing | 0.000001 | | stage/sql/Sending data | 0.000503 | | stage/sql/end | 0.000002 | | stage/sql/query end | 0.000002 | | stage/sql/waiting for handler commit | 0.000014 | | stage/sql/closing tables | 0.000012 | | stage/sql/freeing items | 0.000029 | | stage/sql/cleaning up | 0.000001 | +------------------------------------------------+----------+ 18 rows in set (0.00 sec)
對於上述語句,可以參考profiling的處理,進行一定的順序排列和匯總。
關於上述的profiling在使用觸發器的情況下,會有一定的問題,我在這里簡單介紹一下。場景如下:
1)有group表存儲組的信息,person表存儲用戶信息,group_member存儲組的成員關系,也就是成員關系。group表的設計如下:
CREATE TABLE `group_123` ( `id` mediumint(8) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(20) NOT NULL, `description` varchar(60) NOT NULL DEFAULT '',PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `person_123` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(20) NOT NULL, `description` varchar(60) NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `group_member_123` ( `group_id` mediumint(8) unsigned NOT NULL, `person_id` int(10) unsigned NOT NULL, PRIMARY KEY (`group_id`,`person_id`), KEY `person_id` (`person_id`), CONSTRAINT `group_member_123_ibfk_1` FOREIGN KEY (`group_id`) REFERENCES `group_123` (`id`) ON DELETE CASCADE, CONSTRAINT `group_member_123_ibfk_2` FOREIGN KEY (`person_id`) REFERENCES `person_123` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
2) 觸發器如下,
CREATE TRIGGER `instri_member` AFTER INSERT ON `group_member` FOR EACH ROW insert into group_count_123 values (NEW.group_id, 1) on duplicate key update count = count+1 CREATE TRIGGER `deltri_member` AFTER DELETE ON `group_member` FOR EACH ROW update group_count_123 set count = count-1 where group_id = OLD.group_id
3) 進行如下的插入語句操作:
insert into group_member_123 values((select id from group_123 where name = 'xxx'), (select id from person_123 where name = 'yyy'));
調用show profiles,顯示結果如下:
+----------+------------+----------------------------------------------------------------------------------------------+ | Query_ID | Duration | Query | +----------+------------+----------------------------------------------------------------------------------------------+ | 1 | 0.01630200 | insert into group_count_123 values (NEW.group_id, 1) on duplicate key update count = count+1 | +----------+------------+----------------------------------------------------------------------------------------------+ 1 row in set, 1 warning (0.00 sec)
這里需要注意兩點:
(1)我們調用的語句沒有出現,而觸發器的語句被記錄。
(2)觸發器觸發的這條語句時間很短。
然后我們調用show profile for query 1, 結果如下:
+--------------------------------+----------+ | Status | Duration | +--------------------------------+----------+ | continuing inside routine | 0.000034 | | Executing hook on transaction | 0.000010 | | Sending data | 0.000013 | | checking permissions | 0.000012 | | Opening tables | 0.000049 | | init | 0.000021 | | update | 0.016120 | | end | 0.000018 | | query end | 0.000005 | | closing tables | 0.000022 | +--------------------------------+----------+ 10 rows in set, 1 warning (0.00 sec)
與show profiles的結果基本一致。
下面,我們再看一下,使用performance_schema的統計信息:
SELECT EVENT_ID, TRUNCATE(TIMER_WAIT/1000000000000,6) as Duration, SQL_TEXT FROM performance_schema.events_statements_history_long; +----------+----------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | EVENT_ID | Duration | SQL_TEXT | +----------+----------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | 252 | 0.004271 | truncate table performance_schema.events_statements_history_long | | 198 | 0.016278 | insert into group_count_123 values (NEW.group_id, 1) on duplicate key update count = count+1 | | 176 | 0.050943 | insert into group_member_123 values((select id from group_123 where name = '69be790a-332a-400e-8'), (select id from person_123 where name = 'dca83c7d-b1f6-4193-b')) | +----------+----------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 3 rows in set (0.00 sec)
SELECT event_name AS Stage, TRUNCATE(TIMER_WAIT/1000000000000,6) AS Duration -> FROM performance_schema.events_stages_history_long WHERE NESTING_EVENT_ID=176; +------------------------------------------------+----------+ | Stage | Duration | +------------------------------------------------+----------+ | stage/sql/starting | 0.000249 | | stage/sql/Executing hook on transaction begin. | 0.000009 | | stage/sql/starting | 0.000020 | | stage/sql/checking permissions | 0.000013 | | stage/sql/checking permissions | 0.000011 | | stage/sql/checking permissions | 0.000011 | | stage/sql/Opening tables | 0.000177 | | stage/sql/init | 0.000017 | | stage/sql/System lock | 0.000028 | | stage/sql/update | 0.000014 | | stage/sql/optimizing | 0.000018 | | stage/sql/statistics | 0.000116 | | stage/sql/preparing | 0.000022 | | stage/sql/executing | 0.000007 | | stage/sql/Sending data | 0.000015 | | stage/sql/optimizing | 0.000011 | | stage/sql/statistics | 0.000055 | | stage/sql/preparing | 0.000014 | | stage/sql/executing | 0.000006 | +------------------------------------------------+----------+ 19 rows in set (0.00 sec)
SELECT event_name AS Stage, TRUNCATE(TIMER_WAIT/1000000000000,6) AS Duration FROM performance_schema.events_stages_history_long WHERE NESTING_EVENT_ID=198; +------------------------------------------------+----------+ | Stage | Duration | +------------------------------------------------+----------+ | stage/sql/Sending data | 0.000330 | | stage/sql/Executing hook on transaction begin. | 0.000007 | | stage/sql/Sending data | 0.000013 | | stage/sql/checking permissions | 0.000011 | | stage/sql/Opening tables | 0.000051 | | stage/sql/init | 0.000019 | | stage/sql/update | 0.016126 | | stage/sql/end | 0.000007 | | stage/sql/query end | 0.000004 | | stage/sql/closing tables | 0.000010 | | stage/sql/end | 0.000005 | | stage/sql/query end | 0.000005 | | stage/sql/waiting for handler commit | 0.033397 | | stage/sql/closing tables | 0.000043 | | stage/sql/freeing items | 0.000047 | | stage/sql/cleaning up | 0.000002 | +------------------------------------------------+----------+ 16 rows in set (0.00 sec)
4) 總結:
由上面輸出結果可以看出來,在有觸發器存在的情況下, 使用performance_schema查看運行時間是更加明智的選擇,因為set profiling的方式只會顯示觸發器操作需要的時間,而這個時間很多時候是耗時比較少的操作。
服務器參數調整
先給出比較合理的初始數據配置參數:
[mysqld] # GENERAL datadir = /var/lib/mysql socket = /var/lib/mysql/mysql.sock pid_file = /var/lib/mysql/mysql.pid user = mysql port = 3306 default_storage_engine = InnoDB #INNODB innodb_buffer_pool_size = <value> innodb_log_file_size = <value> innodb_file_per_table = 1 innodb_flush_method = O_DIRECT # MyISAM key_buffer_size = <value> # LOGGING log_error = /var/lib/mysql/mysql-error.log slow_query_log_file = /var/lib/mysql/mysql-slow.log # OTHER tmp_table_size = 32M max_heap_table_size = 32M query_cache_type = 0 query_cache_size = 0 max_connections = <value> thread_cache_size = <value> table_open_cache = <value> open_files_limit = 65535 [client] socket = /var/lib/mysql/mysql.sock port = 3306
簡單說明一下,我下面描述的插入測試數據基於的插入方案是,先獲取大約100個組,然后啟用100個go程(可以理解為線程)並發對100個組下所有的用戶的所有圖片特征進行插入,然后壓測插入速度等信息。我的插入操作在兩台不同的計算機上進行,內網環境,發送2K數據需要的時間小於1ms.
(1)首先需要設置的是innodb_buffer_pool_size,這個值應該在保證系統穩定的情況下,設置的盡量大,計算方式比較復雜,可以參考《高性能mysql》或者官方的建議,設置為內存總量的50%到75%。這是一個非常重要的配置參數,所以說提供比較大的內存對mysql來說很重要,因為只有內存足夠大,才可以將這個值設置的比較大。按照我的測試,我的innodb_buffer_pool_size設置為8G的樣子,在數據量達到二十多個G(其中feature表大約占80%左右的數據量,有上千萬的數據,每條數據大約2K的樣子)的時候,使用select count(*) from feature__123_1; 需要耗時三到四個小時,這其中與沒有secondary index有關,所以不能使用索引覆蓋掃描,但是,如果innodb_buffer_pool_size足夠大,數據基本都在內存中,那么執行上述指令的速度應該快的多。
(2)其次,innodb_log_file_size對於插入操作來說,也很重要。在innodb_buffer_pool_size足夠容納所有數據的情況下,按照我的測試過程來說, 在innodb_log_file_size由50M改為1G(其中innodb_log_files_in_group
都為2),插入速度大約提升了70%的樣子,在innodb_log_file_size為50M時,插入速度大約為500次/s(平均值),在innodb_log_file_size為1G時,插入速度大約為850次/s(平均值)。實際上,我這里設置的innodb_log_file_size還是比較小,按照官方說明,對於我的這里的情況,如果我希望每秒可以插入1000次,那么需要的innodb_log_file_size為1000×2K×3600(一小時)/ 2(innodb_log_files_in_group) = 3.5G。我沒有將這個值設置這么大測試過,有條件的情況下,可以嘗試。
關於更新innodb_log_file_size,方法如下(對於mysql版本大於5.6.7):
1) 停止mysql服務器,並且確定服務器在關閉過程中沒有出現錯誤。
2)修改my.cnf改變log file的配置。想要修改log file的大小,配置innodb_log_file_size,想要增加log files的個數,配置innodb_log_files_in_group.
3) 重新啟動mysql服務器。
在mysql小於等於5.6.7的時候,更新innodb_log_file_size的值比較復雜,建議參考官網:
https://dev.mysql.com/doc/refman/5.6/en/innodb-redo-log.html
(3)innodb_flush_method這個參數,按照我的測試結果來說,O_DIRECT效果很好,至少遠比O_DSYNC(官方推薦過這種刷新方式,見https://dev.mysql.com/doc/refman/5.6/en/optimizing-innodb-diskio.html)好。使用O_DSYNC的時候,數據預熱的速度明顯變慢,在執行完查詢,然后插入之后,再次執行類似的操作,速度提升的速度明顯慢於O_DIRECT,而且插入速度也不如O_DIRECT。我在使用O_DSYNC的時候,甚至出現select id from feature_1 比 select count(*) from feature_1慢很多的情況。所以,對於想要使用O_DSYNC的,建議進行詳細的測試再說。
(4)I/O 調度算法,這個參數實際上不是服務器參數,而是linux系統調度算法。根據《高性能mysql》和官方推薦(Use a noop or deadline I/O scheduler with native AIO on Linux),使用deadline應該優於cfq。在我上面的插入測試條件下,使用deadline時,插入速度大約比使用cfq快10%的樣子。使用deadline時,插入速度大約為930次/s(平均值)。我沒有測試過noop方法,因為按照《高性能mysql》上的描述,對於單硬盤來說,deadline應該比noop更合適,對於磁盤冗余陣列(線上常用方案),可以對noop和deadline同時進行壓測,查看哪一種調度算法更加合適。當然,也可以同時測試cfq,或者其他可用的調用算法。
(5)max_connections根據實際同時會有的連接數進行設置,這個值可以設置的稍微大一點,具體根據實際應用來考慮,也可以監測Connection_errors_max_connections
的值,查看這個值在實際運行中的變化,如果出現較多的這個錯誤,可以考慮調大max_connections的值。具體可以參考《高性能mysql》中文版No.370或者官網的說明(https://dev.mysql.com/doc/refman/5.6/en/client-connections.html)。
(6)thread_cache_size這個值表示mysql緩存的線程數。如果Threads_connected(用戶連接)的變化比較大的話,可以考慮將這個值設置的大一些,也可以監測Threads_created的數值變化,如果增長的速度比較快,則應該也需要調大thread_cache_size的值。具體可以參考《高性能mysql》中文版的No.370。
(7)table_open_cache這個值表示緩存的用於打開表的文件描述符。在mysql5.6.8以后的版本中,這個值已經很大了,應該不需要調整,除非你覺得你的表很多很多。
壓測和測試過程中的問題:
1. 插入速度比較慢的問題。
對於這個問題,自然可以通過將innodb_flush_log_at_trx_commit改為2來顯著提高mysql的插入速度,這種方式是不安全的。最好的方式應該是將innodb_flush_log_at_trx_commit設置為1,然后將innodb的日志文件放在有電池保護的寫緩存的RAID卷中,同時將innodb_flush_method設置為O_DIRECT。
另外,使用自增鍵作為主鍵,也可以提高插入的速度。
2. 讀取速度比較慢。
對於我的例子來說,當mysql存儲的數據大約在24G,而innodb_buffer_pool_size為8G時,對於feature表(占據數據的80%)左右,調用select count(*) from feature;這樣的語句,都需要運行三到四個小時,一方面,因為feature表中沒有二級索引,所以沒有辦法使用覆蓋索引掃描(對於二級索引來說,基本都在內存中,而且二級索引數據量往往比較小),另一方面,如果innodb_buffer_pool_size的量足夠大,那么獲取表的條數應該還是會比較快,前提是數據已經得到了預熱。關於讀取,我有如下的建議:
(1)如果本身數據就會被隨機訪問,例如我這里的例子,在最初的設計中,我們沒有辦法確定group中成員特征被訪問的情況,默認為每個group都是會被隨機訪問的,那么幾乎所有的特征被同樣可能性的訪問到,在這種情況下,數據訪問沒有熱點,我們可以做的一種方式是,通過數據分割,提供比較大的內存,來保證innodb_buffer_pool_size的大小和數據量差不多或者略大,然后通過事先預熱來保證讀取速度滿足我們的需求。
(2)如果innodb_buffer_pool_size比數據量小,但是相差不是很大。我們可以考慮是否可以使用數據壓縮,在我這個例子中,我測試過對特征數據的壓縮,壓縮率可以達到50%的樣子。如果可以通過壓縮的方式使得innodb_buffer_pool_size可以容納常用數據的話,增加一定的CPU開銷(也可能有邏輯復雜度開銷),應該是值得的,對於我的例子來說,我觀察過mysql的CPU利用率,CPU利用率比較低。
(3)如果存在某些很少檢查的大數據,對於這類數據,放在單獨的表格上比較合適,然后使用外健關聯,這樣也可以減少緩存的負擔。
(4)如果數據量很大,遠遠超過innodb_buffer_pool_size的大小,那么可以實際測試使用不同大小的innodb_buffer_pool_size的大小,和查詢QPS一類數據的關系,然后找到一個比較合適的innodb_buffer_pool_size的大小。
提高讀取硬盤的速度,也是一個可以采取的手段。
1)使用SSD的RAID卡,如果沒有這個條件,可以使用RAID卡,將讀取壓力分擔到不同的磁盤上。
2)修改表的結構。將feature的主鍵改為(personID+fileID)。對於我的這個例子來說,因為每次調用基本都是一個人的特征和一個組內的特征進行比較,那么一次獲取同一個人的所有特征這種情況,應該是比較合理的訪問數據庫的方式,將同一個人的所有特征存儲在一起,那么就可以順序獲取這些特征,將減少很多的隨機訪問,使得讀取速度可以提高很多。
(5)查看是否必須存儲這么多的數據,或者說是否必須存儲這么多的熱數據。對於我這個例子來說,后來改為根據所有圖片的特征生成一個總的特征,通過這種方式,數據庫中減少了大量的存儲,至少減少了大量的熱數據。
3. 不要隨便執行一些修改表的結構的語句。
在《高性能mysql》中,我們看到有一條語句叫做optimize table。對於這類語句,不要隨便調用,在我之前出現的數據量為24G,innodb_buffer_pool_size為8G的情況下,我調用了這個語句,mysql花費了七八個小時才執行完成。
4. mysql的觸發器在外健刪除的情況下不會觸發。
我們存在一個統計組內有多少成員的需求,按照考慮,有兩種方式實現,
一種方式是使用查詢緩存(如果打算使用查詢緩存的話, 最好將query_cache_type設置為DEMAND, 然后在語句中使用SQL_CACHE修飾符, 不過在8.0.3中, 這個服務器參數已經被移除),當修改組內成員的操作很少的情況下,可以考慮對select count(*)語句使用查詢緩存,然后下一次訪問就可以不實際計算,這個適用於修改很少,或者組內成員一般不太多的情況。
第二種方式是使用匯總表。這個是我們實際使用的統計方式,參考上面說匯總表的情況。在使用匯總表的過程中,存在一個小的問題,就是說在刪除成員的時候,會使用外健關聯刪除成員表中的對應角色,在這個情況下,成員表上的觸發器不會被觸發,沒辦法更新匯總表上的數據。對於這種問題,可以在成員表中新增加一個觸發器,讓這個觸發器直接產生刪除角色表中對應數據的效果,就可以解決這個問題,雖然更加麻煩,可以滿足要求。
在刪除角色的時候,會涉及對group_member表中的刪除角色ID所在的所有組進行計數減一處理,這個操作最直觀的語句如下:
explain update group_count_123 set count=count-1 where group_id in (select group_id from group_member_123 where person_id = 1);
這個語句的執行效果如何呢?
在group_count包含group表中的所有組ID,測試時,使用的是999個組,group_member中有1011行,其中person_id為1的行數是8行。上述語句的結果如下:
+----+--------------------+--------------------+------------+-----------------+-------------------+---------+---------+------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------------+--------------------+------------+-----------------+-------------------+---------+---------+------------+------+----------+-------------+ | 1 | UPDATE | group_count_123 | NULL | index | NULL | PRIMARY | 3 | NULL | 999 | 100.00 | Using where | | 2 | DEPENDENT SUBQUERY | group_member_123 | NULL | unique_subquery | PRIMARY,person_id | PRIMARY | 7 | func,const | 1 | 100.00 | Using index | +----+--------------------+--------------------+------------+-----------------+-------------------+---------+---------+------------+------+----------+-------------+ 2 rows in set, 1 warning (0.00 sec
如何理解這個結果呢?我們關注一下行數,rows的行數與group_count_123表格相同,第一張表的select_type為update,而第二張表的select_type為dependent subquery,這里的意思就是,這條語句會遍歷group_count_123的所有行,然后對於每一行取出的group_id和person_id聯合獲取group_member_123中的唯一一條記錄,或者沒有記錄。我的佐證是Extra中的Using where和id為2查詢中的key_len為7,正好是3(group_id的長度)+4(person_id的長度)。
有沒有更優的處理方式,尤其是如果一個組內的成員不多的情況,這種方式顯然掃描了很多無用的行,有的,語句如下:
explain update group_count_123 inner join group_member_123 using(group_id) set count=count-1 where person_id = 1;
讓我們來看一下這個語句的輸出結果:
+----+-------------+--------------------+------------+--------+-------------------+-----------+---------+---------------------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+--------------------+------------+--------+-------------------+-----------+---------+---------------------------------+------+----------+-------------+ | 1 | SIMPLE | group_member_123 | NULL | ref | PRIMARY,person_id | person_id | 4 | const | 8 | 100.00 | Using index | | 1 | UPDATE | group_count_123 | NULL | eq_ref | PRIMARY | PRIMARY | 3 | face_id.group_member.group_id | 1 | 100.00 | NULL | +----+-------------+--------------------+------------+--------+-------------------+-----------+---------+---------------------------------+------+----------+-------------+ 2 rows in set, 1 warning (0.01 sec)
這里需要掃描的行數和組內的成員個數一致,應該是最優的語句,執行順序也發生了改變,先執行查找語句,然后使用主鍵索引,找到對應的行進行更新。在這個例子中,因為group_count_123的行數不會低於一個成員所在的組的個數,所以第二種寫法不會比第一種寫法差,而且大多數時間都是優於第一種寫法。這個語句,我是根據《高性能mysql》的6.5.9節(在同一個表上查詢和更新)中的update語句改寫來的。
5. 插入成員表中速度太慢。
我在使用十線程向成員表中插入數據的時候,發現插入速度很慢,每秒只有兩三百。對於這個問題,我們需要做的是,查看單次插入需要的時間,以及單次插入花費在每個階段的時間。根據測量,因為單次插入大約需要30ms,而時間主要在最后的同步階段(query end),所以這個問題可以通過增加線程數來解決。當然,如果希望單線程插入速度顯著提升,就需要減少單次插入的時間,尤其是最后的同步階段的時間。
6. 事務修改一個角色的所有文件ID和特征,會出現大量的死鎖現象。
修改過程中,我使用了刪除再重新插入的方式,這種方式有一個優點,就算是新插入的某個角色的文件ID與之前的這個角色的文件ID存在重復的情況,也不會有問題。在實際運行過程中,會發現大量的死鎖回滾的錯誤,按照我的測試來說,如果使用20個線程以同樣的順序更新同一個組的所有成員的數據的話,回滾的數目和提交的數目基本一致。這個數據可以通過查看show global status like 'Com_commit'; 和 show global status like 'Com_rollback';得到。通過記錄show engine innodb status的數據,分析show engine innodb status的數據,或者通過pt-deadlock-logger檢測mysql消息可以得到。這個問題,可以通過當返回值表示是死鎖回滾的話,那么重新再進行幾次嘗試來解決。我們試着先插入,然后得到第一個插入的自增ID,然后刪除小於這個自增ID的特定用戶的fileID的方式,發現死鎖回滾的情況有了一定程度的減少,但是更新速度更慢了,我嘗試了10線程到50線程的插入測試,發現先刪除后插入都會更快,雖然死鎖回滾的次數也更多。按照我的這個測試結果來看,至少說明,死鎖回滾並不是非常致命的問題。我查看了官網對innodb鎖的介紹,還是沒有找到可以基本消除死鎖回滾的方式,還是需要繼續努力。