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

目标

本文基于真实电商系统生产实践,详细拆解 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 模式更适合电商场景:

模式 形态 优点 缺点
JDBC jar 嵌入应用 无需额外部署 升级需重启每个应用、多语言不支持
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 → MySQL Proxy 通过 JDBC 连 MySQL 写入时做分表路由
Canal → MySQL Canal 直连 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 不一致导致 ClassNotFoundException SPI 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
Canal MySQL Binlog 监听 1.1.7
RocketMQ 消息中转(解耦 Canal 和 ES) 4.x
Elasticsearch 搜索引擎 7.x

核心思想:

  • 写时路由:应用无感知分表,Proxy 屏蔽复杂性
  • 读时同步:Canal 捕获物理分表变更 → RocketMQ 削峰 → Consumer 还原逻辑表名后同步 ES
  • 统一抽象:自定义 SPI 算法让 "多字段路由" 变成一个 ComplexKeysShardingAlgorithm,维护成本极低
相关推荐
Elasticsearch2 小时前
Elastic 重大事件功能如何将 100 条日志样本转化为检测规则
elasticsearch
誰能久伴不乏5 小时前
桌面客户端的运行日志系统:SQLite 分块 + 独立写线程 + 文本降级
数据库·c++·qt·架构·sqlite
未来之窗软件服务11 小时前
终端优先同步架构终端场景下轻量化、高可用通信模型-东方仙盟
架构·东方仙盟·阿雪技术观·网络震荡·dns污染·数据中继器·边界网关
坐看风云变幻16 小时前
《系统架构师成长之路》第 8 期|微服务:单体拆分的时机、粒度与通信
微服务·架构·系统架构
Song_RsHt17 小时前
Redis 与微服务,什么场景下无可替代
java·redis·后端·学习·微服务
Figo_Cheung19 小时前
Figo潜空间层级认知理论:基于齐夫拓扑与黎曼几何的结构化模型研究
人工智能·架构·空间计算
洞见前行19 小时前
用 Tinker 热更新反向实现插件化:两个独立 APK 合二为一的原理详解
架构
这个DBA有点耶19 小时前
分布式集群的“最后一公里”:分片集群与透明分布式集群的工程代价拆解
数据库·分布式·架构
Elastic 中国社区官方博客21 小时前
5 分钟内将 56 页扫描文档转化为带引用的答案:面向搜索无法读取的文档的检索增强生成流水线
大数据·运维·人工智能·elasticsearch·搜索引擎·信息可视化·全文检索