23 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
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 1 |
范围查询友好 | 热点账号集中在一个分片 |
时间分区 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] 宽表设计的三个原则
- 反范式化:宽表故意违反第一范式——同一个用户的名字可能出现在几百条订单记录中。这正是它的价值所在。
- 最终一致性延迟可控:通过 MQ 保证,通常在 1~3 秒内同步完成。前端加一个 loading 状态即可掩盖这短暂的延迟。
- 写多读少时才考虑:如果宽表的写入放大超过原始数据的 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 1 |
加 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:迁移脚本只做"升级",回滚通过发版解决
关联笔记
- 03-数据一致性/02-分布式事务 — 拆分后的数据一致性问题
- 01-基础概念 — DDD 限界上下文与数据库拆分的对应关系