44.21 数据库选型方法
一句话
选型不是“打分排名”,而是先用硬约束淘汰(事务、一致性、合规、团队),再按业务权重给剩下的打分,最后用最小可验证实验确认——打分只负责在 2–3 个候选里排序,不负责淘汰。
五步流程
| 步 | 做什么 | 产出 |
|---|---|---|
| 1 | 写清数据形状:写读比、增长量、查询模式、一致性要求 | 一页纸的负载画像 |
| 2 | 硬约束淘汰:不满足就直接出局 | 候选从 8 个降到 3 个 |
| 3 | 按业务权重打分 | 排序(差距通常很小) |
| 4 | 最小 PoC:真实数据量与查询跑一遍 | 延迟/成本/故障恢复数据 |
| 5 | 写下决策记录与回退方案 | ADR + 迁移预算 |
硬约束清单(不满足就出局)
| 约束 | 不满足的后果 | 直接排除 |
|---|---|---|
| 需要多行/多表事务 | 只能靠应用补偿,正确性风险高 | KV、列族、搜索、向量库 |
| 需要强一致读己之写 | 读到旧数据,业务出错 | 异步复制的只读副本 |
| 需要跨字段组合查询与 join | 应用层拼装,N+1 | 纯 KV、纯向量库 |
| 团队没有该库的运维能力 | 出故障无人能修 | 一切“新潮但没人会”的库 |
| 合规:数据必须留在境内/指定地域 | 合规事故 | 不支持的云服务 |
| 单库容量/连接数上限 | 半年后必须分库分表 | 单机库 |
🔑 硬约束的作用是把“打分”变成“在合规候选里排序”。先打分再淘汰,会出现“分最高的那个根本不能用”的尴尬。
代码:硬约束 + 加权打分
package main
import (
"fmt"
"sort"
)
// 六个评估维度(1–5 分,5 最好)
var dims = []string{"强一致事务", "水平扩展", "查询灵活", "写入吞吐", "运维成本", "生态成熟"}
type db struct {
name string
score [6]float64
}
var dbs = []db{
{"MySQL / PostgreSQL", [6]float64{5, 2, 5, 3, 4, 5}},
{"TiDB", [6]float64{5, 5, 5, 4, 2, 4}},
{"Cassandra / HBase", [6]float64{2, 5, 2, 5, 3, 3}},
{"MongoDB", [6]float64{3, 4, 4, 4, 4, 4}},
{"Redis", [6]float64{2, 3, 2, 5, 4, 5}},
{"ClickHouse", [6]float64{2, 5, 3, 5, 3, 4}},
{"Elasticsearch", [6]float64{1, 5, 4, 3, 3, 4}},
{"Neo4j", [6]float64{3, 3, 2, 3, 2, 3}},
}
// 场景权重:与 dims 一一对应
type scenario struct {
name string
weights [6]float64
// 硬约束:这些维度的最低分要求,达不到直接出局
hardMin map[int]float64
hardWhy string
}
var scenarios = []scenario{
{
name: "交易/账务",
weights: [6]float64{0.35, 0.15, 0.20, 0.10, 0.10, 0.10},
hardMin: map[int]float64{0: 4}, // 必须强一致事务
hardWhy: "需要多行事务与强一致(扣款/记账)",
},
{
name: "日志/时序",
weights: [6]float64{0.05, 0.25, 0.10, 0.35, 0.15, 0.10},
hardMin: map[int]float64{3: 4}, // 必须高写入吞吐
hardWhy: "每秒百万点写入,查询按时间范围",
},
{
name: "内容/检索",
weights: [6]float64{0.05, 0.15, 0.45, 0.10, 0.15, 0.10},
hardMin: map[int]float64{2: 4}, // 必须灵活查询
hardWhy: "多条件过滤 + 全文 + 排序",
},
}
func main() {
for _, sc := range scenarios {
type row struct {
name string
score float64
out string
}
var kept, out []row
for _, d := range dbs {
eliminated := ""
for idx, min := range sc.hardMin {
if d.score[idx] < min {
eliminated = fmt.Sprintf("硬约束未达标:%s(要求 ≥%.0f,实际 %.0f)",
dims[idx], min, d.score[idx])
}
}
if eliminated != "" {
out = append(out, row{d.name, 0, eliminated})
continue
}
s := 0.0
for i, w := range sc.weights {
s += w * d.score[i]
}
kept = append(kept, row{d.name, s, ""})
}
sort.Slice(kept, func(i, j int) bool { return kept[i].score > kept[j].score })
fmt.Printf("=== 场景:%s(硬约束:%s)===\n", sc.name, sc.hardWhy)
fmt.Printf("%-22s %-8s %s\n", "候选", "加权分", "备注")
for i, r := range kept {
note := ""
if i == 0 {
note = "← 首选(需 PoC 确认)"
}
fmt.Printf("%-22s %-8.2f %s\n", r.name, r.score, note)
}
fmt.Printf("被硬约束淘汰 %d 个:\n", len(out))
for _, r := range out {
fmt.Printf(" %-20s %s\n", r.name, r.out)
}
fmt.Println()
}
}
本机实测(macOS,Apple M1,Go 1.27):
=== 场景:交易/账务(硬约束:需要多行事务与强一致(扣款/记账))===
候选 加权分 备注
TiDB 4.50 ← 首选(需 PoC 确认)
MySQL / PostgreSQL 4.25
被硬约束淘汰 6 个:
Cassandra / HBase 硬约束未达标:强一致事务(要求 ≥4,实际 2)
MongoDB 硬约束未达标:强一致事务(要求 ≥4,实际 3)
Redis 硬约束未达标:强一致事务(要求 ≥4,实际 2)
ClickHouse 硬约束未达标:强一致事务(要求 ≥4,实际 2)
Elasticsearch 硬约束未达标:强一致事务(要求 ≥4,实际 1)
Neo4j 硬约束未达标:强一致事务(要求 ≥4,实际 3)
=== 场景:日志/时序(硬约束:每秒百万点写入,查询按时间范围)===
候选 加权分 备注
ClickHouse 4.25 ← 首选(需 PoC 确认)
TiDB 4.10
Cassandra / HBase 4.05
MongoDB 3.95
Redis 3.90
被硬约束淘汰 3 个:
MySQL / PostgreSQL 硬约束未达标:写入吞吐(要求 ≥4,实际 3)
Elasticsearch 硬约束未达标:写入吞吐(要求 ≥4,实际 3)
Neo4j 硬约束未达标:写入吞吐(要求 ≥4,实际 3)
=== 场景:内容/检索(硬约束:多条件过滤 + 全文 + 排序)===
候选 加权分 备注
TiDB 4.35 ← 首选(需 PoC 确认)
MySQL / PostgreSQL 4.20
MongoDB 3.95
Elasticsearch 3.75
被硬约束淘汰 4 个:
Cassandra / HBase 硬约束未达标:查询灵活(要求 ≥4,实际 2)
Redis 硬约束未达标:查询灵活(要求 ≥4,实际 2)
ClickHouse 硬约束未达标:查询灵活(要求 ≥4,实际 3)
Neo4j 硬约束未达标:查询灵活(要求 ≥4,实际 2)
读数:
| 场景 | 硬约束淘汰 | 首位 | 说明 |
|---|---|---|---|
| 交易/账务 | 6 个候选因“无事务”出局 | TiDB 4.50、关系型 4.25 | 打分只在“有事务”的候选里排序 |
| 日志/时序 | 3 个候选因“写入吞吐 < 4”出局 | ClickHouse 4.25 | 淘汰理由比分数更重要 |
| 内容/检索 | 4 个候选因“查询灵活”出局 | TiDB 4.35、MySQL 4.20(搜索库只有 3.75) | 反直觉,见下 |
🔑 打分的作用是在 2–3 个候选里排序,而淘汰由硬约束完成。如果你的打分表里出现了“分最高的候选被硬约束淘汰”,说明流程顺序错了——先淘汰,再打分。
“内容/检索”这一栏是本模型最值得看的结果:搜索库排在最后。原因是“全文检索”没有被单独列成维度,只在“查询灵活”里占一部分权重,于是 SQL 库靠其他维度的综合分赢了。这不是模型的 bug,而是所有加权模型的通病:它无法表达“某一项是刚性需求”。修法只有两个——把刚性需求写成硬约束(全文检索 ≥ 5,直接淘汰不满足的),或者拆成独立维度单独给高权重。先决定“什么是一票否决”,再谈权重。
PoC 要测的五件事
| 测什么 | 怎么测 | 不测的后果 |
|---|---|---|
| 目标数据量下的查询延迟 | 灌入 3–6 个月的真实数据量 | 上线三个月后开始慢 |
| 写入峰值 | 按峰值 × 2 压测(含索引维护) | 大促当天写不进去 |
| 故障恢复 | 杀主节点/断网,看切换时间与丢数据量 | RPO/RTO 只是文档上的数字 |
| 运维动作 | 扩容、备份恢复、版本升级各跑一遍 | 出事时没人会操作 |
| 成本 | 按 1 年存储增长算账单(含备份与流量) | 第二年预算翻倍 |
反模式
| 反模式 | 后果 |
|---|---|
| 按“热度/大厂在用”选 | 团队没有对应的运维能力,故障时无人能修 |
| 只看 benchmark 数字 | 基准与自己的查询模式无关,上线后性能对不上 |
| 先打分再淘汰 | 选出“分最高但根本不能用”的库 |
| 用“一种库解决所有场景” | 交易、搜索、时序各自的最优解不同 |
| 不做 PoC 直接上线 | 数据量一上来就返工,迁移成本远高于 PoC |
| 不写 ADR | 半年后没人知道为什么选它,也不敢换 |
自检
Q1 为什么先做硬约束淘汰,而不是直接加权打分?
▸ 答案:因为加权会“用高分掩盖致命缺陷”。一个候选在扩展性、吞吐、生态上都拿 5 分,但事务只有 1 分,加权后仍可能排第一——而交易场景根本不能用没有事务的库。硬约束是“一票否决”,它把不可行的候选先清掉,打分只在可行集合里做细微排序。正确的顺序是“可行域 → 排序 → PoC”。
Q2 打分差距通常很小(0.1 分级别),为什么还要打分?
▸ 答案:因为打分的作用不是“选出绝对最优”,而是迫使团队把隐含的权重摆到桌面上:你说 A 比 B 好,好在哪个维度、权重多少?这个讨论过程本身就有价值(它能暴露“其实我们最在意的是运维成本”)。最终决策往往由 PoC 数据与团队能力决定,但打分让讨论有结构、让 ADR 有依据。
Q3 一个团队把 MySQL 换成了分布式数据库,理由是“要扩展性”,半年后想换回来。最可能漏了哪一步?
▸ 答案:漏了负载画像与 PoC。多数业务的瓶颈不是单库扩展性,而是慢查询、缺索引、连接池、热点行;换成分布式库后,跨分片事务与延迟反而让业务变慢,运维复杂度上升。正确做法是先量出“当前瓶颈到底是什么”(44.9),如果确实是单库容量,再评估分库分表(44.14)与分布式库的成本差异,并用真实数据量做 PoC。
Q4 为什么 PoC 性能测试经常得出错误结论?
▸ 答案:因为 PoC 常用小数据量 + 理想查询:数据量没到 “索引放不下、必须走磁盘” 的规模,缓存全命中,测出来的是 “内存速度”。正确做法是按 “生产数据量 × 2 + 真实查询分布(含长尾)” 压测,并观察 P99 与资源曲线,而不是只看 QPS 峰值。
Q5 选型之后为什么还要准备 “退出方案”?
▸ 答案:因为选型的假设会变(数据量、查询模式、团队技能、厂商策略)。退出方案至少包括:数据可导出格式、双写/迁移路径、回滚窗口。没有退出方案的选型,本质是把 “技术选择” 变成 “单点依赖”。