微服务架构实战:ShardingSphere Proxy + Canal 分库分表与 ES 同步全链路

本文基于电商生产实践,详解ShardingSphere Proxy 5.5.2的自定义复合分片算法(ComplexKeysShardingAlgorithm)实现与Docker部署,以及Canal监听MySQL ROW模式Binlog、通过RocketMQ异步投递至Spring Boot消费者并同步至Elasticsearch的全链路方案,涵盖SPI扩展、配置文件、Binlog前置条件、表名还原与协同避坑要点。

目标

本文基于真实电商系统生产实践,详细拆解 ShardingSphere Proxy 5.5.2 的分表策略定制,以及 Canal + RocketMQ + Elasticsearch 的数据同步链路,全程附带可直接运行的配置与代码。


一、整体架构概览

先上架构图,理解两大组件在系统中的定位:

`mermaid
flowchart TD
subgraph “Spring Boot 应用层”
A1[订单服务]
A2[支付服务]
A3[商品服务]
A4[其他微服务…]
end

subgraph "ShardingSphere Proxy 5.5.2 独立服务"
    B1[自定义 SPI 分片算法]
    B2[order 表分片算法]
    B3[payment 表分片算法]
end

subgraph "MySQL 真实数据库 单库 50 张分表"
    C1[order_00 ~ order_49]
    C2[payment_00 ~ payment_49]
end

subgraph "Canal + RocketMQ"
    D1[Canal Server 监听 Binlog]
    D2[canal-topic]
end

subgraph "搜索服务"
    E1[BinlogConsumer 统一入口]
    E2[Dispatcher 分发路由]
    E3[ProductListener / OrderListener ...]
end

A1 & A2 & A3 & A4 -- "JDBC MySQL 协议" --> B1
B1 --> B2 & B3
B2 & B3 -- "路由到物理分表" --> C1 & C2
C1 & C2 -- "Binlog" --> D1
D1 -- "投递" --> D2
D2 -- "Consumer" --> E1
E1 --> E2 --> E3
E3 -- "写入" --> F[(Elasticsearch)]

`

核心链路两条:

  • 写链路:应用 → ShardingSphere Proxy → 真实分表
  • 同步链路:MySQL Binlog → Canal → RocketMQ → 搜索服务 → ES

二、ShardingSphere Proxy 分表配置

2.1 为什么选 Proxy 模式

ShardingSphere 有两种使用模式,对比之下 Proxy 模式更适合电商场景:

模式形态优点缺点
JDBCjar 嵌入应用无需额外部署升级需重启每个应用、多语言不支持
Proxy独立数据库代理服务语言无关、统一管控、应用零侵入多一跳网络延迟

电商订单表数据量大、多服务共享,选 Proxy 模式是最优解。

2.2 自定义分片算法 SPI 扩展

我们编写了一个独立的 SPI 插件 jar,让 Proxy 加载,实现复合分片算法。

分片策略设计

电商场景中,订单号、售后单号、用户 ID 等字段需要指向同一张分表。项目采用 “取分片键后三位 % 50” 的策略:

`java
public class ShardingAlgorithmHelper {

private static final int TABLE_SIZE = 50;  // 共 50 张分表

public static String getTableSuffix(int valueSuffix) {
    return "_" + valueSuffix % TABLE_SIZE;
}

/**
 * 根据分片键值集合,从候选表名中筛选出对应的物理分表
 */
public static Set<String> getTableNames(Collection<String> tableNames, Collection<?> columnValues) {
    Set<String> result = new HashSet<>(10);
    for (Object columnValueObj : columnValues) {
        String columnValue = String.valueOf(columnValueObj);
        // 取分片键的后三位
        String suffix = columnValue.length() < 3 ? columnValue : columnValue.substring(columnValue.length() - 3);
        String tableSuffix = getTableSuffix(Integer.parseInt(suffix));
        for (String tableName : tableNames) {
            if (tableName.endsWith(tableSuffix)) {
                result.add(tableName);
            }
        }
    }
    return result;
}

}
`

复合分片算法实现

一个订单表可能被多个维度查询(order_id、user_id、refund_id 等),所以使用 ComplexKeysShardingAlgorithm:

`java
public class OrderComplexKeysShardingAlgorithm implements ComplexKeysShardingAlgorithm {

@Override
public Collection<String> doSharding(Collection<String> tableNames,
                                     ComplexKeysShardingValue<Long> shardingValue) {
    Map<String, Collection<Long>> columnMap = shardingValue.getColumnNameAndShardingValuesMap();

    // 分片键优先级:order_id → order_item_id → refund_id → user_id → shop_id
    Collection<Long> orderIds = columnMap.get("order_id");
    if (CollectionUtils.isNotEmpty(orderIds)) {
        return ShardingAlgorithmHelper.getTableNames(tableNames, orderIds);
    }
    Collection<Long> refundIds = columnMap.get("refund_id");
    if (CollectionUtils.isNotEmpty(refundIds)) {
        return ShardingAlgorithmHelper.getTableNames(tableNames, refundIds);
    }
    Collection<Long> userIds = columnMap.get("user_id");
    if (CollectionUtils.isNotEmpty(userIds)) {
        return ShardingAlgorithmHelper.getTableNames(tableNames, userIds);
    }
    return null;
}

}
`

SPI 注册文件

在 src/main/resources/META-INF/services/ 目录下创建文件,文件名固定为 org.apache.shardingsphere.sharding.spi.ShardingAlgorithm,内容为你实现类的全限定名:

com.demo.sharding.order.OrderComplexKeysShardingAlgorithm com.demo.sharding.payment.PaymentComplexKeysShardingAlgorithm

2.3 Proxy 配置文件

将上面的 SPI 模块打包成 jar,和 Proxy 一起部署。核心配置文件如下:

server.yaml(Proxy 主配置)

yaml port: 3307 mode: type: Standalone # 单机模式,生产环境建议 Cluster + ZooKeeper repository: type: Standalone props: proxy-frontend-flush-threshold: 128 proxy-backend-flush-threshold: 128 proxy-hint-enabled: false check-table-metadata-enabled: false

config-sharding.yaml(分表规则)

`yaml
schemaName: demo_db

dataSources:
ds0:
url: jdbc:mysql://192.168.1.100:3306/demo_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: your_password
connectionTimeoutMilliseconds: 30000
idleTimeoutMilliseconds: 60000
maxLifetimeMilliseconds: 1800000
maxPoolSize: 50

rules:

  • !SHARDING
    tables:

    订单表分表:order_00 ~ order_49

    order:
    actualDataNodes: ds0.order_$->{0…49}
    tableStrategy:
    complex:
    shardingAlgorithmName: order_complex_algorithm

    订单商品表

    order_item:
    actualDataNodes: ds0.order_item_$->{0…49}
    tableStrategy:
    complex:
    shardingAlgorithmName: order_complex_algorithm

    退款表

    order_refund:
    actualDataNodes: ds0.order_refund_$->{0…49}
    tableStrategy:
    complex:
    shardingAlgorithmName: order_complex_algorithm

    支付表

    payment_info:
    actualDataNodes: ds0.payment_info_$->{0…49}
    tableStrategy:
    complex:
    shardingAlgorithmName: payment_complex_algorithm

    shardingAlgorithms:
    order_complex_algorithm:
    type: CLASS_BASED
    props:
    strategy: COMPLEX
    algorithmClassName: com.demo.sharding.order.OrderComplexKeysShardingAlgorithm
    payment_complex_algorithm:
    type: CLASS_BASED
    props:
    strategy: COMPLEX
    algorithmClassName: com.demo.sharding.payment.PaymentComplexKeysShardingAlgorithm
    `

2.4 Docker 一键部署

`yaml

docker-compose-shardingsphere.yaml

version: ‘3.8’
services:
shardingsphere-proxy:
image: apache/shardingsphere-proxy:5.5.2 # 版本务必与 SPI jar 一致
container_name: shardingsphere-proxy
ports:
- “3307:3307”
environment:
- MODE=Standalone
- CONFIG_PATH=/opt/shardingsphere-proxy/conf
volumes:
- ./shardingsphere/conf:/opt/shardingsphere-proxy/conf
- ./shardingsphere/plugin/demo-sharding-plugin.jar:/opt/shardingsphere-proxy/lib/demo-sharding-plugin.jar
restart: always
`

启动命令:

ash docker-compose -f docker-compose-shardingsphere.yaml up -d

2.5 应用侧 JDBC 配置

`yaml

application.yaml (Spring Boot)

spring:
datasource:
# 连接的是 Proxy 的端口 3307,而非 MySQL 的 3306
url: jdbc:mysql://127.0.0.1:3307/demo_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root # Proxy 自身的账号
password: root # Proxy 自身的密码(在 server.yaml 的 authority 节点配置)
driver-class-name: com.mysql.cj.jdbc.Driver
`


三、Canal 监听 Binlog 同步 ES

3.1 为什么用 Canal

Canal 模拟 MySQL Slave 协议,伪装成从库去订阅主库的 Binlog,做到业务代码零侵入地捕获数据变更,非常适合做:

  • ES 索引同步(本文重点)
  • 缓存更新
  • 数据审计
  • 跨库数据迁移

3.2 MySQL Binlog 前置条件

在 MySQL 主库上确认以下配置(my.cnf):

`ini
[mysqld]

开启 Binlog

log-bin=mysql-bin

必须用 ROW 模式,Canal 才能拿到行级变更

binlog-format=ROW

Server ID 不能和 Proxy 或其他从库冲突

server-id=1001

只记录关心的库(可选,减小 Binlog 体积)

binlog-do-db=demo_db
`

重启 MySQL 后,创建 Canal 专用账号:

sql CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_password'; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%'; FLUSH PRIVILEGES;

3.3 Canal Server 部署

canal.properties(核心配置)

`properties
canal.serverMode = RocketMQ # 或 Kafka / RabbitMQ
canal.destinations = demo_instance # 实例名

RocketMQ 配置

canal.mq.exchange = demo-canal-topic
canal.mq.producerGroup = CanalProducerGroup
canal.mq.namesrv = 127.0.0.1:9876
`

instance.properties(订阅哪个 MySQL)

`properties

conf/demo_instance/instance.properties

canal.instance.master.address = 192.168.1.100:3306
canal.instance.master.journal.name = mysql-bin.000001
canal.instance.master.position = 0

监听的库和表(正则),分表用正则通配

canal.instance.filter.regex = demo_db\.order_\d+,demo_db\.order_item_\d+,demo_db\.spu,demo_db\.sku
`

Docker 启动 Canal

ash docker run -d \ --name canal-server \ -p 11111:11111 \ -v /path/to/canal/conf:/canal-server/conf \ canal/canal-server:v1.1.7

3.4 消费者:Spring Boot 监听 RocketMQ

Canal 把 Binlog 变更投递到 RocketMQ 后,搜索服务负责消费并同步到 ES。

核心常量

java public class MqConstants { public static final String CANAL_TOPIC = "demo-canal-topic"; }

BinlogConsumer(统一入口)

`java
@Component
@RocketMQMessageListener(
topic = MqConstants.CANAL_TOPIC,
consumerGroup = MqConstants.CANAL_TOPIC
)
public class BinlogConsumer implements RocketMQListener {

/** 匹配 xxx_数字 格式的分表名 */
private static final Pattern TABLE_PATTERN = Pattern.compile("\\w+[_]{1}[\\d]+");

@Autowired
private Dispatcher dispatcher;

@Override
public void onMessage(String message) {
    Map<String, String> map = JSON.parseObject(message, Map.class);
    // ★ 关键:将分表名还原为逻辑表名
    // Canal 拿到的是 order_42,要还原成 order 才能路由到正确的监听器
    map.put("table", restoreTableName(map.get("table")));
    map.put("database", restoreTableName(map.get("database")));
    dispatcher.dispatch(JSON.toJSONString(map));
}

/**
 * 将 order_42 → order,payment_07 → payment
 * 仅处理 xxx_数字 格式的分表名,不影响含下划线但非分表的普通表
 */
private String restoreTableName(Object o) {
    if (Objects.isNull(o)) return null;
    String name = o.toString();
    Matcher matcher = TABLE_PATTERN.matcher(name);
    if (matcher.matches()) {
        name = name.substring(0, name.lastIndexOf('_'));
    }
    return name;
}

}
`

为什么要还原表名? 因为 Canal 监听的是 MySQL 物理分表(order_00 ~ order_49),但业务逻辑上我们只关心 “order 表发生了变更”,还原后才能统一路由到对应的监听器。

Dispatcher(分发路由)

`java
@Component
public class Dispatcher {

@Autowired
private List<BinlogEventListener> listeners;

public void dispatch(String canalMessage) {
    CanalEventResult<?> result = JSON.parseObject(canalMessage, CanalEventResult.class);
    // 根据 table 名称匹配对应的监听器
    for (BinlogEventListener listener : listeners) {
        if (listener.support(result.getTable())) {
            listener.handle(result);
            return;
        }
    }
    log.warn("未找到处理表 {} 的监听器", result.getTable());
}

}
`

具体监听器示例(商品 SPU)

`java
@Component
public class SpuEventListener implements BinlogEventListener {

@Autowired
private RestHighLevelClient restHighLevelClient;

@Override
public boolean support(String tableName) {
    return "spu".equals(tableName);
}

@Override
public void handle(CanalEventResult<?> result) {
    switch (result.getType()) {
        case INSERT:
            processInsert(result);
            break;
        case UPDATE:
            processUpdate(result);
            break;
        case DELETE:
            processDelete(result);
            break;
        default:
            break;
    }
}

/** 插入 → 新增 ES 文档 */
private void processInsert(CanalEventResult<?> result) {
    Long spuId = result.getPrimaryKey();
    // 通过 Feign 拉取完整商品数据
    ProductDTO product = productClient.loadProduct(spuId);
    // 写入 Elasticsearch
    IndexRequest request = new IndexRequest("product")
        .id(String.valueOf(spuId))
        .source(JSON.toJSONString(product), XContentType.JSON);
    restHighLevelClient.index(request, RequestOptions.DEFAULT);
}

/** 更新 → 先删后插(ES 部分更新较复杂,全量重建更可靠) */
private void processUpdate(CanalEventResult<?> result) {
    Long spuId = result.getPrimaryKey();
    DeleteRequest deleteRequest = new DeleteRequest("product", String.valueOf(spuId));
    restHighLevelClient.delete(deleteRequest, RequestOptions.DEFAULT);
    processInsert(result);
}

/** 删除 → 删除 ES 文档 */
private void processDelete(CanalEventResult<?> result) {
    DeleteRequest request = new DeleteRequest("product", String.valueOf(result.getPrimaryKey()));
    restHighLevelClient.delete(request, RequestOptions.DEFAULT);
}

}
`

3.5 Canal 消息格式示例

RocketMQ 中一条 Canal 消息长这样:

json { "database": "demo_db", "table": "order_42", "type": "UPDATE", "data": [ {"order_id": "100023456789", "status": 3, "pay_amount": 99.00} ], "old": [ {"order_id": "100023456789", "status": 2, "pay_amount": 99.00} ], "ts": 1730000000 }

经过
estoreTableName 处理后:

json { "database": "demo_db", "table": "order", ... }


四、两者的协同关系

这是容易被忽略的一点:ShardingSphere Proxy 和 Canal 虽然是两个独立组件,但它们处理的是同一个 MySQL 实例,关系如下:

组件连的是谁作用
应用 → Proxy → MySQLProxy 通过 JDBC 连 MySQL写入时做分表路由
Canal → MySQLCanal 直连 MySQL 主库(伪装 Slave)读 Binlog,捕获变更

关键注意事项:

  1. Canal 必须直连 MySQL 主库,不能连 Proxy(Proxy 不转发 Binlog 协议)
  2. 因为 Proxy 路由后的写入最终还是落到 MySQL 物理分表上,所以 Canal 能正确监听到变更
  3. 如果 Proxy 后面挂了多库(ds0、ds1…),每个库都要单独部署一个 Canal 实例

五、生产踩坑总结

5.1 ShardingSphere Proxy

坑解决方案
SPI jar 版本与 Proxy 不一致导致 ClassNotFoundExceptionSPI jar 的 shardingsphere.version 必须和 Docker 镜像 tag 完全一致,别用 latest
分表名称和 ctualDataNodes 不匹配SQL 文件里建表必须严格按 order_00 ~ order_49 命名,零前缀不能省
Proxy 挂了 MySQL 连接没释放配置合理的 maxPoolSize、maxLifetimeMilliseconds,定期监控连接数
全表扫描慢查询绕过分片开启 check-table-metadata-enabled: true,Proxy 会拦截不符合分片策略的 SQL

5.2 Canal 同步 ES

坑解决方案
Canal 拿不到分表名前缀canal.instance.filter.regex 里用正则 .order_\d+ 匹配物理分表
ES 同步延迟Canal → RocketMQ 链路确认是否开启了批量投递(canal.mq.batchSize)
分表名还原逻辑写错正则 \w+[_]{1}[\d]+ 确保只还原 xxx_数字 格式,不要误伤含下划线但不是分表的表名
更新商品时关联数据也要同步不要只更新主文档,监听器里还要处理多文档关联更新
Canal 宕机重启丢消息开启 MySQL 半同步复制 + Canal 的 canal.instance.gtidon=true,配合 RocketMQ 的重试机制

六、小结

组件定位版本
ShardingSphere Proxy数据库代理,分库分表路由5.5.2
CanalMySQL Binlog 监听1.1.7
RocketMQ消息中转(解耦 Canal 和 ES)4.x
Elasticsearch搜索引擎7.x

核心思想:

  • 写时路由:应用无感知分表,Proxy 屏蔽复杂性
  • 读时同步:Canal 捕获物理分表变更 → RocketMQ 削峰 → Consumer 还原逻辑表名后同步 ES
  • 统一抽象:自定义 SPI 算法让 “多字段路由” 变成一个 ComplexKeysShardingAlgorithm,维护成本极低
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值