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 选型之后为什么还要准备 “退出方案”?

▸ 答案:因为选型的假设会变(数据量、查询模式、团队技能、厂商策略)。退出方案至少包括:数据可导出格式、双写/迁移路径、回滚窗口。没有退出方案的选型,本质是把 “技术选择” 变成 “单点依赖”。

关联