【架构实战】分库分表ShardingSphere:突破数据库瓶颈

【架构实战】分库分表ShardingSphere:突破数据库瓶颈

作者:架构实战系列 | 日期:2026-05-25


一个"表太大"的隐藏杀手:单表亿级数据背后的真相

2024年初,笔者接手了一个"历史遗留"的订单系统。团队当时面临一个典型问题:订单表已经超过1.2亿行,单表查询最慢超过5秒,DBA每天手工优化索引、改表结构,但性能还是持续恶化。

团队里有人提议:"换TiDB吧!" 也有人说:"上阿里云PolarDB吧,按量付费!" 讨论了两个月,最终我们选择了分库分表方案,迁移到ShardingSphere-Proxy。3个月后,系统QPS从3000提升到8万,99线延迟从5秒降到80毫秒,而成本只增加了一台服务器。

这次经历让我深刻认识到:分库分表不是万能药,但它是突破单机数据库瓶颈的最成熟、成本最低的方案。本文将结合这次真实迁移案例,从分片策略、读写分离、分布式主键,到完整的配置实战和踩坑记录,逐一讲解。


一、分库分表:为什么需要,以及何时需要

1.1 单机数据库的性能天花板

MySQL的单表性能在数据量超过5000万行时急剧下降,原因有三:

瓶颈点 说明
B+树深度增加 数据量越大,树越高,一次索引查询需要的磁盘IO次数越多
索引维护成本 插入/更新时,页分裂和索引更新开销指数级增长
Buffer Pool污染 全量数据塞满内存池,热数据无法充分利用CPU缓存

一个实测数据(8核16GB SSD MySQL):

  • 单表100万行:平均查询延迟 5ms
  • 单表1000万行:平均查询延迟 80ms
  • 单表1亿行:平均查询延迟 1500ms+(索引失效时)

1.2 何时应该分库分表?

触发条件(满足任意一条):

  • 单表数据量超过5000万行
  • 单库QPS超过单机MySQL承载能力(通常2万~5万QPS)
  • 存储空间超过单机磁盘容量
  • 单表宽度超过20个字段且经常全表扫描

分库分表不是银弹------它引入的复杂度远超单机MySQL:

  • 跨分片查询困难(无法使用局部索引)
  • 分布式事务成本高
  • 跨分片JOIN性能差
  • 运维复杂度大幅提升

因此,先考虑优化单机性能(索引、参数调优、读写分离),再考虑分库分表


二、分片策略:选择正确的"切割方式"

2.1 分片键(Shard Key)选择原则

分片键决定了数据的分布方式,是分库分表的核心。好的分片键应该满足:

  1. 业务相关性高:大多数查询都带有分片键条件
  2. 数据分布均匀:避免出现"热点分片"
  3. 查询可路由:能定位到少数分片,而非全部分片

常见分片键选择:

业务场景 推荐分片键 说明
用户中心 user_id 按用户维度查询是主流
订单系统 user_id 或 order_id 按买家查、按订单查两种场景
商品系统 category_id 按类目浏览是主流
日志系统 create_time 按时间范围查询是主流

2.2 分片算法详解

2.2.1 取模算法(Mod)

最简单直接的算法:分片 = hash(key) % 分片数

yaml 复制代码
shardingAlgorithms:
  mod_algorithm:
    type: MOD
    props:
      sharding-count: 4  # 4个分片

优点: 数据分布均匀
缺点: 扩容时(从4扩到8)需要全量数据迁移,代价极大

2.2.2 范围算法(Range)

按时间或ID范围切分:分片 = key / 范围区间

yaml 复制代码
shardingAlgorithms:
  range_algorithm:
    type: RANGE_BY_MONTH
    props:
      date-algorithm-column: create_time
      date-format: yyyy-MM-dd

适用场景: 日志、流水、监控等按时间查询的业务
优点: 扩容方便,直接增加新范围的分片
缺点: 容易产生热点(如当前月份的分片访问量远超历史月份)

2.2.3 一致性Hash算法(HashMod)

将分片数设为2的幂次,扩容时只需迁移50%的数据:

yaml 复制代码
shardingAlgorithms:
  consistent_hash_algorithm:
    type: CONSISTENT_HASH_V1
    props:
      sharding-count: 4
      virtual-nodes: 10  # 每个物理分片10个虚拟节点

核心原理: 将数据按Hash后映射到2^32的环形空间上,每个物理节点负责环上一段区域,扩容时只影响相邻区域的数据。

2.2.4 复合分片算法(Complex)

多个分片键组合路由:

yaml 复制代码
shardingAlgorithms:
  complex_algorithm:
    type: COMPLEX_INLINE
    props:
      sharding-expression: "ds_${user_id % 2}.t_order_${order_id % 2}"

2.3 分片策略选择决策树

复制代码
是否有明确的高频查询分片键?
    ├── 是 → 优先选择该键作为分片键
    │         ↓
    │      数据分布均匀吗?
    │         ├── 是 → 取模/一致性Hash
    │         └── 否 → 范围 + 热点隔离
    │
    └── 否 → 考虑以下方案:
              ├── 时间分片(最通用)
              ├── 默认路由(全部分片扫描,但限制结果集)
              └── 考虑是否真的需要分库分表

三、分布式主键:解决自增ID的跨分片问题

3.1 为什么不能使用自增主键?

分库分表后,每个分片维护自己的自增主键,会出现:

  • 订单表分4片后,4个分片都从1开始自增 → 订单ID重复
  • 无法用单个ID作为全局唯一标识
  • 跨分片查询时ID无序,无法利用范围查询优化

3.2 雪花算法(Snowflake)

Snowflake是Twitter开源的分布式ID算法,64位Long型整数,结构如下:

复制代码
1位符号位 + 41位时间戳 + 10位机器ID + 12位序列号
= 64位

  0    |  0000000000 0000000000 0000000000 0000000000 0    | 0000000000 | 0000000000
[符号位] [        41位时间戳(毫秒)                   ] [10位机器ID] [12位序列号]
  • 时间戳部分:可支持69年(2^41 / 1000 / 3600 / 24 / 365 ≈ 69年)
  • 机器ID:10位,最多支持1024个节点
  • 序列号:每节点每毫秒最多生成4096个ID

3.3 ShardingSphere集成雪花算法

yaml 复制代码
rules:
  - !SHARDING
    tables:
      t_order:
        actualDataNodes: ds_${0..1}.t_order_${0..1}
        
        keyGenerateStrategy:
          column: order_id
          keyGeneratorName: snowflake
        
        # 或者使用雪花算法的变种配置
        keyGenerators:
          snowflake:
            type: SNOWFLAKE
            props:
              # 工作机器ID(建议用服务器IP最后10位取模)
              worker-id: 1
              # 最大时钟回拨毫秒数(允许时钟漂移)
              max-vibration-offset: 1
              # 启用自定义时间戳(用于调试)
              # epoch: 1609459200000  # 2021-01-01
        
        tableStrategy:
          standard:
            shardingColumn: order_id
            shardingAlgorithmName: t_order_inline

3.4 雪花算法的生产坑点

坑点1:时钟回拨

服务器时钟有时会因为NTP同步而回拨,导致生成重复ID。

解决方案:

java 复制代码
@Component
public class ClockBackSnowflakeIdGenerator implements ShardingKeyGenerator {
    
    private final SnowflakeIdWorker worker;
    
    public ClockBackSnowflakeIdGenerator() {
        // 使用配置的工作节点ID
        this.worker = new SnowflakeIdWorker(1, 1);
    }
    
    @Override
    public Comparable<?> generateKey() {
        long id = worker.nextId();
        // 如果发生时钟回拨,这里会抛出异常
        // 生产中需要兜底:等待时钟追上
        return id;
    }
    
    /**
     * 带时钟保护的自旋等待
     */
    private static class SnowflakeIdWorker {
        private final long twepoch = 1609459200000L;
        private final long workerIdBits = 10L;
        private final long sequenceBits = 12L;
        
        private final long workerIdShift;
        private final long timestampLeftShift;
        private final long sequenceMask;
        
        private final long workerId;
        private long sequence = 0L;
        private long lastTimestamp = -1L;
        
        public SnowflakeIdWorker(long workerId, long datacenterId) {
            this.workerId = workerId;
            this.workerIdShift = sequenceBits;
            this.timestampLeftShift = sequenceBits + workerIdBits;
            this.sequenceMask = ~(-1L << sequenceBits);
        }
        
        public synchronized long nextId() {
            long timestamp = timeGen();
            
            // 时钟回拨处理
            if (timestamp < lastTimestamp) {
                long offset = lastTimestamp - timestamp;
                if (offset <= 5) {
                    // 允许5ms以内的时钟漂移,等待追上
                    timestamp = lastTimestamp;
                } else {
                    throw new RuntimeException("Clock moved backwards");
                }
            }
            
            if (lastTimestamp == timestamp) {
                sequence = (sequence + 1) & sequenceMask;
                if (sequence == 0) {
                    timestamp = tilNextMillis(lastTimestamp);
                }
            } else {
                sequence = 0L;
            }
            
            lastTimestamp = timestamp;
            
            return ((timestamp - twepoch) << timestampLeftShift)
                    | (workerId << workerIdShift)
                    | sequence;
        }
        
        private long tilNextMillis(long lastTimestamp) {
            long timestamp = timeGen();
            while (timestamp <= lastTimestamp) {
                timestamp = timeGen();
            }
            return timestamp;
        }
        
        private long timeGen() {
            return System.currentTimeMillis();
        }
    }
}
坑点2:WorkerID分配问题

1024个节点上限在超大规模集群中不够用,且WorkerID需要人工分配,容易出错。

解决方案: 使用ZK/Etcd协调分配,或使用百度UidGenerator、Leaf等成熟方案。


四、读写分离:分库分表的好搭档

4.1 分库分表 + 读写分离的组合架构

复制代码
                    [应用层]
                       |
              [ShardingSphere-Proxy]
              (分片路由 + 读写分离)
                       |
          +------------+------------+
          |                         |
    [写库集群]              [读库集群]
   ds_0(primary)           ds_0_replica
   ds_1(primary)    +      ds_1_replica
   ...                      ...

4.2 ShardingSphere读写分离完整配置

yaml 复制代码
# conf/config-readwrite-splitting.yaml

schemaName: readwrite_splitting_db

dataSources:
  # ===== 写库 =====
  ds_primary_0:
    url: jdbc:mysql://192.168.1.101:3306/app_ds_0?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: sharding_proxy
    password: ShardingProxy@2025
    connectionPoolClassName: com.zaxxer.hikari.HikariDataSource
    maxPoolSize: 50
    minPoolSize: 10
  
  ds_primary_1:
    url: jdbc:mysql://192.168.1.102:3306/app_ds_1?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: sharding_proxy
    password: ShardingProxy@2025
    connectionPoolClassName: com.zaxxer.hikari.HikariDataSource
    maxPoolSize: 50
    minPoolSize: 10

  # ===== 读库 =====
  ds_replica_0_1:
    url: jdbc:mysql://192.168.1.103:3306/app_ds_0?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: sharding_proxy
    password: ShardingProxy@2025
    connectionPoolClassName: com.zaxxer.hikari.HikariDataSource
    maxPoolSize: 50
    minPoolSize: 5
  
  ds_replica_0_2:
    url: jdbc:mysql://192.168.1.104:3306/app_ds_0?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: sharding_proxy
    password: ShardingProxy@2025
    connectionPoolClassName: com.zaxxer.hikari.HikariDataSource
    maxPoolSize: 50
    minPoolSize: 5
  
  ds_replica_1_1:
    url: jdbc:mysql://192.168.1.105:3306/app_ds_1?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: sharding_proxy
    password: ShardingProxy@2025
    connectionPoolClassName: com.zaxxer.hikari.HikariDataSource
    maxPoolSize: 50
    minPoolSize: 5

rules:
  - !READWRITE_SPLITTING
    # 每个分片库配置一主多从
    dataSources:
      # ds_0的读写分离组
      ds_0_replica:
        writeDataSourceName: ds_primary_0
        readDataSourceNames:
          - ds_replica_0_1
          - ds_replica_0_2
        loadBalancerName: random_weight
      
      # ds_1的读写分离组
      ds_1_replica:
        writeDataSourceName: ds_primary_1
        readDataSourceNames:
          - ds_replica_1_1
        loadBalancerName: random_weight
    
    loadBalancers:
      # 随机加权策略(根据配置权重随机选)
      random_weight:
        type: RANDOM
      # 轮询策略
      round_robin:
        type: ROUND_ROBIN
      # 固定副本策略(每次查询同一从库)
      fixed_replica:
        type: FIXED_REPLICA
        props:
          replica-id: ds_replica_0_1

五、完整实战案例:电商订单系统分库分表迁移

5.1 项目背景与迁移前状态

指标 迁移前 迁移后目标
订单表数据量 1.2亿行 按月分片,历史归档
峰值QPS 3,200 80,000+
P99延迟 5,000ms <100ms
可用性 单机无高可用 主从自动切换
月均故障时长 ~4小时 <15分钟

5.2 迁移方案设计

分片策略选择

最终选择按订单ID取模分片 + 时间范围归档的混合策略:

  • 活跃订单 (近3个月):按 order_id % 8 分4库×2表 = 8个分片
  • 历史订单 (3个月前):按 create_time 月份分表,归档到冷数据集群
  • 分片键 :优先 order_id(覆盖订单号查询),次选 user_id(覆盖用户订单列表)
yaml 复制代码
rules:
  - !SHARDING
    defaultDatabaseStrategy:
      standard:
        shardingColumn: order_id
        shardingAlgorithmName: database_inline
    
    tables:
      t_order:
        actualDataNodes: ds_${0..3}.t_order_${0..1}
        
        databaseStrategy:
          standard:
            shardingColumn: order_id
            shardingAlgorithmName: database_inline
        
        tableStrategy:
          standard:
            shardingColumn: order_id
            shardingAlgorithmName: table_inline
        
        keyGenerateStrategy:
          column: order_id
          keyGeneratorName: snowflake
        
        # 绑定表:订单和订单明细同分片,避免跨分片JOIN
        bindingTables:
          - t_order,t_order_item
        
        # 广播表:所有分片都保留一份(字典表等)
        broadcastTables:
          - t_dict_product_category
          - t_dict_region

    shardingAlgorithms:
      database_inline:
        type: INLINE
        props:
          algorithm-expression: ds_${order_id % 4}
      
      table_inline:
        type: INLINE
        props:
          algorithm-expression: t_order_${order_id % 2}
      
      # 历史订单按月分表
      t_order_history:
        type: CLASS_BASED
        props:
          strategy: STANDARD
          algorithmClassName: com.example.sharding.MonthShardingAlgorithm

5.3 历史数据迁移脚本

python 复制代码
#!/usr/bin/env python3
"""
历史订单迁移脚本
功能:将超过3个月的订单迁移到历史表(按月分表)
"""
import pymysql
import datetime
import time
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed

logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)

# 迁移配置
BATCH_SIZE = 5000  # 每批迁移数量
CUTOFF_DATE = datetime.datetime.now() - datetime.timedelta(days=90)
MAX_WORKERS = 4    # 并行迁移线程数
CHUNK_SIZE = 10000 # 查询时的分块大小

# 源数据库连接(从库,保护主库)
SRC_DB_CONFIG = {
    'host': '192.168.1.102',
    'port': 3306,
    'user': 'migration_user',
    'password': 'Migration@Pass',
    'database': 'app_order',
    'charset': 'utf8mb4'
}

# 目标分片库配置
TARGET_DB_CONFIGS = [
    {'host': '192.168.1.101', 'port': 3306, 'user': 'migration_user', 'password': 'Migration@Pass', 'database': 'app_order_ds_0'},
    {'host': '192.168.1.101', 'port': 3306, 'user': 'migration_user', 'password': 'Migration@Pass', 'database': 'app_order_ds_1'},
    {'host': '192.168.1.102', 'port': 3306, 'user': 'migration_user', 'password': 'Migration@Pass', 'database': 'app_order_ds_2'},
    {'host': '192.168.1.102', 'port': 3306, 'user': 'migration_user', 'password': 'Migration@Pass', 'database': 'app_order_ds_3'},
]

def get_target_db(order_id):
    """根据订单ID计算目标分片"""
    shard_index = order_id % 4
    return TARGET_DB_CONFIGS[shard_index]

def migrate_batch(batch_records):
    """迁移一批订单"""
    success_count = 0
    error_count = 0
    
    for record in batch_records:
        order_id = record['order_id']
        target_config = get_target_db(order_id)
        
        try:
            conn = pymysql.connect(**target_config)
            cursor = conn.cursor()
            
            # 插入订单数据
            cursor.execute("""
                INSERT IGNORE INTO t_order (order_id, user_id, product_id, amount, status, create_time)
                VALUES (%(order_id)s, %(user_id)s, %(product_id)s, %(amount)s, %(status)s, %(create_time)s)
            """, record)
            
            conn.commit()
            cursor.close()
            conn.close()
            success_count += 1
            
        except Exception as e:
            error_count += 1
            logger.error(f"迁移订单{order_id}失败: {e}")
    
    return success_count, error_count

def migration_worker(start_id, end_id, batch_size=BATCH_SIZE):
    """单线程迁移worker"""
    src_conn = pymysql.connect(**SRC_DB_CONFIG)
    
    total_success = 0
    total_error = 0
    
    while start_id < end_id:
        with src_conn.cursor(pymysql.cursors.DictCursor) as cursor:
            cursor.execute(f"""
                SELECT order_id, user_id, product_id, amount, status, create_time
                FROM t_order
                WHERE order_id >= {start_id} AND order_id < {end_id}
                  AND create_time < '{CUTOFF_DATE.strftime('%Y-%m-%d %H:%M:%S')}'
                ORDER BY order_id
                LIMIT {batch_size}
            """, )
            records = cursor.fetchall()
        
        if not records:
            break
        
        success, error = migrate_batch(records)
        total_success += success
        total_error += error
        
        # 进度汇报
        last_id = records[-1]['order_id']
        logger.info(f"进度: 已迁移 {total_success} 条, 失败 {total_error} 条, 当前ID={last_id}")
        
        start_id = records[-1]['order_id'] + 1
        time.sleep(0.1)  # 控制迁移速率,保护源库
    
    src_conn.close()
    return total_success, total_error

if __name__ == '__main__':
    logger.info("=" * 50)
    logger.info("历史订单迁移任务开始")
    logger.info(f"截止日期: {CUTOFF_DATE}")
    
    # 获取迁移范围
    src_conn = pymysql.connect(**SRC_DB_CONFIG)
    with src_conn.cursor() as cursor:
        cursor.execute("SELECT MIN(order_id) as min_id, MAX(order_id) as max_id FROM t_order WHERE create_time < %s", (CUTOFF_DATE,))
        result = cursor.fetchone()
        min_id, max_id = result[0], result[1]
        logger.info(f"待迁移ID范围: {min_id} ~ {max_id}")
    src_conn.close()
    
    # 并行迁移
    id_ranges = [(i, i + (max_id - min_id) // MAX_WORKERS + 1) for i in range(min_id, max_id + 1, (max_id - min_id) // MAX_WORKERS + 1)]
    
    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
        futures = [executor.submit(migration_worker, start, end) for start, end in id_ranges]
        total_success = 0
        total_error = 0
        for future in as_completed(futures):
            s, e = future.result()
            total_success += s
            total_error += e
    
    logger.info("=" * 50)
    logger.info(f"迁移完成!成功: {total_success}, 失败: {total_error}")

5.4 应用层改造代码

java 复制代码
/**
 * 分片订单服务
 * 核心:使用ShardingSphere提供的Hint API强制路由
 */
@Service
@Slf4j
public class ShardingOrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 按订单ID查询(自动路由到正确分片)
     */
    public Order queryByOrderId(Long orderId) {
        // ShardingSphere根据order_id自动计算分片
        // 应用层无需关心分片路由细节
        return orderMapper.selectByOrderId(orderId);
    }

    /**
     * 按用户ID查询订单(强制路由到包含该用户的分片)
     * 使用Hint API强制路由
     */
    public List<Order> queryByUserId(Long userId, Integer page, Integer size) {
        // Hint强制路由:根据user_id计算分片
        HintManager hintManager = HintManager.getInstance();
        
        // 强制使用数据库分片键:user_id % 4
        int dbIndex = (int) (userId % 4);
        hintManager.addDatabaseShardingValue("t_order", "user_id", userId);
        hintManager.addTableShardingValue("t_order", "user_id", userId);
        
        try {
            // 查询当前用户的所有分片表,合并结果
            List<Order> allOrders = new ArrayList<>();
            for (int tableIndex = 0; tableIndex < 2; tableIndex++) {
                // Hint指定分片键值
                String sql = String.format(
                    "SELECT * FROM t_order_%d WHERE user_id = ? ORDER BY create_time DESC LIMIT %d OFFSET %d",
                    tableIndex, size, (page - 1) * size
                );
                List<Order> orders = jdbcTemplate.query(sql, 
                    (rs, rowNum) -> mapRow(rs), userId);
                allOrders.addAll(orders);
            }
            
            // 内存中排序分页
            allOrders.sort((a, b) -> b.getCreateTime().compareTo(a.getCreateTime()));
            return allOrders.stream()
                .skip((long) (page - 1) * size)
                .limit(size)
                .collect(Collectors.toList());
        } finally {
            HintManager.close();
        }
    }

    /**
     * 创建订单(雪花算法生成全局唯一ID)
     */
    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        Order order = new Order();
        
        // 雪花算法生成订单ID(由ShardingSphere自动生成或应用层生成)
        Long orderId = IdGeneratorHolder.getGenerator("snowflake").generateKey().longValue();
        
        order.setOrderId(orderId);
        order.setUserId(request.getUserId());
        order.setProductId(request.getProductId());
        order.setAmount(request.getAmount());
        order.setStatus(OrderStatus.PENDING_PAYMENT);
        order.setCreateTime(LocalDateTime.now());
        
        // 插入(ShardingSphere根据order_id自动路由到正确分片)
        orderMapper.insert(order);
        
        log.info("订单创建成功, orderId={}, userId={}", orderId, request.getUserId());
        return order;
    }

    /**
     * 批量查询订单(跨分片聚合)
     * 注意:会扫描所有分片,性能较差,生产环境慎用
     */
    public PageResult<Order> queryByOrderIds(List<Long> orderIds) {
        if (orderIds == null || orderIds.isEmpty()) {
            return PageResult.empty();
        }

        // Hint关闭分片规则,全分片扫描
        HintManager hintManager = HintManager.getInstance();
        hintManager.setMasterRouteOnly(); // 可选:强制走主库

        try {
            String sql = "SELECT * FROM t_order WHERE order_id IN (" 
                       + orderIds.stream().map(String::valueOf).collect(Collectors.joining(",")) 
                       + ")";
            
            List<Order> orders = jdbcTemplate.query(sql, 
                (rs, rowNum) -> mapRow(rs));
            
            // 按传入顺序排序
            Map<Long, Order> orderMap = orders.stream()
                .collect(Collectors.toMap(Order::getOrderId, o -> o));
            
            List<Order> sorted = orderIds.stream()
                .map(orderMap::get)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
            
            return new PageResult<>(sorted, (long) sorted.size());
        } finally {
            HintManager.close();
        }
    }

    private Order mapRow(ResultSet rs) throws SQLException {
        Order order = new Order();
        order.setOrderId(rs.getLong("order_id"));
        order.setUserId(rs.getLong("user_id"));
        order.setProductId(rs.getLong("product_id"));
        order.setAmount(rs.getBigDecimal("amount"));
        order.setStatus(OrderStatus.valueOf(rs.getString("status")));
        order.setCreateTime(rs.getTimestamp("create_time").toLocalDateTime());
        return order;
    }
}

六、踩坑实录:生产迁移的血泪经验

踩坑1:分片键选择错误导致全分片扫描

现象: 用户反馈"按订单号查询"的响应时间反而比迁移前更慢了,部分查询从5ms变成300ms。

原因: 初始设计时按 user_id 分片,但订单查询更多是用订单号直接查(通过索引)。改用 user_id 分片后,订单号查询需要扫描全部8个分片。

排查方法:

sql 复制代码
-- ShardingSphere开启SQL日志,查看实际路由
-- conf/server.yaml 中设置:
props:
  sql-show: true
  max-queue-size: 8192

-- 查看日志中的 actual-sql:
-- SELECT * FROM t_order_0 WHERE order_id=?  -- 单分片命中
-- SELECT * FROM t_order_0 WHERE order_id=? UNION ALL SELECT * FROM t_order_1 ... -- 全分片扫描

解决方案:

  • 短期:将订单号查询改为"订单号 → 查缓存 → 不存在再全分片"的兜底逻辑
  • 长期 :改用 order_id 作为分片键,订单号查询改为直接路由到单分片

踩坑2:分片扩容时数据迁移引发的服务中断

现象: 从4分片扩容到8分片时,order_id % 4 变成 order_id % 8,历史数据全部路由错误,用户查看历史订单出现数据丢失。

原因: 取模算法的分母变化后,Hash结果完全不同。4分片下的 order_id=5 存在 ds_1,但扩容后 order_id=5 应该在 ds_5。

解决方案:

yaml 复制代码
# 一致性Hash扩容:分片数必须是2的幂次
# 4分片:ds_${order_id % 4}
# 扩容到8分片:ds_${order_id % 8}
# 扩容到16分片:ds_${order_id % 16}
# 每次扩容只迁移50%的数据

shardingAlgorithms:
  database_inline:
    type: INLINE
    props:
      # 使用2的幂次分片,支持平滑扩容
      algorithm-expression: ds_${order_id % Long.compare(order_id, 0) > 0 ? (order_id % 8) : 0}

最佳实践:

  • 初期多分配分片(留余量),避免频繁扩容
  • 使用一致性Hash算法
  • 扩容期间采用双写策略:新分片写入,旧分片只读,逐步切流

踩坑3:跨分片JOIN导致内存溢出

现象: 一个复杂的报表查询(订单表JOIN用户表JOIN商品表)在8分片环境下执行时,应用服务器OOM崩溃。

原因: 每个分片返回大量数据,8个分片合并后超出应用服务器内存。

解决方案:

java 复制代码
// 1. 禁止跨分片JOIN,改为应用层合并
public List<OrderVO> getOrderReport(Long userId, LocalDateTime startDate, LocalDateTime endDate) {
    // Step1: 从各分片获取订单
    List<Order> orders = getOrdersFromAllShards(userId, startDate, endDate);
    
    // Step2: 批量获取关联的用户信息和商品信息
    Set<Long> userIds = orders.stream().map(Order::getUserId).collect(Collectors.toSet());
    Set<Long> productIds = orders.stream().map(Order::getProductId).collect(Collectors.toSet());
    
    // 这些关联数据通过Redis缓存,不走分片查询
    Map<Long, User> userMap = getUsersFromCache(userIds);
    Map<Long, Product> productMap = getProductsFromCache(productIds);
    
    // Step3: 应用层组装VO
    return orders.stream().map(order -> OrderVO.builder()
        .orderId(order.getOrderId())
        .userName(userMap.get(order.getUserId()).getName())
        .productName(productMap.get(order.getProductId()).getName())
        .build()
    ).collect(Collectors.toList());
}

// 2. 开启流式查询,避免全量加载到内存
jdbcTemplate.setFetchSize(Integer.MIN_VALUE); // MySQL流式查询标志

踩坑4:分布式事务导致下单失败率飙升

现象: 迁移到ShardingSphere后,下单接口的超时错误率从0.1%飙升到5%。

原因: 订单表分片后,扣减库存需要跨库操作,引入分布式事务。但ShardingSphere的默认XA事务性能极差(TPS下降70%),且网络抖动时频繁超时。

解决方案:

java 复制代码
// 方案1:使用柔性事务(Seata AT模式)替代XA
// 适合:一致性要求不是100%,可接受最终一致性的场景
@Configuration
public class SeataConfig {
    @Bean
    public GlobalTransactionScanner globalTransactionScanner() {
        return new GlobalTransactionScanner("order-service", "order_tx_group");
    }
}

@Service
public class OrderService {
    @GlobalTransactional(timeoutMills = 30000, name = "create-order")
    public void createOrder(CreateOrderRequest request) {
        // 1. 扣减库存(远程调用库存服务)
        inventoryService.deductStock(request.getProductId(), request.getQuantity());
        
        // 2. 创建订单(本地事务)
        orderMapper.insert(order);
        
        // Seata自动管理回滚:任何一个失败,全部回滚
    }
}

// 方案2:业务层消息队列最终一致性(推荐,最稳妥)
@Service
public class OrderService {
    @Transactional
    public void createOrder(CreateOrderRequest request) {
        // 1. 本地创建订单(状态=待确认)
        Order order = createPendingOrder(request);
        
        // 2. 发送扣减库存消息到MQ
        mqProducer.send("inventory:deduct", new DeductStockMessage(
            request.getProductId(), request.getQuantity(), order.getOrderId()
        ));
        
        // 3. 订单状态通过消费MQ消息更新(保证最终一致)
    }

    @KafkaListener(topics = "inventory:deduct:result")
    public void handleInventoryResult(InventoryResultMessage message) {
        if (message.isSuccess()) {
            orderMapper.updateStatus(message.getOrderId(), OrderStatus.PAID);
        } else {
            orderMapper.updateStatus(message.getOrderId(), OrderStatus.FAILED);
            // 触发补偿逻辑
        }
    }
}

踩坑5:分页查询结果不准

现象: 订单列表第1页显示20条,第2页开始有大量重复数据,第10页开始数据减少并最终消失。

原因: 分页 LIMIT 20 OFFSET 20 在8个分片上各执行,汇总后第2页实际拿到的是各分片的第2-3条数据,并非全局的第21-40条。

正确实现:

java 复制代码
/**
 * 分片环境下正确的分页查询
 * 使用游标分页(KeySet Pagination)替代偏移量分页
 */
public PageResult<Order> queryOrdersWithCursor(Long userId, Long lastOrderId, int size) {
    HintManager hintManager = HintManager.getInstance();
    
    try {
        List<Order> orders = new ArrayList<>();
        
        // 先用游标查下一页
        String cursorSql = String.format("""
            SELECT * FROM t_order WHERE user_id = ? AND order_id > ? 
            ORDER BY order_id LIMIT %d
        """, size);
        
        List<Order> nextPage = jdbcTemplate.query(cursorSql, 
            (rs, rowNum) -> mapRow(rs), userId, lastOrderId);
        
        if (!nextPage.isEmpty()) {
            return new PageResult<>(nextPage, nextPage.size() == size ? nextPage.get(size-1).getOrderId() : null);
        }
        
        // 如果游标查不到,fallback到第一页
        String firstPageSql = String.format(
            "SELECT * FROM t_order WHERE user_id = ? ORDER BY order_id LIMIT %d", size);
        List<Order> firstPage = jdbcTemplate.query(firstPageSql, 
            (rs, rowNum) -> mapRow(rs), userId);
        
        return new PageResult<>(firstPage, firstPage.size() == size ? firstPage.get(size-1).getOrderId() : null);
        
    } finally {
        HintManager.close();
    }
}

七、总结与思考

核心要点回顾

  1. 分片键是分库分表的核心:选择错误会导致全分片扫描,性能反而下降。选择高频查询字段 + 数据分布均匀是黄金准则。
  2. 分片算法决定扩容成本:取模简单但扩容代价大,一致性Hash支持平滑扩容但实现复杂。
  3. 雪花算法解决分布式ID问题:但要注意时钟回拨和WorkerID分配问题。
  4. 分库分表+读写分离是黄金组合:写压力分摊到主库集群,读压力分摊到从库集群。
  5. 跨分片JOIN是性能杀手:通过应用层组装、冷热数据分离、广播表等手段规避。
  6. 分布式事务是最大挑战:强一致性用Seata/XA,最终一致性用MQ消息队列。

思考题

  1. 如果订单表按user_id分片,用户查看自己所有订单时需要查询多少个分片?如果按create_time月份分片呢?哪种更好?
  2. 分库分表后,MySQL的局部索引是否还有意义?全局索引如何实现(Proxy层的索引表 or 引入ES)?
  3. 假设业务增长10倍,需要从4分片扩到16分片,如何设计平滑扩容方案使得业务中断时间为0?
  4. Seata的AT模式在分支事务失败时的回滚逻辑是什么?什么情况下会出现脏写?

个人观点

分库分表是"架构进化"的里程碑,但不是终点。它解决了单机数据库的性能瓶颈,但引入了运维复杂度、数据迁移、分布式事务等一系列新问题。我的经验是:在决定分库分表之前,至少尝试以下优化手段:

  1. 索引优化(覆盖索引、联合索引、最左前缀)
  2. Query Cache / Row Cache(MySQL 8.0已废弃,需用Redis替代)
  3. 读写分离(至少能提升2-3倍读性能)
  4. 数据归档(冷热分离,把3个月前的数据迁到归档库)
  5. 参数调优(Buffer Pool、IO参数、连接池)

当以上手段都尝试过、系统瓶颈仍然存在时,再选择分库分表。记住:过早优化是万恶之源,过晚优化是灾难之源。


📌 架构实战系列完结!感谢阅读。

系列回顾:

  • 第一篇:《Redis集群与缓存策略》
  • 第二篇:《MySQL主从复制与读写分离》
  • 第三篇:《分库分表ShardingSphere》
相关推荐
梦梦代码精3 小时前
以前比功能,现在比“不崩溃”——LikeShop如何用工程化架构终结商城维护噩梦
架构·开源·代码规范
该昵称用户已存在3 小时前
双碳背景下的能源数据变现:MyEMS 开源架构的资产化设计思路
架构·开源·能源
百珏3 小时前
海量人群包存储优化:基于 RoaringBitmap 交换格式与 Redis 分片 Bitmap 的实践
java·后端·架构
还有多久拿退休金3 小时前
我在自家页面嵌了个 iframe,结果对方说"你不配"——跨域和 CSP 的那些坑
前端·架构
heimeiyingwang3 小时前
【架构实战】安全性设计:让系统固若金汤
架构
亚空间仓鼠4 小时前
Docker容器化高可用架构部署方案(十五)
android·redis·docker·架构·sentinel
拉卡拉开放平台5 小时前
支付结算架构进阶:聚合支付、空中分账与财务业务一体化方案
大数据·架构
数字时代全景窗6 小时前
DeepSeek的荣耀与Evolver的困局:中国AI创新的一体两面
大数据·人工智能·架构·软件工程
Patrick_Wilson6 小时前
过早优化是万恶之源:50 年工程史反复在教我们的一件事
程序员·架构·ai编程