This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
2026-05-18 00:17:59 +08:00

23 KiB
Raw Permalink Blame History

tags, create time
tags create time
microservice
database
sharding
replication
2026-05-17 14:30

数据库拆分

概述

想象一下:你的电商系统刚上线时只有一张 orders 表,数据量不大,一条 SQL 就搞定。一年后,日订单量涨到 100 万,同样的查询慢到了 5 秒——数据库成了瓶颈,你不得不开始考虑:怎么拆?

微服务的核心设计原则是 "每个服务拥有独立数据库"。这意味着每个服务的表结构、数据存储、甚至数据库类型都可以不同。但当单表数据量持续增长时,你就面临另一个维度的拆分需求。

graph LR
    subgraph "理想状态:微服务 = 独立数据库"
        S1["📦 订单服务<br/>MySQL"]
        S2["👤 用户服务<br/>PostgreSQL"]
        S3["📦 库存服务<br/>Redis + MySQL"]
    end

    style S1 fill:#e8f5e9,stroke:#4caf50
    style S2 fill:#e8f5e9,stroke:#4caf50
    style S3 fill:#e8f5e9,stroke:#4caf50

[!failure] 反面教材:共享数据库

如果两个微服务连接同一个数据库的同一张表,它们就不再是独立的微服务——你得到的是 分布式单体。服务可以随意互相查对方的数据,失去了边界和自治性。这在早期开发中很常见(方便联调),但一定要在正式微服务化之前拆掉。

graph LR
    ORDER["📦 订单服务"] --- SHARED[(❌ 共享 DB)]
    USER["👤 用户服务"] --- SHARED

    style SHARED fill:#ffebee,stroke:#ef5350
    style ORDER fill:#fff3e0,stroke:#ff9800
    style USER fill:#fff3e0,stroke:#ff9800

两种拆分思路:先"纵向切",再"横向切"

拆分不是选择题,而是两步走:第一步按业务域垂直拆分(微服务化的标配),第二步当单个表太大时再水平拆分(分库分表)。

第一步:垂直拆分 — 按业务域独立建库

[!question] 思考一下

如果一个"用户下单"操作需要同时访问订单表和商品信息表——这说明这两个表应该属于同一个数据库吗?

答案是否定的。 商品不会因为你改了订单就跟着变。真正的判断标准是:哪些表经常一起被修改? 如果一组表的变更总是由同一个业务逻辑触发,它们就属于同一个限界上下文,应该放在同一个库里。

核心做法:以 微服务边界 为基准,将相关表打包到一个数据库中。

flowchart TB
    subgraph DBOrder["DB-Order:订单域"]
        direction LR
        T1[orders]
        T2[order_items]
        T3[order_status_log]
    end

    subgraph DBUser["DB-User:用户域"]
        direction LR
        U1[users]
        U2[user_profiles]
        U3[user_addresses]
    end

    subgraph DBProduct["DB-Product:商品域"]
        direction LR
        P1[products]
        P2[categories]
        P3[product_images]
    end

    style DBOrder fill:#e3f2fd,stroke:#1976d2,rx:8
    style DBUser fill:#e3f2fd,stroke:#1976d2,rx:8
    style DBProduct fill:#e3f2fd,stroke:#1976d2,rx:8
优势 说明
物理隔离,互不干扰 订单库崩了不影响用户登录
异构选型 订单用 MySQL(事务强),搜索用 Elasticsearch,缓存用 Redis
天然解耦 服务之间不能直连对方数据库,只能通过 API 通信

[!tip] 拆分的粒度

不要把一张表里的字段都拆到不同库里——那叫"过度拆分"。以表为单位进行垂直拆分是最常见的做法。一个微服务对应一个库,一个库包含多个相关表。

垂直拆分的判断标准(实操 checklist)

当你在犹豫某张表应该留在这个库还是挪到另一个库时,用下面几个维度来判断:

flowchart TD
    START["开始判断"] --> A["这张表和当前库里的表<br/>是否经常被同一个事务修改?"]
    A -->|是| SAME_DB["留在同一库 ✅"]
    A -->|否| B["它们是否属于同一个<br/>业务限界上下文?"]
    B -->|是| SAME_CTX["考虑放在同一库<br/>(降低跨库调用成本)"]
    B -->|否| DIFF_CTX["必须独立建库 ✅"]
    SAME_CTX --> C["变更频率差异大吗?"]
    C -->|高频 vs 低频| SPLIT["建议拆分<br/>(避免互相影响)✅"]
    C -->|同频| SAME_DB
    DIFF_CTX --> END["完成"]
    SAME_DB --> END
    SPLIT --> END
判断维度 放同一库的信号 拆开的信号
事务耦合度 经常在一个 BEGIN...COMMIT 里一起改 各自有独立的写入路径
读取热点 总是被一起查询、一起展示 访问模式完全不同
团队归属 同一小组维护 不同团队负责(Conway 定律)
数据增长率 增长速度接近 一个涨得快、一个基本不变

[!note] 经验法则

如果一组表之间的跨库 JOIN 操作占了日常 SQL 的 80% 以上,把它们放在一起通常更合理。反过来,如果大部分关联查询都只需要一次 LEFT JOIN,说明拆分时机已经成熟——因为 JOIN 已经在两个数据库之间产生网络开销了。

第二步:水平拆分 — 一张表太大了怎么办?

垂直拆分解决的是"谁来负责什么"的问题。但当 单个服务内的单表 达到千万级甚至亿级记录时,就需要水平拆分(也叫分片/Sharding)。

[!note] 为什么要分?

  • 单表超过 2000 万行后,索引效率急剧下降
  • 单机 MySQL 的写入 QPS 通常在 5000~20000,超出后成为瓶颈
  • InnoDB 缓冲池装不下全部索引数据,大量磁盘 IO

水平拆分的核心思想:把一张大表按规则切成多张小表,分散到不同的数据库实例中。

graph TB
    subgraph "拆分前:一张表扛所有"
        BIG_TABLE[(order 表 1 亿行)]
    end

    subgraph "拆分后:按 user_id 散列"
        subgraph "DB-01"
            T1[(order_0001 2500 万行)]
        end
        subgraph "DB-02"
            T2[(order_0002 2500 万行)]
        end
        subgraph "DB-03"
            T3[(order_0003 2500 万行)]
        end
        subgraph "DB-04"
            T4[(order_0004 2500 万行)]
        end
    end

    ROUTE["user_id % 4"] -->|"uid=1001"| T1
    ROUTE -->|"uid=2002"| T2
    ROUTE -->|"uid=3003"| T3
    ROUTE -->|"uid=4004"| T4

    style BIG_TABLE fill:#ffebee,stroke:#ef5350
    style T1 fill:#e8f5e9,stroke:#4caf50
    style T2 fill:#e8f5e9,stroke:#4caf50
    style T3 fill:#e8f5e9,stroke:#4caf50
    style T4 fill:#e8f5e9,stroke:#4caf50

常见分片策略对比

策略 怎么分 适合场景 代价
哈希取模 hash % N user_id % 4,余数决定去哪个库 均匀分布,写入均衡 扩库时需要迁移大部分数据
范围划分 BETWEEN x AND y userId 110000 → DB-A,1000120000 → DB-B 范围查询友好 热点账号集中在一个分片
时间分区 year_month 2024_01 → DB-Jan, 2024_02 → DB-Feb 按生命周期管理(冷数据归档) 最新月份写入压力大
地理位置 华东用户 → 杭州 DB,华南 → 广州 DB 降低跨地域延迟 跨区域操作复杂

[!important] 扩容陷阱

Hash Mod 最容易踩坑:当你从 4 个分片扩展到 8 个分片时,% 4 变成 % 8,几乎所有数据的新归属都变了,需要大规模数据迁移。这是一个需要提前规划的重大决策。

如果扩容是高频需求,建议从一开始就用一致性哈希或预留足够多的槽位。

分片扩容方案:不停机迁移

生产环境的扩容不能停服停机——你需要一个 双写 + 历史数据回迁 的渐进式流程:

flowchart TD
    A["当前状态: N 个分片<br/>hash % N"] --> B["第一步: 新增 M 个分片<br/>总容量变为 N+M"]
    B --> C["第二步: 双写阶段<br/>新写入同时写到旧分片和新分片"]
    C --> D["第三步: 历史数据回迁<br/>按分片逐个搬移存量数据"]
    D --> E{"全部搬完?"}
    E -->|否| D
    E -->|是| F["第四步: 校验数据一致性<br/>checksum 对比"]
    F --> G["第五步: 切读流量<br/>新请求读新分片"]
    G --> H["第六步: 关闭双写<br/>恢复到单写单读"]

    style B fill:#fff3e0,stroke:#ff9800
    style C fill:#fff3e0,stroke:#ff9800
    style D fill:#e3f2fd,stroke:#1976d2
    style F fill:#e8f5e9,stroke:#4caf50
    style G fill:#e8f5e9,stroke:#4caf50
    style H fill:#f3e5f5,stroke:#7b1fa2
阶段 核心操作 关键风险 规避方法
双写 新数据同时写入新旧两套分片 数据不一致、写入性能下降 用消息队列保证异步双写;设置标记位可快速回滚
回迁 按 user_id 范围分批迁移历史数据 迁移期间持续写入导致数据漂移 迁移后对已迁移范围做一次增量同步
切读 将读取路由切换到新分片 漏读、脏数据 先灰度 1% 流量验证,逐步放量
关双写 停止向旧分片写入 遗漏最后一段增量数据 双写关之前做一次全量 checksum 校验

[!warning] 平滑扩容的时间成本

假设你有 1 亿条订单数据,网络带宽 1Gbps,压缩后约 50GB。理论传输时间不到 1 分钟——但实际中还要考虑锁竞争、慢查询、监控告警等因素。建议给每个分片预留 2~4 小时 的迁移窗口期,夜间低峰期执行。

业界方案:ShardingSphere

[!tip] 为什么选 ShardingSphere?

Apache ShardingSphere 是国内使用最广泛的分库分表中间件。它提供三种部署模式:

  • JDBC:嵌入应用,零运维(最常用)
  • Proxy:独立代理服务,语言无关
  • Sidecar:Kubernetes 侧车模式

配置示例

以下是 ShardingSphere-JDBC 的核心配置片段(YAML 格式):

sharding-jdbc:
  data-sources:              # 定义数据源
    ds0: { type: com.zaxxer.hikari.HikariDataSource, ... }
    ds1: { type: com.zaxxer.hikari.HikariDataSource, ... }

  sharding:
    tables:
      orders:                # 逻辑表名
        actual-data-nodes: ds$->{0..1}.orders$->{0..1}
        # 上面的表达式展开后是:ds0.orders0, ds0.orders1, ds1.orders0, ds1.orders1
        table-strategy:
          standard:
            sharding-column: user_id       # 分片键
            sharding-algorithm-name: user-id-mod
        key-generate-strategy:
          column: order_id                 # 主键生成
          key-generator-name: snowflake   # Snowflake 雪花算法

    sharding-algorithms:
      user-id-mod:
        type: MOD
        props:
          sharding-count: 2               # 分成 2 个分片

关键点:

  • 逻辑表名 vs 实际数据节点:应用层看到的永远是 orders,底层路由到 ds0.orders0 等是由中间件透明的完成的
  • 分片键选择:一定要选写入频率高且用于查询条件的字段(如 user_id),否则每次查询都要扫全部分片

分布式 ID 生成:为什么不能用自增主键?

分库后每个库的 AUTO_INCREMENT 是独立的——两个库可能都生成了 id = 100。你必须用一种方式保证 全局唯一。

Snowflake 雪花算法

Twitter 开源的雪花算法是目前最主流的分布式 ID 方案:

graph LR
    subgraph "64-bit 长整型 ID"
        S["符号位<br/>1 bit"]
        T["时间戳<br/>41 bits"]
        D["机器 ID<br/>10 bits"]
        SQ["序列号<br/>12 bits"]
    end

    style S fill:#f5f5f5,stroke:#9e9e9e
    style T fill:#e3f2fd,stroke:#1976d2
    style D fill:#e8f5e9,stroke:#4caf50
    style SQ fill:#fff3e0,stroke:#ff9800
字段 长度 作用 范围
符号位 1 bit 恒为 0(保证 ID 为正数) -
时间戳 41 bits 毫秒级时间戳 可支撑约 69 年
机器 ID 10 bits 区分部署实例 1024 个节点
序列号 12 bits 同一毫秒内的递增序号 每毫秒 4096 个 ID

[!note] 核心特性

  • 单调递增:基于时间戳保证整体趋势递增,适合 InnoDB 聚簇索引的 append-only 写入模式
  • 高吞吐:单实例每秒可生成 4096 × 1000 = 数百万个 ID
  • 无中心节点:不依赖 ZooKeeper 或数据库,挂掉一个机器不影响其他节点

如果同一毫秒内生成的 ID 超过 4096 个,算法会 等待下一毫秒 再重试——这在实际场景中极少发生(通常 QPS < 10 万)。

备选方案对比

方案 唯一性保证 性能 复杂度 适用场景
Snowflake 时间戳+机器ID+序列号组合 极高(本地生成) 中 通用首选
数据库号段模式 每次从 DB 批量拉取一段 ID 高 中高 有现成 DB 基础设施
UUID 随机 128 位 高 极低 对有序性无要求的场景
Redis INCR Redis 原子递增 高 低 已有 Redis 集群

[!warning] UUID 的坑

UUID 虽然简单,但在 MySQL InnoDB 中是 灾难性的:因为 UUID 无序,插入位置随机分布,导致大量的页分裂和碎片化。如果必须用 UUID,建议存为 BINARY(16) 并用 UUID_TO_BIN(uuid, 1) 转换,让它在索引中保持局部有序。

跨库查询:拆分之后最难解决的问题之一

[!question] 经典难题

用户打开"我的订单"页面。订单服务拿到 user_id 查出订单列表,但现在要展示用户的头像和昵称——这些信息在用户库里。数据库已经拆开了,怎么做关联查询?

这是拆分后必然遇到的挑战。没有银弹,只有 权衡后的取舍。

四种方案对比

mindmap
  root((跨库查询方案))
    冗余字段
      最简单最直接
      少量字段
      快照语义
      一致性问题
    接口组装
      按需查询
      链路长时性能差
      N+1 查询陷阱
      适合低频关联
    CQRS / 宽表
      异步最终一致
      写入有额外开销
      适合高频关联
    搜索引擎
      ES 做多维聚合
      架构重
      适合复杂搜索
方案 一句话描述 性能 复杂度
冗余字段 在订单表里直接存用户名字段 ⭐⭐⭐⭐⭐ 低
接口组装 先查订单,循环调用户服务补全信息 ⭐⭐⭐ 中
CQRS / 宽表 异步同步一份含用户信息的宽表 ⭐⭐⭐⭐ 高
搜索引擎 把数据推到 ES,用 ES 做关联查询 ⭐⭐⭐⭐ 中高

实战一:冗余字段(最推荐的首选方案)

-- 订单表中冗余关键字段(快照模式)
CREATE TABLE orders (
    id         BIGINT PRIMARY KEY,
    user_id    BIGINT NOT NULL,
    username   VARCHAR(64),     -- 下单时的用户名快照
    phone      VARCHAR(20),     -- 下单时的手机号(已脱敏)
    created_at TIMESTAMP DEFAULT NOW(),
    INDEX idx_user (user_id)
);

[!note] 关键理解:快照语义

用户后来改了名字、换了手机号,不改订单表。订单上的 username 反映的是下单那一刻的状态,而不是"当前"状态。这不仅是合理的,而且是正确的——用户查看历史订单时,看到的是当时的信息。

如果需要主动更新冗余字段(如用户更换了头像),通过消息队列通知订单服务批量更新。

Go 代码实现示例:

// 下单时将用户信息快照写入订单表
func (s *orderSvc) CreateOrder(ctx context.Context, req *CreateOrderReq) (*Order, error) {
    // 1. 查用户基本信息
    user, err := s.userClient.GetByID(ctx, req.UserID)
    if err != nil {
        return nil, err
    }

    // 2. 构造订单,携带快照字段
    order := &Order{
        UserID:   req.UserID,
        Username: user.Username, // ✅ 快照:锁定下单时的值
        Phone:    maskPhone(user.Phone),
        Items:    req.Items,
    }
    return s.orderRepo.Save(ctx, order)
}

实战二:接口组装(轻量场景够用)

适用于关联查询不频繁的场景,比如后台管理系统的偶尔查看详情。

// 查订单 + 补齐用户信息
func (s *orderSvc) GetOrderWithUser(ctx context.Context, orderID int64) (*OrderDetail, error) {
    // 1. 先查订单
    order, err := s.orderRepoFindByID(ctx, orderID)
    if err != nil {
        return nil, err
    }

    // 2. 再调用户服务补齐
    user, err := s.userClient.GetByID(ctx, order.UserID)
    if err != nil {
        return nil, err
    }

    return &OrderDetail{
        Order:    order,
        UserName: user.Username,
        UserHead: user.AvatarURL,
    }, nil
}

[!warning] N+1 陷阱

如果是列表查询(一次性返回 20 条订单),逐条调用户服务会导致 20 次 RPC 调用。正确做法是:先收集所有 user_id,批量查询用户信息,再拼回去。

// ❌ 错误:N 次 RPC
for _, o := range orders {
    u, _ := userClient.GetByID(ctx, o.UserID)
}

// ✅ 正确:1 次批量 RPC
ids := collectIDs(orders)                          // [1, 3, 7, 12, ...]
users, _ := userClient.GetByIds(ctx, ids)          // 一次拿回所有
byID := indexBy(users, func(u *User) int64 { return u.ID })
for _, o := range orders {
    o.UserInfo = byID[o.UserID]
}

实战三:CQRS / 宽表(重度关联查询必备)

当跨库关联是高频操作(如运营后台的多维度筛选),冗余字段就不够了——你需要一套完整的 事件驱动宽表同步机制:

sequenceDiagram
    participant U as 用户服务
    participant MQ as 消息队列
    participant O as 订单服务
    participant W as 宽表 (Read DB)

    U->>MQ: UserUpdated 事件 (userId, newAvatar)
    MQ->>O: 消费事件
    O->>W: 更新宽表中对应用户的头像

    Note over W: 宽表包含了订单 + 用户 + 商品的冗余字段<br/>只读,专为查询优化

Go 代码实现——事件消费者(订单服务侧):

// Order宽表同步消费者:监听来自各服务的业务事件,维护一张可跨维度查询的宽表
type WideTableSyncer struct {
    wideDB *sql.DB  // 独立的读库连接
}

// OnUserUpdated 消费用户变更事件,更新宽表中对应用户的信息
func (s *WideTableSyncer) OnUserUpdated(ctx context.Context, evt UserUpdatedEvent) error {
    tx, err := s.wideDB.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // 批量更新宽表中的用户快照字段
    _, err = tx.ExecContext(ctx,
        `UPDATE order_wide_table 
         SET username = ?, phone_masked = ?, avatar_url = ?
         WHERE user_id = ?`,
        evt.Username, evt.PhoneMasked, evt.AvatarURL, evt.UserID,
    )
    return tx.Commit()
}

// OnOrderCreated 订单创建时写入宽表(包含完整的关联信息)
func (s *WideTableSyncer) OnOrderCreated(ctx context.Context, evt OrderCreatedEvent) error {
    _, err := s.wideDB.ExecContext(ctx,
        `INSERT INTO order_wide_table
         (order_id, user_id, username, product_name, amount, status, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?)`,
        evt.OrderID, evt.UserID, evt.Username,
        evt.ProductName, evt.Amount, evt.Status, evt.CreatedAt,
    )
    return err
}

[!tip] 宽表设计的三个原则

  1. 反范式化:宽表故意违反第一范式——同一个用户的名字可能出现在几百条订单记录中。这正是它的价值所在。
  2. 最终一致性延迟可控:通过 MQ 保证,通常在 1~3 秒内同步完成。前端加一个 loading 状态即可掩盖这短暂的延迟。
  3. 写多读少时才考虑:如果宽表的写入放大超过原始数据的 3 倍,说明你的场景用冗余字段就够了,不需要上宽表。

何时该拆?——不要过早拆分

拆分是有 代价 的:复杂度上升、运维成本增加、跨服务调用变慢。在决定拆之前,先看这些硬指标是否已经触达:

flowchart TD
    START["你的数据量到了什么级别?"]
    
    START --> SMALL["< 100 万行<br/>单库单机足够"]
    START --> MEDIUM["100 万 ~ 2000 万行<br/>考虑读写分离"]
    START --> LARGE["> 2000 万行<br/>考虑分片"]
    
    SMALL --> S1["✅ 先优化索引"]
    S1 --> S2["✅ 加缓存层 Redis"]
    S2 --> S3["✅ 读写分离<br/>一主多从"]
    
    MEDIUM --> M1["✅ 先做垂直拆分"]
    M1 --> M2["按业务域独立建库"]
    
    LARGE --> L1["✅ 再做水平拆分"]
    L1 --> L2["分库分表 + 宽表同步"]
    
    style SMALL fill:#e8f5e9,stroke:#4caf50
    style MEDIUM fill:#fff3e0,stroke:#ff9800
    style LARGE fill:#ffebee,stroke:#ef5350
    style S3 fill:#e8f5e9,stroke:#4caf50
    style M2 fill:#e8f5e9,stroke:#4caf50
    style L2 fill:#ffebee,stroke:#ef5350

[!quote] 一条经验法则

"在数据量还没到 2000 万行之前,不要做任何形式的数据水平拆分。"

绝大多数系统通过索引优化 + 缓存 + 读写分离就能撑到日活百万级。见过太多团队在项目刚上线就搞分库分表——六个月后回头看,完全是提前踩雷。

拆与不拆的判断矩阵

场景 推荐方案 预期寿命
DAU < 1 万,QPS < 500 单库单机,专心做好索引 半年~1年
DAU 150 万,QPS 5005000 加 Redis 缓存 + 读写分离 1~2 年
DAU 50~200 万,单表 > 2000 万行 垂直拆分(按微服务) 2~3 年
DAU > 200 万,单表 > 5000 万行 水平拆分 + 宽表同步 长期

数据库迁移工具:告别手动执行 SQL

随着服务拆分增多,SQL 脚本的管理变得复杂。手工执行、口头传达"我跑过 V3 了"的方式不再可行——你需要版本化的、可重复执行的数据库迁移工具。

flowchart LR
    Dev["💻 开发环境<br/>git commit SQL 文件"] -->|"CI/CD 自动执行"| Stage["🧪 Staging<br/>flyway migrate"]
    Stage -->|"人工审批"| Prod["🚀 Production<br/>flyway migrate"]
    Prod -->|"校验"| Check["🔒 flyway validate<br/>确认无漂移"]

    style Dev fill:#e3f2fd,stroke:#1976d2
    style Stage fill:#fff3e0,stroke:#ff9800
    style Prod fill:#e8f5e9,stroke:#4caf50
    style Check fill:#f3e5f5,stroke:#7b1fa2

主流工具对比

工具 生态 特点
Flyway Java / Go (via CLI) 基于文件名命名,纯 SQL 脚本,简单直接
Liquibase Java 支持 XML/YAML/JSON,自带回滚生成能力
golang-migrate Go 轻量 CLI,Go 项目首选

Flyway 工作流

db/migration/
├── V1__create_users_table.sql
├── V2__add_user_phone.sql
├── V3__create_orders_table.sql
└── V4__add_order_status_enum.sql

执行顺序严格遵循版本号:V1 → V2 → V3 → V4。Flyway 会在数据库中维护一张 _schema_version 表,记录每个迁移的版本和执行状态。

核心要点:

  • 文件名即版本:V 开头 + 序号 + __ + 描述
  • 不可修改已执行的文件:改了的话 Flyway 会报验证失败(validate 阶段)
  • 永远不要写"反向"SQL:迁移脚本只做"升级",回滚通过发版解决

关联笔记