MYSQL分库分表数据同步es java
一、 基础环境
1.canal :我的是1.1.8的版本,只需要下载canal.deployer-1.1.8就可以,因为分库分表写入es是放在java代码里实现,canal下载canal地址 https://github.com/alibaba/canal/releases 。
2.es:我的是7.10.2 版本,下载地址Elasticsearch 7.10.2 | Elastic ,ik分词器已上传到文章里放在elasticsearch-7.10.2\plugins\并解压。
3.jdk:推荐使用jdk11,我的版本是17所以启动的脚本都要去单独修改。
二、环境启动配置
1.es:配置elasticsearch.yml,路径在 elasticsearch-7.10.2/config/elasticsearch.yml
# 集群名称,自定义你的 Elasticsearch 集群标识
cluster.name: my-es-pc-star
# 当前节点名称,可自定义命名以区分多个节点
node.name: node-1
# 数据保存目录(这里配置为 Windows 路径,请确保该路径存在并有写权限)
path.data: D:/java/Es/elasticsearch-7.10.2/data
# 日志保存目录(包含启动日志、GC 日志等)
path.logs: D:/java/Es/elasticsearch-7.10.2/logs
# 设置监听的主机地址(0.0.0.0 表示监听所有网卡,允许远程访问)
network.host: 0.0.0.0
# HTTP 服务端口(默认 9200)
http.port: 9209
# 设置 HTTP 接收请求体的最大大小,用于支持较大的文档上传(默认是 100mb)
http.max_content_length: 100mb
# 锁定内存,防止内存交换到磁盘,提升性能(需要系统配置支持,例如 Windows 需管理员身份启动)
bootstrap.memory_lock: true
# 防止误操作(如 delete *)删除所有索引,必须显式指定索引名
action.destructive_requires_name: false
# 单节点模式(不启用自动发现机制,适用于本地或单机环境)
discovery.type: single-node
# 允许Canal创建相关的所有索引
action.auto_create_index: "*"
es启动脚本示例,路径在elasticsearch-7.10.2/bin/自行创建脚本:
#!/bin/bash
# =======================
# 自定义 Elasticsearch 启动脚本
# 放在 elasticsearch 目录下(与 bin/ 同级)
# chmod +x start-es.sh 后执行 ./start-es.sh -s
# =======================
ES_HOME=/opt/home/environment/es/elasticsearch-7.10.2
ES_BIN="$ES_HOME/bin/elasticsearch"
ES_JAVA_OPTS="-Xms2g -Xmx2g"
ES_PORT=9209
check_password=true
silent=false
for arg in "$@"; do
case $arg in
-s|--silent)
silent=true
;;
-h|--help|-V|--version)
check_password=false
;;
esac
done
# 模拟 keystore 密码输入(如果需要)
if [ "$check_password" = true ]; then
echo -n "请输入 Elasticsearch keystore 密码: "
read -s KEYSTORE_PASSWORD
echo ""
# 你可以在这里使用 KEYSTORE_PASSWORD 做进一步操作
fi
# 启动 ES(可加参数)
echo "Starting Elasticsearch..."
$ES_BIN -Ehttp.port=$ES_PORT -Epath.data="$ES_HOME/data" -Epath.logs="$ES_HOME/logs" &
PID=$!
# 等待启动
sleep 5
if ps -p $PID > /dev/null; then
echo "Elasticsearch 启动成功,PID=$PID"
else
echo "Elasticsearch 启动失败"
if [ "$silent" = false ]; then
read -p "按任意键退出..." pause
fi
exit 1
fi
2.canal:配置instance.properties,路径在 canal.deployer-1.1.8\conf\example\instance.properties
注意:里面配置的regex改成自己业务需要导入es的分库 或者分表
# 是否开启 GTID 模式,MySQL 如果启用了 GTID(推荐关闭除非你了解)
canal.instance.gtidon=false
# 每个 canal 实例在 MySQL 的唯一标识,不能重复
canal.instance.mysql.slaveId=1001
# MySQL 主库的地址(主机:端口)
canal.instance.master.address=127.0.0.1:4461
# 连接 MySQL 的账号密码(必须具备 REPLICATION 权限)
canal.instance.dbUsername=
canal.instance.dbPassword=
# 字符集,通常为 UTF-8
canal.instance.connectionCharset=UTF-8
# 启用 tsdb(增量订阅位点的版本管理)
canal.instance.tsdb.enable=false
# TSDB 数据存储目录(Windows 路径注意双反斜杠或使用 /)
canal.instance.tsdb.directory=/opt/home/environment/canal/canal-tsdb
# binlog 正则过滤规则(支持正则表达式)
# 匹配所有以 tg_ 开头但不包含 tg_mob_ 的数据库中的所有表
canal.instance.filter.regex=(tg_(?!mob_)[^\\.]+\\..*|ry-star\\.star_user)
# 是否启用 Druid 数据源(一般不启用)
canal.instance.enableDruid=false
canal启动脚本改造示例,路径在canal.deployer-1.1.8/bin/restart.sh:
#!/bin/bash
# Echo 控制(Linux 下默认开启)
# echo 开启可用 set -x,调试用
# 取当前脚本所在目录
ENV_PATH=$(cd "$(dirname "$0")"; pwd)
CONF_DIR="$ENV_PATH/../conf"
# 默认配置文件
CANAL_CONF="$CONF_DIR/canal.properties"
if [ "$1" == "local" ]; then
CANAL_CONF="$CONF_DIR/canal_local.properties"
fi
LOGBACK_CONFIGURATION_FILE="$CONF_DIR/logback.xml"
# classpath 配置,所有 jar 包目录
CLASSPATH="$CONF_DIR:$ENV_PATH/../lib/*"
# JVM 参数配置
JAVA_MEM_OPTS="-Xms8g -Xmx8g"
JAVA_OPTS_EXT="-Djava.awt.headless=true -Djava.net.preferIPv4Stack=true -Dapplication.codeset=UTF-8 -Dfile.encoding=UTF-8"
JAVA_DEBUG_OPT="-server -Xdebug -Xnoagent -Djava.compiler=NONE -Xrunjdwp:transport=dt_socket,address=9099,server=y,suspend=n"
CANAL_OPTS="-DappName=otter-canal -Dlogback.configurationFile=$LOGBACK_CONFIGURATION_FILE -Dcanal.conf=$CANAL_CONF"
JAVA_OPTS="$JAVA_MEM_OPTS $JAVA_OPTS_EXT $JAVA_DEBUG_OPT $CANAL_OPTS"
CMD_STR="java $JAVA_OPTS -classpath \"$CLASSPATH\" com.alibaba.otter.canal.deployer.CanalLauncher"
echo "start cmd : $CMD_STR"
# 启动 Canal
exec java $JAVA_OPTS -classpath "$CLASSPATH" com.alibaba.otter.canal.deployer.CanalLauncher
注意在canal.deployer-1.1.8同级目录下创建canal-tsdb 数据存储目录
然后先启动es 在启动canal
三、配置es模版
因为我的是分库分表,所以要把所有相同的规则索引套用一样的索引模版,例如 数据库1、2、3下的同一张表chatlist表,创建索引就会自动使用同一个模版(根据索引名称去自动套用比如我在模版中配置"template": "chatlist_index_*",那么只要创建的索引前缀和chatlist_index_匹配就会自动套用这套模版例如chatlist_index_1或者chatlist_index_1_1111都可以自动适配),下面是我的一个不同库,同一张表所使用的索引模版demo,根据需求自己设置模版
PUT http://127.0.0.1:9209/_template/chatlist_index
{
"template": "chatlist_index_*",
"settings": {
"index.max_ngram_diff": 8,
"number_of_shards": 1,
"number_of_replicas": 1,
"analysis": {
"filter": {
"lowercase_filter": {
"type": "lowercase"
}
},
"analyzer": {
"custom_ngram_analyzer": {
"tokenizer": "custom_ngram_tokenizer",
"filter": [
"lowercase_filter"
]
},
"keyword_search": {
"tokenizer": "keyword",
"filter": [
"lowercase_filter"
]
}
},
"tokenizer": {
"custom_ngram_tokenizer": {
"type": "ngram",
"min_gram": 2,
"max_gram": 10,
"token_chars": [
"letter",
"digit"
]
}
}
}
},
"mappings": {
"properties": {
"id": {
"type": "long"
},
"user_id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"keyword": {
"type": "keyword"
},
"substring": {
"type": "text",
"analyzer": "custom_ngram_analyzer",
"search_analyzer": "keyword_search"
}
}
},
"user_name": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"keyword": {
"type": "keyword"
},
"substring": {
"type": "text",
"analyzer": "custom_ngram_analyzer",
"search_analyzer": "keyword_search"
}
}
},
"phone": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"keyword": {
"type": "keyword"
},
"substring": {
"type": "text",
"analyzer": "custom_ngram_analyzer",
"search_analyzer": "keyword_search"
}
}
},
"type": {
"type": "keyword"
},
"tgid": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"keyword": {
"type": "keyword"
},
"substring": {
"type": "text",
"analyzer": "custom_ngram_analyzer",
"search_analyzer": "keyword_search"
}
}
},
"uuid": {
"type": "keyword"
},
"file_id": {
"type": "keyword"
},
"record": {
"type": "text"
},
"last_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis"
},
"create_by": {
"type": "keyword"
},
"create_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis"
},
"update_by": {
"type": "keyword"
},
"update_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis"
}
}
}
}
四、java代码写入ES 并设置索引名称为了套用到索引模版
package com.ruoyi.star.listener;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry.*;
import com.alibaba.otter.canal.protocol.Message;
import com.ruoyi.star.config.CanalProperties;
import lombok.RequiredArgsConstructor;
import org.elasticsearch.action.delete.DeleteRequest;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.xcontent.XContentType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Controller;
import javax.annotation.PostConstruct;
import java.net.InetSocketAddress;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Component
@RequiredArgsConstructor
public class CanalListener {
@Autowired
private CanalProperties canalProperties;
private final RestHighLevelClient esClient;
@PostConstruct
public void start() {
System.out.println(">>> CanalListener 已启动监听线程");
System.out.println(">>> CanalConnector 已连接:" + canalProperties.getHost() + ":" + canalProperties.getPort());
new Thread(this::runListener).start();
}
public void runListener() {
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress(canalProperties.getHost(), canalProperties.getPort()),
canalProperties.getDestination(), "", ""
);
connector.connect();
connector.subscribe(canalProperties.getSubscribe());
connector.rollback();
while (true) {
Message message = connector.getWithoutAck(canalProperties.getBatchSize());
long batchId = message.getId();
List<Entry> entries = message.getEntries();
if (entries.isEmpty()) {
sleep(1000);
continue;
}
try {
for (Entry entry : entries) {
if (entry.getEntryType() != EntryType.ROWDATA) continue;
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
EventType eventType = rowChange.getEventType();
String tableName = entry.getHeader().getTableName();
for (RowData rowData : rowChange.getRowDatasList()) {
switch (eventType) {
case INSERT, UPDATE -> handleInsertOrUpdate(tableName, rowData.getAfterColumnsList());
case DELETE -> handleDelete(tableName, rowData.getBeforeColumnsList());
}
}
}
connector.ack(batchId);
} catch (Exception e) {
e.printStackTrace();
connector.rollback(batchId);
}
}
}
private void handleInsertOrUpdate(String tableName, List<Column> columns) throws Exception {
// 将列数据转换成 Map
Map<String, Object> data = toMap(columns);
// 根据表名和数据生成 ES 索引名
String index = getIndexName(tableName, columns);
// 根据表名和数据生成文档 ID
String docId = getDocumentId(tableName, columns);
// 定义所有可能是日期类型的字段名称集合
Set<String> dateFields = Set.of("create_time", "update_time", "last_active", "created", "date", "last_time", "data_updating_time");
// 移除所有日期字段中值为 null、空字符串或无效时间字符串 "0000-00-00 00:00:00" 的字段,避免 ES 日期格式解析失败
data.entrySet().removeIf(entry -> {
if (!dateFields.contains(entry.getKey())) {
return false; // 非日期字段不处理
}
Object val = entry.getValue();
// 过滤条件:值为 null,空字符串,或 "0000-00-00 00:00:00"
return val == null || "".equals(val) || "0000-00-00 00:00:00".equals(val);
});
// 构造索引请求,写入或更新 ES 文档
IndexRequest request = new IndexRequest(index).id(docId).source(data, XContentType.JSON);
esClient.index(request, RequestOptions.DEFAULT);
}
private void handleDelete(String tableName, List<Column> columns) throws Exception {
String index = getIndexName(tableName, columns);
String docId = getDocumentId(tableName, columns);
DeleteRequest request = new DeleteRequest(index, docId);
esClient.delete(request, RequestOptions.DEFAULT);
}
private Map<String, Object> toMap(List<Column> columns) {
Map<String, Object> map = new HashMap<>();
for (Column col : columns) {
map.put(col.getName(), col.getValue());
}
return map;
}
private String getIndexName(String tableName, List<Column> columns) {
String suffix = "unknown";
String createTime = null;
for (Column col : columns) {
if ("create_time".equalsIgnoreCase(col.getName())) {
createTime = col.getValue();
break;
}
}
if (useYearMonth(tableName)) {
suffix = extractYearMonthAndWeekOfMonth(createTime);
} else {
suffix = extractYear(createTime);
}
return tableName.toLowerCase() + "_index_" + suffix;
}
// 判断是否是需要按 yyyyMM wN的表
private boolean useYearMonth(String tableName) {
Set<String> monthlyTables = Set.of(
"chatlist", "contacts", "file", "location", "media", "message", "sessions", "textentities"
);
return monthlyTables.contains(tableName.toLowerCase());
}
/**
* 提取日期字符串或时间戳对应的“年月+月内第几周”后缀,格式为 yyyyMM_wN。
* 其中:
* - yyyyMM 表示年和月,例如202508
* - wN 表示该日期所在月份的第N周,N从1开始计算,周一到周日为一周
* 支持输入为:
* - 10位或13位时间戳字符串(秒或毫秒)
* - 常见日期字符串格式,如 "yyyy-MM-dd HH:mm:ss", "yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd", "yyyy/MM/dd"
* 如果解析失败,返回 "unknown"。
*
* @param createTime 日期字符串或时间戳字符串
* @return 格式化后的年月+月内周字符串,例如 "202508_w3"
*/
private String extractYearMonthAndWeekOfMonth(String createTime) {
if (createTime == null || createTime.isEmpty()) return "unknown";
try {
// 尝试将字符串解析为时间戳(秒或毫秒)
long ts = Long.parseLong(createTime);
if (ts < 10000000000L) ts *= 1000; // 秒转毫秒
Instant instant = Instant.ofEpochMilli(ts);
LocalDate dt = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).toLocalDate();
return formatYearMonthWeek(dt);
} catch (NumberFormatException e) {
// 不是纯数字,尝试用日期格式解析
DateTimeFormatter[] fmts = {
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd"),
DateTimeFormatter.ofPattern("yyyy/MM/dd")
};
for (DateTimeFormatter fmt : fmts) {
try {
LocalDate dt = LocalDate.parse(createTime, fmt);
return formatYearMonthWeek(dt);
} catch (Exception ignore) {
// 格式不匹配,继续尝试下一个格式
}
}
}
// 解析失败返回默认值
return "unknown";
}
/**
* 格式化年月和月内第几周,格式为 yyyyMM_wN
* @param dt LocalDate日期对象
* @return 格式化字符串,例如 "202508_w3"
*/
private String formatYearMonthWeek(LocalDate dt) {
String yearMonth = String.format("%04d%02d", dt.getYear(), dt.getMonthValue());
// 计算“月内第几周”
int dayOfMonth = dt.getDayOfMonth();
LocalDate firstDayOfMonth = dt.withDayOfMonth(1);
// Java中 getDayOfWeek() 返回 MONDAY=1 ... SUNDAY=7
int firstWeekDay = firstDayOfMonth.getDayOfWeek().getValue();
// 计算周数: (当前日 + 第一天是周几 - 1) / 7 向上取整
int weekOfMonth = (int) Math.ceil((dayOfMonth + firstWeekDay - 1) / 7.0);
return yearMonth + "_w" + weekOfMonth;
}
// 提取年 yyyy(支持时间戳或字符串)
private String extractYear(String createTime) {
if (createTime == null || createTime.isEmpty()) return "unknown";
try {
long ts = Long.parseLong(createTime);
if (ts < 10000000000L) ts *= 1000;
return LocalDate.ofEpochDay(ts / 86400000).getYear() + "";
} catch (NumberFormatException e) {
DateTimeFormatter[] fmts = {
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd"),
DateTimeFormatter.ofPattern("yyyy/MM/dd")
};
for (DateTimeFormatter fmt : fmts) {
try {
return LocalDate.parse(createTime, fmt).getYear() + "";
} catch (Exception ignore) {
}
}
}
return "unknown";
}
private String getDocumentId(String tableName, List<Column> columns) {
Map<String, String> map = new HashMap<>();
for (Column c : columns) {
map.put(c.getName(), c.getValue());
}
return switch (tableName.toLowerCase()) {
case "star_user" -> map.getOrDefault("tgid", "null") + "_" + map.getOrDefault("id", "null");
case "sessions" ->
map.getOrDefault("tgid", "null") + "_" + map.getOrDefault("uuid", "null") + "_" + map.getOrDefault("id", "null");
case "textentities" ->
map.getOrDefault("chatlist_id", "null") + "_" + map.getOrDefault("message_id", "null") + "_" + map.getOrDefault("id", "null");
case "chatlist" ->
map.getOrDefault("tgid", "null") + "_" + map.getOrDefault("uuid", "null") + "_" + map.getOrDefault("id", "null");
case "contacts" ->
map.getOrDefault("tgid", "null") + "_" + map.getOrDefault("uuid", "null") + "_" + map.getOrDefault("id", "null");
case "media" ->
map.getOrDefault("chatlist_id", "null") + "_" + map.getOrDefault("message_id", "null") + "_" + map.getOrDefault("id", "null");
case "location" ->
map.getOrDefault("chatlist_id", "null") + "_" + map.getOrDefault("message_id", "null") + "_" + map.getOrDefault("id", "null");
case "file" -> map.getOrDefault("file_id", "null") + "_" + map.getOrDefault("id", "null");
case "message" ->
map.getOrDefault("chatlist_id", "null") + "_" + map.getOrDefault("message_id", "null") + "_" + map.getOrDefault("id", "null");
default -> map.getOrDefault("id", "null");
};
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException ignored) {
}
}
}
package com.ruoyi.star.config;
import lombok.Data;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.Map;
@Data
@Component
/**
* 获取到nacos 中yml的es配置
*/
public class ElasticsearchConfig {
@Value("${spring.elasticsearch.uris}")
private String elasticsearchUris;
@Bean
public RestHighLevelClient restHighLevelClient() {
return new RestHighLevelClient(
RestClient.builder(HttpHost.create(elasticsearchUris))
);
}
}
package com.ruoyi.star.config;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Data
@Component
/**
* 获取到nacos 中yml的canal配置
*/
public class CanalProperties {
@Value("${spring.canal.host}")
private String host;
@Value("${spring.canal.port}")
private int port;
@Value("${spring.canal.destination}")
private String destination;
@Value("${spring.canal.subscribe}")
private String subscribe;
@Value("${spring.canal.batchSize}")
private int batchSize;
@Value("${spring.canal.jdbcurl}")
private String jdbcurl;
@Value("${spring.canal.username}")
private String username;
@Value("${spring.canal.password}")
private String password;
}
canal:
host: 127.0.0.1
port: 11111
destination: example
subscribe: "(tg_(?!mob_)[^\\.]+\\..*|ry-star\\.star_user)"
batchSize: 100
jdbcurl: jdbc:mysql://127.0.0.1:3306/ry-star?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: 123456
其中
@Value("${spring.elasticsearch.uris}")
private String elasticsearchUris;
@Autowired
private CanalProperties canalProperties;
里面配置了es 端口 之类的配置项 可以自行配置
都是我再nacos的canal和es配置端口之类的 可以自行配置
更多推荐



所有评论(0)