SpringBoot3 整合 ShardingSphere 分库分表与 Seata 分布式事务完整指南
适用版本:
- Spring Boot: 3.2.x
- ShardingSphere: 5.4.x
- Seata: 2.0.x
- JDK: 17+
- MyBatis-Plus: 3.5.x
- Spring Data JPA: 3.2.x
目录
1. 架构概览
1.1 整体架构图
┌─────────────────────────────────────────────────────────────┐
│ Spring Boot 应用层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 业务服务A │ │ 业务服务B │ │ 业务服务C │ │
│ │ (订单服务) │ │ (库存服务) │ │ (账户服务) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └──────────────────┼──────────────────┘ │
│ │ │
├────────────────────────────┼─────────────────────────────────┤
│ Seata TC (事务协调器) │
│ ┌─────────────┴─────────────┐ │
│ │ AT / TCC / SAGA / XA │ │
│ └─────────────┬─────────────┘ │
├────────────────────────────┼─────────────────────────────────┤
│ ShardingSphere-JDBC │
│ ┌──────────────────┼──────────────────┐ │
│ │ 分库分表路由 & SQL 解析重写 │ │
│ └──────────────────┬──────────────────┘ │
└────────────────────────────┼─────────────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ DB-0 │ │ DB-1 │ │ DB-2 │
│ Table_0 │ │ Table_0 │ │ Table_0 │
│ Table_1 │ │ Table_1 │ │ Table_1 │
└─────────┘ └─────────┘ └─────────┘
1.2 技术选型说明
1.2.1 ShardingSphere vs 其他分库分表方案
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| ShardingSphere-JDBC | 轻量级、性能高、与应用同进程 | 升级需重启应用 | 中小规模、性能要求高 |
| ShardingSphere-Proxy | 支持异构语言、运维集中化 | 多一层网络IO | 多语言环境、大规模集群 |
| MyCat | 成熟稳定 | 功能相对简单 | 传统项目 |
| TDDL | 阿里内部方案 | 开源版本更新慢 | 阿里生态 |
1.2.2 Seata 四种模式对比
| 模式 | 性能 | 一致性 | 侵入性 | 适用场景 |
|---|---|---|---|---|
| AT | 高 | 最终一致 | 低(无需代码改造) | 大部分业务场景 |
| TCC | 中 | 强一致 | 高(需实现三个方法) | 核心金融业务 |
| SAGA | 高 | 最终一致 | 中(需定义补偿) | 长事务、跨系统 |
| XA | 低 | 强一致 | 低 | 强一致性要求但性能不敏感 |
2. ShardingSphere 分库分表方案
2.0 分库分表查询与分页原理详解
2.0.1 查询路由过程
单表查询流程:
原始 SQL: SELECT * FROM t_order WHERE user_id = 1001
↓ ShardingSphere 解析
1. SQL 解析: 解析出表名、条件、字段等
2. 路由计算: 根据 user_id 计算分片键
- 数据库: ds-$->{1001 % 3} = ds-1
- 表名: t_order_$->{1001 % 10} = t_order_1
3. SQL 改写:
SELECT * FROM t_order_1 WHERE user_id = 1001
4. SQL 执行: 在 ds-1.t_order_1 上执行
5. 结果归并: 返回结果
多表查询流程(未指定分片键):
原始 SQL: SELECT * FROM t_order WHERE status = 'PENDING' LIMIT 10
↓ 无法确定路由,需要全库全表扫描
1. SQL 广播: 需要查询所有分片
ds-0: SELECT * FROM t_order_0 WHERE status = 'PENDING' LIMIT 10
ds-0: SELECT * FROM t_order_1 WHERE status = 'PENDING' LIMIT 10
...
ds-2: SELECT * FROM t_order_9 WHERE status = 'PENDING' LIMIT 10
2. 并行执行: 30个查询(3库 × 10表)同时执行
3. 结果归并: 将30个结果集合并
4. 内存排序: 如果有 ORDER BY
5. 二次截断: 取前 10 条
2.0.2 分页查询的挑战
问题 1:分页偏移量放大
-- 原始 SQL
SELECT * FROM t_order ORDER BY create_time DESC LIMIT 10000, 10;
-- 实际执行(假设 3 个库)
-- ds-0: SELECT * FROM t_order ORDER BY create_time DESC LIMIT 10010;
-- ds-1: SELECT * FROM t_order ORDER BY create_time DESC LIMIT 10010;
-- ds-2: SELECT * FROM t_order ORDER BY create_time DESC LIMIT 10010;
问题:
- 每个分片需要查询 10010 条数据
- 总共需要从数据库返回 30030 条数据
- 内存中归并排序后,只取 10 条
- 性能损耗 = 分片数 × (offset + limit)
问题 2:内存压力
场景: LIMIT 100000, 20 在 10 个分片上执行
每个分片返回: 100,020 条
总数据量: 1,000,200 条
内存占用: 假设每条 1KB,约 1GB 内存
实际使用: 仅 20 条
浪费率: 99.998%
2.0.3 ShardingSphere 分页优化策略
策略 1:流式归并(推荐)
# 配置
props:
sql-show: true
# 启用流式归并
max-connections-size-per-query: 1
// 工作原理:优先队列归并
┌─────────┐ ┌─────────┐ ┌─────────┐
│ ds-0 │ │ ds-1 │ │ ds-2 │
│ cursor │ │ cursor │ │ cursor │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ 比较排序游标指针 │
▼ ▼ ▼
┌─────────────────────────────────┐
│ 优先队列(PriorityQueue) │
│ 始终保持最小/最大的元素在堆顶 │
└────────────┬────────────────────┘
│
▼ 逐条返回
最终结果集
优点:
- 内存占用小(只需维护分片数大小的堆)
- 适合大数据量分页
缺点:
- 需要保持多个数据库连接
- 对排序字段有索引要求
策略 2:内存归并(默认)
// 工作原理
1. 并行查询所有分片
2. 将所有结果加载到内存
3. 内存中排序
4. 截取需要的分页数据
// 适用场景
- 数据量小(offset + limit < 10000)
- 内存充足
2.0.4 实际代码示例
示例 1:带分片键的高效分页
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
private OrderMapper orderMapper;
/**
* 高效分页:指定分片键
* 只会路由到单个分片,性能最优
*/
@GetMapping("/user/{userId}")
public IPage<Order> getUserOrders(
@PathVariable Long userId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
// 构建分页对象
Page<Order> pageParam = new Page<>(page, size);
// 使用 user_id 作为分片键查询
// SQL: SELECT * FROM t_order WHERE user_id = ? ORDER BY create_time DESC LIMIT ?, ?
// 路由: 只查询 ds-{userId%3}.t_order_{计算结果}
return orderMapper.selectPage(pageParam,
new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId)
.orderByDesc(Order::getCreateTime));
}
}
执行过程:
1. 计算路由: user_id=1001 → ds-1.t_order_1
2. 执行 SQL:
SELECT * FROM t_order_1
WHERE user_id = 1001
ORDER BY create_time DESC
LIMIT 0, 20
3. 直接返回: 仅一次数据库查询
示例 2:不带分片键的分页(需优化)
/**
* 低效分页:未指定分片键
* 会查询所有分片,性能较差
*/
@GetMapping("/status/{status}")
public IPage<Order> getOrdersByStatus(
@PathVariable String status,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
Page<Order> pageParam = new Page<>(page, size);
// SQL: SELECT * FROM t_order WHERE status = ? ORDER BY create_time DESC LIMIT ?, ?
// 路由: 需要查询所有分片(3库 × 10表 = 30次查询)
return orderMapper.selectPage(pageParam,
new LambdaQueryWrapper<Order>()
.eq(Order::getStatus, status)
.orderByDesc(Order::getCreateTime));
}
执行过程:
1. 路由分析: 无分片键,全路由
2. 改写 SQL:
ds-0.t_order_0: SELECT * FROM t_order_0 WHERE status = ? ORDER BY create_time DESC LIMIT 0, 20
ds-0.t_order_1: SELECT * FROM t_order_1 WHERE status = ? ORDER BY create_time DESC LIMIT 0, 20
...(共30条SQL)
3. 并行执行: 30个查询同时执行
4. 结果归并:
- 收集 30 × 20 = 600 条数据
- 内存中按 create_time 排序
- 取前 20 条返回
2.0.5 分页优化方案
方案 1:游标分页(强烈推荐)
/**
* 使用游标代替 offset
* 性能稳定,不受页数影响
*/
@GetMapping("/cursor")
public List<Order> getOrdersByCursor(
@RequestParam Long userId,
@RequestParam(required = false) Long lastOrderId,
@RequestParam(defaultValue = "20") Integer size) {
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId)
.orderByDesc(Order::getOrderId)
.last("LIMIT " + size);
// 如果有上次的最后一条记录ID,从该ID开始查询
if (lastOrderId != null) {
wrapper.lt(Order::getOrderId, lastOrderId);
}
return orderMapper.selectList(wrapper);
}
优势分析:
传统分页: LIMIT 10000, 20
- 需要扫描 10020 条数据
- 随着页数增加,性能线性下降
游标分页: WHERE order_id < ? LIMIT 20
- 始终只扫描 20 条数据
- 性能稳定,不受页数影响
- 可以利用主键索引
方案 2:分片键 + 二级索引
/**
* 组合查询:分片键 + 其他条件
*/
@GetMapping("/user/{userId}/status/{status}")
public IPage<Order> getUserOrdersByStatus(
@PathVariable Long userId, // 分片键
@PathVariable String status, // 业务条件
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
Page<Order> pageParam = new Page<>(page, size);
// 通过 user_id 精确路由到单个分片
// 在该分片上通过 status 索引查询
return orderMapper.selectPage(pageParam,
new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId) // 路由字段
.eq(Order::getStatus, status) // 过滤字段
.orderByDesc(Order::getCreateTime));
}
数据库索引设计:
-- 组合索引优化
CREATE INDEX idx_user_status_time
ON t_order (user_id, status, create_time);
-- 查询执行计划
EXPLAIN SELECT * FROM t_order_1
WHERE user_id = 1001 AND status = 'PENDING'
ORDER BY create_time DESC LIMIT 20;
-- 使用索引覆盖,避免回表
方案 3:流式查询(大数据导出)
/**
* 流式查询:适合大数据量导出
*/
@GetMapping("/export")
public void exportOrders(
@RequestParam Long userId,
HttpServletResponse response) throws IOException {
response.setContentType("text/csv");
response.setHeader("Content-Disposition",
"attachment; filename=orders.csv");
// 使用流式查询,避免 OOM
try (Writer writer = response.getWriter()) {
orderMapper.selectList(
new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId)
.orderByDesc(Order::getCreateTime),
resultContext -> {
Order order = resultContext.getResultObject();
writer.write(order.toCsv());
writer.write("\n");
}
);
}
}
方案 4:ES/Solr 搜索引擎(读写分离架构)
重要说明:为什么需要"数据库 + ES"双存储?
┌─────────────────────────────────────────────────────────────────┐
│ 数据存储架构对比 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ❌ 错误理解:ES 替代数据库 │
│ ┌────────┐ │
│ │ ES │ ← 所有读写操作 │
│ └────────┘ │
│ 问题:ES 不保证事务、可能丢数据、不适合频繁更新 │
│ │
│ ✅ 正确架构:数据库 + ES 各司其职 │
│ ┌──────────┐ 同步 ┌──────────┐ │
│ │ MySQL │ ───────────────► │ ES │ │
│ │ (主存储) │ (Canal/MQ) │ (搜索) │ │
│ └──────────┘ └──────────┘ │
│ ↑ ↓ │
│ 写操作/事务 复杂查询/搜索 │
│ │
└─────────────────────────────────────────────────────────────────┘
职责划分:
| 存储系统 | 职责 | 优势 | 不适合 |
|---|---|---|---|
| MySQL(主库) | • 数据持久化 • 事务保证 • 数据一致性 • 核心业务CRUD | • ACID 事务 • 数据可靠 • 关系查询 • 备份恢复 | • 全文搜索 • 复杂聚合 • 大数据分析 |
| Elasticsearch | • 复杂搜索 • 全文检索 • 聚合分析 • 日志分析 | • 搜索快 • 分词 • 相关性排序 • 分析能力强 | • 事务操作 • 频繁更新 • 作为主存储 |
具体原因分析:
// 原因 1:数据可靠性(最重要!)
// ────────────────────────────────
// MySQL:
✅ 强 ACID 保证
✅ 事务回滚机制
✅ Binlog 保证不丢数据
✅ 主从备份、定时快照
// Elasticsearch:
❌ 默认异步刷盘(可能丢数据)
❌ 不支持完整的事务
❌ 集群脑裂可能导致数据不一致
⚠️ ES 是"近实时"搜索,不是实时
// 示例场景:下单后立即查询
@Transactional
public void createOrder(Order order) {
// 1. 写入 MySQL(立即持久化)
orderRepository.save(order);
// 2. 发送MQ异步同步到ES(可能延迟1-2秒)
rabbitTemplate.send("order.sync", order);
// 3. 如果立即去ES查询,可能查不到!
// 所以订单详情必须从MySQL查
}
// 原因 2:事务支持
// ────────────────────────────────
// 场景:下单扣库存扣款(需要事务)
@GlobalTransactional // Seata 分布式事务
public void placeOrder(OrderDTO dto) {
// 1. 创建订单
Order order = orderService.createOrder(dto);
// 2. 扣减库存
inventoryService.reduce(dto.getProductId(), dto.getQuantity());
// 3. 扣款
accountService.deduct(dto.getUserId(), dto.getAmount());
// 如果任何一步失败,全部回滚
}
// ✅ MySQL:完美支持事务
// ❌ ES:不支持跨文档事务,如果失败无法回滚
// 原因 3:数据更新频率
// ────────────────────────────────
// 高频更新场景:订单状态流转
PENDING → PAID → SHIPPED → DELIVERED → COMPLETED
@Service
public class OrderService {
// 每次状态变更都要更新数据库
public void updateStatus(Long orderId, String status) {
// MySQL:优化的B+树,更新很快
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus(status);
orderRepository.save(order); // 索引更新,毫秒级
// ES:需要重新索引整个文档,较慢
// 而且频繁更新会导致大量的段合并,影响性能
}
}
// ✅ MySQL:适合频繁更新(B+树结构优化)
// ❌ ES:不适合频繁更新(倒排索引重建成本高)
// 原因 4:数据完整性约束
// ────────────────────────────────
// MySQL 支持的约束
CREATE TABLE t_order (
order_id BIGINT PRIMARY KEY, -- 主键约束
user_id BIGINT NOT NULL, -- 非空约束
order_no VARCHAR(32) UNIQUE, -- 唯一约束
amount DECIMAL(10,2) CHECK(amount > 0), -- 检查约束
FOREIGN KEY (user_id) REFERENCES t_user(id) -- 外键约束
);
// ES 不支持:
❌ 没有主键约束(可以插入重复ID)
❌ 没有外键约束
❌ 没有检查约束
❌ 没有唯一索引保证
正确的架构设计:
/**
* 完整的读写分离架构
*/
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository; // MySQL
@Autowired
private ElasticsearchRestTemplate esTemplate; // ES
@Autowired
private RabbitTemplate rabbitTemplate; // MQ
/**
* 写操作:只写 MySQL
* 规则:所有增删改操作都走数据库
*/
@Transactional
public Order createOrder(OrderDTO dto) {
// 1. 数据校验
validateOrder(dto);
// 2. 写入 MySQL(主存储)
Order order = new Order();
BeanUtils.copyProperties(dto, order);
order = orderRepository.save(order); // 保证数据持久化
// 3. 异步同步到 ES(不影响主流程)
rabbitTemplate.convertAndSend("order.sync.exchange",
"order.create", order);
return order;
}
/**
* 读操作:根据场景选择
* 规则:
* - 按ID/分片键查询 → MySQL
* - 复杂搜索/分页 → ES
*/
// 场景1:订单详情(精确查询)→ MySQL
public Order getOrderDetail(Long orderId, Long userId) {
// 原因:
// 1. 需要最新数据(ES可能有延迟)
// 2. 有分片键,MySQL查询很快
// 3. 单条记录,没有复杂搜索需求
return orderRepository.findByOrderIdAndUserId(orderId, userId)
.orElseThrow(() -> new NotFoundException("订单不存在"));
}
// 场景2:用户订单列表(简单查询)→ MySQL
public List<Order> getUserOrders(Long userId, Integer limit) {
// 原因:有分片键,直接命中单个分片
return orderRepository.findByUserIdOrderByCreateTimeDesc(
userId, PageRequest.of(0, limit));
}
// 场景3:订单搜索(复杂查询)→ ES
public Page<OrderES> searchOrders(OrderSearchDTO dto) {
// 原因:
// 1. 无分片键,MySQL需要全表扫描
// 2. 多条件组合查询
// 3. 需要全文搜索、模糊匹配
// 4. 大偏移量分页
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.boolQuery()
// 商品名称模糊搜索
.must(QueryBuilders.matchQuery("productName", dto.getKeyword()))
// 状态过滤
.filter(QueryBuilders.termsQuery("status", dto.getStatuses()))
// 时间范围
.filter(QueryBuilders.rangeQuery("createTime")
.gte(dto.getStartTime())
.lte(dto.getEndTime()))
// 金额范围
.filter(QueryBuilders.rangeQuery("amount")
.gte(dto.getMinAmount())
.lte(dto.getMaxAmount())))
.withPageable(PageRequest.of(dto.getPage(), dto.getSize()))
.withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC))
.build();
SearchHits<OrderES> searchHits = esTemplate.search(query, OrderES.class);
List<OrderES> orders = searchHits.stream()
.map(SearchHit::getContent)
.collect(Collectors.toList());
return new PageImpl<>(orders,
PageRequest.of(dto.getPage(), dto.getSize()),
searchHits.getTotalHits());
}
// 场景4:订单统计(聚合查询)→ ES
public OrderStatistics getOrderStatistics(Long userId, String period) {
// ES 的聚合能力强,适合做统计分析
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.termQuery("userId", userId))
.addAggregation(AggregationBuilders
.dateHistogram("ordersByDate")
.field("createTime")
.calendarInterval(DateHistogramInterval.days(1))
.subAggregation(AggregationBuilders.sum("totalAmount").field("amount")))
.build();
// 执行聚合查询
SearchHits<OrderES> searchHits = esTemplate.search(query, OrderES.class);
// 解析聚合结果
return parseStatistics(searchHits.getAggregations());
}
}
数据同步方案:
/**
* MySQL → ES 数据同步
* 方案1:Canal 监听 Binlog(推荐)
*/
@Component
public class OrderCanalListener {
@Autowired
private ElasticsearchRestTemplate esTemplate;
/**
* 监听 MySQL Binlog
*
* 重要:Canal 能捕获 DELETE 操作的完整数据
* 前提:MySQL Binlog 格式必须是 ROW 模式
*/
@CanalEventListener
public void onOrderChange(CanalEntry.EventType eventType, CanalEntry.RowData rowData) {
switch (eventType) {
case INSERT:
case UPDATE:
// 同步到 ES
OrderES orderES = convertToES(rowData);
esTemplate.save(orderES);
break;
case DELETE:
// 从 ES 删除
// ⚠️ 关键:DELETE 的 Binlog 中包含被删除行的完整数据
String orderId = getOrderId(rowData);
esTemplate.delete(orderId, OrderES.class);
break;
}
}
}
/**
* ═══════════════════════════════════════════════════════════
* 重点说明:MySQL 删除操作如何同步到 ES
* ═══════════════════════════════════════════════════════════
*/
// 问题:数据库删除了,怎么知道删除哪条对应的 ES 记录?
// 答案:Canal 监听 Binlog,DELETE 事件包含被删除记录的完整数据!
// ──────────────────────────────────────────────────────────
// 1. MySQL Binlog 格式说明
// ──────────────────────────────────────────────────────────
/**
* MySQL 有三种 Binlog 格式:
*
* STATEMENT 模式(不推荐):
* - 记录 SQL 语句本身
* - DELETE FROM t_order WHERE user_id = 1001;
* - ❌ 无法知道具体删除了哪些行
*
* ROW 模式(强烈推荐):
* - 记录每一行的变更前后数据
* - DELETE: before_image (删除前的完整数据)
* - ✅ 包含主键、所有字段
*
* MIXED 模式:
* - 混合模式,一般情况用 STATEMENT,特殊情况用 ROW
* - ⚠️ 不稳定,不推荐
*/
// MySQL 配置(必须使用 ROW 模式)
-- my.cnf
[mysqld]
# Binlog 格式必须是 ROW
binlog_format = ROW
# Binlog 行镜像(完整镜像)
binlog_row_image = FULL # 记录所有列(推荐)
# binlog_row_image = MINIMAL # 只记录主键和变更列(不推荐,Canal 可能解析不完整)
# 开启 Binlog
log-bin = mysql-bin
server-id = 1
// ──────────────────────────────────────────────────────────
// 2. Canal 捕获 DELETE 事件的原理
// ──────────────────────────────────────────────────────────
/**
* Canal Binlog 解析流程
*/
// 场景:数据库执行删除操作
DELETE FROM t_order_1 WHERE order_id = 123456;
// ↓ MySQL 写入 Binlog(ROW 格式)
Binlog Event:
{
"type": "DELETE",
"database": "order_db_0",
"table": "t_order_1",
"ts": 1699999999000,
"before": [ // ✅ 删除前的完整数据
{
"order_id": 123456,
"user_id": 1001,
"product_id": 5678,
"amount": 199.99,
"status": "COMPLETED",
"create_time": "2024-01-01 10:00:00",
"update_time": "2024-01-02 10:00:00",
"deleted": 0
}
],
"after": null // DELETE 没有 after
}
// ↓ Canal 解析 Binlog
CanalEntry.RowChange:
{
"eventType": "DELETE",
"rowDatas": [
{
"beforeColumns": [ // ✅ 删除前的所有列
{"name": "order_id", "value": "123456", "updated": false},
{"name": "user_id", "value": "1001", "updated": false},
{"name": "amount", "value": "199.99", "updated": false},
...
],
"afterColumns": [] // DELETE 没有 afterColumns
}
]
}
// ↓ 我们的 Consumer 处理
public void onOrderChange(CanalEntry.EventType eventType, CanalEntry.RowData rowData) {
if (eventType == EventType.DELETE) {
// 从 beforeColumns 中提取 order_id
String orderId = getColumnValue(rowData.getBeforeColumnsList(), "order_id");
// 删除 ES 中的文档
esTemplate.delete(orderId, OrderES.class);
}
}
// ──────────────────────────────────────────────────────────
// 3. 完整代码实现
// ──────────────────────────────────────────────────────────
/**
* Canal 消息实体
*/
@Data
public class CanalMessage {
private String type; // INSERT, UPDATE, DELETE
private String database; // 数据库名
private String table; // 表名
private Long ts; // 时间戳
private List<Map<String, Object>> data; // INSERT/UPDATE 的新数据
private List<Map<String, Object>> old; // UPDATE 的旧数据
private List<Map<String, Object>> mysqlType;
}
/**
* Canal 监听器(完整版)
*/
@Component
@Slf4j
public class OrderCanalListener {
@Autowired
private RestHighLevelClient esClient;
/**
* 监听 Canal 消息
*/
@StreamListener("canal-input")
public void handleCanalMessage(CanalMessage message) {
String eventType = message.getType();
log.info("收到Canal消息: type={}, table={}, count={}",
eventType, message.getTable(),
message.getData() != null ? message.getData().size() : 0);
try {
switch (eventType) {
case "INSERT":
handleInsert(message);
break;
case "UPDATE":
handleUpdate(message);
break;
case "DELETE":
handleDelete(message); // ✅ 处理删除
break;
default:
log.warn("未知事件类型: {}", eventType);
}
} catch (Exception e) {
log.error("处理Canal消息失败: type={}, table={}",
eventType, message.getTable(), e);
throw e; // 抛出异常,触发重试
}
}
/**
* 处理 INSERT 事件
*/
private void handleInsert(CanalMessage message) throws IOException {
List<Map<String, Object>> dataList = message.getData();
BulkRequest bulkRequest = new BulkRequest();
for (Map<String, Object> data : dataList) {
OrderES orderES = convertToOrderES(data);
IndexRequest request = new IndexRequest("orders")
.id(String.valueOf(orderES.getOrderId()))
.source(JSON.toJSONString(orderES), XContentType.JSON);
bulkRequest.add(request);
}
esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
log.info("ES插入成功: count={}", dataList.size());
}
/**
* 处理 UPDATE 事件
*/
private void handleUpdate(CanalMessage message) throws IOException {
List<Map<String, Object>> dataList = message.getData();
List<Map<String, Object>> oldList = message.getOld();
BulkRequest bulkRequest = new BulkRequest();
for (int i = 0; i < dataList.size(); i++) {
Map<String, Object> newData = dataList.get(i);
Map<String, Object> oldData = oldList != null && i < oldList.size()
? oldList.get(i) : null;
// 检查是否是逻辑删除(deleted 字段变化)
if (isLogicDelete(newData, oldData)) {
// 逻辑删除:从 ES 删除
String orderId = String.valueOf(newData.get("order_id"));
DeleteRequest request = new DeleteRequest("orders", orderId);
bulkRequest.add(request);
} else {
// 普通更新:更新 ES
OrderES orderES = convertToOrderES(newData);
IndexRequest request = new IndexRequest("orders")
.id(String.valueOf(orderES.getOrderId()))
.source(JSON.toJSONString(orderES), XContentType.JSON);
bulkRequest.add(request);
}
}
esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
log.info("ES更新成功: count={}", dataList.size());
}
/**
* 处理 DELETE 事件(物理删除)
*
* 关键:message.getData() 中包含被删除记录的完整数据
*/
private void handleDelete(CanalMessage message) throws IOException {
// ✅ 重点:DELETE 事件的 data 字段包含被删除的完整数据
List<Map<String, Object>> deletedData = message.getData();
if (deletedData == null || deletedData.isEmpty()) {
log.warn("DELETE事件无数据: table={}", message.getTable());
return;
}
BulkRequest bulkRequest = new BulkRequest();
for (Map<String, Object> data : deletedData) {
// 从被删除的数据中提取主键
Object orderIdObj = data.get("order_id");
if (orderIdObj == null) {
log.error("删除数据中没有order_id: data={}", data);
continue;
}
String orderId = String.valueOf(orderIdObj);
// 从 ES 删除
DeleteRequest deleteRequest = new DeleteRequest("orders", orderId);
bulkRequest.add(deleteRequest);
log.debug("准备删除ES文档: orderId={}", orderId);
}
// 批量删除
BulkResponse bulkResponse = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
if (bulkResponse.hasFailures()) {
log.error("ES删除失败: {}", bulkResponse.buildFailureMessage());
} else {
log.info("ES删除成功: count={}", deletedData.size());
}
}
/**
* 判断是否是逻辑删除
*/
private boolean isLogicDelete(Map<String, Object> newData, Map<String, Object> oldData) {
if (oldData == null) {
return false;
}
// 检查 deleted 字段是否从 0 变为 1
Object oldDeleted = oldData.get("deleted");
Object newDeleted = newData.get("deleted");
return oldDeleted != null && newDeleted != null
&& "0".equals(String.valueOf(oldDeleted))
&& "1".equals(String.valueOf(newDeleted));
}
/**
* 转换为 ES 文档
*/
private OrderES convertToOrderES(Map<String, Object> data) {
OrderES orderES = new OrderES();
orderES.setOrderId(Long.valueOf(String.valueOf(data.get("order_id"))));
orderES.setUserId(Long.valueOf(String.valueOf(data.get("user_id"))));
orderES.setProductId(Long.valueOf(String.valueOf(data.get("product_id"))));
orderES.setAmount(new BigDecimal(String.valueOf(data.get("amount"))));
orderES.setStatus(String.valueOf(data.get("status")));
// ... 其他字段
return orderES;
}
}
// ──────────────────────────────────────────────────────────
// 4. 逻辑删除 vs 物理删除
// ──────────────────────────────────────────────────────────
/**
* 场景对比
*/
// ───── 场景1:物理删除 ─────
// SQL:
DELETE FROM t_order WHERE order_id = 123456;
// Binlog Event:
{
"type": "DELETE",
"data": [
{
"order_id": 123456, // ✅ 包含被删除的主键
"user_id": 1001, // ✅ 包含所有字段
"amount": 199.99,
...
}
]
}
// Canal 处理:
eventType = DELETE
→ 从 data 中提取 order_id
→ 删除 ES 中的文档
// ───── 场景2:逻辑删除 ─────
// SQL:
UPDATE t_order SET deleted = 1 WHERE order_id = 123456;
// Binlog Event:
{
"type": "UPDATE",
"data": [ // 新值
{
"order_id": 123456,
"deleted": 1, // ✅ 变为 1
...
}
],
"old": [ // 旧值
{
"order_id": 123456,
"deleted": 0, // ✅ 之前是 0
...
}
]
}
// Canal 处理:
eventType = UPDATE
→ 检查 deleted 字段:0 → 1
→ 判定为逻辑删除
→ 删除 ES 中的文档
// ──────────────────────────────────────────────────────────
// 5. 测试验证
// ──────────────────────────────────────────────────────────
/**
* 测试 Canal 能否捕获删除的完整数据
*/
@SpringBootTest
@Slf4j
public class CanalDeleteTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private RestHighLevelClient esClient;
@Test
public void testPhysicalDelete() throws Exception {
// 1. 插入测试数据
Order order = new Order();
order.setOrderId(999999L);
order.setUserId(1001L);
order.setAmount(new BigDecimal("199.99"));
orderRepository.save(order);
// 等待同步到 ES
Thread.sleep(2000);
// 2. 验证 ES 中存在
GetRequest getRequest = new GetRequest("orders", "999999");
GetResponse getResponse = esClient.get(getRequest, RequestOptions.DEFAULT);
assertTrue(getResponse.isExists());
log.info("ES中存在文档: {}", getResponse.getSourceAsString());
// 3. 物理删除
orderRepository.deleteById(999999L);
// 等待 Canal 同步
Thread.sleep(2000);
// 4. 验证 ES 中已删除
getResponse = esClient.get(getRequest, RequestOptions.DEFAULT);
assertFalse(getResponse.isExists());
log.info("ES中文档已删除");
}
@Test
public void testLogicDelete() throws Exception {
// 1. 插入测试数据
Order order = new Order();
order.setOrderId(888888L);
order.setUserId(1001L);
order.setDeleted(0);
orderRepository.save(order);
// 等待同步
Thread.sleep(2000);
// 2. 验证 ES 中存在
GetRequest getRequest = new GetRequest("orders", "888888");
assertTrue(esClient.get(getRequest, RequestOptions.DEFAULT).isExists());
// 3. 逻辑删除
order.setDeleted(1);
orderRepository.save(order);
// 等待同步
Thread.sleep(2000);
// 4. 验证 ES 中已删除
assertFalse(esClient.get(getRequest, RequestOptions.DEFAULT).isExists());
log.info("逻辑删除同步成功");
}
}
// ──────────────────────────────────────────────────────────
// 6. 常见问题
// ──────────────────────────────────────────────────────────
/**
* Q1: 如果 Binlog 格式不是 ROW,会怎样?
* A1: STATEMENT 模式下,DELETE 的 Binlog 只有 SQL 语句:
* DELETE FROM t_order WHERE user_id = 1001;
* 无法知道具体删除了哪些行,Canal 无法正确同步!
* ⚠️ 必须使用 ROW 格式
*/
/**
* Q2: 如果 binlog_row_image = MINIMAL,会怎样?
* A2: MINIMAL 模式只记录主键和变更的列:
* DELETE: 只有 order_id
* UPDATE: 只有变更的字段
* ⚠️ 可能导致 ES 文档字段不完整
* ✅ 推荐使用 FULL 模式
*/
/**
* Q3: 批量删除怎么处理?
* A3: DELETE FROM t_order WHERE user_id = 1001;
* ROW 模式会为每一行生成一个 DELETE 事件
* Canal 会逐条捕获,包含每行的完整数据
* 我们的代码会批量删除 ES 中的多个文档
*/
/**
* Q4: 如果删除时 Canal 挂了,数据会不一致吗?
* A4: 不会!Canal 有两层保障:
* 1. Canal 记录 Binlog 位点(position)
* 2. Kafka 提供持久化队列
* Canal 重启后从上次位点继续消费,不会丢失删除事件
* ✅ 最终会同步到 ES
*/
/**
* Q5: 推荐物理删除还是逻辑删除?
* A5: 推荐逻辑删除:
* ✅ 数据可恢复
* ✅ 符合审计要求
* ✅ 避免外键约束问题
* ⚠️ 需要在查询时过滤 deleted = 0
* ⚠️ 需要定期归档清理
*/
// ──────────────────────────────────────────────────────────
// 7. 配置检查清单
// ──────────────────────────────────────────────────────────
/**
* MySQL 配置检查
*/
-- 检查 Binlog 格式
SHOW VARIABLES LIKE 'binlog_format';
-- 应该显示:ROW
-- 检查 Binlog 行镜像
SHOW VARIABLES LIKE 'binlog_row_image';
-- 应该显示:FULL
-- 检查 Binlog 是否开启
SHOW VARIABLES LIKE 'log_bin';
-- 应该显示:ON
-- 创建 Canal 用户并授权
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal123';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
/**
* Canal 配置检查
*/
# canal.properties
canal.serverMode = kafka
kafka.bootstrap.servers = kafka1:9092,kafka2:9092,kafka3:9092
# instance.properties
canal.instance.filter.regex = .*\\..* # 监听所有库表
canal.instance.filter.black.regex = # 黑名单为空
/**
* 方案2:MQ 异步同步
*/
@Component
public class OrderSyncListener {
@Autowired
private ElasticsearchRestTemplate esTemplate;
@RabbitListener(queues = "order.sync.queue")
public void syncToES(Order order) {
try {
// 转换为 ES 文档
OrderES orderES = new OrderES();
BeanUtils.copyProperties(order, orderES);
// 保存到 ES
esTemplate.save(orderES);
} catch (Exception e) {
log.error("同步订单到ES失败: orderId={}", order.getOrderId(), e);
// 重试机制
throw new AmqpRejectAndDontRequeueException("同步失败,等待重试");
}
}
}
海量数据与 ES 同步方案
问题场景
挑战:
- 数据量:亿级订单数据(分库分表 30 个物理表)
- 增量:每天新增 1000万+ 订单
- 要求:MySQL 与 ES 数据一致性
- 限制:不能影响线上业务性能
方案架构
┌─────────────────────────────────────────────────────────────┐
│ 海量数据同步完整架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ MySQL 分库分表(主存储) │ │
│ │ ds-0: t_order_0 ~ t_order_9 │ │
│ │ ds-1: t_order_0 ~ t_order_9 │ │
│ │ ds-2: t_order_0 ~ t_order_9 │ │
│ └────┬──────────────────────────────────────┬──┘ │
│ │ │ │
│ │ 1. 实时增量同步 │ 2. 定期全量同步│
│ │ (Canal Binlog) │ (Batch Job)│
│ ↓ ↓ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Canal Server│ │ 全量同步任务 │ │
│ │ (3个实例) │ │ (分片并行) │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ │ 3. 写入消息队列 │ │
│ ↓ ↓ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Kafka Topic (分区队列) │ │
│ │ - order-sync-0 (ds-0 数据) │ │
│ │ - order-sync-1 (ds-1 数据) │ │
│ │ - order-sync-2 (ds-2 数据) │ │
│ └──────────┬──────────────────────────────────┘ │
│ │ │
│ │ 4. 消费并批量写入 │
│ ↓ │
│ ┌─────────────────────────────────────────────┐ │
│ │ ES Sync Consumer (多实例) │ │
│ │ - 批量消费(1000条/批) │ │
│ │ - 批量写入 ES(Bulk API) │ │
│ │ - 失败重试 + 死信队列 │ │
│ └──────────┬──────────────────────────────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Elasticsearch 集群 │ │
│ │ - 索引分片(30个主分片 + 副本) │ │
│ │ - 索引生命周期管理(ILM) │ │
│ └─────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
方案1:实时增量同步(Canal + Kafka)
1.1 Canal 集群部署(高可用)
# canal-deployer/conf/canal.properties
# Canal 集群配置
canal.id = 1
canal.ip =
canal.port = 11111
canal.zkServers = zk1:2181,zk2:2181,zk3:2181
# 高可用配置
canal.serverMode = kafka
kafka.bootstrap.servers = kafka1:9092,kafka2:9092,kafka3:9092
# 性能优化
canal.instance.memory.buffer.size = 32768
canal.instance.memory.buffer.memunit = 1024
1.2 Canal Instance 配置(监听分库)
# instance-ds0/instance.properties
# 监听 ds-0 数据库
canal.instance.master.address = mysql-ds-0:3306
canal.instance.dbUsername = canal
canal.instance.dbPassword = canal123
canal.instance.connectionCharset = UTF-8
# 只监听订单表
canal.instance.filter.regex = order_db_0\\.t_order_.*
# Kafka 配置
canal.mq.topic = order-sync-0
canal.mq.partition = 0
canal.mq.partitionsNum = 3
canal.mq.partitionHash = user_id
# instance-ds1/instance.properties
# 监听 ds-1 数据库
canal.instance.master.address = mysql-ds-1:3307
canal.instance.filter.regex = order_db_1\\.t_order_.*
canal.mq.topic = order-sync-1
# instance-ds2/instance.properties
# 监听 ds-2 数据库
canal.instance.master.address = mysql-ds-2:3308
canal.instance.filter.regex = order_db_2\\.t_order_.*
canal.mq.topic = order-sync-2
1.3 Kafka Consumer(批量消费)
/**
* 海量数据同步消费者(批量处理)
*/
@Component
@Slf4j
public class OrderESSyncConsumer {
@Autowired
private RestHighLevelClient esClient;
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final int BATCH_SIZE = 1000; // 批量大小
private static final int BULK_RETRY_TIMES = 3; // 重试次数
/**
* 消费 Kafka 消息(批量模式)
*/
@KafkaListener(
topics = {"order-sync-0", "order-sync-1", "order-sync-2"},
groupId = "es-sync-group",
containerFactory = "batchKafkaListenerContainerFactory"
)
public void consumeBatch(List<ConsumerRecord<String, String>> records) {
log.info("开始批量处理: size={}", records.size());
// 1. 解析 Canal 消息
List<OrderES> orderList = new ArrayList<>();
List<String> deleteIds = new ArrayList<>();
for (ConsumerRecord<String, String> record : records) {
try {
CanalMessage message = JSON.parseObject(record.value(), CanalMessage.class);
if ("INSERT".equals(message.getType()) || "UPDATE".equals(message.getType())) {
// 转换为 ES 文档
OrderES orderES = convertToOrderES(message.getData());
orderList.add(orderES);
} else if ("DELETE".equals(message.getType())) {
// 记录删除ID
deleteIds.add(message.getData().get("order_id"));
}
} catch (Exception e) {
log.error("解析消息失败: offset={}", record.offset(), e);
// 记录失败消息到死信队列
sendToDeadLetter(record);
}
}
// 2. 批量写入 ES(Bulk API)
if (!orderList.isEmpty()) {
bulkIndexToES(orderList);
}
// 3. 批量删除
if (!deleteIds.isEmpty()) {
bulkDeleteFromES(deleteIds);
}
log.info("批量处理完成: insert={}, delete={}", orderList.size(), deleteIds.size());
}
/**
* 批量写入 ES(使用 Bulk API)
*/
private void bulkIndexToES(List<OrderES> orders) {
int totalSize = orders.size();
int batchCount = (totalSize + BATCH_SIZE - 1) / BATCH_SIZE;
for (int i = 0; i < batchCount; i++) {
int fromIndex = i * BATCH_SIZE;
int toIndex = Math.min((i + 1) * BATCH_SIZE, totalSize);
List<OrderES> batchOrders = orders.subList(fromIndex, toIndex);
// 构建 Bulk Request
BulkRequest bulkRequest = new BulkRequest();
for (OrderES order : batchOrders) {
IndexRequest indexRequest = new IndexRequest("orders")
.id(String.valueOf(order.getOrderId()))
.source(JSON.toJSONString(order), XContentType.JSON);
bulkRequest.add(indexRequest);
}
// 执行 Bulk 操作(带重试)
executeBulkWithRetry(bulkRequest);
}
}
/**
* 执行 Bulk 操作(带重试机制)
*/
private void executeBulkWithRetry(BulkRequest bulkRequest) {
for (int retry = 0; retry < BULK_RETRY_TIMES; retry++) {
try {
BulkResponse bulkResponse = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
if (bulkResponse.hasFailures()) {
// 部分失败,记录失败项
for (BulkItemResponse itemResponse : bulkResponse.getItems()) {
if (itemResponse.isFailed()) {
log.error("ES写入失败: id={}, reason={}",
itemResponse.getId(),
itemResponse.getFailureMessage());
// 记录失败ID,后续补偿
recordFailedId(itemResponse.getId());
}
}
} else {
// 全部成功
return;
}
} catch (Exception e) {
log.error("Bulk执行异常: retry={}/{}", retry + 1, BULK_RETRY_TIMES, e);
if (retry == BULK_RETRY_TIMES - 1) {
// 最后一次重试失败,记录到死信队列
recordFailedBulk(bulkRequest);
} else {
// 等待后重试
try {
Thread.sleep(1000 * (retry + 1));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
}
}
/**
* 批量删除
*/
private void bulkDeleteFromES(List<String> orderIds) {
BulkRequest bulkRequest = new BulkRequest();
for (String orderId : orderIds) {
DeleteRequest deleteRequest = new DeleteRequest("orders", orderId);
bulkRequest.add(deleteRequest);
}
executeBulkWithRetry(bulkRequest);
}
/**
* 记录失败ID(用于补偿)
*/
private void recordFailedId(String orderId) {
String key = "es:sync:failed:" + LocalDate.now();
redisTemplate.opsForSet().add(key, orderId);
redisTemplate.expire(key, 7, TimeUnit.DAYS);
}
}
1.4 Kafka 配置(性能优化)
@Configuration
public class KafkaConsumerConfig {
/**
* 批量消费配置
*/
@Bean
public KafkaListenerContainerFactory<?> batchKafkaListenerContainerFactory(
ConsumerFactory<String, String> consumerFactory) {
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory);
// 批量消费配置
factory.setBatchListener(true); // 开启批量消费
factory.getContainerProperties().setPollTimeout(3000); // 3秒拉取一次
// 性能优化
factory.setConcurrency(3); // 3个并发消费者
factory.getContainerProperties().setAckMode(
ContainerProperties.AckMode.BATCH); // 批量提交offset
return factory;
}
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka1:9092,kafka2:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "es-sync-group");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
// 性能优化
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 1000); // 每次拉取1000条
props.put(ConsumerConfig.FETCH_MIN_BYTES_CONFIG, 1024 * 1024); // 1MB
props.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 500); // 最多等待500ms
return new DefaultKafkaConsumerFactory<>(props);
}
}
一致性要求分析(重要理念)
核心观点:ES 不需要与数据库强一致,只需要最终一致!
┌─────────────────────────────────────────────────────────────┐
│ 数据一致性要求对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ MySQL(主存储):强一致性 │
│ ┌──────────┐ │
│ │ 写请求 │ │
│ └────┬─────┘ │
│ │ │
│ ├─► 1. 事务提交 │
│ ├─► 2. 立即可读 │
│ └─► 3. ACID 保证 │
│ │
│ ✅ 要求:实时可见,绝对一致 │
│ ✅ 场景:订单详情、账户余额、库存数量 │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ Elasticsearch(搜索引擎):最终一致性 │
│ ┌──────────┐ │
│ │ 写请求 │ │
│ └────┬─────┘ │
│ │ │
│ ├─► 1. 异步同步(1-3秒延迟) │
│ ├─► 2. refresh 后可搜索(默认1秒) │
│ └─► 3. 允许短暂不一致 │
│ │
│ ✅ 要求:最终一致即可,允许延迟 │
│ ✅ 场景:订单搜索、统计分析、日志查询 │
│ │
└─────────────────────────────────────────────────────────────┘
为什么 ES 不需要强一致性?
// ═══════════════════════════════════════════════════════════
// 1. 业务场景决定一致性要求
// ═══════════════════════════════════════════════════════════
/**
* 场景1:下单后查看订单详情(需要强一致)
*/
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository; // MySQL
@Autowired
private ElasticsearchRestTemplate esTemplate; // ES
/**
* 下单
*/
@Transactional
public Order createOrder(OrderDTO dto) {
// 1. 写入 MySQL
Order order = orderRepository.save(order);
// 2. 异步同步到 ES(不等待)
asyncSyncToES(order);
// 3. 立即返回
return order;
}
/**
* 查看订单详情
* ✅ 正确做法:从 MySQL 查
*/
public Order getOrderDetail(Long orderId, Long userId) {
// 原因:用户刚下单,ES 可能还没同步完成(1-3秒延迟)
// 从 MySQL 查询,保证能看到刚创建的订单
return orderRepository.findByOrderIdAndUserId(orderId, userId)
.orElseThrow(() -> new NotFoundException("订单不存在"));
}
/**
* ❌ 错误做法:从 ES 查订单详情
*/
public OrderES getOrderDetailWrong(Long orderId) {
// 问题:用户刚下单,立即查询
// ES 还没同步,查不到订单
// 用户会认为下单失败!
return esTemplate.get(orderId, OrderES.class); // ❌ 可能查不到
}
}
/**
* 场景2:搜索历史订单(允许延迟)
*/
@Service
public class OrderSearchService {
@Autowired
private ElasticsearchRestTemplate esTemplate;
/**
* 搜索订单(可以用 ES)
* ✅ 正确做法:从 ES 搜索
*/
public Page<OrderES> searchOrders(String keyword, Pageable pageable) {
// 原因:
// 1. 搜索的是历史订单(不是刚创建的)
// 2. 延迟 1-3 秒完全可以接受
// 3. 用户不会注意到这个延迟
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.matchQuery("productName", keyword))
.withPageable(pageable)
.build();
return esTemplate.queryForPage(query, OrderES.class);
}
/**
* 订单统计(允许延迟)
*/
public OrderStatistics getStatistics(Long userId) {
// 统计数据允许有延迟
// 用户不会期望统计数据是实时的
// 延迟几秒完全没问题
return esTemplate.query(...);
}
}
// ═══════════════════════════════════════════════════════════
// 2. 实时性要求分级
// ═══════════════════════════════════════════════════════════
/**
* 不同场景的一致性要求
*/
┌──────────────────┬──────────────┬──────────────┬──────────────┐
│ 场景 │ 一致性要求 │ 延迟容忍 │ 数据源 │
├──────────────────┼──────────────┼──────────────┼──────────────┤
│ 订单详情 │ 强一致 │ 0ms │ MySQL ✅ │
│ 支付确认 │ 强一致 │ 0ms │ MySQL ✅ │
│ 库存扣减 │ 强一致 │ 0ms │ MySQL ✅ │
│ 账户余额 │ 强一致 │ 0ms │ MySQL ✅ │
├──────────────────┼──────────────┼──────────────┼──────────────┤
│ 订单列表(自己的) │ 弱一致 │ < 5秒 │ MySQL ✅ │
│ 我的订单(最近10条)│ 最终一致 │ < 10秒 │ ES ✅ │
├──────────────────┼──────────────┼──────────────┼──────────────┤
│ 订单搜索 │ 最终一致 │ < 1分钟 │ ES ✅ │
│ 历史订单查询 │ 最终一致 │ < 5分钟 │ ES ✅ │
│ 订单统计报表 │ 最终一致 │ < 1小时 │ ES ✅ │
│ 商品搜索 │ 最终一致 │ < 10分钟 │ ES ✅ │
│ 日志查询 │ 最终一致 │ < 1小时 │ ES ✅ │
└──────────────────┴──────────────┴──────────────┴──────────────┘
// ═══════════════════════════════════════════════════════════
// 3. 延迟对用户体验的影响
// ═══════════════════════════════════════════════════════════
/**
* 用户能感知的延迟 vs 不能感知的延迟
*/
// 场景 A:下单后立即查看订单详情
用户操作:
1. 点击"提交订单"按钮
2. 1秒后跳转到"订单详情"页面
3. 期望:看到刚创建的订单
如果从 ES 查:
✅ MySQL 写入成功(10ms)
❌ ES 同步延迟(1-3秒)
❌ 用户查询时 ES 还没有数据
❌ 页面显示"订单不存在"
❌ 用户困惑:我刚下的单呢?
解决方案:
✅ 订单详情必须从 MySQL 查
✅ 保证用户立即能看到
// 场景 B:搜索上个月的订单
用户操作:
1. 打开"历史订单"页面
2. 输入商品名称搜索
3. 期望:找到之前的订单
如果从 ES 查:
✅ 订单是历史数据(1个月前)
✅ ES 同步延迟(1-3秒)完全没影响
✅ 用户不会注意到延迟
✅ 搜索速度快,体验好
结论:
✅ 历史数据查询可以用 ES
✅ 延迟完全可以接受
// 场景 C:实时更新订单状态
用户操作:
1. 查看订单详情,状态为"待支付"
2. 完成支付
3. 期望:状态立即变为"已支付"
如果从 ES 查:
✅ MySQL 更新状态(10ms)
❌ ES 同步延迟(1-3秒)
❌ 用户刷新页面,状态还是"待支付"
❌ 用户困惑:我刚付款了怎么还是待支付?
解决方案:
✅ 订单详情从 MySQL 查
✅ 支付后立即能看到新状态
// ═══════════════════════════════════════════════════════════
// 4. 实际代码实现(区分场景)
// ═══════════════════════════════════════════════════════════
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@Autowired
private OrderSearchService searchService;
/**
* 创建订单
*/
@PostMapping
public Result<Order> createOrder(@RequestBody OrderDTO dto) {
Order order = orderService.createOrder(dto);
return Result.success(order);
}
/**
* 订单详情(强一致性)
* ✅ 从 MySQL 查
*/
@GetMapping("/{orderId}")
public Result<Order> getOrderDetail(
@PathVariable Long orderId,
@RequestHeader("User-Id") Long userId) {
// 从 MySQL 查询,保证实时性
Order order = orderService.getOrderDetail(orderId, userId);
return Result.success(order);
}
/**
* 我的订单列表(弱一致性)
* ✅ 从 MySQL 查(因为有分片键 user_id)
*/
@GetMapping("/my")
public Result<List<Order>> getMyOrders(
@RequestHeader("User-Id") Long userId,
@RequestParam(defaultValue = "10") Integer limit) {
// 虽然可以用 ES,但因为有分片键,MySQL 查询也很快
// 而且保证实时性更好
List<Order> orders = orderService.getUserOrders(userId, limit);
return Result.success(orders);
}
/**
* 搜索订单(最终一致性)
* ✅ 从 ES 查
*/
@GetMapping("/search")
public Result<Page<OrderES>> searchOrders(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
// 搜索历史订单,允许有延迟
// 用户搜索的不会是刚创建的订单
Pageable pageable = PageRequest.of(page, size);
Page<OrderES> result = searchService.searchOrders(keyword, pageable);
return Result.success(result);
}
/**
* 订单统计(最终一致性)
* ✅ 从 ES 查
*/
@GetMapping("/statistics")
public Result<OrderStatistics> getStatistics(
@RequestHeader("User-Id") Long userId) {
// 统计数据,允许延迟
// 用户不会期望统计是实时的
OrderStatistics stats = searchService.getStatistics(userId);
return Result.success(stats);
}
}
// ═══════════════════════════════════════════════════════════
// 5. 性能 vs 一致性权衡
// ═══════════════════════════════════════════════════════════
/**
* 方案对比
*/
// 方案1:ES 同步刷盘(强一致,性能差)❌ 不推荐
PUT /orders/_settings
{
"index.translog.durability": "request", // 每次请求都刷盘
"index.refresh_interval": "100ms" // 100ms 刷新一次
}
性能影响:
- 写入 TPS 下降 50-70%
- 写入延迟增加 5-10 倍
- 集群负载增加
收益:
- 同步延迟从 1-3 秒降到 100ms
- 但用户仍然可能看不到刚创建的数据
结论:❌ 不值得!性能损失太大,收益很小
// 方案2:ES 异步刷盘(最终一致,性能好)✅ 推荐
PUT /orders/_settings
{
"index.translog.durability": "async", // 异步刷盘(默认)
"index.translog.sync_interval": "5s", // 5秒同步一次
"index.refresh_interval": "1s" // 1秒刷新一次
}
性能影响:
- 写入 TPS 正常
- 写入延迟低
- 集群负载正常
延迟:
- 端到端延迟:1-5 秒
- 对搜索场景完全可接受
结论:✅ 推荐!性能好,延迟可接受
// ═══════════════════════════════════════════════════════════
// 6. 最佳实践建议
// ═══════════════════════════════════════════════════════════
/**
* 数据源选择决策树
*/
查询请求
↓
是否查询刚创建/更新的数据?
├─ 是 → 使用 MySQL
│ └─ 例:订单详情、支付确认
│
└─ 否 → 是否有分片键?
├─ 是 → 使用 MySQL
│ └─ 例:用户订单列表
│
└─ 否 → 是否复杂搜索?
├─ 是 → 使用 ES
│ └─ 例:全文搜索、聚合统计
│
└─ 否 → 根据数据量决定
├─ 小 → MySQL
└─ 大 → ES
/**
* 一致性配置建议
*/
// MySQL 配置(强一致)
[mysqld]
innodb_flush_log_at_trx_commit = 1 # 强一致
sync_binlog = 1 # 强一致
// ES 配置(最终一致)
PUT /_cluster/settings
{
"transient": {
"index.translog.durability": "async", # ✅ 异步(推荐)
"index.translog.sync_interval": "5s", # ✅ 5秒(推荐)
"index.refresh_interval": "1s" # ✅ 1秒(推荐)
}
}
// Canal 配置(准实时)
canal.mq.flatMessage = true
canal.instance.tsdb.enable = true
// Kafka 配置
max.poll.records = 1000 # 批量消费
fetch.min.bytes = 1048576 # 1MB
结论:
✅ MySQL 强一致(性能损失可接受)
✅ ES 最终一致(延迟可接受)
✅ Canal 准实时同步(1-3秒)
✅ 整体平衡性能与一致性
关键结论:
核心理念:
1. ES 是搜索引擎,不是数据库
2. ES 只需要最终一致,不需要强一致
3. 1-5秒的延迟对搜索场景完全可以接受
4. 用户搜索的都是历史数据,不是刚创建的
使用原则:
- 刚创建/更新的数据 → MySQL(强一致)
- 历史数据搜索 → ES(最终一致)
- 统计分析 → ES(允许延迟)
- 实时详情 → MySQL(不能有延迟)
性能优化:
- 不要为了实时性牺牲 ES 性能
- 异步同步比同步刷盘快 5-10 倍
- 最终一致性已经足够好
用户体验:
- 用户不会注意到 1-5 秒延迟(搜索历史数据)
- 用户会注意到刚创建的数据查不到(详情页)
- 所以详情用 MySQL,搜索用 ES
方案2:定期全量同步(数据对账)
/**
* 全量同步任务(定期执行)
* 用途:
* 1. 初始化 ES 索引
* 2. 数据对账和修复
* 3. 重建索引
*/
@Component
@Slf4j
public class FullSyncJob {
@Autowired
private List<DataSource> shardingDataSources; // 所有分库数据源
@Autowired
private RestHighLevelClient esClient;
@Autowired
private ThreadPoolExecutor syncExecutor;
private static final int PAGE_SIZE = 10000; // 每页1万条
/**
* 全量同步(分片并行)
* 每周执行一次
*/
@Scheduled(cron = "0 0 2 ? * SUN") // 每周日凌晨2点
public void fullSync() {
log.info("开始全量同步...");
long startTime = System.currentTimeMillis();
try {
// 1. 创建新索引(使用别名切换,零停机)
String newIndex = "orders_" + System.currentTimeMillis();
createESIndex(newIndex);
// 2. 并行同步所有分库分表
CountDownLatch latch = new CountDownLatch(shardingDataSources.size() * 10);
AtomicLong totalCount = new AtomicLong(0);
for (int dbIndex = 0; dbIndex < shardingDataSources.size(); dbIndex++) {
DataSource dataSource = shardingDataSources.get(dbIndex);
// 每个库的10张表
for (int tableIndex = 0; tableIndex < 10; tableIndex++) {
int finalDbIndex = dbIndex;
int finalTableIndex = tableIndex;
// 提交异步任务
syncExecutor.execute(() -> {
try {
long count = syncSingleTable(
dataSource,
finalDbIndex,
finalTableIndex,
newIndex
);
totalCount.addAndGet(count);
log.info("完成同步: ds-{}.t_order_{}, count={}",
finalDbIndex, finalTableIndex, count);
} catch (Exception e) {
log.error("同步失败: ds-{}.t_order_{}",
finalDbIndex, finalTableIndex, e);
} finally {
latch.countDown();
}
});
}
}
// 3. 等待所有任务完成
latch.await(2, TimeUnit.HOURS);
// 4. 切换索引别名(原子操作)
switchIndexAlias(newIndex);
// 5. 删除旧索引
deleteOldIndices();
long duration = System.currentTimeMillis() - startTime;
log.info("全量同步完成: total={}, duration={}ms", totalCount.get(), duration);
} catch (Exception e) {
log.error("全量同步失败", e);
}
}
/**
* 同步单个表
*/
private long syncSingleTable(DataSource dataSource, int dbIndex, int tableIndex, String esIndex)
throws SQLException {
String tableName = "t_order_" + tableIndex;
long syncCount = 0;
long minId = 0;
try (Connection conn = dataSource.getConnection()) {
while (true) {
// 分页查询(使用主键范围,避免深分页)
String sql = String.format(
"SELECT * FROM %s WHERE order_id > ? ORDER BY order_id LIMIT ?",
tableName
);
List<Order> orders = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, minId);
ps.setInt(2, PAGE_SIZE);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Order order = mapResultSetToOrder(rs);
orders.add(order);
minId = order.getOrderId();
}
}
if (orders.isEmpty()) {
break;
}
// 批量写入 ES
bulkIndexToES(orders, esIndex);
syncCount += orders.size();
log.debug("同步进度: ds-{}.{}, count={}", dbIndex, tableName, syncCount);
}
}
return syncCount;
}
/**
* 批量写入 ES
*/
private void bulkIndexToES(List<Order> orders, String indexName) throws IOException {
BulkRequest bulkRequest = new BulkRequest();
for (Order order : orders) {
OrderES orderES = convertToOrderES(order);
IndexRequest indexRequest = new IndexRequest(indexName)
.id(String.valueOf(order.getOrderId()))
.source(JSON.toJSONString(orderES), XContentType.JSON);
bulkRequest.add(indexRequest);
}
// 执行批量写入
BulkResponse bulkResponse = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
if (bulkResponse.hasFailures()) {
log.warn("Bulk写入有失败: {}", bulkResponse.buildFailureMessage());
}
}
/**
* 切换索引别名(零停机)
*/
private void switchIndexAlias(String newIndex) throws IOException {
IndicesAliasesRequest request = new IndicesAliasesRequest();
// 移除旧索引的别名
IndicesAliasesRequest.AliasActions removeAction =
IndicesAliasesRequest.AliasActions.remove()
.index("orders_*")
.alias("orders");
// 添加新索引的别名
IndicesAliasesRequest.AliasActions addAction =
IndicesAliasesRequest.AliasActions.add()
.index(newIndex)
.alias("orders");
request.addAliasAction(removeAction);
request.addAliasAction(addAction);
// 原子操作切换
esClient.indices().updateAliases(request, RequestOptions.DEFAULT);
log.info("索引别名切换完成: {} -> orders", newIndex);
}
}
方案3:失败补偿机制
/**
* 同步失败补偿任务
*/
@Component
@Slf4j
public class SyncCompensationJob {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private OrderRepository orderRepository;
@Autowired
private RestHighLevelClient esClient;
/**
* 补偿失败的数据
* 每小时执行一次
*/
@Scheduled(cron = "0 0 * * * ?")
public void compensateFailedSync() {
// 1. 获取失败记录
String key = "es:sync:failed:" + LocalDate.now();
Set<String> failedIds = redisTemplate.opsForSet().members(key);
if (failedIds == null || failedIds.isEmpty()) {
return;
}
log.info("开始补偿同步: count={}", failedIds.size());
// 2. 分批从MySQL查询并重新同步
List<Long> orderIds = failedIds.stream()
.map(Long::parseLong)
.collect(Collectors.toList());
int batchSize = 100;
for (int i = 0; i < orderIds.size(); i += batchSize) {
List<Long> batchIds = orderIds.subList(
i,
Math.min(i + batchSize, orderIds.size())
);
// 从MySQL查询
List<Order> orders = orderRepository.findAllById(batchIds);
// 重新同步到ES
try {
bulkIndexToES(orders, "orders");
// 成功后移除失败记录
batchIds.forEach(id ->
redisTemplate.opsForSet().remove(key, String.valueOf(id)));
} catch (Exception e) {
log.error("补偿失败: ids={}", batchIds, e);
}
}
log.info("补偿同步完成");
}
/**
* 数据一致性校验
* 每天执行一次
*/
@Scheduled(cron = "0 0 3 * * ?")
public void verifyDataConsistency() {
log.info("开始数据一致性校验...");
try {
// 1. 统计 MySQL 总数
long mysqlCount = orderRepository.count();
// 2. 统计 ES 总数
CountRequest countRequest = new CountRequest("orders");
CountResponse countResponse = esClient.count(countRequest, RequestOptions.DEFAULT);
long esCount = countResponse.getCount();
// 3. 对比
long diff = Math.abs(mysqlCount - esCount);
double diffRate = diff * 100.0 / mysqlCount;
log.info("数据一致性校验: MySQL={}, ES={}, diff={}, rate={}%",
mysqlCount, esCount, diff, String.format("%.2f", diffRate));
// 4. 如果差异超过阈值,触发告警
if (diffRate > 1.0) { // 差异超过1%
alertDataInconsistency(mysqlCount, esCount, diff);
}
} catch (Exception e) {
log.error("数据一致性校验失败", e);
}
}
private void alertDataInconsistency(long mysqlCount, long esCount, long diff) {
// 发送告警通知
log.error("数据不一致告警: MySQL={}, ES={}, diff={}", mysqlCount, esCount, diff);
// TODO: 发送钉钉/邮件告警
}
}
性能优化与监控
/**
* 同步性能监控
*/
@Component
public class SyncMetrics {
private final MeterRegistry meterRegistry;
// 同步速率
private final Counter syncCounter;
// 同步延迟
private final Timer syncLatency;
// 失败计数
private final Counter failCounter;
public SyncMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.syncCounter = Counter.builder("es.sync.count")
.description("ES同步数量")
.tag("type", "realtime")
.register(meterRegistry);
this.syncLatency = Timer.builder("es.sync.latency")
.description("ES同步延迟")
.register(meterRegistry);
this.failCounter = Counter.builder("es.sync.fail")
.description("ES同步失败数")
.register(meterRegistry);
}
public void recordSync(int count) {
syncCounter.increment(count);
}
public void recordLatency(long milliseconds) {
syncLatency.record(milliseconds, TimeUnit.MILLISECONDS);
}
public void recordFailure() {
failCounter.increment();
}
}
监控指标:
关键指标:
1. 同步TPS: es.sync.count (每秒同步数量)
2. 同步延迟: es.sync.latency (端到端延迟)
3. 积压数量: kafka.consumer.lag (Kafka消费延迟)
4. 失败率: es.sync.fail / es.sync.count
5. ES写入性能: es.bulk.latency
告警规则:
- 同步延迟 > 30秒
- 失败率 > 1%
- Kafka积压 > 10万条
- 数据一致性差异 > 1%
总结对比
┌──────────────┬─────────────┬─────────────┬─────────────┬─────────────┐
│ 方案 │ 实时性 │ 吞吐量 │ 可靠性 │ 复杂度 │
├──────────────┼─────────────┼─────────────┼─────────────┼─────────────┤
│ Canal+Kafka │ 秒级 │ 100万+/天 │ 高 │ 中 │
│ (实时增量) │ < 3s │ │ ✅ │ │
├──────────────┼─────────────┼─────────────┼─────────────┼─────────────┤
│ 定时全量 │ 小时级 │ 亿级/次 │ 最高 │ 低 │
│ (数据对账) │ 每周一次 │ │ ✅✅ │ │
├──────────────┼─────────────┼─────────────┼─────────────┼─────────────┤
│ 失败补偿 │ 小时级 │ 千级/次 │ 补充 │ 低 │
│ (容错机制) │ 每小时 │ │ ✅ │ │
└──────────────┴─────────────┴─────────────┴─────────────┴─────────────┘
推荐组合方案:
✅ Canal + Kafka (实时增量) - 主力
✅ 定时全量同步 (每周一次) - 数据对账
✅ 失败补偿 (每小时) - 容错兜底
✅ 一致性校验 (每天) - 监控告警
ES 文档设计(冗余字段):
/**
* ES 中的订单文档
* 注意:为了搜索性能,适当冗余关联数据
*/
@Document(indexName = "orders")
public class OrderES {
@Id
private Long orderId;
private Long userId;
// 冗余用户信息(避免 JOIN)
private String userName;
private String userPhone;
private Long productId;
// 冗余商品信息(方便搜索和展示)
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String productName;
@Field(type = FieldType.Keyword)
private String productCategory;
private BigDecimal amount;
@Field(type = FieldType.Keyword)
private String status;
@Field(type = FieldType.Date, format = DateFormat.date_time)
private Date createTime;
// ES 特有:全文搜索字段
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String searchText; // 组合多个字段用于全文搜索
// ES 特有:地理位置
@GeoPointField
private GeoPoint deliveryLocation;
}
总结对比表:
┌──────────────────┬──────────────────┬──────────────────┐
│ 场景 │ 使用 MySQL │ 使用 ES │
├──────────────────┼──────────────────┼──────────────────┤
│ 创建订单 │ ✅ │ ❌ │
│ 更新订单状态 │ ✅ │ ❌ │
│ 删除订单 │ ✅ │ ❌ │
│ 订单详情(byId) │ ✅ │ ❌ │
│ 用户订单列表 │ ✅ │ ❌ │
│ 订单搜索(多条件) │ ❌ │ ✅ │
│ 订单全文搜索 │ ❌ │ ✅ │
│ 订单统计聚合 │ ❌ │ ✅ │
│ 大偏移量分页 │ ❌ │ ✅ │
│ 金额范围查询 │ ✅ │ ✅ │
└──────────────────┴──────────────────┴──────────────────┘
规则:
1. 所有写操作(增删改) → MySQL
2. 按ID/分片键查询 → MySQL
3. 复杂搜索/聚合/分页 → ES
4. 数据通过 Canal/MQ 从 MySQL → ES
常见问题:
Q1: 为什么不直接用 ES,省去 MySQL?
A1:
- ES 不保证事务,可能丢数据(异步刷盘)
- ES 不适合频繁更新
- ES 没有完整的约束机制
- 订单、支付等核心数据必须用数据库保证可靠性
Q2: 为什么不只用 MySQL,省去 ES?
A2:
- MySQL 全文搜索能力弱
- 跨分片复杂查询性能差
- 大数据量聚合分析慢
- 没有相关性排序能力
Q3: ES 和 MySQL 数据不一致怎么办?
A3:
- 以 MySQL 为准(主存储)
- ES 仅作为搜索引擎,允许短暂延迟
- 提供全量/增量同步机制
- 关键查询走 MySQL,搜索走 ES
Q4: 什么时候可以只用 ES?
A4:
- 日志分析(允许丢失)
- 监控数据(允许丢失)
- 用户行为分析(非核心数据)
- 全文搜索引擎(内容管理)
⚠️ 核心业务数据(订单/支付/账户)必须用数据库
2.0.6 分页性能对比
// 性能测试数据(基于 3 个分片)
┌──────────────┬─────────────┬─────────────┬──────────────┐
│ 方案 │ 查询时间 │ 内存占用 │ 适用场景 │
├──────────────┼─────────────┼─────────────┼──────────────┤
│ 精确路由分页 │ ~10ms │ < 1MB │ 有分片键 │
├──────────────┼─────────────┼─────────────┼──────────────┤
│ 全路由小偏移 │ ~50ms │ < 5MB │ offset<100 │
│ (LIMIT 0,20) │ │ │ │
├──────────────┼─────────────┼─────────────┼──────────────┤
│ 全路由大偏移 │ ~2000ms │ ~100MB │ 不推荐 │
│(LIMIT 10000,20)│ │ │ │
├──────────────┼─────────────┼─────────────┼──────────────┤
│ 游标分页 │ ~10ms │ < 1MB │ 推荐 │
│ (WHERE id<?) │ │ │ │
├──────────────┼─────────────┼─────────────┼──────────────┤
│ ES搜索 │ ~20ms │ < 2MB │ 复杂查询 │
└──────────────┴─────────────┴─────────────┴──────────────┘
2.0.7 ShardingSphere 对应用层的透明性
核心原理:对应用层完全透明
ShardingSphere-JDBC 作为一个增强的 JDBC 驱动,完全兼容标准的 JDBC API,因此对 JPA、MyBatis、MyBatis-Plus 等 ORM 框架都是零侵入的。
// ✅ 应用层代码无需改变
// 1. JPA Repository 写法完全一样
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// 方法定义不变
List<Order> findByUserId(Long userId);
Page<Order> findByStatus(String status, Pageable pageable);
@Query("SELECT o FROM Order o WHERE o.userId = ?1 AND o.status = ?2")
List<Order> customQuery(Long userId, String status);
}
// 2. Service 层代码不变
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
// 业务逻辑完全不变
public Order createOrder(Order order) {
return orderRepository.save(order); // ShardingSphere 自动路由
}
public List<Order> getUserOrders(Long userId) {
return orderRepository.findByUserId(userId); // 自动计算分片
}
}
// 3. MyBatis-Plus 写法也不变
@Service
public class OrderMpService extends ServiceImpl<OrderMapper, Order> {
public boolean saveOrder(Order order) {
return this.save(order); // 自动分片保存
}
public IPage<Order> pageQuery(Page<Order> page, Long userId) {
return this.page(page,
new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId)); // 自动路由查询
}
}
ShardingSphere 底层做了什么:
应用层调用
↓
JPA/MyBatis 生成 SQL
↓
JDBC Statement 执行
↓
┌─────────────────────────────────────────┐
│ ShardingSphere JDBC Driver (拦截层) │
│ │
│ 1. SQL 解析 │
│ - 解析表名、字段、条件 │
│ - 提取分片键的值 │
│ │
│ 2. 路由计算 │
│ - 根据分片键计算目标库表 │
│ - user_id=1001 → ds-1.t_order_1 │
│ │
│ 3. SQL 改写 │
│ - t_order → t_order_1 │
│ - 逻辑表名 → 物理表名 │
│ │
│ 4. SQL 执行 │
│ - 路由到正确的数据源 │
│ - 并行执行(如果多分片) │
│ │
│ 5. 结果归并 │
│ - 合并多个分片的结果 │
│ - 排序、分页、聚合 │
└─────────────────────────────────────────┘
↓
返回结果给应用层(应用层感知不到分片)
具体执行示例:
// 应用层代码
Order order = new Order();
order.setUserId(1001L);
order.setAmount(new BigDecimal("199.99"));
orderRepository.save(order);
// ↓ JPA 生成 SQL
// INSERT INTO t_order (user_id, amount, ...) VALUES (1001, 199.99, ...)
// ↓ ShardingSphere 拦截处理
// 1. 解析:提取 user_id = 1001
// 2. 路由计算:
// - 数据库:ds-${1001 % 3} = ds-1
// - 表:t_order_${1001 % 10} = t_order_1
// 3. SQL 改写:
// INSERT INTO t_order_1 (user_id, amount, ...) VALUES (1001, 199.99, ...)
// 4. 执行:在 ds-1 数据源上执行
// 5. 返回:返回插入结果
需要注意的细节:
// ⚠️ 注意事项 1:分片键必须在 SQL 中
// ❌ 不好的写法:缺少分片键
Order order = orderRepository.findById(orderId);
// 问题:只有 order_id,没有 user_id(分片键)
// 结果:需要查询所有分片,性能差
// ✅ 好的写法:包含分片键
Order order = orderRepository.findByUserIdAndOrderId(userId, orderId);
// 结果:精确路由到单个分片
// ⚠️ 注意事项 2:事务范围
@Transactional
public void updateOrders(Long userId, List<Long> orderIds) {
orderIds.forEach(orderId -> {
Order order = orderRepository.findByUserIdAndOrderId(userId, orderId);
order.setStatus("UPDATED");
orderRepository.save(order);
});
}
// 如果所有订单都属于同一用户(同一分片),本地事务有效
// 如果订单跨分片,需要使用 Seata 分布式事务
// ⚠️ 注意事项 3:批量操作优化
// ❌ 低效的批量插入
orders.forEach(order -> orderRepository.save(order)); // N 次数据库调用
// ✅ 高效的批量插入
orderRepository.saveAll(orders); // ShardingSphere 会按分片分组批量执行
配置对比:
# 无分片时的配置
spring:
datasource:
url: jdbc:mysql://localhost:3306/order_db
username: root
password: root123
# ↓↓↓ 改为分片后 ↓↓↓
# 有分片时的配置(应用代码不变!)
spring:
datasource:
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:shardingsphere-config.yaml
# 其他配置在 shardingsphere-config.yaml 中
总结:
✅ 应用层代码 100% 不变
- JPA Repository 接口定义不变
- Service 业务逻辑不变
- 查询方法不变
- 事务注解不变
✅ ShardingSphere 自动处理
- SQL 解析与改写
- 路由计算
- 结果归并
- 分布式主键生成
⚠️ 只需注意
- 尽量带上分片键查询(性能优化)
- 跨分片操作需要分布式事务
- 不支持跨分片 JOIN
2.0.8 分片键选择详解
问题1:分片键一定要是主键吗?
答案:不一定!分片键和主键是两个独立的概念。
// 示例 1:分片键 = 主键
CREATE TABLE t_order (
order_id BIGINT PRIMARY KEY, -- 主键
user_id BIGINT,
amount DECIMAL(10,2),
...
);
// 分片配置:使用主键作为分片键
sharding:
tables:
t_order:
table-strategy:
standard:
sharding-column: order_id # 分片键 = 主键
sharding-algorithm-name: order-id-mod
// 适用场景:按订单ID均匀分布
// 优点:主键查询直接命中单分片
// 缺点:按用户查询需要全分片扫描
// 示例 2:分片键 ≠ 主键(更常见)
CREATE TABLE t_order (
order_id BIGINT PRIMARY KEY, -- 主键
user_id BIGINT NOT NULL, -- 分片键(不是主键)
amount DECIMAL(10,2),
INDEX idx_user_id (user_id) -- 分片键要有索引!
);
// 分片配置:使用非主键字段作为分片键
sharding:
tables:
t_order:
database-strategy:
standard:
sharding-column: user_id # 分片键 = 普通字段
sharding-algorithm-name: user-id-mod
// 适用场景:按用户维度查询为主
// 优点:用户查询直接命中单分片
// 缺点:主键查询需要全分片扫描(除非带上 user_id)
问题2:可以使用多个列作为分片键吗?
答案:可以!ShardingSphere 支持复合分片键。
方式1:标准分片(单列)
# 单列分片键(最常见)
sharding:
tables:
t_order:
table-strategy:
standard:
sharding-column: user_id # 单个字段
sharding-algorithm-name: user-mod
sharding-algorithms:
user-mod:
type: MOD
props:
sharding-count: 10
方式2:复合分片(多列)
# 多列复合分片键
sharding:
tables:
t_order:
table-strategy:
complex:
sharding-columns: user_id,order_type # 多个字段
sharding-algorithm-name: complex-mod
sharding-algorithms:
complex-mod:
type: CLASS_BASED
props:
strategy: COMPLEX
algorithm-class-name: com.example.ComplexShardingAlgorithm
复合分片键 Java 实现:
package com.example;
import org.apache.shardingsphere.sharding.api.sharding.complex.ComplexKeysShardingAlgorithm;
import org.apache.shardingsphere.sharding.api.sharding.complex.ComplexKeysShardingValue;
import java.util.*;
/**
* 复合分片键算法
* 根据 user_id 和 order_type 联合决定分片
*/
public class ComplexShardingAlgorithm implements ComplexKeysShardingAlgorithm<Long> {
@Override
public Collection<String> doSharding(
Collection<String> availableTargetNames, // 所有可用的表名
ComplexKeysShardingValue<Long> shardingValue) { // 分片键的值
// 获取分片键的值
Map<String, Collection<Long>> columnNameAndShardingValuesMap =
shardingValue.getColumnNameAndShardingValuesMap();
Collection<Long> userIds = columnNameAndShardingValuesMap.get("user_id");
Collection<Long> orderTypes = columnNameAndShardingValuesMap.get("order_type");
Set<String> result = new HashSet<>();
// 遍历所有可能的组合
for (Long userId : userIds) {
for (Long orderType : orderTypes) {
// 自定义分片逻辑
// 例如:普通订单和VIP订单分开存储
String tableSuffix = calculateTableSuffix(userId, orderType);
String targetTable = shardingValue.getLogicTableName() + "_" + tableSuffix;
if (availableTargetNames.contains(targetTable)) {
result.add(targetTable);
}
}
}
return result;
}
/**
* 计算表后缀
* 业务规则:
* - VIP订单(type=1):按用户ID % 5 分布到 vip_0 ~ vip_4
* - 普通订单(type=0):按用户ID % 10 分布到 normal_0 ~ normal_9
*/
private String calculateTableSuffix(Long userId, Long orderType) {
if (orderType == 1) {
// VIP订单
return "vip_" + (userId % 5);
} else {
// 普通订单
return "normal_" + (userId % 10);
}
}
@Override
public Properties getProps() {
return new Properties();
}
@Override
public void init(Properties props) {
// 初始化配置
}
}
实际表结构:
-- 数据库中实际存在这些表
CREATE TABLE t_order_vip_0 (...); -- VIP用户订单
CREATE TABLE t_order_vip_1 (...);
CREATE TABLE t_order_vip_2 (...);
CREATE TABLE t_order_vip_3 (...);
CREATE TABLE t_order_vip_4 (...);
CREATE TABLE t_order_normal_0 (...); -- 普通用户订单
CREATE TABLE t_order_normal_1 (...);
...
CREATE TABLE t_order_normal_9 (...);
应用层使用:
// 应用层代码保持不变
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public Order createOrder(Long userId, Long orderType, BigDecimal amount) {
Order order = new Order();
order.setUserId(userId); // 分片键1
order.setOrderType(orderType); // 分片键2
order.setAmount(amount);
// ShardingSphere 自动根据 userId + orderType 计算分片
return orderRepository.save(order);
}
// 查询时也需要提供复合分片键
public List<Order> getUserOrders(Long userId, Long orderType) {
// 精确路由:直接定位到具体表
return orderRepository.findByUserIdAndOrderType(userId, orderType);
}
public List<Order> getAllUserOrders(Long userId) {
// 部分路由:需要查询该用户的所有订单类型
// 会查询 t_order_vip_X 和 t_order_normal_X 多个表
return orderRepository.findByUserId(userId);
}
}
方式3:Hint 强制路由(不依赖 SQL 中的字段)
/**
* 使用 Hint 强制指定分片
* 适用场景:分片键不在 SQL 中,而是从上下文获取
*/
@Service
public class OrderHintService {
@Autowired
private OrderMapper orderMapper;
public List<Order> getOrdersByHint(Long userId) {
// 通过 Hint 指定分片键
try (HintManager hintManager = HintManager.getInstance()) {
// 设置数据库分片键
hintManager.addDatabaseShardingValue("t_order", userId);
// 设置表分片键
hintManager.addTableShardingValue("t_order", userId);
// 执行查询(SQL 中可以不包含分片键)
return orderMapper.selectList(
new QueryWrapper<Order>()
.eq("status", "PENDING") // 没有 user_id
);
}
}
}
分片键选择对比:
┌──────────────┬─────────────────┬─────────────────┬──────────────────┐
│ 分片键类型 │ 优点 │ 缺点 │ 适用场景 │
├──────────────┼─────────────────┼─────────────────┼──────────────────┤
│ 单列-主键 │ 主键查询快 │ 其他查询全表扫描 │ 按ID查询为主 │
│ (order_id) │ 数据分布均匀 │ │ │
├──────────────┼─────────────────┼─────────────────┼──────────────────┤
│ 单列-业务键 │ 业务查询快 │ 主键查询需带分片键│ 按用户/租户查询 │
│ (user_id) │ 符合查询习惯 │ │ │
├──────────────┼─────────────────┼─────────────────┼──────────────────┤
│ 复合键 │ 灵活分片策略 │ 实现复杂 │ 多维度业务场景 │
│(user_id+type)│ 精细化控制 │ 查询需带全键 │ │
├──────────────┼─────────────────┼─────────────────┼──────────────────┤
│ Hint强制路由 │ 不依赖SQL字段 │ 代码侵入性 │ 特殊业务场景 │
│ │ 灵活控制 │ │ │
└──────────────┴─────────────────┴─────────────────┴──────────────────┘
分片键设计建议:
1. 选择原则:
✅ 高基数字段(如 user_id,不是 status)
✅ 覆盖80%查询场景的字段
✅ 数据分布均匀的字段
✅ 不易变化的字段
2. 索引要求:
⚠️ 分片键必须建立索引(即使不是主键)
⚠️ 复合分片键需要建立联合索引
3. 常见选择:
- 2C业务:user_id(用户维度)
- 2B业务:tenant_id(租户维度)
- 时序数据:create_time(时间维度)
- 订单系统:user_id + order_id(复合)
4. 避免选择:
❌ status(值太少,分布不均)
❌ update_time(会变化)
❌ 没有索引的字段
❌ NULL值多的字段
实战示例:电商订单表设计
-- 订单表设计
CREATE TABLE t_order (
-- 主键(全局唯一,雪花算法生成)
order_id BIGINT PRIMARY KEY COMMENT '订单ID',
-- 分片键(业务查询维度)
user_id BIGINT NOT NULL COMMENT '用户ID(分片键)',
-- 其他业务字段
order_no VARCHAR(32) NOT NULL COMMENT '订单号',
amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',
status VARCHAR(20) NOT NULL COMMENT '订单状态',
-- 时间字段
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 索引设计
INDEX idx_user_id (user_id), -- 分片键索引(必须)
INDEX idx_user_status (user_id, status), -- 组合查询
INDEX idx_user_time (user_id, create_time), -- 时间范围查询
UNIQUE KEY uk_order_no (order_no) -- 订单号唯一
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
-- 分片配置
# 按 user_id 分库:3个库
# 按 user_id 分表:每库10张表
# 总共 30 个物理表
2.0.9 最佳实践建议
1. 设计阶段
✅ 选择合适的分片键(覆盖80%查询场景)
✅ 分片键不一定是主键,选择业务查询最频繁的字段
✅ 复合分片键用于复杂业务场景
✅ 冗余必要字段(避免跨库JOIN)
✅ 设计合理的索引(分片键必须有索引)
✅ 预留扩展空间(分片数量设为2的幂)
2. 开发阶段
✅ 优先使用分片键查询
✅ 使用游标分页代替offset
✅ 复杂查询考虑ES
✅ 大数据导出使用流式查询
❌ 避免全表扫描
❌ 避免跨分片JOIN
❌ 避免大offset分页
3. 运维阶段
✅ 监控慢查询
✅ 定期分析查询分布
✅ 优化热点分片
✅ 合理设置连接池
4. SQL编写规范
// ✅ 好的实践
// 1. 带分片键的精确查询
SELECT * FROM t_order
WHERE user_id = 1001 AND order_id = 123456;
// 2. 带分片键的范围查询
SELECT * FROM t_order
WHERE user_id = 1001
AND create_time > '2024-01-01'
ORDER BY create_time DESC
LIMIT 20;
// 3. 游标分页
SELECT * FROM t_order
WHERE user_id = 1001 AND order_id < 123456
ORDER BY order_id DESC
LIMIT 20;
// ❌ 不好的实践
// 1. 无分片键的大偏移分页
SELECT * FROM t_order
WHERE status = 'PENDING'
ORDER BY create_time DESC
LIMIT 10000, 20; -- 会在所有分片上执行
// 2. 跨分片JOIN
SELECT o.*, u.name
FROM t_order o
JOIN t_user u ON o.user_id = u.user_id; -- 跨分片JOIN
// 3. 不带WHERE的全表查询
SELECT COUNT(*) FROM t_order; -- 全表扫描
2. ShardingSphere 分库分表方案(续)
2.1 分库分表策略设计
2.1.1 水平分库
# 按用户ID分库示例
datasource:
ds-0: # 数据库0
url: jdbc:mysql://db-0:3306/order_db_0
ds-1: # 数据库1
url: jdbc:mysql://db-1:3306/order_db_1
ds-2: # 数据库2
url: jdbc:mysql://db-2:3306/order_db_2
sharding:
default-database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: database-inline
sharding-algorithms:
database-inline:
type: INLINE
props:
algorithm-expression: ds-$->{user_id % 3}
2.1.2 水平分表
# 按订单ID分表示例
sharding:
tables:
t_order:
actual-data-nodes: ds-$->{0..2}.t_order_$->{0..9}
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: table-inline
sharding-algorithms:
table-inline:
type: INLINE
props:
algorithm-expression: t_order_$->{order_id % 10}
2.1.3 常用分片算法
1. 取模分片(INLINE)
sharding-algorithms:
mod-algorithm:
type: INLINE
props:
algorithm-expression: t_order_$->{order_id % 4}
2. 哈希取模分片(HASH_MOD)
sharding-algorithms:
hash-mod-algorithm:
type: HASH_MOD
props:
sharding-count: 4
3. 范围分片(INTERVAL)
sharding-algorithms:
interval-algorithm:
type: INTERVAL
props:
datetime-pattern: yyyy-MM-dd HH:mm:ss
datetime-lower: 2024-01-01 00:00:00
datetime-upper: 2025-01-01 00:00:00
sharding-suffix-pattern: yyyyMM
datetime-interval-amount: 1
datetime-interval-unit: MONTHS
4. 复杂分片(CLASS_BASED)
sharding-algorithms:
complex-algorithm:
type: CLASS_BASED
props:
strategy: COMPLEX
algorithm-class-name: com.example.CustomShardingAlgorithm
2.2 分片策略示例
2.2.1 订单表分片设计
-- 原始表结构
CREATE TABLE t_order (
order_id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
amount DECIMAL(10,2),
status VARCHAR(20),
create_time DATETIME,
update_time DATETIME,
INDEX idx_user_id (user_id),
INDEX idx_create_time (create_time)
);
-- 分片后:3个库 × 10个表 = 30个物理表
-- ds-0: t_order_0, t_order_1, ..., t_order_9
-- ds-1: t_order_0, t_order_1, ..., t_order_9
-- ds-2: t_order_0, t_order_1, ..., t_order_9
2.2.2 广播表(配置表、字典表)
sharding:
broadcast-tables:
- t_config
- t_dict
- t_region
2.2.3 绑定表(关联表)
sharding:
binding-tables:
- t_order,t_order_item
tables:
t_order:
actual-data-nodes: ds-$->{0..2}.t_order_$->{0..9}
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: database-mod
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: table-mod
t_order_item:
actual-data-nodes: ds-$->{0..2}.t_order_item_$->{0..9}
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: database-mod
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: table-mod
3. Seata 分布式事务四种模式
3.1 AT 模式(自动补偿)
3.1.1 工作原理
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 应用服务 │─────►│ Seata TC │◄─────│ 应用服务 │
└─────┬────┘ └──────────┘ └─────┬────┘
│ │
│ 1. 注册分支事务 │
│ 2. 执行业务SQL │
│ 3. 生成前后镜像 │
│ 4. 提交本地事务 │
│ 5. 上报分支状态 │
│ │
│◄────── 6. 全局提交/回滚 ────────────►│
│ │
│ 7a. 提交:删除UNDO LOG │
│ 7b. 回滚:根据UNDO LOG还原数据 │
3.1.2 优点与缺点
优点:
- 对业务代码零侵入
- 自动生成回滚SQL
- 性能较好
缺点:
- 依赖数据库本地事务
- 可能出现数据不一致(极端情况)
- 需要额外的 UNDO_LOG 表
3.1.3 适用场景
- 常规CRUD业务
- 对性能有要求
- 团队不想改造现有代码
3.2 TCC 模式(两阶段提交)
3.2.1 工作原理
public interface TccService {
/**
* 第一阶段:Try
* 完成所有业务检查(一致性)
* 预留必须业务资源(准隔离性)
*/
@TwoPhaseBusinessAction(
name = "tccService",
commitMethod = "confirm",
rollbackMethod = "cancel"
)
boolean prepare(BusinessActionContext context,
@BusinessActionContextParameter(paramName = "params") Map<String, Object> params);
/**
* 第二阶段:Confirm
* 真正执行业务
* Try成功,Confirm一定成功
*/
boolean confirm(BusinessActionContext context);
/**
* 第二阶段:Cancel
* 释放Try阶段预留的业务资源
*/
boolean cancel(BusinessActionContext context);
}
3.2.2 实现示例(扣减库存)
@Service
public class InventoryTccServiceImpl implements InventoryTccService {
@Autowired
private InventoryMapper inventoryMapper;
@Override
@Transactional
public boolean prepare(BusinessActionContext context, Map<String, Object> params) {
Long productId = (Long) params.get("productId");
Integer quantity = (Integer) params.get("quantity");
// 1. 检查库存是否充足
Inventory inventory = inventoryMapper.selectById(productId);
if (inventory.getAvailable() < quantity) {
throw new RuntimeException("库存不足");
}
// 2. 冻结库存(预留资源)
inventory.setAvailable(inventory.getAvailable() - quantity);
inventory.setFrozen(inventory.getFrozen() + quantity);
inventoryMapper.updateById(inventory);
return true;
}
@Override
@Transactional
public boolean confirm(BusinessActionContext context) {
Map<String, Object> params = (Map<String, Object>) context.getActionContext("params");
Long productId = (Long) params.get("productId");
Integer quantity = (Integer) params.get("quantity");
// 确认扣减:减少冻结库存
Inventory inventory = inventoryMapper.selectById(productId);
inventory.setFrozen(inventory.getFrozen() - quantity);
inventoryMapper.updateById(inventory);
return true;
}
@Override
@Transactional
public boolean cancel(BusinessActionContext context) {
Map<String, Object> params = (Map<String, Object>) context.getActionContext("params");
Long productId = (Long) params.get("productId");
Integer quantity = (Integer) params.get("quantity");
// 取消操作:恢复可用库存
Inventory inventory = inventoryMapper.selectById(productId);
inventory.setAvailable(inventory.getAvailable() + quantity);
inventory.setFrozen(inventory.getFrozen() - quantity);
inventoryMapper.updateById(inventory);
return true;
}
}
3.2.3 优点与缺点
优点:
- 强一致性
- 不依赖数据库事务
- 可以跨数据库、跨服务
缺点:
- 代码侵入性高(需实现3个方法)
- 开发复杂度高
- 需要考虑幂等性
3.2.4 适用场景
- 金融核心业务
- 对一致性要求极高
- 跨服务的复杂事务
3.3 SAGA 模式(长事务)
3.3.1 工作原理
正向流程:
Service A ──► Service B ──► Service C ──► 成功
│ │ │
│ │ │
补偿流程(失败时):
│ │ │
▼ ▼ ▼
Compensate A Compensate B Compensate C
3.3.2 状态机 JSON 定义
{
"Name": "OrderSaga",
"Comment": "订单创建Saga流程",
"StartState": "CreateOrder",
"Version": "1.0.0",
"States": {
"CreateOrder": {
"Type": "ServiceTask",
"ServiceName": "orderService",
"ServiceMethod": "createOrder",
"CompensateState": "CancelOrder",
"Next": "ReduceInventory",
"Input": ["$.[order]"],
"Output": {
"orderId": "$.orderId"
},
"Status": {
"Success": "SUCCESS",
"Error": "ERROR"
}
},
"ReduceInventory": {
"Type": "ServiceTask",
"ServiceName": "inventoryService",
"ServiceMethod": "reduce",
"CompensateState": "RestoreInventory",
"Next": "DeductBalance",
"Input": ["$.[inventory]"],
"Status": {
"Success": "SUCCESS",
"Error": "ERROR"
}
},
"DeductBalance": {
"Type": "ServiceTask",
"ServiceName": "accountService",
"ServiceMethod": "deduct",
"CompensateState": "RestoreBalance",
"Next": "Succeed",
"Input": ["$.[account]"],
"Status": {
"Success": "SUCCESS",
"Error": "ERROR"
}
},
"Succeed": {
"Type": "Succeed"
},
"CancelOrder": {
"Type": "ServiceTask",
"ServiceName": "orderService",
"ServiceMethod": "cancel",
"Input": ["$.orderId"]
},
"RestoreInventory": {
"Type": "ServiceTask",
"ServiceName": "inventoryService",
"ServiceMethod": "restore",
"Input": ["$.[inventory]"]
},
"RestoreBalance": {
"Type": "ServiceTask",
"ServiceName": "accountService",
"ServiceMethod": "restore",
"Input": ["$.[account]"]
}
}
}
3.3.3 Java 实现示例
@Service
public class OrderSagaService {
@Autowired
private OrderService orderService;
@Autowired
private InventoryService inventoryService;
@Autowired
private AccountService accountService;
/**
* 创建订单(正向操作)
*/
@Transactional
public Long createOrder(OrderDTO orderDTO) {
Order order = new Order();
BeanUtils.copyProperties(orderDTO, order);
order.setStatus("INIT");
orderService.save(order);
return order.getId();
}
/**
* 取消订单(补偿操作)
*/
@Transactional
public void cancelOrder(Long orderId) {
Order order = orderService.getById(orderId);
order.setStatus("CANCELLED");
orderService.updateById(order);
}
/**
* 扣减库存(正向操作)
*/
@Transactional
public void reduceInventory(Long productId, Integer quantity) {
inventoryService.reduce(productId, quantity);
}
/**
* 恢复库存(补偿操作)
*/
@Transactional
public void restoreInventory(Long productId, Integer quantity) {
inventoryService.add(productId, quantity);
}
}
3.3.4 优点与缺点
优点:
- 适合长事务
- 不锁定资源
- 性能好
缺点:
- 最终一致性(存在中间状态)
- 需要设计补偿逻辑
- 业务复杂度高
3.3.5 适用场景
- 订单流程(创建订单→扣库存→扣款)
- 跨系统长事务
- 对性能要求高的场景
3.4 XA 模式(强一致性)
3.4.1 工作原理
┌─────────────────────────────────────────┐
│ 全局事务管理器 (TM) │
└───────────┬─────────────────────────────┘
│
│ 1. 开启全局事务
▼
┌───────────────────────────────────────────┐
│ 资源管理器 (RM) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ DB-1 │ │ DB-2 │ │ DB-3 │ │
│ │ │ │ │ │ │ │
│ │ 2. Prepare│ │ Prepare│ │ Prepare │ │
│ │ 3. Ready │ │ Ready │ │ Ready │ │
│ │ │ │ │ │ │ │
│ │ 4. Commit│ │ Commit │ │ Commit │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└───────────────────────────────────────────┘
3.4.2 Spring Boot 配置
seata:
enabled: true
tx-service-group: my-tx-group
data-source-proxy-mode: XA
spring:
datasource:
type: com.alibaba.druid.pool.xa.DruidXADataSource
3.4.3 代码示例
@Service
public class OrderServiceImpl {
@GlobalTransactional
@Transactional
public void createOrder(OrderDTO orderDTO) {
// 1. 创建订单
orderMapper.insert(order);
// 2. 调用库存服务(XA事务)
inventoryService.reduce(orderDTO.getProductId(), orderDTO.getQuantity());
// 3. 调用账户服务(XA事务)
accountService.deduct(orderDTO.getUserId(), orderDTO.getAmount());
// 如果任何一步失败,所有操作都会回滚
}
}
3.4.4 优点与缺点
优点:
- 强一致性(ACID)
- 对业务代码无侵入
- 标准化协议
缺点:
- 性能差(需要2PC)
- 长时间锁资源
- 数据库必须支持XA
3.4.5 适用场景
- 强一致性要求
- 短事务
- 对性能不敏感
4. SpringBoot3 项目整合
4.1 Maven 依赖配置
<properties>
<java.version>17</java.version>
<spring-boot.version>3.2.0</spring-boot.version>
<shardingsphere.version>5.4.1</shardingsphere.version>
<seata.version>2.0.0</seata.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
</properties>
<dependencies>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- ShardingSphere JDBC -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>${shardingsphere.version}</version>
</dependency>
<!-- Seata Spring Boot Starter -->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>${seata.version}</version>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Druid 连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>1.2.20</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
4.2 ShardingSphere 配置
4.2.1 application.yml 配置
server:
port: 8080
spring:
application:
name: order-service
# JPA 配置
jpa:
hibernate:
ddl-auto: none
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.MySQLDialect
# ShardingSphere 数据源配置
datasource:
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:shardingsphere-config.yaml
# MyBatis-Plus 配置
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: ASSIGN_ID
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
mapper-locations: classpath*:/mapper/**/*.xml
# Seata 配置
seata:
enabled: true
application-id: ${spring.application.name}
tx-service-group: my-tx-group
# AT 模式配置
data-source-proxy-mode: AT
service:
vgroup-mapping:
my-tx-group: default
grouplist:
default: 127.0.0.1:8091
config:
type: file
registry:
type: file
4.2.2 shardingsphere-config.yaml 配置
# 数据源配置
dataSources:
ds-0:
dataSourceClassName: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/order_db_0?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: root123
maxPoolSize: 50
minPoolSize: 5
ds-1:
dataSourceClassName: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3307/order_db_1?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: root123
maxPoolSize: 50
minPoolSize: 5
ds-2:
dataSourceClassName: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3308/order_db_2?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: root123
maxPoolSize: 50
minPoolSize: 5
# 分片规则配置
rules:
- !SHARDING
tables:
# 订单表分片
t_order:
actualDataNodes: ds-$->{0..2}.t_order_$->{0..9}
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: database-mod
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: table-mod
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
# 订单明细表分片
t_order_item:
actualDataNodes: ds-$->{0..2}.t_order_item_$->{0..9}
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: database-mod
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: table-mod
keyGenerateStrategy:
column: item_id
keyGeneratorName: snowflake
# 绑定表
bindingTables:
- t_order,t_order_item
# 广播表
broadcastTables:
- t_dict
- t_config
# 分片算法配置
shardingAlgorithms:
database-mod:
type: MOD
props:
sharding-count: 3
table-mod:
type: MOD
props:
sharding-count: 10
# 主键生成策略
keyGenerators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 1
# 属性配置
props:
sql-show: true
sql-simple: false
4.3 MyBatis-Plus 整合
4.3.1 配置类
package com.example.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.example.mapper")
public class MybatisPlusConfig {
/**
* MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件
PaginationInnerInterceptor paginationInnerInterceptor =
new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(1000L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
return interceptor;
}
}
4.3.2 实体类
package com.example.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("t_order")
public class Order {
@TableId(value = "order_id", type = IdType.ASSIGN_ID)
private Long orderId;
@TableField("user_id")
private Long userId;
@TableField("product_id")
private Long productId;
@TableField("quantity")
private Integer quantity;
@TableField("amount")
private BigDecimal amount;
@TableField("status")
private String status;
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField("deleted")
private Integer deleted;
}
4.3.3 Mapper 接口
package com.example.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.entity.Order;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
/**
* 根据用户ID查询订单
*/
@Select("SELECT * FROM t_order WHERE user_id = #{userId} ORDER BY create_time DESC")
List<Order> selectByUserId(@Param("userId") Long userId);
/**
* 自定义复杂查询
*/
List<Order> selectOrdersWithItems(@Param("userId") Long userId);
}
4.3.4 Service 实现
package com.example.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.entity.Order;
import com.example.mapper.OrderMapper;
import com.example.service.OrderService;
import io.seata.spring.annotation.GlobalTransactional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {
/**
* 创建订单(使用 Seata 分布式事务)
*/
@Override
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
@Transactional(rollbackFor = Exception.class)
public Long createOrder(Order order) {
// 1. 保存订单
this.save(order);
// 2. 调用库存服务扣减库存
// inventoryService.reduce(order.getProductId(), order.getQuantity());
// 3. 调用账户服务扣款
// accountService.deduct(order.getUserId(), order.getAmount());
return order.getOrderId();
}
}
4.4 JPA 整合
4.4.1 配置类
package com.example.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration
@EnableJpaRepositories(basePackages = "com.example.repository")
@EnableJpaAuditing
@EnableTransactionManagement
public class JpaConfig {
// JPA 配置
}
4.4.2 实体类
package com.example.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "t_order")
@EntityListeners(AuditingEntityListener.class)
public class OrderEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "order_id")
private Long orderId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "product_id", nullable = false)
private Long productId;
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "amount", nullable = false)
private BigDecimal amount;
@Column(name = "status", length = 20)
private String status;
@CreatedDate
@Column(name = "create_time", updatable = false)
private LocalDateTime createTime;
@LastModifiedDate
@Column(name = "update_time")
private LocalDateTime updateTime;
@Column(name = "deleted")
private Integer deleted = 0;
}
4.4.3 Repository 接口
package com.example.repository;
import com.example.entity.OrderEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
/**
* 根据用户ID查询订单
*/
List<OrderEntity> findByUserId(Long userId);
/**
* 根据状态查询订单
*/
List<OrderEntity> findByStatus(String status);
/**
* 自定义查询
*/
@Query("SELECT o FROM OrderEntity o WHERE o.userId = :userId AND o.status = :status")
List<OrderEntity> findByUserIdAndStatus(@Param("userId") Long userId,
@Param("status") String status);
}
4.5 Seata 配置(Kubernetes 环境)
4.5.1 不依赖 Nacos 的配置(使用 file 模式)
# application.yml
seata:
enabled: true
application-id: ${spring.application.name}
tx-service-group: my-tx-group
enable-auto-data-source-proxy: true
data-source-proxy-mode: AT
# 配置中心(使用 file 模式)
config:
type: file
file:
name: file:/app/config/seata-config.conf
# 注册中心(使用 file 模式)
registry:
type: file
file:
name: seata-server:8091
# 直接配置 TC 地址(推荐在 K8s 中使用)
service:
vgroup-mapping:
my-tx-group: default
grouplist:
default: seata-server.default.svc.cluster.local:8091
4.5.2 使用 Kubernetes Service Discovery
# application-k8s.yml
seata:
enabled: true
application-id: ${spring.application.name}
tx-service-group: my-tx-group
# 使用 Kubernetes DNS 服务发现
registry:
type: custom
custom:
name: kubernetes
service:
vgroup-mapping:
my-tx-group: default
grouplist:
default: seata-server.default.svc.cluster.local:8091
enable-degrade: false
disable-global-transaction: false
client:
rm:
async-commit-buffer-limit: 10000
report-retry-count: 5
table-meta-check-enable: false
report-success-enable: false
saga-branch-register-enable: false
saga-json-parser: fastjson
lock:
retry-interval: 10
retry-times: 30
retry-policy-branch-rollback-on-conflict: true
tm:
commit-retry-count: 5
rollback-retry-count: 5
default-global-transaction-timeout: 60000
degrade-check: false
degrade-check-period: 2000
degrade-check-allow-times: 10
undo:
data-validation: true
log-serialization: jackson
log-table: undo_log
only-care-update-columns: true
4.5.3 Seata Server ConfigMap
# seata-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: seata-server-config
namespace: default
data:
application.yml: |
server:
port: 7091
spring:
application:
name: seata-server
seata:
config:
type: file
registry:
type: file
store:
mode: db
db:
datasource: druid
db-type: mysql
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://mysql-service:3306/seata?useUnicode=true&rewriteBatchedStatements=true
user: root
password: root123
min-conn: 5
max-conn: 100
global-table: global_table
branch-table: branch_table
lock-table: lock_table
distributed-lock-table: distributed_lock
query-limit: 100
max-wait: 5000
5. Docker 部署方案
5.1 数据库准备
5.1.1 创建数据库脚本
-- init-databases.sql
-- 创建分库
CREATE DATABASE IF NOT EXISTS order_db_0 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS order_db_1 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS order_db_2 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS seata DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 使用 order_db_0
USE order_db_0;
-- 创建订单表(需要在每个分表中创建)
CREATE TABLE t_order_0 (
order_id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
INDEX idx_user_id (user_id),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 创建 t_order_1 到 t_order_9(省略,结构相同)
-- Seata AT 模式需要的 undo_log 表
CREATE TABLE undo_log (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
branch_id BIGINT NOT NULL,
xid VARCHAR(100) NOT NULL,
context VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL,
log_status INT NOT NULL,
log_created DATETIME NOT NULL,
log_modified DATETIME NOT NULL,
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 在 order_db_1 和 order_db_2 中重复上述表结构
-- 使用 seata 库
USE seata;
-- Seata Server 需要的表
CREATE TABLE IF NOT EXISTS global_table (
xid VARCHAR(128) NOT NULL,
transaction_id BIGINT,
status TINYINT NOT NULL,
application_id VARCHAR(32),
transaction_service_group VARCHAR(32),
transaction_name VARCHAR(128),
timeout INT,
begin_time BIGINT,
application_data VARCHAR(2000),
gmt_create DATETIME,
gmt_modified DATETIME,
PRIMARY KEY (xid),
KEY idx_gmt_modified_status (gmt_modified, status),
KEY idx_transaction_id (transaction_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS branch_table (
branch_id BIGINT NOT NULL,
xid VARCHAR(128) NOT NULL,
transaction_id BIGINT,
resource_group_id VARCHAR(32),
resource_id VARCHAR(256),
branch_type VARCHAR(8),
status TINYINT,
client_id VARCHAR(64),
application_data VARCHAR(2000),
gmt_create DATETIME(6),
gmt_modified DATETIME(6),
PRIMARY KEY (branch_id),
KEY idx_xid (xid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS lock_table (
row_key VARCHAR(128) NOT NULL,
xid VARCHAR(128),
transaction_id BIGINT,
branch_id BIGINT NOT NULL,
resource_id VARCHAR(256),
table_name VARCHAR(32),
pk VARCHAR(36),
status TINYINT NOT NULL DEFAULT 0,
gmt_create DATETIME,
gmt_modified DATETIME,
PRIMARY KEY (row_key),
KEY idx_branch_id (branch_id),
KEY idx_xid_and_branch_id (xid, branch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS distributed_lock (
lock_key VARCHAR(64) NOT NULL,
lock_value VARCHAR(64) NOT NULL,
expire BIGINT,
PRIMARY KEY (lock_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
5.2 Docker Compose 配置
# docker-compose.yml
version: '3.8'
services:
# MySQL 数据库 0
mysql-0:
image: mysql:8.0
container_name: mysql-shard-0
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: order_db_0
TZ: Asia/Shanghai
ports:
- "3306:3306"
volumes:
- ./data/mysql-0:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
networks:
- sharding-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
# MySQL 数据库 1
mysql-1:
image: mysql:8.0
container_name: mysql-shard-1
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: order_db_1
TZ: Asia/Shanghai
ports:
- "3307:3306"
volumes:
- ./data/mysql-1:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
networks:
- sharding-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
# MySQL 数据库 2
mysql-2:
image: mysql:8.0
container_name: mysql-shard-2
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: order_db_2
TZ: Asia/Shanghai
ports:
- "3308:3306"
volumes:
- ./data/mysql-2:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
networks:
- sharding-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
# Seata Server
seata-server:
image: seataio/seata-server:2.0.0
container_name: seata-server
ports:
- "7091:7091"
- "8091:8091"
environment:
SEATA_IP: seata-server
SEATA_PORT: 8091
STORE_MODE: db
SEATA_CONFIG_NAME: file:/seata-server/resources/application
volumes:
- ./seata-config:/seata-server/resources
networks:
- sharding-network
depends_on:
mysql-0:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7091/health"]
interval: 30s
timeout: 10s
retries: 3
# 订单服务
order-service:
build:
context: ./order-service
dockerfile: Dockerfile
container_name: order-service
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: docker
JAVA_OPTS: -Xms512m -Xmx1024m
volumes:
- ./logs/order-service:/app/logs
networks:
- sharding-network
depends_on:
- mysql-0
- mysql-1
- mysql-2
- seata-server
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
# 库存服务
inventory-service:
build:
context: ./inventory-service
dockerfile: Dockerfile
container_name: inventory-service
ports:
- "8081:8081"
environment:
SPRING_PROFILES_ACTIVE: docker
JAVA_OPTS: -Xms512m -Xmx1024m
networks:
- sharding-network
depends_on:
- mysql-0
- seata-server
# 账户服务
account-service:
build:
context: ./account-service
dockerfile: Dockerfile
container_name: account-service
ports:
- "8082:8082"
environment:
SPRING_PROFILES_ACTIVE: docker
JAVA_OPTS: -Xms512m -Xmx1024m
networks:
- sharding-network
depends_on:
- mysql-0
- seata-server
networks:
sharding-network:
driver: bridge
volumes:
mysql-0-data:
mysql-1-data:
mysql-2-data:
5.3 Dockerfile
# Dockerfile
FROM eclipse-temurin:17-jre-alpine
LABEL maintainer="your-email@example.com"
# 设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 创建应用目录
WORKDIR /app
# 复制 jar 包
COPY target/*.jar app.jar
# 暴露端口
EXPOSE 8080
# JVM 参数
ENV JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -XX:MaxGCPauseMillis=200"
# 启动命令
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]
5.4 启动脚本
#!/bin/bash
# start.sh
echo "=========================================="
echo "开始部署 ShardingSphere + Seata 环境"
echo "=========================================="
# 1. 创建必要的目录
echo "创建目录结构..."
mkdir -p data/mysql-{0,1,2}
mkdir -p logs/{order-service,inventory-service,account-service}
mkdir -p seata-config
# 2. 构建应用镜像
echo "构建应用镜像..."
cd order-service && mvn clean package -DskipTests && cd ..
cd inventory-service && mvn clean package -DskipTests && cd ..
cd account-service && mvn clean package -DskipTests && cd ..
# 3. 启动所有服务
echo "启动服务..."
docker-compose up -d
# 4. 等待服务启动
echo "等待服务启动..."
sleep 30
# 5. 检查服务状态
echo "检查服务状态..."
docker-compose ps
echo "=========================================="
echo "部署完成!"
echo "订单服务: http://localhost:8080"
echo "库存服务: http://localhost:8081"
echo "账户服务: http://localhost:8082"
echo "Seata 控制台: http://localhost:7091"
echo "=========================================="
6. Kubernetes 集群部署
6.1 命名空间
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: sharding-sphere
labels:
name: sharding-sphere
6.2 MySQL StatefulSet 部署
# mysql-statefulset.yaml
apiVersion: v1
kind: Service
metadata:
name: mysql-headless
namespace: sharding-sphere
labels:
app: mysql
spec:
ports:
- port: 3306
name: mysql
clusterIP: None
selector:
app: mysql
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
namespace: sharding-sphere
spec:
serviceName: mysql-headless
replicas: 3
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0
ports:
- containerPort: 3306
name: mysql
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-secret
key: root-password
- name: MYSQL_DATABASE
value: "order_db"
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql
- name: init-sql
mountPath: /docker-entrypoint-initdb.d
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1000m"
livenessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "localhost"]
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
exec:
command: ["mysql", "-h", "localhost", "-u", "root", "-proot123", "-e", "SELECT 1"]
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: init-sql
configMap:
name: mysql-init-sql
volumeClaimTemplates:
- metadata:
name: mysql-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: "standard"
resources:
requests:
storage: 10Gi
---
# MySQL Service (用于访问单个实例)
apiVersion: v1
kind: Service
metadata:
name: mysql-0
namespace: sharding-sphere
spec:
selector:
app: mysql
statefulset.kubernetes.io/pod-name: mysql-0
ports:
- port: 3306
targetPort: 3306
---
apiVersion: v1
kind: Service
metadata:
name: mysql-1
namespace: sharding-sphere
spec:
selector:
app: mysql
statefulset.kubernetes.io/pod-name: mysql-1
ports:
- port: 3306
targetPort: 3306
---
apiVersion: v1
kind: Service
metadata:
name: mysql-2
namespace: sharding-sphere
spec:
selector:
app: mysql
statefulset.kubernetes.io/pod-name: mysql-2
ports:
- port: 3306
targetPort: 3306
6.3 MySQL ConfigMap 和 Secret
# mysql-config.yaml
apiVersion: v1
kind: Secret
metadata:
name: mysql-secret
namespace: sharding-sphere
type: Opaque
stringData:
root-password: root123
---
apiVersion: v1
kind: ConfigMap
metadata:
name: mysql-init-sql
namespace: sharding-sphere
data:
init.sql: |
-- 创建数据库
CREATE DATABASE IF NOT EXISTS order_db_0 DEFAULT CHARACTER SET utf8mb4;
CREATE DATABASE IF NOT EXISTS order_db_1 DEFAULT CHARACTER SET utf8mb4;
CREATE DATABASE IF NOT EXISTS order_db_2 DEFAULT CHARACTER SET utf8mb4;
-- 创建表结构(在每个库中)
USE order_db_0;
CREATE TABLE IF NOT EXISTS t_order_0 (
order_id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 创建 undo_log 表
CREATE TABLE IF NOT EXISTS undo_log (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
branch_id BIGINT NOT NULL,
xid VARCHAR(100) NOT NULL,
context VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL,
log_status INT NOT NULL,
log_created DATETIME NOT NULL,
log_modified DATETIME NOT NULL,
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
6.4 Seata Server 部署
# seata-server.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: seata-server-config
namespace: sharding-sphere
data:
application.yml: |
server:
port: 7091
spring:
application:
name: seata-server
seata:
config:
type: file
registry:
type: file
server:
service-port: 8091
store:
mode: db
db:
datasource: druid
db-type: mysql
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://mysql-0.sharding-sphere.svc.cluster.local:3306/seata?rewriteBatchedStatements=true
user: root
password: root123
min-conn: 10
max-conn: 100
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: seata-server
namespace: sharding-sphere
spec:
replicas: 1
selector:
matchLabels:
app: seata-server
template:
metadata:
labels:
app: seata-server
spec:
containers:
- name: seata-server
image: seataio/seata-server:2.0.0
ports:
- containerPort: 7091
name: http
- containerPort: 8091
name: service
env:
- name: SEATA_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: SEATA_PORT
value: "8091"
volumeMounts:
- name: config
mountPath: /seata-server/resources/application.yml
subPath: application.yml
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /health
port: 7091
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 7091
initialDelaySeconds: 30
periodSeconds: 5
volumes:
- name: config
configMap:
name: seata-server-config
---
apiVersion: v1
kind: Service
metadata:
name: seata-server
namespace: sharding-sphere
spec:
selector:
app: seata-server
ports:
- name: http
port: 7091
targetPort: 7091
- name: service
port: 8091
targetPort: 8091
type: ClusterIP
6.5 应用服务部署
# order-service.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: order-service-config
namespace: sharding-sphere
data:
application.yml: |
server:
port: 8080
spring:
application:
name: order-service
datasource:
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:shardingsphere-config.yaml
seata:
enabled: true
application-id: order-service
tx-service-group: my-tx-group
service:
vgroup-mapping:
my-tx-group: default
grouplist:
default: seata-server.sharding-sphere.svc.cluster.local:8091
shardingsphere-config.yaml: |
dataSources:
ds-0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://mysql-0.sharding-sphere.svc.cluster.local:3306/order_db_0?serverTimezone=Asia/Shanghai
username: root
password: root123
ds-1:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://mysql-1.sharding-sphere.svc.cluster.local:3306/order_db_1?serverTimezone=Asia/Shanghai
username: root
password: root123
ds-2:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://mysql-2.sharding-sphere.svc.cluster.local:3306/order_db_2?serverTimezone=Asia/Shanghai
username: root
password: root123
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds-$->{0..2}.t_order_$->{0..9}
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: database-mod
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: table-mod
shardingAlgorithms:
database-mod:
type: MOD
props:
sharding-count: 3
table-mod:
type: MOD
props:
sharding-count: 10
props:
sql-show: true
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
namespace: sharding-sphere
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: order-service
image: your-registry/order-service:latest
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "k8s"
- name: JAVA_OPTS
value: "-Xms512m -Xmx1024m -XX:+UseG1GC"
volumeMounts:
- name: config
mountPath: /app/config
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
volumes:
- name: config
configMap:
name: order-service-config
---
apiVersion: v1
kind: Service
metadata:
name: order-service
namespace: sharding-sphere
spec:
selector:
app: order-service
ports:
- port: 8080
targetPort: 8080
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: order-service-ingress
namespace: sharding-sphere
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: order.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: order-service
port:
number: 8080
6.6 部署脚本
#!/bin/bash
# deploy-k8s.sh
echo "=========================================="
echo "部署 ShardingSphere + Seata 到 Kubernetes"
echo "=========================================="
# 1. 创建命名空间
echo "创建命名空间..."
kubectl apply -f namespace.yaml
# 2. 部署 MySQL
echo "部署 MySQL StatefulSet..."
kubectl apply -f mysql-config.yaml
kubectl apply -f mysql-statefulset.yaml
# 等待 MySQL 就绪
echo "等待 MySQL 就绪..."
kubectl wait --for=condition=ready pod -l app=mysql -n sharding-sphere --timeout=300s
# 3. 部署 Seata Server
echo "部署 Seata Server..."
kubectl apply -f seata-server.yaml
# 等待 Seata 就绪
echo "等待 Seata Server 就绪..."
kubectl wait --for=condition=ready pod -l app=seata-server -n sharding-sphere --timeout=300s
# 4. 部署应用服务
echo "部署应用服务..."
kubectl apply -f order-service.yaml
kubectl apply -f inventory-service.yaml
kubectl apply -f account-service.yaml
# 5. 检查部署状态
echo "检查部署状态..."
kubectl get all -n sharding-sphere
echo "=========================================="
echo "部署完成!"
echo "=========================================="
7. 生产环境最佳实践
7.1 分库分表最佳实践
7.1.1 分片键选择原则
- 高基数:选择值分布均匀的字段
- 业务相关:符合业务查询习惯
- 不可变:避免选择会变化的字段
// 好的分片键示例
- user_id (用户维度查询多)
- order_id (订单维度查询多)
- create_time (时间范围查询多)
// 不好的分片键示例
- status (值太少,分布不均)
- update_time (会变化)
7.1.2 分片数量规划
# 分片数量建议
单表数据量: 500万-1000万
分表数量: 2^n (2, 4, 8, 16, 32)
分库数量: 根据业务量,建议 3-10 个
# 扩容预留
初期: 4 库 × 16 表 = 64 个物理表
扩容: 8 库 × 32 表 = 256 个物理表
7.2 Seata 性能优化
7.2.1 AT 模式优化
seata:
client:
rm:
# 异步提交缓冲区大小
async-commit-buffer-limit: 10000
# 不上报成功状态(提高性能)
report-success-enable: false
# 关闭表元数据检查
table-meta-check-enable: false
undo:
# 只记录更新字段
only-care-update-columns: true
# 压缩 undo log
compress:
enable: true
type: zip
threshold: 64k
7.2.2 连接池优化
spring:
datasource:
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
7.3 监控与告警
7.3.1 关键指标
# ShardingSphere 监控
- SQL 执行时间
- 路由命中率
- 连接池使用率
# Seata 监控
- 全局事务数量
- 分支事务数量
- 事务超时次数
- 回滚次数
7.3.2 Prometheus 配置
# prometheus.yml
scrape_configs:
- job_name: 'shardingsphere'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['order-service:8080']
- job_name: 'seata'
metrics_path: '/metrics'
static_configs:
- targets: ['seata-server:7091']
7.4 常见问题处理
7.4.1 跨库关联查询
// 问题:跨库 JOIN 不支持
// 解决方案 1:冗余数据
@Data
public class Order {
private Long orderId;
private Long userId;
private String userName; // 冗余用户名
}
// 解决方案 2:应用层聚合
public OrderVO getOrderDetail(Long orderId) {
Order order = orderMapper.selectById(orderId);
User user = userService.getById(order.getUserId());
return OrderVO.builder()
.order(order)
.user(user)
.build();
}
7.4.2 分布式主键生成
# 使用雪花算法
sharding:
key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: ${WORKER_ID:1}
max-vibration-offset: 1
7.4.3 数据迁移方案
# 1. 使用 ShardingSphere 的数据迁移工具
# 2. 双写方案(过渡期)
# 3. 使用 Canal 实现数据同步
7.5 安全加固
# 数据库密码加密
spring:
datasource:
password: ENC(加密后的密码)
jasypt:
encryptor:
algorithm: PBEWithMD5AndDES
password: ${JASYPT_PASSWORD}
8. 总结与对比
8.1 四种事务模式选型建议
| 场景 | 推荐模式 | 理由 |
|---|---|---|
| 电商下单 | AT | 性能好,开发简单 |
| 支付扣款 | TCC | 强一致性,资金安全 |
| 订单流程 | SAGA | 长事务,流程清晰 |
| 报表统计 | XA | 强一致性要求 |
8.2 部署方式对比
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Docker | 简单快速 | 不易扩展 | 开发测试 |
| K8s | 自动化运维 | 复杂度高 | 生产环境 |
8.3 参考资源
- ShardingSphere 官方文档: https://shardingsphere.apache.org/
- Seata 官方文档: https://seata.io/
- MyBatis-Plus 官方文档: https://baomidou.com/
文档版本: v1.0.0
最后更新: 2024-12-09
作者: Tiger IoT Team
1万+

被折叠的 条评论
为什么被折叠?



