💡 Key Takeaways
- The "I'll Just Use UUIDs Everywhere" Disaster
- Premature Normalization: When Third Normal Form Becomes Your Enemy
- The NULL Nightmare: When Optional Becomes Impossible
- Index Overload: When More Isn't Better
三年前,我在黑色星期五的凌晨2:47看到我们的初创公司的数据库停止运转。我们有50,000个并发用户,200万美元的待处理交易,而且一个查询需要45秒才能返回产品可用性。我们的CTO在Slack上尖叫。我们的投资者在打电话。我盯着自己六个月前设计的架构,意识到我所做的每一个“聪明”决定,现在每分钟都在让我们损失约8,000美元的收入。
💡 关键要点
- “我会到处使用UUID”灾难
- 过早的归一化:当第三范式变成你的敌人
- NULL噩梦:当可选变得不可能
- 索引过载:当更多并非更好
我是Marcus Chen,过去十二年我担任数据库架构师,服务于从小型SaaS初创公司到财富500强企业的各类客户。我设计过处理每天5亿交易的系统架构,优化过让关键路径减少200毫秒的查询,没错—我几乎犯过每一个数据库设计错误。那次黑色星期五事件?它让我在数据库设计方面的学习超越了我整个计算机科学学位。
如今,我是TXT1.ai的首席数据库架构师,我们的AI驱动通信平台每年处理超过30亿条短信。但我通过不断失败走到这里,我想分享我所学到的高昂教训,以便您能够跳过凌晨2点的恐慌和愤怒的投资者电话。
“我会到处使用UUID”灾难
让我从我称之为4万美元的错误开始。在2019年,我为一家中型电子商务公司设计一个客户关系管理系统。我刚刚读了一篇关于UUID是处理主键的“现代”方法的博客文章——不再使用自增整数,不再暴露顺序ID,非常适合分布式系统。我被说服了。
所以我让系统中每个主键都是UUID。用户表?UUID。订单表?UUID。订单行项目?你猜对了——UUID。我感觉自己像个天才。架构看上去很干净,没有顺序ID的漏洞,如果需要,我可以在客户端生成ID。有什么问题呢?
一切。绝对一切都出错了。
六个月内,我们的数据库大小膨胀到340GB,而它应该在180GB左右。查询性能周周下降。我们的索引大小巨大——仅订单表的索引就达12GB。订单与行项目之间的连接本应耗时50毫秒,却花费了800毫秒。数据库花费了大量时间在磁盘I/O上,我们的AWS RDS费用几乎翻了一番。
我以痛苦的经历学到的教训是:UUID占128位(16字节),而4字节整数或8字节bigint仅占4倍的存储。但真正致命的不是存储——而是索引片段化。UUID是随机的,这意味着每次插入都会在B树索引中导致随机写入。使用顺序整数时,新行附加到索引的末尾。使用UUID时,数据库不断重平衡整个索引结构。
我们测量了影响:使用整数ID插入100,000行需要8秒。使用UUID进行相同操作需要34秒。这仅仅是主键选择就带来了4.25倍的性能损失。当你每天处理50,000个订单时,这种损失迅速累积。
修复成本是三周的开发时间,并且需要在维护窗口内仔细组织迁移。我们将高流量表的主键改为bigint,只保留UUID用于我们确实需要全球唯一标识符的表——结果发现只有47个表中的两个表需要UUID。
我现在的规则是:除非你有确切的、记录在案的理由使用UUID,否则主键应使用自增整数或bigint。而“它似乎更现代”并不是一个合理的理由。
过早的归一化:当第三范式变成你的敌人
刚从大学毕业,我对归一化产生了痴迷。我背熟了所有的范式,能在睡梦中背诵Codd的规则,并相信一个适当归一化的数据库是设计卓越的巅峰。因此,当我设计我的第一个生产系统——一个内容管理平台时,我把所有东西都归一化到第三范式及以上。
“你今天做的每一个‘聪明’的数据库决策,都是六个月后潜在的凌晨2点危机。为你将要拥有的系统设计,而不是你想要的系统。”
我有一个用户表,一个用户地址表(因为用户可能有多个地址),一个用户电话表(多个电话!),一个用户偏好表,一个用户设置表和一个用户元数据表。加载某个用户的个人资料需要连接六个表。我对它看起来“干净”的样子感到非常自豪。
然后我们上线了。用户个人资料页面——整个应用程序中访问频率最高的页面——加载时间达到了1.2秒。我们每次页面查看都进行了六次连接,对于每天有10,000个活跃用户来说,这意味着每天仅仅是个人资料查看就要进行60,000次连接。数据库的CPU始终超过70%。
当我们的首席开发人员把我叫到一旁,向我展示查询执行计划时,我意识到问题的严重性。“Marcus,”他说,“我们连接六个表来显示用户的姓名、电子邮件和电话号码。这简直疯狂。”他说得对。我是为了理论上的纯洁性而优化,而不是为了实际性能。
我们进行了战略性非规范化。用户的主要地址回到了用户表中。他们的主要电话号码?同样。我们仍然保留额外地址和电话号码的单独表,但94%的用户只有一个地址和一个电话号码。这个单一的变化将我们的个人资料页面平均查询时间从1.2秒减少到180毫秒——提升了85%。
我从中学到的教训是:归一化是一种工具,而不是宗教。第三范式是一个很好的起点,但实际性能往往需要战略性非规范化。现在,我遵循我称之为“80/20非规范化规则”的原则——如果80%的查询需要来自多个表的数据,那么这些数据可能应放在一个表中。我根据实际使用情况衡量生产中的查询模式,并基于实际使用而不是理论纯洁进行非规范化。
关键是知道何时进行非规范化。高读、低写的表格非常适合进行非规范化。用户个人资料、产品目录、配置数据——这些都是非规范化的好地方。高写入量的交易表?保持这些规范化以避免更新异常。
NULL噩梦:当可选变得不可能
我曾经喜欢可空列。它们看起来如此灵活,如此宽容。用户可能没有中间名?让它可空。订单可能没有折扣码?可空。产品可能没有重量?你也明白了。
| 主键类型 | 存储大小 | 索引性能 | 最佳使用案例 |
|---|---|---|---|
| 自增INT | 4字节 | 优良(顺序) | 单服务器系统,高流量表 |
| 自增BIGINT | 8字节 | 优良(顺序) | 大型单服务器系统 |
| UUID(v4) | 16字节 | 差(随机) | 分布式系统,安全敏感ID |
| ULID/UUID(v7) | 16字节 | 良好(时间排序) | 需要可排序的分布式系统 |
| 复合键 | 变化 | 中等到良好 | 自然关系,多租户系统 |
在一个特别糟糕的项目中,我设计了一个库存管理系统,其中大约60%的列都是可空的。这看起来很合理——因为并不是每个字段总会有数据,对吧?干嘛强行设定默认值呢,NULL明显表明“没有值”呢?
问题立刻就出现了。查询变成了一个NULL检查的雷区。想要找到所有没有重量的产品?你以为“WHERE weight IS NULL”能工作,但如果产品的重量明确设为0呢?现在你需要“WHERE weight IS NULL OR weight = 0”。想要求和订单总额?最好使用COALESCE,否则如果任何单个值为NULL,你的SUM可能会返回NULL。