目录

MySQL-15 分库分表与分区

目录

MySQL 分库分表与分区


目录

  1. 为什么要分库分表
  2. MySQL 分区表
  3. 垂直拆分
  4. 水平拆分(Sharding)
  5. 分片键的选择
  6. 分片路由策略
  7. 分库分表带来的问题
  8. 数据迁移与平滑扩容
  9. 中间件与框架选型
  10. Go 实战:手写简单分片路由

1. 为什么要分库分表

1.1 单机 MySQL 的瓶颈

单机 MySQL 在数据量和并发量达到一定规模后,会遇到以下瓶颈:

性能瓶颈:
  单表数据量超过 2000 万行后,B+Tree 高度增加,索引查询变慢
  单库 QPS 上限约 10,000~30,000(视机器配置)
  大表 DDL(加字段、加索引)耗时长,期间产生大量锁等待

存储瓶颈:
  单机磁盘容量有限(TB 级)
  单表 .ibd 文件过大,备份、恢复耗时

可用性瓶颈:
  单机故障影响全部业务
  主从复制延迟随数据量增大而增加

1.2 什么时候考虑分库分表

不要过早分库分表!先考虑以下优化:
  1. 加索引、优化慢查询
  2. 读写分离(主库写,从库读)
  3. 引入缓存层(Redis)
  4. 升级硬件(SSD、更大内存)

满足以下条件之一,才考虑分库分表:
  单表行数 > 1000 万,且查询性能明显下降
  单库 QPS 持续超过 5,000,主从复制延迟 > 1s
  单机磁盘使用率 > 70%,预计 1 年内撑满
  大表 DDL 超过 30 分钟,影响业务

1.3 拆分的两个维度

垂直拆分(按列/按业务):解决"表太宽"或"库太杂"的问题
  垂直分表:把一张宽表按列拆成多张表(冷热分离)
  垂直分库:把不同业务的表拆到不同数据库(微服务化)

水平拆分(按行,Sharding):解决"数据量太大"的问题
  水平分表:同一个库内,把数据按规则拆到多张结构相同的表
  水平分库:把数据拆到多个库,每个库可以在不同机器上

2. MySQL 分区表

分区表是 MySQL 原生支持的功能,对应用透明(仍是一张逻辑表),底层按规则将数据分散到不同的物理文件。

2.1 分区类型

RANGE 分区(最常用)

按列值的范围分区,非常适合时间序列数据

-- 按年份分区的订单表
CREATE TABLE orders (
    id          BIGINT UNSIGNED AUTO_INCREMENT,
    order_no    VARCHAR(64) NOT NULL,
    user_id     BIGINT UNSIGNED NOT NULL,
    amount      DECIMAL(10,2) NOT NULL,
    created_at  DATETIME NOT NULL,
    PRIMARY KEY (id, created_at)  -- 分区键必须包含在主键中
) ENGINE=InnoDB
PARTITION BY RANGE (YEAR(created_at)) (
    PARTITION p2022 VALUES LESS THAN (2023),
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p2025 VALUES LESS THAN (2026),
    PARTITION p_future VALUES LESS THAN MAXVALUE  -- 兜底分区
);

分区裁剪(Partition Pruning):查询时带上分区键,MySQL 只扫描相关分区。

-- 查询 2024 年的数据,只扫描 p2024 分区
EXPLAIN SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31';
-- partitions: p2024  ← 分区裁剪生效

-- 不带分区键,扫描所有分区(分区表退化为全表扫描)
EXPLAIN SELECT * FROM orders WHERE order_no = 'ORD202401010001';
-- partitions: p2022,p2023,p2024,p2025,p_future  ← 裁剪失效!

添加新分区(每年操作一次)

-- 先把 p_future 改掉,再加新的 p_future
ALTER TABLE orders REORGANIZE PARTITION p_future INTO (
    PARTITION p2026 VALUES LESS THAN (2027),
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

删除旧分区(极快,直接删文件)

-- 删除 2022 年分区(比 DELETE 快几个数量级,不产生 Undo Log)
ALTER TABLE orders DROP PARTITION p2022;

LIST 分区

按列值的枚举列表分区,适合按地区、按类别等固定集合分区。

-- 按地区分区
CREATE TABLE user_profiles (
    id       BIGINT UNSIGNED AUTO_INCREMENT,
    user_id  BIGINT UNSIGNED NOT NULL,
    region   TINYINT NOT NULL COMMENT '1=华北 2=华南 3=华东 4=西部',
    nickname VARCHAR(64),
    PRIMARY KEY (id, region)
) ENGINE=InnoDB
PARTITION BY LIST (region) (
    PARTITION p_north  VALUES IN (1),
    PARTITION p_south  VALUES IN (2),
    PARTITION p_east   VALUES IN (3),
    PARTITION p_west   VALUES IN (4)
);

HASH 分区

按列值取模均匀分布,适合没有明显范围规律的场景。

-- 按 user_id 哈希分 8 个区
CREATE TABLE user_behavior (
    id      BIGINT UNSIGNED AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,
    action  VARCHAR(32) NOT NULL,
    ts      DATETIME NOT NULL,
    PRIMARY KEY (id, user_id)
) ENGINE=InnoDB
PARTITION BY HASH(user_id)
PARTITIONS 8;

KEY 分区

与 HASH 类似,但使用 MySQL 内部的哈希函数,支持多列和字符串类型。

PARTITION BY KEY(user_id, created_at)
PARTITIONS 16;

2.2 分区表的限制

1. 分区键必须包含在主键和所有唯一索引中
2. 每个表最多 8192 个分区(MySQL 8.0+)
3. 外键不能和分区表一起使用
4. 分区表的全文索引不支持
5. 查询不带分区键时,退化为全分区扫描(比单表还慢)

适合分区表的场景:
  ✅ 历史数据归档(按时间范围,快速删除旧数据)
  ✅ 数据都在同一台机器上,只是文件物理隔离
  ✅ 查询条件基本都带分区键

不适合分区表的场景:
  ❌ 需要跨机器扩展(分区表无法跨机器)
  ❌ 查询模式多样,分区键不固定
  ❌ 数据量还没到瓶颈(过度设计)

2.3 查看分区信息

-- 查看各分区数据量
SELECT
    PARTITION_NAME,
    TABLE_ROWS,
    AVG_ROW_LENGTH,
    DATA_LENGTH / 1024 / 1024 AS data_mb
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'your_db' AND TABLE_NAME = 'orders';

-- 查看分区裁剪是否生效(看 partitions 列)
EXPLAIN SELECT * FROM orders WHERE created_at >= '2024-01-01';

3. 垂直拆分

3.1 垂直分表:冷热列分离

一张宽表中,有些列频繁访问(热字段),有些列偶尔访问(冷字段)。宽表会导致:

  • 行数据变长,单个数据页存储的行数减少,IO 变多
  • 查询时读取了大量无用列(如 TEXT、BLOB 字段)

案例:商品表拆分

-- 拆分前:一张 20+ 列的宽表
CREATE TABLE products (
    id           BIGINT PRIMARY KEY,
    name         VARCHAR(255),       -- 热字段(列表页)
    price        DECIMAL(10,2),      -- 热字段(列表页)
    category_id  INT,                -- 热字段
    stock        INT,                -- 热字段
    thumbnail    VARCHAR(512),       -- 热字段
    -- 以下是冷字段(详情页才用)
    description  TEXT,               -- 商品详情,大字段
    spec_json    JSON,               -- 规格参数
    images_json  JSON,               -- 图片列表
    seo_title    VARCHAR(255),
    seo_keywords VARCHAR(255),
    created_at   DATETIME,
    updated_at   DATETIME
);

-- 拆分后:主表(高频访问)+ 详情表(低频访问)
CREATE TABLE products (
    id           BIGINT PRIMARY KEY AUTO_INCREMENT,
    name         VARCHAR(255) NOT NULL,
    price        DECIMAL(10,2) NOT NULL,
    category_id  INT NOT NULL,
    stock        INT NOT NULL DEFAULT 0,
    thumbnail    VARCHAR(512),
    created_at   DATETIME NOT NULL DEFAULT NOW(),
    updated_at   DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW()
);

CREATE TABLE product_details (
    product_id   BIGINT PRIMARY KEY,  -- 与 products.id 一一对应
    description  TEXT,
    spec_json    JSON,
    images_json  JSON,
    seo_title    VARCHAR(255),
    seo_keywords VARCHAR(255),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

效果

  • 商品列表查询只访问 products(行短,IO 少,缓存命中率高)
  • 商品详情页联合查询 product_details(只在必要时)

3.2 垂直分库:按业务拆分

将不同业务模块的表分散到不同数据库(通常对应微服务),解决单库连接数不够业务耦合的问题。

拆分前(大单体数据库):
  shop_db
    ├── users
    ├── user_profiles
    ├── orders
    ├── order_items
    ├── products
    ├── product_details
    ├── categories
    ├── carts
    ├── payments
    └── logistics

拆分后(按业务垂直分库):
  user_db       ← 用户服务
    ├── users
    └── user_profiles

  order_db      ← 订单服务
    ├── orders
    └── order_items

  product_db    ← 商品服务
    ├── products
    ├── product_details
    └── categories

  payment_db    ← 支付服务
    └── payments

  logistics_db  ← 物流服务
    └── logistics

垂直分库的代价

  • 跨库 JOIN 变为应用层 JOIN(查多次再合并)
  • 跨库事务需要分布式事务方案(见上一篇)
  • 数据冗余(如订单表冗余用户名、商品名,避免跨库查询)

4. 水平拆分(Sharding)

4.1 水平分表(同库)

数据量大但并发不高时,在同一个数据库内将数据拆到多张结构相同的表。

order_db
  ├── orders_0
  ├── orders_1
  ├── orders_2
  └── orders_3

路由规则示例(user_id % 4):
  user_id=100 → 100 % 4 = 0 → orders_0
  user_id=101 → 101 % 4 = 1 → orders_1

适用场景:单库 IO 和存储是瓶颈,但 QPS 还好。

4.2 水平分库(跨机器)

数据量大且并发高时,将数据拆到多个数据库,部署在不同机器上。

                      ┌──────────────┐
请求 ──路由层──▶       │   order_db_0 │  machine-1
                      │  orders_0    │
                      │  orders_1    │
                      └──────────────┘
                      ┌──────────────┐
                      │   order_db_1 │  machine-2
                      │  orders_2    │
                      │  orders_3    │
                      └──────────────┘
                      ┌──────────────┐
                      │   order_db_2 │  machine-3
                      │  orders_4    │
                      │  orders_5    │
                      └──────────────┘
                      ┌──────────────┐
                      │   order_db_3 │  machine-4
                      │  orders_6    │
                      │  orders_7    │
                      └──────────────┘

2 库 × 4 表/库 = 8 张表,可承载 8 倍数据量

4.3 分库分表组合

实际生产中通常是先分库,每个库再分表

分片规则示例(4 库 × 4 表 = 16 个分片):

shard_id = user_id % 16
db_index  = shard_id / 4    → 决定去哪个库
tbl_index = shard_id % 4    → 决定去哪张表

user_id=1000:
  shard_id = 1000 % 16 = 8
  db_index  = 8 / 4 = 2   → order_db_2
  tbl_index = 8 % 4 = 0   → orders_0
  最终:order_db_2.orders_0

5. 分片键的选择

分片键(Sharding Key)是水平拆分的核心,选择不当会导致数据倾斜大量跨分片查询

5.1 选择原则

原则 1:高频查询必须带分片键
  最常用的查询条件要包含分片键,避免散列查询(全分片扫描)

原则 2:数据分布均匀
  分片键的值要足够离散,避免某个分片数据远多于其他分片(热点)

原则 3:业务关联性强的数据落在同一分片
  如:同一用户的所有订单要在同一分片,避免跨分片聚合

原则 4:不要频繁变化
  分片键一旦确定,更改代价极大(需要数据迁移)

原则 5:尽量使用整型
  整型取模运算比字符串哈希快

5.2 常见分片键方案

按用户 ID 分片(最常见)

适用:ToC 业务,数据天然以用户为单位聚合
  订单表按 user_id 分片 → 查询"我的订单"不跨分片
  用户行为表按 user_id 分片 → 查询"我的浏览记录"不跨分片

问题:大 V 用户(粉丝千万)产生的数据远多于普通用户 → 热点
解决:对大 V 单独处理(独立分片或特殊路由)

按订单 ID 分片

适用:ToB 业务或需要按订单直接查询
  商家查询"今天所有订单"会跨分片(商家名下可能有多个用户)

问题:同一用户的订单散落在不同分片,查"我的订单"需跨分片
权衡:如果按订单 ID 查询是最高频操作,选此方案

按时间分片(不推荐单独用)

问题:写入永远在最新分片(热点写),旧分片几乎没有写入
      大多数业务不是按时间查,而是按用户/商家查,必然跨分片
推荐:时间 + 用户 ID 组合分片,或用分区表代替

按地理位置分片

适用:有明显地域属性的业务(外卖、打车)
  骑手订单按城市 ID 分片 → 城市维度的统计不跨分片

问题:一线城市数据远多于小城市 → 不均匀
解决:大城市独立分片,小城市合并分片

5.3 案例:电商订单分片键

业务查询频率排序:
  1. 用户查询"我的订单列表"(按 user_id)     ← 最高频,ToC
  2. 商家查询"我店的订单"(按 shop_id)        ← 高频,ToB
  3. 客服查询某个订单(按 order_id)           ← 中频
  4. 运营查询某天所有订单(按日期)             ← 低频,走数仓

结论:选 user_id 作为分片键(满足最高频查询)

代价及解决方案:
  商家查"我店的订单" → 跨分片,走 Elasticsearch 或数仓
  按 order_id 查    → order_id 中编码 user_id(见分布式 ID 章节)
  运营按日期查       → 走离线数仓或 OLAP 系统

6. 分片路由策略

6.1 Range 路由

按范围划分分片,如按 ID 范围、时间范围。

分片规则:
  shard_0: user_id  [0,        5,000,000)
  shard_1: user_id  [5,000,000, 10,000,000)
  shard_2: user_id  [10,000,000, 15,000,000)
  ...

优点:
  扩容时只需增加新分片,不用迁移历史数据
  范围查询性能好(连续数据在同一分片)

缺点:
  新注册用户永远在最新分片(写热点)
  各分片数据量不均匀(早期用户少,晚期用户多)

6.2 Hash 路由(取模)

shard_id = hash(sharding_key) % shard_count

优点:
  数据分布均匀,无热点写
  实现简单

缺点:
  扩容时需要迁移大量数据(shard_count 变化,路由结果全变)
  范围查询必须全分片扫描

6.3 一致性哈希路由

解决取模路由扩容迁移量大的问题。

原理:
  将所有分片 ID 映射到一个虚拟环(0 ~ 2^32)上
  分片键 hash 后,顺时针找到第一个分片节点

  [shard_0]---[shard_1]---[shard_2]---[shard_3]---(环形)

扩容(新增 shard_4):
  只有 shard_4 与 shard_0 之间的数据需要迁移
  理论上只迁移 1/N 的数据(N = 新分片数)

虚节点(Virtual Node):
  每个物理节点对应多个虚节点(如 150 个),使数据分布更均匀
  避免节点少时分布不均匀的问题

6.4 路由表(Mapping Table)

维护一张"用户 ID → 分片 ID"的映射表,灵活性最高。

CREATE TABLE shard_mapping (
    user_id   BIGINT UNSIGNED PRIMARY KEY,
    shard_id  TINYINT UNSIGNED NOT NULL,
    INDEX idx_shard (shard_id)
);
优点:
  可以按需迁移单个用户,大 V 单独处理
  扩容时精准控制迁移范围

缺点:
  每次路由都要查这张表(需要缓存,否则成为性能瓶颈)
  映射表本身需要高可用

7. 分库分表带来的问题

水平分库分表是最后的手段,因为它会带来一系列新问题,每个问题都需要专门的解决方案。

7.1 分布式 ID

分库分表后,各分片的自增 ID 会冲突,必须使用全局唯一 ID

方案一:UUID

id := uuid.New().String() // e.g. "550e8400-e29b-41d4-a716-446655440000"
缺点:
  36 字节字符串,占用空间大,索引性能差
  UUID v4 完全随机,插入时导致 B+Tree 页分裂,写入性能差
  不包含时间信息,无法按 ID 排序反映插入顺序

方案二:数据库自增(多主)

使用多个数据库,每个库生成不同步长的自增 ID:

-- 库1:AUTO_INCREMENT 从 1 开始,步长 2(生成奇数 ID)
SET @@auto_increment_offset = 1;
SET @@auto_increment_increment = 2;

-- 库2:AUTO_INCREMENT 从 2 开始,步长 2(生成偶数 ID)
SET @@auto_increment_offset = 2;
SET @@auto_increment_increment = 2;
缺点:
  生成 ID 需要访问数据库,性能瓶颈
  步长固定,扩展新节点困难

方案三:Snowflake 雪花算法(推荐)

64 bit 结构:
┌──────────────────────────────────────────────────────────────────┐
│ 0 │     41bit 毫秒时间戳      │ 10bit 机器ID │   12bit 序列号    │
│   │  (从某个纪元到现在的毫秒)  │ (5bit+5bit)  │  (每毫秒最多4096) │
└──────────────────────────────────────────────────────────────────┘

特点:
  趋势递增(时间戳在高位),写入 B+Tree 性能好
  纯内存生成,每秒可生成 400 万+ ID
  包含时间信息,可反向解析出生成时间
  10bit 机器ID 支持 1024 个节点

问题:
  依赖系统时钟,时钟回拨会导致 ID 重复
  解决方案:检测到时钟回拨时,等待时钟追上或抛异常
// 简化版 Snowflake(生产建议使用 github.com/bwmarrin/snowflake)
type Snowflake struct {
    mu        sync.Mutex
    epoch     int64  // 起始纪元(毫秒时间戳)
    nodeID    int64  // 机器 ID(0~1023)
    sequence  int64  // 序列号(0~4095)
    lastStamp int64  // 上次生成 ID 的时间戳
}

const (
    nodeBits     = 10
    sequenceBits = 12
    maxNodeID    = -1 ^ (-1 << nodeBits)     // 1023
    maxSequence  = -1 ^ (-1 << sequenceBits) // 4095
    timeShift    = nodeBits + sequenceBits    // 22
    nodeShift    = sequenceBits               // 12
)

func NewSnowflake(nodeID int64) (*Snowflake, error) {
    if nodeID < 0 || nodeID > maxNodeID {
        return nil, fmt.Errorf("nodeID must be between 0 and %d", maxNodeID)
    }
    // 2020-01-01 00:00:00 UTC 作为纪元,延长可用时间
    epoch := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
    return &Snowflake{nodeID: nodeID, epoch: epoch}, nil
}

func (s *Snowflake) NextID() (int64, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    now := time.Now().UnixMilli() - s.epoch

    if now < s.lastStamp {
        // 时钟回拨:等待最多 2ms
        wait := s.lastStamp - now
        if wait > 2 {
            return 0, fmt.Errorf("clock moved backwards by %d ms", wait)
        }
        time.Sleep(time.Duration(wait) * time.Millisecond)
        now = time.Now().UnixMilli() - s.epoch
    }

    if now == s.lastStamp {
        s.sequence = (s.sequence + 1) & maxSequence
        if s.sequence == 0 {
            // 同一毫秒内序列号用完,等下一毫秒
            for now <= s.lastStamp {
                now = time.Now().UnixMilli() - s.epoch
            }
        }
    } else {
        s.sequence = 0
    }

    s.lastStamp = now
    id := (now << timeShift) | (s.nodeID << nodeShift) | s.sequence
    return id, nil
}

// 从 Snowflake ID 反解时间(调试用)
func ParseSnowflakeTime(id int64, epoch time.Time) time.Time {
    ms := id >> timeShift
    return epoch.Add(time.Duration(ms) * time.Millisecond)
}

将分片信息编码进 ID(关键技巧)

// 在 Snowflake ID 中编码 shard_id,实现按 ID 反向路由
// 结构:时间戳 | shard_id | 序列号
// 这样通过 order_id 就能知道它在哪个分片,无需映射表

const (
    shardBits    = 6  // 支持 64 个分片
    seqBits      = 10 // 每毫秒每分片 1024 个 ID
    shardMaxID   = (1 << shardBits) - 1 // 63
)

func NewShardedID(timestamp, shardID, sequence int64) int64 {
    return (timestamp << (shardBits + seqBits)) |
           (shardID  << seqBits) |
           sequence
}

func ExtractShardID(id int64) int64 {
    return (id >> seqBits) & shardMaxID
}

// 使用示例:
// orderID = NewShardedID(now, userID%64, seq)
// 查询时:shardID = ExtractShardID(orderID)
// 无需查映射表,直接路由到正确分片

7.2 跨分片查询

这是分库分表后最棘手的问题。

场景一:按非分片键查询(散列查询)

问题:
  分片键是 user_id,商家要查"我店铺的所有订单"(按 shop_id)
  shop_id 不是分片键,无法路由到单一分片

解决方案 1:全分片广播查询(不推荐,性能差)
  同时查所有分片,合并结果
  适合数据量小、分片数少的场景

解决方案 2:建立二级索引表(推荐)
  维护一张 shop_id → order_id 的索引表
  先查索引表得到 order_id 列表
  再用 order_id 中编码的 shard_id 查对应分片
-- 二级索引表(存在单独的 index_db)
CREATE TABLE shop_order_index (
    shop_id   BIGINT UNSIGNED NOT NULL,
    order_id  BIGINT UNSIGNED NOT NULL,  -- 编码了 shard_id
    created_at DATETIME NOT NULL,
    PRIMARY KEY (shop_id, order_id),
    INDEX idx_created (shop_id, created_at)
);
// 商家查询订单:二级索引 → 精准路由
func (s *OrderService) ListShopOrders(ctx context.Context, shopID uint64, page, size int) ([]Order, error) {
    // 1. 查二级索引表,得到 order_id 列表
    var orderIDs []uint64
    s.indexDB.WithContext(ctx).
        Select("order_id").
        Where("shop_id = ?", shopID).
        Order("created_at DESC").
        Offset((page-1)*size).Limit(size).
        Find(&orderIDs)

    if len(orderIDs) == 0 {
        return nil, nil
    }

    // 2. 按 shard_id 分组
    shardGroups := make(map[int64][]uint64)
    for _, id := range orderIDs {
        shardID := ExtractShardID(int64(id))
        shardGroups[shardID] = append(shardGroups[shardID], id)
    }

    // 3. 并发查各分片(通常命中 1~2 个分片,因为订单都属于某个用户)
    var mu sync.Mutex
    var orders []Order
    var wg sync.WaitGroup
    var firstErr error

    for shardID, ids := range shardGroups {
        wg.Add(1)
        go func(sid int64, oids []uint64) {
            defer wg.Done()
            db := s.getShardDB(sid)
            var shardOrders []Order
            err := db.WithContext(ctx).
                Where("id IN ?", oids).
                Find(&shardOrders).Error
            mu.Lock()
            defer mu.Unlock()
            if err != nil && firstErr == nil {
                firstErr = err
            }
            orders = append(orders, shardOrders...)
        }(shardID, ids)
    }
    wg.Wait()

    if firstErr != nil {
        return nil, firstErr
    }

    // 4. 内存排序(不同分片的数据需要重新排序)
    sort.Slice(orders, func(i, j int) bool {
        return orders[i].CreatedAt.After(orders[j].CreatedAt)
    })
    return orders, nil
}

解决方案 3:数据双写(冗余存储)

同一份订单数据,写入两套分片:
  按 user_id 分片的 orders_by_user → 满足用户查询
  按 shop_id 分片的 orders_by_shop → 满足商家查询

代价:存储翻倍,两套数据要保持一致
适合:读远多于写,且两种维度查询都高频

场景二:分页查询

问题:查第 2 页(每页 20 条),SQL 是 LIMIT 20 OFFSET 20
  每个分片执行 LIMIT 20 OFFSET 20,各取 20 条(共 n*20 条)
  汇总后全局排序,取前 20 条
  页码越大,拉取的数据越多(深分页严重)

方案 1:禁止深分页(推荐)
  限制最多查前 100 页(2000 条)
  提示用户使用搜索缩小范围

方案 2:游标分页(瀑布流场景)
  用上次结果的最大/最小 ID 作为游标,不用 OFFSET
  每次只查 cursor 之后的数据

方案 3:对于管理后台的深分页
  数据同步到 Elasticsearch,由 ES 承担复杂查询
// 游标分页(不跨分片,性能好)
type OrderCursor struct {
    LastID        uint64    `json:"last_id"`
    LastCreatedAt time.Time `json:"last_created_at"`
}

func (s *OrderService) ListUserOrdersByCursor(
    ctx context.Context, userID uint64, cursor *OrderCursor, size int,
) ([]Order, *OrderCursor, error) {
    db := s.getShardDBByUserID(userID) // 按 user_id 路由,不跨分片
    tableName := s.getTableName(userID)

    query := db.WithContext(ctx).Table(tableName).
        Where("user_id = ?", userID).
        Order("created_at DESC, id DESC").
        Limit(size + 1) // 多取一条,判断是否有下一页

    if cursor != nil {
        query = query.Where(
            "(created_at < ? OR (created_at = ? AND id < ?))",
            cursor.LastCreatedAt, cursor.LastCreatedAt, cursor.LastID,
        )
    }

    var orders []Order
    if err := query.Find(&orders).Error; err != nil {
        return nil, nil, err
    }

    var nextCursor *OrderCursor
    if len(orders) > size {
        last := orders[size-1]
        nextCursor = &OrderCursor{
            LastID:        last.ID,
            LastCreatedAt: last.CreatedAt,
        }
        orders = orders[:size]
    }

    return orders, nextCursor, nil
}

场景三:聚合统计(COUNT、SUM、AVG)

问题:统计全站今日下单数
  SELECT COUNT(*) FROM orders WHERE DATE(created_at) = '2024-01-01'
  需要查所有分片,汇总结果

解决方案:
  1. 在线统计(数据量小):并发查所有分片,SUM 各分片的 COUNT
  2. 预计算(推荐):定时任务统计写入汇总表,查汇总表
  3. 数仓(推荐):将数据同步到 ClickHouse / Hive,在数仓做统计
// 并发查所有分片做聚合
func (s *OrderService) CountTodayOrders(ctx context.Context) (int64, error) {
    today := time.Now().Format("2006-01-02")
    var total int64
    var mu sync.Mutex
    var wg sync.WaitGroup
    var firstErr error

    for i := 0; i < s.shardCount; i++ {
        wg.Add(1)
        go func(shardID int) {
            defer wg.Done()
            var count int64
            db := s.shards[shardID]
            err := db.WithContext(ctx).
                Table(fmt.Sprintf("orders_%d", shardID)).
                Where("DATE(created_at) = ?", today).
                Count(&count).Error
            mu.Lock()
            defer mu.Unlock()
            if err != nil && firstErr == nil {
                firstErr = err
                return
            }
            total += count
        }(i)
    }
    wg.Wait()
    return total, firstErr
}

场景四:跨分片 JOIN

问题:查询订单同时关联用户信息
  SELECT o.*, u.name FROM orders o JOIN users u ON o.user_id = u.id
  orders 在 order_db(分片),users 在 user_db(分片)
  数据库层无法执行跨库 JOIN

解决方案:
  1. 应用层 JOIN:先查 orders,再按 user_id 批量查 users(IN 查询),内存合并
  2. 数据冗余:在 orders 表冗余 user_name、user_phone 等常用字段
  3. 全局表(字典表):数据量小且基本不变的表(如省市区),同步到每个分片
-- 数据冗余:订单表冗余用户和商品信息(下单时快照)
CREATE TABLE orders_0 (
    id           BIGINT UNSIGNED PRIMARY KEY,
    user_id      BIGINT UNSIGNED NOT NULL,
    user_name    VARCHAR(64),    -- 冗余自 users.name(下单时快照)
    user_phone   VARCHAR(20),    -- 冗余(脱敏后)
    product_id   BIGINT UNSIGNED NOT NULL,
    product_name VARCHAR(255),   -- 冗余自 products.name(下单时快照)
    product_sku  VARCHAR(64),    -- 冗余
    quantity     INT UNSIGNED NOT NULL,
    unit_price   DECIMAL(10,2) NOT NULL,  -- 下单时的价格快照
    amount       DECIMAL(10,2) NOT NULL,
    status       VARCHAR(32) NOT NULL,
    created_at   DATETIME NOT NULL,
    -- 冗余字段的好处:查订单不需要跨库查用户/商品
    INDEX idx_user_id (user_id)
);

7.3 分布式事务

分库分表后,跨分片写操作无法使用单机事务,需要分布式事务方案(见上一篇)。

实践建议

1. 尽量设计成同一分片内完成事务
   下单时,把订单和订单项都按 user_id 路由到同一分片

2. 无法避免跨分片时,用本地消息表(最终一致性)
   商品库存减少(product_db)→ MQ → 订单状态更新(order_db)

3. 金融级强一致才用 TCC,绝大多数业务接受最终一致

7.4 唯一约束失效

问题:
  唯一约束只在单表内有效
  orders_0 和 orders_1 的 order_no 字段,数据库无法保证跨表唯一

解决方案:
  1. 全局唯一 ID 生成器(Snowflake)保证 ID 唯一
  2. 业务唯一键(order_no)通过 Redis SETNX 或 唯一索引表 保证
// Redis 分布式锁保证 order_no 全局唯一
func (s *OrderService) generateUniqueOrderNo(ctx context.Context) (string, error) {
    for i := 0; i < 3; i++ {
        orderNo := fmt.Sprintf("ORD%s%06d", time.Now().Format("20060102150405"), rand.Intn(999999))
        ok, err := s.redis.SetNX(ctx, "orderno:"+orderNo, "1", 24*time.Hour).Result()
        if err != nil {
            return "", err
        }
        if ok {
            return orderNo, nil
        }
        // 极小概率冲突,重试
    }
    return "", errors.New("failed to generate unique order no")
}

7.5 运维复杂度

问题:
  原来 1 个数据库 → 现在 16 个数据库实例
  DDL 变更(加字段)需要在所有分片执行
  备份、监控、慢查询分析都要汇总 16 份

解决方案:
  DDL:使用 gh-ost 或 pt-online-schema-change,在所有分片顺序执行
  监控:统一接入 Prometheus + Grafana,汇总所有分片指标
  慢查询:统一接入慢查询分析平台(如 Percona PMM)
  备份:各分片独立备份,备份脚本循环执行

8. 数据迁移与平滑扩容

8.1 初次分库分表:存量数据迁移

从单库迁移到分库分表,需要做到不停服、可回滚

推荐方案:双写迁移(业界标准)

阶段一:准备
  1. 建好新的分库分表结构
  2. 代码支持"双写模式"(写旧库同时写新库)
  3. 代码支持"读旧库/读新库"的开关(配置中心控制)

阶段二:全量迁移
  4. 用迁移工具将旧库数据全量同步到新库(分批次,限速)
  5. 全量迁移完成后,验证数据一致性(行数、关键字段抽查)

阶段三:双写
  6. 灰度上线双写代码(写旧库 + 写新库)
  7. 用 binlog 或对比工具持续验证新旧库数据一致性

阶段四:切读
  8. 小流量切到新库读(1% → 10% → 50% → 100%)
  9. 持续监控错误率和延迟

阶段五:下线旧库
  10. 读流量全部切到新库后,停止双写
  11. 观察一段时间(1~2周),确认无问题,下线旧库
// 双写模式示例
type OrderRepo struct {
    oldDB    *gorm.DB
    newShards []*gorm.DB
    // 配置开关,从配置中心读取
    dualWrite    bool // 是否双写
    readFromNew  bool // 是否从新库读
    newWriteRatio float64 // 新库写入比例(灰度用)
}

func (r *OrderRepo) Create(ctx context.Context, order *Order) error {
    // 写旧库(始终写,保证回滚能力)
    if err := r.oldDB.WithContext(ctx).Create(order).Error; err != nil {
        return err
    }

    // 写新库(双写阶段)
    if r.dualWrite {
        shardDB := r.getShardDB(order.UserID)
        if err := shardDB.WithContext(ctx).Create(order).Error; err != nil {
            // 新库写失败:记录日志,不影响旧库的结果
            // 由数据修复任务补偿
            log.Warn("dual write to new shard failed",
                zap.Uint64("order_id", order.ID),
                zap.Error(err),
            )
        }
    }
    return nil
}

func (r *OrderRepo) FindByUserID(ctx context.Context, userID uint64) ([]Order, error) {
    if r.readFromNew {
        // 读新库
        shardDB := r.getShardDB(userID)
        var orders []Order
        return orders, shardDB.WithContext(ctx).
            Where("user_id = ?", userID).Find(&orders).Error
    }
    // 读旧库
    var orders []Order
    return orders, r.oldDB.WithContext(ctx).
        Where("user_id = ?", userID).Find(&orders).Error
}

8.2 扩容(分片数从 N 增加到 2N)

取模路由的扩容是最常见的挑战。

当前:4 个分片(user_id % 4)

  shard_0: user_id ≡ 0 (mod 4)
  shard_1: user_id ≡ 1 (mod 4)
  shard_2: user_id ≡ 2 (mod 4)
  shard_3: user_id ≡ 3 (mod 4)

扩容到 8 个分片(user_id % 8):

  新 shard_0: user_id ≡ 0 (mod 8)  ← 原 shard_0 的一半数据
  新 shard_4: user_id ≡ 4 (mod 8)  ← 原 shard_0 的另一半数据

  新 shard_1: user_id ≡ 1 (mod 8)  ← 原 shard_1 的一半数据
  新 shard_5: user_id ≡ 5 (mod 8)  ← 原 shard_1 的另一半数据

  ...

关键发现:翻倍扩容时,每个旧分片只需迁移"一半"数据到新分片
  从 shard_0 迁移 user_id % 8 == 4 的数据到 new_shard_4
  其余数据不动

扩容步骤:
  1. 新建 shard_4~7(空实例)
  2. 从 shard_0 向 shard_4 迁移数据(user_id % 8 == 4 的记录)
     从 shard_1 向 shard_5 迁移数据
     ...(可并行)
  3. 双写:同时写 shard_0 和 shard_4(根据 user_id 决定)
  4. 数据一致后,将路由切到 8 分片逻辑
  5. 删除旧分片中已迁走的数据
// 翻倍扩容路由(过渡期)
func (s *ShardRouter) GetShardDuringExpansion(userID uint64) *gorm.DB {
    oldShard := userID % 4
    newShard := userID % 8

    // 迁移状态:每个旧分片的迁移进度(从配置中心读取)
    migrationDone := s.isMigrationDone(oldShard)

    if migrationDone {
        return s.newShards[newShard] // 迁移完成,路由到新分片
    }

    // 还在迁移中,根据 user_id 决定
    if newShard >= 4 {
        // 这部分数据需要从 shard_0 迁到 shard_4
        // 查旧分片(迁移未完成时数据还在旧分片)
        return s.oldShards[oldShard]
    }
    return s.oldShards[oldShard]
}

9. 中间件与框架选型

9.1 ShardingSphere(Java 生态主流)

Apache ShardingSphere 提供两种接入方式:

ShardingSphere-JDBC(应用内,无额外进程)

# 配置示例
dataSources:
  ds_0: ...  # order_db_0
  ds_1: ...  # order_db_1

rules:
  - !SHARDING
    tables:
      orders:
        actualDataNodes: ds_${0..1}.orders_${0..3}
        databaseStrategy:
          standard:
            shardingColumn: user_id
            shardingAlgorithmName: db_hash
        tableStrategy:
          standard:
            shardingColumn: user_id
            shardingAlgorithmName: tbl_hash
    shardingAlgorithms:
      db_hash:
        type: INLINE
        props:
          algorithm-expression: ds_${user_id % 2}
      tbl_hash:
        type: INLINE
        props:
          algorithm-expression: orders_${user_id % 4}

ShardingSphere-Proxy(独立代理进程,对应用透明)

应用 → ShardingSphere-Proxy(MySQL 协议) → 各分片
应用无需修改任何代码,像访问单机 MySQL 一样

9.2 Vitess(Go 生态,YouTube 出品)

优势:
  Google/YouTube 在生产大规模验证
  支持自动化 resharding(扩容)
  内置连接池管理
  对 Kubernetes 友好

适合:
  超大规模(YouTube 级别)
  团队有运维 Vitess 的能力

9.3 应用层自实现(Go 小团队推荐)

对于中小规模业务,直接在应用层实现分片路由,无需引入复杂中间件:

// 见第 10 节完整实现

10. Go 实战:手写简单分片路由

以电商订单系统为例,实现一个完整的分库分表路由层。

10.1 配置与初始化

package sharding

import (
    "context"
    "fmt"
    "sync"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
)

// ShardConfig 分片配置
type ShardConfig struct {
    DBCount    int      // 分库数量
    TableCount int      // 每库分表数量
    DSNs       []string // 各库的 DSN(len = DBCount)
}

// ShardManager 分片管理器
type ShardManager struct {
    cfg    ShardConfig
    dbs    []*gorm.DB // 各分库的连接
    totalShards int   // 总分片数 = DBCount * TableCount
}

func NewShardManager(cfg ShardConfig) (*ShardManager, error) {
    if len(cfg.DSNs) != cfg.DBCount {
        return nil, fmt.Errorf("DSNs count must equal DBCount")
    }

    dbs := make([]*gorm.DB, cfg.DBCount)
    for i, dsn := range cfg.DSNs {
        db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
            Logger: logger.Default.LogMode(logger.Warn),
        })
        if err != nil {
            return nil, fmt.Errorf("open db[%d]: %w", i, err)
        }
        sqlDB, _ := db.DB()
        sqlDB.SetMaxOpenConns(100)
        sqlDB.SetMaxIdleConns(10)
        dbs[i] = db
    }

    return &ShardManager{
        cfg:         cfg,
        dbs:         dbs,
        totalShards: cfg.DBCount * cfg.TableCount,
    }, nil
}

// Route 根据 userID 返回对应的 DB 连接和表名
func (m *ShardManager) Route(userID uint64) (*gorm.DB, string) {
    shardID   := userID % uint64(m.totalShards)
    dbIndex   := shardID / uint64(m.cfg.TableCount)
    tableIndex := shardID % uint64(m.cfg.TableCount)
    tableName := fmt.Sprintf("orders_%d", tableIndex)
    return m.dbs[dbIndex].Table(tableName), tableName
}

// RouteByOrderID 通过 order_id 路由(order_id 中编码了 shard_id)
func (m *ShardManager) RouteByOrderID(orderID uint64) (*gorm.DB, string) {
    shardID   := ExtractShardID(int64(orderID)) % int64(m.totalShards)
    dbIndex   := shardID / int64(m.cfg.TableCount)
    tableIndex := shardID % int64(m.cfg.TableCount)
    tableName := fmt.Sprintf("orders_%d", tableIndex)
    return m.dbs[dbIndex].Table(tableName), tableName
}

// AllShards 遍历所有分片(用于广播查询)
func (m *ShardManager) AllShards() []ShardRef {
    refs := make([]ShardRef, 0, m.totalShards)
    for dbIdx := 0; dbIdx < m.cfg.DBCount; dbIdx++ {
        for tblIdx := 0; tblIdx < m.cfg.TableCount; tblIdx++ {
            refs = append(refs, ShardRef{
                DB:        m.dbs[dbIdx],
                TableName: fmt.Sprintf("orders_%d", tblIdx),
                ShardID:   dbIdx*m.cfg.TableCount + tblIdx,
            })
        }
    }
    return refs
}

type ShardRef struct {
    DB        *gorm.DB
    TableName string
    ShardID   int
}

10.2 数据模型与自动建表

// Order 订单模型(每个分片表结构相同)
type Order struct {
    ID          uint64     `gorm:"primaryKey"`
    OrderNo     string     `gorm:"uniqueIndex;size:64;not null"`
    UserID      uint64     `gorm:"not null;index"`
    ProductID   uint64     `gorm:"not null"`
    ProductName string     `gorm:"size:255;not null"`  // 冗余字段
    UnitPrice   float64    `gorm:"type:decimal(10,2);not null"`
    Quantity    uint32     `gorm:"not null"`
    Amount      float64    `gorm:"type:decimal(10,2);not null"`
    Status      string     `gorm:"type:enum('created','paid','shipped','completed','cancelled');default:created"`
    CreatedAt   time.Time  `gorm:"index"`
    UpdatedAt   time.Time
}

// AutoMigrate 在所有分片上建表
func (m *ShardManager) AutoMigrate() error {
    for _, ref := range m.AllShards() {
        if err := ref.DB.Table(ref.TableName).AutoMigrate(&Order{}); err != nil {
            return fmt.Errorf("migrate shard %d: %w", ref.ShardID, err)
        }
    }
    return nil
}

10.3 分片 ID 生成器

// IDGenerator 带分片信息的 ID 生成器
type IDGenerator struct {
    mu         sync.Mutex
    nodeID     int64
    epoch      int64
    sequence   int64
    lastMillis int64
    totalShards int64
}

func NewIDGenerator(nodeID int64, totalShards int) *IDGenerator {
    epoch := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
    return &IDGenerator{
        nodeID:      nodeID,
        epoch:       epoch,
        totalShards: int64(totalShards),
    }
}

// NextIDForUser 生成包含 shard 信息的 ID
// ID 结构:41bit时间戳 | 8bit shardID | 15bit 序列号
func (g *IDGenerator) NextIDForUser(userID uint64) (uint64, error) {
    g.mu.Lock()
    defer g.mu.Unlock()

    shardID := int64(userID) % g.totalShards
    now := time.Now().UnixMilli() - g.epoch

    if now < g.lastMillis {
        return 0, fmt.Errorf("clock moved backwards")
    }
    if now == g.lastMillis {
        g.sequence = (g.sequence + 1) & 0x7FFF // 15bit
        if g.sequence == 0 {
            for now <= g.lastMillis {
                now = time.Now().UnixMilli() - g.epoch
            }
        }
    } else {
        g.sequence = 0
    }
    g.lastMillis = now

    // 41bit时间 | 8bit分片 | 15bit序列
    id := (uint64(now) << 23) | (uint64(shardID) << 15) | uint64(g.sequence)
    return id, nil
}

func ExtractShardIDFromOrderID(orderID uint64, totalShards int64) int64 {
    return int64((orderID >> 15) & 0xFF) % totalShards
}

10.4 订单服务完整实现

type OrderService struct {
    shards  *ShardManager
    idGen   *IDGenerator
    indexDB *gorm.DB  // 二级索引库(单独的数据库,存储 shop->order 映射)
    redis   *redis.Client
    log     *zap.Logger
}

// CreateOrderReq 下单请求
type CreateOrderReq struct {
    UserID      uint64
    ProductID   uint64
    ProductName string
    UnitPrice   float64
    Quantity    uint32
}

// CreateOrder 下单(核心流程)
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) (*Order, error) {
    // 1. 生成全局唯一 ID(包含 shard 信息)
    orderID, err := s.idGen.NextIDForUser(req.UserID)
    if err != nil {
        return nil, fmt.Errorf("generate order id: %w", err)
    }

    // 2. 生成全局唯一订单号
    orderNo, err := s.generateOrderNo(ctx)
    if err != nil {
        return nil, fmt.Errorf("generate order no: %w", err)
    }

    order := &Order{
        ID:          orderID,
        OrderNo:     orderNo,
        UserID:      req.UserID,
        ProductID:   req.ProductID,
        ProductName: req.ProductName,
        UnitPrice:   req.UnitPrice,
        Quantity:    req.Quantity,
        Amount:      req.UnitPrice * float64(req.Quantity),
        Status:      "created",
    }

    // 3. 路由到对应分片并写入
    db, tableName := s.shards.Route(req.UserID)
    if err := db.WithContext(ctx).Create(order).Error; err != nil {
        return nil, fmt.Errorf("create order in shard (table=%s): %w", tableName, err)
    }

    s.log.Info("order created",
        zap.Uint64("order_id", order.ID),
        zap.String("order_no", order.OrderNo),
        zap.String("table", tableName),
    )
    return order, nil
}

// GetOrderByID 按订单 ID 查询(从 ID 中解析分片,精准路由)
func (s *OrderService) GetOrderByID(ctx context.Context, orderID uint64) (*Order, error) {
    db, _ := s.shards.RouteByOrderID(orderID)
    var order Order
    err := db.WithContext(ctx).Where("id = ?", orderID).First(&order).Error
    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, nil
        }
        return nil, err
    }
    return &order, nil
}

// ListUserOrders 查询用户订单列表(游标分页,精准路由,不跨分片)
func (s *OrderService) ListUserOrders(
    ctx context.Context, userID uint64, cursor *OrderCursor, size int,
) (orders []Order, nextCursor *OrderCursor, err error) {
    db, _ := s.shards.Route(userID)
    query := db.WithContext(ctx).
        Where("user_id = ?", userID).
        Order("created_at DESC, id DESC").
        Limit(size + 1)

    if cursor != nil {
        query = query.Where(
            "(created_at < ? OR (created_at = ? AND id < ?))",
            cursor.LastCreatedAt, cursor.LastCreatedAt, cursor.LastID,
        )
    }

    if err = query.Find(&orders).Error; err != nil {
        return nil, nil, err
    }

    if len(orders) > size {
        last := orders[size-1]
        nextCursor = &OrderCursor{LastID: last.ID, LastCreatedAt: last.CreatedAt}
        orders = orders[:size]
    }
    return
}

type OrderCursor struct {
    LastID        uint64    `json:"last_id"`
    LastCreatedAt time.Time `json:"last_created_at"`
}

// UpdateOrderStatus 更新订单状态(按 user_id 路由)
func (s *OrderService) UpdateOrderStatus(
    ctx context.Context, orderID uint64, userID uint64,
    oldStatus, newStatus string,
) error {
    db, tableName := s.shards.Route(userID)
    result := db.WithContext(ctx).
        Where("id = ? AND user_id = ? AND status = ?", orderID, userID, oldStatus).
        Update("status", newStatus)
    if result.Error != nil {
        return fmt.Errorf("update order status (table=%s): %w", tableName, result.Error)
    }
    if result.RowsAffected == 0 {
        return fmt.Errorf("order not found or status not match: id=%d, expected=%s", orderID, oldStatus)
    }
    return nil
}

// CountOrdersByStatus 统计指定状态的订单数(广播查询所有分片)
func (s *OrderService) CountOrdersByStatus(ctx context.Context, status string) (int64, error) {
    shards := s.shards.AllShards()
    results := make(chan int64, len(shards))
    errs := make(chan error, len(shards))

    for _, ref := range shards {
        go func(r ShardRef) {
            var count int64
            err := r.DB.WithContext(ctx).Table(r.TableName).
                Where("status = ?", status).
                Count(&count).Error
            if err != nil {
                errs <- err
                return
            }
            results <- count
        }(ref)
    }

    var total int64
    for i := 0; i < len(shards); i++ {
        select {
        case count := <-results:
            total += count
        case err := <-errs:
            return 0, err
        case <-ctx.Done():
            return 0, ctx.Err()
        }
    }
    return total, nil
}

// generateOrderNo 生成全局唯一订单号(Redis SETNX 保证唯一性)
func (s *OrderService) generateOrderNo(ctx context.Context) (string, error) {
    for i := 0; i < 5; i++ {
        no := fmt.Sprintf("ORD%s%08d",
            time.Now().Format("20060102"),
            rand.Intn(99999999),
        )
        ok, err := s.redis.SetNX(ctx, "orderno:lock:"+no, "1", 24*time.Hour).Result()
        if err != nil {
            return "", err
        }
        if ok {
            return no, nil
        }
    }
    return "", errors.New("failed to generate unique order no after 5 retries")
}

10.5 初始化与使用示例

func main() {
    // 初始化分片管理器(2库×4表=8分片)
    shardMgr, err := NewShardManager(ShardConfig{
        DBCount:    2,
        TableCount: 4,
        DSNs: []string{
            "root:pass@tcp(127.0.0.1:3306)/order_db_0?charset=utf8mb4&parseTime=True",
            "root:pass@tcp(127.0.0.1:3307)/order_db_1?charset=utf8mb4&parseTime=True",
        },
    })
    if err != nil {
        log.Fatal("init shard manager", zap.Error(err))
    }

    // 建表(生产环境用 DDL 工具,不用 AutoMigrate)
    if err := shardMgr.AutoMigrate(); err != nil {
        log.Fatal("auto migrate", zap.Error(err))
    }

    idGen := NewIDGenerator(1, 8) // nodeID=1, 8个分片

    svc := &OrderService{
        shards: shardMgr,
        idGen:  idGen,
        // ... redis, indexDB
    }

    ctx := context.Background()

    // 下单
    order, err := svc.CreateOrder(ctx, CreateOrderReq{
        UserID:      1001,
        ProductID:   5001,
        ProductName: "iPhone 15 Pro",
        UnitPrice:   7999.00,
        Quantity:    1,
    })
    if err != nil {
        log.Fatal("create order", zap.Error(err))
    }
    fmt.Printf("订单创建成功: ID=%d, OrderNo=%s\n", order.ID, order.OrderNo)

    // 按 ID 查询
    found, _ := svc.GetOrderByID(ctx, order.ID)
    fmt.Printf("查询订单: %+v\n", found)

    // 查询用户订单列表
    orders, cursor, _ := svc.ListUserOrders(ctx, 1001, nil, 10)
    fmt.Printf("用户订单: %d 条, nextCursor=%v\n", len(orders), cursor)

    // 统计所有分片
    count, _ := svc.CountOrdersByStatus(ctx, "created")
    fmt.Printf("待支付订单数: %d\n", count)
}

小结

分库分表决策树:
  数据量/QPS 是否到瓶颈?
    → 没有:先加索引、读写分离、缓存
    → 是单表数据量大:分区表(按时间 RANGE,运维简单)
    → 是并发/QPS 高:垂直分库(按业务拆)
    → 垂直分库后单库仍然撑不住:水平分库分表(Sharding)

水平分片的核心原则:
  1. 分片键选择最高频查询条件(通常是 user_id)
  2. ID 中编码分片信息(避免映射表查询)
  3. 非分片键查询走二级索引表或搜索引擎
  4. 跨分片聚合走数仓或离线任务
  5. 分片数选 2 的幂次(方便翻倍扩容,迁移量最小)

分库分表后必须解决的问题:
  ┌──────────────────┬────────────────────────────────┐
  │ 问题              │ 解决方案                        │
  ├──────────────────┼────────────────────────────────┤
  │ 全局唯一 ID       │ Snowflake(ID 中编码 shard_id)  │
  │ 跨分片查询        │ 二级索引表 + 并发查询合并         │
  │ 分页             │ 游标分页 / 禁止深分页              │
  │ 聚合统计          │ 预计算汇总表 / 数仓              │
  │ 跨分片 JOIN       │ 数据冗余 / 应用层 JOIN           │
  │ 分布式事务        │ 本地消息表(最终一致)            │
  │ 扩容             │ 翻倍扩容 + 双写迁移              │
  │ DDL 变更          │ gh-ost / pt-osc 在各分片执行    │
  └──────────────────┴────────────────────────────────┘