【Java + Elasticsearch全量 & 增量同步实战】

Java + Elasticsearch 全量 & 增量同步实战:打造高性能合同搜索系统

在企业合同管理系统中,我们常常遇到以下挑战:

  • 合同量大,文本内容多,传统数据库查询慢

  • 搜索需求多样:全文搜索、按签署人筛选、分页排序

  • 历史合同也要可搜索,不仅仅是新建合同

  • 统计报表需求:合同签署量、签署人分析等

本文将分享如何使用 Elasticsearch + MySQL + ClickHouse 构建一个高性能合同搜索系统,并提供完整 Java 示例。


一、系统架构概览

合同系统采用“三角架构”:

                ┌───────────────────────┐
                │       前端 / API       │
                │  (创建 / 修改 / 查询) │
                └─────────┬─────────────┘
                          │
                          ▼
                ┌───────────────────────┐
                │       MySQL 数据库     │
                │  (权威业务数据源)    │
                └─────────┬─────────────┘
                          │
           ┌──────────────┴───────────────┐
           │                              │
   【历史数据全量初始化】            【增量同步 / 实时更新】
           │                              │
           ▼                              ▼
分页 / 批量读取历史合同         新建合同 / 修改合同 / 删除合同
           │                              │
           ▼                              ▼
     转换为 ContractDoc                转换为 ContractDoc
           │                              │
           ▼                              ▼
        ES Bulk API                    ES Index / Update / Delete API
           │                              │
           └───────────┬──────────────────┘
                       ▼
                ┌───────────────┐
                │ Elasticsearch │
                │   contract    │
                │     index     │
                └───────────────┘
                       │
                       ▼
                ┌───────────────┐
                │   查询接口    │
                │(合同列表 / 搜索)│
                └───────────────┘

核心说明

  • MySQL:权威数据源,存储所有合同业务信息

  • ES:用于搜索,支持全文搜索、筛选和排序

  • ClickHouse:用于统计报表,处理大规模合同分析


二、为什么使用宽表

1. 什么是宽表?

宽表 = 把多张业务表的数据提前合并到一张字段很多的表里,用“空间换时间”,减少查询时的 join。

传统 MySQL 查询可能涉及多个 join,性能差:

SELECT c.*, u.name, e.enterprise_name
FROM contract c
JOIN user u ON c.user_id = u.id
JOIN enterprise e ON c.enterprise_id = e.id
WHERE c.status = 'SIGNED';

宽表设计后,所有信息在一条记录中:

{
  "contractId": 10001,
  "contractTitle": "劳动合同",
  "contractStatus": "SIGNED",
  "signTime": "2025-12-01 10:30:00",
  "initiatorId": 2001,
  "initiatorName": "张三",
  "initiatorPhone": "138****",
  "enterpriseId": 3001,
  "enterpriseName": "天津数字认证有限公司",
  "fileHash": "xxxx",
  "signType": "SILENT",
  "source": "OPEN_API"
}
  • 查询无需 join,ES 或 ClickHouse 查询极快

  • 冗余换来性能,是搜索系统的设计常态


三、ES 全量初始化历史数据

1. Java 代码示例(全量导入)

@Service
public class ContractEsService {

    @Autowired
    private ContractMapper contractMapper;

    @Autowired
    private RestHighLevelClient esClient;

    /**
     * 全量初始化合同数据到 Elasticsearch
     */
    public void initHistoricalContracts() throws IOException {
        int pageSize = 500;
        int page = 0;

        while (true) {
            List<Contract> contracts = contractMapper.selectHistorical(page * pageSize, pageSize);
            if (contracts.isEmpty()) break;

            BulkRequest bulkRequest = new BulkRequest();
            for (Contract contract : contracts) {
                ContractDoc doc = toContractDoc(contract);
                bulkRequest.add(new IndexRequest("contract_index")
                        .id(String.valueOf(doc.getContractId()))
                        .source(doc.toMap()));
            }
            esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
            page++;
        }
    }

    private ContractDoc toContractDoc(Contract contract) {
        ContractDoc doc = new ContractDoc();
        doc.setContractId(contract.getId());
        doc.setContractTitle(contract.getTitle());
        doc.setContractStatus(contract.getStatus());
        doc.setSignTime(contract.getSignTime());
        doc.setInitiatorId(contract.getUserId());
        doc.setInitiatorName(contract.getUserName());
        doc.setInitiatorPhone(contract.getUserPhone());
        doc.setEnterpriseId(contract.getEnterpriseId());
        doc.setEnterpriseName(contract.getEnterpriseName());
        doc.setFileHash(contract.getFileHash());
        doc.setSignType(contract.getSignType());
        doc.setSource(contract.getSource());
        return doc;
    }
}

说明

  • 分批读取,避免内存爆炸

  • BulkRequest 提高写入性能

  • ContractDoc 为宽表结构,支持全文搜索


四、增量同步

1. 新建合同

public void saveContract(Contract contract) throws IOException {
    contractMapper.insert(contract); // 写 MySQL

    ContractDoc doc = toContractDoc(contract);
    esClient.index(new IndexRequest("contract_index")
            .id(String.valueOf(doc.getContractId()))
            .source(doc.toMap()), RequestOptions.DEFAULT); // 写 ES
}

2. 更新合同

public void updateContract(Contract contract) throws IOException {
    contractMapper.update(contract);

    ContractDoc doc = toContractDoc(contract);
    esClient.update(new UpdateRequest("contract_index", String.valueOf(doc.getContractId()))
            .doc(doc.toMap()), RequestOptions.DEFAULT);
}

3. 删除合同

public void deleteContract(Long contractId) throws IOException {
    contractMapper.delete(contractId);
    esClient.delete(new DeleteRequest("contract_index", String.valueOf(contractId)), RequestOptions.DEFAULT);
}

五、查询示例

public List<ContractDoc> searchContracts(String keyword, String status) throws IOException {
    SearchRequest searchRequest = new SearchRequest("contract_index");
    SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();

    BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
    if (keyword != null) {
        boolQuery.must(QueryBuilders.multiMatchQuery(keyword, "contractTitle", "enterpriseName"));
    }
    if (status != null) {
        boolQuery.filter(QueryBuilders.termQuery("contractStatus", status));
    }
    sourceBuilder.query(boolQuery).from(0).size(20);
    searchRequest.source(sourceBuilder);

    SearchResponse response = esClient.search(searchRequest, RequestOptions.DEFAULT);
    List<ContractDoc> results = new ArrayList<>();
    for (SearchHit hit : response.getHits()) {
        results.add(ContractDoc.fromMap(hit.getSourceAsMap()));
    }
    return results;
}

  • 支持全文搜索和条件过滤

  • 支持分页

  • 支持宽表字段查询(无需 join)


六、增量 & 历史数据同步策略总结

数据类型处理方式
历史合同全量初始化 → 批量写入 ES
新建合同实时写入 ES
更新合同实时更新 ES
删除合同实时删除 ES

建议

  • 增量同步可结合 消息队列 + CDC,保证最终一致性

  • 历史数据初始化建议在低峰时执行,分批写入


七、总结

  1. 宽表 + ES:提高合同搜索性能,避免 join

  2. 全量初始化历史数据:ES 支持既往合同搜索

  3. 增量同步:保证新数据实时可查

  4. 三角架构(MySQL + ES + ClickHouse):各司其职

    • MySQL:权威数据

    • ES:快速搜索

    • ClickHouse:报表分析【聚合极快、适合统计数据量(亿级)报表】

通过这套设计,合同系统既能秒级响应搜索,又能提供高效报表分析,满足大规模企业业务需求。

### 使用 Canal 家族工具实现 MySQL 到 Elasticsearch全量和实时增量数据同步 #### 1. 技术栈概述 Canal 是阿里巴巴开源的一款用于捕获 MySQL 数据库 Binlog 日志并进行解析的工具,能够将数据库中的变更事件传递给下游消费者。通过结合 Logstash 和 Kibana,可以构建一套完整的解决方案来完成从 MySQL 到 Elasticsearch全量增量数据同步。 - **Logstash**: 负责全量数据导入。 - **Canal**: 提供增量数据订阅能力。 - **Elasticsearch**: 存储最终的数据索引。 - **Kibana**: 可视化展示 Elasticsearch 中的数据。 --- #### 2. 环境准备 以下是所需软件及其版本: - MySQL (需开启 binlog 功能)[^1] - ElasticSearch 6.8.13[^2] - Kibana 6.8.13[^2] - Canal Server 1.1.5[^1] - Canal Adapter[^4] 确保以上组件均已正确安装并运行正常。 --- #### 3. 配置步骤 ##### 3.1 开启 MySQL 的 Binlog 功能 为了使 Canal 正常工作,MySQL 必须启用二进制日志记录功能,并设置唯一的服务器 ID。编辑 `my.cnf` 文件: ```ini [mysqld] server-id=1 log-bin=mysql-bin binlog-format=ROW ``` 重启 MySQL 后验证配置是否生效: ```sql SHOW VARIABLES LIKE &#39;log_bin&#39;; SHOW MASTER STATUS; ``` 如果返回的结果显示 `ON` 或者有具体的文件名,则表示成功[^1]。 --- ##### 3.2 配置 Canal Server 下载并解压 Canal Server 至指定目录,修改其核心配置文件 `instance.properties` 来适配您的 MySQL 数据库环境: ```properties canal.instance.master.address=127.0.0.1:3306 canal.instance.dbUsername=canal_user canal.instance.dbPassword=your_password canal.instance.connectionCharset=UTF-8 canal.instance.filter.regex=my_database\\.my_table.* ``` 其中需要注意的是用户名权限问题,建议创建专门的用户赋予如下权限: ```sql GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO &#39;canal_user&#39;@&#39;%&#39;; FLUSH PRIVILEGES; ``` 启动 Canal Server 并确认服务状态正常[^1]。 --- ##### 3.3 设置 Canal Adapter 进行数据转发至 ES Adapter 主要负责对接不同的目标端(如 Kafka、ES)。对于本场景而言,重点在于调整 adapter 下对应的目标地址参数。 编辑路径下的 `application.yml` 文件,添加以下内容: ```yaml spring: cloud: stream: bindings: input: destination: example_topic canal: server: tcp://localhost:11111 es: clusterNodes: localhost:9300 indexName: my_index_name ``` 同时还需要定义好映射关系表结构与文档字段之间的关联规则,在同级目录找到 `mapping.json` 模板填写实际需求。 --- ##### 3.4 利用 Logstash 执行初次全量迁移 编写一份标准输入插件脚本来抓取远程主机上的 SQL 表格信息传送到本地节点上建立初始副本集。 示例 pipeline.conf 如下所示: ```conf input { jdbc { jdbc_driver_library =&gt; &quot;/path/to/mysql-connector-java.jar&quot; jdbc_driver_class =&gt; &quot;com.mysql.jdbc.Driver&quot; jdbc_connection_string =&gt; &quot;jdbc:mysql://localhost:3306/mydb?useSSL=false&amp;serverTimezone=UTC&quot; jdbc_user =&gt; &quot;root&quot; statement =&gt; &quot;SELECT id,name FROM users&quot; } } output { elasticsearch { hosts =&gt; [&quot;http://localhost:9200&quot;] index =&gt; &quot;users-index&quot; } } ``` 执行命令加载此流程描述符即可触发一次性传输操作[^2]。 --- ##### 3.5 整合 Kibana 展现分析成果 最后一步便是打开浏览器访问 http://&lt;ip&gt;:5601 创建新的仪表盘项目绑定先前设定好的 indices name ,从而直观查看效果变化趋势图谱等等附加价值报表形式呈现出来[^3]。 --- ###
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值