記一次 SQL 優化過程,從 7.2s 到 10ms

最近在實現一個列表查詢的功能時遇到了【性能】問題,因爲對後端性能方面沒有太深刻的瞭解和實踐,因此在發現問題後卡了較長時間,經過查閱文檔,藉助分析工具,最終找到並解決問題。html

這篇文章記錄我解決問題的過程和學習到的新知識,若是有理解錯誤的地方,請幫我指出。前端

執行環境:Postgres 9.6.0Sequelize 4.44.0sql

問題背景

我要實現的功能是提供一個後端列表查詢接口,這個接口涉及到數據庫的三個表,分別是 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 JoinSequelize 生成的 SQL 沒什麼問題,怎麼確認是 SQL 執行語句的問題呢?oop

EXPLAIN 命令

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 timerows 是每次執行的平均值,以及整個語句最終執行時長(Execution time)。

"cost" 使用的是任意單位,一般是關心其相對值。而"actual time"數值是以真實時間的毫秒計的。

使用ANALYZE若是須要執行 UPDATEINSERTDELETE 等操做要留心,最好是在事務中執行。

BEGIN;
EXPLAIN ANALYZE UPDATE ...;
ROLLBACK; or COMMIT;
複製代碼

閱讀 Query Plan

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 ScanBitmap Index Scan 的方式掃描 tenk1 表,將符合條件(unique1 < 100)的條目讀入內存 Hash。

接着使用 Hash Join 的鏈接方式,根據鏈接條件Hash Cond: (t2.unique2 = t1.unique2)鏈接子節點的掃描結果。

最後使用 quicksort 的方式,基於 t1.fivethous 字段進行排序,獲得最終查詢結果。

什麼是索引(Index)?

數據庫中索引就像書籍中的關鍵字檢索,按字符順序排列,當咱們想看某個關鍵字在書本中出現的位置的時候,能夠經過這份 Index 快速定位到具體頁面,直接翻到對應的頁面,而不是從第一頁開始一頁一頁地查找關鍵字。

數據庫中分爲 ClusterIndex 和 NonClusterIndex,ClusterIndex 一個表只有一個,由數據庫根據 Table 惟一主鍵建立,NonClusterIndex 一個表能夠出現不少個,能夠由用戶自由維護。

當咱們在 DB 中運行 Create Index 時,就是建立了一個 NonClusterIndex,數據庫會維護一個結構來記錄 ClusterIndex 和 NonClusterIndex 的關係。

按照 NonClusterIndex 進行排序,以後使用索引就能快速定位到想要查找的條目。

不過須要注意:

  1. 維護索引自己須要成本的,在數據庫內容發生變動時,一般都須要對應地調整索引。
  2. 使用索引也須要開銷,使用索引的順序會致使數據庫讀取頭在行間跳躍,這比順序讀取的開銷要大得多。

Seq Scan、Index Scan、Bitmap Heap Scan

在執行不一樣的 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, Hash Join, Merge Join

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)。

總結

經過一次完整的學習,掌握了科學有效的方法,之後就不用盲目去猜想是否須要添加索引。遇到性能問題也能更快更有效地解決。

再次說明,這篇文章是我在學習過程當中的筆記,因爲水平有限,可能存在不許確的信息,僅供參考。深刻的學習,還請以官方文檔爲準。

推薦閱讀

相關文章
相關標籤/搜索