在Web应用开发中,我们经常遇到这样的场景:用户提交的数据需要同时写入MySQL数据库和Elasticsearch(ES)搜索引擎。MySQL作为关系型数据库负责数据的持久化存储和事务处理,而ES则提供高效的全文检索能力。这两套系统如何保持数据一致性,同时满足实时搜索需求,成为开发者面临的关键技术难题。
我最近在重构一个电商平台的商品管理系统时,就遇到了这个典型问题。当商家后台修改商品信息后,前端用户搜索商品时偶尔会出现"数据不同步"的情况——MySQL中已更新的价格在ES搜索结果中仍显示旧值。更棘手的是,业务方要求搜索结果的延迟必须控制在毫秒级。
在解决MySQL与ES数据同步问题时,业界主要有以下几种方案:
双写模式:应用层同时向MySQL和ES写入数据
定时同步:定期全量/增量同步MySQL数据到ES
基于binlog的同步:通过解析MySQL二进制日志触发ES更新
消息队列异步处理:通过消息队列解耦写入过程
针对毫秒级搜索和数据一致性的双重需求,我们最终选择了"双写+消息队列补偿"的混合方案。这个方案的核心思路是:
选择这个方案主要基于以下考虑:
整体架构分为三层:
code复制应用服务 → MySQL → Binlog监听 → 消息队列 → 补偿服务 → ES
↘___________直接写入___________↗
php复制class ProductService {
public function updateProduct($productId, $data) {
// 开启MySQL事务
DB::beginTransaction();
try {
// 1. 更新MySQL
$product = Product::find($productId);
$product->fill($data);
$product->version += 1; // 乐观锁版本控制
$product->save();
// 2. 同步更新ES
$esClient = new ElasticsearchClient();
$esParams = [
'index' => 'products',
'id' => $productId,
'body' => [
'doc' => $data,
'doc_as_upsert' => true
]
];
$esResponse = $esClient->update($esParams);
// 3. 提交事务
DB::commit();
return true;
} catch (\Exception $e) {
// 记录失败日志并发送到消息队列
DB::rollBack();
Log::error("Product update failed: ".$e->getMessage());
$this->sendToRetryQueue($productId, $data);
return false;
}
}
}
php复制class RetryConsumer {
public function handle($message) {
$productId = $message['product_id'];
$data = $message['data'];
// 获取当前最新版本
$currentVersion = Product::find($productId)->version;
// 检查消息中的版本是否过期
if ($message['version'] < $currentVersion) {
Log::info("Skip outdated version: ".$message['version']);
return;
}
// 重试ES更新
try {
$esClient = new ElasticsearchClient();
$esParams = [
'index' => 'products',
'id' => $productId,
'body' => [
'doc' => $data,
'doc_as_upsert' => true
]
];
$esClient->update($esParams);
} catch (\Exception $e) {
// 重试失败,延迟后重新入队
$this->requeue($message, 60); // 60秒后重试
}
}
}
为了解决并发更新导致的数据一致性问题,我们引入了版本控制机制:
sql复制ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
我们开发了定期校验脚本来确保数据一致性:
php复制class ConsistencyChecker {
public function checkProducts($batchSize = 1000) {
$lastId = 0;
do {
$products = Product::where('id', '>', $lastId)
->orderBy('id')
->take($batchSize)
->get();
if ($products->isEmpty()) break;
foreach ($products as $product) {
$esData = $this->getFromES($product->id);
if (!$this->compareData($product, $esData)) {
$this->triggerRepair($product->id);
}
}
$lastId = $products->last()->id;
} while (true);
}
}
经过上述方案实施后,我们获得了以下指标:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 平均搜索延迟 | 1200ms | 350ms |
| 数据不一致率 | 0.8% | 0.01% |
| 系统吞吐量 | 500QPS | 1500QPS |
| 99分位延迟 | 2500ms | 800ms |
在实际实施过程中,我们遇到了几个典型问题:
ES映射冲突:初期没有严格定义字段类型,导致自动推断的映射与业务需求不符
版本控制失效:在高并发场景下出现过version竞争条件
消息堆积:促销期间补偿队列出现大量积压
连接泄漏:未正确关闭的ES连接导致内存溢出
这个方案还可以在以下方面进行优化:
在实际业务中,技术方案的选择需要权衡实时性、一致性和系统复杂度。对于大多数电商场景,本文介绍的"双写+补偿"方案能够在保证毫秒级搜索体验的同时,将数据不一致窗口控制在可接受范围内。