外键:别再问它们快不快了
问问自己,你到底在优化什么。
我见过的代价最昂贵的数据库优化,始于有人删除了所有的外键(Foreign Keys)。
原因并非他们测算出了瓶颈,也不是写入真的很慢,而是因为他们在某处读到“外键无法扩展”。六个月后,他们产生了 20 亿条孤儿记录(orphaned records),计费系统在向已注销的用户收费,分析数据的误差高达 40%。
当他们试图重新加回约束时,数据库在尝试校验那些已经损坏的存量数据时陷入了瘫痪。
Web 开发领域流传着一种根深蒂固的观点:外键天生就慢,是你在晋升到“真正”的大规模系统之前必须拆掉的辅助轮。但这完全误解了约束存在的意义。你并不是在“快”与“慢”之间做选择,而是在不同的故障模式(failure modes)之间做选择。
可以这样理解:安全玻璃、安全带和安全气囊都会增加汽车的重量。它们绝对会让你的车变慢,降低燃油效率。但你不会为了优化百公里加速而把它们拆掉,因为你在优化的是完全不同的东西。
问题不在于外键是否会拖慢速度。它们当然会。问题在于你换取了什么,以及你是否真的需要它。
你到底在交易什么
让我举个具体的例子。假设你正在构建一个天气监测系统,其中包含气象站、传感器设备、传感器读数和美国各州的表。
你会用外键把所有东西都关联起来吗?让我们思考一下实际会发生什么变化,以及后果是什么:
美国各州的数据可能不会改变。怀俄明州短期内不会改名。当你明知参考数据是静态的时候,没必要在每次插入时都通过外键去校验州代码。那是毫无意义的开销。
气象站会被添加、移动和退役。但这里有个问题:如果有人误删了一条气象站记录,你希望历史读数因此“丢失”所属站点吗?也许你实际上希望即使站点消失了,数据也能保持完整。这意味着你将读数视为历史快照,而不是实时引用,这改变了使用外键是否还有意义。
传感器读数每分钟被插入数千次。每一次外键检查都意味着一次查询。每一次查询都会在表上产生竞争。如果缓慢的校验导致你的插入队列堆积,进而导致你丢失实时数据,那么这种数据丢失与产生一条孤儿记录相比,是性质完全不同的损失。
你应该能看出端倪了。这种选择不是关于“性能”与“正确性”这两个抽象概念的博弈。它是关于在给定的实际约束和后果下,你更愿意容忍哪种特定的故障。
如果错误的引用意味着计费数据损坏或违反监管要求,那么无论性能代价如何,你可能都需要外键的保护。如果缓慢的校验意味着你会因为队列溢出而永久丢失实时传感器数据,那么校验可能就是错误的权衡。
当快速写入真正重要时
假设你已经决定需要极高的写入速度。你的队列正在堆积,事务正在超时,而且外键检查确实造成了你已经测量到(而不仅仅是理论推导)的问题。
你有几个选择。你可以将事务隔离级别从 SERIALIZABLE 降为 READ COMMITTED,这更快,但牺牲了一些一致性保证。你可以进行批量提交,每个事务插入 1000 行而不是一行,以摊销外键开销。或者,你可以将数据去范式化(denormalize),采用仅追加(append-only)的日志结构,根本不去尝试校验引用。
顺便说一句,第三种选择并不是在作弊。它只是一种不同的设计:
CREATE TABLE sensor_log ( id BIGSERIAL PRIMARY KEY, recorded_at TIMESTAMPTZ NOT NULL, data JSONB NOT NULL -- { station_id, sensor_id, temp, humidity, ... });
CREATE INDEX ON sensor_log USING GIN (data);CREATE INDEX ON sensor_log (recorded_at);没有连接(Join),没有外键检查。只需追加数据,然后按时间范围或 JSONB 字段上的 GIN 索引进行查询。这是“最佳实践”吗?从数据库教科书的角度来看,可能不是。但如果你要在树莓派上每分钟插入 50,000 行数据,它绝对行得通。
当人们将“最佳实践”视为一种道德准则,而不是一种在常见场景下表现良好、但可能并不适合你的模式时,脱节就发生了。
范式化陷阱
数据库课程热衷于教授范式化(Normalization)。不惜一切代价避免重复。要么达到第三范式,要么就别干。
于是你最终得到了类似这样的结构:Orders → OrderItems → Products → Variants → Colors → Sizes
为了回答“去年圣诞节我订的是红衬衫还是蓝衬衫?”这个问题,你需要进行六表连接。天哪,如果你还需要包含产品名称,那还得在目录层级中再多连三张表。
但等等。理由通常是:“如果品牌更改了‘蓝色’的标注方式怎么办?”如果发生了这种情况,你真的希望历史订单追溯性地改变颜色吗?当然不。当某人下订单时,他们购买的是那一刻存在的“蓝色 T 恤,M 码”,而不是对以后可能更新的目录条目的某种抽象引用。
这一点值得深思,因为它很微妙。有些数据从根本上说是快照(Snapshot),而不是引用(Reference)。当你把快照数据当作实时引用来处理时,你最终会陷入荒谬的连接激增中,只为了重构那些本应在写入时就去范式化的东西。
直接在订单上存储 {"color": "blue", "size": "M"}。搞定。
识别快照数据
你如何知道什么时候该使用快照?问问自己这是否是一个时间点记录:
订单捕获购买时的产品详情。审计日志记录用户执行操作时的状态。历史表在更新前保存记录状态。事件流捕获发生了什么、何时发生、以及使用了什么数据。
如果答案是“是的,这是在记录一个时刻”,那就停止范式化。开始快照化。
不透明的大对象(Opaque Blobs)
除了快照之外,还有另一类数据:你永远不会深入查询的数据。你只是存储它并整体检索它。
像 {"model": "gpt-4", "temperature": 0.7, "max_tokens": 2000} 这样的 LLM 模型配置,你不会按 temperature 来查询。你是在需要时通过请求 ID 获取整个配置。解码后的 JWT 载荷、用于调试的 API 请求/响应日志、包含主题设置和通知标志的用户偏好对象。这些都是不透明的大对象。你不需要范式化。你不需要外键。把它们塞进 JSONB,然后继续你的生活。
为了查出订购了什么颜色的衬衫而进行的 6 表连接?那不是正确的范式化。那是对你存储的是引用还是值的思维混乱。
(不过要小心:如果你以后需要查询这些数据,这种做法可能会产生灾难性的后果。请参阅 JSONB 的诱惑,了解这种方法何时会创造其自身的噩梦。)
规模取决于上下文
你会听到有人说“外键无法扩展”。但规模完全取决于你的硬件和架构。
一个每分钟向 microSD 卡记录 10,000 条传感器读数的树莓派?对那种硬件来说,这确实是高规模。而拥有预置 IOPS、处理数十亿行数据的 AWS Aurora?你完全可以靠外键一路平推,甚至汗都不出一滴。
真正的硬限制不在于行数或写入量,而在于分片(Sharding)。
当你的 Users 表在服务器 A,而 Orders 表在服务器 B 时,外键在物理上就无法工作了。数据库没有跨网络边界强制执行约束的机制。到那个阶段,你已经在运行后台任务来清理孤儿记录,并实现最终一致性模式了。
这种情况发生在多租户 SaaS 中(为了合规,每个租户都有独立的数据库),或者在拥有 50,000 个运行本地 SQLite 的边缘设备的 IoT 部署中。一旦到了这一步,无论性能考虑如何,外键都(字面意义上)没戏了。
但在你触及那个架构边界之前,当你还在构建一个只有 10 个用户的内部工具时,或许没必要过早地去优化 Netflix 才需要面对的问题。
实践中到底该如何权衡
与其问“我是否该使用外键”,不如试着问这三个问题:
如果这个引用错了,会出什么事? 是会导致诉讼、计费错误、违反监管要求?还是仅仅在你的分析仪表盘里少了一条关联数据,返回一个 null?
如果校验变慢了,会出什么事? 你会丢失不可替代的实时数据吗?还是说你的查询只是多花了 50 毫秒?
这份数据是快照还是引用? 你是在记录某个事物在特定时刻的样子,还是在指向权威的当前值?
从这些问题出发,模式就会自然浮现:
必须使用: 金融交易、身份验证会话,以及任何数据损坏就意味着法律责任的场景。无论性能开销如何,这些通常都需要外键。
可以不用: 高吞吐量日志、仅追加(Append-only)的时序数据,以及任何每分钟写入百万级事件的场景。你可能不需要在每次写入时都承担校验开销。
去范式化更好: 历史快照(如订单和审计日志)、总是作为完整大对象获取的数据(如用户偏好)、你无法控制的模式(如来自外部 API 的 webhook 负载)……这些通常在去范式化后表现更好。
但请注意,我说的是“通常”和“可能”。因为上下文很重要,而你的上下文与我的不同。
最后的思考
外键本身并不是性能问题。它们是写入速度与数据完整性之间的一种权衡,而这种权衡是否合理,完全取决于你具体的瓶颈和具体的后果。
真正的问题在于,很多人在没有实际测量是否存在写入性能问题,也没有考虑会失去什么的情况下,仅仅因为读到了一些关于“互联网规模(web scale)”的文章就删除了外键。结果就是,你把 Netflix 的架构盲目崇拜(cargo-culting)到了一个每天只处理 100 笔交易的新项目上。
也许对你的业务场景来说,性能开销是值得的。也许不值得。但至少请根据你真正想要优化的目标来做决定,而不是根据你认为“应该”优化的目标。
你到底在优化什么?