最近在實現一個列表查詢的功能時遇到了【性能】問題,因爲對後端性能方面沒有太深刻的瞭解和實踐,因此在發現問題後卡了較長時間,經過查閱文檔,藉助分析工具,最終找到並解決問題。html
這篇文章記錄我解決問題的過程和學習到的新知識,若是有理解錯誤的地方,請幫我指出。前端
執行環境:Postgres 9.6.0
,Sequelize 4.44.0
sql
我要實現的功能是提供一個後端列表查詢接口,這個接口涉及到數據庫的三個表,分別是 UserSurveys
, Surveys
, Questionnaires
。實體關係是 1:1:1
。數據庫
查詢接口從 User_Surveys
開始,經過外鍵 survey_id
關聯 Surveys
表,外鍵 quesitonnaire_id
關聯 Questionnaires
表。後端
這個接口須要支持根據 Questionnaire.name 過濾最終返回的 UserSurveys 條目。很簡單的需求,以前已經作過不少次,我不加思索地寫下如下代碼並迅速提交代碼(隱去了不相關的代碼邏輯),項目中使用 Sequelize (一個 Node.js ORM 庫)。網絡
await UserSurvey.findAndCountAll({
offset,
limit,
where: {
status,
},
include: [
{
model: Survey,
required: true,
include: [
{
model: Questionnaire,
required: true,
where: {
name: {
[Op.like]: `%${questoinnaire_name}%`
}
}
}
}
]
}
],
order: [
['created_at', 'DESC'],
]
});
複製代碼
發完測試環境,開發自測的過程當中發現,當我傳入 querstionnaire_name 的時候請求特別慢!session
慢到什麼程度?搜索【問卷】請求完成時間是 【14.89s】!!!簡單刷新重試幾回,確認問題來自後端接口。ide
順着請求路由,回溯到後端代碼,確認我編寫的代碼沒有問題。那就是 SQL
執行的問題?在日誌文件中找到最終執行的 SQL
語句。工具
EXPLAIN ANALYZE SELECT "survey->questionnaire"."id"
AS "survey.questionnaire.id", "survey->questionnaire"."name" AS "survey.questionnaire.name" FROM ("user_surveys" AS "user_survey"
INNER JOIN "surveys" AS "survey" ON "user_survey"."survey_id" = "survey"."id"
INNER JOIN "questionnaires" AS "survey->questionnaire" ON "survey"."questionnaire_id" = "survey->questionnaire"."id"
AND "survey->questionnaire"."name" like '%問卷%') where "user_survey".status = 'PUBLISH' ORDER BY"user_survey"."created_at" DESC LIMIT 10 OFFSET 0;
複製代碼
三個表經過條件 Inner Join
,Sequelize
生成的 SQL
沒什麼問題,怎麼確認是 SQL
執行語句的問題呢?oop
在 Postgres
執行 SQL
語句以前,會對查詢條件,數據屬性,表結構等進行分析,選出一種最優的 Query Plan
。在 Query Plan
中描述了 Postgres
將會使用何種方式掃描數據庫,如何鏈接表,運行成本等等信息。
經過閱讀理解這些信息,開發者就能更好地理解查詢語句的運行過程。
使用 EXPLAIN
命令很簡單,只須要在原有語句前添加 EXPLAIN
。例如官方文檔中的例子。
EXPLAIN SELECT * FROM tenk1;
QUERY PLAN
-------------------------------------------------------------
Seq Scan on tenk1 (cost=0.00..458.00 rows=10000 width=244)
複製代碼
執行 EXPLAIN SELECT * FROM tenk1;
獲得的運行結果 Postgres
給出的 QUERY PLAN
,結果中能夠看到將會使用 Seq Scan
的方式,遍歷 tenk1 表,cost=0.00..458.0
表示預計的啓動開銷 0.00
, 和返回全部記錄的總開銷458.00
,總共會返回 10000
行(rows),每行佔用內存 244 bytes
。
EXPLAIN
命令給出的是預估的執行計劃數據,並不會真正地運行語句,想獲得更真實的運行數據,須要使用加上 ANALYZE
參數。
EXPLAIN ANALYZE SELECT * FROM tenk1;
QUERY PLAN
--------------------------------------------------------------------------------------------------------
Seq Scan on tenk1 (cost=0.00..458.00 rows=10000 width=244) (actual time=0.128..0.377 rows=10000 loops=1)
Planning time: 0.181 ms
Execution time: 0.501 ms
複製代碼
加上 ANALYZE
參數以後,能夠看到操做被執行的總次數(loops
),而 actual time
和 rows
是每次執行的平均值,以及整個語句最終執行時長(Execution time)。
"cost" 使用的是任意單位,一般是關心其相對值。而"actual time"數值是以真實時間的毫秒計的。
使用ANALYZE
若是須要執行 UPDATE
,INSERT
,DELETE
等操做要留心,最好是在事務中執行。
BEGIN;
EXPLAIN ANALYZE UPDATE ...;
ROLLBACK; or COMMIT;
複製代碼
Query Plan 返回的是一個樹結構,執行順序是子節點優先,一個上層節點的開銷包括它的全部子節點的開銷。
舉個例子:
EXPLAIN ANALYZE SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2
ORDER BY t1.fivethous;
複製代碼
返回的 Query Plan
--------------------------------------------------------------------------------------------------------------------------------------------
Sort (cost=717.34..717.59 rows=101 width=488) (actual time=7.761..7.774 rows=100 loops=1)
Sort Key: t1.fivethous
Sort Method: quicksort Memory: 77kB
-> Hash Join (cost=230.47..713.98 rows=101 width=488) (actual time=0.711..7.427 rows=100 loops=1)
Hash Cond: (t2.unique2 = t1.unique2)
-> Seq Scan on tenk2 t2 (cost=0.00..445.00 rows=10000 width=244) (actual time=0.007..2.583 rows=10000 loops=1)
-> Hash (cost=229.20..229.20 rows=101 width=244) (actual time=0.659..0.659 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 28kB
-> Bitmap Heap Scan on tenk1 t1 (cost=5.07..229.20 rows=101 width=244) (actual time=0.080..0.526 rows=100 loops=1)
Recheck Cond: (unique1 < 100)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0) (actual time=0.049..0.049 rows=100 loops=1)
Index Cond: (unique1 < 100)
Planning time: 0.194 ms
Execution time: 8.008 ms
複製代碼
->
標記着樹節點,暫時移除開銷的信息,抽離出來樹幹結構以下。
Sort
Sort Key: t1.fivethous
Sort Method: quicksort Memory: 77kB
└── Hash Join
Hash Cond: (t2.unique2 = t1.unique2)
├── Seq Scan
└── Hash
└── Bitmap Heap Scan
└── Bitmap Index Scan
Index Cond: (unique1 < 100)
複製代碼
從這個結構中能夠看出,Postgres 使用 Seq Scan
的方式讀取 tenk2 表,再使用 Bitmap Heap Scan
和 Bitmap Index Scan
的方式掃描 tenk1 表,將符合條件(unique1 < 100)
的條目讀入內存 Hash。
接着使用 Hash Join
的鏈接方式,根據鏈接條件Hash Cond: (t2.unique2 = t1.unique2)
鏈接子節點的掃描結果。
最後使用 quicksort
的方式,基於 t1.fivethous
字段進行排序,獲得最終查詢結果。
數據庫中索引就像書籍中的關鍵字檢索,按字符順序排列,當咱們想看某個關鍵字在書本中出現的位置的時候,能夠經過這份 Index
快速定位到具體頁面,直接翻到對應的頁面,而不是從第一頁開始一頁一頁地查找關鍵字。
數據庫中分爲 ClusterIndex 和 NonClusterIndex,ClusterIndex 一個表只有一個,由數據庫根據 Table 惟一主鍵建立,NonClusterIndex 一個表能夠出現不少個,能夠由用戶自由維護。
當咱們在 DB 中運行 Create Index
時,就是建立了一個 NonClusterIndex,數據庫會維護一個結構來記錄 ClusterIndex 和 NonClusterIndex 的關係。
按照 NonClusterIndex 進行排序,以後使用索引就能快速定位到想要查找的條目。
不過須要注意:
在執行不一樣的 Query 時,規劃器可能會使用不一樣的掃描節點方式。
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 7000;
QUERY PLAN
------------------------------------------------------------
Seq Scan on tenk1 (cost=0.00..483.00 rows=7001 width=244)
Filter: (unique1 < 7000)
複製代碼
上面的例子中,規劃器將使用 Seq Scan
的方式掃描 tenk1 表的每一行,根據 Filter 條件過濾符合條件的條目。
有的時候,規劃器則會選擇使用 Index Scan
,經過索引條件直接查找。
EXPLAIN SELECT * FROM tenk1 WHERE unique1 = 42;
QUERY PLAN
-----------------------------------------------------------------------------
Index Scan using tenk1_unique1 on tenk1 (cost=0.29..8.30 rows=1 width=244)
Index Cond: (unique1 = 42)
複製代碼
在這種規劃類型中,表的數據行是以索引順序抓取的,意味着數據庫讀取頭須要在數據行以前來回跳動,數據量很大的狀況下,開銷很是大。
還有另外一種類型 Bitmap Heap Scan
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND stringu1 = 'xxx';
QUERY PLAN
------------------------------------------------------------------------------
Bitmap Heap Scan on tenk1 (cost=5.04..229.43 rows=1 width=244)
Recheck Cond: (unique1 < 100)
Filter: (stringu1 = 'xxx'::name)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0)
Index Cond: (unique1 < 100)
複製代碼
Bitmap Heap Scan 分爲兩步,首先經過 Bitmap Index Scan 從表中抓取出匹配的行,再由上層規劃節點在讀取他們以前按照物理位置排序,這樣能夠最小化開銷。
接着看錶鏈接相關的知識。
Nested loops 工做方式是循環從一張表中讀取數據,而後訪問另外一張表(一般有索引)。驅動表中的每一行與 inner 表中的相應記錄JOIN,相似一個嵌套的循環。
EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t1.unique2 = t2.unique2;
QUERY PLAN
--------------------------------------------------------------------------------------
Nested Loop (cost=4.65..118.62 rows=10 width=488)
-> Bitmap Heap Scan on tenk1 t1 (cost=4.36..39.47 rows=10 width=244)
Recheck Cond: (unique1 < 10)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..4.36 rows=10 width=0)
Index Cond: (unique1 < 10)
-> Index Scan using tenk2_unique2 on tenk2 t2 (cost=0.29..7.91 rows=1 width=244)
Index Cond: (unique2 = t1.unique2)
複製代碼
在這個例子中,規劃器使用了 Nested Loop
的錶鏈接方式,使用 Bitmap Heap Scan 掃描 tenk1 表,得到 10 行數據。接着 Nested Loop 將對得到的每行數據進行執行一次內部掃描,將匹配條件的 (unique2 = t1.unique2)
數據鏈接。運行成本約爲 【外層掃描開銷 39.27】 + 【內層掃描開銷 10 * 7.91】+ 一點 CPU 的計算時間。
使用 JS 代碼描述,過程大概以下,對於 tenk1 中的每一行數據,都會在 tenk2 中執行一次查找,若是沒有索引的狀況下,時間複雜度是 O(MN)。
for (const t1 of tenk1) {
for (const t2 of tenk2) {
if (t1.unique2 === t2.unique2) {
t1.tenk2 = t2;
break;
}
}
}
複製代碼
稍加修改查詢條件,可能會獲得另外一種鏈接方式
EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;
QUERY PLAN
------------------------------------------------------------------------------------------
Hash Join (cost=230.47..713.98 rows=101 width=488)
Hash Cond: (t2.unique2 = t1.unique2)
-> Seq Scan on tenk2 t2 (cost=0.00..445.00 rows=10000 width=244)
-> Hash (cost=229.20..229.20 rows=101 width=244)
-> Bitmap Heap Scan on tenk1 t1 (cost=5.07..229.20 rows=101 width=244)
Recheck Cond: (unique1 < 100)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0)
Index Cond: (unique1 < 100)
複製代碼
在這個例子中,規劃器選擇了哈希鏈接,tenk1 (一般是選擇較小的表) 經過 Bitmap Heap Scan 掃入到內存中的哈希表,結合 tenk2 的掃描結果對每一行進行哈希表探測。
用 JS 代碼描述代碼執行過程大體以下,這種方式的時間複雜度爲 O(N + M)
const dict = {};
for (const t1 of tenk1) {
dict[t1.unique2] = t1;
}
for (const t2 of tenk2) {
t2['tenk2'] = dict[t2.unique2]
}
複製代碼
還有一種鏈接方式 Merge Join
,這種鏈接方式要求輸入中的鏈接鍵都是有序的。
EXPLAIN SELECT *
FROM tenk1 t1, onek t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;
QUERY PLAN
------------------------------------------------------------------------------------------
Merge Join (cost=198.11..268.19 rows=10 width=488)
Merge Cond: (t1.unique2 = t2.unique2)
-> Index Scan using tenk1_unique2 on tenk1 t1 (cost=0.29..656.28 rows=101 width=244)
Filter: (unique1 < 100)
-> Sort (cost=197.83..200.33 rows=1000 width=244)
Sort Key: t2.unique2
-> Seq Scan on onek t2 (cost=0.00..148.00 rows=1000 width=244)
複製代碼
對於大量行的排序,順序掃描加排序一般比索引掃描更高效,由於索引掃描須要非連續的磁盤訪問。
掌握瞭如何使用 EXPLAIN 命令,回到最初的問題,查看爲何個人查詢語句問題出在哪裏?
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=256.52..256.52 rows=1 width=540) (actual time=7257.566..7257.568 rows=10 loops=1)
-> Sort (cost=256.52..256.52 rows=1 width=540) (actual time=7257.564..7257.566 rows=10 loops=1)
Sort Key: user_survey.created_at DESC
Sort Method: top-N heapsort Memory: 26kB
-> Nested Loop (cost=26.88..256.51 rows=1 width=540) (actual time=156.527..7257.419 rows=196 loops=1)
-> Nested Loop (cost=0.00..221.74 rows=1 width=548) (actual time=0.957..7057.564 rows=1647 loops=1)
Join Filter: (survey.questionnaire_id = "survey->questionnaire".id)
Rows Removed by Join Filter: 6539694
-> Seq Scan on questionnaires "survey->questionnaire" (cost=0.00..130.07 rows=1 width=532) (actual time=0.009..1.591 rows=3441 loops=1)
Filter: ((name)::text ~~ '%問卷%'::text)
Rows Removed by Filter: 176
-> Seq Scan on surveys survey (cost=0.00..67.96 rows=1896 width=32) (actual time=0.001..1.247 rows=1901 loops=3441)
-> Bitmap Heap Scan on user_surveys user_survey (cost=26.88..34.75 rows=2 width=24) (actual time=0.121..0.121 rows=0 loops=1647)
Recheck Cond: (((status)::text = 'PUBLISH'::text) AND (survey_id = survey.id))
Heap Blocks: exact=134
-> BitmapAnd (cost=26.88..26.88 rows=2 width=0) (actual time=0.120..0.120 rows=0 loops=1647)
-> Bitmap Index Scan on user_surveys_status_index (cost=0.00..10.23 rows=242 width=0) (actual time=0.064..0.064 rows=310 loops=1647)
Index Cond: ((status)::text = 'PUBLISH'::text)
-> Bitmap Index Scan on user_surveys_survey_id_index (cost=0.00..16.33 rows=1753 width=0) (actual time=0.055..0.055 rows=72 loops=1647)
Index Cond: (survey_id = survey.id)
Planning time: 1.434 ms
Execution time: 7257.672 ms
(22 rows)
複製代碼
從 Query Plan 上能夠看到,這個查詢須要 7.2s 的執行時間!哪裏有問題?
Nested Loop (cost=0.00..221.74 rows=1 width=548) (actual time=0.957..7057.564 rows=1647 loops=1)
Join Filter: (survey.questionnaire_id = "survey->questionnaire".id)
Rows Removed by Join Filter: 6539694
-> Seq Scan on questionnaires "survey->questionnaire" (cost=0.00..130.07 rows=1 width=532) (actual time=0.009..1.591 rows=3441 loops=1)
Filter: ((name)::text ~~ '%問卷%'::text)
Rows Removed by Filter: 176
-> Seq Scan on surveys survey (cost=0.00..67.96 rows=1896 width=32) (actual time=0.001..1.247 rows=1901 loops=3441)
複製代碼
從報告中能夠看出,規劃器在鏈接 Surveys 和 Questionnaires 表時,使用的鏈接方式是 Nested Loop,外層遍歷 Questionnaires 表獲得 3441 行數據,內層繼續順序遍歷 Surveys 表,執行 3441 次,前面提到了,這種方式最差的時間複雜度是 O(MN)。
對症下藥,優化的方案就是讓規劃器有更優的執行方案可選,改善錶鏈接的糟糕表現,具體操做是給 surveys.questionnaire_id 添加索引。
CREATE INDEX surveys_questionnaire_id_index ON surveys (questionnaire_id);
複製代碼
再次執行 EXPLAIN ANYLYZE
,能夠看到規劃器使用上了咱們增長的索引,基於索引檢索以後再進行錶鏈接明顯快了不少,查詢總時長從 7.2s 減少到 160ms。
...
Nested Loop (cost=4.34..156.90 rows=1 width=548) (actual time=0.030..7.091 rows=1647 loops=1)
-> Seq Scan on questionnaires "survey->questionnaire" (cost=0.00..130.07 rows=1 width=532) (actual time=0.008..0.839 rows=3441 loops=1)
Filter: ((name)::text ~~ '%問卷%'::text)
Rows Removed by Filter: 176
-> Bitmap Heap Scan on surveys survey (cost=4.34..26.74 rows=8 width=32) (actual time=0.001..0.001 rows=0 loops=3441)
Recheck Cond: (questionnaire_id = "survey->questionnaire".id)
Heap Blocks: exact=281
-> Bitmap Index Scan on surveys_questionnaire_id_index (cost=0.00..4.34 rows=8 width=0) (actual time=0.001..0.001 rows=0 loops=3441)
Index Cond: (questionnaire_id = "survey->questionnaire".id)
...
Planning time: 0.692 ms
Execution time: 160.381 ms
複製代碼
繼續查看報告,發如今作第外層 Nested Loop
錶鏈接的時候,Bitmap Heap Scan on user_surveys 執行時間爲 1647 * 0.085ms。
Nested Loop (cost=31.20..191.79 rows=1 width=540) (actual time=0.971..148.585 rows=196 loops=1)
-> Nested Loop (cost=4.34..157.03 rows=1 width=548) (actual time=0.024..6.963 rows=1647 loops=1)
...
-> Bitmap Heap Scan on user_surveys user_survey (cost=26.86..34.74 rows=2 width=24) (actual time=0.085..0.085 rows=0 loops=1647)
Recheck Cond: (((status)::text = 'PUBLISH'::text) AND (survey_id = survey.id))
Heap Blocks: exact=134
-> BitmapAnd (cost=26.86..26.86 rows=2 width=0) (actual time=0.085..0.085 rows=0 loops=1647)
-> Bitmap Index Scan on user_surveys_status_index (actual time=0.015..0.015 rows=310 loops=1647)
Index Cond: ((status)::text = 'PUBLISH'::text)
-> Bitmap Index Scan on user_surveys_survey_id_index (actual time=0.069..0.069 rows=72 loops=1647)
Index Cond: (survey_id = survey.id)
複製代碼
主要時間是花在了掃描兩個索引上,單次耗時 0.085ms
BitmapAnd (cost=26.86..26.86 rows=2 width=0) (actual time=0.085..0.085 rows=0 loops=1647)
-> Bitmap Index Scan on user_surveys_status_index (actual time=0.015..0.015 rows=310 loops=1647)
Index Cond: ((status)::text = 'PUBLISH'::text)
-> Bitmap Index Scan on user_surveys_survey_id_index (actual time=0.069..0.069 rows=72 loops=1647)
Index Cond: (survey_id = survey.id)
複製代碼
再次對症下藥,添加一個多列索引
CREATE INDEX user_surveys_status_survey_id_index ON user_surveys (status, survey_id);
複製代碼
多列索引列的順序是有講究的,按照經常使用頻率從左到右,這樣能更好地利用索引。
再次執行 EXPLAIN
Limit (cost=163.81..163.82 rows=1 width=540) (actual time=10.097..10.099 rows=10 loops=1)
-> Sort (cost=163.81..163.82 rows=1 width=540) (actual time=10.096..10.096 rows=10 loops=1)
Sort Key: user_survey.created_at DESC
Sort Method: top-N heapsort Memory: 26kB
-> Nested Loop (cost=4.76..163.80 rows=1 width=540) (actual time=0.258..10.023 rows=196 loops=1)
-> Nested Loop (cost=4.34..157.03 rows=1 width=548) (actual time=0.027..6.812 rows=1647 loops=1)
-> Seq Scan on questionnaires "survey->questionnaire" (cost=0.00..130.21 rows=1 width=532) (actual time=0.008..0.810 rows=3441 loops=1)
Filter: ((name)::text ~~ '%問卷%'::text)
Rows Removed by Filter: 176
-> Bitmap Heap Scan on surveys survey (cost=4.34..26.74 rows=8 width=32) (actual time=0.001..0.001 rows=0 loops=3441)
Recheck Cond: (questionnaire_id = "survey->questionnaire".id)
Heap Blocks: exact=281
-> Bitmap Index Scan on surveys_questionnaire_id_index (cost=0.00..4.34 rows=8 width=0) (actual time=0.001..0.001 rows=0 loops=3441)
Index Cond: (questionnaire_id = "survey->questionnaire".id)
-> Index Scan using user_surveys_status_survey_id_index on user_surveys user_survey (cost=0.42..6.75 rows=2 width=24) (actual time=0.002..0.002 rows=0 loops=1647)
Index Cond: (((status)::text = 'PUBLISH'::text) AND (survey_id = survey.id))
Planning time: 0.622 ms
Execution time: 10.163 ms
複製代碼
如今執行時長已經降到了 10ms 了,10ms
是規劃器執行的時間,加上數據傳遞給客戶端,ORM 包的處理,網絡傳輸等時間才更接近用戶在前端最終感知到的時間。
最終,Chrome Network Panel 上看,從前端發起查詢時長終於降到合理的範圍(前端 Input 組件自己會作 500ms
Debounce)。
經過一次完整的學習,掌握了科學有效的方法,之後就不用盲目去猜想是否須要添加索引。遇到性能問題也能更快更有效地解決。
再次說明,這篇文章是我在學習過程當中的筆記,因爲水平有限,可能存在不許確的信息,僅供參考。深刻的學習,還請以官方文檔爲準。