如何实现数据实时同步到 ES?八年 Java 开发的实战方案(从业务到代码)
开篇:那次因同步延迟被用户投诉的经历
三年前做电商商品搜索时,运营反馈 "新上架的 100 款商品,用户搜了半小时还没显示"------ 查了才发现,原来商品数据是每天凌晨全量同步到 ES 的,实时性完全跟不上业务需求。最后紧急改成 binlog 实时同步,才把同步延迟从 "小时级" 压到 "秒级",用户投诉直接降为零。
作为经手过电商、日志、用户行为分析等场景的八年 Java 开发,我太清楚 ES 实时同步的核心价值:数据同步的实时性,直接决定了搜索体验、监控时效、数据分析的准确性 。今天就从 业务场景→方案选型→核心代码→踩坑优化,带你吃透数据实时同步到 ES 的全流程,附可直接落地的实战方案。
一、先明确:哪些业务场景需要 "实时同步到 ES"?
不是所有数据都需要实时同步,先搞清楚业务需求,才能选对方案。八年开发中,这三类场景对 ES 实时同步的需求最迫切:
业务场景 | 同步核心需求 | 延迟容忍度 | 数据量级 |
---|---|---|---|
电商商品搜索 | 新商品上架、库存变更需立即被搜索到 | 毫秒级~秒级 | 百万级商品数据 |
分布式日志分析(ELK) | 应用日志实时同步到 ES,用于问题排查 | 秒级~分钟级 | 每秒万条日志 |
用户行为实时分析 | 用户点击、浏览行为同步到 ES,做实时推荐 | 秒级 | 每秒千条行为数据 |
金融风控监控 | 交易记录实时同步,用于风险规则判定 | 毫秒级 | 每秒百条交易数据 |
关键结论:需要 "实时查询 / 分析" 的场景,才需要实时同步到 ES;如果是离线报表(如每日销售额统计),全量同步即可,没必要追求实时。
二、4 种实时同步方案:选型比技术更重要
企业中常用的 ES 实时同步方案有 4 种,各有优劣,选对方案能少走 80% 的弯路。先看对比表,再逐个拆解:
同步方案 | 核心原理 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
Canal 监听 MySQL binlog | 伪装成 MySQL 从库,监听 binlog 日志 | 低侵入(不改业务代码)、实时性强(秒级) | 仅支持 MySQL,需处理 binlog 解析 | 业务数据同步(商品、订单) |
Flink CDC 同步 | 基于 CDC(变更数据捕获)技术,实时拉取变更 | 支持多数据源(MySQL/Oracle)、高吞吐 | 学习成本高,需搭建 Flink 集群 | 大数据量、多源数据同步 |
业务代码直接写 ES | 业务逻辑中新增 / 修改数据后,调用 ES API | 开发快、逻辑简单 | 侵入业务代码、事务不一致风险 | 中小项目、简单同步场景 |
Logstash 同步 | 读取日志 / 数据库,通过 Pipeline 同步到 ES | 配置化开发、无需写代码 | 实时性一般(秒级~分钟级)、不支持复杂转换 | 日志同步(ELK 栈)、简单数据同步 |
八年开发经验:90% 的业务场景,用 Canal 或 Flink CDC 就能搞定------ 业务代码直接写 ES 容易踩 "事务不一致" 的坑,Logstash 实时性不够,只有小项目才考虑。
三、实战方案 1:Canal 监听 MySQL binlog(中小项目首选)
Canal 是阿里开源的中间件,核心是 "伪装成 MySQL 从库,监听 binlog 日志",同步延迟能做到 1 秒内,且不侵入业务代码,是业务数据同步的首选方案。
1. 前置准备:开启 MySQL binlog
Canal 依赖 MySQL binlog,先在 MySQL 配置文件(my.cnf
)中开启:
ini
# 开启binlog,日志格式为ROW(记录行级变更,支持增量同步)
log-bin=mysql-bin
binlog-format=ROW
server-id=1 # 必须唯一,Canal会用不同的server-id伪装从库
重启 MySQL 后,执行show variables like 'log_bin';
,显示ON
即开启成功。
2. 核心架构:Canal Server + Canal Client + ES
- Canal Server:连接 MySQL,拉取 binlog 日志,解析成结构化数据;
- Canal Client:订阅 Canal Server 的消息,将数据转换后写入 ES;
- ES:最终存储同步后的数据,提供搜索能力。
3. 核心代码:Canal Client 同步到 ES
(1)引入依赖(Maven)
xml
<!-- Canal客户端依赖 -->
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.6</version>
</dependency>
<!-- ES客户端依赖(RestHighLevelClient) -->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.14.0</version>
</dependency>
(2)Canal Client 配置(监听 MySQL 商品表)
java
@Service
@Slf4j
public class CanalEsSyncService {
// ES客户端(单例,避免重复创建连接)
private final RestHighLevelClient esClient;
// Canal客户端
private CanalConnector canalConnector;
// 初始化ES客户端和Canal客户端
@PostConstruct
public void init() {
// 1. 初始化ES客户端
esClient = new RestHighLevelClient(
RestClient.builder(new HttpHost("192.168.0.100", 9200, "http"))
);
// 2. 初始化Canal客户端(连接Canal Server)
canalConnector = CanalConnectors.newSingleConnector(
new InetSocketAddress("192.168.0.101", 11111), // Canal Server地址
"example", // 实例名(Canal Server配置的)
"", "" // 用户名密码(MySQL的,若没设置则为空)
);
// 3. 启动Canal客户端,开始监听
new Thread(this::startSync, "canal-es-sync-thread").start();
}
// 核心同步逻辑
private void startSync() {
try {
// 连接Canal Server
canalConnector.connect();
// 订阅商品表:数据库名.表名,支持通配符(如mall_db.tb_product*)
canalConnector.subscribe("mall_db.tb_product");
// 回滚到最新位置,避免重复消费
canalConnector.rollback();
while (true) {
// 批量拉取binlog数据(每次拉100条,超时3秒)
Message message = canalConnector.getWithoutAck(100, 3000, 3000);
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
// 没有新数据,休眠100ms再拉
Thread.sleep(100);
continue;
}
// 处理binlog数据,同步到ES
processBinlogEntries(message.getEntries());
// 确认消费成功,Canal Server会标记该批次已处理
canalConnector.ack(batchId);
}
} catch (Exception e) {
log.error("Canal同步ES失败", e);
// 异常时回滚,避免数据丢失
canalConnector.rollback();
}
}
// 解析binlog条目,同步到ES
private void processBinlogEntries(List<CanalEntry.Entry> entries) throws IOException {
for (CanalEntry.Entry entry : entries) {
// 只处理ROW_DATA类型(行级变更)
if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
continue;
}
CanalEntry.RowChange rowChange = null;
try {
// 解析binlog内容
rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
} catch (Exception e) {
log.error("解析binlog失败", e);
continue;
}
// 获取表名(如tb_product)
String tableName = entry.getHeader().getTableName();
// 获取变更类型(INSERT/UPDATE/DELETE)
CanalEntry.EventType eventType = rowChange.getEventType();
// 处理每一行的变更
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
// 转换为Map(列名→列值)
Map<String, Object> dataMap = convertRowDataToMap(
eventType == CanalEntry.EventType.DELETE ? rowData.getBeforeColumnsList() : rowData.getAfterColumnsList()
);
// 同步到ES:索引名用表名(如tb_product),文档ID用商品ID
String esIndex = tableName.toLowerCase();
String esDocId = dataMap.get("id").toString(); // 假设商品表主键是id
switch (eventType) {
case INSERT:
case UPDATE:
// 新增/更新:用ES的index API(存在则更新,不存在则新增)
indexDocToEs(esIndex, esDocId, dataMap);
break;
case DELETE:
// 删除:用ES的delete API
deleteDocFromEs(esIndex, esDocId);
break;
default:
log.warn("不支持的变更类型:{}", eventType);
}
}
}
}
// 将Canal的列数据转换为Map
private Map<String, Object> convertRowDataToMap(List<CanalEntry.Column> columns) {
Map<String, Object> dataMap = new HashMap<>();
for (CanalEntry.Column column : columns) {
dataMap.put(column.getName(), column.getValue());
}
return dataMap;
}
// 写入/更新ES文档
private void indexDocToEs(String index, String docId, Map<String, Object> data) throws IOException {
// 构建ES请求
IndexRequest request = new IndexRequest(index)
.id(docId) // 文档ID,确保唯一(避免重复数据)
.source(data, XContentType.JSON); // 数据内容
// 发送请求到ES
IndexResponse response = esClient.index(request, RequestOptions.DEFAULT);
log.info("ES写入成功:索引={}, 文档ID={}, 结果={}", index, docId, response.getResult());
}
// 从ES删除文档
private void deleteDocFromEs(String index, String docId) throws IOException {
DeleteRequest request = new DeleteRequest(index, docId);
DeleteResponse response = esClient.delete(request, RequestOptions.DEFAULT);
log.info("ES删除成功:索引={}, 文档ID={}, 结果={}", index, docId, response.getResult());
}
// 关闭资源
@PreDestroy
public void close() throws IOException {
if (canalConnector != null) {
canalConnector.disconnect();
}
if (esClient != null) {
esClient.close();
}
}
}
4. 关键优化:避免数据不一致和性能问题
(1)幂等性处理(防止重复同步)
Canal 可能因网络波动重发消息,导致 ES 重复写入。解决方案:用 MySQL 主键作为 ES 文档 ID(indexDocToEs
中的docId
),ES 的index
API 会自动覆盖相同 ID 的文档,天然实现幂等。
(2)批量写入(提升 ES 性能)
单个文档写入 ES 性能低,改成批量写入:
typescript
// 批量写入ES的方法
private void bulkIndexToEs(String index, List<Map<String, Object>> dataList) throws IOException {
BulkRequest bulkRequest = new BulkRequest();
for (Map<String, Object> data : dataList) {
String docId = data.get("id").toString();
bulkRequest.add(new IndexRequest(index).id(docId).source(data, XContentType.JSON));
}
// 执行批量请求
BulkResponse response = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
if (response.hasFailures()) {
log.error("ES批量写入失败:{}", response.buildFailureMessage());
} else {
log.info("ES批量写入成功:共{}条", dataList.size());
}
}
在processBinlogEntries
中攒够 500 条数据再调用批量写入,ES 写入性能能提升 10 倍以上。
四、实战方案 2:Flink CDC 同步(大数据量高吞吐场景)
如果是 "多数据源同步"(如 MySQL+Oracle)或 "每秒万级数据量",Canal 的单节点可能扛不住,这时需要 Flink CDC------ 基于 Flink 的流处理能力,支持高吞吐、Exactly-Once 语义(数据不丢不重)。
1. 核心架构:Flink CDC Source → Flink 转换 → ES Sink
- Flink CDC Source:连接 MySQL/Oracle,捕获数据变更;
- Flink 转换:清洗、过滤、关联数据(如商品表关联分类表);
- ES Sink:将处理后的数据批量写入 ES。
2. 核心代码:Flink CDC 同步商品 + 分类数据到 ES
(1)引入依赖(Maven)
xml
<!-- Flink CDC依赖 -->
<dependency>
<groupId>com.ververica</groupId>
<artifactId>flink-connector-mysql-cdc</artifactId>
<version>2.2.1</version>
</dependency>
<!-- Flink ES Sink依赖 -->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-elasticsearch7_2.12</artifactId>
<version>1.14.6</version>
</dependency>
(2)Flink 作业代码
typescript
public class MysqlToEsFlinkJob {
public static void main(String[] args) throws Exception {
// 1. 创建Flink执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 启用Checkpoint(确保Exactly-Once语义,每5秒一次)
env.enableCheckpointing(5000);
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 2. 配置MySQL CDC Source(监听商品表和分类表)
DebeziumSourceFunction<String> mysqlSource = MySQLSource.<String>builder()
.hostname("192.168.0.100")
.port(3306)
.username("root")
.password("123456")
.databaseList("mall_db") // 数据库名
.tableList("mall_db.tb_product, mall_db.tb_category") // 表名,多个用逗号分隔
.deserializer(new JsonDebeziumDeserializationSchema()) // 解析为JSON格式
.startupOptions(StartupOptions.initial()) // 首次同步全量数据,之后增量同步
.build();
// 3. 读取CDC数据,转换为DataStream
DataStream<String> cdcStream = env.addSource(mysqlSource);
// 4. 数据转换:解析JSON,关联商品和分类(示例:商品表关联分类表名称)
DataStream<ProductEsDoc> productEsStream = cdcStream
.map(json -> JSON.parseObject(json, Map.class)) // 解析JSON为Map
.keyBy(map -> {
// 按表名和关联键分组(商品表按category_id,分类表按id)
String table = map.get("database") + "." + map.get("table");
if ("mall_db.tb_product".equals(table)) {
return "category_" + map.get("after.category_id");
} else if ("mall_db.tb_category".equals(table)) {
return "category_" + map.get("after.id");
}
return "unknown";
})
.window(TumblingProcessingTimeWindows.of(Time.seconds(1))) // 1秒窗口聚合
.apply((window, key, input, out) -> {
// 聚合窗口内的商品和分类数据,关联分类名称
Map<String, Object> product = null;
String categoryName = null;
for (Map<String, Object> map : input) {
String table = map.get("database") + "." + map.get("table");
Map<String, Object> data = (Map<String, Object>) map.get("after");
if ("mall_db.tb_product".equals(table)) {
product = data;
} else if ("mall_db.tb_category".equals(table)) {
categoryName = data.get("name").toString();
}
}
// 关联后生成ES文档
if (product != null) {
ProductEsDoc doc = new ProductEsDoc();
doc.setId(product.get("id").toString());
doc.setName(product.get("name").toString());
doc.setPrice(new BigDecimal(product.get("price").toString()));
doc.setCategoryName(categoryName); // 关联的分类名称
out.collect(doc);
}
});
// 5. 配置ES Sink,写入ES
List<HttpHost> esHosts = Collections.singletonList(new HttpHost("192.168.0.100", 9200, "http"));
ElasticsearchSink.Builder<ProductEsDoc> esSinkBuilder = new ElasticsearchSink.Builder<>(
esHosts,
(element, context, requestIndexer) -> {
// 构建ES请求
String json = JSON.toJSONString(element);
IndexRequest request = Requests.indexRequest()
.index("tb_product") // ES索引名
.id(element.getId()) // 文档ID=商品ID
.source(json, XContentType.JSON);
requestIndexer.add(request);
}
);
// 配置批量写入参数(提升性能)
esSinkBuilder.setBulkFlushMaxActions(1000); // 每1000条批量写入
esSinkBuilder.setBulkFlushMaxSizeMb(5); // 每5MB批量写入
esSinkBuilder.setBulkFlushInterval(1000); // 每1秒批量写入
// 6. 将Sink添加到DataStream
productEsStream.addSink(esSinkBuilder.build());
// 7. 启动Flink作业
env.execute("MysqlToEsFlinkJob");
}
// ES商品文档实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ProductEsDoc {
private String id;
private String name;
private BigDecimal price;
private String categoryName; // 关联的分类名称
}
}
五、八年踩坑总结:这些坑能让你少走 3 年弯路
-
binlog 格式选错导致同步失败
早期用
STATEMENT
格式的 binlog,Canal 无法解析行级变更,同步到 ES 的数据全是 NULL。解决方案:必须用ROW
格式(binlog-format=ROW
),记录每行数据的变更详情。 -
ES 索引映射没提前创建
同步数据时没提前定义 ES 索引的 mapping,导致
price
字段被自动识别为text
类型,无法做范围查询(如 "价格 < 100")。解决方案:同步前先创建索引映射:jsonPUT /tb_product { "mappings": { "properties": { "id": {"type": "keyword"}, "name": {"type": "text", "analyzer": "ik_max_word"}, // 中文分词 "price": {"type": "double"}, // 明确为数值类型 "categoryName": {"type": "keyword"} } } }
-
Canal 单节点故障导致同步中断
线上 Canal Server 宕机后,数据同步中断了 20 分钟。解决方案:搭建 Canal 集群(主从模式),配合 ZooKeeper 实现高可用,避免单点故障。
-
Flink Checkpoint 配置不当导致数据重复
没启用 Checkpoint 时,Flink 作业重启后会重新同步全量数据,导致 ES 出现重复文档。解决方案:启用 Checkpoint,并设置为
EXACTLY_ONCE
语义,确保数据不丢不重。
六、总结:同步方案选型指南(按场景对号入座)
-
中小项目,MySQL 单数据源:选 Canal,开发快、维护成本低,同步延迟秒级;
-
大数据量(每秒万级),多数据源:选 Flink CDC,支持高吞吐、Exactly-Once,适合复杂数据转换;
-
日志同步(ELK 栈) :选 Logstash,配置化开发,无需写代码;
-
极小项目(如个人博客) :选业务代码直接写 ES,快速上线,不用搭中间件。
八年开发的最大体会:数据实时同步到 ES,不是 "越复杂越好",而是 "越匹配业务越好" 。比如电商商品同步,Canal 足够用,没必要上 Flink CDC;反之,大数据量的日志分析,Logstash 比业务代码写 ES 高效 10 倍。
下次有人问你 "怎么实现数据实时同步到 ES",别只说 "用 Canal",把业务场景、方案对比、踩坑经验讲清楚 ------ 这才是八年开发该有的深度。