目标
本文基于真实电商系统生产实践,详细拆解 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,捕获变更 |
关键注意事项:
- Canal 必须直连 MySQL 主库,不能连 Proxy(Proxy 不转发 Binlog 协议)
- 因为 Proxy 路由后的写入最终还是落到 MySQL 物理分表上,所以 Canal 能正确监听到变更
- 如果 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,维护成本极低