暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

解剖网易MySQL中间件Cetus前身

DBA天团 2021-02-05
1509


网易MySQL中间件cetus是以mysql-proxy为基础开发的,沿用了mysql-proxy的基本框架,如网络状态机和插件体系,同时,cetus也移除了原有的一些组件,如lua脚本和多线程。原版mysql-proxy项目已经停止维护,cetus作为后继者将用心提供更好的服务,本文分析mysql-proxy的软件结构,对cetus的原理感兴趣的朋友可以一读。


一 组件


底架层 (chassis)提供 命令行类程序 的通用功能,主要包括命令行解析 、配置文件解析、 日志、插件和事件循环。

网络层 抽象四个阶段:连接、认证、SQL查询、关闭,这些阶段又可以细分为更多的mysql协议层状态, 网络层的状态暴露给插件使用。

插件层 ,跟随mysql协议状态转移被触发,通常需要识别mysql协议包并决策下一个协议状态, 以完成包的转发。底架层和网络层分别引用插件层的一套钩子函数,在运行时,底架层和网络层会通过 钩子函数调用插件层。但在代码结构中,只有插件层会依赖底架层和网络层的头文件, 插件层在网络层和底架层之上。

协议库 ,实现mysql请求与结果集包的解析。

lua接口 ,插件层可以用它方便地暴露一些配置功能给用户。

底架层负责载入网络层和插件层,网络层在底架层之上,插件层在网络层之上,协议库和lua接口在旁边, 会被网络层和插件层调用。

二 底架层

底架层是个通用框架,使用这个框架的流程大致如下:


chassis* chas = chassis_new();
//按需使用组件
chassis_log* log = chassis_log_new();  //日志功能
chassis_options_t* opts = chassis_options_new(); //命令行解析,对GOption的简单包裹
chassis_keyfile_to_options(); //配置文件转换到选项,统一解析
network_mysqld_init(chas); //主应用启动
for (plugin) {
  chassis_plugin_load(); //加载所有插件
  chassis_plugin_get_options(); //处理插件自定义选项
  plugin->apply_config(); //通过钩子函数,初始化插件配置
}
chassis_mainloop(); //进入事件循环


底架层是通用框架,与具体业务逻辑无关,除了mysql-proxy主程序,还有一个binlog工具也基于它开发 (参考mysql-binlog-dump.c) 。底架层有日志、选项解析、配置文件、主应用、插件、事件机等组件,可以 按需求选择使用,mysql-proxy主程序使用了其所有组件,而binlog工具只用了日志和配置文件组件,甚至连 主应用都没有用到。下面对各个组件详细介绍。


2.1 命令行解析


命令行解析使用glib: GOption,只进行了简单的封装。

chassis_option_t
 的字段和GOptionEntry完全一样,只是其中的字符串类型由 const char*
 换成了 char*
 ,他们的区别就是 chassis_option_t
 中的 char*
 是可以释放的。从使用的角度来看,GOptionEntry 只能静态声明,而 chassis_option_t
 可以动态构造,它是可new可free版的GOptionEntry。而动态构造的好处 就是字段绑定更直观。

mysql-proxy对GOptionEntry有直接使用, 在proxy-plugin.c文件的 network_mysqld_proxy_plugin_get_options()
 函数中,是这么用的: 


static GOptionEntry * network_mysqld_proxy_plugin_get_options(chassis_plugin_config *config) {
  guint i;
  // 数组定义,arg_data字段都是0
  static GOptionEntry config_entries[] = {
      { "proxy-address",            'P'0, G_OPTION_ARG_STRING, NULL"listening""<host:port>" },
      { "proxy-read-only-backend-addresses"'r'0, G_OPTION_ARG_STRING_ARRAY, NULL"address:port""<host:port>" },
      { "proxy-backend-addresses",  'b'0, G_OPTION_ARG_STRING_ARRAY, NULL"address:port""<host:port>" },
      { "proxy-skip-profiling",     0, G_OPTION_FLAG_REVERSE, G_OPTION_ARG_NONE, NULL"disables"NULL },
      { "proxy-fix-bug-25371",      00, G_OPTION_ARG_NONE, NULL"fix bug #25371"NULL },
      { "proxy-lua-script",         's'0, G_OPTION_ARG_FILENAME, NULL"filename""<file>" },
      { "no-proxy",                 0, G_OPTION_FLAG_REVERSE, G_OPTION_ARG_NONE, NULL"don't start"NULL },
      { NULL,                       00, G_OPTION_ARG_NONE,   NULLNULLNULL }
    };

  i = 0;
  // 把config的字段赋值给arg_data,必须与上面声明一一对应
  config_entries[i++].arg_data = &(config->address);
  config_entries[i++].arg_data = &(config->read_only_backend_addresses);
  config_entries[i++].arg_data = &(config->backend_addresses);
  config_entries[i++].arg_data = &(config->profiling);
  config_entries[i++].arg_data = &(config->fix_bug_25371);
  config_entries[i++].arg_data = &(config->lua_script);
  config_entries[i++].arg_data = &(config->start_proxy);
  return config_entries;
}


arg_data
 字段的赋值不能放到数组定义中,因为初始化不能用动态内存。所以代码要写两块, 一块声明,一块赋值,还要顺序一样,显然这种 代码容易出错 。

使用 chassis_option_t
 可以解决这个问题,使用示例在mysql-proxy-cli.c文件 chassis_frontend_set_chassis_options()
 函数中:


int chassis_frontend_set_chassis_options(chassis_frontend_t *frontend, chassis_options_t *opts) {
  chassis_options_add(opts, "verbose-shutdown"00, G_OPTION_ARG_NONE, &(frontend->verbose_shutdown), "Always"NULL);
  chassis_options_add(opts, "daemon",           00, G_OPTION_ARG_NONE, &(frontend->daemon_mode), "Start in da-mode"NULL);
  chassis_options_add(opts, "basedir",          00, G_OPTION_ARG_STRING, &(frontend->base_dir), "Base directo""<absolute path>");
  chassis_options_add(opts, "pid-file",         00, G_OPTION_ARG_STRING, &(frontend->pid_file), "PID file in ""<file>");
  chassis_options_add(opts, "plugin-dir",       00, G_OPTION_ARG_STRING, &(frontend->plugin_dir), "path to th""<path>");
...



chassis_options_add()
 间接调用 chassis_option_new()
 新建一个 chassis_option_t
 , arg_data
 通过函数参数传入,一条选项的所有信息集中在一起,代码清晰, 不易犯错 。chassis_option_t
 可以动态构造,解析之前还是需要先调用 chassis_options_to_g_option_entries()
 转换成GOptionEntry才能解析,返回的动态数组还要释放,使用的流程稍显麻烦,所以有些选项不多的地方 还是直接使用了GOptionEntry。

  • 相关文件

    • chassis-options.c


2.2 配置文件解析


配置文件解析使用glib: GKeyFile。

GKeyFile仅解析文件得到键值对,键值对到变量的解析通过 chassis_keyfile_to_options_with_error()
 实现,因为变量已经关联到GOptionEntry数组了,所以此函数直接使用这个数组作为输出参数,解析到的结果 赋值给GOptionEntry中的argdata。

  • 相关文件

    • chassis-keyfile.c


2.3 配置应用过程


选项载入媒介有命令行选项和配置文件,选项来源有主应用和插件, 现在来看一下软件启动时,配置应该用哪些接口,怎样安排优先级。

--defaults-file
 和 --version
 这两个选项只能在命令行使用,得到 --defaults-file
 的值是解析 配置文件的前提,启动过程可以查看 main_cmdline()
 函数:

(chassis_option_t
 和 GOptionEntry
 提供配置名称( long_name
 )和内部变量( arg_data
 )的关联
 ,也就是说,它们是解析的context)。


  1. 解析 defaults-file
     和 version
     建立 一个小的context 并完成命令行解析

    • chassis_frontend_init_base_options()

  2. 加载配置文件

    • chassis_frontend_open_config_file()

  3. 建立 主context

    • chassis_frontend_set_chassis_options()

    • chassis_options_to_g_option_entries()

  4. 解析命令行,使用 主context

    • g_option_context_parse()

  5. 解析配置文件,使用 主context

    • chassis_keyfile_to_options_with_error()

  6. 对每个插件 取context -> 命令行 -> 配置文件

    1. chassis_plugin_get_options()
       取得插件的context

    2. g_option_context_parse()
       解析命令行选项

    3. chassis_keyfile_to_options_with_error()
       解析配置文件


如果某个选项在命令行和配置文件中都存在,通常来说,我们期望命令行的值会覆盖配置文件中的值。从解析 流程看,步骤4已经把命令行的值赋给变量了,步骤5为保证命令行的高优先级,对已赋值的变量需要跳过。 

但是,GOptionEntry的环境只能检测到字符串类型是否被赋过值,对数字类型无法检测, chassis_keyfile_to_options_with_error()
 函数只跳过已赋值的字符串类型,未跳过已赋值的数字类型。 这是 有问题 的,字符串类选项命令行优先级高,而数字类选项配置文件优先级高。

  • 相关文件

    • mysql-proxy-cli.c

    • chassis-frontend.c


2.4 主应用


chassis结构体有这样三个字段:


struct chassis {
  chassis_private *priv;
  void (*priv_shutdown)(chassis *chas, chassis_private *priv);;
  void (*priv_free)(chassis *chas, chassis_private *priv);;
  ...
}


主应用使用底架层时,先定义一个 chassis_private
 结构体,里面字段按主应用自己的需求来定义,然后 声明两个清理函数,分别赋值给 chassis->priv_shutdown
 和 chassis->priv_free
 作为钩子函数。之所以 叫private,底架层虽然持有其引用,却不知道其内容,它们是主应用私有的。

对mysql-proxy来说,chassis的主应用就是网络层 network_mysqld
 ,与网络层相关的一些全局变量就适合 放在chassis->priv中,比如网络层管理的会话列表、后端机器列表等,详见 chassis_private
 定义。

另一个使用chassis的程序,mysql-binlog-dump程序,功能比较简单,并没有用到私有数据和钩子函数。


2.4.1 主应用与底架层组件关系


如果底架层仅仅是个库,那么它不需要知道上层调用者的任何信息,但事实上,底架层除了提供功能库,还兼有 事件分发与流程控制的作用,所以它需要持有上层应用的钩子函数,用于触发上层应用。



这种组件关系既能保证解耦,又不失灵活性,mysql-proxy用的很多,常见几个如下:

底层高层数据数据类型钩子
chassis
network_mysqld
chassis->priv
chassis_private
priv_shutdown/..
chassis
插件全局chassis->modules
chassis_plugin_config
init/get_options/..
network_mysqld_con
插件会话con->plugin_con_state
自定义 void*con->plugins
  • 相关文件

    • chassis-mainloop.h

    • network-mysqld.h


2.5 插件接口


底架层扫描插件目录,按照插件名称,找到对应的.so动态库,使用GModule加载动态库,动态库唯一 的接口是 plugin_init
 ,可以直接从.so文件查到。调用 plugin_init
 ,插件在底架层注册四个钩子函数:

  • init

    返回一个不透明 chassis_plugin_config
     指针

  • get_options

    取得插件的配置context用于解析

  • apply_config

    解析完插件配置传回插件,插件以此做初始化

  • destroy

底架层通过这些函数控制插件的初始化、配置加载与查看以及销毁过程,而插件层在底架层之上可使用其所有 接口,接口关系同主应用。

  • 相关文件

    • chassis-plugin.c


2.6 多线程事件机


软件启动多个线程,每个线程各有一个独立libevent事件循环,当某个线程想要添加事件时,使用 chassis_event_add
 添加事件,这个函数不像 event_add
 那样把事件加到本线程的事件机,而是把事件 加入一个队列,然后触发所有线程来抢这个事件。 chassis_event_add()
 函数流程如下


g_async_queue_lock(chas->threads->event_queue); // 锁定全局消息队列 
//把事件加入全局队列
g_async_queue_push_unlocked(chas->threads->event_queue, op);
//通知所有线程去抢事件,会触发每个线程的 chassis_event_handle 函数
send(chas->threads->event_notify_fds[1], C("."), 0)
g_async_queue_unlock(chas->threads->event_queue);//解锁



chassis_event_threads_t->event_notify_fds[2]
 是一个 socket对 ,一进一出,类似管道。所有线程 初始化的时候都注册监听 socket对 的读端口, chassis_event_add()
 往 socket对 的写端口写入一个 标记字节,所有线程响应进入 chassis_event_handle()
 函数:


do {
  //所有线程抢这个锁,抢到往下执行,抢不到的block在此
  g_async_queue_lock(chas->threads->event_queue);
  if ((op = g_async_queue_try_pop_unlocked(chas->threads->event_queue))) {
    // 抢到了op, 就加入本线程的事件机,这里只把事件拿走,并没有处理事件的任务,所以很快。
    chassis_event_op_apply(op, event_base);
    // 抢到事件的线程负责把 socket对 中的标记移除
    recv(event_thread->notify_fd, ping, 10);
  }
  g_async_queue_unlock(chas->threads->event_queue); //释放锁,block的线程得以返回
 } while (op);


任何线程都可以发起事件,事件会随机被某个线程拾起执行。多线程事件机可以充分利用多核cpu资源,提高 事件处理效率。

  • 相关文件

    • chassis-mainloop.c

    • chassis-event-thread.c


三 网络层


3.1 网络状态机

网络层实现 客户端 <-> 代理端 <-> 服务端 三点通信的机制,通信状态对应mysql协议的各个状态。 在状态转移的恰当时机(之前或之后)把控制权交给插件层,让插件层决定下个状态走向。例如,收到SQL之后, 陷入插件,插件分析SQL决定下一个状态,转发SQL到服务端,或是直接回复客户端。



上图的协议状态都在 network_mysqld_con_state_t
 枚举中声明, network_mysqld_hooks
 结构体中与其 名字一一对应的函数,就是用于注册到插件的钩子函数。


3.2 插件接口

类似于底架层,网络层也持有插件的钩子函数,这样网络状态变更时,便可以通知到插件层。插件初始化 过程中,先要建立监听会话,插件钩子就挂到这个会话中,以后每个新会话建立,都从监听会话克隆, 钩子函数就在每个会话中存在。

钩子函数的声明在 struct network_mysqld_hooks
 ,钩子函数注册,以proxy-plugin为例, 在 network_mysqld_proxy_connection_init()
 函数中,网络层对钩子函数的调用主要在 plugin_call()
 函数中。


3.3 网络库

  • network-address

    地址字符串转换

  • network-socket

    TCP socket

  • network-packet

    数据包操作

  • network-queue

    数据包队列

  • network-backend

    后端管理

  • network-injection

    数据包注入队列操作


3.4 包处理


network_mysqld_read()
 从网络接收mysql协议包,它的实现分两步:

  • network_socket_read()
     读取TCP数据段,放入 network_socket->recv_queue_raw
     队列,也叫原生包

  • network_mysqld_con_get_packet()
     重新整理原生包,解析出完整的mysql协议包, 放入 network_socket->recv_queue
     队列

network_mysqld_con_get_packet()
 函数对原生包进行简要解析,即查看一下mysql协议包头,按包头指定的 长度提取完整协议包。


3.4.1 接收结果集包组

结果集的数据包是按组处理的,一组数据包才构成一个完整的结果集。 从后端接收结果集在 network_mysqld_con_hanle()
 的 CON_STATE_READ_QUERY_RESULT
 状态中。代码摘要


case CON_STATE_READ_QUERY_RESULT:
do {
  network_mysqld_read(srv, recv_sock); // 一次读到一个结果集协议包
  plugin_call(srv, con, con->state); // 结果集包 顺序、逐个送入插件
 } while (con->state == CON_STATE_READ_QUERY_RESULT); // 插件收集完 包组 会转移到下个状态
break;


从 plugin_call()
 进入插件,插件解析每个结果集包,并进行分组,代码摘要:


NETWORK_MYSQLD_PLUGIN_PROTO(proxy_read_query_result)
{
  //跟踪结果包,如果构成一组,返回is_finished = true
  is_finished = network_mysqld_proto_get_query_result(&packet, con);
  if (is_finished) {
    ret = proxy_lua_read_query_result(con); // 处理包组
    con->state = CON_STATE_SEND_QUERY_RESULT; // 网络状态递进
  }
  // 返回网络层,网络根据状态有无递进,来判断是否收完一组
  return NETWORK_SOCKET_SUCCESS;
}


其中 network_mysqld_proto_get_query_result()
 函数解析结果集包,con->parse.command里记录了当初 发往服务器的命令类型,也就是当前结果集是那个命令返回的。不同的命令又调用不同的函数去解析结果集, 现在只看最常用的 COM_QUERY
 的解析 network_mysqld_proto_get_com_query_result()
 ,这属于协议层 的函数,在此简要分析:


int network_mysqld_proto_get_com_query_result(network_packet *packet, network_mysqld_com_query_result_t *query)
{
  //又是状态机,query->state追踪结果包解析状态,每次进入此函数都不一样
  switch (query->state) {
  case PARSE_COM_QUERY_INIT:// 解析 列数包,然后转移到下个状态
    break;
  case PARSE_COM_QUERY_FIELD: // 解析 列定义包,转移到下个状态
    break;
  case PARSE_COM_QUERY_RESULT: // 解析 数据行包,之后is_finished置true,凑完一组
    break;
  }
  return is_finished;
}

旧版协议,包组通常以 MYSQLD_PACKET_EOF
 包结束,如果遇到错误,则以 MYSQLD_PACKET_ERR
 包结束, 此处代码讲解的简化流程未考虑错误包。


四  插件层

2和3的插件接口部分描述了插件层的对接方式,底架层通过钩子函数控制的是插件的全局属性 而网络层通过钩子函数控制的是插件的会话属性。全局属性比较简单,不再赘述,此处主要分析插件的会话 过程。

上图显示插件层会话的生命周期,跟随网络状态转移,网络状态机的连接、认证和断开阶段可以看作暂时状态 ,正常运行期间,主循环通常处于query和query response阶段,即SQL和结果集的代理转发阶段。

从网络会话角度看,插件的实现者只需处理好钩子函数里的内容,做好网络状态转移的决策即可,至于TCP和 mysql协议的内容,完全不需要关心,这是很好的抽象屏障。


4.1 proxy-plugin


说起插件,通常是用来实现一些可选的附加功能,但是mysql-proxy不一样,它的最主要逻辑就是在插件里实现 的。如果只有底架层,那么它是一个空转的事件机,如果再加上网络层,那它是一个空转的网络状态机。网络 会话只有通过插件才能实现额外功能。

proxy-plugin就是最核心的插件,代理转发期间对SQL分析、改写、注入,对结果集的解析,以及所有数据包的 转发决策都是在这个插件中完成的。

mysql-proxy对开发者提供了两种接口,一种就是插件,开发者可以写一个插件,依赖mysql-proxy提供的头文件 和动态库编译,然后放倒相应目录,在配置中加载。另一种是Lua接口,lua开发相对插件开发更方便,不用编译 。

而lua接口就是从proxy-plugin放出去的,所以接着上面说的,如果再在网络层上加个proxy-plugin,其实它基本 上还是在空转,它的每个钩子函数不过转而去调用lua的函数,也就是说,真正的分析与决策逻辑在lua脚本里。


4.2 admin-plugin


这个插件是个单向插件,他与服务端完全不接触,与服务端相关的钩子函数,比如 con_send_auth
 , con_read_query_result
 等,它根本就不注册。 举个例子:proxy-plugin里,用户认证过来了,proxy转到下一个网络状态,即发往后端,然后收到后端回复再 转到下个状态,即发回客户端。而在admin-plugin里,用户认证过来了,admin自己验证客户端身份,直接跳到 下下个状态,给用户回复。虽然用的同一个网络状态机,对状态转移的决策不同,admin就成为一个单边通信的 插件。

admin-plugin也放出了lua接口,方便开发者改动。


五  协议库


协议库 实现mysql请求与结果集包的解析,程序中又分为包解析和协议解析:

  • network-mysqld-proto.h

    协议解析

  • network-mysqld-packet.h

    包解析,依赖协议解析

首先看协议解析头文件中的函数,有个大概规律,函数名包含append,且第一个参数是GString,用于组装; 函数名包含get,且第一个参数是 network_packet
 ,用于解析。它负责组装和解析协议的 基本类型 ,例如:

  • int8/int16…

    各类整型

  • lenenc_int

    Length Encoded Integer

  • lenenc_string

    Length Encoded String

  • lenenc_gstring

    同上,使用GString

另外,还提供了 包头操作 (组装/解析包长度和序列号)和 密码混杂 。

包解析,负责解析和组装结果集包、认证包和Prepare语句包等,这些包也是由 基本类型 组成的,所以, 包解析在协议解析层次之上。

协议解析和包解析都用到网络层的 network_packet
 结构体,其定义为:

struct {
  GString *data;
  guint offset;
}

data就是完整的包数据,offset记录解析状态,即目前解析到的位置,有了状态,用于解析的函数就可以 顺序调用。


六 lua接口


lua和c的互通使用栈的方式,程序中大部分 *-lua.h结尾的文件都是用于lua和c的交互。 在启动时调用 network_mysqld_lua_setup_global()
 ,c里的状态就被绑定到lua里的proxy模块。


网易MySQL开源中间件Cetus

__________________________

github地址https://github.com/Lede-Inc/cetus/blob/master/doc/cetus-quick-try.md,欢迎加star关注

社群

技术专家在线及时反馈

cetus开源qq群号: 521824702

cetus开源微信群:扫描网易DBA小助手加入


往期精彩文章

__________________________

网易中间件Cetus开源啦

网易开源中间件 -Cetus监控模块

网易分片中间件cetus扩容方案

数据库导入导出基础扫盲

网易DG Broker系列:切换自如

网易乐得RDS开发:实时监控mysql

网易DBA女神揭秘区块链天机

网易北京研发中心DBA招募令

网易乐得RDS设计—任务调度篇


欢迎分享

网易乐得DBA组负责网易乐得电商、网易邮箱、网易技术部数据库日常运维,负责数据库私有云平台的开发和维护,负责数据库及数据库中间件的开发和测试等,分享最前沿实用数据库干货,关注网易乐得DBA,精修数据库功底。

文章转载自DBA天团,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论