1. 程式人生 > 實用技巧 >怒肝兩個月MySQL原始碼,2W字MySQL協議詳解(超硬核)

怒肝兩個月MySQL原始碼,2W字MySQL協議詳解(超硬核)

最近,在開發一個分庫分表中介軟體,由於功能需求,需要分析MySQL協議,發現網上對於MySQL協議分析的文章大部分都過時了,原因是分析的MySQL版本太低了。怎麼辦呢?於是乎,我便硬著頭皮開始啃MySQL原始碼,經過兩個多月的整理,終於總結出這篇MySQL協議。

注:部分來自於網際網路,感謝資料庫大牛前輩們的默默付出!

互動過程

MySQL客戶端與伺服器的互動主要分為兩個階段:握手認證階段和命令執行階段。

握手認證階段

握手認證階段為客戶端與伺服器建立連線後進行,互動過程如下:

  • 伺服器 -> 客戶端:握手初始化訊息
  • 客戶端 -> 伺服器:登陸認證訊息
  • 伺服器 -> 客戶端:認證結果訊息

命令執行階段

客戶端認證成功後,會進入命令執行階段,互動過程如下:

  • 客戶端 -> 伺服器:執行命令訊息
  • 伺服器 -> 客戶端:命令執行結果

MySQL客戶端與伺服器的完整互動過程如下

基本型別

整型值

MySQL報文中整型值分別有1、2、3、4、8位元組長度,使用小位元組序傳輸。

字串(以NULL結尾)(Null-Terminated String)

字串長度不固定,當遇到'NULL'(0x00)字元時結束。

二進位制資料(長度編碼)(Length Coded Binary)

資料長度不固定,長度值由資料前的1-9個位元組決定,其中長度值所佔的位元組數不定,位元組數由第1個位元組決定,如下表:

字串(長度編碼)(Length Coded String)

字串長度不固定,無'NULL'(0x00)結束符,編碼方式與上面的 Length Coded Binary 相同。

報文結構

報文分為訊息頭和訊息體兩部分,其中訊息頭佔用固定的4個位元組,訊息體長度由訊息頭中的長度欄位決定,報文結構如下:

訊息頭

報文長度

用於標記當前請求訊息的實際資料長度值,以位元組為單位,佔用3個位元組,最大值為 0xFFFFFF,即接近 16 MB 大小(比16MB少1個位元組)。

序號

在一次完整的請求/響應互動過程中,用於保證訊息順序的正確,每次客戶端發起請求時,序號值都會從0開始計算。

訊息體

訊息體用於存放請求的內容及相應的資料,長度由訊息頭中的長度值決定。

報文型別

登陸認證互動報文

握手初始化報文(伺服器 -> 客戶端)

服務協議版本號:該值由 PROTOCOL_VERSION 巨集定義決定(參考MySQL原始碼/include/mysql_version.h標頭檔案定義)

服務版本資訊:該值為字串,由 MYSQL_SERVER_VERSION 巨集定義決定(參考MySQL原始碼/include/mysql_version.h標頭檔案定義)

伺服器執行緒ID:伺服器為當前連線所建立的執行緒ID。

挑戰隨機數:MySQL資料庫使用者認證採用的是挑戰/應答的方式,伺服器生成該挑戰數併發送給客戶端,由客戶端進行處理並返回相應結果,然後伺服器檢查是否與預期的結果相同,從而完成使用者認證的過程。

伺服器功能標誌:用於與客戶端協商通訊方式,各標誌位含義如下(參考MySQL原始碼/include/mysql_com.h中的巨集定義):

字元編碼:標示伺服器所使用的字符集。

伺服器狀態:狀態值定義如下(參考MySQL原始碼/include/mysql_com.h中的巨集定義):

登陸認證報文(客戶端 -> 伺服器)

MySQL 4.0 及之前的版本

MySQL 4.1 及之後的版本

客戶端權能標誌:用於與客戶端協商通訊方式,標誌位含義與握手初始化報文中的相同。客戶端收到伺服器發來的初始化報文後,會對伺服器傳送的權能標誌進行修改,保留自身所支援的功能,然後將權能標返回給伺服器,從而保證伺服器與客戶端通訊的相容性。

最大訊息長度:客戶端傳送請求報文時所支援的最大訊息長度值。

字元編碼:標識通訊過程中使用的字元編碼,與伺服器在認證初始化報文中傳送的相同。

使用者名稱:客戶端登陸使用者的使用者名稱稱。

挑戰認證資料:客戶端使用者密碼使用伺服器傳送的挑戰隨機數進行加密後,生成挑戰認證資料,然後返回給伺服器,用於對使用者身份的認證。

資料庫名稱:當客戶端的權能標誌為 CLIENT_CONNECT_WITH_DB 被置位時,該欄位必須出現。

客戶端命令請求報文(客戶端 -> 伺服器)

命令:用於標識當前請求訊息的型別,例如切換資料庫(0x02)、查詢命令(0x03)等。命令值的取值範圍及說明如下表(參考MySQL原始碼/include/mysql_com.h標頭檔案中的定義):

引數:內容是使用者在MySQL客戶端輸入的命令(不包括每行命令結尾的";"分號)。另外這個欄位的字串不是以NULL字元結尾,而是通過訊息頭中的長度值計算而來。

例如:當我們在MySQL客戶端中執行use hutaow;命令時(切換到hutaow資料庫),傳送的請求報文資料會是下面的樣子:

0x020x680x750x740x610x6f0x77

其中,0x02為請求型別值COM_INIT_DB,後面的0x68 0x75 0x74 0x61 0x6f 0x77為ASCII字元hutaow。

COM_QUIT 訊息報文

功能:關閉當前連線(客戶端退出),無引數。

COM_INIT_DB 訊息報文

功能:切換資料庫,對應的SQL語句為USE。

COM_QUERY 訊息報文

功能:最常見的請求訊息型別,當用戶執行SQL語句時傳送該訊息。

COM_FIELD_LIST 訊息報文

功能:查詢某表的欄位(列)資訊,等同於SQL語句SHOW [FULL] FIELDS FROM ...。

COM_CREATE_DB 訊息報文

功能:建立資料庫,該訊息已過時,而被SQL語句CREATE DATABASE代替。

COM_DROP_DB 訊息報文

功能:刪除資料庫,該訊息已過時,而被SQL語句DROP DATABASE代替。

COM_REFRESH 訊息報文

功能:清除快取,等同於SQL語句FLUSH,或是執行mysqladmin flush-foo命令時傳送該訊息。

COM_SHUTDOWN 訊息報文

功能:停止MySQL服務。執行mysqladmin shutdown命令時傳送該訊息。

COM_STATISTICS 訊息報文

功能:檢視MySQL服務的統計資訊(例如執行時間、每秒查詢次數等)。執行mysqladmin status命令時傳送該訊息,無引數。

COM_PROCESS_INFO 訊息報文

功能:獲取當前活動的執行緒(連線)列表。等同於SQL語句SHOW PROCESSLIST,或是執行mysqladmin processlist命令時傳送該訊息,無引數。

COM_PROCESS_KILL 訊息報文

功能:要求伺服器中斷某個連線。等同於SQL語句KILL。

COM_DEBUG 訊息報文

功能:要求伺服器將除錯資訊儲存下來,儲存的資訊多少依賴於編譯選項設定(debug=no|yes|full)。執行mysqladmin debug命令時傳送該訊息,無引數。

COM_PING 訊息報文

功能:該訊息用來測試連通性,同時會將伺服器的無效連線(超時)計數器清零。執行mysqladmin ping命令時傳送該訊息,無引數。

COM_CHANGE_USER 訊息報文

功能:在不斷連線的情況下重新登陸,該操作會銷燬MySQL伺服器端的會話上下文(包括臨時表、會話變數等)。有些連線池用這種方法實現清除會話上下文。

COM_BINLOG_DUMP 訊息報文

功能:該訊息是備份連線時由從伺服器向主伺服器傳送的最後一個請求,主伺服器收到後,會響應一系列的報文,每個報文都包含一個二進位制日誌事件。如果主伺服器出現故障時,會發送一個EOF報文。

COM_TABLE_DUMP 訊息報文

功能:將資料表從主伺服器複製到從伺服器中,執行SQL語句LOAD TABLE ... FROM MASTER時傳送該訊息。目前該訊息已過時,不再使用。

COM_REGISTER_SLAVE 訊息報文

功能:在從伺服器report_host變數設定的情況下,當備份連線時向主伺服器傳送的註冊訊息。

COM_PREPARE 訊息報文

功能:預處理SQL語句,使用帶有"?"佔位符的SQL語句時傳送該訊息。

COM_EXECUTE 訊息報文

功能:執行預處理語句。

COM_LONG_DATA 訊息報文

該訊息報文有兩種形式,一種用於傳送二進位制資料,另一種用於傳送文字資料。

功能:用於傳送二進位制(BLOB)型別的資料(呼叫mysql_stmt_send_long_data函式)。

功能:用於傳送超長字串型別的資料(呼叫mysql_send_long_data函式)

COM_CLOSE_STMT 訊息報文

功能:銷燬預處理語句。

COM_RESET_STMT 訊息報文

功能:將預處理語句的引數快取清空。多數情況和COM_LONG_DATA一起使用。

COM_SET_OPTION 訊息報文

功能:設定語句選項,選項值為/include/mysql_com.h標頭檔案中定義的enum_mysql_set_option列舉型別:

  • MYSQL_OPTION_MULTI_STATEMENTS_ON
  • MYSQL_OPTION_MULTI_STATEMENTS_OFF

COM_FETCH_STMT 訊息報文

功能:獲取預處理語句的執行結果(一次可以獲取多行資料)。

伺服器響應報文(伺服器 -> 客戶端)

當客戶端發起認證請求或命令請求後,伺服器會返回相應的執行結果給客戶端。客戶端在收到響應報文後,需要首先檢查第1個位元組的值,來區分響應報文的型別。

注:響應報文的第1個位元組在不同型別中含義不同,比如在OK報文中,該位元組並沒有實際意義,值恆為0x00;而在Result Set報文中,該位元組又是長度編碼的二進位制資料結構(Length Coded Binary)中的第1位元組。

響應報文

客戶端的命令執行正確時,伺服器會返回OK響應報文。

MySQL 4.0 及之前的版本

MySQL 4.1 及之後的版本

受影響行數:當執行INSERT/UPDATE/DELETE語句時所影響的資料行數。

索引ID值:該值為AUTO_INCREMENT索引欄位生成,如果沒有索引欄位,則為0x00。注意:當INSERT插入語句為多行資料時,該索引ID值為第一個插入的資料行索引值,而非最後一個。

伺服器狀態:客戶端可以通過該值檢查命令是否在事務處理中。

告警計數:告警發生的次數。

伺服器訊息:伺服器返回給客戶端的訊息,一般為簡單的描述性字串,可選欄位。

響應報文

MySQL 4.0 及之前的版本

MySQL 4.1 及之後的版本

錯誤編號:錯誤編號值定義在原始碼/include/mysqld_error.h標頭檔案中。

伺服器狀態:伺服器將錯誤編號通過mysql_errno_to_sqlstate函式轉換為狀態值,狀態值由5位元組的ASCII字元組成,定義在原始碼/include/sql_state.h標頭檔案中。

伺服器訊息:錯誤訊息字串到達訊息尾時結束,長度可以由訊息頭中的長度值計算得出。訊息長度為0-512位元組。

Result Set 訊息

當客戶端傳送查詢請求後,在沒有錯誤的情況下,伺服器會返回結果集(Result Set)給客戶端。

Result Set 訊息分為五部分,結構如下:

Result Set Header 結構

Field結構計數:用於標識Field結構的數量,取值範圍0x00-0xFA。

額外資訊:可選欄位,一般情況下不應該出現。只有像SHOW COLUMNS這種語句的執行結果才會用到額外資訊(標識表格的列數量)。

Field 結構

Field為資料表的列資訊,在Result Set中,Field會連續出現多次,次數由Result Set Header結構中的IField結構計數值決定。

MySQL 4.0 及之前的版本

MySQL 4.1 及之後的版本

目錄名稱:在4.1及之後的版本中,該欄位值為"def"。

資料庫名稱:資料庫名稱標識。

資料表名稱:資料表的別名(AS之後的名稱)。

資料表原始名稱:資料表的原始名稱(AS之前的名稱)。

列(欄位)名稱:列(欄位)的別名(AS之後的名稱)。

列(欄位)原始名稱:列(欄位)的原始名稱(AS之前的名稱)。

字元編碼:列(欄位)的字元編碼值。

列(欄位)長度:列(欄位)的長度值,真實長度可能小於該值,例如VARCHAR(2)型別的欄位實際只能儲存1個字元。

列(欄位)型別:列(欄位)的型別值,取值範圍如下(參考原始碼/include/mysql_com.h標頭檔案中的enum_field_type列舉型別定義):

列(欄位)標誌:各標誌位定義如下(參考原始碼/include/mysql_com.h標頭檔案中的巨集定義):

數值精度:該欄位對DECIMAL和NUMERIC型別的數值欄位有效,用於標識數值的精度(小數點位置)。

預設值:該欄位用在資料表定義中,普通的查詢結果中不會出現。

:Field結構的相關處理函式:

  • 客戶端:/client/client.c原始檔中的unpack_fields函式
  • 伺服器:/sql/sql_base.cc原始檔中的send_fields函式

EOF 結構

EOF結構用於標識Field和Row Data的結束,在預處理語句中,EOF也被用來標識引數的結束。

MySQL 4.0 及之前的版本

MySQL 4.1 及之後的版本

告警計數:伺服器告警數量,在所有資料都發送給客戶端後該值才有效。

狀態標誌位:包含類似SERVER_MORE_RESULTS_EXISTS這樣的標誌位。

:由於EOF值與其它Result Set結構共用1位元組,所以在收到報文後需要對EOF包的真實性進行校驗,校驗條件為:

  • 第1位元組值為0xFE
  • 包長度小於9位元組

:EOF結構的相關處理函式:

  • 伺服器:protocol.cc原始檔中的send_eof函式

Row Data 結構

在Result Set訊息中,會包含多個Row Data結構,每個Row Data結構又包含多個欄位值,這些欄位值組成一行資料。

欄位值:行資料中的欄位值,字串形式。

:Row Data結構的相關處理函式:

  • 客戶端:/client/client.c原始檔中的read_rows函式

Row Data 結構(二進位制資料)

該結構用於傳輸二進位制的欄位值,既可以是伺服器返回的結果,也可以是由客戶端傳送的(當執行預處理語句時,客戶端使用Result Set訊息來發送引數及資料)。

空點陣圖:前2個位元位被保留,值分別為0和1,以保證不會和OK、Error包的首位元組衝突。在MySQL 5.0及之後的版本中,這2個位元位的值都為0。

欄位值:行資料中的欄位值,二進位制形式。

PREPARE_OK 響應報文(Prepared Statement)

用於響應客戶端發起的預處理語句報文,組成結構如下:

其中 PREPARD_OK 的結構如下:

Parameter 響應報文(Prepared Statement)

預處理語句的值與引數正確對應後,伺服器會返回 Parameter 報文。

型別:與 Field 結構中的欄位型別相同。

標誌:與 Field 結構中的欄位標誌相同。

數值精度:與 Field 結構中的數值精度相同。

欄位長度:與 Field 結構中的欄位長度相同。

程式碼分析

協議頭

● 資料變成在網路裡傳輸的資料,需要額外的在頭部新增4 個位元組的包頭.

. packet length(3位元組), 包體的長度

. packet number(1位元組), 從0開始的遞增的

● sql “select 1” 的網路協議是?

協議頭

● packet length三個位元組意味著MySQL packet最大16M大於16M則被分包(net_write_command, my_net_write)

● packet number分包從0開始,依次遞增.每一次執行sql, packet_number清零(sql/net_serv.c:net_clear)

協議型別

● handshake

● auth

● ok|error

● resultset

○ header

○ field

○ eof

○ row

● command packet

連線時的互動

協議說明

● 協議內欄位分三種形式

○ 固定長度(include/my_global.h)

■ uint*korr 解包 *

■ int*store 封包

○ length coded binary(sql-common/pack.c)

■ net_field_length 解包

■ net_store_length 封包

○ null-terminated string

● length coded binary

○ 避免binary unsafe string, 字串的長度儲存在字串的前面

■ length<251 1 byte

■ length <256^2 3 byte(第一個byte是252)

■ length<256^3 4byte(第一個byte是253)

■ else 9byte(第一個byte是254)

handshake packet

● 該協議由服務端傳送客戶端

● 括號內為位元組數,位元組數為n為是null-terminated string;位元組數為大寫的N表示length code binary.

● salt就是scramble.分成兩個部分是為了相容4.1版本

● sql_connect.cc:check_connection

● sql_client.c:mysql_real_connect

auth packet

● 該協議是從客戶端對密碼使用scramble加密後傳送到服務端

● 其中databasename是可選的.salt就是加密後的密碼.

● sql_client.c:mysql_real_connect

● sql_connect.c:check_connection

ok packet

● ok包,命令和insert,update,delete的返回結果

● 包體首位元組為0.

● insert_id, affect_rows也是一併發過來.

● src/protocol.cc:net_send_ok

error packet

● 錯誤的命令,非法的sql的返回包

● 包體首位元組為255.

● error code就是CR_***,include/errmsg.h ● sqlstate marker是#

● sqlstate是錯誤狀態,include/sql_state.h

● message是錯誤的資訊

● sql/protocol.cc:net_send_error_packet

resultset packet

● 結果集的資料包,由多個packet組合而成

● 例如查詢一個結構集,順序如下: ○ header ○ field1....fieldN ○ eof ○ row1...rowN ○ eof

● sql/client.c:cli_read_query_result

● 下面是一個sql "select * from d"查詢結果集的例子,結果 集是6行,3個欄位 ○ 公式:假設結果集有N行, M個欄位.則包的個數為,header(1) + field (M) + eof(1) + row(N) + eof(1) ○ 所以這個例子的MySQL packet的個數是12個

resultset packet - header

● field packet number決定了接下來的field packet的個數.

● 一個返回6行記錄,3個欄位的查詢語句

resultset packet - field

● 結果集中一個欄位一個field packet.

● tables_alias是sql語句裡表的別名,org_table才是表的真 實名字.

● sql/protocol.cc:Protocol::send_fields

● sql/client.c:cli_read_query_result

resultset packet - eof

● eof包是用於分割field packet和row packet.

● 包體首位元組為254

● sql/protocol.cc:net_send_eof

resultset packet - row

● row packet裡才是真正的資料包.一行資料一個packet.

● row裡的每個欄位都是length coded binary

● 欄位的個數在header packet裡

● sql/client.c:cli_read_rows

command packet

● 命令包,包括我們的sql語句還有一些常見的命令.

● 包體首字母表示命令的型別(include/mysql_com.h),大 部分命令都是COM_QUERY.

網路協議關鍵函式

● net_write_command(sql/net_serv.cc)所有的sql最終呼叫這個命令傳送出去.

● my_net_write(sql/net_serv.cc)連線階段的socket write操作呼叫這個函式.

● my_net_read讀取包,會判斷包大小,是否是分包

● my_real_read解析MySQL packet,第一次讀取4位元組,根據packet length再讀取餘下來的長度

● cli_safe_read客戶端解包函式,包含了my_net_read

NET緩衝

● 每次socket操作都會先把資料寫,讀到net->buff,這是一 個緩衝區, 減少系統呼叫呼叫的次數.

● 當寫入的資料和buff內的資料超過buff大小才會發出一次 write操作,然後再把要寫入的buff裡插入數, 寫入不會 導致buff區區域擴充套件.(sql/net_serv.cc: net_write_buff).

● net->buff大小初始net->max_packet, 讀取會導致會導致 buff的realloc最大net->max_packet_size

● 一次sql命令的結束都會呼叫net_flush,把buff裡的資料 都寫到socket裡.

VIO緩衝

● 從my_read_read可以看出每次packet讀取都是按需讀取, 為了減少系統呼叫,vio層面加了一個read_buffer.

● 每次讀取前先判斷vio->read_buffer所需資料的長度是 否足夠.如果存在則直接copy. 如果不夠,則觸發一次 socket read 讀取2048個字(vio/viosocket.c: vio_read_buff)

MySQL API

● 資料從mysql_send_query處傳送給服務端,實際呼叫的是 net_write_command.

● cli_read_query_result解析header packet, field packet,獲 得field_count的個數

● mysql_store_result解析了row packet,並存儲在result- >data裡

● myql_fetch_row其實遍歷result->data

PACKET NUMBER

在做proxy的時候在這裡迷糊過,翻了幾遍程式碼才搞明白,細節如下:客戶端服務端的net->pkt_nr都從0開始.接受包時比較packet number 和net->pkt_nr是否相等,否則報packet number亂序,連線報錯;相等則pkt_nr自增.傳送包時把net->pkt_nr作為packet number傳送,然後對net->pkt_nr進行自動保持和對端的同步.

接收包

傳送包

我們來幾個具體場景的packet number, net->pkt_nr的變化

連線

開始兩方都為0,服務端傳送handshake packet(pkt=0)之後自增為1,然後等待對端傳送過來pkt=1的包

查詢

每次查詢,服務客戶端都會對net->pkt_nr進行清零

開始兩方net->pkt_nr皆為0, 命令傳送後客戶端端為1,服務端開始傳送分包,分包的pkt_nr的依次遞增,客戶端的net->pkt_nr也隨之增加.

解包的細節

my_net_read負責解包,首先讀取4個位元組,判斷packet number是否等於net->pkt_nr然後再次讀取packet_number長度的包體。

虛擬碼如下:

網路層優化

從ppt裡可以看到,一個resultset packet由多個包組成,如果每次讀寫包都導致系統呼叫那肯定是不合理,常規優化方法:寫大包加預讀

NET->BUFF

每個包傳送到網路或者從網路讀包都會先把資料包儲存在net->buff裡,待到net->buff滿了或者一次命令結束才會通過socket發出給對端.net->buff有個初始大小(net->max_packet),會隨讀取資料的增多而擴充套件.

VIO->READ_BUFFER

每次從網路讀包,並不是按包的大小讀取,而是會盡量讀取2048個位元組,這樣一個resultset包的讀取不會再引起多次的系統呼叫了.header packet讀取完畢後, 接下來的field,eof, row apcket讀取僅僅需要從vio-read_buffer拷貝指定位元組的資料即可.

MYSQL API說明

api和MySQL客戶端都會使用sql/client.c這個檔案,解包的過程都是使用sql/client.c:cli_read_query_result.

mysql_store_result來解析row packet,並把資料儲存到res->data裡,此時所有資料都存記憶體裡了.

mysql_fetch_row僅僅是使用內部的遊標,遍歷result->data裡的資料

mysql_free_result是把result->data指定的行資料釋放掉.