网易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", 0, 0, 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, 0, 0, G_OPTION_ARG_NONE, NULL, NULL, NULL }
};
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", 0, 0, G_OPTION_ARG_NONE, &(frontend->verbose_shutdown), "Always", NULL);
chassis_options_add(opts, "daemon", 0, 0, G_OPTION_ARG_NONE, &(frontend->daemon_mode), "Start in da-mode", NULL);
chassis_options_add(opts, "basedir", 0, 0, G_OPTION_ARG_STRING, &(frontend->base_dir), "Base directo", "<absolute path>");
chassis_options_add(opts, "pid-file", 0, 0, G_OPTION_ARG_STRING, &(frontend->pid_file), "PID file in ", "<file>");
chassis_options_add(opts, "plugin-dir", 0, 0, 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)。
解析
defaults-file
和version
建立 一个小的context 并完成命令行解析chassis_frontend_init_base_options()加载配置文件
chassis_frontend_open_config_file()建立 主context
chassis_frontend_set_chassis_options()chassis_options_to_g_option_entries()解析命令行,使用 主context
g_option_context_parse()解析配置文件,使用 主context
chassis_keyfile_to_options_with_error()对每个插件 取context -> 命令行 -> 配置文件
chassis_plugin_get_options()
取得插件的contextg_option_context_parse()
解析命令行选项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, 1, 0);
}
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_intLength Encoded Integer
lenenc_stringLength 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小助手加入

往期精彩文章
__________________________

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






