zoukankan      html  css  js  c++  java
  • MySQL 通讯协议

    Client/Server 通讯协议用于客户端链接、代理、主备复制等,支持 SSL、压缩,在链接阶段进行认证,在执行命令时可以支持 Prepared Statements 以及 Stored Procedures 。

    当打算编写数据库代理、中间件、对 MySQL 数据包进行审核时,都需要了解底层的通信协议。在本文中,主要介绍 MySQL 通讯协议相关的内容。

    简介

    服务器启动后,会使用 TCP 监听一个本地端口,当客户端的连接请求到达时,就会执行三段握手以及 MySQL 的权限验证;验证成功后,客户端开始发送请求,服务器会以响应的报文格式返回数据;当客户端发送完成后,会发送一个特殊的报文,告知服务器已结束会话。

    MySQL 定义了几种包类型,A) 客户端->服务器,登录时的 auth 包、执行 SQL 时的 CMD 包;B) 服务器->客户端,登录时的握手包、数据包、数据流结束包、成功包(OK Packet)、错误信息包。

    协议定义了基本的数据类型,如 int、string 等;数据的传送格式等。

    协议

    MySQL 的客户端与服务器之间支持多种通讯方式,最广泛使用的是 TCP 通讯;另外,还支持命名管道和共享内存,而 TCP 就是最通用的一种方式,在此仅介绍 TCP 方式。

    在 C/S 之间,实际采用的是一种类似半双工式的模式收发数据,即在一个 TCP 链路上,客户端发出请求数据后,只有在接收完所有的服务端响应数据以后才能发下一次请求,中间不能发其它数据,需要有很强的顺序性要求。

    MySQL 客户端与服务器的交互主要分为两个阶段,分别为握手认证阶段和命令执行阶段,详细来说一次正常的过程如下:

    1. 三次握手建立 TCP 连接。
    
    2. 建立 MySQL 连接,也就是认证阶段。
        服务端 -> 客户端:发送握手初始化包 (Handshake Initialization Packet)。
        客户端 -> 服务端:发送验证包 (Client Authentication Packet)。
        服务端 -> 客户端:认证结果消息。
    
    3. 认证通过之后,客户端开始与服务端之间交互,也就是命令执行阶段。
        客户端 -> 服务端:发送命令包 (Command Packet)。
        服务端 -> 客户端:发送回应包 (OK Packet, or Error Packet, or Result Set Packet)。
    
    4. 断开 MySQL 连接。
        客户端 -> 服务器:发送退出命令包。
    
    5. 四次握手断开 TCP 连接。

    从服务器发往客户端的数据包有四种:数据包、数据结束包、成功报告包以及错误消息包。Result Set Packet 首先发送包头+列包+EOF包+行包+EOF包。

    报文格式

    所有的包有统一的格式,并通过函数 my_net_write()@sql/net_serv.cc 写入 buffer 等待发送。

    +-------------------+--------------+---------------------------------------------------+
    |      3 Bytes      |    1 Byte    |                   N Bytes                         |
    +-------------------+--------------+---------------------------------------------------+
    |<= length of msg =>|<= sequence =>|<==================== data =======================>|
    |<============= header ===========>|<==================== body =======================>|

    MySQL 报文格式如上,消息头包含了 A) 报文长度,标记当前请求的实际数据长度,以字节为单位;B) 序号,为了保证交互时报文的顺序,每次客户端发起请求时,序号值都会从 0 开始计算。

    消息体用于存放报文的具体数据,长度由消息头中的长度值决定。

    单个报文的最大长度为 (2^24-1)Bytes ,也即 (16M-1)Bytes,对于包长为 (2^24-1)Bytes 也会拆为两个包发送。这是因为最初没有考虑 16M 的限制,从而没有预留任何字段来标志这个包的数据不完整,所以只好把长度为 (2^24-1) 的包视做不完整的包,直到后面收到一个长度小于 (2^24-1) 的包,然后拼起来。

    这也意味着最后一个包的长度有可能是 0。

    基本类型

    接下来介绍一下报文中的数据类型,也就是不同的数据类型在报文中的表现形式。

    整型值

    MySQL 报文中整型值分别有 1、2、3、4、8 字节长度,使用小字节序传输。

    二进制数据

    也就是 Length Coded Binary,其数据长度不固定,长度值由数据前的 1-9 个字节决定,其中长度值所占的字节数不定,字节数由第 1 个字节决定,如下:

    第一个字节值    后续字节数  长度值说明
        0-250            0   第一个字节值即为数据的真实长度
          251            0   空数据,数据的真实长度为零
          252            2   后续额外2个字节标识了数据的真实长度
          253            3   后续额外3个字节标识了数据的真实长度
          254            8   后续额外8个字节标识了数据的真实长度

    字符串

    根据是否以 NULL 结尾,分为了有两种形式:

    • 以 NULL 结尾,Null-Terminated String
      字符串长度不固定,当遇到 'NULL'(0x00) 字符时结束。
    • 长度编码,Length Coded String
      字符串长度不固定,无 'NULL'(0x00) 结束符,编码方式与上面的二进制数据相同。

    客户端请求报文

    也就是从客户端发送到服务端的请求命令。

    +-------------------+------------------------------------------------------------------+
    |      1 Bytes      |                             N Bytes                              |
    +-------------------+------------------------------------------------------------------+
    |<==== command ====>|<============================ arguments =========================>|

    客户端向服务端发送的请求,其中第一个字节用于标识当前请求消息的类型,这也就定义了请求的种类,其中包括了:切换数据库 COM_INIT_DB(0x02)、查询命令 COM_QUERY(0x03) 等。

    命令的宏定义在 include/mysql_com.h 文件中,该命令会在 dispatch_command() 中根据不同的命令进入不同代码处理逻辑。

    报文中的参数内容是用户在 MySQL 客户端输入的命令,不包括每行命令结尾的 ';' 分号,采用的是非 NULL 结尾的字符串表示方法。

    例如:当在 MySQL 客户端中执行 use mysql; 命令时,发送的请求报文数据会是下面的样子:

    0x02 0x6d 0x79 0x73 0x71 0x6c

    0x02 为请求类型值 COM_INIT_DB,后面的 0x6d 0x79 0x73 0x71 0x6c 为 ASCII 字符 mysql 。

    错误码

    也就是当发生了错误之后,服务端发送给客户端的报文。

    MySQL 的错误包含了三部分:A) MySQL 特定的错误码,数字类型,不通用;B) SQLSTATE,为 5 个字符的字符串,采用 ANSI SQL 和 ODBC 的标准;C) 错误信息。

    对于错误报文的格式可以参照参考文件,其中第二字节表示由 MySQL 定义的错误编码,服务器状态实际是 ANSI SQL 对应的编码,两者并非一一对应。

    在 MySQL 中可以通过 perror ERROR 查看;详细的文档,可以参考官方文档 Appendix B Errors, Error Codes, and Common Problems 。

    抓包分析

    可以通过 tcpdump 捕获包并保存在文件中,然后通过 Wireshark 打开文件,查看网络包的内容,相对来说比较方便。可以通过 tcpdump -D 查看支持的网卡接口,通过 -i 指定接口,在此使用 lo

    注意,tcpdump 不能捕获 unix socket,链接时不能使用 -S /tmp/mysql.sock 或者 -h localhost 参数,应当使用 -h 127.1 。

    可以将 tcpdump 的包输出到 stdout 或者通过 -w 保存到文件,然后用 Wireshark 分析。

    ----- 将抓包的数据保存到文件
    # tcpdump -i lo port 3306 -w filename
    
    ----- 当然,也可以打印到终端,然后处理数据
    # tcpdump -i lo port 3306 -nn -X -q
    # tcpdump -i any -s 0 -l -w - dst port 3306 | strings | grep -iE 'select|update'

    认证协议

    认证稍微有点复杂,单独拉出来。

    MySQL 的用户管理模块信息存储在系统表 mysql.user 中,其中包括了授权用户的基本信息以及一些权限信息。在登陆时,只会用到 host、user、passwd 三个字段,也就是说登陆认证需要 host+user 关联,当然可以使用 * 通配符。

    服务器在收到新的连接请求时,会调用 login_connection() 作身份验证,先根据 IP 做 ACL 检查,然后才进入用户名密码验证阶段。

    其中报文的格式如下。

    auth protocol

    MySQL 认证采用经典的 CHAP 协议,即挑战握手认证协议,在 native_password_authenticate()函数的注释中简单介绍了该协议的执行过程:

    1. the server sends the random scramble to the client.
    2. client sends the encrypted password back to the server.
    3. the server checks the password.

    random scramble 在 4.1 之前的版本中是 8 字节整数,在 4.1 以及后续版本是 20 字节整数,该值是通过 create_random_string() 函数生成。

    根据版本不同,分为了两类。

    4.0版本之前

    基本流程如下:

    1. 服务器发送随机字符串 (scramble) 给客户端。可以参考 create_random_string() 的生成方法。
    2. 客户端把用户明文密码加密一下,然后再将其与服务器发送的随机字符串加密一下,然后变成了新的 scramble_buff 发送给服务端。可以参考 scramble() 函数的实现。
    3. 服务端将 mysql.user.password 中的值加上原始随机字符串进行加密,如果加密后的值和客户端发送过来的内容一样,则验证成功。

    需要注意的是:真正意义上的密码是明文密码的加密 hash 值; 如果有人知道了这个用户的 password 哈希值,而不用知道原始明文密码,实际上他就能直接登录服务器。

    4.1 以后版本

    数据库中保存的密码是用 SHA1(SHA1(password)) 加密的,其流程为:

    1. 服务器发送随机字符串 (scramble) 给客户端。
    2. 客户端作如下计算,然后客户端将 token 发送给服务端。

      stage1_hash = SHA1(明文密码)

      token = SHA1(scramble + SHA1(stage1_hash)) XOR stage1_hash

    3. 服务端作如下计算,比对 SHA1(stage1_hash) 和 mysql.user.password 是否相同。

      stage1_hash = token XOR SHA1(scramble + mysql.user.password)

    这里实际用到了异或的自反性: A XOR B XOR B = A ,对于给定的数 A,用同样的运算因子 B 作两次异或运算后仍得到 A 本身。对于当前情况的话,实际的计算过程如下。

    token = SHA1(scramble + SHA1(SHA1(password))) XOR SHA1(password)         // 客户端返回的值
          = PASSWORD XOR SHA1(password)
    
    stage1_hash = token XOR SHA1(scramble + mysql.user.password) = token XOR PASSWORD
                = [PASSWORD XOR SHA1(password)] XOR PASSWORD
                = SHA1(password)

    因此,校验时,只需要 SHA1(stage1_hash) 与 mysql.user.password 比较一下即可。

    这次没上一个版本的缺陷了. 有了 mysql.user.password 和 scramble 也不能获得 token,因为没法获得 stage1_hash

    但是如果用户的 mysql.user.password 泄露,并且可以在网络上截取的一次完整验证数据,从而可以反解出 stage1_hash 的值。而该值是不变的,因此下次连接获取了新的 scramble 后,自己加密一下 token 仍然可以链接到服务器。

    源码分析

    接下来分别介绍客户端、服务端的程序。

    客户端

    对于 mysql 客户端,源码保存在 client/mysql.cc 文件中,下面是 main() 函数的主要执行流程。

    main()
     |-sql_connect()
     | |-sql_real_connect()
     |   |-mysql_init()                             # 调用MySQL初始化
     |   |-mysql_options()                          # 设置链接选项
     |   |-mysql_real_connect()                     # sql-common/client.c
     |     |-connect_sync_or_async()                # 通过该函数尝试链接
     |     | |-my_connect()                         # 实际通过该函数建立链接
     |     |-cli_safe_read()                        # 等待第一个handshake包
     |     |-run_plugin_auth()                      # 通过插件实现认证
     |
     |-put_info()                                   # 打印客户端的欢迎信息
     |-read_and_execute()                           # 开始等待输入、执行SQL

    客户端最终会调用 mysql_real_connect(),实际调用的是 cli_mysql_real_connect(),通过该函数建立链接,其中认证方式可以通过 run_plugin_auth() 时用插件实现。

    然后,会输出一系列的欢迎信息,并通过 read_and_execute() 执行 SQL 命令。

    在 MySQL 客户端执行时,并非所有的命令都是需要发送到服务端的,其中有一个数组定义了常见的命令。

    static COMMANDS commands[] = {
      { "?",      '?', com_help,   1, "Synonym for `help'." },
      { "clear",  'c', com_clear,  0, "Clear the current input statement."},
      ... ...
    };

    每次读取一行都会通过 find_command() 函数进行检测,如果满足对应的命令,且对应的函数变量非空,则直接执行,如 clear,此时不需要输入分号即可;如果没有找到,则必须要等待输入分号。

    int read_and_execute(bool interactive)
    {
        while (!aborted) {
            if (!interactive) {                               // 是否为交互模式
                ... ...   // 非交互模式,直接执行
            } else {                                          // 交互模式
                char *prompt = ...;                           // 首先会设置提示符
                line = readline(prompt);                      // 从命令行读取
                if ( ... && (com= find_command(line))) {      // 从commands[]中查找
                    (*com->func)(&glob_buffer,line);          // 如果是help、edit等指令,则直接执行
                }
                add_line(...);                                // 常见的SQL,最终在此执行
            }
        }
    }
    
    int com_go(String *buffer,char *line)
    {
        timer=start_timer();                                                // 设置时间
        error= mysql_real_query_for_lazy(buffer->ptr(),buffer->length());   // 执行查询SQL
        do {
            // 获取结果
        } while(!(err= mysql_next_result(&mysql)));
    }

    在 add_line() 函数中,最终会调用 com_go() 函数,该函数是执行的主要函数,会最终调用 MySQL API 执行相应的 SQL、返回结果、输出时间等统计信息。

    服务端

    服务端通过 network_init() 执行一系列初始化之后,会阻塞在 handle_connections_sockets() 函数的 select()/poll() 函数处。

    对于 one_thread_per_connection 这种方式,会新建一个线程执行 handle_one_connection() 。

    handle_one_connection()
     |-thd_prepare_connection()
       |-login_connection()
         |-check_connection()
           |-acl_authenticate()

    源码内容如下。

    /* sql/sql_connect.cc */
    int check_connection(THD *thd)
    {
        if (!thd->main_security_ctx.host) {  // 通过TCP/IP连接,或者本地用-h 127.1
             if (acl_check_host(...))        // 检查hostname
        } else {                             // 使用unix sock连接,不会进行检测
            ... ...
        }
        return acl_authenticate(thd, connect_errors, 0)
    }
    
    /* sql/sql_acl.cc */
    bool acl_authenticate(THD *thd, uint connect_errors, uint com_change_user_pkt_len)
    {
        if (command == COM_CHANGE_USER) {
    
        } else {
            do_auth_once()                      // 执行认证模式
    
        }
    }

    在 acl_check_host() 会检查两个对象,一个是 hash 表 acl_check_hosts;另一个是动态数组 acl_wild_hosts 。这2个对象是在启动的时候,通过 init_check_host() 从 mysq.users 表里读出并加载的,其中 acl_wild_hosts 用于存放有统配符的主机,acl_check_hosts 存放具体的主机。

    最终会调用 acl_authenticate() 这是主要的认证函数。

    插件实现

    MySQL 的认证授权采用插件实现。

    默认采用 mysql_native_password 插件,也就是使用 native_password_auth_client() 作用户端的认证,实际有效的函数是 scramble()

    上述的函数通过用户输入的 password、服务器返回的 scramble 生成 reply,返回给服务端;可以通过 password('string') 查看加密后的密文。

    以 plugin/auth/ 目录下的插件为例,在启动服务器时,可添加 --plugin-load=auth_test_plugin.so参数自动加载相应的授权插件。

    ----- 获得foobar的加密格式
    mysql> select password('foobar');
    ----- 旧的加密格式
    mysql> select old_password('foobar');
    ----- 默认方式
    mysql> create user 'foobar2'@'localhost' identified via mysql_native_password using 'xxx';
    
    ----- 也可以动态加载
    mysql> install plugin test_plugin_server soname 'auth_test_plugin.so';
    ----- 查看当前支持的插件
    mysql> select * from information_schema.plugins where plugin_type='authentication';
    
    mysql> create user 'foobar'@'localhost' identified with test_plugin_server;
    mysql> SET PASSWORD FOR 'foobar'@'localhost'=PASSWORD('new_password');
    mysql> DROP USER 'foobar'@'localhost';
    mysql> FLUSH PRIVILEGES;
    mysql> SELECT host, user, password, plugin FROM mysql.user;

    在 plugin 目录下有很多 auth 插件可供参考,详细可参考官网 Writing Authentication Plugins 。

    总结

    在如下列举客户端与服务端的详细交互过程,其中客户端代码在 client 目录下。

    ### Client(mysql)  ###                       ### Server(mysqld) ###
    ----------------------------------------     --------------------------------------------------
    main()                                       mysqld_main()
     |-sql_connect()                              |-init_ssl()
     | |-sql_real_connect() {for(;;)}             |-network_init()
     |   |-mysql_init()                           |-handle_connections_sockets()
     |   |-init_connection_options()                |-select()/poll()
     |   |-mysql_real_connect()                     |
     |     |-cli_mysql_real_connect()               |
     |       |-socket()                             |
     |       |-vio_new()                            |
     |       |-vio_socket_connect()                 |
     |       | |-mysql_socket_connect()             |
     |       |   |-connect()                        |
     |       |   |                                  |
     |       |   |        [Socket Connect]          |
     |       |   |>>==========>>==========>>======>>|
     |       |                                      |-accept()
     |       |-vio_keepalive()                      |-vio_new()
     |       |-my_net_set_read_timeout()            |-my_net_init()
     |       |-my_net_set_write_timeout()           |-create_new_thread()
     |       |-vio_io_wait()                          |-handle_one_connection()    {新线程}
     |       |                                          |-thd_prepare_connection() {for(;;)}
     |       |                                          | |-login_connection()
     |       |                                          |   |-check_connection()
     |       |                                          |     |-acl_check_host()
     |       |                                          |     |-vio_keepalive()
     |       |                                          |     |-acl_authenticate()
     |       |                                          |       |-do_auth_once()
     |       |                                          |       | |-native_password_authenticate()  {插件实现}
     |       |                                          |       |   |-create_random_string()
     |       |                                          |       |   |-send_server_handshake_packet()
     |       |                                          |       |   |
     |       |              [Handshake Initialization]  |       |   |
     |       |<<==<<==========<<==========<<==========<<==========<<|
     |       |-cli_safe_read()                          |       |   |-my_net_read()
     |       |-run_plugin_auth()                        |       |   |
     |       | |-native_password_auth_client()          |       |   |
     |       |   |-scramble()                           |       |   |
     |       |     |-my_net_write()                     |       |   |
     |       |     |                                    |       |   |
     |       |     |            [Client Authentication] |       |   |
     |       |     |>>==========>>==========>>==========>>========>>|
     |       |                                          |       |   |-check_scramble()
     |       |                                          |       |-mysql_change_db()
     |       |                                          |       |-my_ok()
     |       |                      [OK]                |       |
     |       |<<==========<<==========<<==========<<==========<<|
     |       |-cli_safe_read()                          |
     |                                                  |
     |                                                  |
     |                                                  |
     |                                                  |
     |-put_info() {Welcome Info}                        |
     |-read_and_execute() [for(;;)]                     |
                                                        |-thd_is_connection_alive()  [while()]
                                                        |-do_command()

    参考

    关于 MySQL的认证流程,包括客户端和服务器端,可以参考本地 MySQL认证协议;详细的协议介绍可以参考 MySQL Client/Server Protocol,或者 中文资料,或者保存的本地资料 MySQL服务器和客户端通信协议分析 。

    MySQL 的认证授权可以采用插件,在 plugin 目录下有很多 auth 插件可供参考,具体可以参考官网的 MySQL Reference - Writing Authentication Plugins 。

  • 相关阅读:
    LeetCode 965. Univalued Binary Tree
    LeetCode 961. N-Repeated Element in Size 2N Array
    LeetCode 832. Flipping an Image
    语法设计——基于LL(1)文法的预测分析表法
    简单的词法设计——DFA模拟程序
    LeetCode 905. Sort Array By Parity
    LeetCode 804. Unique Morse Code Words
    【原创】用事实说话,Firefox 的性能是 Chrome 的 2 倍,Edge 的 4 倍,IE11 的 6 倍!
    【新特性速递】新增单标签页模式,界面更加清爽!
    【新特性速递】重构表格列锁定代码,只有一个横向滚动条,更加现代化!
  • 原文地址:https://www.cnblogs.com/qiumingcheng/p/10655142.html
Copyright © 2011-2022 走看看