canal adapter 是canal 中的一个组件,canal通过拉取mysql binlog的方式提供对binlog的增量消费,canal adapter提供了es,mysql,hbase等增量消费mysql数据的能力,现使用canal adapter 1.1.4 发行版本用于mysql->es 的增量与全量同步
- 全量同步
Canal 本身不提供全量同步的能力,canal adapter自身通过select 全表的方式进行全量同步,在全量同步开始前,记下增量同步的位点,全量同步完成后,通过基于row模式的binlog进行覆盖以保证数据的一致性 - 增量同步
canal adapter通过canal 拉取binlog进行过滤并消费,通过解析mysql到es的一条sql来确定其对应关系
具体流程源码可以查看 https://github.com/alibaba/canal
现讨论使用过程中遇到的一些问题
1.如何保证高可用
通过两个canal adapter服务同时去订阅一个canal 的方式来保证高可用,同一时间,只会有一个 canal adapter订阅成功,其它canal adapter只会处在开始订阅状态,当成功订阅canal adapter的机器宕机时,另一台机器便会成功订阅。可以在zk的canal-adapter节点查看当前destination成功订阅的机器ip
2.产品化支持
Canal adapter本身是支持远程数据库中读取配置的,藏的比较隐蔽,通过在bootsrap.yml文件中指定远程数据库进行读取配置, 配置文件有两种
- application.yml: canal_config 表中id=2的content字段存储,主要包括全量同步的datasource以及增量任务的配置
private ConfigItem getRemoteAdapterConfig() {
String sql = "select name, content, modified_time from canal_config where id=2";
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
if (rs.next()) {
ConfigItem configItem = new ConfigItem();
configItem.setId(2L);
configItem.setName(rs.getString("name"));
configItem.setContent(rs.getString("content"));
configItem.setModifiedTime(rs.getTimestamp("modified_time").getTime());
return configItem;
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
return null;
}
- xxx.yml:canal_adapter_config表中的每行记录,每一行都是一个mysql->es同步的映射文件
private void loadModifiedAdapterConfigs() {
Map<String, ConfigItem> remoteConfigStatus = new HashMap<>();
String sql = "select id, category, name, modified_time from canal_adapter_config";
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
ConfigItem configItem = new ConfigItem();
configItem.setId(rs.getLong("id"));
configItem.setCategory(rs.getString("category"));
configItem.setName(rs.getString("name"));
configItem.setModifiedTime(rs.getTimestamp("modified_time").getTime());
remoteConfigStatus.put(configItem.getCategory() + "/" + configItem.getName(), configItem);
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
...
}
确定了配置文件读取方式后,如下文各种坑就开始了
3.多任务跑串问题
通过CommonRequest 类中的etl开关进行多任务的控制,发现多任务增量开关正常使用,log也没有异常,但是增量开关打开后,并没有增量同步能力。一顿debug后发现开关中控制的destination跑串了,多任务开关打开关闭会通过outerAdapterKey这个字段来进行管控,需要在两种配置文件中分别进行配置,不看源码甚至连这个配置项的存在都不知道。。
public class ESSyncConfig implements AdapterConfig {
private String dataSourceKey; // 数据源key
private String outerAdapterKey; // adapter key
private String groupId; // group id
private String destination; // canal destination
private ESMapping esMapping;
...
}
4.任务暂停状态下,产品化的应用如何找到成功订阅的canal adapter 地址
由于1的解决,adapter产品化的服务需要通过etl api来对任务进行管控,一旦任务停止过长,zk就会丢失当前订阅成功canal adapter 的ip,就无法使用etl进行管控,这样就需要存在一个默认的持续开启的任务来找到多台canal adapter中至少一台机器的ip
5.修改远程配置数据库后,canal adapter会不断重复订阅任务、取消这两个流程
首先确定配置的修改顺序是先application.yml,再修改映射文件。重复订阅任务、取消显然是存在死循环,adapter读取远程配置是通过2s一读数据库,对于数据库的modifyTime字段与本地存储的currentConfigTimestamp进行比对来确定是否更新。debug发现每次读取远程数据库写入adapter都会refresh之前的配置类,重新生成一个currentConfigTimestamp 造成死循环。在DbRemoteConfigLoader类中新增一个单例的adapterConfigHolder来获取该时间标示,成功解决
public class DbRemoteConfigLoader implements RemoteConfigLoader {
private static final Logger logger = LoggerFactory.getLogger(DbRemoteConfigLoader.class);
private DruidDataSource dataSource;
private volatile long currentConfigTimestamp = 0;//就是它
private Map<String, ConfigItem> remoteAdapterConfigs = new MapMaker().makeMap();
private ScheduledExecutorService executor = Executors.newScheduledThreadPool(2,
new NamedThreadFactory("remote-adapter-config-scan"));
private RemoteAdapterMonitor remoteAdapterMonitor = new RemoteAdapterMonitorImpl();
...
}
6.全量同步时抛出大量es连接关闭异常
如题,主要是由于int threadCount = Runtime.getRuntime().availableProcessors(); 获取当前cpu数量来决定线程数,对于一个2C的k8s应用可能得到几十个线程
public class AbstractEtlService{
...
if (cnt >= 10000) {
int threadCount = Runtime.getRuntime().availableProcessors();
long offset;
long size = CNT_PER_TASK;
long workerCnt = cnt / size + (cnt % size == 0 ? 0 : 1);
if (logger.isDebugEnabled()) {
logger.debug("workerCnt {} for cnt {} threadCount {}", workerCnt, cnt, threadCount);
}
}
7.全量同步连接池问题
需要在application.yml中指定全量同步的数据库连接数量 maxActive,同问题3,配置项隐藏比较深。。
8.全量同步数据库连接超时
数据库全量同步通过select全表方式拉数据,流式查询只能保护客户端,服务端会产生深度分页问题,将其改造成使用主键索引的方式去select全表大大提升速度,但是要要求主键必须是自增的,不然全量同步时会丢失数据。
`try {
DruidDataSource dataSource = DatasourceConfig.DATA_SOURCES.get(config.getDataSourceKey());
List<Object> values = new ArrayList<>();
// 拼接条件
if (config.getMapping().getEtlCondition() != null && params != null) {
String etlCondition = config.getMapping().getEtlCondition();
for (String param : params) {
etlCondition = etlCondition.replace("{}", "?");
values.add(param);
}
sql += " " + etlCondition;
}
if (logger.isDebugEnabled()) {
logger.debug("etl sql : {}", sql);
}
// 获取总数
String countSql = "SELECT COUNT(1) FROM ( " + sql + ") _CNT ";
long cnt = (Long) Util.sqlRS(dataSource, countSql, values, rs -> {
Long count = null;
try {
if (rs.next()) {
count = ((Number) rs.getObject(1)).longValue();
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
return count == null ? 0L : count;
});
// 当大于1万条记录时开启多线程
if (cnt >= 10000) {
int threadCount = Runtime.getRuntime().availableProcessors();
long offset;
long size = CNT_PER_TASK;
long workerCnt = cnt / size + (cnt % size == 0 ? 0 : 1);
if (logger.isDebugEnabled()) {
logger.debug("workerCnt {} for cnt {} threadCount {}", workerCnt, cnt, threadCount);
}
ExecutorService executor = Util.newFixedThreadPool(threadCount, 5000L);
List<Future<Boolean>> futures = new ArrayList<>();
for (long i = 0; i < workerCnt; i++) {
offset = size * i;
String sqlFinal = sql + " LIMIT " + offset + "," + size;
Future<Boolean> future = executor.submit(() -> executeSqlImport(dataSource,
sqlFinal,
values,
config.getMapping(),
impCount,
errMsg));
futures.add(future);
}
for (Future<Boolean> future : futures) {
future.get();
}
executor.shutdown();
} else {
executeSqlImport(dataSource, sql, values, config.getMapping(), impCount, errMsg);
}
logger.info("数据全量导入完成, 一共导入 {} 条数据, 耗时: {}", impCount.get(), System.currentTimeMillis() - start);
etlResult.setResultMessage("导入" + type + " 数据:" + impCount.get() + " 条");
} catch (Exception e) {
logger.error(e.getMessage(), e);
errMsg.add(type + " 数据导入异常 =>" + e.getMessage());
}
if (errMsg.isEmpty()) {
etlResult.setSucceeded(true);
} else {
etlResult.setErrorMessage(Joiner.on("\n").join(errMsg));
}
return etlResult;`