SQL——性能優化篇(下)


目錄

查詢優化器

我們總是希望數據庫可以運行得更快,也就是響應時間更快,吞吐量更大。想要達到這樣的目的,

我們一方面需要高並發的事務處理能力,

另一方面需要創建合適的索引,讓數據的查找效率最大化。

事務和索引的使用是數據庫中的兩個重要核心,事務可以讓數據庫在增刪查改的過程中,保證數據的正確性和安全性,而索引可以幫數據庫提升數據的查找效率。

如果我們想要知道如何獲取更高的 SQL 查詢性能,最好的方式就是理解數據庫是如何進行查詢優化和執行的。

什么是查詢優化器

了解查詢優化器的作用之前,我們先來看看一條 SQL 語句的執行都需要經歷哪些環節,如下圖所示:

你能看到一條 SQL 查詢語句首先會經過分析器,進行語法分析和語義檢查。

我們之前講過語法分析是檢查 SQL 拼寫和語法是否正確,語義檢查是檢查 SQL 中的訪問對象是否存在。比如我們在寫 SELECT 語句的時候,列名寫錯了,系統就會提示錯誤。

語法檢查和語義檢查可以保證 SQL 語句沒有錯誤,最終得到一棵語法分析樹,然后經過查詢優化器得到查詢計划,最后交給執行器進行執行。

查詢優化器的目標是找到執行 SQL 查詢的最佳執行計划,執行計划就是查詢樹,它由一系列物理操作符組成,這些操作符按照一定的運算關系組成查詢的執行計划。

在查詢優化器中,可以分為邏輯查詢優化階段和物理查詢優化階段。

邏輯查詢優化就是通過改變 SQL 語句的內容來使得 SQL 查詢更高效,同時為物理查詢優化提供更多的}候選執行計划。

通常采用的方式是對 SQL 語句進行等價變換,對查詢進行重寫,而查詢重寫的數學基礎就是關系代數。

對條件表達式進行等價謂詞重寫、條件簡化,對視圖進行重寫,對子查詢進行優化,對連接語義進行了外連接消除、嵌套連接消除等。

邏輯查詢優化是基於關系代數進行的查詢重寫,而關系代數的每一步都對應着物理計算,這些物理計算往往存在多種算法,因此需要計算各種物理路徑的代價,從中選擇代價最小的作為執行計划。

在這個階段里,對於單表多表連接的操作,需要高效地使用索引,提升查詢效率。

在這兩個階段中,

查詢重寫屬於代數級、語法級的優化,也就是屬於邏輯范圍內的優化,

而基於代價的估算模型是從連接路徑中選擇代價最小的路徑,屬於物理層面的優化。

查詢優化器的兩種優化方式

查詢優化器的目的就是生成最佳的執行計划,而生成最佳執行計划的策略通常有以下兩種方式。

第一種是基於規則的優化器(RBO,Rule-Based Optimizer),規則就是人們以往的經驗,或者是采用已經被證明是有效的方式。通過在優化器里面嵌入規則,來判斷 SQL 查詢符合哪種規則,就按照相應的規則來制定執行計划,同時采用啟發式規則去掉明顯不好的存取路徑。

第二種是基於代價的優化器(CBO,Cost-Based Optimizer),這里會根據代價評估模型,計算每條可能的執行計划的代價,也就是 COST,從中選擇代價最小的作為執行計划。相比於 RBO 來說,CBO 對數據更敏感,因為它會利用數據表中的統計信息來做判斷,針對不同的數據表,查詢得到的執行計划可能是不同的,因此制定出來的執行計划也更符合數據表的實際情況。

但我們需要記住,SQL 是面向集合的語言,並沒有指定執行的方式,因此在優化器中會存在各種組合的可能。我們需要通過優化器來制定數據表的掃描方式、連接方式以及連接順序,從而得到最佳的 SQL 執行計划。

你能看出來,RBO 的方式更像是一個出租車老司機,憑借自己的經驗來選擇從 A 到 B 的路徑。而 CBO 更像是手機導航,通過數據驅動,來選擇最佳的執行路徑。

CBO 是如何統計代價的

大部分 RDBMS 都支持基於代價的優化器(CBO),CBO 隨着版本的迭代也越來越成熟,但是 CBO 依然存在缺陷。通過對 CBO 工作原理的了解,我們可以知道 CBO 可能存在的不足有哪些,有助於讓我們知道優化器是如何確定執行計划的。

能調整的代價模型的參數有哪些

首先,我們先來了解下 MySQL 中的COST ModelCOST Model就是優化器用來統計各種步驟的代價模型,在 5.7.10 版本之后,MySQL 會引入兩張數據表,里面規定了各種步驟預估的代價(Cost Value) ,我們可以從mysql.server_costmysql.engine_cost這兩張表中獲得這些步驟的代價:

SQL > SELECT * FROM mysql.server_cost

server_cost 數據表是在 server 層統計的代價,具體的參數含義如下:

  1. disk_temptable_create_cost,表示臨時表文件(MyISAM 或 InnoDB)的創建代價,默認值為 20。
  2. disk_temptable_row_cost,表示臨時表文件(MyISAM 或 InnoDB)的行代價,默認值 0.5。
  3. key_compare_cost,表示鍵比較的代價。鍵比較的次數越多,這項的代價就越大,這是一個重要的指標,默認值 0.05。
  4. memory_temptable_create_cost,表示內存中臨時表的創建代價,默認值 1。
  5. memory_temptable_row_cost,表示內存中臨時表的行代價,默認值 0.1。
  6. row_evaluate_cost,統計符合條件的行代價,如果符合條件的行數越多,那么這一項的代價就越大,因此這是個重要的指標,默認值 0.1。

由這張表中可以看到,如果想要創建臨時表,尤其是在磁盤中創建相應的文件,代價還是很高的。

然后我們看下在存儲引擎層都包括了哪些代價:

SQL > SELECT * FROM mysql.engine_cost

engine_cost主要統計了頁加載的代價,我們之前了解到,一個頁的加載根據頁所在位置的不同,讀取的位置也不同,可以從磁盤 I/O 中獲取,也可以從內存中讀取。因此在engine_cost數據表中對這兩個讀取的代價進行了定義:

  1. io_block_read_cost,從磁盤中讀取一頁數據的代價,默認是 1。
  2. memory_block_read_cost,從內存中讀取一頁數據的代價,默認是 0.25。

既然 MySQL 將這些代價參數以數據表的形式呈現給了我們,我們就可以根據實際情況去修改這些參數。因為隨着硬件的提升,各種硬件的性能對比也可能發生變化,比如針對普通硬盤的情況,可以考慮適當增加io_block_read_cost的數值,這樣就代表從磁盤上讀取一頁數據的成本變高了。當我們執行全表掃描的時候,相比於范圍查詢,成本也會增加很多。

比如我想將io_block_read_cost參數設置為 2.0,那么使用下面這條命令就可以:

UPDATE mysql.engine_cost
  SET cost_value = 2.0
  WHERE cost_name = 'io_block_read_cost';
FLUSH OPTIMIZER_COSTS;

我們對mysql.engine_cost中的io_block_read_cost參數進行了修改,然后使用FLUSH OPTIMIZER_COSTS更新內存,然后再查看engine_cost數據表,發現io_block_read_cost參數中的cost_value已經調整為 2.0。

如果我們想要專門針對某個存儲引擎,比如 InnoDB 存儲引擎設置io_block_read_cost,比如設置為 2,可以這樣使用:

INSERT INTO mysql.engine_cost(engine_name, device_type, cost_name, cost_value, last_update, comment)
  VALUES ('InnoDB', 0, 'io_block_read_cost', 2,
  CURRENT_TIMESTAMP, 'Using a slower disk for InnoDB');
FLUSH OPTIMIZER_COSTS;

然后我們再查看一下mysql.engine_cost數據表:

從圖中你能看到針對 InnoDB 存儲引擎可以設置專門的io_block_read_cost參數值。

代價模型如何計算

總代價的計算是一個比較復雜的過程,上面只是列出了一些常用的重要參數,我們可以根據情況對它們進行調整,也可以使用默認的系統參數值。

那么總的代價是如何進行計算的呢?

在論文《Access Path Selection-in a Relational Database Management System》中給出了計算模型,如下圖所示:

你可以簡單地認為,總的執行代價等於 I/O 代價 +CPU 代價。

在這里 PAGE FETCH 就是 I/O 代價,也就是頁面加載的代價,包括數據頁和索引頁加載的代價。

W*(RSI CALLS) 就是 CPU 代價。W 在這里是個權重因子,表示了 CPU 到 I/O 之間轉化的相關系數,RSI CALLS 代表了 CPU 的代價估算,包括了鍵比較(compare key)以及行估算(row evaluating)的代價。

為了讓你更好地理解,我說下關於 W 和 RSI CALLS 的英文解釋:W is an adjustable weight between I/O and CPU utilization. The number of RSI calls is used to approximate CPU utilization。

這樣你應該能明白為了讓 CPU 代價和 I/O 代價放到一起來統計,我們使用了轉化的系數 W,

另外需要說明的是,在 MySQL5.7 版本之后,代價模型又進行了完善,不僅考慮到了 I/O 和 CPU 開銷,還對內存計算和遠程操作的代價進行了統計,也就是說總代價的計算公式演變成下面這樣:

總代價 = I/O 代價 + CPU 代價 + 內存代價 + 遠程代價

這里對內存代價和遠程代價不進行講解,我們只需要關注 I/O 代價和 CPU 代價即可。

總結

查詢優化器在 RDBMS 中是個非常重要的角色。在優化器中會經歷邏輯查詢優化和物理查詢優化階段。

最后我們只是簡單梳理了下 CBO 的總代價是如何計算的,以及包括了哪些部分。CBO 的代價計算是個復雜的過程,細節很多,不同優化器的實現方式也不同。另外隨着優化器的逐漸成熟,考慮的因素也會越來越多。在某些情況下 MySQL 還會把 RBO 和 CBO 組合起來一起使用。RBO 是個簡單固化的模型,在 Oracle 8i 之前采用的就是 RBO,在優化器中一共包括了 15 種規則,輸入的 SQL 會根據符合規則的情況得出相應的執行計划,在 Oracle 10g 版本之后就用 CBO 替代了 RBO。

CBO 中需要傳入的參數除了 SQL 查詢以外,還包括了優化器參數、數據表統計信息和系統配置等,這實際上也導致 CBO 出現了一些缺陷,比如統計信息不准確,參數配置過高或過低,都會導致路徑選擇的偏差。除此以外,查詢優化器還需要在優化時間和執行計划質量之間進行平衡,比如一個執行計划的執行時間是 10 秒鍾,就沒有必要花 1 分鍾優化執行計划,除非該 SQL 使用頻繁高,后續可以重復使用該執行計划。同樣 CBO 也會做一些搜索空間的剪枝,以便在有效的時間內找到一個“最優”的執行計划。這里,其實也是在告訴我們,為了得到一個事物,付出的成本過大,即使最終得到了,有時候也是得不償失的。

使用性能分析工具定位SQL執行慢

數據庫服務器的優化步驟

當我們遇到數據庫調優問題的時候,該如何思考呢?我把思考的流程整理成了下面這張圖。

整個流程划分成了觀察(Show status)和行動(Action)兩個部分。字母 S 的部分代表觀察(會使用相應的分析工具),字母 A 代表的部分是行動(對應分析可以采取的行動)。

我們可以通過觀察了解數據庫整體的運行狀態,通過性能分析工具可以讓我們了解執行慢的 SQL 都有哪些,查看具體的 SQL 執行計划,甚至是 SQL 執行中的每一步的成本代價,這樣才能定位問題所在,找到了問題,再采取相應的行動。

我來詳細解釋一下這張圖。

首先在 S1 部分,我們需要觀察服務器的狀態是否存在周期性的波動。如果存在周期性波動,有可能是周期性節點的原因,比如雙十一、促銷活動等。這樣的話,我們可以通過 A1 這一步驟解決,也就是加緩存,或者更改緩存失效策略。

如果緩存策略沒有解決,或者不是周期性波動的原因,我們就需要進一步分析查詢延遲和卡頓的原因。

接下來進入 S2 這一步,我們需要開啟慢查詢。慢查詢可以幫我們定位執行慢的 SQL 語句。我們可以通過設置 long_query_time 參數定義“慢”的閾值,如果 SQL 執行時間超過了 long_query_time,則會認為是慢查詢。當收集上來這些慢查詢之后,我們就可以通過分析工具對慢查詢日志進行分析。

在 S3 這一步驟中,我們就知道了執行慢的 SQL,這樣就可以針對性地用 EXPLAIN 查看對應 SQL 語句的執行計划,或者使用 show profile 查看 SQL 中每一個步驟的時間成本。這樣我們就可以了解 SQL 查詢慢是因為執行時間長,還是等待時間長。

如果是 SQL 等待時間長,我們進入 A2 步驟。

在這一步驟中,我們可以調優服務器的參數,比如適當增加數據庫緩沖池等。如果是 SQL 執行時間長,就進入 A3 步驟,這一步中我們需要考慮是索引設計的問題?還是查詢關聯的數據表過多?還是因為數據表的字段設計問題導致了這一現象。然后在這些維度上進行對應的調整。

如果 A2 和 A3 都不能解決問題,我們需要考慮數據庫自身的 SQL 查詢性能是否已經達到了瓶頸,如果確認沒有達到性能瓶頸,就需要重新檢查,重復以上的步驟。如果已經達到了性能瓶頸,進入 A4 階段,需要考慮增加服務器,采用讀寫分離的架構,或者考慮對數據庫進行分庫分表,比如垂直分庫、垂直分表和水平分表等。

以上就是數據庫調優的流程思路。如果我們發現執行 SQL 時存在不規則延遲或卡頓的時候,就可以采用分析工具幫我們定位有問題的 SQL,這三種分析工具你可以理解是 SQL 調優的三個步驟:慢查詢、EXPLAIN 和 SHOW PROFILING。

使用慢查詢定位執行慢的 SQL

好慢詢可以幫我們找到執行慢的 SQL,在使用前,我們需要先看下慢查詢是否已經開啟,使用下面這條命令即可:

mysql > show variables like '%slow_query_log';

我們能看到 slow_query_log=OFF,也就是說慢查詢日志此時是關上的。我們可以把慢查詢日志打開,注意設置變量值的時候需要使用 global,否則會報錯:

mysql > set global slow_query_log='ON';

然后我們再來查看下慢查詢日志是否開啟,以及慢查詢日志文件的位置:

你能看到這時慢查詢分析已經開啟,同時文件保存在 DESKTOP-4BK02RP-slow 文件中。

接下來我們來看下慢查詢的時間閾值設置,使用如下命令:

mysql > show variables like '%long_query_time%';

這里如果我們想把時間縮短,比如設置為 3 秒,可以這樣設置:

mysql > set global long_query_time = 3;

我們可以使用 MySQL 自帶的 mysqldumpslow 工具統計慢查詢日志(這個工具是個 Perl 腳本,你需要先安裝好 Perl)。

mysqldumpslow 命令的具體參數如下:

  • -s:采用 order 排序的方式,排序方式可以有以下幾種。

    分別是 c(訪問次數)、t(查詢時間)、l(鎖定時間)、r(返回記錄)、ac(平均查詢次數)、al(平均鎖定時間)、ar(平均返回記錄數)和 at(平均查詢時間)。其中 at 為默認排序方式。

  • -t:返回前 N 條數據 。

  • -g:后面可以是正則表達式,對大小寫不敏感。

比如我們想要按照查詢時間排序,查看前兩條 SQL 語句,這樣寫即可:

perl mysqldumpslow.pl -s t -t 2 "C:\ProgramData\MySQL\MySQL Server 8.0\Data\DESKTOP-4BK02RP-slow.log"

你能看到開啟了慢查詢日志,並設置了相應的慢查詢時間閾值之后,只要大於這個閾值的 SQL 語句都會保存在慢查詢日志中,然后我們就可以通過 mysqldumpslow 工具提取想要查找的 SQL 語句了。

如何使用 EXPLAIN 查看執行計划

定位了查詢慢的 SQL 之后,我們就可以使用 EXPLAIN 工具做針對性的分析,

比如我們想要了解 product_comment 和 user 表進行聯查的時候所采用的的執行計划,可以使用下面這條語句:

EXPLAIN SELECT comment_id, product_id, comment_text, product_comment.user_id, user_name FROM product_comment JOIN user on product_comment.user_id = user.user_id 

EXPLAIN 可以幫助我們了解數據表的讀取順序、SELECT 子句的類型、數據表的訪問類型、可使用的索引、實際使用的索引、使用的索引長度、上一個表的連接匹配條件、被優化器查詢的行的數量以及額外的信息(比如是否使用了外部排序,是否使用了臨時表等)等。

SQL 執行的順序是根據 id 從大到小執行的,也就是 id 越大越先執行,當 id 相同時,從上到下執行。

數據表的訪問類型所對應的 type 列是我們比較關注的信息。type 可能有以下幾種情況:

在這些情況里,all 是最壞的情況,因為采用了全表掃描的方式。index 和 all 差不多,只不過 index 對索引表進行全掃描,這樣做的好處是不再需要對數據進行排序,但是開銷依然很大。如果我們在 extra 列中看到 Using index,說明采用了索引覆蓋,也就是索引可以覆蓋所需的 SELECT 字段,就不需要進行回表,這樣就減少了數據查找的開銷。

比如我們對 product_comment 數據表進行查詢,設計了聯合索引 composite_index (user_id, comment_text),然后對數據表中的 comment_id、comment_text、user_id 這三個字段進行查詢,最后用 EXPLAIN 看下執行計划:

EXPLAIN SELECT comment_id, comment_text, user_id FROM product_comment 

你能看到這里的訪問方式采用了 index 的方式,key 列采用了聯合索引,進行掃描。Extral 列為 Using index,告訴我們索引可以覆蓋 SELECT 中的字段,也就不需要回表查詢了。

range 表示采用了索引范圍掃描,這里不進行舉例,從這一級別開始,索引的作用會越來越明顯,因此我們需要盡量讓 SQL 查詢可以使用到 range 這一級別及以上的 type 訪問方式。

index_merge 說明查詢同時使用了兩個或以上的索引,最后取了交集或者並集。比如想要對 comment_id=500000 或者 user_id=500000 的數據進行查詢,數據表中 comment_id 為主鍵,user_id 是普通索引,我們可以查看下執行計划:

EXPLAIN SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id = 500000 OR user_id = 500000

你能看到這里同時使用到了兩個索引,分別是主鍵和 user_id,采用的數據表訪問類型是 index_merge,通過 union 的方式對兩個索引檢索的數據進行合並。

ref 類型表示采用了非唯一索引,或者是唯一索引的非唯一性前綴。比如我們想要對 user_id=500000 的評論進行查詢,使用 EXPLAIN 查看執行計划:

EXPLAIN SELECT comment_id, comment_text, user_id FROM product_comment WHERE user_id = 500000 

這里 user_id 為普通索引(因為 user_id 在商品評論表中可能是重復的),因此采用的訪問類型是 ref,同時在 ref 列中顯示 const,表示連接匹配條件是常量,用於索引列的查找。

eq_ref 類型是使用主鍵或唯一索引時產生的訪問方式,通常使用在多表聯查中。假設我們對 product_comment 表和 usre 表進行聯查,關聯條件是兩張表的 user_id 相等,使用 EXPLAIN 進行執行計划查看:

EXPLAIN SELECT * FROM product_comment JOIN user WHERE product_comment.user_id = user.user_id 

const 類型表示我們使用了主鍵或者唯一索引(所有的部分)與常量值進行比較,比如我們想要查看 comment_id=500000,查看執行計划:

EXPLAIN SELECT comment_id, comment_text, user_id FROM product_comment WHERE comment_id = 500000 

需要說明的是 const 類型和 eq_ref 都使用了主鍵或唯一索引,不過這兩個類型有所區別,const 是與常量進行比較,查詢效率會更快,而 eq_ref 通常用於多表聯查中。

system 類型一般用於 MyISAM 或 Memory 表,屬於 const 類型的特例,當表只有一行時連接類型為 system(我在 GitHub 上上傳了 test_myisam 數據表,該數據表只有一行記錄,下載地址:https://github.com/cystanford/SQL_MyISAM)。我們查看下執行計划:

EXPLAIN SELECT * FROM test_myisam

你能看到除了 all 類型外,其他類型都可以使用到索引,但是不同的連接方式的效率也會有所不同,效率從低到高依次為 all < index < range < index_merge < ref < eq_ref < const/system。我們在查看執行計划的時候,通常希望執行計划至少可以使用到 range 級別以上的連接方式,如果只使用到了 all 或者 index 連接方式,我們可以從 SQL 語句和索引設計的角度上進行改進。

使用 SHOW PROFILE 查看 SQL 的具體執行成本

SHOW PROFILE 相比 EXPLAIN 能看到更進一步的執行解析,包括 SQL 都做了什么、所花費的時間等。默認情況下,profiling 是關閉的,我們可以在會話級別開啟這個功能。

mysql > show variables like 'profiling';

通過設置 profiling='ON’來開啟 show profile:

mysql > set profiling = 'ON';

我們可以看下當前會話都有哪些 profiles,使用下面這條命令:

mysql > show profiles;

你能看到當前會話一共有 2 個查詢,如果我們想要查看上一個查詢的開銷,可以使用:

mysql > show profile;

我們也可以查看指定的 Query ID 的開銷,比如 show profile for query 2 查詢結果是一樣的。在 SHOW PROFILE 中我們可以查看不同部分的開銷,比如 cpu、block.io 等:

通過上面的結果,我們可以弄清楚每一步驟的耗時,以及在不同部分,比如 CPU、block.io 的執行時間,這樣我們就可以判斷出來 SQL 到底慢在哪里。

不過 SHOW PROFILE 命令將被棄用,我們可以從 information_schema 中的 profiling 數據表進行查看。

總結

梳理了 SQL 優化的思路,從步驟上看,我們需要先進行觀察和分析,分析工具的使用在日常工作中還是很重要的。今天只介紹了常用的三種分析工具,實際上可以使用的分析工具還有很多。

總結一下今天文章里提到的三種分析工具。

我們可以通過慢查詢日志定位執行慢的 SQL,然后通過 EXPLAIN 分析該 SQL 語句是否使用到了索引,以及具體的數據表訪問方式是怎樣的。

我們也可以使用 SHOW PROFILE 進一步了解 SQL 每一步的執行時間,包括 I/O 和 CPU 等資源的使用情況。

數據庫主從同步的作用

我們之前講解了 Redis,它是一種高性能的內存數據庫;

而 MySQL 是基於磁盤文件的關系型數據庫,相比於 Redis 來說,讀取速度會慢一些,但是功能強大,可以用於存儲持久化的數據。

在實際工作中,我們常常將 Redis 作為緩存與 MySQL 配合來使用,當有數據訪問請求的時候,首先會從緩存中進行查找,如果存在就直接取出,如果不存在再訪問數據庫,這樣就提升了讀取的效率,也減少了對后端數據庫的訪問壓力。可以說使用 Redis 這種緩存架構是高並發架構中非常重要的一環。

img

當然我們也可以對 MySQL 做主從架構並且進行讀寫分離,讓主服務器(Master)處理寫請求,從服務器(Slave)處理讀請求,這樣同樣可以提升數據庫的並發處理能力。

為什么需要主從同步

首先不是所有的應用都需要對數據庫進行主從架構的設置,畢竟設置架構本身是有成本的,如果我們的目的在於提升數據庫高並發訪問的效率,那么首先需要考慮的應該是如何優化你的 SQL 和索引,這種方式簡單有效,其次才是采用緩存的策略,比如使用 Redis,通過 Redis 高性能的優勢將熱點數據保存在內存數據庫中,提升讀取的效率,最后才是對數據庫采用主從架構,進行讀寫分離。

按照上面的方式進行優化,使用和維護的成本是由低到高的。

主從同步設計不僅可以提高數據庫的吞吐量,還有以下 3 個方面的作用。

  • 可以讀寫分離。我們可以通過主從復制的方式來同步數據,然后通過讀寫分離提高數據庫並發處理能力。

簡單來說就是同一份數據被放到了多個數據庫中,其中一個數據庫是 Master 主庫,其余的多個數據庫是 Slave 從庫。當主庫進行更新的時候,會自動將數據復制到從庫中,而我們在客戶端讀取數據的時候,會從從庫中進行讀取,也就是采用讀寫分離的方式。互聯網的應用往往是一些“讀多寫少”的需求,采用讀寫分離的方式,可以實現更高的並發訪問。原本所有的讀寫壓力都由一台服務器承擔,現在有多個“兄弟”幫忙處理讀請求,這樣就減少了對后端大哥(Master)的壓力。同時,我們還能對從服務器進行負載均衡,讓不同的讀請求按照策略均勻地分發到不同的從服務器上,讓讀取更加順暢。讀取順暢的另一個原因,就是減少了鎖表的影響,比如我們讓主庫負責寫,當主庫出現寫鎖的時候,不會影響到從庫進行 SELECT 的讀取。

  • 數據備份。我們通過主從復制將主庫上的數據復制到了從庫上,相當於是一種熱備份機制,也就是在主庫正常運行的情況下進行的備份,不會影響到服務。
  • 具有高可用性。我剛才講到的數據備份實際上是一種冗余的機制,通過這種冗余的方式可以換取數據庫的高可用性,也就是當服務器出現故障或宕機的情況下,可以切換到從服務器上,保證服務的正常運行。

關於高可用性的程度,我們可以用一個指標衡量,即正常可用時間 / 全年時間。比如要達到全年 99.999% 的時間都可用,就意味着系統在一年中的不可用時間不得超過 5.256 分鍾,也就 3652460*(1-99.999%)=5.256 分鍾,其他時間都需要保持可用的狀態。需要注意的是,這 5.256 分鍾包括了系統崩潰的時間,也包括了日常維護操作導致的停機時間。

實際上,更高的高可用性,意味着需要付出更高的成本代價。在現實中我們需要結合業務需求和成本來進行選擇。

提到主從同步的原理,我們就需要了解在數據庫中的一個重要日志文件,那就是 Binlog 二進制日志,它記錄了對數據庫進行更新的事件。實際上主從同步的原理就是基於 Binlog 進行數據同步的。在主從復制過程中,會基於 3 個線程來操作,一個主庫線程,兩個從庫線程。

主從同步的原理是怎樣的

提到主從同步的原理,我們就需要了解在數據庫中的一個重要日志文件,那就是 Binlog 二進制日志,它記錄了對數據庫進行更新的事件。實際上主從同步的原理就是基於 Binlog 進行數據同步的。在主從復制過程中,會基於 3 個線程來操作,一個主庫線程,兩個從庫線程。

二進制日志轉儲線程(Binlog dump thread)是一個主庫線程。當從庫線程連接的時候,主庫可以將二進制日志發送給從庫,當主庫讀取事件的時候,會在 Binlog 上加鎖,讀取完成之后,再將鎖釋放掉。

從庫 I/O 線程會連接到主庫,向主庫發送請求更新 Binlog。這時從庫的 I/O 線程就可以讀取到主庫的二進制日志轉儲線程發送的 Binlog 更新部分,並且拷貝到本地形成中繼日志(Relay log)。

從庫 SQL 線程會讀取從庫中的中繼日志,並且執行日志中的事件,從而將從庫中的數據與主庫保持同步。

img

所以你能看到主從同步的內容就是二進制日志(Binlog),它雖然叫二進制日志,實際上存儲的是一個又一個事件(Event),這些事件分別對應着數據庫的更新操作,比如 INSERT、UPDATE、DELETE 等。另外我們還需要注意的是,不是所有版本的 MySQL 都默認開啟服務器的二進制日志,在進行主從同步的時候,我們需要先檢查服務器是否已經開啟了二進制日志。

進行主從同步的內容是二進制日志,它是一個文件,在進行網絡傳輸的過程中就一定會存在延遲(比如 500ms),這樣就可能造成用戶在從庫上讀取的數據不是最新的數據,也就是主從同步中的數據不一致性問題。比如我們對一條記錄進行更新,這個操作是在主庫上完成的,而在很短的時間內(比如 100ms)又對同一個記錄進行了讀取,這時候從庫還沒有完成數據的更新,那么我們通過從庫讀到的數據就是一條舊的記錄。

如何解決主從同步的數據一致性問題

可以想象下,如果我們想要操作的數據都存儲在同一個數據庫中,那么對數據進行更新的時候,可以對記錄加寫鎖,這樣在讀取的時候就不會發生數據不一致的情況,但這時從庫的作用就是備份,並沒有起到讀寫分離,分擔主庫讀壓力的作用。

img

因此我們還需要繼續想辦法,在進行讀寫分離的同時,解決主從同步中數據不一致的問題,也就是解決主從之間數據復制方式的問題,如果按照數據一致性從弱到強來進行划分,有以下 3 種復制方式。

方法 1:異步復制

異步模式就是客戶端提交 COMMIT 之后不需要等從庫返回任何結果,而是直接將結果返回給客戶端,這樣做的好處是不會影響主庫寫的效率,但可能會存在主庫宕機,而 Binlog 還沒有同步到從庫的情況,也就是此時的主庫和從庫數據不一致。這時候從從庫中選擇一個作為新主,那么新主則可能缺少原來主服務器中已提交的事務。所以,這種復制模式下的數據一致性是最弱的。

img

方法 2:半同步復制

MySQL5.5 版本之后開始支持半同步復制的方式。原理是在客戶端提交 COMMIT 之后不直接將結果返回給客戶端,而是等待至少有一個從庫接收到了 Binlog,並且寫入到中繼日志中,再返回給客戶端。這樣做的好處就是提高了數據的一致性,當然相比於異步復制來說,至少多增加了一個網絡連接的延遲,降低了主庫寫的效率。

img

方法 3:組復制

組復制技術,簡稱 MGR(MySQL Group Replication)。是 MySQL 在 5.7.17 版本中推出的一種新的數據復制技術,這種復制技術是基於 Paxos 協議的狀態機復制。

剛才介紹的異步復制和半同步復制都無法最終保證數據的一致性問題,半同步復制是通過判斷從庫響應的個數來決定是否返回給客戶端,雖然數據一致性相比於異步復制有提升,但仍然無法滿足對數據一致性要求高的場景,比如金融領域。MGR 很好地彌補了這兩種復制模式的不足。

下面我們來看下 MGR 是如何工作的(如下圖所示)。

首先我們將多個節點共同組成一個復制組,在執行讀寫(RW)事務的時候,需要通過一致性協議層(Consensus 層)的同意,也就是讀寫事務想要進行提交,必須要經過組里“大多數人”(對應 Node 節點)的同意,大多數指的是同意的節點數量需要大於(N/2+1),這樣才可以進行提交,而不是原發起方一個說了算。而針對只讀(RO)事務則不需要經過組內同意,直接 COMMIT 即可。

在一個復制組內有多個節點組成,它們各自維護了自己的數據副本,並且在一致性協議層實現了原子消息和全局有序消息,從而保證組內數據的一致性。(具體原理點擊這里可以參考。)

img

MGR 將 MySQL 帶入了數據強一致性的時代,是一個划時代的創新,其中一個重要的原因就是 MGR 是基於 Paxos 協議的。Paxos 算法是由 2013 年的圖靈獎獲得者 Leslie Lamport 於 1990 年提出的,有關這個算法的決策機制你可以去網上搜一下。或者點擊這里查看具體的算法,另外作者在 2001 年發布了一篇簡化版的文章,你如果感興趣的話,也可以看下。

事實上,Paxos 算法提出來之后就作為分布式一致性算法被廣泛應用,比如 Apache 的 ZooKeeper 也是基於 Paxos 實現的。

總結

講解了數據庫的主從同步,如果你的目標僅僅是數據庫的高並發,那么可以先從 SQL 優化,索引以及 Redis 緩存數據庫這些方面來考慮優化,然后再考慮是否采用主從架構的方式。

在主從架構的配置中,如果想要采取讀寫分離的策略,我們可以自己編寫程序,也可以通過第三方的中間件來實現。

自己編寫程序的好處就在於比較自主,我們可以自己判斷哪些查詢在從庫上來執行,針對實時性要求高的需求,我們還可以考慮哪些查詢可以在主庫上執行。同時,程序直接連接數據庫,減少了中間件層,相當於減少了性能損耗。

采用中間件的方法有很明顯的優勢,功能強大,使用簡單。但因為在客戶端和數據庫之間增加了中間件層會有一些性能損耗,同時商業中間件也是有使用成本的。我們也可以考慮采取一些優秀的開源工具,比如 MaxScale。它是 MariaDB 開發的 MySQL 數據中間件。比如在下圖中,使用 MaxScale 作為數據庫的代理,通過路由轉發完成了讀寫分離。同時我們也可以使用 MHA 工具作為強一致的主從切換工具,從而完成 MySQL 的高可用架構。

img

數據庫沒有備份,沒有使用Binlog的情況下,如何恢復數據

q前面講解了 MySQL 的復制技術,通過主從同步可以實現讀寫分離,熱備份,讓服務器更加高可用。MySQL 的復制主要是通過 Binlog 來完成的,Binlog 記錄了數據庫更新的事件,從庫 I/O 線程會向主庫發送 Binlog 更新的請求,同時主庫二進制轉儲線程會發送 Binlog 給從庫作為中繼日志進行保存,然后從庫會通過中繼日志重放,完成數據庫的同步更新。

這種同步操作是近乎實時的同步,然而也有人為誤操作情況的發生,比如 DBA 人員為了方便直接在生產環境中對數據進行操作,或者忘記了當前是在開發環境,還是在生產環境中,就直接對數據庫進行操作,這樣很有可能會造成數據的丟失,情況嚴重時,誤操作還有可能同步給從庫實時更新。不過我們依然有一些策略可以防止這種誤操作,比如利用延遲備份的機制。延遲備份最大的作用就是避免這種“手抖”的情況,讓我們在延遲從庫進行誤操作前停止下來,進行數據庫的恢復。

當然如果我們對數據庫做過時間點備份,也可以直接恢復到該時間點。不過我們今天要討論的是一個特殊的情況,也就是在沒做數據庫備份,沒有開啟使用 Binlog 的情況下,盡可能地找回數據。

InnoDB 存儲引擎的表空間

InnoDB 存儲引擎的文件格式是.ibd 文件,數據會按照表空間(tablespace)進行存儲,分為共享表空間和獨立表空間。

如果想要查看表空間的存儲方式,我們可以對innodb_file_per_table變量進行查詢,使用show variables like 'innodb_file_per_table';。

ON 表示獨立表空間,而 OFF 則表示共享表空間。

img

如果采用共享表空間的模式,InnoDB 存儲的表數據都會放到共享表空間中,也就是多個數據表共用一個表空間,同時表空間也會自動分成多個文件存放到磁盤上。

這樣做的好處在於單個數據表的大小可以突破文件系統大小的限制,最大可以達到 64TB,也就是 InnoDB 存儲引擎表空間的上限。不足也很明顯,多個數據表存放到一起,結構不清晰,不利於數據的找回,同時將所有數據和索引都存放到一個文件中,也會使得共享表空間的文件很大。

采用獨立表空間的方式可以讓每個數據表都有自己的物理文件,也就是 table_name.ibd 的文件,在這個文件中保存了數據表中的數據、索引、表的內部數據字典等信息。它的優勢在於每張表都相互獨立,不會影響到其他數據表,存儲結構清晰,利於數據恢復,同時數據表還可以在不同的數據庫之間進行遷移。

如果.ibd 文件損壞了,數據如何找回

如果我們之前沒有做過全量備份,也沒有開啟 Binlog,那么我們還可以通過.ibd 文件進行數據恢復,采用獨立表空間的方式可以很方便地對數據庫進行遷移和分析。如果我們誤刪除(DELETE)某個數據表或者某些數據行,也可以采用第三方工具回數據。

我們這里可以使用 Percona Data Recovery Tool for InnoDB 工具,能使用工具進行修復是因為我們在使用 DELETE 的時候是邏輯刪除。我們之前學習過 InnoDB 的頁結構,在保存數據行的時候還有個刪除標記位,對應的是頁結構中的 delete_mask 屬性,該屬性為 1 的時候標記了記錄已經被邏輯刪除,實際上並不是真的刪除。不過當有新的記錄插入的時候,被刪除的行記錄可能會被覆蓋掉。所以當我們發生了 DELETE 誤刪除的時候,一定要第一時間停止對誤刪除的表進行更新和寫入,及時將.ibd 文件拷貝出來並進行修復。

如果已經開啟了 Binlog,就可以使用閃回工具,比如 mysqlbinlog 或者 binlog2sql,從工具名稱中也能看出來它們都是基於 Binlog 來做的閃回。原理就是因為 Binlog 文件本身保存了數據庫更新的事件(Event),通過這些事件可以幫我們重現數據庫的所有更新變化,也就是 Binlog 回滾。

下面我們就來看下沒有做過備份,也沒有開啟 Binlog 的情況下,如果.ibd 文件發生了損壞,如何通過數據庫自身的機制來進行數據恢復。

實際上,InnoDB 是有自動恢復機制的,如果發生了意外,InnoDB 可以在讀取數據表時自動修復錯誤。但有時候.ibd 文件損壞了,會導致數據庫無法正常讀取數據表,這時我們就需要人工介入,調整一個參數,這個參數叫做innodb_force_recovery。

我們可以通過命令show variables like 'innodb_force_recovery';來查看當前參數的狀態,你能看到默認為 0,表示不進行強制恢復。如果遇到錯誤,比如 ibd 文件中的數據頁發生損壞,則無法讀取數據,會發生 MySQL 宕機的情況,此時會將錯誤日志記錄下來。

img

innodb_force_recovery參數一共有 7 種狀態,除了默認的 0 以外,還可以為 1-6 的取值,分別代表不同的強制恢復措施。

當我們需要強制恢復的時候,可以將innodb_force_recovery設置為 1,表示即使發現了損壞頁也可以繼續讓服務運行,這樣我們就可以讀取數據表,並且對當前損壞的數據表進行分析和備份。

通常innodb_force_recovery參數設置為 1,只要能正常讀取數據表即可。但如果參數設置為 1 之后還無法讀取數據表,我們可以將參數逐一增加,比如 2、3 等。一般來說不需要將參數設置到 4 或以上,因為這有可能對數據文件造成永久破壞。另外當innodb_force_recovery設置為大於 0 時,相當於對 InnoDB 進行了寫保護,只能進行 SELECT 讀取操作,還是有限制的讀取,對於 WHERE 條件以及 ORDER BY 都無法進行操作。

當我們開啟了強制恢復之后,數據庫的功能會受到很多限制,我們需要盡快把有問題的數據表備份出來,完成數據恢復操作。整體的恢復步驟可以按照下面的思路進行:

  1. 使用innodb_force_recovery啟動服務器

innodb_force_recovery參數設置為 1,啟動數據庫。如果數據表不能正常讀取,需要調大參數直到能讀取數據為止。通常設置為 1 即可。

  1. 備份數據表

在備份數據之前,需要准備一個新的數據表,這里需要使用 MyISAM 存儲引擎。原因很簡單,InnoDB 存儲引擎已經寫保護了,無法將數據備份出來。然后將損壞的 InnoDB 數據表備份到新的 MyISAM 數據表中。

  1. 刪除舊表,改名新表

數據備份完成之后,我們可以刪除掉原有損壞的 InnoDB 數據表,然后將新表進行改名。

  1. 關閉innodb_force_recovery,並重啟數據庫

innodb_force_recovery大於 1 的時候會有很多限制,我們需要將該功能關閉,然后重啟數據庫,並且將數據表的 MyISAM 存儲引擎更新為 InnoDB 存儲引擎。

InnoDB 文件的損壞與恢復實例

我們剛才說了 InnoDB 文件損壞時的人工操作過程,下面我們用一個例子來模擬下。

生成 InnoDB 數據表

為了簡便,我們創建一個數據表 t1,只有 id 一個字段,類型為 int。使用命令create table t1(id int);即可。

img

然后創建一個存儲過程幫我們生成一些數據:

BEGIN
-- 當前數據行
DECLARE i INT DEFAULT 0;
-- 最大數據行數
DECLARE max_num INT DEFAULT 100;
-- 關閉自動提交
SET autocommit=0;
REPEAT
SET i=i+1;
-- 向t1表中插入數據
INSERT INTO t1(id) VALUES(i);
UNTIL i = max_num
END REPEAT;
-- 提交事務
COMMIT;
END

然后我們運行call insert_t1(),這個存儲過程幫我們插入了 100 條數據,這樣我們就有了 t1.ibd 這個文件。

模擬損壞.ibd 文件

實際工作中我們可能會遇到各種各樣的情況,比如.ibd 文件損壞等,如果遇到了數據文件的損壞,MySQL 是無法正常讀取的。在模擬損壞.ibd 文件之前,我們需要先關閉掉 MySQL 服務,然后用編輯器打開 t1.ibd,類似下圖所示:

img

文件是有二進制編碼的,看不懂沒有關系,我們只需要破壞其中的一些內容即可,比如在 t1.ibd 文件中刪除了 2 行內容(文件大部分內容為 0,我們在文件中間部分找到一些非 0 的取值,然后刪除其中的兩行:4284 行與 4285 行,原 ibd 文件和損壞后的 ibd 文件見GitHub地址。其中 t1.ibd 為創建的原始數據文件,t1- 損壞.ibd 為損壞后的數據文件,你需要自己創建 t1 數據表,然后將 t1- 損壞.ibd 拷貝到本地,並改名為 t1.ibd)。

然后我們保存文件,這時.ibd 文件發生了損壞,如果我們沒有打開innodb_force_recovery,那么數據文件無法正常讀取。為了能讀取到數據表中的數據,我們需要修改 MySQL 的配置文件,找到[mysqld]的位置,然后再下面增加一行innodb_force_recovery=1。

img

備份數據表

當我們設置innodb_force_recovery參數為 1 的時候,可以讀取到數據表 t1 中的數據,但是數據不全。我們使用SELECT * FROM t1 LIMIT 10;讀取當前前 10 條數據。

img

但是如果我們想要完整的數據,使用SELECT * FROM t1 LIMIT 100;就會發生如下錯誤。

img

這是因為讀取的部分包含了已損壞的數據頁,我們可以采用二分查找判斷數據頁損壞的位置。這里我們通過實驗,可以得出只有最后一個記錄行收到了損壞,而前 99 條記錄都可以正確讀出(具體實驗過程省略)。

這樣我們就能判斷出來有效的數據行的位置,從而將它們備份出來。首先我們創建一個相同的表結構 t2,存儲引擎設置為 MyISAM。我剛才講過這里使用 MyISAM 存儲引擎是因為在innodb_force_recovery=1的情況下,無法對 innodb 數據表進行寫數據。使用命令CREATE TABLE t2(id int) ENGINE=MyISAM;

然后我們將數據表 t1 中的前 99 行數據復制給 t2 數據表,使用:

INSERT INTO t2 SELECT * FROM t1 LIMIT 99;

img

我們剛才講過在分析 t1 數據表的時候無法使用 WHERE 以及 ORDER BY 等子句,這里我們可以實驗一下,如果想要查詢 id<10 的數據行都有哪些,那么會發生如下錯誤。原因是損壞的數據頁無法進行條件判斷。

img

刪除舊表,改名新表

剛才我們已經恢復了大部分的數據。雖然還有一行記錄沒有恢復,但是能找到絕大部分的數據也是好的。然后我們就需要把之前舊的數據表刪除掉,使用DROP TABLE t1;

img

更新表名,將數據表名稱由 t2 改成 t1,使用RENAME TABLE t2 to t1;。

img

將新的數據表 t1 存儲引擎改成 InnoDB,不過直接修改的話,會報如下錯誤:

img

關閉innodb_force_recovery,並重啟數據庫

因為上面報錯,所以我們需要將 MySQL 配置文件中的innodb_force_recovery=1刪除掉,然后重啟數據庫。最后將 t1 的存儲引擎改成 InnoDB 即可,使用ALTER TABLE t1 engine = InnoDB;

img

總結

我們剛才人工恢復了損壞的 ibd 文件中的數據,雖然沒有 100% 找回,但是相比於束手無措來說,已經是不幸中的萬幸,至少我們還可以把正確的數據頁中的記錄成功備份出來,盡可能恢復原有的數據表。在這個過程中相信你應該對 ibd 文件,以及 InnoDB 自身的強制恢復(Force Recovery)機制有更深的了解。

數據表損壞,以及人為的誤刪除都不是我們想要看到的情況,但是我們不能指望運氣,或者說我們不能祈禱這些事情不會發生。在遇到這些情況的時候,應該通過機制盡量保證數據庫的安全穩定運行。這個過程最主要的就是應該及時備份,並且開啟二進制日志,這樣當有誤操作的時候就可以通過數據庫備份以及 Binlog 日志來完成數據恢復。同時采用延遲備份的策略也可以盡量抵御誤操作。總之,及時備份是非常有必要的措施,同時我們還需要定時驗證備份文件的有效性,保證備份文件可以正常使用。

如果你遇到了數據庫 ibd 文件損壞的情況,並且沒有采用任何的備份策略,可以嘗試使用 InnoDB 的強制恢復機制,啟動 MySQL 並且將損壞的數據表轉儲到 MyISAM 數據表中,盡可能恢復已有的數據。總之機制比人為更靠譜,我們要為長期的運營做好充足的准備。一旦發生了誤操作這種緊急情況,不要慌張,及時采取對應的措施才是最重要的。

SQL注入

我們之前已經講解了 SQL 的使用及優化,正常的 SQL 調用可以幫我們從數據庫中獲取想要的數據,然而我們構建的 Web 應用是個應用程序,本身也可能存在安全漏洞,如果不加以注意,就會出現 Web 安全的隱患,比如通過非正常的方式注入 SQL。

在過去的幾年中,我們也能經常看到用戶信息被泄露,出現這種情況,很大程度上和 SQL 注入有關。所以了解 SQL 注入的原理以及防范還是非常有必要的。

SQL 注入的原理

SQL 注入也叫作 SQL Injection,它指的是將非法的 SQL 命令插入到 URL 或者 Web 表單中進行請求,而這些請求被服務器認為是正常的 SQL 語句從而進行執行。也就是說,如果我們想要進行 SQL 注入,可以將想要執行的 SQL 代碼隱藏在輸入的信息中,而機器無法識別出來這些內容是用戶信息,還是 SQL 代碼,在后台處理過程中,這些輸入的 SQL 語句會顯現出來並執行,從而導致數據泄露,甚至被更改或刪除。

為什么我們可以將 SQL 語句隱藏在輸入的信息中呢?這里舉一個簡單的例子。

比如下面的 PHP 代碼將瀏覽器發送過來的 URL 請求,通過 GET 方式獲取 ID 參數,賦值給 $id 變量,然后通過字符串拼接的方式組成了 SQL 語句。這里我們沒有對傳入的 ID 參數做校驗,而是采用了直接拼接的方式,這樣就可能產生 SQL 注入。

$id=$_GET['id'];
$sql="SELECT * FROM users WHERE id='$id' LIMIT 0,1";
$result=mysql_query($sql);
$row = mysql_fetch_array($result);

如果我們在 URL 中的?id= 后面輸入’ or 1=1 --+,那么 SQL 語句就變成了下面這樣:

SELECT * FROM users WHERE id='' or 1=1 --  LIMIT 0,1

其中我們輸入的(+)在瀏覽器 URL 中相當於空格,而輸入的(–)在 SQL 中表示注釋語句,它會將后面的 SQL 內容都注釋掉,這樣整個 SQL 就相當於是從 users 表中獲取全部的數據。然后我們使用 mysql_fetch_array 從結果中獲取一條記錄,這時即使 ID 輸入不正確也沒有關系,同樣可以獲取數據表中的第一行記錄。

一個 SQL 注入的實例

通常我們希望通過 SQL 注入可以獲取更多的信息,比如數據庫的名稱、數據表名稱和字段名等。下面我們通過一個簡單的 SQL 實例來操作一下。

搭建 sqli-labs 注入環境

首先我們需要搭建 sqli-labs 注入環境,在這個項目中,我們會面臨 75 個 SQL 注入的挑戰,你可以像游戲闖關一樣對 SQL 注入的原理進行學習。

下面的步驟是關於如何在本地搭建 sqli-labs 注入環境的,成功搭建好的環境類似鏈接里展現的。

第一步,下載 sqli-labs。

sqli-labs 是一個開源的 SQL 注入平台,你可以從GitHub上下載它。

第二步,配置 PHP、Apache 環境(可以使用 phpStudy 工具)。

運行 sqli-labs 需要 PHP、Apache 環境,如果你之前沒有安裝過它們,可以直接使用 phpStudy 這個工具,它不僅集成了 PHP、Apache 和 MySQL,還可以方便地指定 PHP 的版本。在今天的項目中,我使用的是 PHP5.4.45 版本。

img

第三步,配置 sqli-labs 及 MySQL 參數。

首先我們需要給 sqli-labs 指定需要訪問的數據庫賬戶密碼,對應sqli-labs-master\sql-connections\db-creds.inc文件,這里我們需要修改$dbpass參數,改成自己的 MySQL 的密碼。

img

此時我們訪問本地的sqli-labs項目http://localhost/sqli-labs-master/出現如下頁面,需要先啟動數據庫,選擇Setup/reset Database for labs即可。

img

如果此時提示數據庫連接錯誤,可能需要我們手動修改 MySQL 的配置文件,需要調整的參數如下所示(修改 MySQL 密碼驗證方式為使用明文,同時設置 MySQL 默認的編碼方式):

[client]
default-character-set=utf8
[mysql]
default-character-set=utf8
[mysqld]
character-set-server = utf8
default_authentication_plugin = mysql_native_password

第一個 SQL 注入挑戰

在我們成功對 sqli-labs 進行了配置,現在可以進入到第一關挑戰環節。訪問本地的http://localhost/sqli-labs-master/Less-1/頁面,如下所示:

img

我們可以在 URL 后面加上 ID 參數,獲取指定 ID 的信息,比如http://localhost/sqli-labs-master/Less-1/?id=1。

這些都是正常的訪問請求,現在我們可以通過 1 or 1=1 來判斷 ID 參數的查詢類型,訪問http://localhost/sqli-labs-master/Less-1/?id=1 or 1=1。

img

你可以看到依然可以正常訪問,證明 ID 參數不是數值查詢,然后我們在 1 后面增加個單引號,來查看下返回結果,訪問http://localhost/sqli-labs-master/Less-1/?id=1'。

這時數據庫報錯,並且在頁面上返回了錯誤信息:You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ‘‘1’’ LIMIT 0,1’ at line 1。

我們對這個錯誤進行分析,首先''1'' LIMIT 0,1'這個語句,我們去掉最外層的單引號,得到'1'' LIMIT 0,1,因為我們輸入的參數是1',繼續去掉1',得到'' LIMIT 0,1。這樣我們就能判斷出后台的 SQL 語句,類似於下面這樣:

$sql="SELECT ... FROM ... WHERE id='$id' LIMIT 0,1";

兩處省略號的地方分別代表 SELECT 語句中的字段名和數據表名稱。

判斷查詢語句的字段數

現在我們已經對后台的 SQL 查詢已經有了大致的判斷,它是通過字符串拼接完成的 SQL 查詢。現在我們再來判斷下這個查詢語句中的字段個數,通常可以在輸入的查詢內容后面加上 ORDER BY X,這里 X 是我們估計的字段個數。如果 X 數值大於 SELECT 查詢的字段數,則會報錯。根據這個原理,我們可以嘗試通過不同的 X 來判斷 SELECT 查詢的字段個數,這里我們通過下面兩個 URL 可以判斷出來,SELECT 查詢的字段數為 3 個:

報錯:

http://localhost/sqli-labs-master/Less-1/?id=1' order by 4 --+

正確:

http://localhost/sqli-labs-master/Less-1/?id=1' order by 3 --+

獲取當前數據庫和用戶信息

下面我們通過 SQL 注入來獲取想要的信息,比如想要獲取當前數據庫和用戶信息。

這里我們使用 UNION 操作符。在 MySQL 中,UNION 操作符前后兩個 SELECT 語句的查詢結構必須一致。剛才我們已經通過實驗,判斷出查詢語句的字段個數為 3,因此在構造 UNION 后面的查詢語句時也需要查詢 3 個字段。這里我們可以使用:SELECT 1,database(),user(),也就是使用默認值 1 來作為第一個字段,整個 URL 為:http://localhost/sqli-labs-master/Less-1/?id=' union select 1,database(),user() --+。

img

頁面中顯示的security即為當前的數據庫名稱,root@localhost為當前的用戶信息。

獲取 MySQL 中的所有數據庫名稱

我們還想知道當前 MySQL 中所有的數據庫名稱都有哪些,數據庫名稱數量肯定會大於 1,因此這里我們需要使用GROUP_CONCAT函數,這個函數可以將GROUP BY產生的同一個分組中的值連接起來,並以字符串形式返回。

具體使用如下:

http://localhost/sqli-labs-master/Less-1/?id=' union select 1,2,(SELECT GROUP_CONCAT(schema_name) FROM information_schema.schemata)--+

這樣我們就可以把多個數據庫名稱拼接在一起,作為字段 3 返回給頁面。

img

你能看到這里我使用到了 MySQL 中的information_schema數據庫,這個數據庫是 MySQL 自帶的數據庫,用來存儲數據庫的基本信息,比如數據庫名稱、數據表名稱、列的數據類型和訪問權限等。我們可以通過訪問information_schema數據庫,獲得更多數據庫的信息。

查詢 wucai 數據庫中所有數據表

在上面的實驗中,我們已經得到了 MySQL 中所有的數據庫名稱,這里我們能看到 wucai 這個數據庫。如果我們想要看 wucai 這個數據庫中都有哪些數據表,可以使用:

http://localhost/sqli-labs-master/Less-1/?id=' UNION SELECT 1,2,(SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema='wucai') --+

這里我們同樣將數據表名稱使用 GROUP_CONCAT 函數拼接起來,作為字段 3 進行返回。

img

查詢 heros 數據表中所有字段名稱

在上面的實驗中,我們從 wucai 數據庫中找到了熟悉的數據表 heros,現在就來通過 information_schema 來查詢下 heros 數據表都有哪些字段,使用下面的命令即可:

http://localhost/sqli-labs-master/Less-1/?id=' UNION SELECT 1,2,(SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='heros') --+

這里會將字段使用 GROUP_CONCAT 函數進行拼接,並將結果作為字段 3 進行返回,返回的結果如下所示:

attack_growth,attack_max,attack_range,attack_speed_max,attack_start,birthdate,defense_growth,defense_max,defense_start,hp_5s_growth,hp_5s_max,hp_5s_start,hp_growth,hp_max,hp_start,id,mp_5s_growth,mp_5s_max,mp_5s_start,mp_growth,mp_max,mp_start,name,role_assist,role_main

img

使用 SQLmap 工具進行 SQL 注入檢測

經過上面的實驗你能體會到,如果我們編寫的代碼存在着 SQL 注入的漏洞,后果還是很可怕的。通過訪問information_schema就可以將數據庫的信息暴露出來。

了解到如何完成注入 SQL 后,我們再來了解下 SQL 注入的檢測工具,它可以幫我們自動化完成 SQL 注入的過程,這里我們使用的是 SQLmap 工具。

下面我們使用 SQLmap 再模擬一遍剛才人工 SQL 注入的步驟。

獲取當前數據庫和用戶信息

我們使用sqlmap -u來指定注入測試的 URL,使用--current-db來獲取當前的數據庫名稱,使用--current-user獲取當前的用戶信息,具體命令如下:

python sqlmap.py -u "http://localhost/sqli-labs-master/Less-1/?id=1" --current-db --current-user

然后你能看到 SQLmap 幫我們獲取了相應的結果:

img

獲取 MySQL 中的所有數據庫名稱

我們可以使用--dbs來獲取 DBMS 中所有的數據庫名稱,這里我們使用--threads參數來指定 SQLmap 最大並發數,設置為 5,通常該參數不要超過 10,具體命令為下面這樣:

python sqlmap.py -u "http://localhost/sqli-labs-master/Less-1/?id=1" --threads=5 --dbs

同樣 SQLmap 幫我們獲取了 MySQL 中存在的 8 個數據庫名稱:

img

查詢 wucai 數據庫中所有數據表

當我們知道 DBMS 中存在的某個數據庫名稱時,可以使用 -D 參數對數據庫進行指定,然后使用--tables參數顯示出所有的數據表名稱。比如我們想要查看 wucai 數據庫中都有哪些數據表,使用:

python sqlmap.py -u "http://localhost/sqli-labs-master/Less-1/?id=1" --threads=5 -D wucai --tables

img

查詢 heros 數據表中所有字段名稱

我們也可以對指定的數據表,比如 heros 表進行所有字段名稱的查詢,使用-D指定數據庫名稱,-T指定數據表名稱,--columns對所有字段名稱進行查詢,命令如下:

python sqlmap.py -u "http://localhost/sqli-labs-master/Less-1/?id=1" --threads=5 -D wucai -T heros --columns

img

查詢 heros 數據表中的英雄信息

當我們了解了數據表中的字段之后,就可以對指定字段進行查詢,使用-C參數進行指定。比如我們想要查詢 heros 數據表中的id、name和hp_max字段的取值,這里我們不采用多線程的方式,具體命令如下:

python sqlmap.py -u "http://localhost/sqli-labs-master/Less-1/?id=1" -D wucai -T heros -C id,name,hp_max --dump

img

完整的結果一共包括 69 個英雄信息都顯示出來了,這里我只截取了部分的英雄結果。

總結

使用了 sqli-labs 注入平台作為實驗數據,使用了 SQLmap 工具自動完成 SQL 注入。SQL 注入的方法還有很多,我們今天講解的只是其中一個方式。你如果對 SQL 注入感興趣,也可以對 sqli-labs 中其他例子進行學習,了解更多 SQL 注入的方法。

在這個過程中,最主要的是理解 SQL 注入的原理。在日常工作中,我們需要對用戶提交的內容進行驗證,以防止 SQL 注入。當然很多時候我們都在使用編程框架,這些框架已經極大地降低了 SQL 注入的風險,但是只要有 SQL 拼接的地方,這種風險就可能存在。

總之,代碼規范性對於 Web 安全來說非常重要,盡量不要采用直接拼接的方式進行查詢。同時在 Web 上線之后,還需要將生產環境中的錯誤提示信息關閉,以減少被 SQL 注入的風險。此外我們也可以采用第三方的工具,比如 SQLmap 來對 Web 應用進行檢測,以增強 Web 安全性。

關於索引以及緩沖池的一些解惑

關於索引(B+ 樹索引和 Hash 索引,以及索引原則)

什么是自適應 Hash 索引?

在回答這個問題前,讓我們先回顧下 B+ 樹索引和 Hash 索引:

因為 B+ 樹可以使用到范圍查找,同時是按照順序的方式對數據進行存儲,因此很容易對數據進行排序操作,在聯合索引中也可以利用部分索引鍵進行查詢。這些情況下,我們都沒法使用 Hash 索引,因為 Hash 索引僅能滿足(=)(<>)和 IN 查詢,不能使用范圍查詢。此外,Hash 索引還有一個缺陷,數據的存儲是沒有順序的,在 ORDER BY 的情況下,使用 Hash 索引還需要對數據重新排序。而對於聯合索引的情況,Hash 值是將聯合索引鍵合並后一起來計算的,無法對單獨的一個鍵或者幾個索引鍵進行查詢。

MySQL 默認使用 B+ 樹作為索引,因為 B+ 樹有着 Hash 索引沒有的優點,那么為什么還需要自適應 Hash 索引呢?這是因為 Hash 索引在進行數據檢索的時候效率非常高,通常只需要 O(1) 的復雜度,也就是一次就可以完成數據的檢索。雖然 Hash 索引的使用場景有很多限制,但是優點也很明顯,所以 MySQL 提供了一個自適應 Hash 索引的功能(Adaptive Hash Index)。注意,這里的自適應指的是不需要人工來制定,系統會根據情況自動完成。

什么情況下才會使用自適應 Hash 索引呢?如果某個數據經常被訪問,當滿足一定條件的時候,就會將這個數據頁的地址存放到 Hash 表中。這樣下次查詢的時候,就可以直接找到這個頁面的所在位置。

需要說明的是自適應 Hash 索引只保存熱數據(經常被使用到的數據),並非全表數據。因此數據量並不會很大,因此自適應 Hash 也是存放到緩沖池中,這樣也進一步提升了查找效率。

InnoDB 中的自適應 Hash 相當於“索引的索引”,采用 Hash 索引存儲的是 B+ 樹索引中的頁面的地址。如下圖所示:

你能看到,采用自適應 Hash 索引目的是方便根據 SQL 的查詢條件加速定位到葉子節點,特別是當 B+ 樹比較深的時候,通過自適應 Hash 索引可以明顯提高數據的檢索效率。

我們來看下自適應 Hash 索引的原理。

自適應 Hash 采用 Hash 函數映射到一個 Hash 表中,如下圖所示,查找字典類型的數據非常方便。

Hash 表是數組 + 鏈表的形式。通過 Hash 函數可以計算索引鍵值所對應的 bucket(桶)的位置,如果產生 Hash 沖突,就需要遍歷鏈表來解決。

我們可以通過innodb_adaptive_hash_index變量來查看是否開啟了自適應 Hash,比如:

mysql> show variables like '%adaptive_hash_index';

我來總結一下,InnoDB 本身不支持 Hash 索引,但是提供自適應 Hash 索引,不需要用戶來操作,存儲引擎會自動完成。自適應 Hash 是 InnoDB 三大關鍵特性之一,另外兩個分別是插入緩沖和二次寫。

什么是聯合索引的最左原則?

關於聯合索引的最左原則,講一個非常形象的解釋:

假設我們有 x、y、z 三個字段,創建聯合索引(x, y, z)之后,我們可以把 x、y、z 分別類比成“百分位”、“十分位”和“個位”。

查詢“x=9 AND y=8 AND z=7”的過程,就是在一個由小到大排列的數值序列中尋找“987”,可以很快找到。

查詢“y=8 AND z=7”,就用不上索引了,因為可能存在 187、287、387、487………這樣就必須掃描所有數值。

在這個基礎上再補充說明一下。

查詢“z=7 AND y=8 AND x=9”的時候,如果三個字段 x、y、z 在條件查詢的時候是亂序的,但采用的是等值查詢(=)或者是 IN 查詢,那么 MySQL 的優化器可以自動幫我們調整為可以使用聯合索引的形式。

當我們查詢“x=9 AND y>8 AND z=7”的時候,如果建立了 (x,y,z) 順序的索引,這時候 z 是用不上索引的。這是因為 MySQL 在匹配聯合索引最左前綴的時候,如果遇到了范圍查詢,比如(<)(>)和 between 等,就會停止匹配。索引列最多作用於一個范圍列,對於后面的 Z 來說,就沒法使用到索引了。

通過這個我們也可以知道,聯合索引的最左前綴匹配原則針對的是創建的聯合索引中的順序,如果創建了聯合索引(x,y,z),那么這個索引的使用順序就很重要了。如果在條件語句中只有 y 和 z,那么就用不上聯合索引。

此外,SQL 條件語句中的字段順序並不重要,因為在邏輯查詢優化階段會自動進行查詢重寫。

最后你需要記住,如果我們遇到了范圍條件查詢,比如(<)(<=)(>)(>=)和 between 等,那么范圍列后的列就無法使用到索引了。

Hash 索引與 B+ 樹索引是在建索引的時候手動指定的嗎?

如果使用的是 MySQL 的話,我們需要了解 MySQL 的存儲引擎都支持哪些索引結構,如下圖所示(參考來源 https://dev.mysql.com/doc/refman/8.0/en/create-index.html)。如果是其他的 DBMS,可以參考相關的 DBMS 文檔。

你能看到,針對 InnoDB 和 MyISAM 存儲引擎,都會默認采用 B+ 樹索引,無法使用 Hash 索引。InnoDB 提供的自適應 Hash 是不需要手動指定的。如果是 Memory/Heap 和 NDB 存儲引擎,是可以進行選擇 Hash 索引的。

關於緩沖池

緩沖池和查詢緩存是一個東西嗎?

首先我們需要了解在 InnoDB 存儲引擎中,緩沖池都包括了哪些。

在 InnoDB 存儲引擎中有一部分數據會放到內存中,緩沖池則占了這部分內存的大部分,它用來存儲各種數據的緩存,如下圖所示:

從圖中,你能看到 InnoDB 緩沖池包括了數據頁、索引頁、插入緩沖、鎖信息、自適應 Hash 和數據字典信息等。

InnoDB 存儲引擎基於磁盤文件存儲,訪問物理硬盤和在內存中進行訪問,速度相差很大,為了盡可能彌補這兩者之間 I/O 效率的差值,我們就需要把經常使用的數據加載到緩沖池中,避免每次訪問都進行磁盤 I/O。

“頻次 * 位置”這個原則,可以幫我們對 I/O 訪問效率進行優化。

首先,位置決定效率,提供緩沖池就是為了在內存中可以直接訪問數據。

其次,頻次決定優先級順序。因為緩沖池的大小是有限的,比如磁盤有 200G,但是內存只有 16G,緩沖池大小只有 1G,就無法將所有數據都加載到緩沖池里,這時就涉及到優先級順序,會優先對使用頻次高的熱數據進行加載。

了解了緩沖池的作用之后,我們還需要了解緩沖池的另一個特性:預讀。

緩沖池的作用就是提升 I/O 效率,而我們進行讀取數據的時候存在一個“局部性原理”,也就是說我們使用了一些數據,大概率還會使用它周圍的一些數據,因此采用“預讀”的機制提前加載,可以減少未來可能的磁盤 I/O 操作。

那么什么是查詢緩存呢?

查詢緩存是提前把查詢結果緩存起來,這樣下次不需要執行就可以直接拿到結果。需要說明的是,在 MySQL 中的查詢緩存,不是緩存查詢計划,而是查詢對應的結果。這就意味着查詢匹配的魯棒性大大降低,只有相同的查詢操作才會命中查詢緩存。因此 MySQL 的查詢緩存命中率不高,在 MySQL8.0 版本中已經棄用了查詢緩存功能。

查看是否使用了查詢緩存,使用命令:

show variables like '%query_cache%';

緩沖池並不等於查詢緩存,它們的共同點都是通過緩存的機制來提升效率。

但緩沖池服務於數據庫整體的 I/O 操作,而查詢緩存服務於 SQL 查詢和查詢結果集的,因為命中條件苛刻,而且只要數據表發生變化,查詢緩存就會失效,因此命中率低。

其他

很多人對 InnoDB 和 MyISAM 的取舍存在疑問,到底選擇哪個比較好呢?

我們需要先了解 InnoDB 和 MyISAM 各自的特點。

InnoDB 支持事務和行級鎖,是 MySQL 默認的存儲引擎;MyISAM 只支持表級鎖,不支持事務,更適合讀取數據庫的情況。

如果是小型的應用,需要大量的 SELECT 查詢,可以考慮 MyISAM;如果是事務處理應用,需要選擇 InnoDB。

這兩種引擎各有特點,當然你也可以在 MySQL 中,針對不同的數據表,可以選擇不同的存儲引擎。

文章中的“product_comment”表結構和數據,網盤里下載,提取碼為 32ep。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM