1.canal介绍:
是用于数据同步的中间件。 依靠主从同步的原理向MySql数据库拉取binlog日志(ROW格式,记录的是数据库变更前后的数据变化),解析后转发到下游。
**性能比较好,主要的性能消耗在binlog解析、io的消耗、操作数据时锁的竞争。 **
可以应用于异步的Mysql与Redis的数据缓存同步:一个比较重要的应用就是利用canal server解析将数据转发到消息队列上,用于消息的异步更新。
**以下介绍主要流程:
解析->(过滤)->put->get->send **
解析是将binlog日志解析为数据变更事件Event,之后需要放入存储区域中,使用Memory内存
package com.alibaba.otter.canal.store.memory 下的 MemoryEventStoreWithBuffer 封装了内存缓冲区及其操作
**包含put get ack三个位点:(三个操作安全的原子类)
- put 最后存放位点
- get 最后获取位点
- ack 最后确认成功发送位点**
2.canal 中内存缓冲区Ringbuffer
1) put方法
put方法需要获取final ReentrantLock lock = this.lock 同步锁;(分为超时放弃和阻塞获取) 获取这个锁之后调用doPut
private void doPut(List<Event> data) {
long current = putSequence.get();
long end = current + data.size();
// 先写数据,再更新对应的cursor,并发度高的情况,putSequence会被get请求可见,拿出了ringbuffer中的老的Entry值
for (long next = current + 1; next <= end; next++) {
entries[getIndex(next)] = data.get((int) (next - current - 1));
}
putSequence.set(end);//记录最终的put位置
// 记录一下gets memsize信息,方便快速检索
if (batchMode.isMemSize()) {
long size = 0;
for (Event event : data) {
size += calculateSize(event);
}
putMemSize.getAndAdd(size);
}
profiling(data, OP.PUT);
// tell other threads that store is not empty
notEmpty.signal();
}
其中向数据缓存区中先存入数据再 putSequence.set(end);再记录put位点;避免拿到循环队列中老的值
2)get方法
执行doGet之前依然需要获取final ReentrantLock lock = this.lock 同步锁,保证线程安全
private Events<Event> doGet(Position start, int batchSize) throws CanalStoreException {
LogPosition startPosition = (LogPosition) start;
long current = getSequence.get();
long maxAbleSequence = putSequence.get();
long next = current;
long end = current;
// 如果startPosition为null,说明是第一次,默认+1处理
if (startPosition == null || !startPosition.getPostion().isIncluded()) { // 第一次订阅之后,需要包含一下start位置,防止丢失第一条记录
next = next + 1;
}
if (current >= maxAbleSequence) {
return new Events<>();
}
Events<Event> result = new Events<>();
List<Event> entrys = result.getEvents();
long memsize = 0;
if (batchMode.isItemSize()) {
end = (next + batchSize - 1) < maxAbleSequence ? (next + batchSize - 1) : maxAbleSequence;
// 提取数据并返回
for (; next <= end; next++) {
Event event = entries[getIndex(next)];
if (ddlIsolation && isDdl(event.getEventType())) {
// 如果是ddl隔离,直接返回
if (entrys.size() == 0) {
entrys.add(event);// 如果没有DML事件,加入当前的DDL事件
end = next; // 更新end为当前
} else {
// 如果之前已经有DML事件,直接返回了,因为不包含当前next这记录,需要回退一个位置
end = next - 1; // next-1一定大于current,不需要判断
}
break;
} else {
entrys.add(event);
}
}
} else {
long maxMemSize = batchSize * bufferMemUnit;
for (; memsize <= maxMemSize && next <= maxAbleSequence; next++) {
// 永远保证可以取出第一条的记录,避免死锁
Event event = entries[getIndex(next)];
if (ddlIsolation && isDdl(event.getEventType())) {
// 如果是ddl隔离,直接返回
if (entrys.size() == 0) {
entrys.add(event);// 如果没有DML事件,加入当前的DDL事件
end = next; // 更新end为当前
} else {
// 如果之前已经有DML事件,直接返回了,因为不包含当前next这记录,需要回退一个位置
end = next - 1; // next-1一定大于current,不需要判断
}
break;
} else {
entrys.add(event);
memsize += calculateSize(event);
end = next;// 记录end位点
}
}
}
PositionRange<LogPosition> range = new PositionRange<>();
result.setPositionRange(range);
range.setStart(CanalEventUtils.createPosition(entrys.get(0)));
range.setEnd(CanalEventUtils.createPosition(entrys.get(result.getEvents().size() - 1)));
range.setEndSeq(end);
// 记录一下是否存在可以被ack的点
for (int i = entrys.size() - 1; i >= 0; i--) {
Event event = entrys.get(i);
// GTID模式,ack的位点必须是事务结尾,因为下一次订阅的时候mysql会发送这个gtid之后的next,如果在事务头就记录了会丢这最后一个事务
if ((CanalEntry.EntryType.TRANSACTIONBEGIN == event.getEntryType() && StringUtils.isEmpty(event.getGtid()))
|| CanalEntry.EntryType.TRANSACTIONEND == event.getEntryType() || isDdl(event.getEventType())) {
// 将事务头/尾设置可被为ack的点
range.setAck(CanalEventUtils.createPosition(event));
break;
}
}
if (getSequence.compareAndSet(current, end)) {
getMemSize.addAndGet(memsize);
notFull.signal();
profiling(result.getEvents(), OP.GET);
return result;
} else {
return new Events<>();
}
}
两个关键点:1)DDL隔离 也就是DDL独占一个Entrys(发送消息的时候会再次进行拆分)
2)getSequence.compareAndSet(current, end) 获取Entrys后更新get位点
3)rollBack方法
也是一样先获取同步锁;之后回滚getSequence......
public void rollback() throws CanalStoreException {
final ReentrantLock lock = this.lock;
lock.lock();
try {
getSequence.set(ackSequence.get());
getMemSize.set(ackMemSize.get());
} finally {
lock.unlock();
}
}
可以看到回滚了get位点到ack位点上,没有回滚put位点。
github.com/alibaba/can…
本文在阿里开源代码中已有很多注释,本人做了一下总结,用于个人记录也用于分享。
4)支持流式批量的getBatch
认为出现发送到canal client(RocketMQ)失败的概率非常小,可以支持批量从环形队列中获取消息,减少加锁次数提高性能。按流的顺序ack,一旦一个前面的消息发送到重试上限,失败后会回滚后面发送的所有消息,认为后面发送的所有消息失败。
发送成功后更新position,文件消费位置。
3.问题:
- canal可能丢消息吗?
canal默认依赖于文件的position位置记录消费位置,如果持久化存储的position不正确就可能丢信息。但如果position正确,最多只可能多发重复信息,RocketMQ消费端可以做一些幂等逻辑判断。 - canal异步更新缓存优势在于什么?
一是将更新缓存的任务与用户线程解耦;二是异步更新缓存,方便合并写请求并支持批处理操作。 - canal崩溃后会丢消息吗?
只要position位置没有置后,最多只可能多发送一些重复消息。 - canal优缺点?
优点:对业务代码无侵入性、解析性能比较好。
缺点:开启binlog会损耗Mysql数据库性能;canal内部的环形队列基于内存,ack后消息变为可覆盖,不支持历史数据的回溯。
4.一般的整体流程
整体流程
(BinLogEvent 解析为——> Canal的Entry即canal自定义的事件, message封装List send——> List 轻量级消息)
注意,支持List,说明canal支持将多个消息打包成批量消息发送到消息队列,也就是说明支持 消费者端批量处理消息。(这个List也可以支持让List里面的消息一条一条发送,批量则是一个整体)
5. canal性能提升的一些点
1)原生 使用线程池的方式多线程接收Binlog 、
2)解析效率-算法效率高
3)获取消息-RingBuffer队列支持异步批量获取数据 无需get位点被ack、
4)反序列化和batch开销(send什么?性能优化点)
flatMessage模式,性能普遍比不开启flatMessage模式慢,主要就是protoobject的反序列化开销和小batch的发送成本,也就是说flatMessage是解析好的,而非flatMessage模式是没有解析好的message,相当于节约了一个步骤,发送到client端。后续可以通过更多的topic+分区追回。 (Message = List)
5)可以依据业务提升网络IO:可以发送批量消息,通过合理提高get一个批次的数据量,增大一次网络IO的数量提升效率(但太大会导致MQ端响应缓慢,影响MQ的写入)。
6) 合理设置topic+分区;对于MQ来说增加性能。
下面是阿里官方测试:
canal的性能经过阿里测试,性能TPS,从解析到存储到转发,性能是越来越低的。官方建议自己开发时可以考虑避开存入内存缓冲区
但我认为可能会有大量的数据解析,通过缓冲区方便管理数据。
下面是阿里官方给的性能测试结果:
说明阶段越早结束,性能越高,比如不发送到消息队列,而是直接从canal内存缓冲区中获取消息去更新redis缓存,性能更高,但是随之而来的是数据管理问题及消息积压问题等。
7)binlog parser优化 并发解析模型 (针对瓶颈点采用了多线程的模式提升吞吐)