数据分片(Sharding)模式

订单表一年破了亿,每天新增几十万条记录。DBA 跑过来告诉你:单表超过 5000 万行,B+ 树的层级变深,查询性能开始明显下降,最慢的一条 SQL 跑了 10 秒。索引优化、SQL 改写、缓存加速——这些手段能治标但不能治本。根本问题是:单机数据库的容量和性能是有上限的,当数据量超过这个上限,必须把数据分散到多个数据库实例上。这就是数据分片(Sharding)要解决的问题:把数据水平拆分到多个节点,每个节点只负责一部分数据的读写,从而突破单机数据库的容量和性能瓶颈。

分片策略

分片策略决定了数据如何分布到各个节点。常见的三种策略是哈希分片、范围分片和目录分片。

哈希分片根据分片键(Sharding Key)的哈希值来决定数据归属哪个分片。例如 hash(order_id) % 4,结果为 0 的去分片 0,结果为 1 的去分片 1,以此类推。

flowchart TB
    User["用户请求"] --> SH["分片键\norder_id"]

    SH --> H1["哈希函数\nhash(order_id)"]
    H1 --> M["取模运算\n% shard_count"]

    M -->|0| S0["分片 0\nShards 0"]
    M -->|1| S1["分片 1\nShards 1"]
    M -->|2| S2["分片 2\nShards 2"]
    M -->|3| S3["分片 3\nShards 3"]

哈希分片的优势是数据分布均匀,适合随机读写场景。缺点是范围查询困难——如果要查"最近一个月的订单",需要广播到所有分片查询再聚合;还有扩容问题:分片数量变化时,大多数数据需要重新映射,迁移成本高。解决扩容问题的方案是一致性哈希(Consistent Hashing),通过环形哈希空间和虚拟节点,减少扩容时的数据迁移量。

范围分片根据分片键的值域范围来决定数据归属。例如 user_id < 1000000 去分片 0,1000000 <= user_id < 2000000 去分片 1。

范围分片的优势是支持范围查询,查询"ID 在 1000 到 2000 之间的订单"可以直接定位到某个分片;扩容相对简单,只需调整范围边界即可。缺点是可能出现热点问题——如果新用户集中在某个 ID 段,该分片的数据量会明显高于其他分片。

目录分片维护一个 Lookup 表,记录分片键到分片节点的映射关系。查询时先查 Lookup 表获取分片节点,再去对应节点查询。

目录分片的优势是灵活性最高,可以根据业务规则自定义数据分布;扩容时可以只修改 Lookup 表,不需要迁移数据。缺点是增加了一层查询开销,Lookup 表本身也需要高可用设计。

public class DirectoryShardingStrategy implements ShardingStrategy {
    private final Map<String, String> lookupTable = new ConcurrentHashMap<>();

    public String getShard(String shardingKey) {
        // 先查 Lookup 表
        String shardId = lookupTable.get(shardingKey);
        if (shardId == null) {
            // 未命中,根据规则计算
            shardId = calculateShard(shardingKey);
            lookupTable.put(shardingKey, shardId);
        }
        return shardId;
    }

    private String calculateShard(String key) {
        // 业务规则:VIP 用户去独立分片
        if (isVipUser(key)) {
            return "shard_vip";
        }
        // 普通用户按哈希分布
        return "shard_" + (Math.abs(key.hashCode()) % 4);
    }
}

分片键选择原则

分片键的选择直接决定了查询模式和数据分布均匀度,是分片设计中最关键的决策。

高频查询字段优先:如果 80% 的查询都是"按用户查订单",那么 user_id 是比 order_id 更好的分片键。这样用户相关的订单都在同一个分片,查询性能最优。如果选择了 order_id 作为分片键,每次查用户的订单都需要跨分片扫描。

数据分布均匀:分片键的值域分布应该尽量均匀,避免出现"一个大分片 + 多个小分片"的极端情况。例如按月份分片在月初和月末数据量差异巨大;按地区分片可能导致经济发达地区的数据量远大于其他地区。

避免跨分片查询:跨分片查询(Scatter-Gather)是最影响性能的操作。设计分片键时,应该尽量让高频查询能在单一分片内完成。如果业务确实需要跨分片聚合查询,需要评估是否接受广播查询的性能损耗,或者在应用层做并行查询 + 结果归并。

避免分片键变更:分片键确定后很难修改,因为历史数据的分片归属已经确定。如果业务演进导致分片键不再合理,改造成本非常高。设计时需要充分考虑业务发展趋势。

分布式 ID 生成

分片后,主键不能再依赖数据库的自增 ID(多个分片自增会冲突)。需要引入分布式 ID 生成方案。

雪花算法(Snowflake)是最广泛使用的方案。它通过时间戳 + 机器 ID + 序列号生成 64 位整数,理论上每秒可生成约 400 万个不重复 ID。

public class SnowflakeIdGenerator {
    private final long workerId;
    private final long datacenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

    private static final long EPOCH = 1609459200000L; // 2021-01-01
    private static final long WORKER_ID_BITS = 5L;
    private static final long DATACENTER_ID_BITS = 5L;
    private static final long SEQUENCE_BITS = 12L;

    private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
    private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS);

    public SnowflakeIdGenerator(long workerId, long datacenterId) {
        if (workerId > MAX_WORKER_ID || workerId < 0) {
            throw new IllegalArgumentException("worker Id can't be greater than " + MAX_WORKER_ID);
        }
        if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
            throw new IllegalArgumentException("datacenter Id can't be greater than " + MAX_DATACENTER_ID);
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    public synchronized long nextId() {
        long timestamp = timeGen();

        if (timestamp < lastTimestamp) {
            throw new RuntimeException("Clock moved backwards. Refusing to generate id");
        }

        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & ((1 << SEQUENCE_BITS) - 1);
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = timestamp;
        return ((timestamp - EPOCH) << (WORKER_ID_BITS + DATACENTER_ID_BITS + SEQUENCE_BITS))
                | (datacenterId << (WORKER_ID_BITS + SEQUENCE_BITS))
                | (workerId << SEQUENCE_BITS)
                | sequence;
    }
}

百度 UIDGenerator基于雪花算法优化,通过 RingBuffer 预批量生成 ID,减少了锁竞争,支持数据库 ID 段预取。美团 Leaf提供两种模式:号段模式(基于数据库批量获取 ID 段)和雪花模式(基于 ZooKeeper 分配 workerId)。

分片后的事务问题

本地事务只能在单一数据库实例内生效,分片后跨分片的操作无法保证原子性。这是分片架构的核心挑战之一。

弱化一致性的方案:对于大多数互联网业务,可以接受最终一致性。通过消息队列(如 RocketMQ、Kafka)实现可靠消息投递,确保跨分片的操作最终一致。例如扣库存和扣余额分别在两个分片,先扣库存并发送消息,消息消费者负责扣余额;如果扣余额失败,触发补偿操作。

分布式事务方案:如果业务对一致性要求极高,可以引入 Saga 或 2PC。但 2PC 在分片场景下性能损耗严重,Saga 是更合理的选择。具体可参考Saga 分布式事务模式

应用层协调:在应用层实现两阶段提交逻辑:第一阶段向所有分片发送预提交请求,第二阶段根据结果决定全局提交或回滚。这种方案实现复杂且容易出错,仅在特殊场景下使用。

分片中间件

ShardingSphere 和 MyCAT 是两个主流的分片中间件。

ShardingSphere是一套生态完整的数据库中间件,包括 Sharding-JDBC(JAR 引入,代理模式)和 Sharding-Proxy(独立部署,代理模式)。它支持多种分片策略、分片算法插件化配置、分片后的事务支持(CROSS_SHARD_TRANSACTION),社区活跃,文档完善。

spring:
  shardingsphere:
    rules:
      sharding:
        tables:
          t_order:
            actual-data-nodes: ds_${0..1}.t_order_${0..15}
            table-strategy:
              standard:
                sharding-column: order_id
                sharding-algorithm-name: order_inline
            key-generate-strategy:
              column: order_id
              key-generator-name: snowflake
        sharding-algorithms:
          order_inline:
            type: INLINE
            props:
              algorithm-expression: t_order_${order_id % 16}

MyCAT是早期国产的分片中间件,基于 MySQL Proxy 实现。支持数据分片、读写分离、故障切换等功能。缺点是架构较老,性能和稳定性不如 ShardingSphere,对新特性的支持也较慢。

选型建议:如果团队技术栈偏 Java,ShardingSphere 是首选;如果需要支持异构数据库或多语言环境,可以考虑 MyCAT 或自研分片层。