🔄 引言:ES 的数据从哪里来?
前面几篇我们讲了怎么在 ES 里建索引、怎么查询、怎么调优------但有一个根本问题一直没有正面回答:ES 里的数据是怎么进来的?
最直接的答案是"应用写入",但在生产环境里,事情远没这么简单。
大多数 SaaS 系统的数据主要存在 MySQL(或其他关系型数据库)里,ES 是搜索层,是 MySQL 的"搜索视图"。每当 MySQL 里的商品被修改、订单状态更新、用户信息变更,ES 里对应的文档也必须同步更新------而且是实时的,可靠的。
这个看似简单的"同步",是 ES 落地实践中工程复杂度最高的环节之一。本篇把所有主流方案摆出来,逐一分析利弊,给出适合不同场景的完整代码。
一、六种同步方案全景对比
先建立整体认知,再深入实战。
| 方案 | 实时性 | 复杂度 | 数据一致性 | 入侵性 | 适用规模 |
|---|---|---|---|---|---|
| 双写(应用层) | 实时 | 低 | 弱(无事务) | 高(改代码) | 小 |
| 定时 ETL | 分钟级 | 低 | 中 | 低 | 小 |
| Logstash JDBC | 分钟级 | 中 | 中 | 无 | 中 |
| Canal + MQ | 秒级 | 中高 | 强 | 无 | 中大 |
| Flink CDC | 秒级 | 高 | 强 | 无 | 大 |
| Debezium | 秒级 | 中 | 强 | 无 | 中大 |
选型决策树:
bash
Q1: 数据量大吗?(>1000万条,或每天增量 >100万)
是 → Q2: 有大数据团队吗?
是 → Flink CDC(强大,但需要大数据技术栈)
否 → Canal + RocketMQ(主流,Java 生态友好)
否 → Q3: 允许分钟级延迟吗?
是 → Logstash JDBC(简单,零代码)
否 → Canal + RocketMQ 或 Debezium
二、方案一:双写(应用层)------最简单但最脆弱
2.1 实现
java
// ProductService.java
@Service
@RequiredArgsConstructor
@Transactional
public class ProductService {
private final ProductRepository mysqlRepo;
private final ElasticsearchClient esClient;
/**
* 双写:先写 MySQL,再写 ES
*
* 这种方案最直观,但有一个根本问题:
* MySQL 和 ES 不在同一个事务里。
* 如果 MySQL 写入成功,ES 写入失败,数据就不一致了。
* 如果用重试机制,需要保证幂等,还要处理 ES 宕机的情况。
*/
public Product createProduct(ProductCreateRequest req) {
// Step 1: 写入 MySQL(在事务里)
Product product = productMapper.toEntity(req);
product = mysqlRepo.save(product);
// Step 2: 写入 ES(事务外,失败不会回滚 MySQL)
try {
esClient.index(i -> i
.index("products")
.id(product.getId())
.document(product)
);
} catch (Exception e) {
// ES 写入失败时,把失败记录写入"补偿表",由后台任务异步重试
// 这是双写方案必须配套的"兜底机制"
compensationService.saveFailedSync(product.getId(), "CREATE", e.getMessage());
log.error("ES 同步失败,已记录补偿任务: productId={}", product.getId(), e);
}
return product;
}
}
2.2 双写的补偿机制
java
// SyncCompensationService.java ------ 双写失败的异步补偿
@Service
@RequiredArgsConstructor
@Slf4j
public class SyncCompensationService {
private final SyncFailureRepository failureRepo;
private final ProductRepository productRepo;
private final ElasticsearchClient esClient;
@Scheduled(fixedDelay = 30000) // 每 30 秒扫描一次
public void retryFailedSyncs() {
List<SyncFailure> failures = failureRepo.findPendingRetries(
LocalDateTime.now().minusHours(24), // 只重试 24 小时内的失败
100 // 每次最多处理 100 条
);
for (SyncFailure failure : failures) {
try {
Product product = productRepo.findById(failure.getEntityId())
.orElse(null);
if (product == null) {
// 商品已删除,ES 里也删除
esClient.delete(d -> d.index("products").id(failure.getEntityId()));
} else {
esClient.index(i -> i
.index("products")
.id(product.getId())
.document(product)
);
}
failure.setStatus(SyncStatus.SUCCESS);
failureRepo.save(failure);
log.info("补偿同步成功: {}", failure.getEntityId());
} catch (Exception e) {
failure.setRetryCount(failure.getRetryCount() + 1);
if (failure.getRetryCount() >= 5) {
failure.setStatus(SyncStatus.DEAD_LETTER); // 放弃重试,人工处理
log.error("补偿同步超过最大重试次数,转入死信: {}", failure.getEntityId());
}
failureRepo.save(failure);
}
}
}
}
⚠️ 双写方案的本质局限:双写无法完全保证一致性,只能通过补偿机制降低不一致的时间窗口。如果你的业务对搜索数据的一致性要求很高(如法律文书检索、财务报表),不建议用双写。对于普通电商搜索,短暂的不一致(秒级到分钟级)通常是可以接受的。
三、方案二:Logstash JDBC------零代码,快速起步
Logstash 是 ELK 栈的数据管道组件,内置 JDBC 插件可以直接从数据库拉取数据同步到 ES,不需要改应用代码。
3.1 增量同步配置
ruby
# logstash-product-sync.conf
# 核心思路:利用 updated_at 字段做增量,定时拉取最近更新的记录
input {
jdbc {
# 数据库连接(MySQL)
jdbc_driver_library => "/opt/logstash/lib/mysql-connector-java-8.0.33.jar"
jdbc_driver_class => "com.mysql.cj.jdbc.Driver"
jdbc_connection_string => "jdbc:mysql://mysql:3306/saas_db?useSSL=false&serverTimezone=Asia/Shanghai"
jdbc_user => "logstash_user"
jdbc_password => "${MYSQL_PASSWORD}" # 从环境变量读取,不要硬编码
# SQL:增量查询(只拉取上次同步时间之后更新的记录)
# :sql_last_value 是 Logstash 自动维护的游标值(上次处理的最大 updated_at)
statement => "
SELECT
p.id,
p.name,
p.brand,
p.category,
p.price,
p.description,
p.stock,
p.is_active,
p.is_deleted,
p.tenant_id,
p.created_at,
p.updated_at,
GROUP_CONCAT(pt.tag_name) AS tags
FROM products p
LEFT JOIN product_tags pt ON p.id = pt.product_id
WHERE p.updated_at > :sql_last_value
AND p.updated_at <= NOW() -- 避免查询到当前时刻正在写入的数据
GROUP BY p.id
ORDER BY p.updated_at ASC
LIMIT 10000
"
# 游标字段:Logstash 用这个字段记录同步进度
tracking_column => "updated_at"
tracking_column_type => "timestamp"
use_column_value => true
# 游标持久化:重启后从断点继续,而不是全量重新同步
last_run_metadata_path => "/var/logstash/metadata/product_last_run"
# 每分钟执行一次(cron 格式)
schedule => "* * * * *"
# 时区处理(非常重要!否则时间字段会差8小时)
jdbc_default_timezone => "Asia/Shanghai"
# 批量处理大小(每批拉多少条)
jdbc_fetch_size => 1000
}
}
filter {
# 数据转换
mutate {
# 重命名字段(MySQL 列名 → ES 字段名)
rename => { "is_active" => "isActive" }
rename => { "is_deleted" => "isDeleted" }
# 把 tags 字符串分割成数组
split => { "tags" => "," }
# 移除 Logstash 自动添加的元数据字段
remove_field => ["@version", "tags"]
}
# 处理软删除:如果 is_deleted=true,在 ES 中也删除该文档
if [isDeleted] == 1 {
mutate {
add_field => { "[@metadata][action]" => "delete" }
}
} else {
mutate {
add_field => { "[@metadata][action]" => "index" }
}
}
# 类型转换(JDBC 拿到的数值可能是字符串,需要显式转换)
mutate {
convert => {
"price" => "float"
"stock" => "integer"
}
}
}
output {
elasticsearch {
hosts => ["https://es-node1:9200"]
user => "elastic"
password => "${ES_PASSWORD}"
ssl => true
ssl_certificate_verification => false # 自签名证书,生产用 cacert 替换
# 目标索引(利用 tenant_id 字段路由到不同索引,需要 ES 索引模板支持)
index => "tenant_%{tenant_id}_products"
# 文档 ID(用 MySQL 的主键,保证幂等)
document_id => "%{id}"
# 根据 action 字段决定是写入还是删除
action => "%{[@metadata][action]}"
# 批量写入配置
flush_size => 1000 # 积累 1000 条就写入
idle_flush_time => 10 # 最多等 10 秒就写入(保证实时性)
}
# 调试时开启,生产关闭
# stdout { codec => rubydebug }
}
3.2 Logstash 方案的局限
这个方案的关键限制是:无法捕获删除操作 (除非用软删除)和无法捕获增量快于轮询间隔的变更(如 1 分钟内同一条记录改了 10 次,只能同步最终状态,中间状态丢失)。
对于绝大多数搜索场景,这两个限制都是可以接受的------我们只需要最终状态。所以 Logstash JDBC 是中小体量、允许分钟级延迟场景的完美方案:零代码改造,配置文件搞定。
四、方案三:Canal + RocketMQ------主流生产方案
Canal 是阿里开源的 MySQL binlog 解析工具,通过伪装成 MySQL Slave,实时接收 binlog 事件,可以做到秒级甚至毫秒级的数据同步,且天然支持增删改三种操作。
4.1 架构设计
bash
MySQL Primary
│ binlog(row格式)
↓
Canal Server(伪装成 MySQL Slave)
│ 解析 binlog 事件
↓
RocketMQ Topic: es-sync-product
│ 消费
↓
ES Sync Consumer(Java 服务)
│ 数据转换 + 幂等处理
↓
Elasticsearch
为什么要加 MQ 而不是 Canal 直接写 ES?
Canal Server 本身不负责重试和容错,如果直接写 ES,ES 短暂不可用时 binlog 事件就会丢失。加入 MQ 后,Canal 只负责把事件发到 MQ(可靠投递),消费者按自己的速度处理,ES 不可用时消息堆积在 MQ,恢复后继续消费------解耦了生产和消费,提升了整体可靠性。
4.2 MySQL 配置
ini
# my.cnf ------ Canal 依赖 binlog row 格式
[mysqld]
log-bin=mysql-bin # 开启 binlog
binlog-format=ROW # 必须是 ROW 格式(记录行变更,而不是 SQL 语句)
binlog-row-image=FULL # 完整记录变更前后的所有列值(Canal 需要)
server-id=1 # 必须与 Canal 的 server-id 不同
# 为 Canal 创建专用账号
sql
-- 创建 Canal 专用 MySQL 账号(权限最小化原则)
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_password';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
4.3 Canal Server 部署(Docker)
yaml
# docker-compose-canal.yml
services:
canal-server:
image: canal/canal-server:v1.1.7
container_name: canal-server
environment:
# Canal 实例名称(对应 conf/example 目录)
- canal.destinations=product-sync
# MySQL 连接信息
- canal.instance.master.address=mysql:3306
- canal.instance.dbUsername=canal
- canal.instance.dbPassword=canal_password
# 只监听指定的库和表(减少无关 binlog 的处理)
- canal.instance.filter.regex=saas_db\\.products,saas_db\\.product_tags
# MQ 配置(Canal 把事件发到 RocketMQ)
- canal.mq.servers=rocketmq-nameserver:9876
- canal.mq.topic=es-sync-product
# 按 tenant_id 做 MQ partition,保证同一租户的事件有序
- canal.mq.partition.hash=.*\\..*:tenant_id
volumes:
- canal-logs:/home/admin/canal-server/logs
networks:
- sync-net
4.4 Canal 事件消费者(核心实现)
java
// CanalEsSyncConsumer.java
@Component
@RequiredArgsConstructor
@Slf4j
@RocketMQMessageListener(
topic = "es-sync-product",
consumerGroup = "es-sync-consumer-group",
// 顺序消费:保证同一分区(同一租户)的消息按顺序处理
consumeMode = ConsumeMode.ORDERLY,
messageModel = MessageModel.CLUSTERING
)
public class CanalEsSyncConsumer implements RocketMQListener<String> {
private final ElasticsearchClient esClient;
private final ObjectMapper objectMapper;
private final IdempotencyChecker idempotencyChecker;
/**
* 消费 Canal 发送的 binlog 事件
*
* Canal 消息格式示例:
* {
* "type": "UPDATE", // INSERT/UPDATE/DELETE
* "table": "products",
* "ts": 1705300245000, // 事件时间戳(毫秒)
* "data": [{"id":"P001","name":"..."}], // 变更后的数据
* "old": [{"name":"old name"}] // 变更前的数据(UPDATE 时有)
* }
*/
@Override
public void onMessage(String message) {
CanalMessage canalMsg;
try {
canalMsg = objectMapper.readValue(message, CanalMessage.class);
} catch (JsonProcessingException e) {
log.error("Canal 消息解析失败: {}", message, e);
// 解析失败的消息不能重试(格式问题,重试也没用),记录后丢弃
deadLetterService.save(message, "PARSE_ERROR");
return;
}
log.debug("处理 Canal 事件: type={}, table={}, ids={}",
canalMsg.getType(), canalMsg.getTable(),
canalMsg.getData().stream().map(d -> d.get("id")).collect(Collectors.toList())
);
try {
switch (canalMsg.getType()) {
case "INSERT", "UPDATE" -> handleUpsert(canalMsg);
case "DELETE" -> handleDelete(canalMsg);
default -> log.warn("未知事件类型: {}", canalMsg.getType());
}
} catch (Exception e) {
// 业务处理异常,抛出让 RocketMQ 重试
// RocketMQ 会按退避策略重试(1s, 5s, 10s, 30s...)
log.error("处理 Canal 事件失败,将重试: {}", canalMsg, e);
throw new RuntimeException("ES 同步失败,触发重试", e);
}
}
/**
* 处理 INSERT/UPDATE 事件
*
* 关键:幂等处理。
* 因为 MQ 至少一次投递(at-least-once)的语义,同一条消息可能被消费多次。
* 我们用 ES 的版本号(external_version)来保证幂等:
* 用 Canal 事件的时间戳作为版本号,只有时间戳更新的消息才能更新文档。
*/
private void handleUpsert(CanalMessage message) throws IOException {
List<Map<String, Object>> dataList = message.getData();
if (dataList.size() == 1) {
// 单条更新(最常见)
Map<String, Object> data = dataList.get(0);
String productId = (String) data.get("id");
String tenantId = (String) data.get("tenant_id");
// 幂等检查:如果这个事件已经处理过,跳过
String eventKey = productId + ":" + message.getTs();
if (idempotencyChecker.isProcessed(eventKey)) {
log.debug("重复事件,跳过: {}", eventKey);
return;
}
// 数据转换:MySQL 行数据 → ES 文档格式
Map<String, Object> esDoc = transformToEsDoc(data);
// 写入 ES,使用外部版本号保证幂等
// version_type=external:只有传入的 version > ES 中存储的 version 时才更新
esClient.index(i -> i
.index(buildIndexName(tenantId))
.id(productId)
.versionType(VersionType.External)
.version(message.getTs()) // 用 binlog 时间戳作为版本号
.document(esDoc)
);
idempotencyChecker.markProcessed(eventKey, Duration.ofHours(24));
log.debug("ES 文档更新成功: {}", productId);
} else {
// 批量更新(较少见,如 UPDATE ... WHERE ... 影响多行)
BulkRequest.Builder bulkBuilder = new BulkRequest.Builder();
for (Map<String, Object> data : dataList) {
String productId = (String) data.get("id");
String tenantId = (String) data.get("tenant_id");
Map<String, Object> esDoc = transformToEsDoc(data);
bulkBuilder.operations(op -> op
.index(idx -> idx
.index(buildIndexName(tenantId))
.id(productId)
.version(message.getTs())
.versionType(VersionType.External)
.document(esDoc)
)
);
}
BulkResponse response = esClient.bulk(bulkBuilder.build());
if (response.errors()) {
// 批量操作有失败,检查是否是版本冲突(可以忽略)
response.items().stream()
.filter(item -> item.error() != null)
.filter(item -> !"version_conflict_engine_exception"
.equals(item.error().type())) // 版本冲突是幂等的,不算错误
.forEach(item -> log.error("Bulk 写入失败: id={}, error={}",
item.id(), item.error().reason()));
}
}
}
/**
* 处理 DELETE 事件
*/
private void handleDelete(CanalMessage message) throws IOException {
for (Map<String, Object> data : message.getData()) {
String productId = (String) data.get("id");
String tenantId = (String) data.get("tenant_id");
try {
esClient.delete(d -> d
.index(buildIndexName(tenantId))
.id(productId)
);
log.debug("ES 文档删除成功: {}", productId);
} catch (ElasticsearchException e) {
if (e.response().status() == 404) {
// 文档不存在,视为已删除,幂等处理
log.debug("ES 文档已不存在,忽略删除操作: {}", productId);
} else {
throw e;
}
}
}
}
/**
* MySQL 行数据 → ES 文档格式转换
*
* 这里是很多 Bug 的高发地:
* 1. 类型转换:MySQL 的 DECIMAL 类型在 Canal 里是字符串,需要转 double
* 2. 时间格式:MySQL datetime 到 ES date 的格式转换
* 3. 布尔值:MySQL 的 TINYINT(1) 在 Canal 里是 0/1,需要转 boolean
* 4. JSON 字段:MySQL 的 JSON 类型需要额外反序列化
*/
private Map<String, Object> transformToEsDoc(Map<String, Object> mysqlRow) {
Map<String, Object> doc = new HashMap<>(mysqlRow);
// DECIMAL → double
if (doc.containsKey("price")) {
doc.put("price", Double.parseDouble(doc.get("price").toString()));
}
// TINYINT(1) → boolean
if (doc.containsKey("is_active")) {
doc.put("is_active", "1".equals(doc.get("is_active").toString()));
}
if (doc.containsKey("is_deleted")) {
doc.put("is_deleted", "1".equals(doc.get("is_deleted").toString()));
}
// 时间格式转换(Canal 输出的是 "2024-01-15 08:00:00" 格式)
// ES date 字段接受这个格式,通常不需要转换,但要确保时区正确
// 移除不需要同步的字段
doc.remove("internal_notes");
doc.remove("cost_price"); // 成本价不对外暴露
return doc;
}
private String buildIndexName(String tenantId) {
return "tenant_" + tenantId + "_products";
}
}
4.5 幂等性保障
java
// IdempotencyChecker.java ------ 基于 Redis 的幂等检查
@Component
@RequiredArgsConstructor
public class IdempotencyChecker {
private final RedisTemplate<String, String> redisTemplate;
private static final String KEY_PREFIX = "es_sync:idempotent:";
/**
* 检查事件是否已处理过
* 用 Redis SETNX 实现:设置成功说明未处理,设置失败说明已处理
*/
public boolean isProcessed(String eventKey) {
String redisKey = KEY_PREFIX + eventKey;
// GET 比 EXISTS 更准确(考虑 key 过期的边界情况)
return redisTemplate.opsForValue().get(redisKey) != null;
}
public void markProcessed(String eventKey, Duration ttl) {
String redisKey = KEY_PREFIX + eventKey;
redisTemplate.opsForValue().set(redisKey, "1", ttl);
}
/**
* 原子性检查 + 标记(推荐用这个,避免 check-then-act 的竞态条件)
* 返回 true 表示首次处理(可以继续),false 表示重复(应跳过)
*/
public boolean checkAndMark(String eventKey, Duration ttl) {
String redisKey = KEY_PREFIX + eventKey;
// SETNX:只有 key 不存在时才设置,原子操作
Boolean isNew = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", ttl);
return Boolean.TRUE.equals(isNew);
}
}
五、方案四:Flink CDC------大数据场景的实时管道
Flink CDC 是 Apache Flink 生态的变更数据捕获方案,底层使用 Debezium 读取 binlog,上层提供 Flink SQL 接口,支持复杂的流式计算和数据转换。
5.1 适用场景
Flink CDC 适合以下场景:
- 数据量极大(亿级),需要分布式并行处理
- 同步过程中需要复杂的数据转换(多表 JOIN、聚合、窗口计算)
- 已有 Flink 技术栈,统一数据管道
- 需要同时把数据同步到多个目标(ES + Kafka + 数据仓库)
5.2 Flink SQL 实现 MySQL → ES 同步
java
// FlinkCdcSyncJob.java
public class FlinkCdcSyncJob {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 设置检查点(保证 exactly-once 语义)
env.enableCheckpointing(60000); // 每 60 秒做一次 checkpoint
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30000);
// ── 创建 MySQL CDC 源表 ─────────────────────────────────────────
tableEnv.executeSql("""
CREATE TABLE mysql_products (
id STRING,
name STRING,
brand STRING,
category STRING,
price DECIMAL(10, 2),
description STRING,
stock INT,
is_active BOOLEAN,
is_deleted BOOLEAN,
tenant_id STRING,
created_at TIMESTAMP(3),
updated_at TIMESTAMP(3),
PRIMARY KEY (id) NOT ENFORCED
) WITH (
'connector' = 'mysql-cdc',
'hostname' = 'mysql',
'port' = '3306',
'username' = 'canal',
'password' = '${MYSQL_PASSWORD}',
'database-name' = 'saas_db',
'table-name' = 'products',
-- scan.startup.mode:
-- initial: 先全量扫描,再增量(适合初次同步)
-- latest-offset: 只消费最新 binlog(适合已有 ES 数据,只同步增量)
'scan.startup.mode' = 'initial',
-- 并行度(设置为 MySQL 分片数,通常是 1,因为 binlog 是串行的)
'parallelism' = '1'
)
""");
// ── 创建 ES 目标表 ──────────────────────────────────────────────
tableEnv.executeSql("""
CREATE TABLE es_products (
id STRING,
name STRING,
brand STRING,
category STRING,
price DOUBLE,
description STRING,
stock INT,
is_active BOOLEAN,
tenant_id STRING,
created_at STRING,
updated_at STRING,
PRIMARY KEY (id) NOT ENFORCED
) WITH (
'connector' = 'elasticsearch-7',
'hosts' = 'https://es-node1:9200',
'username' = 'elastic',
'password' = '${ES_PASSWORD}',
-- 动态索引名:根据 tenant_id 路由到不同索引
'index' = 'tenant_{tenant_id}_products',
'document-id.key-delimiter' = '$',
'sink.bulk-flush.max-actions' = '1000',
'sink.bulk-flush.interval' = '5s'
)
""");
// ── 数据转换 + 写入 ─────────────────────────────────────────────
tableEnv.executeSql("""
INSERT INTO es_products
SELECT
id,
name,
brand,
category,
CAST(price AS DOUBLE),
description,
stock,
is_active,
tenant_id,
DATE_FORMAT(created_at, 'yyyy-MM-dd HH:mm:ss'),
DATE_FORMAT(updated_at, 'yyyy-MM-dd HH:mm:ss')
FROM mysql_products
WHERE is_deleted = FALSE -- 过滤软删除的记录
""");
env.execute("MySQL-to-ES Sync Job");
}
}
5.3 处理 DELETE 事件
Flink CDC 的 Flink SQL 接口对 DELETE 的处理稍有特殊:
java
// Flink SQL 对 CDC 事件的处理:
// INSERT 事件 → 对应 ES 的 index 操作
// UPDATE 事件 → 对应 ES 的 index 操作(覆盖写)
// DELETE 事件 → 对应 ES 的 delete 操作
// Flink 的 ES Connector 会自动处理这三种情况,无需额外代码
// 但如果使用 DataStream API(比 SQL 更灵活),可以显式处理:
DataStream<RowData> cdcStream = // ... 从 MySQL CDC source 读取
cdcStream.addSink(new ElasticsearchSink.Builder<RowData>(
httpHosts,
new ElasticsearchSinkFunction<RowData>() {
@Override
public void process(RowData element, RuntimeContext ctx,
RequestIndexer indexer) {
RowKind rowKind = element.getRowKind();
switch (rowKind) {
case INSERT, UPDATE_AFTER -> {
// 写入或更新
IndexRequest request = Requests.indexRequest()
.index(buildIndexName(element))
.id(element.getString(0).toString()) // id 字段
.source(rowDataToMap(element), XContentType.JSON);
indexer.add(request);
}
case DELETE, UPDATE_BEFORE -> {
// 删除(UPDATE_BEFORE 是更新前的旧数据,通常不需要处理)
if (rowKind == RowKind.DELETE) {
DeleteRequest request = Requests.deleteRequest()
.index(buildIndexName(element))
.id(element.getString(0).toString());
indexer.add(request);
}
}
}
}
}
).build());
六、数据一致性保障:定期校验与修复
无论用哪种同步方案,都需要一个定期的数据校验机制,发现 MySQL 和 ES 之间的差异并修复。
java
// DataConsistencyCheckService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class DataConsistencyCheckService {
private final ProductRepository mysqlRepo;
private final ElasticsearchClient esClient;
/**
* 每天凌晨 2 点执行全量校验(选业务低峰期)
*
* 校验策略:
* 不直接比较所有字段(数据量大时无法承受),
* 而是比较关键字段(updated_at + 文档数量)来快速发现差异
*/
@Scheduled(cron = "0 0 2 * * ?")
public void dailyConsistencyCheck() {
log.info("开始 MySQL-ES 数据一致性校验");
List<String> tenantIds = tenantRepository.findAllActiveTenantIds();
for (String tenantId : tenantIds) {
try {
checkTenantConsistency(tenantId);
} catch (Exception e) {
log.error("租户 [{}] 一致性校验失败", tenantId, e);
}
}
}
private void checkTenantConsistency(String tenantId) throws IOException {
// 1. 查 MySQL:最近 24 小时有更新的商品(updated_at > 昨天同时)
LocalDateTime since = LocalDateTime.now().minusHours(25); // 多 1 小时缓冲
List<Product> mysqlProducts = mysqlRepo
.findByTenantIdAndUpdatedAtAfter(tenantId, since);
if (mysqlProducts.isEmpty()) return;
// 2. 批量查 ES:同一批商品的 updated_at
List<String> productIds = mysqlProducts.stream()
.map(Product::getId)
.collect(Collectors.toList());
MgetResponse<Map> esResponse = esClient.mget(mg -> mg
.index("tenant_" + tenantId + "_products")
.ids(productIds),
(Class<Map>) (Class<?>) Map.class
);
// 3. 比较差异
Map<String, String> esUpdatedAtMap = esResponse.docs().stream()
.filter(MultiGetResponseItem::isResult)
.filter(doc -> doc.result().found())
.collect(Collectors.toMap(
doc -> doc.result().id(),
doc -> (String) doc.result().source().get("updated_at")
));
List<String> missingInEs = new ArrayList<>();
List<String> staleInEs = new ArrayList<>();
for (Product mysqlProduct : mysqlProducts) {
String esUpdatedAt = esUpdatedAtMap.get(mysqlProduct.getId());
if (esUpdatedAt == null) {
missingInEs.add(mysqlProduct.getId()); // ES 里没有这条数据
} else if (mysqlProduct.getUpdatedAt()
.isAfter(LocalDateTime.parse(esUpdatedAt, dtFormatter))) {
staleInEs.add(mysqlProduct.getId()); // ES 里的数据比 MySQL 旧
}
}
// 4. 修复差异(重新同步)
if (!missingInEs.isEmpty() || !staleInEs.isEmpty()) {
log.warn("租户 [{}] 发现数据不一致:缺失={}, 过期={}",
tenantId, missingInEs.size(), staleInEs.size());
List<String> toSync = new ArrayList<>();
toSync.addAll(missingInEs);
toSync.addAll(staleInEs);
List<Product> productsToSync = mysqlRepo.findAllById(toSync);
productVectorIndexService.batchIndexWithVectors(productsToSync);
log.info("租户 [{}] 数据修复完成,同步 {} 条", tenantId, toSync.size());
}
}
}
七、SaaS 多租户同步的隔离设计
在 SaaS 场景里,大租户的数据量可能是小租户的 1000 倍,同步任务不能用同一个线程池处理所有租户,否则大租户会阻塞小租户的同步。
java
// TenantAwareSyncExecutor.java
@Component
public class TenantAwareSyncExecutor {
// 大租户使用独立线程池(不与小租户竞争资源)
private final ExecutorService enterprisePool = Executors.newFixedThreadPool(
10, new NamedThreadFactory("enterprise-sync")
);
// 小租户共享线程池
private final ExecutorService sharedPool = Executors.newFixedThreadPool(
20, new NamedThreadFactory("shared-sync")
);
public CompletableFuture<Void> submitSync(String tenantId,
Runnable syncTask) {
TenantPlan plan = tenantRepository.getPlan(tenantId);
ExecutorService pool = (plan == TenantPlan.ENTERPRISE)
? enterprisePool : sharedPool;
return CompletableFuture.runAsync(syncTask, pool)
.exceptionally(e -> {
log.error("租户 [{}] 同步任务失败", tenantId, e);
alertService.sendAlert("ES同步失败", tenantId, e);
return null;
});
}
}
📋 本篇小结
| 方案 | 最适用场景 | 核心优势 | 主要限制 |
|---|---|---|---|
| 双写 | 小项目快速落地 | 零依赖,最简单 | 一致性弱,需补偿机制 |
| Logstash JDBC | 中小量,允许延迟 | 零代码改造 | 不能捕获 DELETE,分钟级延迟 |
| Canal + RocketMQ | 生产主流 | 秒级,可靠,支持 DELETE | 需要部署 Canal 和 MQ |
| Flink CDC | 大数据,复杂转换 | 分布式,exactly-once | 技术栈重,门槛高 |
| Debezium | 云原生,Kafka 生态 | 与 Kafka 深度集成 | 需要 Kafka |
❓ 高频面试 & AI 问答
Q: MySQL 数据如何实时同步到 Elasticsearch?
A: 主流方案是 Canal + 消息队列。Canal 伪装成 MySQL Slave 读取 binlog,解析后发到 RocketMQ/Kafka,消费者读取消息并写入 ES。这种方式对应用无侵入,支持增删改三种操作,延迟通常在秒级以内。
Q: Canal 和 Debezium 有什么区别?怎么选?
A: Canal 是阿里开源、Java 生态友好,与 RocketMQ 配合成熟,国内用户多,文档和社区支持好;Debezium 是 Red Hat 开源,与 Kafka 深度集成,支持更多数据库类型(PostgreSQL、MongoDB 等),是云原生/Kafka 生态的首选。Java 团队用 RocketMQ 选 Canal,Kafka 生态选 Debezium。
Q: ES 数据同步如何保证幂等性?
A: 用 binlog 事件的时间戳作为 ES 文档的外部版本号(version_type=external),ES 只接受版本号更大的更新,自动拒绝过期的重复消息。同时在 Redis 里记录已处理的事件 Key(TTL 24小时),双重保障幂等。
Q: 怎么检测 MySQL 和 ES 的数据不一致?
A: 定期(如每日凌晨)运行对账任务:对最近时间段内有变更的数据(通过 updated_at 过滤),批量查询 ES 对应文档的更新时间,比较差异,对不一致的数据重新全量同步。不要全量对比所有数据,效率太低;按时间窗口滚动对比即可。
🔗 上一篇
第08篇 :ES + AI 大模型------向量检索与语义搜索全栈实战
🔗 下一篇
第10篇(终篇) :生产级 ES 运维------监控、备份、安全与故障排查完全手册
前九篇把 ES 从零到功能全部打通,最后一篇聚焦"跑稳":Prometheus + Grafana 监控体系搭建、自动快照备份策略、ES 安全 RBAC 配置、从 Yellow 到 Red 的完整故障排查手册------以及整个系列的学习路线总结与进阶方向。