【架构实战】分库分表实战:ShardingSphere与中间件选型

【架构实战】分库分表实战:ShardingSphere与中间件选型

一、一张表8亿行,备份花了3天

2020年,我们订单表突破了8亿行,单表容量超过300GB。

问题一个个冒出来:

  • 写入QPS到800就卡死,主库CPU满负荷
  • 按月备份一次要3天,运维崩溃
  • 一个COUNT(*)跑了15分钟
  • 主从延迟超过30秒,读库数据严重滞后

架构团队开会讨论三个方案

  1. 升级硬件,买更好的数据库服务器
  2. 迁移到分布式数据库(TiDB、OceanBase)
  3. 业务层做分库分表

最终选了方案3------成本最低,可控性最高。但这条路,全是坑。

二、分库分表核心概念

2.1 垂直拆分 vs 水平拆分

复制代码
【垂直拆分(拆业务)】
                    单库
    ┌────────────────┬─────────────────┐
    │   用户表        │   订单表         │
    │   商品表        │   库存表         │
    │   支付表        │   营销表         │
    └────────────────┴─────────────────┘
              ↓ 垂直拆分
    ┌──────────┐  ┌──────────┐  ┌──────────┐
    │ 用户库    │  │ 订单库    │  │ 商品库    │
    │ 用户表    │  │ 订单表    │  │ 商品表    │
    │ 登录表    │  │ 支付表    │  │ 库存表    │
    └──────────┘  └──────────┘  └──────────┘

【水平拆分(拆数据)】
                    订单表(8亿行)
    ┌───────────────────────────────────────┐
    │  user_id=1到user_id=8亿              │
    └───────────────────────────────────────┘
              ↓ 按 user_id % 4 水平拆分
    ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
    │ 订单表0   │ │ 订单表1   │ │ 订单表2   │ │ 订单表3   │
    │id%4=0    │ │id%4=1    │ │id%4=2    │ │id%4=3    │
    └──────────┘ └──────────┘ └──────────┘ └──────────┘

垂直拆分优先原则:先做业务拆分,数据量还是大再做水平拆分。

2.2 分片键的选择------最关键的决策

分片键决定数据分布的均匀性和查询的复杂度。

分片键策略 优点 缺点 适用场景
用户ID取模 数据均匀分布 扩容需要rehash 用户维度查询为主
时间范围 扩展简单 热点数据集中 按时间归档
地理位置 就近访问 部分地区数据多 区域性业务
业务主键 业务直观 分布可能不均 特定业务对象

我们的选择 :使用 user_id % 16 作为分片键,将数据均匀分布到16个库。

三、ShardingSphere实战

3.1 为什么选ShardingSphere

选型对比:

中间件 架构 性能 侵入性 社区
ShardingSphere-JDBC 客户端SDK 无额外网络开销 代码层侵入 Apache,活跃
MyCat 独立代理 多一跳网络延迟 无侵入 较早,更新慢
DBLE 独立代理 多一跳 无侵入 爱可生维护
Vitess 独立代理 成熟可靠 无侵入 Google背书

选择ShardingSphere-JDBC的核心原因

  • 性能最好,无代理层网络开销
  • 与Spring Boot天然集成
  • 社区活跃,问题响应快
  • 支持分布式事务(Seata集成)

3.2 分库分表配置

yaml 复制代码
# application-sharding.yml
spring:
  shardingsphere:
    datasource:
      names: ds0,ds1,ds2,ds3,ds4,ds5,ds6,ds7,ds8,ds9,ds10,ds11,ds12,ds13,ds14,ds15
    rules:
      sharding:
        tables:
          t_order:
            # 16库 * 8表 = 128个物理分片
            actualDataNodes: ds$->{0..15}.t_order_$->{0..7}
            databaseStrategy:
              standard:
                shardingColumn: user_id
                shardingAlgorithmName: database-inline
            tableStrategy:
              standard:
                shardingColumn: order_id
                shardingAlgorithmName: table-inline
            keyGenerateStrategy:
              column: order_id
              keyGeneratorName: snowflake
        shardingAlgorithms:
          database-inline:
            type: INLINE
            props:
              algorithm-expression: ds$->{user_id % 16}
          table-inline:
            type: INLINE
            props:
              algorithm-expression: t_order_$->{order_id % 8}
        keyGenerators:
          snowflake:
            type: SNOWFLAKE
            props:
              worker-id: ${worker.id}

3.3 分布式主键------Snowflake算法

java 复制代码
// Snowflake ID 结构(64位)
// 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
// 符号│         时间戳(41位)            │ 数据中心│ 机器ID│    序列号(12位)
// 1位 │             2                  │   1    │   2   │      3

// 关键实现
public class SnowflakeIdGenerator {
    private final long workerId;
    private final long datacenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;
    
    // 起始时间戳 (2020-01-01)
    private final long twepoch = 1577808000000L;
    
    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();
        
        // 时钟回拨处理
        if (timestamp < lastTimestamp) {
            long offset = lastTimestamp - timestamp;
            if (offset <= 5) {
                Thread.sleep(offset * 2);  // 等待追上
                timestamp = System.currentTimeMillis();
            } else {
                throw new RuntimeException("时钟回拨超过5ms");
            }
        }
        
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & 0xFFF;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        
        lastTimestamp = timestamp;
        
        return ((timestamp - twepoch) << 22)
             | (datacenterId << 17)
             | (workerId << 12)
             | sequence;
    }
}

四、分库分表后的核心挑战

4.1 跨分片查询

问题 :按 user_id 分片后,按 order_status 查询需要遍历所有分片。

解决方案:Elasticsearch做二级索引。

java 复制代码
// 订单创建时同步到ES
@Transactional
public void createOrder(Order order) {
    // 1. 写入分库分表
    orderMapper.insert(order);
    
    // 2. 同步到ES(异步,允许短暂延迟)
    CompletableFuture.runAsync(() -> {
        elasticsearchRepository.save(order.toEsDoc());
    });
}

// 按状态查询时走ES
public Page<Order> searchByStatus(String status, int page, int size) {
    SearchHits<OrderDoc> hits = elasticsearchTemplate.search(
        new NativeSearchQueryBuilder()
            .withQuery(QueryBuilders.termQuery("status", status))
            .withPageable(PageRequest.of(page, size))
            .build(),
        OrderDoc.class
    );
    
    // 获取ID列表,从分库分表中批量加载详情
    List<Long> orderIds = hits.stream()
        .map(h -> h.getContent().getOrderId())
        .collect(Collectors.toList());
    
    return orderMapper.selectByIds(orderIds);
}

4.2 分布式事务

java 复制代码
// 使用Seata AT模式
@GlobalTransactional(name = "create-order", timeoutMills = 300000)
public void createOrderWithTransaction(OrderRequest request) {
    // 1. 创建订单(分库分表)
    orderService.create(request);
    
    // 2. 扣减库存(另一个服务)
    inventoryService.deduct(request);
    
    // 3. 扣减余额(另一个服务)
    accountService.deduct(request);
}

4.3 分页的噩梦

跨分片分页需要用"全局排序+二次查询":

java 复制代码
public List<Order> getOrdersByPage(int pageNo, int pageSize, String sortField) {
    // 每个分片都取 pageNo * pageSize 条
    int limit = pageNo * pageSize + pageSize;
    
    List<Order> allOrders = new ArrayList<>();
    for (int db = 0; db < 16; db++) {
        List<Order> shardOrders = orderMapper.queryFromShard(
            db, sortField, 0, limit);
        allOrders.addAll(shardOrders);
    }
    
    // 全局排序后取分页
    return allOrders.stream()
        .sorted(Comparator.comparing(Order::getCreateTime).reversed())
        .skip((long) (pageNo - 1) * pageSize)
        .limit(pageSize)
        .collect(Collectors.toList());
}

工程建议:业务上限制深分页,前端只允许查询前100页。

五、数据迁移方案

从单表迁移到分库分表的平滑方案:

复制代码
阶段一:双写(1周)
  应用 ──写──> 原单表
       └──写──> 新分库分表(异步,失败不影响主流程)

阶段二:数据校验(3天)
  对比原表和新分库分表的数据,修复差异

阶段三:灰度切读(1周)
  5%  → 20% → 50% → 100% 读流量逐步切到分库分表

阶段四:下线原表(1天)
  确认无误后,停止双写,下线原表

六、总结

分库分表不是所有数据库性能问题的答案,先做好索引优化、读写分离、缓存策略,实在不行再考虑。

核心要点

  1. 先垂直拆分,再水平拆分,顺序不能乱
  2. 分片键是灵魂,决定了后续所有查询的复杂度
  3. ShardingSphere-JDBC性能最好,但代码有侵入性
  4. 跨分片查询用ES做二级索引,跨分片分页要限制深度
  5. 分布式事务用Seata,别自己造轮子
  6. 迁移方案要稳妥,双写+灰度切读是标准操作

一个教训 :当年我们为了解决分库分表问题,引入了ES、Seata、Canal一堆中间件。后来发现一半的查询只需要 user_id 维度就能完成。分片键选对了,80%的问题自动消失。


个人观点,仅供参考

相关推荐
柒和远方1 小时前
从审计日志到可下载证据包:事务型 Outbox、租约 fencing 与保留水位
前端·后端·架构
ITxiaobing20232 小时前
程序化广告RTB场景下IP地理定位服务的精度与工程架构选型
网络协议·tcp/ip·架构
heimeiyingwang2 小时前
【架构实战】MySQL索引优化:从慢查询到毫秒响应
数据库·mysql·架构
望易2 小时前
相变外驱定理理论
人工智能·架构
爱上纯净的蓝天2 小时前
使用 AtomCode 构建微前端架构:从设计到实现实战
架构·vue·实战·react·微前端·atomcode
没落英雄4 小时前
5. 从零开始搭建一个 AI Agent —— 人机协作与中断恢复
前端·人工智能·架构
CIO_Alliance4 小时前
iPaaS概念原理(2)| iPaaS的架构模型是怎样的?
人工智能·低代码·架构·ipaas·系统集成
斯蒂文6685 小时前
[MAF预定义ChatClient中间件-03]CachingChatClient——利用缓存省钱省时间
缓存·中间件
阳明山水6 小时前
从论文到生产:销量预测系统的工程化落地实践
大数据·人工智能·深度学习·机器学习·架构