本文结合MySQL提供的协议,逐步分析Canal从连接到订阅binlog这一过程。
MySQL初始化完成之后,就会监听并准备接收来自外部的连接请求。它将这一逻辑交给handle_connections_methods()函数去完成。该函数主要监听三种连接方式,分别是命名管道(namedpipes)、TCP/IP(sockets)以及共享内存(shared_memory)。一般我们的客户端(例如JDBC驱动和mysqld)都是用TCP/IP的方式连接MySQL服务器,而后两种一般在嵌入式开发用的比较多。
Canal采用TCP/IP的方式模拟MySQL连接协议交互,所以下面我们也会以TCP/IP的维度去分析Canal如何连接上MySQL节点。
在介绍如何连接前,我们需要知道MySQL数据包的组成。和众多基于TCP开发的应用一样,MySQL为了解决粘包问题,自定义了自己数据包的帧格式。所有的数据包均有header和body两部分组成。
header由data length和sequence_id两部分组成,共4个字节,其中data length表示body数据长度的,占3个字节,这也导致Server发送到Client的最大packet大小为(2^24−1) bytes,也就是16M,超出的大小将会被分到不同的packet中;而sequence_id仅有1个字节,目的是防止串包。机制是每收到一个报文都在其 sequenceId 上加 1,并随着需要返回的信息返回回去。如果 DB 检测到 sequenceId 连续,则表明没有串包。如果不连续,则串包,DB 会直接丢弃这个连接。除此之外,分包的后每个包sequence依次加1。
body表示具体传输的数据,也就是有效负载,不同的协议这部分传递的信息不同。
MySQL的连接阶段主要包含“握手”和“认证”两个步骤,不管是Canal、slave还是其他jdbc服务,都要经过以下步骤,具体参考下图。
在MySQL和客户端建立了TCP连接后,Server端会优先再一次发起一次握手请求,这个过程也是三次握手。该握手过程主要涉及以下任务:
下面我们来逐点分析:
在MySQL中,Server会在TCP的三次握手后主动发送请求,而第一阶段就是发送Handshake Packet,该packet中主要包含MySQL服务端的一些参数信息,例如服务端的版本号,通信过程中的最大消息长度,编码信息以及用于Client认证选取的方法等等。(注意MySQL3.21.0后的版本都是发送Protocol::HandshakeV10版本,其他版本忽略)
相关字段解析如下:
protocol_version:服务器协议版本号,默认为10。
server_version:服务器的版本号。
thread id:服务器为客户端分配的线程id,可以通过SHOW PROCESSLIST看到。
auth-plugin-data-part-1:服务器生成的用于验证随机字符串(有的地方叫挑战随机数)的前8个字节。
filter:默认0x00的填充值
capability_flags_1:服务端的能力(capabilities)低两个字节
character_set:字符编码号。
status_flags:服务器状态。
capability_flags_2:服务端的能力(capabilities)高两个字节
auth_plugin_data_len: 随机字符串总长度
reserved:默认0x00填充值
auth-plugin-data-part-2:服务器生成的用于验证随机字符串数据的剩余字节。
auth_plugin_name:用户校验插件名
注:相关代码在文件sql/sql_acl.cc的函数send_server_handshake_packet中
Handshake Packet这里需要重点关注和用户认证相关的auth_plugin_name、auth-plugin-data-part-1/2,以及表示服务端能力的capability_flags_1/2。
先来看第一个东西,挑战随机数。MySQL数据库用户认证采用的是挑战/应答的方式,服务器生成该挑战数并发送给客户端,由客户端进行处理并返回相应结果,然后服务器检查是否与预期的结果相同,从而完成用户认证的过程。这里涉及到MySQL的认证过程:
a、MySQL将用户的密码已密文的形式存储在mysql.user表的password字段中,密文的加密方式是通过对明文密码的进行两次SHA1计算出来的哈希值,代码中称这计算出来的哈希值为stage2hash。也就是说stage2hash的计算方式为:
stage2hash = SHA1(SHA1(明文密码))
这里猜测MySQL的设计者故意进行两次SHA1的理由是怕用户的密码设置比较简单,通过暴力方式还是可以解出比较简单的密码,第二次SHA1的参数会是一个复杂无规则的160位字符串。所以即使这个哈希值被盗取了,依然无法解析出原本的明文密码。
b、客户端将MySQL发送过来的挑战随机数(代码中称为scramble),同样进行SHA1的加密生成密钥key,计算方式是:
key = SHA1(scramble|stage2hash)
客户端将收到的scramble和本地计算的stage2hash一起SHA1后,生成密钥,并将密钥和stage1hash进行加密,加密的方式是对key和stage1hash进行一次XOR操作,而stage1hash是对明文密码进行一次SHA1后的结果,也就是
发送给服务端的密码密文 = XOR(key,SHA1(明文密码))
c、服务端分析密文是否正确也很简单,将key和客户端发送的密文进行一次XOR,得到stage1hash,然后再对stage1hash进行一次SHA1,将得到的结果和数据库存的进行比较就行。
其次第二个东西是表示服务端能力的capability_flags_1/2,MySQL版本比较多,客户端和服务端的版本不一定匹配,客户端需要的功能,或者服务端支持的功能之间需要有一个协商,握手中会传递这一部分。协商时主要依赖capabilities flag,其中包括是否支持压缩功能、长密码和SSL等等
Client在收到来自Server的Handshake Packet之后,可以根据需要选择基于SSL的通信,还是普通文本。这里我们介绍下基于SSL的连接,注意支持SSL的前提是需要前面服务端给的capabilities有提供CLIENT_SSL 能力。
当服务端发送完Protocol::Handshake数据包后,客户端可以发送Protocol::SSLRequest建立SSL连接,其中的SSLRequest packet payload如下:
需要注意一点是,当请求SSL连接时,需要把CLIENT_SSL capabitities塞入到请求中。
假如认证成功后, 服务端会向客户端返回 OK_Packet,认证失败, 会向客户端返回 ERR_Packet。
所以需要判断认证成功还是失败,只需要判断接收到的包第一个字节值是否为10进制的-1 还是0
这里分析的是Canal和MySQL建立了TCP之后的,Handshake Packet和SSLRequest Packet的交互,实现放在了MysqlConnector.negotiate方法中。
// MysqlConnector.java
private void negotiate(SocketChannel channel) throws IOException {
// Mysql发出的包,header大小都为4个字节
HeaderPacket header = PacketManager.readHeader(channel, 4, timeout);
// 获取到header之后,取前面3个字节组成的数字即为接下来packet body也就是payload大小
byte[] body = PacketManager.readBytes(channel, header.getPacketBodyLength(), timeout);
if (body[0] < 0) {// check field_count
if (body[0] == -1) {
ErrorPacket error = new ErrorPacket();
error.fromBytes(body);
throw new IOException("handshake exception:\n" + error.toString());
} else if (body[0] == -2) {
throw new IOException("Unexpected EOF packet at handshake phase.");
} else {
throw new IOException("unpexpected packet with field_count=" + body[0]);
}
}
HandshakeInitializationPacket handshakePacket = new HandshakeInitializationPacket();
// 将获取过来的body解析为HandshakeInitializationPacket包
handshakePacket.fromBytes(body);
connectionId = handshakePacket.threadId; // 记录一下connection
logger.info("handshake initialization packet received, prepare the client authentication packet to send");
// 这里canal用的是SSL连接,返回的是SSLRequest
ClientAuthenticationPacket clientAuth = new ClientAuthenticationPacket();
clientAuth.setCharsetNumber(charsetNumber);
clientAuth.setUsername(username);
clientAuth.setPassword(password);
// 注意这里canal把server发送过来的全部capabilities发送获取
clientAuth.setServerCapabilities(handshakePacket.serverCapabilities);
clientAuth.setDatabaseName(defaultSchema);
// 拼接随机挑战数的高低位
clientAuth.setScrumbleBuff(joinAndCreateScrumbleBuff(handshakePacket));
byte[] clientAuthPkgBody = clientAuth.toBytes();
HeaderPacket h = new HeaderPacket();
h.setPacketBodyLength(clientAuthPkgBody.length);
// sequence + 1
h.setPacketSequenceNumber((byte) (header.getPacketSequenceNumber() + 1));
// 将SSL Request写到和mysql连接的channel中
PacketManager.writePkg(channel, h.toBytes(), clientAuthPkgBody);
logger.info("client authentication packet is sent out.");
// 再次获取server发送过来的packet,检查验证是否成功
header = null;
header = PacketManager.readHeader(channel, 4);
body = null;
body = PacketManager.readBytes(channel, header.getPacketBodyLength(), timeout);
assert body != null;
// 不为0则表示认证失败
if (body[0] < 0) {
// -1表示Err_packet
if (body[0] == -1) {
ErrorPacket err = new ErrorPacket();
err.fromBytes(body);
throw new IOException("Error When doing Client Authentication:" + err.toString());
} else if (body[0] == -2) {
auth323(channel, header.getPacketSequenceNumber(), handshakePacket.seed);
// throw new
// IOException("Unexpected EOF packet at Client Authentication.");
} else {
throw new IOException("unpexpected packet with field_count=" + body[0]);
}
}
}
成功连接到Mysql后,任何slave节点在获取binlog前,都需要向master发送COM_REGISTER_SLAVE命令进行注册。该命令的payload如下(左边数字表示长度,下同):
1 [15] COM_REGISTER_SLAVE,第一个字节值恒定为16进制的15,如果划算成10进制就是21
4 server-id,slave的server id,不同的slave必须有不同的slaveId
1 slaves hostname length,slave的host信息长度
string[$len] slaves hostname,slave的host信息
1 slaves user len,slave用于连接master的账号名长度
string[$len] slaves user,slave用于连接master的账号名,需要有replication slave和replication client 权限
1 slaves password len,slave用于连接master的密码长度
string[$len] slaves password,slave用于连接master的密码
2 slaves mysql-port,slave的端口
4 replication rank,这个估计没用的字段,忽略即可
4 master-id,通常为0
在MySQL Replication中,都需要使用一个唯一server id来区别不同的server实例。
canal在 RegisterSlaveCommandPacket 类中实现了关于COM_BINLOG_DUMP命令的封装
public class RegisterSlaveCommandPacket extends CommandPacket {
// implement by parent
private byte command;
public String reportHost;
public int reportPort;
public String reportUser;
public String reportPasswd;
public long serverId;
public RegisterSlaveCommandPacket(){
setCommand((byte) 0x15);
}
// implement by parent
public byte getCommand() {
return command;
}
public byte[] toBytes() throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(getCommand());
ByteHelper.writeUnsignedIntLittleEndian(serverId, out);
out.write((byte) reportHost.getBytes().length);
ByteHelper.writeFixedLengthBytesFromStart(reportHost.getBytes(), reportHost.getBytes().length, out);
out.write((byte) reportUser.getBytes().length);
ByteHelper.writeFixedLengthBytesFromStart(reportUser.getBytes(), reportUser.getBytes().length, out);
out.write((byte) reportPasswd.getBytes().length);
ByteHelper.writeFixedLengthBytesFromStart(reportPasswd.getBytes(), reportPasswd.getBytes().length, out);
ByteHelper.writeUnsignedShortLittleEndian(reportPort, out);
ByteHelper.writeUnsignedIntLittleEndian(0, out);// Fake
// rpl_recovery_rank
ByteHelper.writeUnsignedIntLittleEndian(0, out);// master id
return out.toByteArray();
}
...
}
注意,在toBytes()方法里面拼装数据时,用了小端模式。MySQL的报文采用的是小端模式(而PostgreSQL采用的则是大端模式)
MySQL支持两种binlog dump的命令,分别是指定binlog filename + position的COM_BINLOG_DUMP,以及通过指定GTID的 COM_BINLOG_DUMP_GTID。
该命令用于根据指定的binlog文件位置,从Master中获取binlog文件流。
1 [12] COM_BINLOG_DUMP 16进制的12,表示该命令为获取binlog流
4 binlog-pos 指定binlog文件的位置
2 flags 目前只有一个值,也就是BINLOG_DUMP_NON_BLOCK(0X01)
4 server-id slave的server_id
string[EOF] binlog-filename binlog文件名
注意点1:并非需要指定binlog filename,没有指定时将默认从Master的第一个binlog文件开始消费起;(可以通过SHOW BINARY LOGS知道当前第一个binlog的文件和大小)
注意点2:关于payload的第三个参数flags,目前仅有一个值就是0x01,表示BINLOG_DUMP_NON_BLOCK,该值的意思是当没有更多的binlog event时,就返回一个EOF Packet给Slave,而不是阻塞当前连接;
注意点3:Master接收到该指令后,并非会返回binlog event或者时EOF Package,有可能也会返回一个Error Packet,例如找不到指定的binlog文件或者是没有复制权限。
canal在 BinlogDumpCommandPacket 实现发送请求binlog 命令:
public byte[] toBytes() throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
// 0. write command number
out.write(getCommand());
// 1. write 4 bytes bin-log position to start at
ByteHelper.writeUnsignedIntLittleEndian(binlogPosition, out);
// 2. write 2 bytes bin-log flags
int binlog_flags = 0;
binlog_flags |= BINLOG_SEND_ANNOTATE_ROWS_EVENT;
out.write(binlog_flags);
// 补0x00 是因为flags长度不够2个字节
out.write(0x00);
// 3. write 4 bytes server id of the slave
ByteHelper.writeUnsignedIntLittleEndian(this.slaveServerId, out);
// 4. write bin-log file name if necessary
if (StringUtils.isNotEmpty(this.binlogFileName)) {
out.write(this.binlogFileName.getBytes());
}
return out.toByteArray();
}
这里canal为兼容MariaDB,指定binlog flags值为BINLOG_SEND_ANNOTATE_ROWS_EVENT,该值对应0x10,在MySQL中是无效的,所以MySQL当没有binlog event时,不会发送EOF给canal,而是阻塞当前线程。
但是当源是MariaDB时,该参数的值0x10表示允许发送AnnotateRowsEvent,否则以空的QueryLogEvent来代替。
跳过(●'◡'●)
当canal发送完Binlog Dump的命令之后,Mysql就会开始推送将一个个binlog event推送给canal。需要注意的是,每次Mysql都只会推送一个event给下游的slave,我们先来看下binlog event packet结构。
每个event包括Binlog event header,Post-Header和Body三部分,注意区分这里的EventHeader和MySQL发送过来的packet的header,后者是MySQL网络层发送时自动加上的,非binlog字节管理的部分。
对于所有event而言,Binlog Eventheader的长度与格式是相同的,Post-Header对于同类型的event是长度相同的,部分类型的event并没有Post-Header。而Body则为event中的最终的部分,表示具体信息,同样也不是所有的event都会有body,FORMAT_DESCRIPTION_EVENT就没有。
EventHeader表示通用的事件头,其结构如下:
4 timestamp
1 event type
4 server-id
4 event-size
if binlog-version > 1:
4 log pos
2 flags
timestamp (4) -- Unix事件戳,表示event产生的事件
event_type (1) -- binlog 事件类型
server_id (4) -- 产生事件的mysql serverId
event_size (4) -- 事件大小,这里的大小=EventHader自身 + EventBody
log_pos (4) -- 下一个事件的位点,也就是binlog文件的position
flags (2) -- binlog事件标识
canal将获取binlog event的实现放在类DirectLogFetcher.fetch方法中。
EventBody就不介绍了,不同的事件其结构有不同,详情可以查看binlog event 列表
canal获取binlog event流的实现逻辑是在DirectLogFetcher.fetch方法中
// DirectLogFetcher
public boolean fetch() throws IOException {
try {
// Fetching packet header from input.
// 从MySQL的连接中(默认是Socket)获取消息头,由上面的介绍可知header大小为4个字节
// 所以这里的NET_HEADER_SIZE值是4
if (!fetch0(0, NET_HEADER_SIZE)) {
logger.warn("Reached end of input stream while fetching header");
return false;
}
// Fetching the first packet(may a multi-packet).
// header的前3个字节表示body的长度
int netlen = getUint24(PACKET_LEN_OFFSET);
// header的最后一个字节表示sequenceId
int netnum = getUint8(PACKET_SEQ_OFFSET);
// 根据header给出的body大小,获取payload
if (!fetch0(NET_HEADER_SIZE, netlen)) {
logger.warn("Reached end of input stream: packet #" + netnum + ", len = " + netlen);
return false;
}
// mark是4个字节的整形数字,表示当前同步是否异常
final int mark = getUint8(NET_HEADER_SIZE);
if (mark != 0) {
if (mark == 255) // error from master
{
// Indicates an error, for example trying to fetch from
// wrong
// binlog position.
position = NET_HEADER_SIZE + 1;
final int errno = getInt16();
String sqlstate = forward(1).getFixString(SQLSTATE_LENGTH);
String errmsg = getFixString(limit - position);
throw new IOException("Received error packet:" + " errno = " + errno + ", sqlstate = " + sqlstate
+ " errmsg = " + errmsg);
} else if (mark == 254) {
// Indicates end of stream. It's not clear when this would
// be sent.
logger.warn("Received EOF packet from server, apparent"
+ " master disconnected. It's may be duplicate slaveId , check instance config");
return false;
} else {
// Should not happen.
throw new IOException("Unexpected response " + mark + " while fetching binlog: packet #" + netnum
+ ", len = " + netlen);
}
}
// 当前mysql是否处于半同步状态
if (issemi) {
// parse semi mark
int semimark = getUint8(NET_HEADER_SIZE + 1);
int semival = getUint8(NET_HEADER_SIZE + 2);
this.semival = semival;
}
// 就前面知道MySQL一个packet的payload最大是16M,这里通过判断payload是否
// 为16M进行组包,当payload大小为16M时,则需要从socket中获取下一个packet,
// 组装成一个完整的event再返回给调用方,知道payload的大小不再是16M。
// 那么如果event的大小刚好是16M的整数倍会怎样,此时MySQL会在发送完完整的event
// 之后,再发送一个body长都为0的packet表示结束。
while (netlen == MAX_PACKET_LENGTH) {
if (!fetch0(0, NET_HEADER_SIZE)) {
logger.warn("Reached end of input stream while fetching header");
return false;
}
netlen = getUint24(PACKET_LEN_OFFSET);
netnum = getUint8(PACKET_SEQ_OFFSET);
if (!fetch0(limit, netlen)) {
logger.warn("Reached end of input stream: packet #" + netnum + ", len = " + netlen);
return false;
}
}
// Preparing buffer variables to decoding.
if (issemi) {
origin = NET_HEADER_SIZE + 3;
} else {
origin = NET_HEADER_SIZE + 1;
}
position = origin;
limit -= origin;
return true;
} catch (SocketTimeoutException e) {
close(); /* Do cleanup */
logger.error("Socket timeout expired, closing connection", e);
throw e;
} catch (InterruptedIOException e) {
close(); /* Do cleanup */
logger.info("I/O interrupted while reading from client socket", e);
throw e;
} catch (ClosedByInterruptException e) {
close(); /* Do cleanup */
logger.info("I/O interrupted while reading from client socket", e);
throw e;
} catch (IOException e) {
close(); /* Do cleanup */
logger.error("I/O error while reading from client socket", e);
throw e;
}
}
MysqlConnection.seek负责调用DirectLogFetcher.fetch方法,在循环中不断获取binlog event
public void seek(String binlogfilename, Long binlogPosition, SinkFunction func) throws IOException {
updateSettings();
// 设置binlog位点
sendBinlogDump(binlogfilename, binlogPosition);
DirectLogFetcher fetcher = new DirectLogFetcher(connector.getReceiveBufferSize());
fetcher.start(connector.getChannel());
LogDecoder decoder = new LogDecoder();
// 指定需要获取的binlog事件
decoder.handle(LogEvent.ROTATE_EVENT);
decoder.handle(LogEvent.FORMAT_DESCRIPTION_EVENT);
decoder.handle(LogEvent.QUERY_EVENT);
decoder.handle(LogEvent.XID_EVENT);
LogContext context = new LogContext();
while (fetcher.fetch()) {
accumulateReceivedBytes(fetcher.limit());
LogEvent event = null;
// 将socket中拿到的二进制流解析成对应的binlog事件
event = decoder.decode(fetcher, context);
if (event == null) {
throw new CanalParseException("parse failed");
}
// 调用sink方法将event暂时存储到EventTransactionBuffer中
if (!func.sink(event)) {
break;
}
}
}
(将从socket获取到二进制流解析成对应的event步骤跳过,T_T event 类型太多文章写不下来,就知道有这个步骤就行)
canal从socket中获取到完整的event并解析完成后,在sink方法就会调用EventTransactionBuffer.add方法放入到buffer中。
// EventTransactionBuffer.java
public void add(CanalEntry.Entry entry) throws InterruptedException {
switch (entry.getEntryType()) {
case TRANSACTIONBEGIN:
flush();// 刷新上一次的数据
put(entry);
break;
case TRANSACTIONEND:
put(entry);
flush();
break;
case ROWDATA:
put(entry);
// 针对非DML的数据,直接输出,不进行buffer控制
EventType eventType = entry.getHeader().getEventType();
if (eventType != null && !isDml(eventType)) {
flush();
}
break;
case HEARTBEAT:
// master过来的heartbeat,说明binlog已经读完了,是idle状态
put(entry);
flush();
break;
default:
break;
}
}
private void put(CanalEntry.Entry data) throws InterruptedException {
// 首先检查是否有空位
if (checkFreeSlotAt(putSequence.get() + 1)) {
long current = putSequence.get();
long next = current + 1;
// 先写数据,再更新对应的cursor,并发度高的情况,putSequence会被get请求可见,拿出了ringbuffer中的老的Entry值
entries[getIndex(next)] = data;
putSequence.set(next);
} else {
flush();// buffer区满了,刷新一下
put(data);// 继续加一下新数据
}
}
canal会在检测到事件类型为事件头,事件尾,非DML语句(例如DDL),心跳事件或者buffer满了之后,会立即调用sink模块判断是否需要过滤该事件,随后再调用store模块写入目标存储。
关于buffer,其本质是一个内存数组,大小由配置参数canal.instance.transaction.size决定,默认是1024,这个参数主要控制parser解析后,提交到event store时,能够保证的事务一致性的数量。例如当你提交一个大事务时,其中的event数量超出了1024,此时buffer只能保证以1024为单位的event能够被一次性刷入到event store。按作者的说法,目前get数据获取时,暂时没有考虑事务完整读取的机制,主要还是考虑业务需求,对于事务完整性不敏感. 要保证完整读取其实也不难。
这里简单介绍下Canal如何获取binlog二进制流,和暂存的buffer,至于详细的parse模块、sink和store模块,甚至Canal的具体架构,可以看下田工的博客 田守枝canal源码分析
1、田守枝canal源码分析
2、MySQL官方主从同步协议介绍