ShardingSphere分库分表与Seata分布式事务完整指南

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. 架构概览
  2. ShardingSphere 分库分表方案
  3. Seata 分布式事务四种模式
  4. SpringBoot3 项目整合
  5. Docker 部署方案
  6. Kubernetes 集群部署
  7. 生产环境最佳实践

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 字段:01
→ 判定为逻辑删除
→ 删除 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 分片键选择原则
  1. 高基数:选择值分布均匀的字段
  2. 业务相关:符合业务查询习惯
  3. 不可变:避免选择会变化的字段
// 好的分片键示例
- 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

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值