Files
cs-note/hhs/MS/03-数据一致性/01-数据库拆分.md
T

592 lines
23 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
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 限界上下文与数据库拆分的对应关系