Files
cs-note/hhs/MS/03-数据一致性/01-数据库拆分.md
T
2026-05-24 11:42:38 +08:00

592 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [microservice, database, sharding, replication]
create time: 2026-05-17 14:30
---
# 数据库拆分
## 概述
想象一下:你的电商系统刚上线时只有一张 `orders` 表,数据量不大,一条 SQL 就搞定。一年后,日订单量涨到 100 万,同样的查询慢到了 5 秒——数据库成了瓶颈,你不得不开始考虑:**怎么拆?**
微服务的核心设计原则是 **"每个服务拥有独立数据库"**。这意味着每个服务的表结构、数据存储、甚至数据库类型都可以不同。但当单表数据量持续增长时,你就面临另一个维度的拆分需求。
```mermaid
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] 反面教材:共享数据库
>
> 如果两个微服务连接同一个数据库的同一张表,它们就不再是独立的微服务——你得到的是 **分布式单体**。服务可以随意互相查对方的数据,失去了边界和自治性。这在早期开发中很常见(方便联调),但一定要在正式微服务化之前拆掉。
```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["这张表和当前库里的表<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
水平拆分的核心思想:**把一张大表按规则切成多张小表,分散到不同的数据库实例中。**
```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 个分片<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 格式):
```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["符号位<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` 查出订单列表,但现在要展示用户的头像和昵称——这些信息在用户库里。数据库已经拆开了,怎么做关联查询?
这是拆分后必然遇到的挑战。没有银弹,只有 **权衡后的取舍**。
### 四种方案对比
```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: 宽表包含了订单 + 用户 + 商品的冗余字段<br/>只读,专为查询优化
```
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 万行<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 1~50 万,QPS 500~5000 | 加 Redis 缓存 + 读写分离 | 1~2 年 |
| DAU 50~200 万,单表 > 2000 万行 | 垂直拆分(按微服务) | 2~3 年 |
| DAU > 200 万,单表 > 5000 万行 | 水平拆分 + 宽表同步 | 长期 |
## 数据库迁移工具:告别手动执行 SQL
随着服务拆分增多,SQL 脚本的管理变得复杂。手工执行、口头传达"我跑过 V3 了"的方式不再可行——你需要**版本化的、可重复执行的数据库迁移工具**。
```mermaid
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**:迁移脚本只做"升级",回滚通过发版解决
## 关联笔记
- [[03-数据一致性/02-分布式事务]] — 拆分后的数据一致性问题
- [[01-基础概念]] — DDD 限界上下文与数据库拆分的对应关系