先搞清楚一件事:为什么要有 ES 数据同步?
想象一下,你开了一家图书馆:
- MySQL 是图书管理员,负责把书一本本摆好、登记在册(增删改查、事务保证)
- Elasticsearch 是图书检索系统,用户输入"三体",0.01 秒就能告诉你第几排第几架(全文检索、聚合分析)
问题来了:管理员刚上架了一本新书,检索系统怎么立刻知道这本书的存在?这就是数据同步要解决的事。
方案一:同步双写------"一边记账一边贴标签"
场景
你开了个电商网站,用户下单后,订单既要存到 MySQL(方便财务对账),又要同步到 ES(方便客服搜索订单)。
代码
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RestHighLevelClient esClient;
@Transactional
public void createOrder(Order order) {
// 第一步:写入 MySQL
orderMapper.insert(order);
// 第二步:写入 ES
IndexRequest request = new IndexRequest("orders")
.id(order.getId().toString())
.source(JSON.toJSONString(order), XContentType.JSON);
esClient.index(request, RequestOptions.DEFAULT);
}
}
这就像你一边记账一边贴标签,看起来简单直接,但问题很大:
- 耦合严重:每个写 MySQL 的地方都要加一段 ES 代码,改一个接口要改三处
- 性能暴跌:本来写 MySQL 只要 10ms,现在还要等 ES 响应,接口响应时间翻倍
- 数据不一致:MySQL 写成功了,ES 突然宕机了,数据就丢了。你总不能让用户"订单创建失败,因为搜索引擎挂了"吧?
结论:小项目玩玩可以,生产环境慎用。和redis也会有这样那样的问题
方案二:异步双写(MQ)------"写完账扔纸条给同事"
场景
还是电商订单,但这次我们学聪明了:写完 MySQL 就返回成功,同步 ES 的事交给别人干。
架构
用户下单 → 写入 MySQL → 发送 MQ 消息 → 返回成功给用户
↓
消费者拿到消息 → 写入 ES
代码
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Transactional
public void createOrder(Order order) {
// 第一步:写入 MySQL
orderMapper.insert(order);
// 第二步:发 MQ 消息(事务提交后自动发送)
kafkaTemplate.send("order-sync-topic", order.getId().toString());
// 方法结束,事务提交,用户已经收到"下单成功"
}
}
// 消费者:异步同步到 ES
@Component
public class OrderEsSyncConsumer {
@Autowired
private RestHighLevelClient esClient;
@Autowired
private OrderMapper orderMapper;
@KafkaListener(topics = "order-sync-topic")
public void syncToEs(String orderId) {
try {
// 根据 ID 查询完整订单数据
Order order = orderMapper.selectById(orderId);
// 写入 ES
IndexRequest request = new IndexRequest("orders")
.id(orderId)
.source(JSON.toJSONString(order), XContentType.JSON);
esClient.index(request, RequestOptions.DEFAULT);
} catch (Exception e) {
// 写入失败,抛异常触发 MQ 重试
throw new RuntimeException("ES 同步失败", e);
}
}
}
这就像你写完账扔了张纸条给同事,让他帮你贴标签。好处是:
- 解耦:ES 挂了不影响用户下单
- 性能好:用户不用等 ES 响应
- 可重试:同事贴标签失败了,纸条还在,可以重试
但要注意几个坑:
- 顺序问题 :如果用户先创建订单,然后立刻修改地址,两条消息可能乱序到达。解决:用
orderId作为 Kafka 分区键,保证同一个订单的消息顺序消费 - 幂等问题 :同一条消息可能被消费多次。解决:ES 写入时用
id作为文档 ID,重复写入会自动覆盖
方案三:Canal 监听 Binlog------"偷偷看管理员的记账本"
场景
这是企业级最主流的方案。你的系统已经很庞大了,有几十个微服务都在写 MySQL,你不可能让每个服务都去发 MQ 消息。怎么办?
答案是:直接监听 MySQL 的 binlog 日志,谁改了数据我都知道。
MySQL → 开启 binlog → Canal(伪装成 MySQL 从库)→ 解析 binlog → 发送到 MQ → 消费者写入 ES
配置
第一步:MySQL 开启 binlog
-- my.cnf 配置
[mysqld]
log-bin=mysql-bin
binlog-format=ROW
server-id=1
第二步:Canal 配置
# canal.properties
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.mq.topic=canal-mysql-sync
第三步:消费者代码
@Component
public class CanalEsSyncConsumer {
@Autowired
private RestHighLevelClient esClient;
@KafkaListener(topics = "canal-mysql-sync")
public void syncToEs(String canalMessage) {
// 解析 Canal 消息
CanalEntry.Entry entry = CanalEntry.Entry.parseFrom(canalMessage);
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
String tableName = entry.getHeader().getTableName();
CanalEntry.EventType eventType = rowChange.getEventType();
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
if (eventType == CanalEntry.EventType.INSERT ||
eventType == CanalEntry.EventType.UPDATE) {
// 新增或更新:写入 ES
String json = parseRowDataToJson(rowData.getAfterColumnsList());
IndexRequest request = new IndexRequest(tableName)
.id(extractId(rowData.getAfterColumnsList()))
.source(json, XContentType.JSON);
esClient.index(request, RequestOptions.DEFAULT);
} else if (eventType == CanalEntry.EventType.DELETE) {
// 删除:从 ES 删除
DeleteRequest request = new DeleteRequest(tableName)
.id(extractId(rowData.getBeforeColumnsList()));
esClient.delete(request, RequestOptions.DEFAULT);
}
}
}
}
这就像你偷偷在管理员的记账本旁边装了个摄像头,不管谁改了账本,你都能看到。好处是:
- 零侵入:业务代码完全不用改,新增一个微服务也不用管 ES 同步的事
- 高实时:binlog 是实时产生的,延迟在毫秒级
- 可靠:binlog 是 MySQL 原生的,不会丢数据
结论:中大型项目首选方案。
方案四:Logstash 定时拉取------"每隔几分钟去翻一遍账本"
场景
你对实时性要求不高,比如用户行为日志分析,T+1 出报表就行。
配置
input {
jdbc {
jdbc_driver => "com.mysql.jdbc.Driver"
jdbc_url => "jdbc:mysql://localhost:3306/log_db"
jdbc_user => "root"
jdbc_password => "123456"
schedule => "*/5 * * * *" # 每5分钟执行一次
statement => "SELECT * FROM user_log WHERE update_time > :sql_last_value"
use_column_value => true
tracking_column => "update_time"
}
}
output {
elasticsearch {
hosts => ["http://es-host:9200"]
index => "user_logs"
document_id => "%{id}"
}
}
这就像你每隔 5 分钟去翻一遍账本,把新记录抄到检索卡片上。好处是零代码改造,坏处是:
- 延迟高:最多延迟 5 分钟
- 数据库压力大 :每次都要全表扫描(虽然有
update_time索引优化)
结论:适合离线分析、报表类场景。
方案五:DataX 批量同步------"搬家式迁移"
场景
你要把历史数据从 MySQL 迁移到 ES,比如老系统升级,10 亿条订单数据要搬过去。
配置
{
"job": {
"content": [
{
"reader": {
"name": "mysqlreader",
"parameter": {
"username": "root",
"password": "123456",
"connection": [
{
"querySql": ["SELECT * FROM orders WHERE create_time >= '2024-01-01'"],
"jdbcUrl": ["jdbc:mysql://localhost:3306/order_db"]
}
],
"splitPk": "id"
}
},
"writer": {
"name": "elasticsearchwriter",
"parameter": {
"endpoint": "http://es-host:9200",
"index": "orders",
"type": "_doc",
"batchSize": 1000,
"splitter": ","
}
}
}
],
"setting": {
"speed": {
"channel": 5
}
}
}
}
这就像搬家公司,一次性把整个仓库的东西搬走。适合大数据量迁移,但不适合实时同步
| 方案 | 实时性 | 侵入性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 同步双写 | 毫秒级 | 高 | 低 | 小项目、简单业务 |
| MQ 异步双写 | 秒级 | 中 | 中 | 中型分布式系统 |
| Canal 监听 Binlog | 毫秒级 | 低 | 中高 | 企业级首选 |
| Logstash 定时拉取 | 分钟级 | 无 | 低 | 离线分析、报表 |
| DataX 批量同步 | 一次性 | 无 | 低 | 历史数据迁移 |
生产环境必须遵守的 4 条铁律
铁律一:ES 写入必须幂等
// ✅ 正确:指定文档 ID,重复写入自动覆盖
IndexRequest request = new IndexRequest("orders")
.id(order.getId().toString()) // 指定 ID
.source(json, XContentType.JSON);
铁律二:必须处理顺序问题
// 发送消息时,用业务 ID 作为分区键
kafkaTemplate.send("order-sync-topic", orderId, orderJson);
// 这样同一个 orderId 的消息永远进入同一个分区,保证顺序
铁律三:必须定期校对数据
-- 每天凌晨跑一次校对脚本
SELECT COUNT(*) FROM orders; -- MySQL 数据量
GET /orders/_count; -- ES 数据量
-- 如果不一致,触发全量修复
铁律四:ES 故障时要有降级方案
@KafkaListener(topics = "order-sync-topic")
public void syncToEs(String orderId) {
int retryCount = 0;
while (retryCount < 3) {
try {
// 写入 ES
esClient.index(request, RequestOptions.DEFAULT);
return; // 成功则退出
} catch (Exception e) {
retryCount++;
if (retryCount == 3) {
// 三次失败,写入失败队列,人工介入
log.error("ES 同步失败,写入死信队列: {}", orderId, e);
deadLetterQueue.add(orderId);
}
Thread.sleep(1000 * retryCount); // 指数退避
}
}
}
一句话总结
- 小项目:同步双写,简单粗暴
- 中型项目:MQ 异步双写,解耦可靠
- 大型项目:Canal 监听 Binlog,零侵入、高实时
- 离线分析:Logstash 定时拉取,零代码改造
- 数据迁移:DataX 批量同步,搬家式处理