如何实现数据实时同步到 ES?八年 Java 开发的实战方案(从业务到代码)

如何实现数据实时同步到 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 倍以上。

如果是 "多数据源同步"(如 MySQL+Oracle)或 "每秒万级数据量",Canal 的单节点可能扛不住,这时需要 Flink CDC------ 基于 Flink 的流处理能力,支持高吞吐、Exactly-Once 语义(数据不丢不重)。

  • Flink CDC Source:连接 MySQL/Oracle,捕获数据变更;
  • Flink 转换:清洗、过滤、关联数据(如商品表关联分类表);
  • ES Sink:将处理后的数据批量写入 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>
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 年弯路

  1. binlog 格式选错导致同步失败

    早期用STATEMENT格式的 binlog,Canal 无法解析行级变更,同步到 ES 的数据全是 NULL。解决方案:必须用ROW格式(binlog-format=ROW),记录每行数据的变更详情。

  2. ES 索引映射没提前创建

    同步数据时没提前定义 ES 索引的 mapping,导致price字段被自动识别为text类型,无法做范围查询(如 "价格 < 100")。解决方案:同步前先创建索引映射:

    json 复制代码
    PUT /tb_product
    {
      "mappings": {
        "properties": {
          "id": {"type": "keyword"},
          "name": {"type": "text", "analyzer": "ik_max_word"}, // 中文分词
          "price": {"type": "double"}, // 明确为数值类型
          "categoryName": {"type": "keyword"}
        }
      }
    }
  3. Canal 单节点故障导致同步中断

    线上 Canal Server 宕机后,数据同步中断了 20 分钟。解决方案:搭建 Canal 集群(主从模式),配合 ZooKeeper 实现高可用,避免单点故障。

  4. 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",把业务场景、方案对比、踩坑经验讲清楚 ------ 这才是八年开发该有的深度。

相关推荐
秋难降4 分钟前
优雅的代码是什么样的?🫣
java·python·代码规范
现在就干7 分钟前
Spring事务基础:你在入门时踩过的所有坑
java·后端
浮游本尊18 分钟前
Java学习第13天 - 数据库事务管理与MyBatis Plus
java
该用户已不存在19 分钟前
Gradle vs. Maven,Java 构建工具该用哪个?
java·后端·maven
JohnYan30 分钟前
Bun技术评估 - 23 Glob
javascript·后端·bun
浮游本尊34 分钟前
Java学习第11天 - Spring Boot高级特性与实战项目
java
浮游本尊34 分钟前
Java学习第12天 - Spring Security安全框架与JWT认证
java
二闹34 分钟前
聊天怕被老板发现?摩斯密码来帮你
后端·python
用户298698530141 小时前
# C#:删除 Word 中的页眉或页脚
后端
David爱编程1 小时前
happens-before 规则详解:JMM 中的有序性保障
java·后端