主题
数据库扩展性:先找瓶颈,再增加分布式边界
数据库慢了,增加副本未必解决写入瓶颈;数据量大了,拆成许多表也未必减少磁盘和锁争用。扩展方案要回答一个具体问题:哪部分负载可以搬走,搬走之后,原来一次事务、一次查询或一次发布完成的事情,会被拆成几步?
本文围绕读写分离、分库分表、全局 ID、迁移和在线 DDL、冷热分离建立选择顺序。数据库机制按 PostgreSQL 18、MySQL 8.4 文档说明;Vitess 22.0 的固定版本工作流用于解释分片迁移,不作为当前版本推荐。实际实验仅包括 Go 1.27.2 的路由计算,以及 PostgreSQL 18.6 单用户模式中的顺序回放模拟,没有运行复制集群或生产迁移。
先确认哪一种扩展解决眼前问题
| 现象或需求 | 可先考虑的办法 | 能减轻什么 | 不会自动解决什么 |
|---|---|---|---|
| 查询读太多无关行 | 查询改写、合适索引、减少返回列 | 单次请求工作量 | 无限增长的数据与写入 |
| 读多写少,允许部分旧读 | 增加只读副本、读写分离 | 主要节点的部分读负载 | 写容量、读己之写 |
| 工作集略超当前机器 | 更合理的内存/磁盘与垂直扩容 | 单机资源瓶颈,运维相对简单 | 单机上限、热点锁 |
| 大部分访问围绕独立租户 | 按租户等键分片 | 分散存储与可独立执行的读写 | 大租户热点、跨租户查询 |
| 历史数据大、访问少 | 时间分区、归档、冷热分离 | 在线工作集、维护和存储压力 | 历史查询的延迟与取回流程 |
| 发布 schema 变更容易阻塞 | 兼容式发布、受支持的在线 DDL | 缩短部分阻塞窗口 | 元数据锁、I/O 与复制积压 |
这些是设计上的选择条件,不是一套“数据到多少行就必须分库”的硬阈值。业务查询、行宽、索引、磁盘、维护和可靠性目标不同,上限也不同。
读写分离最先改变的是读到什么
异步复制允许副本落后。写主库后立刻去任意副本读,可能看不到刚写的数据。接收日志与应用日志是不同阶段;MySQL 半同步确认收到日志,也不能自动保证这个副本已经应用。PostgreSQL 同步复制的保证同样取决于等待级别,不能只听到“同步”两个字就推断任意副本可立即读。[1][2]
正在绘制图示…
查看图源代码
sequenceDiagram
participant U as 用户请求
participant P as 写节点
participant R as 读副本
U->>P: 创建订单
P-->>U: 提交成功
U->>R: 紧接着查询订单
R-->>U: 可能尚不存在
P->>R: 复制并应用到该事务
U->>R: 再查询
R-->>U: 可见新订单
这是文档语义的示意,没有把复制延迟编成固定毫秒数。读路由应该由一致性要求决定:
| 读场景 | 常见策略 | 代价和边界 |
|---|---|---|
| 刚支付后的订单结果 | 读主库,或等待选定副本达到明确提交位置 | 增加主库负载或等待;故障时需清晰回退 |
| 普通列表、浏览量 | 允许读副本,规定最大可接受陈旧程度 | 数据短暂不一致,监控过旧副本 |
| 同一会话要求单调读 | 固定合适路由或跟踪进度,避免切到更落后的副本 | 会话状态与路由变复杂 |
| 校验库存、唯一性等写前条件 | 在正确的主库事务中检查并修改 | 不可用一个副本旧读替代约束或加锁 |
“写后十秒都读主库”属于经验性时间策略;延迟超过十秒仍会出错。基于 GTID/LSN 等可验证进度的等待更有根据,但要实现超时、节点切换和进度所属拓扑的判断。本篇未实现这类路由协议。主库故障切换还必须防止旧主继续接受写入,仅改一条连接串不足以解决双主风险。[1:1][3]
分表、分区、分库分别搬走了什么
按业务领域拆不同表或服务,通常是纵向拆分。把同一类记录按键拆成多份,是横向拆分。数据库原生分区可以把一张逻辑表拆成多个物理分区,但这些分区若仍在同一实例上,并不会自动带来多机写容量。分库分片才把所有权和负载分到不同节点,同时把跨片事务、查询和运维带进来。[4][5]
选分片键时,不要只看分布均匀。它同时决定大部分请求能否路由到一片,以及有关联的记录能否在同一片完成事务。
| 候选分片键 | 好处 | 容易出现的成本 |
|---|---|---|
| tenant_id / user_id | 同租户或同用户请求容易局部完成 | 大租户、大用户可能独占热点 |
| 随机化业务 ID 的哈希 | 更容易分散记录数量 | 按用户查询可能遍历很多片 |
| 时间范围 | 按时间查询、归档、删除容易裁剪 | 最新时间区间可能承受几乎全部写入 |
| 地域/业务区域 | 靠近访问者,边界可解释 | 地域负载不均、跨地域用户迁移 |
理想分片键具有足够基数、分布较均匀、与主要查询和事务边界一致,并且不轻易变化。改分片键通常就是搬记录,还要处理相关子表和路由。一个热门账户上的顺序更新,即使全库分成一百片,也可能仍集中在那一个账户。[5:1]
分片数从四变五,取模会搬走多少数据
最短路由是 hash(key) % shardCount。问题是分片数一变,许多键的余数都变。实际 Go 实验对 0 到 1023 的键,使用 FNV-1a 32 位散列,比较直接取模与固定 16 个虚拟桶:
text
keys=1024 modulo4to5=823 moved-one-of16-buckets=69直接从 4 改成 5,823 个键改了目标。固定桶方案只把桶 0 的所有者换到新片,69 个键需要改路由。这个实验的后者只是一次小迁移,并没有把五个节点重新均衡;两个数字不能用来宣称“完成同等扩容时少搬多少倍”。它说明的是,把逻辑桶到物理节点的映射单独管理,能够控制每次迁移范围。
正在绘制图示…
查看图源代码
flowchart LR
K["业务键"] --> H["稳定散列"]
H --> B["固定逻辑桶"]
B --> M["版本化的桶→节点映射"]
M --> S1["节点 0…3"]
M --> S2["新节点 4"]
C["迁移协议确认数据已就绪"] --> M
路由计算变化不是迁移完成。新节点必须先有数据,旧写者必须知道何时失去写权限,映射更新还要处理缓存和旧版本客户端。FNV 在这里仅是确定性教学散列,不是安全函数,也不是对抗恶意热点的分布保证。
全局 ID 要兼顾唯一性、排序和生成责任
每个库独立从 1 自增,在汇总或跨片迁移时会相撞。可使用组合身份如“片 ID + 本地 ID”,也可以生成真正独立于当前位置的全局 ID;后者对迁移通常更方便,但需要另外设计生成方式。
| 方案 | 优点 | 必须承担的责任 |
|---|---|---|
| 集中或分段分配序列 | 规则清晰,可批量获取号段降低请求次数 | 分配器可用性、号段不重复;失败会产生空号 |
| UUID v4 | 可独立生成,不依赖统一时钟排序 | 随机索引写入局部性、空间成本;唯一性依赖足够随机性 |
| UUID v7 | 带时间排序特征,兼顾分布式生成 | 时钟、同毫秒生成策略;不代表所有节点严格全序 |
| Snowflake 风格时间+节点+序列 | 紧凑,便于按时间大致排序 | 节点号不得重复,序列耗尽与时钟回拨要有策略 |
UUID 的格式与生成要求以 RFC 9562 为准;Snowflake 指 Twitter 原始设计这一类思路,不是推荐直接运行已归档代码。Vitess 的 sequence 功能展示了分段缓存全局编号的实现路径。[6][7][8]
ID 不一定等于分片键:全局订单号唯一,不表示按订单号路由就最适合“查询某用户全部订单”。还要决定是否需要独立的 lookup 索引,并承担它的一致性维护。
跨片查询会把一次本地工作变成分发与合并
包含分片键的查询可以定向路由;不含分片键的查询可能扇出所有分片,再合并结果。分片越多,单次全局查询越容易被最慢分片拖住,还增加网络和协调开销。这是工作分解的推论,不是某个固定吞吐数字。
| 操作 | 跨片后通常要增加什么 | 设计选择 |
|---|---|---|
| 全局 ORDER BY / LIMIT | 每片取候选,协调者归并 | 使用稳定的复合排序键;大 OFFSET 特别昂贵 |
| SUM / COUNT / AVG | 先局部聚合再合并 | AVG 需要汇总 sum 与 count,不能直接平均各片平均值 |
| 跨片 JOIN | 搬数据、重分布或应用层多阶段查询 | 将高频关联共置、维护读模型,或换到分析系统 |
| 唯一性 | 单片唯一约束不覆盖全部片 | 全局分配、路由约束或集中注册表 |
| 跨片事务 | 协调提交或业务补偿 | 能共置就先共置;2PC 与补偿语义不同 |
两阶段提交可以协调多个参与者的原子提交,但增加协调、故障恢复和资源持有成本。Saga/补偿则是让多步业务在失败后执行相反动作,不等价于数据库回滚,也可能被外部观察到中间状态。具体中间件支持到哪里,应查其版本文档;不能在分片后继续假定所有单库 SQL 与事务都透明不变。[9]
数据迁移要先解决“谁的新旧顺序说了算”
一次在线迁移常包含快照回填、持续变化同步、校验、切流和旧系统退出。真正难的是:回填期间源库还在变,目标库可能先收到新变化,再收到旧快照。
本次 PostgreSQL 顺序回放模拟先写目标版本 2,再回填版本 1。无条件 upsert 的实际结果是:
text
naive-late-backfill
1|old|1|f新值被旧快照覆盖。给 upsert 增加版本比较后:
sql
ON CONFLICT(id) DO UPDATE
SET value = excluded.value, version = excluded.version, deleted = excluded.deleted
WHERE excluded.version > target.version;实际结果变成:
text
versioned-late-backfill
1|new|2|f这不是一套通用迁移算法。它依赖同一业务记录的版本有可靠单调顺序,重复版本内容不冲突,写入都遵守同一规则;多源写入不能凭各自本地计数直接满足这些前提。SQL 行为来自 PostgreSQL 的 ON CONFLICT 语义。[10]
删除也要参与排序。若版本 3 表示删除,却物理抹掉版本信息,迟到的版本 2 可能重新插入这行。本例保留带版本号的 tombstone(删除标记),晚来的旧值被拒绝,实际得到 1|NULL|3|t,对未删除记录计数为 0。删除标记何时可清除,还要根据日志保留、最大重放范围和快照生命周期判断,不能随意设一个很短 TTL。
一次可核查的切流,应有明确检查点
正在绘制图示…
查看图源代码
flowchart LR
A["准备目标 schema 与索引"] --> B["确定快照和增量起点"]
B --> C["回填 + 增量同步"]
C --> D["按一致边界校验"]
D --> E["暂停/隔离旧写入<br/>追到切换位置"]
E --> F["发布新路由,恢复写入"]
F --> G["验证业务与监控<br/>保留可行回退路径"]
G --> H["回退窗口结束后清理旧数据"]
Vitess 的 Reshard 工作流明确包含复制、VDiff、切流和清理;切换写流量涉及暂停源端写入并等待目标追上,反向复制影响能否回退。这是已有工具的具体例子,不是要求手写同一套协议。[11][12]
| 检查点 | 不能只看什么 | 应增加的证据 |
|---|---|---|
| 回填完成 | 进度百分比或两边 COUNT 一样 | 主键覆盖、分段内容校验、删除和更新一致性 |
| 增量追平 | 延迟仪表显示接近零 | 明确日志位置/事务边界与当前写入是否仍在变化 |
| 业务兼容 | 表结构相似 | 约束、排序规则、时区、默认值、触发器与权限 |
| 回退可用 | 旧库还留着 | 新写入能否回传,旧应用能否理解新数据 |
| 清理安全 | 新路径暂时没报警 | 回退窗口结束、备份可恢复、所有旧客户端退出 |
异步“双写两个库”并不天然可靠。一边成功另一边失败时必须能补偿和重放;有些场景可以用源事务内 outbox 加增量投递,有些适合数据库日志同步。无论选哪条路,都要定义权威源、幂等、顺序和对账,不能把两个 API 调用当作一个事务。
在线 DDL 也会等锁,也会花资源
| 引擎与操作 | 提供的能力 | 必须保留的边界 |
|---|---|---|
| MySQL 8.4 INSTANT | 特定变更可只改元数据,避免重建全表 | 只支持特定操作与条件,仍需要元数据锁 |
| MySQL 8.4 INPLACE | 部分工作在引擎内完成,可允许一定并发 | 名字不保证不重建,不保证零磁盘/CPU/日志成本 |
| MySQL 8.4 COPY | 建新副本并复制数据 | 并发与锁影响通常更大,需计划空间与窗口 |
| PostgreSQL CREATE INDEX CONCURRENTLY | 尽量避免阻止正常写入 | 多阶段工作和等待,不能放在普通事务块里;失败可留下 invalid 索引 |
| PostgreSQL ALTER TABLE | 某些变更元数据即可,某些要扫描或重写 | 锁级别和重写条件依动作与表达式而定,不能一概称在线 |
MySQL 显式请求不支持的算法/锁组合会报错,实际支持矩阵要按变更逐项查;不要以为 LOCK=NONE 表示整个操作没有任何元数据锁。长事务持有的 metadata lock 可以让 DDL 等待,并牵连后来的访问。[13][14][15]
PostgreSQL 并发建索引也不是免费操作,扫描、等待老事务和失败清理都要监控;约束验证、NOT NULL、列类型变更等有各自的优化与锁条件。应先在同版本、近似规模上验证,而不是把一次空表 ALTER 的速度推广到大表。[16][17]
应用兼容发布通常分三步:先扩展 schema,让旧代码仍能工作;再回填、上线新读写并确认旧版本退出;最后收缩,删除旧字段。删列前尤其要看后台任务、报表和回退版本,不仅看当前 Web 服务。对可回滚的发布,新增数据必须仍能被回退代码理解,否则“回退程序”并不等于“回退系统”。
冷热分离改变查询路径,也改变恢复路径
热数据是当前请求需要低延迟访问的数据,冷数据是低频、可容忍更慢读取的数据。按时间分区能方便移动或脱离整段历史数据,但“时间老”与“没人再更新”不是同一件事,退款、补账、合规查询仍可能访问历史记录。[4:1]
| 存放位置 | 适合的访问 | 要提前设计 |
|---|---|---|
| 事务库热分区 | 最新订单、频繁更新 | 索引、缓存与写入容量 |
| 较低成本数据库/分析库 | 需要可查询历史,允许不同延迟 | 同步滞后、SQL 差异、跨冷热聚合 |
| 对象存储归档 | 大批量长期保留、按文件重建分析 | 格式版本、清单与校验和、加密、取回时间与费用 |
S3 的生命周期规则能转换存储类别或过期对象,但不等于已经完成数据库逻辑归档。删除热数据前应验证归档完整、能实际读取恢复、权限与保留规则正确;归档类别还可能有取回等待和最短计费期限。本文只讨论机制,没有查询实时价格或执行任何云存储变更。[18]
扩展数据库最可靠的起点,仍是把读写、一致性、维护和恢复需求量化。一个暂时够用的单库往往比一个不知道如何迁移和回退的分片系统更容易运营;当瓶颈和边界足够清楚,再引入相应复杂度。
参考资料
完整复现材料
路由实验使用 Go 1.27.2,仅计算内存中的映射,不连接数据库。SQL 实验实际使用 PostgreSQL 18.6 的 single-user 离线模式,将预设的新旧事件顺序执行,验证条件 upsert 与删除标记。它不是 CDC 产品、双写协议或并发迁移的集成测试,没有验证在线 DDL 与复制延迟。
sh
go run main.go
export POSTGRES=/path/to/postgresql-18.6/bin/postgres
export PGDATA="$(mktemp -d)/data"
/path/to/postgresql-18.6/bin/initdb -D "$PGDATA" -A trust --no-locale -E UTF8
python3 run_single.py migration.sql必须新建可丢弃数据目录,没有其他 PostgreSQL 进程占用;不能对生产执行。run_single.py 只支持本文固定测试格式,不是通用 SQL 解析器。
main.go
代码块 · 37 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"hash/fnv"
"strconv"
)
func hash(id int) uint32 {
h := fnv.New32a()
_, _ = h.Write([]byte(strconv.Itoa(id)))
return h.Sum32()
}
func main() {
oldMap := [16]int{}
newMap := [16]int{}
for i := range oldMap {
oldMap[i] = i % 4
newMap[i] = oldMap[i]
}
newMap[0] = 4 // Only virtual bucket 0 changes owner in this teaching model.
moduloMoves, bucketMoves := 0, 0
for id := 0; id < 1024; id++ {
h := hash(id)
if h%4 != h%5 {
moduloMoves++
}
b := h % 16
if oldMap[b] != newMap[b] {
bucketMoves++
}
}
fmt.Printf("keys=1024 modulo4to5=%d moved-one-of16-buckets=%d\n", moduloMoves, bucketMoves)
if bucketMoves == 0 || bucketMoves >= moduloMoves {
panic("unexpected fixture distribution")
}
}migration.sql
sql
\set ON_ERROR_STOP on
CREATE TEMP TABLE target(id integer PRIMARY KEY,value text,version bigint NOT NULL,deleted boolean NOT NULL);
INSERT INTO target VALUES(1,'new',2,false);
INSERT INTO target VALUES(1,'old',1,false)
ON CONFLICT(id) DO UPDATE SET value=excluded.value,version=excluded.version,deleted=excluded.deleted;
\echo naive-late-backfill
SELECT id,value,version,deleted FROM target;
TRUNCATE target;
INSERT INTO target VALUES(1,'new',2,false);
INSERT INTO target VALUES(1,'old',1,false)
ON CONFLICT(id) DO UPDATE SET value=excluded.value,version=excluded.version,deleted=excluded.deleted
WHERE excluded.version>target.version;
\echo versioned-late-backfill
SELECT id,value,version,deleted FROM target;
INSERT INTO target VALUES(1,NULL,3,true)
ON CONFLICT(id) DO UPDATE SET value=excluded.value,version=excluded.version,deleted=excluded.deleted
WHERE excluded.version>target.version;
INSERT INTO target VALUES(1,'new',2,false)
ON CONFLICT(id) DO UPDATE SET value=excluded.value,version=excluded.version,deleted=excluded.deleted
WHERE excluded.version>target.version;
\echo delete-then-old-event
SELECT id,value,version,deleted FROM target;
SELECT COUNT(*) AS visible_rows FROM target WHERE NOT deleted;run_single.py
代码块 · 42 行 · 点击收起 / 展开
python
"""Run the deliberately simple fixtures shipped with these articles.
Input: ordinary SQL and optional psql \\set/\\pset/\\echo directives.
This is a fixture adapter, not a general SQL parser. No fixture may contain
semicolons inside quoted values, comments, function bodies, or identifiers.
"""
import os
from pathlib import Path
import re
import subprocess
import sys
sql = Path(sys.argv[1]).read_text()
lines = []
for line in sql.splitlines():
if line.startswith('\\echo '):
label = line[len('\\echo '):].replace("'", "''")
lines.append("SELECT '" + label + "';")
elif line.startswith('\\'):
continue
else:
lines.append(line)
commands = []
for part in '\n'.join(lines).split(';'):
part = part.strip()
if not part:
continue
# COPY gives legible values; it does not change the SELECT's semantics.
if re.match(r'(?i)^(SELECT|WITH)\b', part):
part = "COPY (" + part + ") TO STDOUT WITH (DELIMITER '|', NULL 'NULL')"
commands.append(part + ';\n\n')
run = subprocess.run(
[os.environ.get('POSTGRES', 'postgres'), '--single', '-j',
'-D', os.environ['PGDATA'], 'postgres'],
input=''.join(commands), text=True, capture_output=True)
print(run.stdout, end='')
print(run.stderr, end='', file=sys.stderr)
if run.returncode:
sys.exit(run.returncode)
# The stand-alone backend may recover from a SQL error and exit zero.
# An ERROR is still a failed fixture; expected failures are checked outside.
if re.search(r'\b(ERROR|FATAL|PANIC):', run.stderr):
sys.exit(1)