mysql数据同步ES方案---Canal
引言
之前公司开发社交APP的时候 在开发和初上线阶段,我们一直采用的是 MySQL 来存储用户的各种数据,满足基本的查询需求。当时系统业务量小,数据规模有限,因此 MySQL 能很好地支持查询操作,响应速度快,系统压力也较小。然而,随着产品推广和广告投入的增加,用户量迅速增长,查询需求变得更加复杂,这时单纯依靠 MySQL 支持所有查询就逐渐暴露出一些问题:
1. 数据量的增长
随着用户数增加、交易记录增多、日志信息积累,MySQL 数据表的规模逐步扩大。随着数据表变大,每次查询需要更多的时间遍历、扫描和过滤大量的数据行,导致查询响应速度变慢。
2. 查询需求的精细化
随着各种各样的需求上线,查询需求逐渐从简单筛选变为多条件筛选、模糊匹配、排序和聚合分析等更复杂的需求。MySQL 对于复杂查询的支持有限,即使通过优化索引,面对复杂的筛选和分析需求,查询效率仍无法满足。
3. 系统性能和压力问题
随着频繁的复杂查询增多,数据库的负载也随之增加。尤其是在数据量和并发访问量增加的情况下,MySQL 的性能瓶颈愈加明显,直接影响到系统的整体响应速度,甚至会导致响应变慢或短时宕机。
为了解决这些问题,我们采用了“空间换效率”的策略,引入了 Elasticsearch。通过将用户行为数据从 MySQL 异步同步到 Elasticsearch,我们利用其强大的搜索、聚合和分析能力,将这些复杂查询从 MySQL 转移到 Elasticsearch。这种方案不仅大大减轻了 MySQL 的查询负担,也使系统能够在海量数据环境下保持快速响应,从而更好地支持社交产品中的实时和复杂查询需求。
为了实现 MySQL 数据到 Elasticsearch 的实时同步,我们面临的核心问题是如何将 MySQL 数据变化高效、准确地推送到 Elasticsearch。理想的方案应该具备以下几个特点:
- 实时性:确保 MySQL 数据的增删改操作能够迅速反映在 Elasticsearch 中,使查询结果始终保持最新状态。
- 高效性:避免每次数据更新都进行全量同步,应该仅将变化部分同步,减少资源消耗。
- 数据一致性:确保 MySQL 和 Elasticsearch 中的数据保持一致,避免出现因延迟或丢失导致的数据不一致问题。
为了解决这一系列问题,常见的数据同步方案包括同步双写、异步双写、基于 SQL 的数据抽取和基于 Binlog 的实时同步等。这些方案各有优缺点,且在不同场景下会选择不同的工具。例如,业界常用的同步工具有 Canal、DTS、Databus、Flink、CloudCanal、Maxwell、DRD 以及 Yugong 等。
今天我将重点介绍其中一种高效的实时同步工具——Canal。
Canal 简介
Canal 是由阿里巴巴开源的一款数据捕获工具,专为解决 MySQL 数据实时同步问题设计。它通过模拟 MySQL 从库的方式来监听和解析 binlog 日志,从而获取 MySQL 的数据变更,支持将这些变更同步到其他数据存储系统,比如 Redis、Elasticsearch、Kafka 等,满足多种数据同步需求。
Canal 的同步原理
Canal 的工作原理主要基于 MySQL binlog 日志。MySQL binlog 是一个记录所有数据更改操作(INSERT、UPDATE、DELETE)的日志文件,通常用于数据库的主从同步。Canal 利用 binlog 来实现数据同步,其核心原理包括以下几个步骤:
- 模拟 MySQL 从库
Canal 通过伪装成 MySQL 的从库来连接到主库,就像主从复制一样读取 binlog 日志,抓取所有的数据变更事件。这种方式无需对主库进行代码改动,且不影响数据库性能。 - 解析 binlog 日志
Canal 从 binlog 中获取的数据是二进制格式,需要对其进行解析。Canal 可以解析常见的增删改操作(如 INSERT、UPDATE、DELETE),并将这些操作解析成可以被识别的 JSON 格式,便于后续处理。 - 事件发布与同步
解析完成后,Canal 将这些数据变更事件推送给指定的目标系统,比如 Kafka 或直接写入 Elasticsearch。这样,每当 MySQL 中的数据发生变更时,Elasticsearch 就能及时收到并更新,保持与 MySQL 数据的一致性。
这种方式实现了数据的实时性和准确性,且具备较好的扩展性,不会增加 MySQL 主库的负载。Canal 的这一同步机制,让它成为高效、稳定的数据同步工具,广泛应用于实时数据同步和分布式数据系统中。
方案选型
在数据同步过程中,我们可以直接使用 Canal 自带的 Canal Adapter,将 MySQL 数据变化实时同步到 Elasticsearch。这种方式实现简单且直观,适合对同步性能要求较高且系统结构相对简单的场景。
然而,在实际应用中,业务需求往往更为复杂,我们可能需要更加灵活的同步方式,比如异步处理、多系统分发、或者更高的容错能力。在这些情况下,我们可以在 Canal 和 Elasticsearch 之间引入消息队列(如 RabbitMQ),以满足以下场景需求:
- 多系统分发
如果数据不仅需要同步到 Elasticsearch,还需同步到其他系统(如缓存系统、推荐系统等),引入 RabbitMQ 可以让 Canal 只负责将数据推送到 RabbitMQ,由多个消费端从 RabbitMQ 订阅消息并写入不同的目标系统,实现灵活的数据分发。 - 异步与容错处理
Canal 和 Elasticsearch 的直接连接是实时且同步的,而 RabbitMQ 可以作为缓冲区来存储 Canal 推送的变更事件,便于异步消费。如果 Elasticsearch 出现短暂故障,RabbitMQ 会将 Canal 的消息缓存,待 Elasticsearch 恢复后再继续消费,避免数据丢失。 - 负载均衡
通过 RabbitMQ,还可以实现同步负载的分摊,防止 Canal 和 Elasticsearch 直接连接时出现高负载问题,有效保障系统的稳定性。
因此,在数据同步方案设计中,如果对系统的稳定性、容错性和可扩展性有更高要求,可以考虑使用 RabbitMQ 等消息队列来增强 Canal 的同步能力,实现灵活而高效的数据同步。
架构设计
基于上述方案,我将使用 Hyperf 框架,结合 Canal、RabbitMQ、MySQL 和 Elasticsearch,实现一个简单而灵活的数据同步功能。具体来说,通过 Canal 监听 MySQL 的 binlog 日志,将数据变更事件推送到 RabbitMQ 中,Hyperf 作为消费端从 RabbitMQ 接收这些变更事件,处理后将数据同步到 Elasticsearch 中。此架构保证了系统的高容错性和扩展性,同时实现了高效的异步数据同步。
备注:系统所需的 RabbitMQ、MySQL 和 Elasticsearch 环境已通过 Docker 安装配置。
使用 Docker 安装和配置 Canal 服务端(含 RabbitMQ 和 MySQL 配置)
-
创建挂载目录
在主机上创建一个挂载目录,以便持久化 Canal 的配置文件和日志文件:mkdir -p /docker/canal/conf mkdir -p /docker/canal/logs
-
首次启动 Canal 容器
启动 Canal 容器,让它生成默认的配置文件:docker run --name canal -d canal/canal-server:latest
-
复制默认配置文件到挂载目录
将canal.properties
和instance.properties
配置文件从容器复制到主机挂载目录,以便后续配置:docker cp canal:/home/admin/canal-server/conf/canal.properties /docker/canal/conf/ docker cp canal:/home/admin/canal-server/conf/example/instance.properties /docker/canal/conf/
-
停止并删除容器
复制完成后,停止并删除 Canal 容器:docker stop canal docker rm canal
-
编辑 canal.properties 以配置 RabbitMQ
打开挂载目录下的canal.properties
文件,添加 RabbitMQ 的相关配置。以下是示例配置:
```properties
canal.serverMode = rabbitMQ
rabbitmq.host = 192.168.110.190:5672
rabbitmq.virtual.host = /
rabbitmq.exchange = canal_exchange
rabbitmq.username = admin
rabbitmq.password = admin
rabbitmq.queue =
rabbitmq.routingKey =
rabbitmq.deliveryMode = 2
```
* **`canal.serverMode`**:用于指定 Canal 的消息投递模式,支持以下几种模式:TCP、Kafka、RocketMQ、RabbitMQ 和 PulsarMQ。在这里,设置为 `rabbitMQ`,意味着 Canal 将 MySQL 数据变化通过 RabbitMQ 推送,供后续的消费端处理。
* **`rabbitmq.host`**:RabbitMQ 的服务地址和端口。在这里,`192.168.110.190:5672` 表示 RabbitMQ 服务器的 IP 地址是 `192.168.110.190`,端口号为 `5672`。
* **`rabbitmq.virtual.host`**:指定 RabbitMQ 的虚拟主机(Virtual Host),这是 RabbitMQ 中的一个逻辑隔离环境。`/` 表示使用默认虚拟主机。
* **`rabbitmq.exchange`**:Canal 数据推送所使用的交换机名称。这里指定为 `canal_exchange`,用于在 RabbitMQ 中接收 Canal 的数据变更消息。交换机会根据消息的 routing key 将消息路由到相应的队列。
* **`rabbitmq.username` 和 `rabbitmq.password`**:RabbitMQ 的用户名和密码。
* **`rabbitmq.queue`**:指定 Canal 推送数据的目标队列名称。消费者可以从这个队列中接收 MySQL 数据变更事件。
* **`rabbitmq.routingKey`**:用于消息路由的路由键。通常可以指定为数据库名称和表名(例如 `${db}.${table}`),以便将消息发送到正确的队列。
* **`rabbitmq.deliveryMode`**:指定消息的持久化方式。设置为 `2` 表示消息将被持久化到磁盘,以确保消息在 RabbitMQ 重启或崩溃时不会丢失。
-
编辑 instance.properties 以配置 MySQL
在挂载目录下找到instance.properties
文件,填写 MySQL 的连接信息,配置示例如下:properties 复制代码 canal.instance.master.address=<MYSQL_HOST>:<MYSQL_PORT> canal.instance.dbUsername=<MYSQL_USERNAME> canal.instance.dbPassword=<MYSQL_PASSWORD> canal.instance.filter.regex=<DATABASE_NAME>.<TABLE_NAME>
canal.instance.master.address
:MySQL 主库的 IP 和端口。canal.instance.dbUsername
和canal.instance.dbPassword
:连接 MySQL 的用户名和密码,确保该用户有权限读取 binlog。canal.instance.filter.regex
:指定要同步的数据库和表,例如circle.circle_table
,如果不设置此项,将同步所有表。
重新启动 Canal 容器并挂载配置文件
重新启动 Canal 容器,将配置文件和日志目录挂载到容器中:
docker run -d --name canal \
-p 11111:11111 \
-v /docker/canal/conf:/home/admin/canal-server/conf \
-v /docker/canal/logs:/home/admin/canal-server/logs \
canal/canal-server:latest
这样,Canal 服务端已通过 Docker 安装并完成 RabbitMQ 和 MySQL 的配置。现在可以将 MySQL 数据变更事件推送至 RabbitMQ,接下来可以使用消费端(如 Hyperf 框架)来接收并同步数据到 Elasticsearch。
技术流程实现
步骤 1:创建数据库和表
首先,我们需要在 MySQL 中创建一个名为 circle
的数据库,以及一个表 circle_global
,用于存储圈子相关的数据。
1.1 创建数据库
在 MySQL 中执行以下 SQL 语句,创建数据库:
CREATE DATABASE circle;
1.2 创建表 circle_global
接下来,切换到刚创建的数据库,并创建名为 circle_global
的表。执行以下 SQL 语句:
USE circle;
CREATE TABLE `circle_global` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`circle_id` bigint(20) NOT NULL,
`user_id` bigint(20) NOT NULL,
`created_at` datetime NOT NULL,
`like_count` int(11) DEFAULT '0',
PRIMARY KEY (`id`),
KEY `idx_created_at` (`created_at`),
KEY `idx_like_count` (`like_count`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
1.3 表结构说明
id
:主键,唯一标识每条记录,采用自增长方式。circle_id
:圈子的 ID,用于标识不同的圈子。user_id
:用户的 ID,标识参与该圈子的用户。created_at
:记录创建的时间,采用 datetime 类型。like_count
:记录该圈子的点赞数,默认值为 0。
表的索引:
- 主键索引:对
id
列进行主键索引,确保每条记录的唯一性。 - 创建时间索引:在
created_at
列上创建索引,以加速基于时间的查询。 - 点赞数索引:在
like_count
列上创建索引,以支持点赞数的快速统计和查询。
步骤 2:在 Elasticsearch 中创建索引
在 MySQL 数据库和表创建完成后,我们需要在 Elasticsearch 中创建一个索引,以便存储和管理从 circle_global
表中同步过来的数据。
2.1 创建索引 circle_global
使用以下 curl
命令在 Elasticsearch 中创建索引 circle_global
,并定义索引的映射:
curl -X PUT "http://localhost:9200/circle_global?pretty" -H 'Content-Type: application/json' -d'
{
"mappings": {
"properties": {
"circle_id": {
"type": "long"
},
"user_id": {
"type": "long"
},
"created_at": {
"type": "date"
},
"like_count": {
"type": "integer"
}
}
}
}
'
2.2 映射说明
circle_id
:类型为long
,用于存储圈子 ID。user_id
:类型为long
,用于存储用户 ID。created_at
:类型为date
,用于存储创建时间。like_count
:类型为integer
,用于存储点赞数。
2.3 验证索引创建
创建索引后,可以使用以下命令验证索引是否成功创建:
curl -X GET "http://localhost:9200/_cat/indices?v"
在返回的列表中查找 circle_global
索引,如果存在,表示索引创建成功。
步骤 3:在 Hyperf 中安装 AMQP 和 Elasticsearch 组件
在准备好 MySQL 和 Elasticsearch 之后,我们需要在 Hyperf 项目中安装必要的组件,以便能够与 RabbitMQ 和 Elasticsearch 进行交互。
3.1 安装 AMQP 组件
使用 Composer 安装 Hyperf 的 AMQP 组件,执行以下命令:
composer require hyperf/amqp
3.2 安装 Elasticsearch 组件
同样,使用 Composer 安装 Elasticsearch 的 PHP 客户端,执行以下命令:
composer require elasticsearch/elasticsearch
3.3 配置环境变量
在项目根目录下找到 .env
文件,并添加以下配置,以便 AMQP 和 Elasticsearch 组件能够正确连接到相应的服务。
APP_NAME=skeleton
APP_ENV=dev
# 数据库 配置
DB_DRIVER=mysql
DB_HOST=192.168.110.190
DB_PORT=3306
DB_DATABASE=circle
DB_USERNAME=root
DB_PASSWORD=123456
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci
DB_PREFIX=
# RabbitMQ 配置
AMQP_HOST=192.168.110.190
AMQP_PORT=5672
AMQP_USER=admin
AMQP_PASSWORD=admin
AMQP_VHOST=/
AMQP_ENABLE=true
# Elasticsearch 配置
ELASTICSEARCH_HOST=192.168.110.190:9200
3.4 更新 AMQP 配置文件
在 config/autoload/amqp.php
文件中,确保 AMQP 的连接信息与 .env
中的环境变量一致:
return [
'default' => [
'host' => env('RABBITMQ_HOST', 'localhost'),
'port' => env('RABBITMQ_PORT', 5672),
'user' => env('RABBITMQ_USER', 'guest'),
'password' => env('RABBITMQ_PASSWORD', 'guest'),
'vhost' => env('RABBITMQ_VHOST', '/'),
],
];
3.5 更新 Elasticsearch 配置文件
在 config/autoload/elasticsearch.php
文件中,确保 Elasticsearch 的连接信息与 .env
中的环境变量一致:
return [
'default' => [
'hosts' => [
env('ELASTICSEARCH_HOST', 'localhost:9200'),
],
],
];
步骤 4:使用单例模式创建 Elasticsearch 实例
为了避免在应用程序中重复创建 Elasticsearch 客户端实例,我们可以使用单例模式来管理 Elasticsearch 的连接。这种方式确保在整个应用生命周期内,只创建一次 Elasticsearch 客户端,从而提高资源利用率并减少开销。
4.1 创建 Elasticsearch 单例类
在 app/Components
目录下创建一个名为 Elasticsearch.php
的类,用于管理 Elasticsearch 客户端的单例实例:
<?php
namespace App\Components;
use Elasticsearch\ClientBuilder;
class Elasticsearch
{
private static $instance;
/**
* 私有化构造函数,防止外部实例化
*/
private function __construct()
{
}
/**
* 禁止克隆对象
*/
private function __clone()
{
}
/**
* 获取 Elasticsearch 客户端的单例实例
*
* @return \Elasticsearch\Client
*/
public static function getInstance()
{
if (is_null(self::$instance)) {
self::$instance = ClientBuilder::create()
->setHosts([env('ELASTICSEARCH_HOST', 'localhost:9200')])
->build();
}
return self::$instance;
}
}
4.2 代码说明
- 私有化构造函数:通过私有化构造函数,防止外部代码实例化该类。
- 单例获取方法:
getInstance
方法用于获取 Elasticsearch 客户端的单例实例。如果实例尚未创建,则调用ClientBuilder
创建新的实例,并将其存储在self::$instance
中。 - 防止克隆:通过私有化
__clone
方法,确保单例实例不能被克隆。
通过这种设计,我们能够在 Hyperf 应用中高效地管理 Elasticsearch 客户端的连接,避免了资源浪费和连接创建的开销。接下来,您可以在需要使用 Elasticsearch 的地方直接调用 Elasticsearch::getInstance()
来获取客户端实例。
步骤 5:创建消费模式并实现监听 Canal 的逻辑
在 Hyperf 中,我们将创建一个消费者类,监听 Canal 发送的消息,并将这些消息写入 Elasticsearch 实例中。以下是实现的详细步骤:
5.1 创建 Canal 消费者类
在 app/Amqp/Consumer
目录下创建一个名为 CanalConsumer.php
的类,用于处理 Canal 的消息:
<?php
namespace App\Amqp\Consumer;
use Hyperf\Amqp\Message\ConsumerMessage;
use Hyperf\Amqp\Result;
use App\Components\Elasticsearch;
use Hyperf\Amqp\Annotation\Consumer;
use Hyperf\Amqp\Annotation\Inject;
#[Consumer(exchange: 'canal_exchange', routingKey: 'canal', queue: 'canal_queue', name: "CanalConsumer", nums: 1)]
class CanalConsumer extends ConsumerMessage
{
public function consume($data): string
{
// 解析 Canal 发送的数据
$canalMessage = json_decode($data, true);
// 检查是否包含数据库变更信息
if (isset($canalMessage['data']) && is_array($canalMessage['data'])) {
foreach ($canalMessage['data'] as $row) {
// 获取 Elasticsearch 客户端实例
$client = Elasticsearch::getInstance();
// 准备要写入 Elasticsearch 的参数
$params = [
'index' => 'circle_global', // Elasticsearch 的索引名称
'id' => $row['id'], // 使用 MySQL 的 id 作为文档 ID
'body' => [
'circle_id' => $row['circle_id'],
'user_id' => $row['user_id'],
'created_at' => $row['created_at'],
'like_count' => $row['like_count'],
],
];
// 将数据写入到 Elasticsearch 索引中
$client->index($params);
}
}
return Result::ACK; // 确认消息消费成功
}
}
5.2 代码说明
- 注解配置:使用
#[Consumer]
注解配置消费者的信息,指定交换机、路由键、队列名和并发消费数量。 - 解析消息:在
consume
方法中,解析 Canal 发送的 JSON 数据。确保数据存在并为数组格式。 - 循环处理数据:如果
data
字段存在,循环处理每一行数据。 - 获取 Elasticsearch 实例:调用
Elasticsearch::getInstance()
获取客户端实例,避免重复创建。 - 准备 Elasticsearch 写入参数:将从 Canal 获取的数据映射到 Elasticsearch 索引的字段。
- 写入 Elasticsearch:使用
$client->index($params)
将数据写入指定的 Elasticsearch 索引。 - 返回确认结果:返回
Result::ACK
,确认消息已成功消费。
5.3 启动 Hyperf 服务
确保所有配置和代码都已完成后,启动 Hyperf 服务:
php bin/hyperf.php start
可以看到 CanalConsumer消费者已经成功启动监听
5.4 测试数据同步
-
在 MySQL 的
circle_global
表中执行插入操作。 -
确认 Canal 正常工作,并查看
CanalConsumer
是否成功接收到消息并写入到 Elasticsearch。 -
使用以下命令查询 Elasticsearch,确认数据是否已经同步:
curl -X GET "http://localhost:9200/circle_global/_search?pretty"
可以看到,我们已经成功实现了通过监听 Canal 发送的消息,将 MySQL 中的变更数据实时写入 Elasticsearch 实例。这一过程不仅提高了数据处理的效率,也为后续的数据分析和搜索提供了强有力的支持。