知识点编号:265
难度系数:⭐⭐⭐⭐
实用指数:💯💯💯💯💯
📖 前言:一个数据的"跨界"故事
想象一下,你有一家超市(MySQL数据库),里面存放着各种商品信息:名称、价格、库存等。现在,你想开一个"搜索服务台"(ElasticSearch),让顾客能快速搜索到想要的商品。
问题来了:超市货架上的商品每天都在变化(增删改),你的搜索服务台怎么能实时知道这些变化呢?🤔
总不能每次都让员工手动跑到货架前抄写信息吧?这就是我们今天要解决的问题:如何优雅地实现MySQL到ElasticSearch的数据同步!
🎯 为什么需要MySQL到ES的数据同步?
场景1:电商搜索 🛒
- MySQL:存储商品完整信息(订单、库存、价格)
- ElasticSearch:提供快速的商品搜索功能
场景2:日志分析 📊
- MySQL:存储业务操作记录
- ElasticSearch:提供强大的日志检索和分析
场景3:数据仓库 🏢
- MySQL:业务系统的主数据库
- ElasticSearch:提供实时数据分析和报表
核心原因:
- MySQL擅长事务处理(ACID特性) ✅
- ElasticSearch擅长全文搜索和聚合分析 🔍
- 让专业的人做专业的事!💪
🎬 实现方案全景图
┌──────────────────────────────────────────────────────────┐
│ 数据同步方案 │
├──────────────────────────────────────────────────────────┤
│ │
│ 方案1: 定时全量同步 (简单粗暴) 😅 │
│ 方案2: 应用双写 (代码侵入) 😓 │
│ 方案3: Binlog订阅 (推荐⭐⭐⭐⭐⭐) │
│ └─ Canal (阿里开源) │
│ └─ Maxwell (Zendesk开源) │
│ └─ Debezium (RedHat开源) │
│ 方案4: Logstash (ELK全家桶) 🎨 │
│ │
└──────────────────────────────────────────────────────────┘
🌟 方案一:定时全量同步(小白入门版)
原理图:
┌─────────┐ 定时任务 ┌────────┐ 批量写入 ┌──────┐
│ MySQL │ ───────────→ │ 应用 │ ───────────→ │ ES │
│ (主库) │ SELECT * │ │ Bulk API │ │
└─────────┘ └────────┘ └──────┘
↓
每5分钟执行一次
生活比喻 🏪:
就像你每隔5分钟跑到超市货架前,把所有商品信息抄一遍,然后更新到搜索服务台。
实现代码:
@Scheduled(cron = "0 */5 * * * ?") // 每5分钟执行一次
public void syncData() {
// 1. 从MySQL查询所有数据
List<Product> products = productMapper.selectAll();
// 2. 批量写入ES
BulkRequest bulkRequest = new BulkRequest();
for (Product product : products) {
IndexRequest request = new IndexRequest("products")
.id(product.getId().toString())
.source(JSON.toJSONString(product), XContentType.JSON);
bulkRequest.add(request);
}
// 3. 执行批量操作
restHighLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT);
log.info("同步完成,共同步{}条数据", products.size());
}
优缺点分析:
| 维度 | 评价 | 说明 |
|---|---|---|
| 实现难度 | ⭐ | 极简单,新手友好 |
| 实时性 | ❌ | 有延迟(取决于定时间隔) |
| 性能 | ❌❌ | 每次全量扫描,数据库压力大 |
| 数据一致性 | ⚠️ | 可能出现短暂不一致 |
| 适用场景 | 📦 | 数据量小、实时性要求不高 |
🎪 方案二:应用双写(代码侵入版)
原理图:
写请求
↓
┌────────┐
│ 应用 │
└────┬───┘
│
┌────┴────┐
↓ ↓
┌───────┐ ┌──────┐
│ MySQL │ │ ES │
└───────┘ └──────┘
(同步写) (同步写)
生活比喻 📝:
你在超市添加新商品时,既要在货架上摆放(MySQL),也要在搜索服务台记录(ES)。
实现代码:
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Autowired
private RestHighLevelClient esClient;
@Transactional
public void addProduct(Product product) {
try {
// 1. 写入MySQL
productMapper.insert(product);
// 2. 写入ES
IndexRequest request = new IndexRequest("products")
.id(product.getId().toString())
.source(JSON.toJSONString(product), XContentType.JSON);
esClient.index(request, RequestOptions.DEFAULT);
log.info("商品添加成功,id={}", product.getId());
} catch (Exception e) {
log.error("商品添加失败", e);
throw new RuntimeException("添加商品失败");
}
}
@Transactional
public void updateProduct(Product product) {
// 1. 更新MySQL
productMapper.updateById(product);
// 2. 更新ES
try {
UpdateRequest request = new UpdateRequest("products",
product.getId().toString())
.doc(JSON.toJSONString(product), XContentType.JSON);
esClient.update(request, RequestOptions.DEFAULT);
} catch (Exception e) {
log.error("ES更新失败", e);
// 异步补偿机制
sendToRetryQueue(product);
}
}
}
优缺点分析:
| 维度 | 评价 | 说明 |
|---|---|---|
| 实现难度 | ⭐⭐ | 需要改造业务代码 |
| 实时性 | ✅ | 实时同步 |
| 性能 | ⚠️ | 每次操作要写两个库 |
| 数据一致性 | ⚠️ | 可能出现双写不一致 |
| 代码侵入性 | ❌ | 业务代码耦合严重 |
常见问题 ⚠️:
问题1:MySQL写成功,ES写失败怎么办?
// 解决方案:引入重试队列
@Async
public void sendToRetryQueue(Product product) {
// 发送到MQ进行异步重试
rabbitTemplate.convertAndSend("es-retry-queue", product);
}
问题2:事务怎么办?
- MySQL的事务无法覆盖ES
- 需要引入最终一致性方案(后面会讲)
🎉 方案三:Binlog订阅(业界最佳实践⭐⭐⭐⭐⭐)
什么是Binlog?📚
Binlog(Binary Log) = MySQL的"小本本"📔
想象MySQL是一个勤奋的会计,它会把每一笔账(数据变更)都记录在小本本上:
- 张三买了一件商品 ✍️
- 李四修改了地址 ✍️
- 王五删除了订单 ✍️
这个小本本就是Binlog!
Binlog的三种格式:
| 格式 | 记录内容 | 优点 | 缺点 |
|---|---|---|---|
| Statement | SQL语句 | 日志量小 | 可能导致主从不一致 |
| Row | 数据行变化 | 数据准确 | 日志量大 |
| Mixed | 混合模式 | 平衡 | 复杂度高 |
数据同步推荐使用:Row格式 ✅
Canal:Binlog订阅神器 🔧
Canal是什么? Canal是阿里巴巴开源的,基于MySQL Binlog的增量订阅&消费组件。
形象比喻: Canal就像一个"邮递员"📮,专门负责监听MySQL的小本本(Binlog),一旦有新内容,就立即送到ES那里。
Canal工作原理:
┌──────────────────────────────────────────────────────────┐
│ Canal 工作原理 │
└──────────────────────────────────────────────────────────┘
┌─────────────┐
│ MySQL │
│ 主库 │
│ │
│ Binlog ━━━━┿━━━━━━━━┓
└─────────────┘ ║
║ 1. Canal伪装成MySQL从库
↓
┌───────────────┐
│ Canal Server │
│ (订阅Binlog) │
└───────┬───────┘
│
│ 2. 解析Binlog事件
│ - INSERT
│ - UPDATE
│ - DELETE
↓
┌───────────────┐
│ Canal Client │
│ (业务消费) │
└───────┬───────┘
│
│ 3. 写入ES
↓
┌──────────┐
│ ES │
└──────────┘
Canal详细配置步骤 🛠️
步骤1:MySQL开启Binlog
编辑MySQL配置文件my.cnf:
[mysqld]
# 开启binlog
log-bin=mysql-bin
# 选择Row模式
binlog-format=ROW
# 设置server-id
server-id=1
重启MySQL:
systemctl restart mysqld
验证Binlog是否开启:
mysql> SHOW VARIABLES LIKE 'log_bin';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_bin | ON |
+---------------+-------+
mysql> SHOW MASTER STATUS;
+------------------+----------+--------------+------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB |
+------------------+----------+--------------+------------------+
| mysql-bin.000001 | 154 | | |
+------------------+----------+--------------+------------------+
步骤2:创建Canal专用账号
-- 创建canal用户
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal123';
-- 授权
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
-- 刷新权限
FLUSH PRIVILEGES;
步骤3:部署Canal Server
Docker方式(推荐):
# 拉取镜像
docker pull canal/canal-server:latest
# 启动Canal
docker run -d \
--name canal-server \
-p 11111:11111 \
-e canal.instance.master.address=192.168.1.100:3306 \
-e canal.instance.dbUsername=canal \
-e canal.instance.dbPassword=canal123 \
-e canal.instance.filter.regex=.*\..* \
canal/canal-server:latest
传统方式:
# 1. 下载Canal
wget https://github.com/alibaba/canal/releases/download/canal-1.1.6/canal.deployer-1.1.6.tar.gz
# 2. 解压
mkdir canal && tar -zxvf canal.deployer-1.1.6.tar.gz -C canal
# 3. 修改配置
cd canal/conf/example
vi instance.properties
修改instance.properties:
# MySQL连接配置
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal123
# 订阅的表(支持正则)
# 示例:只订阅test库的所有表
canal.instance.filter.regex=test\..*
# Binlog位置(首次同步)
canal.instance.master.journal.name=
canal.instance.master.position=
canal.instance.master.timestamp=
启动Canal:
sh bin/startup.sh
查看日志:
tail -f logs/canal/canal.log
tail -f logs/example/example.log
步骤4:编写Canal客户端代码
添加依赖:
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.6</version>
</dependency>
客户端代码:
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry.*;
import com.alibaba.otter.canal.protocol.Message;
import com.google.protobuf.InvalidProtocolBufferException;
import org.elasticsearch.action.delete.DeleteRequest;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.update.UpdateRequest;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.xcontent.XContentType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.net.InetSocketAddress;
import java.util.List;
@Component
public class CanalClient {
@Autowired
private RestHighLevelClient esClient;
private CanalConnector connector;
@PostConstruct
public void init() {
// 连接Canal Server
connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("127.0.0.1", 11111),
"example", // destination名称
"", // 用户名(空表示不需要)
"" // 密码
);
}
public void start() {
try {
// 1. 连接Canal
connector.connect();
// 2. 订阅(可以指定具体的表)
connector.subscribe("test\.product");
// 3. 回滚到未消费的位置
connector.rollback();
while (true) {
// 4. 获取数据(一次最多100条)
Message message = connector.getWithoutAck(100);
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
Thread.sleep(1000);
} else {
// 5. 处理数据
handleMessage(message.getEntries());
// 6. 确认消费
connector.ack(batchId);
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
connector.disconnect();
}
}
private void handleMessage(List<Entry> entries) {
for (Entry entry : entries) {
// 只处理行变更事件
if (entry.getEntryType() != EntryType.ROWDATA) {
continue;
}
try {
// 解析数据
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
EventType eventType = rowChange.getEventType();
String tableName = entry.getHeader().getTableName();
// 处理不同类型的事件
for (RowData rowData : rowChange.getRowDatasList()) {
switch (eventType) {
case INSERT:
handleInsert(tableName, rowData);
break;
case UPDATE:
handleUpdate(tableName, rowData);
break;
case DELETE:
handleDelete(tableName, rowData);
break;
default:
break;
}
}
} catch (InvalidProtocolBufferException e) {
e.printStackTrace();
}
}
}
// 处理INSERT事件
private void handleInsert(String tableName, RowData rowData) {
try {
// 获取插入后的数据
List<Column> columns = rowData.getAfterColumnsList();
String id = null;
JSONObject json = new JSONObject();
for (Column column : columns) {
if (column.getIsKey()) {
id = column.getValue();
}
json.put(column.getName(), column.getValue());
}
// 写入ES
IndexRequest request = new IndexRequest(tableName)
.id(id)
.source(json.toJSONString(), XContentType.JSON);
esClient.index(request, RequestOptions.DEFAULT);
System.out.println("✅ INSERT: " + json.toJSONString());
} catch (Exception e) {
e.printStackTrace();
}
}
// 处理UPDATE事件
private void handleUpdate(String tableName, RowData rowData) {
try {
// 获取更新后的数据
List<Column> columns = rowData.getAfterColumnsList();
String id = null;
JSONObject json = new JSONObject();
for (Column column : columns) {
if (column.getIsKey()) {
id = column.getValue();
}
json.put(column.getName(), column.getValue());
}
// 更新ES
UpdateRequest request = new UpdateRequest(tableName, id)
.doc(json.toJSONString(), XContentType.JSON)
.docAsUpsert(true); // 如果不存在则插入
esClient.update(request, RequestOptions.DEFAULT);
System.out.println("🔄 UPDATE: " + json.toJSONString());
} catch (Exception e) {
e.printStackTrace();
}
}
// 处理DELETE事件
private void handleDelete(String tableName, RowData rowData) {
try {
// 获取删除前的数据
List<Column> columns = rowData.getBeforeColumnsList();
String id = null;
for (Column column : columns) {
if (column.getIsKey()) {
id = column.getValue();
break;
}
}
// 从ES删除
DeleteRequest request = new DeleteRequest(tableName, id);
esClient.delete(request, RequestOptions.DEFAULT);
System.out.println("❌ DELETE: id=" + id);
} catch (Exception e) {
e.printStackTrace();
}
}
}
Canal优缺点分析:
| 维度 | 评价 | 说明 |
|---|---|---|
| 实现难度 | ⭐⭐⭐ | 需要部署Canal Server |
| 实时性 | ✅✅ | 毫秒级延迟 |
| 性能 | ✅✅ | 对MySQL无压力 |
| 数据一致性 | ✅✅ | 准实时一致 |
| 代码侵入性 | ✅✅ | 业务代码无感知 |
| 可靠性 | ✅✅ | 支持HA、断点续传 |
🎨 方案四:Logstash-JDBC(ELK全家桶)
原理图:
┌─────────┐ JDBC轮询 ┌───────────┐ 输出插件 ┌──────┐
│ MySQL │ ─────────→ │ Logstash │ ─────────→ │ ES │
└─────────┘ └───────────┘ └──────┘
配置示例:
# logstash-mysql.conf
input {
jdbc {
jdbc_driver_library => "/path/to/mysql-connector-java.jar"
jdbc_driver_class => "com.mysql.jdbc.Driver"
jdbc_connection_string => "jdbc:mysql://localhost:3306/test"
jdbc_user => "root"
jdbc_password => "password"
# SQL查询(增量同步)
statement => "SELECT * FROM product WHERE update_time > :sql_last_value"
# 定时执行(每分钟)
schedule => "* * * * *"
# 记录上次同步位置
use_column_value => true
tracking_column => "update_time"
tracking_column_type => "timestamp"
last_run_metadata_path => ".logstash_jdbc_last_run"
}
}
filter {
# 数据转换(可选)
mutate {
remove_field => ["@version", "@timestamp"]
}
}
output {
elasticsearch {
hosts => ["localhost:9200"]
index => "products"
document_id => "%{id}"
}
# 同时输出到控制台(调试用)
stdout {
codec => json_lines
}
}
启动Logstash:
bin/logstash -f logstash-mysql.conf
优缺点分析:
| 维度 | 评价 | 说明 |
|---|---|---|
| 实现难度 | ⭐⭐ | 配置简单 |
| 实时性 | ⚠️ | 取决于轮询间隔 |
| 性能 | ⚠️ | 定期全表扫描压力大 |
| 数据一致性 | ✅ | 基于时间戳的增量同步 |
| 适用场景 | 📊 | 数据分析、日志收集 |
🎯 方案对比总结
| 方案 | 实时性 | 性能 | 复杂度 | 推荐指数 | 适用场景 |
|---|---|---|---|---|---|
| 定时全量 | ❌ 差 | ❌ 差 | ⭐ 简单 | ⭐⭐ | 数据量小、离线场景 |
| 应用双写 | ✅ 好 | ⚠️ 一般 | ⭐⭐ 中等 | ⭐⭐⭐ | 新项目、可控代码 |
| Canal/Binlog | ✅✅ 很好 | ✅✅ 很好 | ⭐⭐⭐ 较复杂 | ⭐⭐⭐⭐⭐ | 生产推荐 |
| Logstash | ⚠️ 一般 | ⚠️ 一般 | ⭐⭐ 中等 | ⭐⭐⭐ | 已有ELK环境 |
🚨 常见问题与解决方案
问题1:数据一致性如何保证?
挑战:
- MySQL写成功,ES写失败 ❌
- 网络抖动导致重复消费 🔁
- Canal宕机后数据丢失 💥
解决方案:
1) 幂等性设计
// 使用唯一ID,ES会自动处理重复写入
IndexRequest request = new IndexRequest("products")
.id(product.getId().toString()) // 指定ID
.source(json);
2) 重试机制
@Retryable(
value = {IOException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public void syncToES(Product product) {
// ES写入逻辑
}
3) 死信队列
@RabbitListener(queues = "es-sync-queue")
public void handleMessage(Product product) {
try {
syncToES(product);
} catch (Exception e) {
// 重试3次后放入死信队列
rabbitTemplate.convertAndSend("es-dead-letter-queue", product);
}
}
问题2:全量数据如何初始化?
场景:项目上线前,MySQL已有1000万条历史数据。
解决方案:
方案A:使用Logstash进行全量导入
# 一次性全量导入
input {
jdbc {
statement => "SELECT * FROM product"
schedule => "* * * * *"
}
}
方案B:自己写脚本批量导入
public class BatchSyncService {
private static final int BATCH_SIZE = 1000;
public void fullSync() {
long total = productMapper.selectCount(null);
long pages = (total + BATCH_SIZE - 1) / BATCH_SIZE;
for (long page = 0; page < pages; page++) {
// 分页查询
List<Product> products = productMapper.selectPage(
new Page<>(page, BATCH_SIZE)
).getRecords();
// 批量写入ES
BulkRequest bulkRequest = new BulkRequest();
for (Product product : products) {
bulkRequest.add(new IndexRequest("products")
.id(product.getId().toString())
.source(JSON.toJSONString(product), XContentType.JSON));
}
esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
log.info("同步进度:{}/{}", page + 1, pages);
// 休息一下,避免打爆ES
Thread.sleep(100);
}
log.info("✅ 全量同步完成!");
}
}
问题3:如何监控同步状态?
监控指标:
- Canal消费延迟(秒)
- ES写入成功率(%)
- 同步失败次数(次)
- 数据一致性(MySQL vs ES数量)
实现示例:
@Component
public class SyncMonitor {
@Scheduled(cron = "0 */5 * * * ?")
public void checkConsistency() {
// 1. 查询MySQL总数
long mysqlCount = productMapper.selectCount(null);
// 2. 查询ES总数
CountRequest countRequest = new CountRequest("products");
long esCount = esClient.count(countRequest, RequestOptions.DEFAULT)
.getCount();
// 3. 对比差异
long diff = Math.abs(mysqlCount - esCount);
if (diff > 100) {
// 发送告警
alertService.sendAlert(
"数据同步异常",
String.format("MySQL: %d, ES: %d, 差异: %d",
mysqlCount, esCount, diff)
);
}
log.info("数据一致性检查: MySQL={}, ES={}, Diff={}",
mysqlCount, esCount, diff);
}
}
问题4:如何处理大字段?
场景:商品详情字段有HTML内容,达到100KB。
问题:
- ES索引速度慢 🐌
- 占用大量存储空间 💾
解决方案:
方案A:字段拆分
// MySQL存储完整数据
@Data
public class Product {
private Long id;
private String name;
private String detail; // 详情(大字段)
}
// ES只存储搜索必要字段
@Data
public class ProductES {
private Long id;
private String name;
private String summary; // 摘要(小字段)
}
方案B:禁用索引
PUT /products
{
"mappings": {
"properties": {
"id": {"type": "long"},
"name": {"type": "text"},
"detail": {
"type": "text",
"index": false // 不建索引,只存储
}
}
}
}
问题5:DDL变更如何处理?
场景:MySQL表结构变更(新增字段、修改字段类型)。
挑战:
- Canal能感知DDL吗?✅ 能!
- ES的Mapping需要手动更新吗?⚠️ 看情况
解决方案:
自动感知DDL
// Canal可以订阅DDL事件
if (entry.getEntryType() == EntryType.DDLENTRY) {
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
String sql = rowChange.getSql();
log.warn("检测到DDL变更: {}", sql);
// 发送通知
notifyService.sendDingTalk("⚠️ 数据库表结构变更: " + sql);
}
ES Mapping更新策略
# 1. 新增字段(ES会自动识别)
# 无需操作,ES动态映射会自动添加
# 2. 修改字段类型(需要重建索引)
# 步骤:
# a) 创建新索引
PUT /products_v2
{
"mappings": {
"properties": {
"price": {"type": "double"} # 原来是long
}
}
}
# b) 数据迁移
POST /_reindex
{
"source": {"index": "products"},
"dest": {"index": "products_v2"}
}
# c) 切换别名
POST /_aliases
{
"actions": [
{"remove": {"index": "products_v1", "alias": "products"}},
{"add": {"index": "products_v2", "alias": "products"}}
]
}
🎓 生产环境最佳实践
1️⃣ 架构设计
┌──────────────────────────────────────────────────────────┐
│ 高可用架构设计 │
└──────────────────────────────────────────────────────────┘
┌─────────────┐
│MySQL 主库 │
└──────┬──────┘
│ Binlog
┌──────┴──────┐
↓ ↓
┌───────────┐ ┌───────────┐
│ Canal │ │ Canal │ ← 高可用(HA)
│ Server1 │ │ Server2 │
└─────┬─────┘ └─────┬─────┘
│ │
└──────┬──────┘
↓
┌────────────┐
│ MQ │ ← 削峰+解耦
│ (Kafka) │
└─────┬──────┘
│
┌─────┴──────┐
↓ ↓
┌──────────┐ ┌──────────┐
│Consumer 1│ │Consumer 2│ ← 并行消费
└────┬─────┘ └────┬─────┘
│ │
└──────┬─────┘
↓
┌──────────────┐
│ ES Cluster │
│ (3节点) │
└──────────────┘
2️⃣ 性能优化
批量写入ES
private static final int BATCH_SIZE = 500;
private List<IndexRequest> batchBuffer = new ArrayList<>();
public void syncToES(Product product) {
IndexRequest request = new IndexRequest("products")
.id(product.getId().toString())
.source(JSON.toJSONString(product), XContentType.JSON);
batchBuffer.add(request);
// 达到批次大小或超时,批量提交
if (batchBuffer.size() >= BATCH_SIZE) {
flush();
}
}
private void flush() {
if (batchBuffer.isEmpty()) return;
BulkRequest bulkRequest = new BulkRequest();
batchBuffer.forEach(bulkRequest::add);
try {
BulkResponse response = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
if (response.hasFailures()) {
// 处理失败
handleFailures(response);
}
} catch (Exception e) {
log.error("批量写入ES失败", e);
} finally {
batchBuffer.clear();
}
}
异步写入
@Configuration
public class AsyncConfig {
@Bean(name = "esExecutor")
public Executor esExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("es-sync-");
executor.initialize();
return executor;
}
}
@Service
public class AsyncSyncService {
@Async("esExecutor")
public void asyncSyncToES(Product product) {
// 异步写入ES
}
}
3️⃣ 监控告警
@Component
public class CanalMetrics {
@Autowired
private MeterRegistry meterRegistry;
// 消费延迟
private Timer syncLatency;
// 失败次数
private Counter failCounter;
@PostConstruct
public void init() {
syncLatency = Timer.builder("canal.sync.latency")
.description("Canal同步延迟")
.register(meterRegistry);
failCounter = Counter.builder("canal.sync.fail")
.description("Canal同步失败次数")
.register(meterRegistry);
}
public void recordSuccess(long latencyMs) {
syncLatency.record(latencyMs, TimeUnit.MILLISECONDS);
}
public void recordFailure() {
failCounter.increment();
}
}
4️⃣ 数据校验
@Component
public class DataVerifier {
/**
* 数据一致性校验
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
public void verify() {
// 1. 抽样校验
List<Long> sampleIds = randomSampleIds(1000);
int inconsistentCount = 0;
for (Long id : sampleIds) {
Product mysql = productMapper.selectById(id);
ProductES es = searchFromES(id);
if (!isConsistent(mysql, es)) {
inconsistentCount++;
log.warn("数据不一致: id={}", id);
// 自动修复
syncToES(mysql);
}
}
// 2. 发送报告
String report = String.format(
"数据校验完成: 抽样%d条,不一致%d条,一致率%.2f%%",
sampleIds.size(),
inconsistentCount,
(1 - inconsistentCount * 1.0 / sampleIds.size()) * 100
);
log.info(report);
notifyService.sendReport(report);
}
}
📝 总结
关键要点 🎯
-
Canal是生产环境首选方案 ⭐⭐⭐⭐⭐
- 非侵入式
- 高性能
- 准实时
- 高可用
-
核心组件
- MySQL Binlog(数据源)
- Canal Server(订阅解析)
- MQ(削峰解耦)
- Consumer(业务消费)
- ElasticSearch(目标存储)
-
三大挑战
- 数据一致性 → 幂等性 + 重试 + 校验
- 性能优化 → 批量 + 异步 + 并行
- 高可用 → HA + 监控 + 告警
-
生活类比
- MySQL = 超市货架 🏪
- Binlog = 进货日志 📔
- Canal = 搬运工 🚚
- MQ = 中转仓库 📦
- ES = 搜索服务台 🔍
🎉 彩蛋:面试官可能追问的问题
Q1:Canal宕机了怎么办?
A:Canal支持HA模式,使用ZooKeeper做选主,同时记录消费位点(binlog position),重启后可以从断点继续消费。
Q2:如果Binlog被删除了呢?
A:定期做全量同步兜底,同时监控binlog保留时间,确保足够长(建议7天以上)。
Q3:性能瓶颈在哪里?
A:通常在ES写入端,可以通过批量写入、增加Consumer数量、优化ES配置来提升。
Q4:如何保证数据100%一致?
A:理论上无法100%保证,但可以通过定时校验+自动修复,达到99.99%的一致性。
📚 参考资料
💡 温馨提示: 数据同步不是银弹,选择合适的方案比追求完美更重要!根据业务场景、团队能力、成本预算综合考虑。
加油,打工人! 💪💪💪
⭐ 如果觉得有帮助,别忘了给个Star哦!⭐
📧 有问题欢迎提Issue交流!
🎓 学习路上,我们一起成长!