--- tags: [microservice, database, sharding, replication] create time: 2026-05-17 14:30 --- # 数据库拆分 ## 概述 想象一下:你的电商系统刚上线时只有一张 `orders` 表,数据量不大,一条 SQL 就搞定。一年后,日订单量涨到 100 万,同样的查询慢到了 5 秒——数据库成了瓶颈,你不得不开始考虑:**怎么拆?** 微服务的核心设计原则是 **"每个服务拥有独立数据库"**。这意味着每个服务的表结构、数据存储、甚至数据库类型都可以不同。但当单表数据量持续增长时,你就面临另一个维度的拆分需求。 ```mermaid graph LR subgraph "理想状态:微服务 = 独立数据库" S1["📦 订单服务
MySQL"] S2["👤 用户服务
PostgreSQL"] S3["📦 库存服务
Redis + MySQL"] end style S1 fill:#e8f5e9,stroke:#4caf50 style S2 fill:#e8f5e9,stroke:#4caf50 style S3 fill:#e8f5e9,stroke:#4caf50 ``` > [!failure] 反面教材:共享数据库 > > 如果两个微服务连接同一个数据库的同一张表,它们就不再是独立的微服务——你得到的是 **分布式单体**。服务可以随意互相查对方的数据,失去了边界和自治性。这在早期开发中很常见(方便联调),但一定要在正式微服务化之前拆掉。 ```mermaid 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] 思考一下 > > 如果一个"用户下单"操作需要同时访问订单表和商品信息表——这说明这两个表应该属于同一个数据库吗? > > **答案是否定的。** 商品不会因为你改了订单就跟着变。真正的判断标准是:**哪些表经常一起被修改?** 如果一组表的变更总是由同一个业务逻辑触发,它们就属于同一个限界上下文,应该放在同一个库里。 核心做法:以 **微服务边界** 为基准,将相关表打包到一个数据库中。 ```mermaid 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) 当你在犹豫某张表应该留在这个库还是挪到另一个库时,用下面几个维度来判断: ```mermaid flowchart TD START["开始判断"] --> A["这张表和当前库里的表
是否经常被同一个事务修改?"] A -->|是| SAME_DB["留在同一库 ✅"] A -->|否| B["它们是否属于同一个
业务限界上下文?"] B -->|是| SAME_CTX["考虑放在同一库
(降低跨库调用成本)"] B -->|否| DIFF_CTX["必须独立建库 ✅"] SAME_CTX --> C["变更频率差异大吗?"] C -->|高频 vs 低频| SPLIT["建议拆分
(避免互相影响)✅"] 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 水平拆分的核心思想:**把一张大表按规则切成多张小表,分散到不同的数据库实例中。** ```mermaid 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 1~10000 → DB-A,10001~20000 → DB-B | 范围查询友好 | 热点账号集中在一个分片 | | **时间分区** `year_month` | 2024_01 → DB-Jan, 2024_02 → DB-Feb | 按生命周期管理(冷数据归档) | 最新月份写入压力大 | | **地理位置** | 华东用户 → 杭州 DB,华南 → 广州 DB | 降低跨地域延迟 | 跨区域操作复杂 | > [!important] 扩容陷阱 > > Hash Mod 最容易踩坑:当你从 4 个分片扩展到 8 个分片时,`% 4` 变成 `% 8`,**几乎所有数据的新归属都变了**,需要大规模数据迁移。这是一个需要提前规划的重大决策。 > > 如果扩容是高频需求,建议从一开始就用一致性哈希或预留足够多的槽位。 ### 分片扩容方案:不停机迁移 生产环境的扩容不能停服停机——你需要一个 **双写 + 历史数据回迁** 的渐进式流程: ```mermaid flowchart TD A["当前状态: N 个分片
hash % N"] --> B["第一步: 新增 M 个分片
总容量变为 N+M"] B --> C["第二步: 双写阶段
新写入同时写到旧分片和新分片"] C --> D["第三步: 历史数据回迁
按分片逐个搬移存量数据"] D --> E{"全部搬完?"} E -->|否| D E -->|是| F["第四步: 校验数据一致性
checksum 对比"] F --> G["第五步: 切读流量
新请求读新分片"] G --> H["第六步: 关闭双写
恢复到单写单读"] 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 格式): ```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 方案: ```mermaid graph LR subgraph "64-bit 长整型 ID" S["符号位
1 bit"] T["时间戳
41 bits"] D["机器 ID
10 bits"] SQ["序列号
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` 查出订单列表,但现在要展示用户的头像和昵称——这些信息在用户库里。数据库已经拆开了,怎么做关联查询? 这是拆分后必然遇到的挑战。没有银弹,只有 **权衡后的取舍**。 ### 四种方案对比 ```mermaid mindmap root((跨库查询方案)) 冗余字段 最简单最直接 少量字段 快照语义 一致性问题 接口组装 按需查询 链路长时性能差 N+1 查询陷阱 适合低频关联 CQRS / 宽表 异步最终一致 写入有额外开销 适合高频关联 搜索引擎 ES 做多维聚合 架构重 适合复杂搜索 ``` | 方案 | 一句话描述 | 性能 | 复杂度 | |------|-----------|------|--------| | **冗余字段** | 在订单表里直接存用户名字段 | ⭐⭐⭐⭐⭐ | 低 | | **接口组装** | 先查订单,循环调用户服务补全信息 | ⭐⭐⭐ | 中 | | **CQRS / 宽表** | 异步同步一份含用户信息的宽表 | ⭐⭐⭐⭐ | 高 | | **搜索引擎** | 把数据推到 ES,用 ES 做关联查询 | ⭐⭐⭐⭐ | 中高 | ### 实战一:冗余字段(最推荐的首选方案) ```sql -- 订单表中冗余关键字段(快照模式) 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 代码实现示例: ```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) } ``` ### 实战二:接口组装(轻量场景够用) 适用于关联查询不频繁的场景,比如后台管理系统的偶尔查看详情。 ```go // 查订单 + 补齐用户信息 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`,**批量查询**用户信息,再拼回去。 ```go // ❌ 错误: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 / 宽表(重度关联查询必备) 当跨库关联是高频操作(如运营后台的多维度筛选),冗余字段就不够了——你需要一套完整的 **事件驱动宽表同步机制**: ```mermaid 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: 宽表包含了订单 + 用户 + 商品的冗余字段
只读,专为查询优化 ``` Go 代码实现——事件消费者(订单服务侧): ```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 倍,说明你的场景用冗余字段就够了,不需要上宽表。 ## 何时该拆?——不要过早拆分 拆分是有 **代价** 的:复杂度上升、运维成本增加、跨服务调用变慢。在决定拆之前,先看这些硬指标是否已经触达: ```mermaid flowchart TD START["你的数据量到了什么级别?"] START --> SMALL["< 100 万行
单库单机足够"] START --> MEDIUM["100 万 ~ 2000 万行
考虑读写分离"] START --> LARGE["> 2000 万行
考虑分片"] SMALL --> S1["✅ 先优化索引"] S1 --> S2["✅ 加缓存层 Redis"] S2 --> S3["✅ 读写分离
一主多从"] 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 1~50 万,QPS 500~5000 | 加 Redis 缓存 + 读写分离 | 1~2 年 | | DAU 50~200 万,单表 > 2000 万行 | 垂直拆分(按微服务) | 2~3 年 | | DAU > 200 万,单表 > 5000 万行 | 水平拆分 + 宽表同步 | 长期 | ## 数据库迁移工具:告别手动执行 SQL 随着服务拆分增多,SQL 脚本的管理变得复杂。手工执行、口头传达"我跑过 V3 了"的方式不再可行——你需要**版本化的、可重复执行的数据库迁移工具**。 ```mermaid flowchart LR Dev["💻 开发环境
git commit SQL 文件"] -->|"CI/CD 自动执行"| Stage["🧪 Staging
flyway migrate"] Stage -->|"人工审批"| Prod["🚀 Production
flyway migrate"] Prod -->|"校验"| Check["🔒 flyway validate
确认无漂移"] 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**:迁移脚本只做"升级",回滚通过发版解决 ## 关联笔记 - [[03-数据一致性/02-分布式事务]] — 拆分后的数据一致性问题 - [[01-基础概念]] — DDD 限界上下文与数据库拆分的对应关系