File: README.md
设计数据密集型应用(第二版) - 中文翻译版
作者: Martin Kleppmann,《Designing Data-Intensive Applications 2nd Edition》: 英国剑桥大学分布式系统研究员,演讲者,博主和开源贡献者,软件工程师和企业家,曾在 LinkedIn 和 Rapportive 负责数据基础架构。
译者:冯若航 / Vonng ([email protected]) Pigsty 创始人,活跃开源贡献者,PostgreSQL Hacker。开源 RDS PG 发行版 Pigsty 与公众号《老冯云数》作者,数据库老司机,云计算泥石流,曾于阿里,苹果,探探担任架构师与DBA。
校订: @yingang | 繁體中文 by @afunTW | 完整贡献者列表
阅读:访问 https://ddia.vonng.com 阅读本书在线版本,或使用 Hugo / OINK 0.4.1 主题自行构建。在线版支持稳定图表编号与交叉引用、顺序阅读、整书打印、Markdown / llms.txt 输出和 EPUB 导出。
译序
不懂数据库的全栈工程师不是好架构师
—— 冯若航 / Vonng
现今,尤其是在互联网领域,大多数应用都属于数据密集型应用。本书从底层数据结构到顶层架构设计,将数据系统设计中的精髓娓娓道来。其中的宝贵经验无论是对架构师、DBA、还是后端工程师、甚至产品经理都会有帮助。
这是一本理论结合实践的书,书中很多问题,译者在实际场景中都曾遇到过,读来让人击节扼腕。如果能早点读到这本书,该少走多少弯路啊!
这也是一本深入浅出的书,讲述概念的来龙去脉而不是卖弄定义,介绍事物发展演化历程而不是事实堆砌,将复杂的概念讲述的浅显易懂,但又直击本质不失深度。每章最后的引用质量非常好,是深入学习各个主题的绝佳索引。
本书为数据系统的设计、实现、与评价提供了很好的概念框架。读完并理解本书内容后,读者可以轻松看破大多数的技术忽悠,与技术砖家撕起来虎虎生风🤣。
这是 2017 年译者读过最好的一本技术类书籍,这么好的书没有中文翻译,实在是遗憾。某不才,愿为先进技术文化的传播贡献一份力量。既可以深入学习有趣的技术主题,又可以锻炼中英文语言文字功底,何乐而不为?
前言
在我们的社会中,技术是一种强大的力量。数据、软件、通信可以用于坏的方面:不公平的阶级固化,损害公民权利,保护既得利益集团。但也可以用于好的方面:让底层人民发出自己的声音,让每个人都拥有机会,避免灾难。本书献给所有将技术用于善途的人们。
计算是一种流行文化,流行文化鄙视历史。流行文化关乎个体身份和参与感,但与合作无关。流行文化活在当下,也与过去和未来无关。我认为大部分(为了钱)编写代码的人就是这样的,他们不知道自己的文化来自哪里。
—— 阿兰・凯接受 Dobb 博士的杂志采访时(2012 年)
目录
法律声明
从原作者处得知,已经有简体中文的翻译计划,将于 2018 年末完成。购买地址
译者纯粹出于 学习目的 与 个人兴趣 翻译本书,不追求任何经济利益。
译者保留对此版本译文的署名权,其他权利以原作者和出版社的主张为准。
本译文只供学习研究参考之用,不得公开发行或用于商业用途,有能力阅读英文书籍者请购买正版支持,本书英文原版在 O'REILLY 平台上提供在线免费试预览。
贡献
- 全文校订 by @yingang
- 序言初翻修正 by @seagullbird
- 第一章语法标点校正 by @nevertiree
- 第六章部分校正 与第十章的初翻 by @MuAlex
- 第一部分前言,ch2 校正 by @jiajiadebug
- 词汇表、后记关于野猪的部分 by @cg-zhou
- 繁體中文版本与转换脚本 by @afunTW
- 多处翻译修正 by @songzhibin97 @MamaShip @FangYuan33
- 感谢所有作出贡献,提出意见的朋友们:
| ISSUE & Pull Requests | USER | Title |
|---|---|---|
| 386 | @uncle-lv | ch2: 优化一处翻译 |
| 384 | @PanggNOTlovebean | docs: 优化中文文档的措辞和表达 |
| 383 | @PanggNOTlovebean | docs: 修正 ch4 中的术语和表达错误 |
| 382 | @uncle-lv | ch1: 优化一处翻译 |
| 381 | @Max-Tortoise | ch4: 修正一处术语不完整问题 |
| 377 | @huang06 | 优化翻译术语 |
| 375 | @z-soulx | 对于是否100%全中文翻译的必要性讨论?个人-没必要100%,特别是“名词”,有原单词更加适合it人员 |
| 371 | @lewiszlw | CPU core -> CPU 核心 |
| 369 | @bbwang-gl | ch7: 可串行化快照隔离检测一个事务何时修改另一个事务的读取 |
| 368 | @yhao3 | 更新 zh-tw.py 与 zh-tw 内容 |
| 367 | @yhao3 | 修正拼写、格式和标点问题 |
| 366 | @yangshangde | ch8: 将“电源失败”改为“电源失效” |
| 365 | @xyohn | ch1: 优化“存储与计算分离”相关翻译 |
| 364 | @xyohn | ch1: 优化“存储与计算分离”相关翻译 |
| 363 | @xyohn | #362: 优化一处翻译 |
| 362 | @xyohn | ch1: 优化一处翻译 |
| 359 | @c25423 | ch10: 修正一处拼写错误 |
| 358 | @lewiszlw | ch4: 修正一处拼写错误 |
| 356 | @lewiszlw | ch2: 修正一处标点错误 |
| 355 | @duroey | ch12: 修正一处格式错误 |
| 354 | @justlorain | ch7: 修正一处参考链接 |
| 353 | @fantasyczl | ch3&9: 修正两处引用错误 |
| 352 | @fantasyczl | 支持输出为 EPUB 格式 |
| 349 | @xiyihan0 | ch1: 修正一处格式错误 |
| 348 | @omegaatt36 | ch3: 修正一处图像链接 |
| 346 | @Vermouth1995 | ch1: 优化一处翻译 |
| 343 | @kehao-chen | ch10: 优化一处翻译 |
| 341 | @YKIsTheBest | ch3: 优化两处翻译 |
| 340 | @YKIsTheBest | ch2: 优化多处翻译 |
| 338 | @YKIsTheBest | ch1: 优化一处翻译 |
| 335 | @kimi0230 | 修正一处繁体中文错误 |
| 334 | @soulrrrrr | ch2: 修正一处繁体中文错误 |
| 332 | @justlorain | ch5: 修正一处翻译错误 |
| 331 | @Lyianu | ch9: 更正几处拼写错误 |
| 330 | @Lyianu | ch7: 优化一处翻译 |
| 329 | @Lyianu | ch6: 指出一处翻译错误 |
| 328 | @justlorain | ch4: 更正一处翻译遗漏 |
| 326 | @liangGTY | ch1: 优化一处翻译 |
| 323 | @marvin263 | ch5: 优化一处翻译 |
| 322 | @marvin263 | ch8: 优化一处翻译 |
| 304 | @spike014 | ch11: 优化一处翻译 |
| 298 | @Makonike | ch11&12: 修正两处错误 |
| 284 | @WAangzE | ch4: 更正一处列表错误 |
| 283 | @WAangzE | ch3: 更正一处错别字 |
| 282 | @WAangzE | ch2: 更正一处公式问题 |
| 281 | @lyuxi99 | 更正多处内部链接错误 |
| 280 | @lyuxi99 | ch9: 更正内部链接错误 |
| 279 | @codexvn | ch9: 指出公式在 GitHub Pages 显示的问题 |
| 278 | @LJlkdskdjflsa | 发现了繁体中文版本中的错误翻译 |
| 275 | @117503445 | 更正 LICENSE 链接 |
| 274 | @uncle-lv | ch7: 修正错别字 |
| 273 | @quwang123 | ch7: 统一了 write skew 的翻译 |
| 271 | @Makonike | ch6: 统一了 rebalancing 的翻译 |
| 270 | @Ynjxsjmh | ch7: 修正不一致的翻译 |
| 263 | @zydmayday | ch5: 修正译文中的重复单词 |
| 260 | @haifeiWu | ch4: 修正部分不准确的翻译 |
| 258 | @bestgrc | ch3: 修正一处翻译错误 |
| 257 | @samwu4166 | ch8: 修正一处拼写错误 |
| 256 | @AlphaWang | ch7: 修正“可串行化”相关内容的多处翻译不当 |
| 255 | @AlphaWang | ch7: 修正“可重复读”相关内容的多处翻译不当 |
| 253 | @AlphaWang | ch7: 修正“读已提交”相关内容的多处翻译不当 |
| 246 | @derekwu0101 | ch3: 修正繁体中文的转译错误 |
| 245 | @skyran1278 | ch12: 修正繁体中文的转译错误 |
| 244 | @Axlgrep | ch9: 修正不通顺的翻译 |
| 242 | @lynkeib | ch9: 修正不通顺的翻译 |
| 241 | @lynkeib | ch8: 修正不正确的公式格式 |
| 240 | @leo-987 | ch9: 修正不通顺的翻译 |
| 239 | @BeBraveBeCurious | ch7: 修正不一致的翻译 |
| 237 | @zhangnew | ch3: 修正错误的图片链接 |
| 229 | @lis186 | 指出繁体中文的转译错误:复杂 |
| 226 | @chroming | ch1: 修正导航栏中的章节名称 |
| 220 | @skyran1278 | ch9: 修正线性一致的繁体中文翻译 |
| 194 | @BeBraveBeCurious | ch4: 修正错误的翻译 |
| 193 | @BeBraveBeCurious | ch4: 优化译文 |
| 192 | @BeBraveBeCurious | ch4: 修正不一致和不通顺的翻译 |
| 190 | @Pcrab | ch1: 修正不准确的翻译 |
| 187 | @narojay | ch9: 修正生硬的翻译 |
| 186 | @narojay | ch8: 修正错别字 |
| 185 | @leo-987 | 指出小标题跳转的问题 |
| 184 | @DavidZhiXing | ch10: 修正失效的网址 |
| 183 | @OneSizeFitsQuorum | ch8: 修正错别字 |
| 182 | @lroolle | 建议docsify的主题风格 |
| 181 | @YunfengGao | ch2: 修正翻译错误 |
| 180 | @skyran1278 | ch3: 指出繁体中文的转译错误 |
| 177 | @exzhawk | 支持 Github Pages 里的公式显示 |
| 176 | @haifeiWu | ch2: 语义网相关翻译更正 |
| 175 | @cwr31 | ch7: 不变式相关翻译更正 |
| 174 | @BeBraveBeCurious | README & preface: 更正不正确的中文用词和标点符号 |
| 173 | @ZvanYang | ch12: 修正不完整的翻译 |
| 171 | @ZvanYang | ch12: 修正重复的译文 |
| 169 | @ZvanYang | ch12: 更正不太通顺的翻译 |
| 166 | @bp4m4h94 | ch1: 发现错误的文献索引 |
| 164 | @longjiquan | preface: 更正错误的标点符号 |
| 163 | @llmmddCoder | ch1: 更正错误字 |
| 160 | @Zhayhp | ch2: 建议将 network model 翻译为网状模型 |
| 159 | @1ess | ch4: 更正错误字 |
| 157 | @ZvanYang | ch7: 更正不太通顺的翻译 |
| 155 | @ZvanYang | ch7: 更正不太通顺的翻译 |
| 153 | @DavidZhiXing | ch9: 修正缩略图的错别字 |
| 152 | @ZvanYang | ch7: 除重->去重 |
| 151 | @ZvanYang | ch5: 修订sibling相关的翻译 |
| 147 | @ZvanYang | ch5: 更正一处不准确的翻译 |
| 145 | @ui-HookeyChiang | 识别了当前简繁转译过程中处理不当的地方,暂通过转换脚本规避 |
| 144 | @secret4233 | ch7: 不翻译next-key locking |
| 143 | @imcheney | ch3: 更新残留的机翻段落 |
| 142 | @XIJINIAN | 建议去除段首的制表符 |
| 141 | @Flyraty | ch5: 发现一处错误格式的章节引用 |
| 140 | @Bowser1704 | ch5: 修正章节Summary中多处不通顺的翻译 |
| 139 | @Bowser1704 | ch2&ch3: 修正多处不通顺的或错误的翻译 |
| 137 | @fuxuemingzhu | ch5&ch6: 优化多处不通顺的或错误的翻译 |
| 134 | @fuxuemingzhu | ch4: 优化多处不通顺的或错误的翻译 |
| 133 | @fuxuemingzhu | ch3: 优化多处错误的或不通顺的翻译 |
| 132 | @fuxuemingzhu | ch3: 优化一处容易产生歧义的翻译 |
| 131 | @KAKXA | ch6: 修正两处错误的翻译 |
| 129 | @anaer | ch4: 修正两处强调文本和四处代码变量名称 |
| 128 | @meilin96 | ch5: 修正一处错误的引用 |
| 126 | @cwr31 | ch10: 修正一处错误的翻译(功能 -> 函数) |
| 125 | @dch1228 | ch2: 优化 how best 的翻译(如何以最佳方式) |
| 123 | @yingang | translation updates (chapter 9, TOC in readme, glossary, etc.) |
| 121 | @yingang | translation updates (chapter 5 to chapter 8) |
| 120 | @jiong-han | Typo fix: 呲之以鼻 -> 嗤之以鼻 |
| 119 | @cclauss | Streamline file operations in convert() |
| 118 | @yingang | translation updates (chapter 2 to chapter 4) |
| 117 | @feeeei | 统一每章的标题格式 |
| 115 | @NageNalock | 第七章病句修改: 重复词语 |
| 114 | @Sunt-ing | Update README.md: correct the book name |
| 113 | @lpxxn | 修改语句 |
| 112 | @ibyte2011 | Update ch9.md |
| 110 | @lpxxn | 读已写入数据 |
| 107 | @abbychau | 單調鐘和好死还是赖活着 |
| 106 | @enochii | typo in ch2: fix braces typo |
| 105 | @LiminCode | Chronicle translation error |
| 104 | @Sunt-ing | several advice for better translation |
| 103 | @Sunt-ing | typo in ch4: should be 完成 rather than 完全 |
| 102 | @Sunt-ing | ch4: better-translation: 扼杀 → 破坏 |
| 101 | @Sunt-ing | typo in Ch4: should be "改变" rathr than "盖面" |
| 100 | @LiminCode | fix missing translation |
| 99 | @mrdrivingduck | ch6: fix the word rebalancing |
| 98 | @jacklightChen | fix ch7.md: fix wrong references |
| 97 | @jenac | 96 |
| 96 | @PragmaTwice | ch2: fix typo about 'may or may not be' |
| 95 | @EvanMu96 | fix translation of "the battle cry" in ch5 |
| 94 | @kemingy | ch6: fix markdown and punctuations |
| 93 | @kemingy | ch5: fix markdown and some typos |
| 92 | @Gilbert1024 | Merge pull request #1 from Vonng/master |
| 88 | @kemingy | fix typo for ch1, ch2, ch3, ch4 |
| 87 | @wynn5a | Update ch3.md |
| 86 | @northmorn | Update ch1.md |
| 85 | @sunbuhui | fix ch2.md: fix ch2 ambiguous translation |
| 84 | @ganler | Fix translation: use up |
| 83 | @afunTW | Using OpenCC to convert from zh-cn to zh-tw |
| 82 | @kangni | fix gitbook url |
| 78 | @hanyu2 | Fix unappropriated translation |
| 77 | @Ozarklake | fix typo |
| 75 | @2997ms | Fix typo |
| 74 | @2997ms | Update ch9.md |
| 70 | @2997ms | Update ch7.md |
| 67 | @jiajiadebug | fix issues in ch2 - ch9 and glossary |
| 66 | @blindpirate | Fix typo |
| 63 | @haifeiWu | Update ch10.md |
| 62 | @ych | fix ch1.md typesetting problem |
| 61 | @xianlaioy | docs:钟-->种,去掉ou |
| 60 | @Zombo1296 | 否则 -> 或者 |
| 59 | @AlexanderMisel | 呼叫->调用,显着->显著 |
| 58 | @ibyte2011 | Update ch8.md |
| 55 | @saintube | ch8: 修改链接错误 |
| 54 | @Panmax | Update ch2.md |
| 53 | @ibyte2011 | Update ch9.md |
| 52 | @hecenjie | Update ch1.md |
| 51 | @latavin243 | fix 修正ch3 ch4几处翻译 |
| 50 | @AlexZFX | 几个疏漏和格式错误 |
| 49 | @haifeiWu | Update ch1.md |
| 48 | @scaugrated | fix typo |
| 47 | @lzwill | Fixed typos in ch2 |
| 45 | @zenuo | 删除一个多余的右括号 |
| 44 | @akxxsb | 修正第七章底部链接错误 |
| 43 | @baijinping | "更假简单"->"更加简单" |
| 42 | @tisonkun | 修复 ch1 中的无序列表格式 |
| 38 | @renjie-c | 纠正多处的翻译小错误 |
| 37 | @tankilo | fix translation mistakes in ch4.md |
| 36 | @wwek | 1.修复多个链接错误 2.名词优化修订 3.错误修订 |
| 35 | @wwek | fix ch7.md to ch8.md link error |
| 34 | @wwek | Merge pull request #1 from Vonng/master |
| 33 | @wwek | fix part-ii.md link error |
| 32 | @JCYoky | Update ch2.md |
| 31 | @elsonLee | Update ch7.md |
| 26 | @yjhmelody | 修复一些明显错误 |
| 25 | @lqbilbo | 修复链接错误 |
| 24 | @artiship | 修改词语顺序 |
| 23 | @artiship | 修正错别字 |
| 22 | @artiship | 纠正翻译错误 |
| 21 | @zhtisi | 修正目录和本章标题不符的情况 |
| 20 | @rentiansheng | Update ch7.md |
| 19 | @LHRchina | 修复语句小bug |
| 16 | @MuAlex | Master |
| 15 | @cg-zhou | Update translation progress |
| 14 | @cg-zhou | Translate glossary |
| 13 | @cg-zhou | 详细修改了后记中和印度野猪相关的描述 |
| 12 | @ibyte2011 | 修改了部分翻译 |
| 11 | @jiajiadebug | ch2 100% |
| 10 | @jiajiadebug | ch2 20% |
| 9 | @jiajiadebug | Preface, ch1, part-i translation minor fixes |
| 7 | @MuAlex | Ch6 translation pull request |
| 6 | @MuAlex | Ch6 change version1 |
| 5 | @nevertiree | Chapter 01语法微调 |
| 2 | @seagullbird | 序言初翻 |
许可证
本项目采用 CC-BY 4.0 许可证,您可以在这里找到完整说明:
File: content/zh/_index.md
title: 设计数据密集型应用(第二版) linkTitle: DDIA type: book layout: landing book_kind: book outputs: [HTML, print, markdown, LLMS] search_exclude: true cascade: type: book breadcrumbs: false breadcrumbs: false
- 作者:Martin Kleppmann
- 译者:冯若航 / Vonng 与 DDIA 中文翻译贡献者
- 原作:Designing Data-Intensive Applications, Second Edition
不懂数据库的全栈工程师不是好架构师。
—— 冯若航 / Vonng
本书从数据模型与存储引擎出发,逐步进入复制、分片、事务、一致性、批处理与流处理,最后回到技术责任与社会影响。它关注的不是某一款产品,而是设计、实现和评价现代数据系统时可以反复使用的思维框架。
第二版中文译文按原书十四章持续校订。在线版本提供章节树导航、稳定图表编号与交叉引用、全文搜索、顺序阅读、整书打印以及适合工具读取的 Markdown / llms.txt 输出。
阅读本书
出版与授权说明
本译文仅供学习研究参考,不追求任何经济利益,不得公开传播发行或用于商业用途。译者保留译文署名权,其他权利以原作者与出版社的主张为准。有能力阅读英文的读者请购买正版支持作者与出版社。
翻译校订与完整贡献记录见贡献者页面;发现内容或排版问题,可前往 GitHub 提交反馈。
File: content/zh/ch1.md
title: 数据系统架构中的权衡 book_kind: chapter book_number: "1" book_part: I weight: 101 breadcrumbs: false
没有解决方案,只有权衡取舍。[…] 你只能尽力做出最佳权衡,也只能期望如此。
Thomas Sowell,与 Fred Barnes 的访谈(2005 年)
如今,数据是许多应用开发的核心。随着 Web 应用、移动应用、软件即服务(SaaS)和云服务的普及,在共享的服务器端数据基础设施中存储众多用户的数据,已经司空见惯。用户活动、业务交易、设备和传感器产生的数据都需要保存下来,以供分析。用户与应用交互时,既会读取已存储的数据,也会产生更多数据。
少量数据可以在单台机器上存储和处理,通常比较容易应付。然而,随着数据量或查询速率增长,数据就需要分布到多台机器上,由此带来许多挑战。应用需求变得更加复杂之后,把所有数据放在一个系统里也不再够用,往往需要组合多个能力各异的存储或处理系统。
如果数据管理是开发应用时面临的主要挑战之一,我们就称这种应用为 数据密集型 应用 [^1]。计算密集型 系统的难点在于如何将某项极其庞大的计算并行化;而对数据密集型应用来说,我们通常更关心如何存储和处理海量数据、如何管理数据变更、如何在故障和并发面前保证一致性,以及如何维持服务的高可用性。
这类应用通常由一些标准构件搭建而成,它们提供各种常用功能。例如,许多应用都需要:
- 存储数据,以便自己或其他应用日后能够再次找到(数据库)
- 记住开销昂贵的操作结果,加快读取速度(缓存)
- 允许用户按关键字搜索数据,或以各种方式过滤数据(搜索索引)
- 在事件和数据变更发生后立即处理(流处理)
- 定期处理累积的大批量数据(批处理)
构建应用时,我们通常会选用几个软件系统或服务(例如数据库和 API),再用应用代码把它们拼接起来。如果你的用途恰好是这些数据系统原本就为之设计的,整个过程可能相当容易。
但是,随着应用要实现的目标越来越高,挑战也随之而来。数据库系统种类繁多,特性各异,适用目的也各不相同——该选择哪一种?缓存有不同的做法,搜索索引也有多种构建方式,诸如此类——该如何权衡?你必须判断哪些工具和方法最适合手头的任务;而当一项工作无法由单个工具独立完成时,把多个工具组合起来也可能很困难。
本书将帮助你决定采用哪些技术,以及如何组合这些技术。正如你将看到的,并不存在一种从根本上优于其他方案的做法;每种方案都有利有弊。本书会教你提出恰当的问题来评估和比较数据系统,从而找出最能满足特定应用需求的方案。
我们的旅程从当今组织使用数据的一些典型方式开始。这里的许多思想源自 企业软件,也就是大型组织(例如大公司和政府机构)的软件需求与工程实践。因为在过去,只有大型组织才拥有如此庞大的数据量,需要复杂的技术方案;只要数据量足够小,用电子表格保存就行了!不过近年来,小公司和初创企业管理海量数据、构建数据密集型系统,也已变得十分普遍。
数据系统的一项关键挑战是:不同的人需要用数据做截然不同的事情。在一家公司里,你和你的团队有自己的一套优先事项;另一个团队即使在处理同一份数据,也可能有完全不同的目标。而且,这些目标未必得到明确表达,因而很容易造成误解,并引发对正确方案的争论。
为了帮助你了解有哪些选择,本章将比较几组相对的概念,并探讨它们之间的权衡:
- 事务型系统与分析型系统有何区别(“分析型与事务型系统”);
- 云服务与自托管系统各有哪些利弊(“云服务与自托管”);
- 何时应当从单节点系统转向分布式系统(“分布式与单节点系统”);以及
- 如何在业务需求与用户权利之间取得平衡(“数据系统、法律与社会”)。
此外,本章还会提供阅读本书其余部分所需的术语。
[!TIP] 术语:前端与后端
本书讨论的许多内容都与 后端开发 有关。以 Web 应用为例,运行在浏览器中的客户端代码称为 前端,处理用户请求的服务器端代码则称为 后端。移动应用与前端相似:它们负责提供用户界面,并且常常经由互联网与服务器端后端通信。前端有时也会在用户设备上管理本地数据 [^2],但数据基础设施面临的最大挑战往往在后端:前端只需处理一个用户的数据,而后端要代表 所有 用户管理数据。
后端服务通常可以通过 HTTP(有时是 WebSocket)访问。它一般由一些应用代码组成:这些代码在一个或多个数据库中读写数据,有时也与缓存、消息队列等其他数据系统交互;这些系统可以统称为 数据基础设施。应用代码通常是 无状态 的,也就是说,处理完一个 HTTP 请求后,它就会忘掉有关该请求的一切。任何需要在请求之间持久保存的信息,都必须存放在客户端或服务器端的数据基础设施中。
分析型与事务型系统 {#sec_introduction_analytics}
如果你在企业中从事数据系统工作,很可能会遇到几类与数据打交道的人。第一类是 后端工程师,负责构建处理数据读取和更新请求的服务。这些服务通常直接面向外部用户,或通过其他服务间接为外部用户提供功能(参见“微服务与无服务器”);有时也只供组织内其他部门使用。
除了管理后端服务的团队,通常还有两类人需要访问组织的数据:业务分析师 根据组织的活动生成报表,帮助管理层做出更好的决策,这就是 商业智能(BI);数据科学家 则从数据中寻找新的洞见,或者利用数据分析和机器学习/AI 构建面向用户的产品功能,例如电商网站上“购买了 X 的人也购买了 Y”的推荐、风险评分或垃圾邮件过滤等预测分析,以及搜索结果排名。
业务分析师和数据科学家使用的工具不同,工作方式也不同,但仍有一些共同之处:两者都要进行 分析,也就是查看用户和后端服务产生的数据,但通常不会修改这些数据(纠正错误或许除外)。他们可能会创建衍生数据集,以某种方式处理原始数据。由此形成了两类相互分离的系统——本书将始终沿用这种区分:
- 事务型系统 由创建数据的后端服务和数据基础设施组成,例如直接为外部用户提供服务。应用代码根据用户执行的操作,读取和修改数据库中的数据。
- 分析型系统 服务于业务分析师和数据科学家。它们保存事务型系统数据的只读副本,并针对分析所需的数据处理方式进行优化。
正如下一节将要说明的,事务型系统与分析型系统往往有充分的理由彼此分离。随着这两类系统日趋成熟,又出现了两个专业角色:数据工程师 与 分析工程师。数据工程师懂得如何集成事务型系统和分析型系统,并对组织的数据基础设施承担更广泛的责任 [^3]。分析工程师则对数据进行建模和转换,使其更便于组织内的业务分析师和数据科学家使用 [^4]。
许多工程师专攻事务型或分析型系统中的一类。不过,本书会同时涵盖两者,因为它们都在组织的数据生命周期中扮演重要角色。我们将深入探讨为内部和外部用户提供服务所需的数据基础设施,帮助你更好地与分界线另一侧的同事合作。
事务处理与分析的特征 {#sec_introduction_oltp}
在商业数据处理的早期,每次写入数据库通常都对应一笔 商业交易:完成一笔销售、向供应商下订单、发放员工工资,等等。后来,数据库的应用扩展到不涉及金钱往来的领域,事务 这个名称却沿用下来,用来指构成一个逻辑单元的一组读写操作。
[!NOTE]
第 8 章会详细探讨“事务”的含义。本章则宽泛地用这个词指代低延迟的读写操作。
尽管数据库开始处理形形色色的数据——社交媒体帖子、游戏中的操作、地址簿联系人,等等——基本访问模式仍与处理商业交易相似。事务型系统通常按某个键查找少量记录(称为 点查询),再根据用户输入插入、更新或删除记录。由于这些应用具有交互性,这种访问模式称为 联机事务处理(OLTP)。
与此同时,数据库也越来越多地用于分析,而分析的访问模式与 OLTP 大相径庭。分析查询通常会扫描海量记录,计算计数、总和或平均值等聚合统计量,而不是把一条条记录返回给用户。例如,连锁超市的业务分析师可能想回答下面的问题:
- 我们每家商店在一月份的总收入是多少?
- 在我们最近的促销期间,我们比平时多卖出了多少香蕉?
- 哪个品牌的婴儿食品最常与 X 品牌尿布一起购买?
这类查询产生的报表是商业智能的重要依据,可以帮助管理层决定下一步行动。为了把这种数据库使用模式与事务处理区分开来,人们称之为 联机分析处理(OLAP)[^5]。OLTP 与分析之间的界线并不总是泾渭分明,但{{< xref tbl="1-1" page="/ch1" anchor="tab_oltp_vs_olap" >}}表 1-1{{< /xref >}}列出了二者的一些典型特征。
{{< tbl num="1-1" id="tab_oltp_vs_olap" caption="事务型系统与分析型系统的特征比较" >}}
| 属性 | 事务型系统(OLTP) | 分析型系统(OLAP) |
|---|---|---|
| 主要读取模式 | 点查询(按键读取单条记录) | 聚合大量记录 |
| 主要写入模式 | 创建、更新和删除单条记录 | 批量导入(ETL)或事件流 |
| 人类用户示例 | Web 或移动应用的最终用户 | 为决策提供支持的内部分析师 |
| 机器用途示例 | 检查某项操作是否获得授权 | 检测欺诈或滥用模式 |
| 查询类型 | 由应用预先定义的一组固定查询 | 分析师可以任意查询 |
| 数据表示的内容 | 数据的最新状态(当前时点) | 一段时间内发生的事件历史 |
| 数据集规模 | GB 至 TB | TB 至 PB |
| {{< /tbl >}} |
[!NOTE]
OLAP 中的 联机(online)含义并不明确;它大概是指查询并非只用于预定义报表,分析师还会以交互方式使用 OLAP 系统进行探索性查询。
事务型系统一般不允许用户自行编写 SQL 查询并提交给数据库执行,否则用户可能读取或修改自己无权访问的数据。用户也可能写出执行开销很高的查询,影响其他用户使用数据库。因此,OLTP 系统主要执行写在应用代码中的一组固定查询,只在维护或排查故障时偶尔运行一次性自定义查询。分析数据库则不同:它通常允许用户自由手写任意 SQL 查询,也可以通过 Tableau、Looker 或 Microsoft Power BI 等数据可视化或仪表盘工具自动生成查询。
还有一类系统专为分析型负载(即聚合大量记录的查询)而设计,却嵌入在面向用户的产品中。这类用途称为 产品分析 或 实时分析,为此设计的系统包括 Pinot、Druid 和 ClickHouse [^6]。
数据仓库 {#sec_introduction_dwh}
起初,同一套数据库既用于事务处理,也用于分析查询。事实证明,SQL 在这方面非常灵活,两类查询都能胜任。不过到了 20 世纪 80 年代末和 90 年代初,企业开始不再使用 OLTP 系统进行分析,转而在一个独立的数据库系统上运行分析查询。这个独立的数据库称为 数据仓库。
一家大型企业可能拥有几十乃至上百个联机事务处理系统:支撑面向客户的网站,控制实体店的销售终端(收银系统),跟踪仓库库存,规划车辆路线,管理供应商和员工,以及执行许多其他任务。每个系统都很复杂,都需要专门的团队维护,因此最终大多彼此独立运行。
通常不宜让业务分析师和数据科学家直接查询这些 OLTP 系统,原因有以下几点:
- 所需数据可能散落在多个事务型系统中,很难通过一条查询组合这些数据集;这个问题称为 数据孤岛。
- 适合 OLTP 的模式和数据布局不太适合分析(参见“星型与雪花型:分析模式”)。
- 分析查询的开销可能很高;在 OLTP 数据库上运行它们,会影响其他用户的性能。
- 出于安全或合规方面的考虑,OLTP 系统可能位于一个不允许用户直接访问的独立网络中。
相比之下,数据仓库 是一个独立的数据库,分析师可以尽情查询,而不会影响 OLTP 系统的运行 [^7]。正如第 4 章将要介绍的,数据仓库的存储方式往往与 OLTP 数据库截然不同,以便针对分析中常见的查询类型进行优化。
数据仓库保存着企业各个 OLTP 系统中数据的只读副本。数据先从 OLTP 数据库中抽取出来(通过定期转储或连续的更新流),再转换成便于分析的模式并加以清理,最后载入数据仓库。把数据送入数据仓库的这一过程称为 提取—转换—加载(ETL),如{{< xref fig="1-1" page="/ch1" anchor="fig_dwh_etl" >}}图 1-1{{< /xref >}}所示。有时也会对调 转换 与 加载 两步的顺序,即先加载数据,再在数据仓库内转换;这便成了 ELT。
{{< fig num="1-1" id="fig_dwh_etl" src="/fig/ddia_0101.png" caption="将数据通过 ETL 导入数据仓库的简化示意图。" class="ddia-figure ddia-figure--standard" width="2953" height="2099" />}}
有时,ETL 流程的数据源是外部 SaaS 产品,例如客户关系管理(CRM)、电子邮件营销或信用卡处理系统。此时,你无法直接访问原始数据库,只能通过软件供应商的 API 获取数据。把这些外部系统的数据导入自己的数据仓库,就能进行 SaaS API 本身无法支持的分析。针对 SaaS API 的 ETL,通常由 Fivetran、Singer 或 AirByte 等专业数据连接器服务实现。
有些数据库系统提供 混合事务/分析处理(HTAP),目标是在单个系统中同时支持 OLTP 和分析,无需通过 ETL 把数据从一个系统送入另一个系统 [^8] [^9]。然而,许多 HTAP 系统内部仍由一个 OLTP 系统和一个独立的分析系统耦合而成,只是共同隐藏在统一接口之后。因此,要理解这类系统的工作原理,二者的区别依然重要。
此外,即使已有 HTAP,由于事务型系统和分析型系统的目标与需求不同,把它们分开依然很常见。特别是,每个事务型系统各自拥有数据库通常被视为良好实践(参见“微服务与无服务器”),这样可能产生数百个独立的事务型数据库;而企业通常只设一个数据仓库,以便业务分析师在一条查询中组合多个事务型系统的数据。
因此,HTAP 并不能取代数据仓库。它适合的是另一类场景:同一个应用既要运行扫描大量数据行的分析查询,又要低延迟地读取和更新单条记录。例如,欺诈检测就可能具有这样的工作负载 [^10]。
事务型系统与分析型系统的分离,体现了一种更广泛的趋势:随着工作负载要求越来越高,系统也日益专门化,针对特定负载进行优化。通用系统应付少量数据绰绰有余;但规模越大,系统往往越专门化 [^11]。
从数据仓库到数据湖 {#from-data-warehouse-to-data-lake}
数据仓库通常采用 关系 数据模型,并通过 SQL 查询(参见第 3 章),有时还会配合专门的商业智能软件。这种模型很适合业务分析师所需的查询,却不太适合数据科学家的需求;他们可能需要完成下面的工作:
- 把数据转换成适合训练机器学习模型的形式。这通常要把数据库表中的行列转换为数值向量或矩阵,其中的数值称为 特征。以尽可能提高训练后模型性能的方式完成这种转换,称为 特征工程;它通常需要编写难以用 SQL 表达的定制代码。
- 对文本数据(例如商品评论)应用自然语言处理技术,尝试从中提取结构化信息(例如作者表达的情绪,或提到了哪些主题)。同样,他们也可能要用计算机视觉技术从照片中提取结构化信息。
尽管人们一直尝试为 SQL 数据模型加入机器学习算子 [^12],也在关系模型的基础上构建高效的机器学习系统 [^13],许多数据科学家仍不愿在数据仓库这样的关系数据库中工作。他们往往更喜欢 pandas、scikit-learn 等 Python 数据分析库,R 等统计分析语言,以及 Spark 等分布式分析框架 [^14]。我们将在“数据框、矩阵与数组”中进一步讨论这些工具。
因此,组织需要以适合数据科学家使用的形式提供数据。解决方案是 数据湖:一个集中的数据存储库,保存一切可能对分析有用的数据副本,这些数据通过 ETL 流程从事务型系统取得。数据湖与数据仓库的区别在于,它只保存文件,并不强制规定文件格式或数据模型。数据湖中的文件可以是一批数据库记录,以 Avro 或 Parquet 等文件格式编码(参见第 5 章);也完全可以是文本、图像、视频、传感器读数、稀疏矩阵、特征向量、基因组序列,或任何其他类型的数据 [^15]。数据湖不仅更加灵活,而且往往比关系数据存储更便宜,因为它可以使用对象存储等廉价通用的文件存储(参见“云原生系统架构”)。
ETL 流程已经泛化为 数据管道;在某些情况下,数据湖成为事务型系统通往数据仓库途中的一站。数据湖以事务型系统产生的“原始”形态保存数据,不把它们转换成关系数据仓库的模式。这种做法的好处是,每个数据消费者都可以把原始数据转换成最适合自己需求的形式。它有一个诙谐的名字——寿司原则:“原始数据更好”[^16]。
除了把数据从数据湖载入独立的数据仓库,也可以直接针对数据湖中的文件运行典型的数据仓库工作负载(SQL 查询和业务分析),并与数据科学和机器学习工作负载并存。这种架构称为 数据湖仓;它需要在数据湖的文件存储之上增加查询执行引擎和元数据层(例如模式管理)[^17]。
Apache Hive、Spark SQL、Presto 和 Trino 都采用了这种方法。
超越数据湖 {#beyond-the-data-lake}
随着分析实践日益成熟,组织也越来越重视分析系统与数据管道的管理和运维,例如 DataOps 宣言就体现了这种趋势 [^18]。其中包括治理、隐私,以及遵守 GDPR、CCPA 等法规的问题;我们将在“数据系统、法律与社会”和“立法与自律”中讨论这些问题。
此外,分析数据的提供形式越来越多,不仅包括文件和关系表,还包括事件流(参见第 12 章)。采用基于文件的分析时,可以定期(例如每天)重新运行分析,以响应数据的变化;流处理则能让分析系统快得多,通常在几秒内便对事件作出响应。具体是否值得采用流处理,要看应用对时效性的要求。例如,它可以用来识别并阻止潜在的欺诈或滥用活动。
有时,分析系统的输出还会提供给事务型系统,这个过程有时称为 反向 ETL [^19]。例如,在分析系统中训练好的机器学习模型可以部署到生产环境,向最终用户生成“购买了 X 的人也购买了 Y”之类的推荐。分析系统中这类投入实际应用的输出也称为 数据产品 [^20]。机器学习模型可以借助 TFX、Kubeflow 或 MLflow 等专用工具部署到事务型系统。
权威记录系统与衍生数据 {#sec_introduction_derived}
除了区分事务型系统与分析型系统,本书还区分 权威记录系统 与 衍生数据系统。这组术语很有用,可以帮助你理清数据在系统中的流向:
权威记录系统
: 权威记录系统也称 权威数据源,保存某类数据的权威或 规范 版本。新数据到来时,例如用户输入,首先写入这里。每项事实只表示一次(通常采用 规范化 表示;参见“规范化、反规范化与连接”)。如果其他系统与权威记录系统的数据不一致,那么按照定义,应以权威记录系统中的值为准。
衍生数据系统
: 衍生系统中的数据,是以另一个系统中的现有数据为基础,经过某种转换或处理而得到的结果。衍生数据即使丢失,也可以从原始数据源重新创建。缓存就是一个典型例子:若数据在缓存中,便可直接返回;若缓存中没有所需数据,则可以退回底层数据库读取。反规范化值、索引、物化视图、转换后的数据表示,以及在数据集上训练的模型,也都属于这一类。
从技术上讲,衍生数据是 冗余 的,因为它复制了现有信息。但是,要让读查询获得良好性能,这种冗余往往不可或缺。你可以从同一个数据源衍生出多个不同的数据集,从不同“视角”观察数据。
分析型系统通常属于衍生数据系统,因为它们消费的是在别处创建的数据。事务型服务则可能同时包含权威记录系统和衍生数据系统:权威记录系统是数据首先写入的主数据库,衍生数据系统则是加快常见读取操作的索引和缓存,尤其适用于权威记录系统无法高效回答的查询。
大多数数据库、存储引擎和查询语言,本身并不天然是权威记录系统或衍生系统。数据库只是工具,如何使用由你决定。一个系统究竟属于哪一类,取决于它在应用中的用法,而不是采用了什么工具。明确哪些数据衍生自哪些其他数据,可以让原本令人困惑的系统架构变得清晰。
如果一个系统的数据衍生自另一个系统,那么每当权威记录系统中的原始数据发生变化,就需要有相应流程更新衍生数据。遗憾的是,许多数据库在设计时都假定应用只会使用这一个数据库,因而很难集成多个系统并传播这类更新。我们将在“数据集成”中讨论 数据集成 的各种方法;借助这些方法,可以组合多个数据系统,完成单个系统无法独力完成的任务。
至此,我们对分析与事务处理的比较告一段落。下一节要讨论的另一项权衡,你可能已经见过许多人反复争论。
云服务与自托管 {#sec_introduction_cloud}
无论组织要做什么,最先遇到的问题之一总是:应当由内部完成,还是外包出去?应当自建,还是购买?
归根结底,这取决于业务的优先事项。管理学中通常认为,属于组织核心能力或竞争优势的事情应当在内部完成;非核心、例行或司空见惯的事情则应交给供应商 [^21]。举一个极端的例子:大多数公司都不会自行发电(能源公司除外,这里也不考虑应急备用电源),因为从电网购电更加便宜。
对软件来说,有两个重要决定:由谁来构建,又由谁来部署。两项工作都可以按不同程度外包,从而形成{{< xref fig="1-2" page="/ch1" anchor="fig_cloud_spectrum" >}}图 1-2{{< /xref >}}所示的一条连续谱。一个极端是完全定制的软件,由你自行编写并在内部运行;另一个极端是广泛使用的云服务或软件即服务(SaaS)产品,由外部供应商开发和运维,你只能通过 Web 界面或 API 使用。
{{< fig num="1-2" id="fig_cloud_spectrum" src="/fig/ddia_0102.png" caption="软件类型及其运维方式的连续谱。" class="ddia-figure ddia-figure--panorama" width="1772" height="392" />}}
这条连续谱的中间,是由你 自托管 的现成软件(可以是开源软件,也可以是商业软件),也就是由你亲自部署。例如,下载 MySQL 并安装到一台由你掌控的服务器上。这台服务器可以是你自己的硬件——通常称为 本地部署,即使它实际位于租用的数据中心机架里,并不真的在你的自有场所——也可以是云中的虚拟机,即 基础设施即服务(IaaS)。这条连续谱上还有更多中间位置,例如采用开源软件,但运行自己修改过的版本。
与这条连续谱相互独立的另一个问题是:无论在云端还是本地,究竟要 如何 部署服务,例如是否采用 Kubernetes 之类的编排框架。不过,部署工具的选择不在本书讨论范围内,因为还有其他因素对数据系统架构的影响更大。
云服务的利弊 {#sec_introduction_cloud_tradeoffs}
使用云服务而不是自行运行同类软件,本质上就是把软件的运维外包给云服务商。采用云服务既有充分的支持理由,也有充分的反对理由。云服务商声称,与自建基础设施相比,使用它们的服务能够节省时间和金钱,还能让你行动得更快。
云服务究竟是否比自托管更便宜、更省事,很大程度上取决于你掌握的技能和系统承受的工作负载。如果你已经熟悉所需系统的部署和运维,而且负载相当容易预测(也就是所需机器数量不会剧烈波动),那么购买自己的机器并自行运行软件,往往更加便宜 [^22] [^23]。
反过来,如果你需要一个自己还不会部署和运维的系统,那么采用云服务,通常比从头学习如何自行管理更加容易、快捷。如果还必须专门招聘和培训人员来维护、运维这个系统,成本可能会非常高。使用云服务时仍然需要运维团队(参见“云时代的运维”),但把基础系统管理外包出去,可以让团队专注于更高层次的问题。
把系统运维外包给专门经营这项服务的公司,也可能得到更好的服务,因为供应商在服务众多客户的过程中积累了丰富的运维经验。另一方面,如果由你自行运维,就能针对自己的特定工作负载配置和调优服务;云服务商不太可能愿意为你进行这样的定制。
如果系统负载随时间大幅波动,云服务尤其有价值。假如机器按峰值负载配置,但计算资源在大多数时候都处于闲置状态,系统的成本效益就会很差。在这种情况下,云服务的优势在于,可以更加容易地随着需求变化增加或减少计算资源。
例如,分析型系统的负载通常变化极大:要快速执行一项大型分析查询,需要同时动用大量计算资源;但查询完成后,这些资源就会闲置,直到用户发出下一项查询。预定义查询(例如日报)可以排队并调度执行,从而平滑负载;但对交互式查询而言,越是希望迅速得到结果,工作负载的波动就越大。如果数据集庞大到必须投入大量计算资源才能快速查询,使用云服务可以节省成本,因为闲置资源可以归还服务商,而不必任其空置。数据集较小时,这种差别就没那么显著。
云服务最大的缺点是你无法掌控它:
- 如果服务缺少你需要的功能,你只能客气地询问供应商是否愿意添加;通常无法自己动手实现。
- 如果服务宕机,你只能等它恢复。
- 如果你的某种使用方式触发了缺陷或性能问题,诊断起来会非常困难。对于自行运行的软件,你可以从操作系统获取性能指标和调试信息,从而了解它的行为,还可以查看服务器日志;但服务由供应商托管时,你通常无法接触这些内部信息。
- 如果服务停止运营、价格高到无法接受,或供应商以你不喜欢的方式改动产品,你也只能任其摆布——继续运行旧版本通常不可行,因此只能被迫迁移到其他服务 [^24]。如果存在提供兼容 API 的替代服务,这种风险会小一些;但是许多云服务并没有标准 API,切换成本很高,因而产生了供应商锁定问题。
- 你必须相信云服务商能够保护数据安全,这会增加遵守隐私和安全法规的难度。
尽管存在这些风险,组织在云服务之上构建新应用,或者采用只在系统某些部分使用云服务的混合方案,还是越来越普遍。不过,云服务不会取代所有内部数据系统:许多老系统诞生在云计算之前;只要某项服务有现有云服务无法满足的特殊需求,内部系统仍然不可或缺。例如,高频交易等对延迟极其敏感的应用,就需要完全掌控硬件。
云原生系统架构 {#sec_introduction_cloud_native}
云计算不仅采用了不同的经济模式——订阅服务,而不是购买硬件和软件许可证,再自行运行软件——它的兴起也从技术层面深刻影响了数据系统的实现方式。云原生 一词用来描述专为利用云服务优势而设计的架构。
原则上,几乎任何可以自托管的软件都能以云服务的形式提供;事实上,许多流行的数据系统如今都有相应的托管服务。然而,从一开始就按云原生思路设计的系统已经展现出若干优势:在相同硬件上性能更好,故障恢复更快,能够迅速调整计算资源以匹配负载,并且可以支持更大的数据集 [^25] [^26] [^27]。{{< xref tbl="1-2" page="/ch1" anchor="tab_cloud_native_dbs" >}}表 1-2{{< /xref >}}列出了两类系统的一些例子。
{{< tbl num="1-2" id="tab_cloud_native_dbs" caption="自托管数据库系统与云原生数据库系统示例" >}}
| 类别 | 自托管系统 | 云原生系统 |
|---|---|---|
| 事务型/OLTP | MySQL、PostgreSQL、MongoDB | AWS Aurora [^25]、Azure SQL DB Hyperscale [^26]、Google Cloud Spanner |
| 分析型/OLAP | Teradata、ClickHouse、Spark | Snowflake [^27]、Google BigQuery、Azure Synapse Analytics |
| {{< /tbl >}} |
云服务的分层 {#layering-of-cloud-services}
许多自托管数据系统对运行环境的要求非常简单:在 Linux 或 Windows 等常规操作系统上运行,把数据存成文件系统中的文件,并通过 TCP/IP 等标准网络协议通信。少数系统依赖 GPU(用于机器学习)或 RDMA 网卡等特殊硬件,但总体而言,自托管软件使用的都是十分通用的计算资源:CPU、内存、文件系统和 IP 网络。
在云中,这类软件可以运行在基础设施即服务(IaaS)环境里,使用一台或多台虚拟机(也称为 实例),每台实例分配一定数量的 CPU、内存、磁盘和网络带宽。与物理机器相比,云实例的开通速度更快,可选规格也更多;但除此之外,它们与传统计算机相似:你可以随意运行任何软件,也要自行负责管理。
与之相对,云原生服务的关键思想是:不仅使用操作系统管理的计算资源,还要在低层云服务的基础上构建更高层的服务。例如:
- Amazon S3、Azure Blob Storage 和 Cloudflare R2 等 对象存储 服务用于保存大型文件。它们的 API 比普通文件系统更受限,只提供基本的文件读写;但好处是隐藏了底层物理机器。服务会自动把数据分布到许多机器上,你不必担心其中某台机器的磁盘空间耗尽。即使某些机器或其磁盘彻底损坏,数据也不会丢失。
- 许多其他服务又建立在对象存储和其他云服务之上。例如,Snowflake 是一种云端分析数据库(数据仓库),依靠 S3 存储数据 [^27];还有一些服务进一步构建在 Snowflake 之上。
计算机领域的抽象一向如此:该选择哪一层,并没有唯一正确的答案。一般来说,层次越高的抽象,往往越面向特定用例。如果你的需求恰好符合某个高层系统的设计场景,那么直接使用现成系统,通常比自己用低层系统搭建省心得多,也足以满足需要。反过来,如果没有任何高层系统符合需求,那就只能用低层组件自行构建。
存储与计算的分离 {#sec_introduction_storage_compute}
在传统计算中,磁盘存储被视为持久存储:我们假定数据一旦写入磁盘,就不会丢失。为了容忍单块硬盘故障,人们通常使用 RAID(独立磁盘冗余阵列),在连接到同一台机器的多块磁盘上保存数据副本。RAID 既可以由硬件实现,也可以由操作系统通过软件实现;对于访问文件系统的应用来说,这一切都是透明的。
云中的计算实例(虚拟机)也可以连接本地磁盘,但云原生系统通常更愿意把它们当作临时缓存,而不是长期存储。原因在于:一旦相应实例发生故障,本地磁盘就无法访问;为了适应负载变化而把实例换成另一台物理机上的更大或更小规格时,本地磁盘同样无法访问。
作为本地磁盘的替代方案,云服务还提供虚拟磁盘存储,可以从一个实例卸载,再挂载到另一个实例上,例如 Amazon EBS、Azure 托管磁盘和 Google Cloud 持久磁盘。这种虚拟磁盘并不是真正的物理磁盘,而是由另一组机器提供的云服务,用来模拟磁盘的行为——也就是 块设备,其中每个块通常为 4 KiB。这项技术让传统的磁盘软件可以在云中运行,但块设备模拟会引入额外开销;如果系统从一开始便针对云设计,这些开销本来可以避免 [^25]。它还使应用对网络异常极为敏感,因为虚拟块设备上的每次 I/O 实际上都是一次网络调用 [^28]。
为了解决这个问题,云原生服务通常避开虚拟磁盘,转而建立在针对特定工作负载优化的专用存储服务之上。S3 等对象存储服务适合长期保存较大的文件,大小从数百 KB 到数 GB 不等。数据库中的单行或单个值通常远小于这个范围;因此,云数据库通常在一个独立服务中管理较小的值,并把包含许多个值的较大数据块存入对象存储 [^26] [^29]。我们将在第 4 章介绍相应的实现方法。
在传统系统架构中,同一台计算机同时负责存储(磁盘)和计算(CPU 与内存);而在云原生系统中,这两项职责在一定程度上相互分离,或者说被 解耦 了 [^9] [^27] [^30] [^31]。例如,S3 只负责保存文件;如果要分析其中的数据,就必须在 S3 之外运行分析代码。这也意味着数据需要通过网络传输,我们将在“分布式与单节点系统”中进一步讨论。
此外,云原生系统往往采用 多租户 模式:它并不为每个客户单独分配一台机器,而是由同一项服务在共享硬件上处理多个客户的数据和计算 [^32]。
多租户可以提高硬件利用率,更容易实现可伸缩性,也便于云服务商管理;但要保证一个客户的活动不影响其他客户的性能或安全,就必须经过周密的工程设计 [^33]。
云时代的运维 {#sec_introduction_operations}
传统上,管理组织服务器端数据基础设施的人员称为 数据库管理员(DBA)或 系统管理员(sysadmin)。近年来,许多组织尝试把软件开发与运维角色融入同一个团队,让团队共同负责后端服务和数据基础设施;DevOps 理念推动了这一趋势。站点可靠性工程师(SRE)则是 Google 对这一理念的实践 [^34]。
运维的职责是确保服务可靠地交付给用户,包括配置基础设施和部署应用;同时保障生产环境稳定,包括监控和诊断任何可能影响可靠性的问题。对自托管系统来说,传统运维有大量工作落在单台机器上,例如容量规划(监控可用磁盘空间,并在耗尽前增加磁盘)、开通新机器、在机器之间迁移服务,以及安装操作系统补丁。
许多云服务通过 API 隐藏了实际承载服务的一台台机器。例如,云存储不再提供固定容量的磁盘,而是采用 按量计费:你无需提前规划容量,就可以存储数据,然后按实际占用的空间付费。此外,即使个别机器发生故障,许多云服务仍然保持高可用性(参见“可靠性与容错”)。
关注点从单台机器转向服务,也伴随着运维角色的变化。可靠地提供服务这一高层目标没有改变,但流程和工具已经演变。DevOps/SRE 理念更强调:
- 自动化——采用可重复的流程,而不是手工执行一次性任务;
- 采用临时性的虚拟机和服务,而不是长时间运行的服务器;
- 支持频繁更新应用;
- 从事故中吸取教训;以及
- 即使人员来去更替,也要保留组织对系统的知识 [^35]。
随着云服务兴起,运维角色也发生了分化:基础设施公司的运维团队专攻如何面向大量客户提供可靠服务;云服务客户则力求把投入基础设施的时间和精力降到最低 [^36]。
云服务的客户仍然需要运维,只是关注的方面有所不同,例如为一项任务选择最合适的服务、集成不同服务,以及从一项服务迁移到另一项服务。尽管按量计费消除了传统意义上的容量规划,你依然必须清楚哪些资源被用在什么地方,免得为并不需要的云资源白白花钱:容量规划变成了财务规划,性能优化变成了成本优化 [^37]。
而且,云服务仍有资源上限或 配额,例如并发运行的进程数上限。你必须提前了解并做好规划,不能等到撞上限额才处理 [^38]。
采用云服务或许比自行运行基础设施更加容易、快捷,但学习如何使用仍有成本,有时还得设法绕过它的限制。随着供应商越来越多,面向各种用例的云服务层出不穷,如何集成不同服务成了一项格外棘手的挑战 [^39] [^40]。
ETL(参见“数据仓库”)只是其中一部分,事务型云服务同样需要彼此集成。目前还缺少简化这类集成的标准,因此往往要投入大量手工工作。
还有一些运维工作无法完全外包给云服务,例如维护应用及其依赖库的安全,管理自有服务之间的交互,监控服务负载,以及追查性能下降或服务中断等问题的根源。云计算固然正在改变运维的角色,但运维仍与以往任何时候一样重要。
分布式与单节点系统 {#sec_introduction_distributed}
由多台机器通过网络通信而构成的系统,称为 分布式系统。参与分布式系统的每个进程称为一个 节点。采用分布式系统可能出于以下各种原因:
固有的分布式系统
: 如果一项应用涉及两个或更多相互交互的用户,而每个用户都使用自己的设备,那么这个系统不可避免地是分布式的:设备之间只能通过网络通信。
云服务之间的请求
: 如果数据存储在一个服务中,却要由另一个服务处理,就必须通过网络把数据从一个服务传到另一个服务。
容错/高可用性
: 如果应用需要在一台机器(乃至多台机器、网络或整个数据中心)发生故障时继续运行,可以用多台机器提供冗余:一台发生故障后,由另一台接管。参见“可靠性与容错”以及第 6 章关于复制的讨论。
可伸缩性
: 如果数据量或计算需求增长到单台机器无力承担,或许可以把负载分散到多台机器上。参见“可伸缩性”。
延迟
: 如果用户遍布世界各地,你可能希望在全球多个区域部署服务器,让每个用户都由地理位置较近的服务器提供服务。这样一来,用户就不必等待网络数据包绕过半个地球才得到响应。参见“描述性能”。
弹性
: 如果应用有时繁忙、有时空闲,云端部署可以随着需求扩大或缩小,让你只为正在使用的资源付费。这在单台机器上很难做到:即使大部分时间几乎没有负载,仍要按最大负载预先配置机器。
使用专用硬件
: 系统的各个部分可以采用与各自工作负载相匹配的硬件。例如,对象存储可以使用磁盘很多、CPU 很少的机器;数据分析系统可以使用 CPU 和内存很多、却没有磁盘的机器;机器学习系统则可以使用配备 GPU 的机器——训练深度神经网络及执行其他机器学习任务时,GPU 的效率远高于 CPU。
法律合规
: 一些国家制定了数据驻留法律,要求有关本国管辖范围内人员的数据,必须在该国境内存储和处理 [^41]。这类规定的适用范围不尽相同:有些只针对医疗或金融数据,有些则更加宽泛。因此,如果一项服务的用户分布在多个这样的司法管辖区,就不得不把用户数据分散到多个地点的服务器上。
可持续性
: 如果作业在何时、何地运行具有一定灵活性,就可以选择可再生电力充足的时间和地点,并避开电网负荷紧张的时候。这样既能减少碳排放,也能利用价格低廉的电力 [^42] [^43]。
这些理由既适用于自行编写的服务(应用代码),也适用于由数据库等现成软件组成的服务。
分布式系统的问题 {#sec_introduction_dist_sys_problems}
分布式系统也有缺点。每一个经过网络的请求和 API 调用,都必须面对失败的可能:网络可能中断,服务可能过载或崩溃,任何请求都有可能超时而收不到响应。此时,我们不知道服务究竟有没有收到请求,贸然重试未必安全。我们将在第 9 章详细讨论这些问题。
尽管数据中心网络很快,调用另一个服务仍然远远慢于在同一进程中调用函数 [^44]。
处理海量数据时,与其把数据从存储位置传到另一台机器上处理,往往不如把计算带到已经保存数据的机器上来得快 [^45]。
节点更多也不一定更快:有些情况下,一台计算机上简单的单线程程序,性能可以显著胜过拥有 100 多个 CPU 核心的集群 [^46]。
分布式系统往往很难排查故障:如果系统响应缓慢,怎样才能找出问题在哪里?用于诊断分布式系统问题的技术统称为 可观测性 [^47] [^48]。它收集系统的执行数据,并允许人们查询这些数据,从而既能分析高层指标,也能追查单个事件。OpenTelemetry、Zipkin 和 Jaeger 等 追踪 工具可以记录哪个客户端为了什么操作调用了哪个服务器,以及每次调用花了多长时间 [^49]。
数据库提供了多种保证数据一致性的机制,我们将在第 6 章和第 8 章中看到。但是,当每个服务都有自己的数据库时,如何保持不同服务之间的数据一致,就成了应用自身的问题。分布式事务(参见第 8 章)是一种可能的手段,但很少用于微服务,因为它与服务彼此独立的目标背道而驰,而且许多数据库根本不支持分布式事务 [^50]。
出于以上种种原因,只要一项工作能在单台机器上完成,通常就比搭建分布式系统简单得多,也便宜得多 [^23] [^46] [^51]。CPU 越来越快,内存和磁盘的容量越来越大,硬件也越来越可靠。再加上 DuckDB、SQLite 和 KùzuDB 等单节点数据库,如今许多工作负载都可以在单个节点上运行。我们将在第 4 章进一步探讨这个话题。
微服务与无服务器 {#sec_introduction_microservices}
把系统分布到多台机器上,最常见的做法是将它划分为客户端和服务器,由客户端向服务器发送请求。这种通信最常使用 HTTP,我们将在“流经服务的数据流:REST 与 RPC”中进一步讨论。同一个进程既可以是服务器(处理传入的请求),也可以是客户端(向其他服务发出请求)。
这种构建应用的方式传统上称为 面向服务架构(SOA);近年来,这一思想又进一步演化为 微服务 架构 [^52] [^53]。在这种架构中,每项服务都有明确的用途(例如 S3 的用途就是文件存储);服务通过 API 向客户端开放能力,由客户端经网络调用;每项服务还由一个团队负责维护。这样一来,复杂应用就可以拆分成多项相互交互的服务,分别由不同团队管理。
把复杂软件拆成多项服务有几个优点:各项服务可以独立更新,减少团队之间的协调;每项服务可以获得符合自身需要的硬件资源;实现细节隐藏在 API 后面,服务负责人可以自由改变实现,而不影响客户端。在数据存储方面,通常每项服务都有自己的数据库,服务之间不共享数据库。否则,整个数据库结构实际上都会成为服务 API 的一部分,很难再行修改;而且,一项服务发出的查询也可能拖累其他服务的性能。
另一方面,服务多了本身也会滋生复杂度:每项服务都需要相应基础设施,用来部署新版本、根据负载调整硬件资源、收集日志、监控服务健康状况,并在出现问题时向值班工程师告警。Kubernetes 等 编排 框架为这类基础设施提供了基础能力,因此成了部署服务的常用方式。在开发过程中测试一项服务也可能很麻烦,因为它所依赖的其他服务必须一并运行。
微服务 API 也很难演化。调用 API 的客户端会期待其中存在某些字段;随着业务需求改变,开发者或许想要增删 API 字段,但这可能导致客户端出错。更糟的是,这类问题往往要到开发周期后期,更新后的服务 API 部署到预发布或生产环境时才被发现。OpenAPI 和 gRPC 等 API 描述标准有助于管理客户端 API 与服务器 API 之间的关系,我们将在第 5 章中进一步讨论。
微服务主要是用技术手段解决人员问题:让不同团队无需彼此协调,也能独立推进工作。这对大公司很有价值;但在团队不多的小公司里,微服务很可能只是没有必要的额外开销,此时最好采用最简单的方式实现应用 [^52]。
无服务器(serverless),又称 函数即服务(FaaS),是另一种服务部署方式:它把基础设施管理进一步外包给云服务商 [^33]。使用虚拟机时,你必须明确决定何时启动或关闭实例;无服务器模型则由云服务商根据发往服务的请求,自动分配和释放硬件资源 [^54]。这种部署方式把更多运维负担转移给云服务商,并允许按使用量灵活计费,不再按机器实例收费。为了提供这些好处,许多无服务器基础设施会限制函数执行时长和运行时环境,而且函数第一次调用时可能启动缓慢。“无服务器”这个名称也容易误导:每次执行无服务器函数仍然要用到一台服务器,只是下一次执行可能换到另一台。此外,BigQuery 和各种 Kafka 产品也采用了“无服务器”这个说法,用来表示服务能够自动伸缩,并按使用量而不是机器实例收费。
正如云存储用按量计费取代了容量规划(提前决定购买多少磁盘),无服务器模式也把按量计费带到了代码执行:你只需为应用代码实际运行的时间付费,不必提前配置资源。
云计算与超级计算 {#id17}
云计算并非构建大规模计算系统的唯一方式,另一条路线是 高性能计算(HPC),也称为 超级计算。尽管二者有所重叠,但与云计算和企业数据中心系统相比,HPC 的侧重点通常不同,采用的技术也不一样。差异包括:
- 超级计算机通常用于计算密集型的科学计算任务,例如天气预报、气候建模、分子动力学(模拟原子和分子的运动)、复杂优化问题,以及求解偏微分方程。云计算则更常用于在线服务、业务数据系统,以及其他需要以高可用性响应用户请求的系统。
- 超级计算机通常运行大型批处理作业,并不时把计算状态作为检查点写入磁盘。如果某个节点发生故障,一种常见做法是直接停止整个集群的工作负载,修复故障节点,再从最近的检查点重新开始计算 [^55] [^56]。云服务通常不能这样停掉整个集群,因为服务必须持续响应用户,尽量减少中断。
- 超级计算机的节点通常通过共享内存和远程直接内存访问(RDMA)通信,既有高带宽,又有低延迟,但前提是系统用户彼此高度信任 [^57]。在云计算中,网络和机器常由互不信任的组织共享,因此需要更强的安全机制,例如资源隔离(使用虚拟机等)、加密和认证。
- 云数据中心网络通常以 IP 和以太网为基础,采用 Clos 拓扑来提供较高的对分带宽——这是衡量网络整体性能的常用指标 [^55] [^58]。超级计算机则常采用专门的网络拓扑,例如多维网格和环面 [^59];对于通信模式已知的 HPC 工作负载,这类拓扑可以提供更好的性能。
- 云计算允许节点分布在多个地理区域;超级计算机通常假定所有节点都彼此邻近。
大规模分析系统有时也具备超级计算的一些特征;如果你在这个领域工作,了解这些技术会很有价值。不过,本书主要关注必须持续可用的服务,正如“可靠性与容错”所讨论的那样。
数据系统、法律与社会 {#sec_introduction_compliance}
本章到目前为止已经说明,数据系统架构不仅受技术目标和要求影响,也受其所服务组织中人类需求的影响。越来越多的数据系统工程师开始认识到,只满足自己所在企业的需求还不够:我们也对整个社会负有责任。
其中一个特别值得关注的问题,是存储个人及其行为数据的系统。自 2018 年起,《通用数据保护条例》(GDPR)赋予许多欧洲国家的居民更大的个人数据控制权和更多法律权利;世界各地的不少国家和地区也采用了类似的隐私法规,例如《加州消费者隐私法案》(CCPA)。《欧盟人工智能法案》 等针对 AI 的法规,又进一步限制了个人数据的使用方式。
即使在没有直接受到监管的领域,人们也越来越清楚地认识到计算机系统对个人和社会的影响。社交媒体改变了人们获取新闻的方式,进而影响政治观点,甚至可能左右选举结果。自动化系统也越来越多地作出对个人影响深远的决定,例如谁能获得贷款或保险,谁能得到工作面试的机会,以及谁会成为犯罪嫌疑人 [^60]。
每一个参与这类系统的人,都有责任考虑它们的伦理影响,并确保系统遵守相关法律。并非人人都要成为法律和伦理专家,但具备基本的法律与伦理常识,与掌握分布式系统的基础知识同样重要。
法律因素正在影响数据系统设计最根本的部分 [^61]。例如,GDPR 赋予个人要求删除其数据的权利,有时称为 被遗忘权。然而,正如本书将要介绍的,许多数据系统的设计依赖仅追加日志等不可变结构;一个本应不可变的文件,要怎样从中间删除某些数据?如果数据已经纳入衍生数据集(参见“权威记录系统与衍生数据”),例如成为机器学习模型的训练数据,又该如何删除?回答这些问题带来了新的工程挑战。
目前,还没有明确指南说明哪些具体技术或系统架构算是“符合 GDPR”。法规有意不规定特定技术,因为技术进步可能很快就会使这些规定过时。法律文本给出的只是有待解释的高层原则。因此,如何遵守隐私法规并没有简单答案;不过,在讨论本书的一些技术时,我们会从这个角度加以审视。
一般来说,我们之所以存储数据,是因为相信它的价值高于存储成本。但不要忘记,存储成本不只是付给 Amazon S3 或其他服务的账单。成本效益分析还应计入这些风险:数据一旦泄露,或遭到攻击者窃取、破坏,可能承担法律责任并蒙受声誉损失;如果数据的存储和处理不符合法律规定,还可能产生诉讼费用和罚款 [^51]。
政府或警方也可能强迫企业交出数据。如果数据可能暴露某些在当地被法律定为犯罪的行为——例如,在一些中东和非洲国家,同性恋会受到刑事处罚;在美国一些州,寻求堕胎也可能受到刑事追究——那么存储这些数据会给用户带来切实的安全风险。例如,位置数据很容易暴露一个人曾前往堕胎诊所;哪怕只是一段用户 IP 地址的历史日志,也可能泄露其大致位置。
把所有风险都考虑在内之后,合理的结论可能是:某些数据根本不值得存储,因此应当删除。数据最小化 原则(有时也用德语 Datensparsamkeit 表示)与“大数据”理念背道而驰;后者倾向于先存下大量数据,指望它们将来也许会派上用场 [^62]。不过,数据最小化符合 GDPR:个人数据只能为具体、明确的目的而收集,日后不得用于其他目的,也不得在超出原定目的所需期限后继续保留 [^63]。
企业同样开始重视隐私和安全问题。信用卡公司要求支付处理企业遵守严格的支付卡行业(PCI)标准;支付处理商要频繁接受独立审计机构的评估,验证是否持续合规。软件供应商面临的审查也日趋严格,许多采购方如今要求供应商符合服务组织控制(SOC)第 2 类标准。与 PCI 合规一样,供应商要通过第三方审计来验证是否符合要求。
总而言之,必须在业务需求与数据被收集、处理的人们的需求之间取得平衡。这个话题远不止于此;第 14 章将深入讨论伦理与法律合规问题,包括偏见和歧视。
总结 {#summary}
本章的主题是理解权衡:许多问题并没有唯一正确的答案,而是有几种不同的做法,各有利弊。我们探讨了影响数据系统架构的一些最重要的选择,也介绍了阅读本书其余部分所需的术语。
首先,我们区分了事务型系统(事务处理,即 OLTP)与分析型系统(OLAP),看到两者不仅管理着访问模式不同的各类数据,服务的群体也不相同。我们还认识了数据仓库与数据湖,它们通过 ETL 接收事务型系统送来的数据。第 4 章将会说明,由于要服务的查询类型不同,事务型系统与分析型系统的内部数据布局往往大相径庭。
接着,我们把出现时间较晚的云服务,与此前长期主导数据系统架构的自托管软件范式作了比较。哪种方式成本效益更高,很大程度上取决于具体情况;但不可否认的是,云原生方法正在深刻改变数据系统的架构,例如将存储与计算分离。
云系统天然就是分布式系统,我们也简要考察了分布式系统与单机方案之间的一些权衡。有些场景无法避免分布式;但只要系统还能保留在一台机器上,就不宜急着将其分布式化。第 9 章将更详细地讨论分布式系统带来的挑战。
最后,我们看到,数据系统架构不仅由部署系统的企业需求决定,也受隐私法规影响;这些法规保护着数据处理所涉及人员的权利,而许多工程师很容易忽视这一点。如何把法律要求转化为技术实现,目前还没有得到充分理解;但在阅读本书后续内容时,务必始终把这个问题放在心上。
参考文献
[^1]: Richard T. Kouzes, Gordon A. Anderson, Stephen T. Elbert, Ian Gorton, and Deborah K. Gracio. The Changing Paradigm of Data-Intensive Computing. IEEE Computer, volume 42, issue 1, January 2009. doi:10.1109/MC.2009.26
[^2]: Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan. Local-first software: you own your data, in spite of the cloud. At 2019 ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software (Onward!), October 2019. doi:10.1145/3359591.3359737
[^3]: Joe Reis and Matt Housley. Fundamentals of Data Engineering. O’Reilly Media, 2022. ISBN: 9781098108304
[^4]: Rui Pedro Machado and Helder Russa. Analytics Engineering with SQL and dbt. O’Reilly Media, 2023. ISBN: 9781098142384
[^5]: Edgar F. Codd, S. B. Codd, and C. T. Salley. Providing OLAP to User-Analysts: An IT Mandate. E. F. Codd Associates, 1993. Archived at perma.cc/RKX8-2GEE
[^6]: Chinmay Soman and Neha Pawar. Comparing Three Real-Time OLAP Databases: Apache Pinot, Apache Druid, and ClickHouse. startree.ai, April 2023. Archived at perma.cc/8BZP-VWPA
[^7]: Surajit Chaudhuri and Umeshwar Dayal. An Overview of Data Warehousing and OLAP Technology. ACM SIGMOD Record, volume 26, issue 1, pages 65–74, March 1997. doi:10.1145/248603.248616
[^8]: Fatma Özcan, Yuanyuan Tian, and Pinar Tözün. Hybrid Transactional/Analytical Processing: A Survey. At ACM International Conference on Management of Data (SIGMOD), May 2017. doi:10.1145/3035918.3054784
[^9]: Adam Prout, Szu-Po Wang, Joseph Victor, Zhou Sun, Yongzhu Li, Jack Chen, Evan Bergeron, Eric Hanson, Robert Walzer, Rodrigo Gomes, and Nikita Shamgunov. Cloud-Native Transactions and Analytics in SingleStore. At International Conference on Management of Data (SIGMOD), June 2022. doi:10.1145/3514221.3526055
[^10]: Chao Zhang, Guoliang Li, Jintao Zhang, Xinning Zhang, and Jianhua Feng. HTAP Databases: A Survey. IEEE Transactions on Knowledge and Data Engineering, April 2024. doi:10.1109/TKDE.2024.3389693
[^11]: Michael Stonebraker and Uğur Çetintemel. ‘One Size Fits All’: An Idea Whose Time Has Come and Gone. At 21st International Conference on Data Engineering (ICDE), April 2005. doi:10.1109/ICDE.2005.1
[^12]: Jeffrey Cohen, Brian Dolan, Mark Dunlap, Joseph M. Hellerstein, and Caleb Welton. MAD Skills: New Analysis Practices for Big Data. Proceedings of the VLDB Endowment, volume 2, issue 2, pages 1481–1492, August 2009. doi:10.14778/1687553.1687576
[^13]: Dan Olteanu. The Relational Data Borg is Learning. Proceedings of the VLDB Endowment, volume 13, issue 12, August 2020. doi:10.14778/3415478.3415572
[^14]: Matt Bornstein, Martin Casado, and Jennifer Li. Emerging Architectures for Modern Data Infrastructure: 2020. future.a16z.com, October 2020. Archived at perma.cc/LF8W-KDCC
[^15]: Martin Fowler. DataLake. martinfowler.com, February 2015. Archived at perma.cc/4WKN-CZUK
[^16]: Bobby Johnson and Joseph Adler. The Sushi Principle: Raw Data Is Better. At Strata+Hadoop World, February 2015.
[^17]: Michael Armbrust, Ali Ghodsi, Reynold Xin, and Matei Zaharia. Lakehouse: A New Generation of Open Platforms that Unify Data Warehousing and Advanced Analytics. At 11th Annual Conference on Innovative Data Systems Research (CIDR), January 2021.
[^18]: DataKitchen, Inc. The DataOps Manifesto. dataopsmanifesto.org, 2017. Archived at perma.cc/3F5N-FUQ4
[^19]: Tejas Manohar. What is Reverse ETL: A Definition & Why It’s Taking Off. hightouch.io, November 2021. Archived at perma.cc/A7TN-GLYJ
[^20]: Simon O’Regan. Designing Data Products. towardsdatascience.com, August 2018. Archived at perma.cc/HU67-3RV8
[^21]: Camille Fournier. Why is it so hard to decide to buy? skamille.medium.com, July 2021. Archived at perma.cc/6VSG-HQ5X
[^22]: David Heinemeier Hansson. Why we’re leaving the cloud. world.hey.com, October 2022. Archived at perma.cc/82E6-UJ65
[^23]: Nima Badizadegan. Use One Big Server. specbranch.com, August 2022. Archived at perma.cc/M8NB-95UK
[^24]: Steve Yegge. Dear Google Cloud: Your Deprecation Policy is Killing You. steve-yegge.medium.com, August 2020. Archived at perma.cc/KQP9-SPGU
[^25]: Alexandre Verbitski, Anurag Gupta, Debanjan Saha, Murali Brahmadesam, Kamal Gupta, Raman Mittal, Sailesh Krishnamurthy, Sandor Maurice, Tengiz Kharatishvili, and Xiaofeng Bao. Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases. At ACM International Conference on Management of Data (SIGMOD), pages 1041–1052, May 2017. doi:10.1145/3035918.3056101
[^26]: Panagiotis Antonopoulos, Alex Budovski, Cristian Diaconu, Alejandro Hernandez Saenz, Jack Hu, Hanuma Kodavalla, Donald Kossmann, Sandeep Lingam, Umar Farooq Minhas, Naveen Prakash, Vijendra Purohit, Hugh Qu, Chaitanya Sreenivas Ravella, Krystyna Reisteter, Sheetal Shrotri, Dixin Tang, and Vikram Wakade. Socrates: The New SQL Server in the Cloud. At ACM International Conference on Management of Data (SIGMOD), pages 1743–1756, June 2019. doi:10.1145/3299869.3314047
[^27]: Midhul Vuppalapati, Justin Miron, Rachit Agarwal, Dan Truong, Ashish Motivala, and Thierry Cruanes. Building An Elastic Query Engine on Disaggregated Storage. At 17th USENIX Symposium on Networked Systems Design and Implementation (NSDI), February 2020.
[^28]: Nick Van Wiggeren. The Real Failure Rate of EBS. planetscale.com, March 2025. Archived at perma.cc/43CR-SAH5
[^29]: Colin Breck. Predicting the Future of Distributed Systems. blog.colinbreck.com, August 2024. Archived at perma.cc/K5FC-4XX2
[^30]: Gwen Shapira. Compute-Storage Separation Explained. thenile.dev, January 2023. Archived at perma.cc/QCV3-XJNZ
[^31]: Ravi Murthy and Gurmeet Goindi. AlloyDB for PostgreSQL under the hood: Intelligent, database-aware storage. cloud.google.com, May 2022. Archived at archive.org
[^32]: Jack Vanlightly. The Architecture of Serverless Data Systems. jack-vanlightly.com, November 2023. Archived at perma.cc/UDV4-TNJ5
[^33]: Eric Jonas, Johann Schleier-Smith, Vikram Sreekanti, Chia-Che Tsai, Anurag Khandelwal, Qifan Pu, Vaishaal Shankar, Joao Carreira, Karl Krauth, Neeraja Yadwadkar, Joseph E. Gonzalez, Raluca Ada Popa, Ion Stoica, David A. Patterson. Cloud Programming Simplified: A Berkeley View on Serverless Computing. arxiv.org, February 2019.
[^34]: Betsy Beyer, Jennifer Petoff, Chris Jones, and Niall Richard Murphy. Site Reliability Engineering: How Google Runs Production Systems. O’Reilly Media, 2016. ISBN: 9781491929124
[^35]: Thomas Limoncelli. The Time I Stole $10,000 from Bell Labs. ACM Queue, volume 18, issue 5, November 2020. doi:10.1145/3434571.3434773
[^36]: Charity Majors. The Future of Ops Jobs. acloudguru.com, August 2020. Archived at perma.cc/GRU2-CZG3
[^37]: Boris Cherkasky. (Over)Pay As You Go for Your Datastore. medium.com, September 2021. Archived at perma.cc/Q8TV-2AM2
[^38]: Shlomi Kushchi. Serverless Doesn’t Mean DevOpsLess or NoOps. thenewstack.io, February 2023. Archived at perma.cc/3NJR-AYYU
[^39]: Erik Bernhardsson. Storm in the stratosphere: how the cloud will be reshuffled. erikbern.com, November 2021. Archived at perma.cc/SYB2-99P3
[^40]: Benn Stancil. The data OS. benn.substack.com, September 2021. Archived at perma.cc/WQ43-FHS6
[^41]: Maria Korolov. Data residency laws pushing companies toward residency as a service. csoonline.com, January 2022. Archived at perma.cc/CHE4-XZZ2
[^42]: Severin Borenstein. Can Data Centers Flex Their Power Demand? energyathaas.wordpress.com, April 2025. Archived at https://perma.cc/MUD3-A6FF
[^43]: Bilge Acun, Benjamin Lee, Fiodar Kazhamiaka, Aditya Sundarrajan, Kiwan Maeng, Manoj Chakkaravarthy, David Brooks, and Carole-Jean Wu. Carbon Dependencies in Datacenter Design and Management. ACM SIGENERGY Energy Informatics Review, volume 3, issue 3, pages 21–26. doi:10.1145/3630614.3630619
[^44]: Kousik Nath. These are the numbers every computer engineer should know. freecodecamp.org, September 2019. Archived at perma.cc/RW73-36RL
[^45]: Joseph M. Hellerstein, Jose Faleiro, Joseph E. Gonzalez, Johann Schleier-Smith, Vikram Sreekanti, Alexey Tumanov, and Chenggang Wu. Serverless Computing: One Step Forward, Two Steps Back. At Conference on Innovative Data Systems Research (CIDR), January 2019.
[^46]: Frank McSherry, Michael Isard, and Derek G. Murray. Scalability! But at What COST? At 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS), May 2015.
[^47]: Cindy Sridharan. Distributed Systems Observability: A Guide to Building Robust Systems. Report, O’Reilly Media, May 2018. Archived at perma.cc/M6JL-XKCM
[^48]: Charity Majors. Observability — A 3-Year Retrospective. thenewstack.io, August 2019. Archived at perma.cc/CG62-TJWL
[^49]: Benjamin H. Sigelman, Luiz André Barroso, Mike Burrows, Pat Stephenson, Manoj Plakal, Donald Beaver, Saul Jaspan, and Chandan Shanbhag. Dapper, a Large-Scale Distributed Systems Tracing Infrastructure. Google Technical Report dapper-2010-1, April 2010. Archived at perma.cc/K7KU-2TMH
[^50]: Rodrigo Laigner, Yongluan Zhou, Marcos Antonio Vaz Salles, Yijian Liu, and Marcos Kalinowski. Data management in microservices: State of the practice, challenges, and research directions. Proceedings of the VLDB Endowment, volume 14, issue 13, pages 3348–3361, September 2021. doi:10.14778/3484224.3484232
[^51]: Jordan Tigani. Big Data is Dead. motherduck.com, February 2023. Archived at perma.cc/HT4Q-K77U
[^52]: Sam Newman. Building Microservices, second edition. O’Reilly Media, 2021. ISBN: 9781492034025
[^53]: Chris Richardson. Microservices: Decomposing Applications for Deployability and Scalability. infoq.com, May 2014. Archived at perma.cc/CKN4-YEQ2
[^54]: Mohammad Shahrad, Rodrigo Fonseca, Íñigo Goiri, Gohar Chaudhry, Paul Batum, Jason Cooke, Eduardo Laureano, Colby Tresness, Mark Russinovich, Ricardo Bianchini. Serverless in the Wild: Characterizing and Optimizing the Serverless Workload at a Large Cloud Provider. At USENIX Annual Technical Conference (ATC), July 2020.
[^55]: Luiz André Barroso, Urs Hölzle, and Parthasarathy Ranganathan. The Datacenter as a Computer: Designing Warehouse-Scale Machines, third edition. Morgan & Claypool Synthesis Lectures on Computer Architecture, October 2018. doi:10.2200/S00874ED3V01Y201809CAC046
[^56]: David Fiala, Frank Mueller, Christian Engelmann, Rolf Riesen, Kurt Ferreira, and Ron Brightwell. Detection and Correction of Silent Data Corruption for Large-Scale High-Performance Computing,” at International Conference for High Performance Computing, Networking, Storage and Analysis (SC), November 2012. doi:10.1109/SC.2012.49
[^57]: Anna Kornfeld Simpson, Adriana Szekeres, Jacob Nelson, and Irene Zhang. Securing RDMA for High-Performance Datacenter Storage Systems. At 12th USENIX Workshop on Hot Topics in Cloud Computing (HotCloud), July 2020.
[^58]: Arjun Singh, Joon Ong, Amit Agarwal, Glen Anderson, Ashby Armistead, Roy Bannon, Seb Boving, Gaurav Desai, Bob Felderman, Paulie Germano, Anand Kanagala, Jeff Provost, Jason Simmons, Eiichi Tanda, Jim Wanderer, Urs Hölzle, Stephen Stuart, and Amin Vahdat. Jupiter Rising: A Decade of Clos Topologies and Centralized Control in Google’s Datacenter Network. At Annual Conference of the ACM Special Interest Group on Data Communication (SIGCOMM), August 2015. doi:10.1145/2785956.2787508
[^59]: Glenn K. Lockwood. Hadoop’s Uncomfortable Fit in HPC. glennklockwood.blogspot.co.uk, May 2014. Archived at perma.cc/S8XX-Y67B
[^60]: Cathy O’Neil: Weapons of Math Destruction: How Big Data Increases Inequality and Threatens Democracy. Crown Publishing, 2016. ISBN: 9780553418811
[^61]: Supreeth Shastri, Vinay Banakar, Melissa Wasserman, Arun Kumar, and Vijay Chidambaram. Understanding and Benchmarking the Impact of GDPR on Database Systems. Proceedings of the VLDB Endowment, volume 13, issue 7, pages 1064–1077, March 2020. doi:10.14778/3384345.3384354
[^62]: Martin Fowler. Datensparsamkeit. martinfowler.com, December 2013. Archived at perma.cc/R9QX-CME6
[^63]: Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 (General Data Protection Regulation). Official Journal of the European Union L 119/1, May 2016.
File: content/zh/ch2.md
title: 定义非功能性需求 book_kind: chapter book_number: "2" book_part: I weight: 102 breadcrumbs: false
互联网做得太好了,以至于大多数人将它看作像太平洋这样的自然资源,而不是什么人工产物。上一次出现这种规模且几乎不出错的技术,是什么时候?
艾伦・凯,
接受 Dr Dobb’s Journal 采访(2012 年)
构建应用程序时,你会面对一张需求清单。其中排在最前面的,很可能是应用必须提供的功能:需要哪些页面和按钮,每项操作要完成什么,才能实现软件的目的。这些就是 功能需求(functional requirements)。
此外,你可能还有一些 非功能性需求(nonfunctional requirements):例如,应用应当快速、可靠、安全、符合法律规定,而且易于维护。这些要求未必会明确写下来,因为它们看似理所当然,但其重要性丝毫不亚于应用的功能:一个慢得让人无法忍受,或是很不可靠的应用,几乎等于不存在。
安全性等许多非功能性需求超出了本书的范围。不过,本章会讨论其中几项,并帮助你准确表述自己的系统需要达到什么要求:
- 如何定义和衡量系统的 性能(参见“描述性能”);
- 服务 可靠 意味着什么——也就是即使出了问题,仍能继续正确工作(参见“可靠性与容错”);
- 随着系统负载增长,能否高效增加计算能力,使系统具备 可伸缩性(参见“可伸缩性”);以及
- 如何让系统在长期使用中更易维护(参见“可维护性”)。
后续章节深入讨论数据密集型系统的实现细节时,还会用到本章引入的术语。不过,抽象定义读起来难免枯燥。为了让这些概念更加具体,我们先从一个社交网络服务的实现案例讲起,以此说明性能与可伸缩性在实践中意味着什么。
案例研究:社交网络首页时间线 {#sec_introduction_twitter}
假设你的任务是实现一个类似 X(原 Twitter)的社交网络,用户可以发帖,也可以关注其他用户。这里的实现会比真实服务简单得多 [^1] [^2] [^3],但足以说明大规模系统会遇到的一些问题。
假设用户每天发布 5 亿条帖子,平均每秒 5,700 条;偶尔,发帖速率会飙升至每秒 150,000 条 [^4]。再假设每位用户平均关注 200 人,也有 200 名关注者(实际分布范围非常广:大多数人只有寥寥几名关注者,而巴拉克・奥巴马等少数名人则有超过一亿名关注者)。
表示用户、帖子与关注关系 {#id20}
假设我们把所有数据都保存在关系数据库中,如{{< xref fig="2-1" page="/ch2" anchor="fig_twitter_relational" >}}图 2-1{{< /xref >}}所示:一张表存储用户,一张表存储帖子,还有一张表存储关注关系。
{{< fig num="2-1" id="fig_twitter_relational" src="/fig/ddia_0201.png" caption="一个允许用户相互关注的社交网络的简单关系模式。" class="ddia-figure ddia-figure--panorama" width="1772" height="542" />}}
假设这个社交网络需要支持的主要读操作是 首页时间线(home timeline),用来展示你所关注的人最近发布的帖子(为简单起见,我们忽略广告、来自未关注用户的推荐帖,以及其他扩展功能)。可以用下面这条 SQL 查询来获取某位用户的首页时间线:
SELECT posts.*, users.* FROM posts
JOIN follows ON posts.sender_id = follows.followee_id
JOIN users ON posts.sender_id = users.id
WHERE follows.follower_id = current_user
ORDER BY posts.timestamp DESC
LIMIT 1000执行这条查询时,数据库先用 follows 表找出 current_user 关注的所有人,再查找这些用户最近发布的帖子,并按时间戳排序,取出其中最新的 1,000 条。
帖子讲究时效,因此假设某人发帖之后,我们希望其关注者能在 5 秒内看到。一个办法是,只要用户在线,客户端就每隔 5 秒重复执行一次上述查询(这称为 轮询(polling))。如果同时在线且已登录的用户有 1,000 万,就意味着每秒要执行 200 万次查询。即使延长轮询间隔,这个数字仍然很大。
而且,这条查询本身开销不小:如果你关注了 200 人,数据库就要分别取出这 200 人最近发布的帖子,再把这些列表合并起来。每秒 200 万次时间线查询,意味着数据库每秒要查询某个发帖者的近期帖子 4 亿次——这是个惊人的数字,而且还只是平均情况。有些用户关注了数万个账户,为他们执行这条查询的代价极高,也很难保证速度。
时间线的物化与更新 {#sec_introduction_materializing}
怎样才能做得更好?首先,与其让客户端轮询,不如由服务器把新帖子主动推送给当前在线的关注者。其次,可以预先计算上述查询的结果,使首页时间线请求直接由缓存提供。
设想我们为每位用户保存一个数据结构,其中装着他们的首页时间线,也就是他们所关注的人最近发布的帖子。每当有人发帖,我们就找出他的所有关注者,把这条帖子插入每位关注者的首页时间线,就像把信投进一个个邮箱。这样,用户登录时只需把预先计算好的时间线交给他们即可。若要接收时间线上的新帖通知,客户端只需订阅不断加入其首页时间线的帖子流。
这种方法的缺点是,每次发帖都要完成更多工作,因为首页时间线是需要随之更新的 衍生数据(derived data)。整个过程如{{< xref fig="2-2" page="/ch2" anchor="fig_twitter_timelines" >}}图 2-2{{< /xref >}}所示。当一个初始请求引发多个下游请求时,我们用 扇出(fan-out) 来表示请求数量被放大的倍数。
{{< fig num="2-2" id="fig_twitter_timelines" src="/fig/ddia_0202.png" caption="扇出——把新帖子投递给发帖用户的每一位关注者。" class="ddia-figure ddia-figure--wide" width="1772" height="638" />}}
以每秒 5,700 条帖子的速率计算,如果每条帖子平均要送达 200 名关注者(也就是扇出系数为 200),每秒就要完成略多于 100 万次首页时间线写入。这个数字依然很大,但与另一种方案每秒 4 亿次按发帖者查询近期帖子的操作相比,已经节省了很多工作。
如果某个特殊事件使发帖速率骤增,我们无须立刻完成所有时间线投递;可以先把投递任务放入队列,并接受帖子暂时要过一会儿才会出现在关注者的时间线上。即使出现这种负载高峰,时间线仍然可以快速加载,因为读取只需访问缓存。
这种预先计算并不断更新查询结果的过程称为 物化(materialization),时间线缓存就是一个 物化视图(materialized view) 的例子(我们会在“维护物化视图”中进一步讨论这个概念)。物化视图加快了读取,代价则是写入时要做更多工作。对大多数用户而言,写入成本并不高,但社交网络还必须考虑一些极端情况:
- 如果一位用户关注了非常多的账户,而且这些账户发帖频繁,那么写入其物化时间线的速率会很高。不过,这位用户多半也不会读完时间线中的所有帖子,因此可以丢弃其中一部分时间线写入,只向用户展示所关注账户发布帖子的一个样本 [^5]。
- 如果一位拥有海量关注者的名人发帖,我们就要完成大量工作,把这条帖子插入数百万人的首页时间线。在这种情况下,丢弃部分写入是不可接受的。一种解决办法是把名人帖子与其他人的帖子分开处理:不必费力把名人帖子加入数百万条时间线,而是将其单独存储,等读取时再与物化时间线合并。即使采用这类优化,社交网络要承载名人账户仍可能需要大量基础设施 [^6]。
描述性能 {#sec_introduction_percentiles}
讨论软件性能时,通常会考虑两类主要指标:
响应时间
: 从用户发出请求到收到所需响应所经过的时间。计量单位是秒(或毫秒、微秒)。
吞吐量
: 系统每秒处理的请求数或数据量。对于给定的硬件资源,系统能处理的吞吐量存在上限,也就是 最大吞吐量。计量单位通常写成“每秒多少个……”。
在社交网络案例中,“每秒帖子数”和“每秒时间线写入数”是吞吐量指标;“加载首页时间线所需的时间”和“帖子送达关注者所需的时间”则是响应时间指标。
吞吐量和响应时间之间往往存在联系,{{< xref fig="2-3" page="/ch2" anchor="fig_throughput" >}}图 2-3{{< /xref >}}勾勒了在线服务中二者的一种典型关系。请求吞吐量较低时,服务的响应时间也很短;随着负载增大,响应时间随之上升。这是 排队 造成的:当请求到达负载很高的系统时,CPU 很可能正在处理先前的请求,新来的请求只好等到前一个处理完毕。当吞吐量逐渐逼近硬件的处理极限时,排队延迟会急剧增加。
{{< fig num="2-3" id="fig_throughput" src="/fig/ddia_0203.png" caption="当服务的吞吐量接近其处理能力上限时,排队会使响应时间急剧增加。" class="ddia-figure ddia-figure--wide" width="2953" height="1018" />}}
[!TIP] 当过载系统无法恢复时
当系统濒临过载、吞吐量已被推到极限附近时,有时会陷入恶性循环:系统效率越来越低,因而变得更加过载。例如,等待处理的请求排起长队,响应时间可能因此增长到客户端超时并重发请求。请求速率随之进一步上升,让问题愈演愈烈——这就是 重试风暴(retry storm)。即使负载随后下降,系统也可能一直停留在过载状态,直到重启或以其他方式重置。这种现象称为 亚稳态故障(metastable failure),它可能导致生产系统严重中断 [^7] [^8]。
为了避免重试压垮服务,可以在客户端逐渐延长并随机扰动连续重试之间的等待时间(指数退避 [^9] [^10]),还可以暂时停止向最近曾返回错误或发生超时的服务发送请求(采用 熔断器 [^11] [^12] 或 令牌桶 算法 [^13])。服务器也可以在察觉自己接近过载时主动拒绝请求(负载卸除 [^14]),并在响应中要求客户端降低发送速率(背压 [^1] [^15])。排队算法和负载均衡算法的选择同样会产生影响 [^16]。
在各项性能指标中,用户通常最关心响应时间;吞吐量则决定了所需的计算资源(例如服务器数量),从而决定处理特定工作负载的成本。如果吞吐量可能增长到超出当前硬件的处理能力,就需要扩充容量。如果增加计算资源能够显著提高系统的最大吞吐量,我们就称这个系统具有 可伸缩性(scalability)。
本节主要关注响应时间;我们会在“可伸缩性”一节回头讨论吞吐量与可伸缩性。
延迟与响应时间 {#id23}
“延迟”和“响应时间”有时会被混为一谈,但本书将按下面的特定含义使用这几个术语(如{{< xref fig="2-4" page="/ch2" anchor="fig_response_time" >}}图 2-4{{< /xref >}}所示):
- 响应时间 是客户端看到的时间,其中包括系统各处产生的全部延误。
- 服务时间 是服务真正用于处理用户请求的时间。
- 排队延迟 可能发生在流程中的多个位置。例如,请求到达后,也许必须等到 CPU 空闲才能开始处理;如果同一台机器上的其他任务正在通过出站网络接口发送大量数据,响应数据包也可能先在缓冲区中等待。
- 延迟 泛指请求没有得到实际处理的时间,也就是请求处于 潜伏(latent) 状态的时间。具体来说,网络延迟 或 网络时延 是请求和响应在网络中传输所花的时间。
{{< fig num="2-4" id="fig_response_time" src="/fig/ddia_0204.png" caption="响应时间、服务时间、网络延迟和排队延迟。" class="ddia-figure ddia-figure--wide" width="2953" height="1018" />}}
在{{< xref fig="2-4" page="/ch2" anchor="fig_response_time" >}}图 2-4{{< /xref >}}中,时间从左向右流逝;参与通信的每个节点用一条水平线表示,请求或响应消息则画成从一个节点指向另一个节点的粗斜箭头。本书后面还会经常遇到这种图示方式。
即使反复发送同一个请求,每次的响应时间也可能相差很大。许多因素都会带来随机的额外延迟,例如:上下文切换到后台进程、网络丢包和 TCP 重传、垃圾回收暂停、缺页迫使系统从磁盘读取数据、服务器机架的机械振动 [^17],等等。我们会在“超时和无界延迟”中进一步讨论这个问题。
响应时间的波动很大一部分往往来自排队延迟。服务器同时能处理的任务数量有限(例如受 CPU 核数限制),因此只需少数几个慢请求,就足以阻碍后续请求,这种现象称为 队头阻塞(head-of-line blocking)。即使后续请求本身的服务时间很短,客户端看到的总体响应时间仍会很长,因为它们要等待先前的请求完成。排队延迟不属于服务时间,所以在客户端测量响应时间十分重要。
平均值、中位数与分位数 {#id24}
由于每次请求的响应时间都不一样,我们不能只把它看成一个数字,而应将其视为一组可测量数值的 分布(distribution)。在{{< xref fig="2-5" page="/ch2" anchor="fig_lognormal" >}}图 2-5{{< /xref >}}中,每根灰色柱条代表一次服务请求,柱条的高度表示这次请求所花的时间。大多数请求都相当快,但偶尔会出现耗时长得多的 异常值。网络延迟的变化也称为 抖动(jitter)。
{{< fig num="2-5" id="fig_lognormal" src="/fig/ddia_0205.png" caption="用 100 次服务请求的响应时间样本说明平均值和分位数。" class="ddia-figure ddia-figure--panorama" width="2953" height="902" />}}
服务通常会报告 平均 响应时间(严格来说是 算术平均值:把所有响应时间相加,再除以请求数)。平均响应时间有助于估算吞吐量的上限 [^18]。不过,如果你想知道“典型”的响应时间,平均值就不是很好的指标,因为它没有告诉你究竟有多少用户实际经历了这样的等待。
通常,采用 分位数(percentile) 更合适。将响应时间从快到慢排列,中位数 就是位于正中间的值。例如,如果响应时间的中位数是 200 毫秒,就意味着一半请求用时不到 200 毫秒,另一半则需要更长时间。因此,如果想知道用户通常要等多久,中位数是个很好的指标。中位数也称为 第 50 分位数,有时缩写为 p50。
为了弄清异常值究竟有多糟,可以观察更高的分位数。常用的有第 95、99 和 99.9 分位数,分别缩写为 p95、p99 和 p999。它们对应这样一个响应时间阈值:分别有 95%、99% 或 99.9% 的请求快于这个阈值。例如,如果第 95 分位数的响应时间是 1.5 秒,就意味着每 100 个请求中,有 95 个用时不到 1.5 秒,另外 5 个则需要 1.5 秒或更久。{{< xref fig="2-5" page="/ch2" anchor="fig_lognormal" >}}图 2-5{{< /xref >}}对此作了说明。
响应时间的高分位数也称为 尾延迟(tail latencies),它们十分重要,因为会直接影响用户对服务的体验。例如,亚马逊用第 99.9 分位数来描述内部服务的响应时间要求,尽管它只影响每 1,000 个请求中的一个。这是因为,响应最慢的客户往往是在账户中积累了最多数据的人——他们购买过很多商品,也就是最有价值的客户 [^19]。确保网站对这些客户同样快速,是维持其满意度的重要手段。
另一方面,亚马逊认为,针对第 99.99 分位数(每 10,000 个请求中最慢的一个)进行优化,成本过高,收益又不足。降低极高分位数处的响应时间非常困难,因为它很容易受到不可控随机事件的影响,而且越往后收益越小。
[!TIP] 响应时间对用户的影响
从直觉上说,速度快的服务当然比慢的服务更受用户欢迎 [^20]。然而,要获得可靠数据,量化延迟对用户行为的影响,却出人意料地困难。
一些经常被引用的数据并不可靠。2006 年,Google 报告称,搜索结果的响应时间从 400 毫秒增加到 900 毫秒,与流量和收入下降 20% 存在相关性 [^21]。然而,Google 在 2009 年的另一项研究中又报告说,延迟增加 400 毫秒,只使每天的搜索次数减少了 0.6% [^22];同年,Bing 发现加载时间增加 2 秒会使广告收入减少 4.3% [^23]。这些公司似乎没有公开过更新的数据。
Akamai 一项较新的研究 [^24] 声称,响应时间增加 100 毫秒,会使电子商务网站的转化率最多下降 7%。然而仔细查看就会发现,同一项研究还显示,加载速度 非常快 的页面也与较低的转化率相关!这个看似矛盾的结果可以这样解释:加载最快的往往是没有有用内容的页面,例如 404 错误页面。该研究没有尝试把页面内容的影响与加载时间的影响区分开来,因此其结果恐怕没有什么意义。
Yahoo 的一项研究 [^25] 在控制搜索结果质量的前提下,比较了加载较快和较慢的搜索结果的点击率。研究发现,当快、慢响应相差 1.25 秒或更久时,快速搜索获得的点击量会多出 20%~30%。
响应时间指标的应用 {#sec_introduction_slo_sla}
如果后端服务要为一次最终用户请求执行多次调用,那么高分位数尤其重要。即使这些调用并行发出,最终用户请求仍要等待其中最慢的一次完成。正如{{< xref fig="2-6" page="/ch2" anchor="fig_tail_amplification" >}}图 2-6{{< /xref >}}所示,只需一个慢调用,就足以拖慢整个最终用户请求。即使后端调用中只有很小一部分速度较慢,一次最终用户请求所需的后端调用越多,其中出现慢调用的概率也就越大,最终会有更高比例的用户请求变慢。这种效应称为 尾部延迟放大(tail latency amplification) [^26]。
{{< fig num="2-6" id="fig_tail_amplification" src="/fig/ddia_0206.png" caption="当一次请求需要多个后端调用时,只需一个慢调用,就会拖慢整个最终用户请求。" class="ddia-figure ddia-figure--wide" width="2880" height="1304" />}}
分位数经常出现在 服务级别目标(SLO) 和 服务级别协议(SLA) 中,用来规定服务应达到的性能和可用性 [^27]。例如,一项 SLO 可能要求服务的中位响应时间低于 200 毫秒、第 99 分位数的响应时间低于 1 秒,并要求至少 99.9% 的有效请求得到非错误响应。SLA 则是一份合同,规定未达到 SLO 时会怎样处理(例如,客户可能有权获得退款)。这至少是其基本思路;在实践中,要为 SLO 和 SLA 定义良好的可用性指标并不简单 [^28] [^29]。
[!TIP] 计算分位数
如果想在服务的监控仪表板上加入响应时间分位数,就需要持续、高效地计算这些指标。例如,可以维护一个滚动窗口,记录最近 10 分钟内所有请求的响应时间;每隔一分钟,计算窗口中各个数值的中位数和其他分位数,并把这些指标绘制在图表上。
最简单的实现是保存时间窗口内所有请求的响应时间列表,并每分钟对它排序一次。如果这样做效率太低,也有一些算法能够以极低的 CPU 和内存开销,算出相当准确的分位数近似值。用于估算分位数的开源库包括 HdrHistogram、t-digest [^30] [^31]、OpenHistogram [^32] 和 DDSketch [^33]。
注意,对分位数取平均值——例如为了降低时间分辨率,或合并多台机器的数据——在数学上没有意义。聚合响应时间数据的正确方法是把直方图相加 [^34]。
可靠性与容错 {#sec_introduction_reliability}
每个人对于一个东西是否可靠,都有直观的判断。人们对可靠软件的典型期望包括:
- 应用程序表现出用户所期望的功能。
- 允许用户犯错,或以出乎意料的方式使用软件。
- 在预期的负载和数据量下,性能足以满足所需的使用场景。
- 系统能防止未经授权的访问和滥用。
如果把这些合在一起称为“正确工作”,那么 可靠性(reliability) 可以粗略理解为“即使出了问题,也能继续正确工作”。为了更准确地描述所谓“出了问题”,我们要区分 故障(fault) 和 失效(failure) [^35] [^36] [^37]:
故障
: 系统的某个 部分 停止正常工作。例如,单块硬盘发生故障、单台机器崩溃,或者系统所依赖的外部服务中断。
失效
: 整个系统 停止向用户提供所需的服务;换言之,系统没有达到服务级别目标(SLO)。
故障与失效之所以容易混淆,是因为二者其实是同一件事,只是观察的层次不同。例如,如果一块硬盘停止工作,我们会说这块硬盘失效了:若整个系统只有这一块硬盘,系统也就停止了提供所需服务。然而,如果我们谈论的是一个包含许多硬盘的系统,那么单块硬盘失效从整个系统的角度看只是一项故障;只要数据在另一块硬盘上还有副本,整个系统就可能容忍这项故障。
容错 {#id27}
如果某些故障发生时,系统仍能继续向用户提供所需服务,我们就称它是 容错的(fault-tolerant)。如果系统无法容忍某个部分出现故障,这个部分就称为 单点故障(SPOF),因为它一旦发生故障,就会升级为整个系统的失效。
以社交网络案例为例,扇出过程中可能发生这样一种故障:负责更新物化时间线的某台机器崩溃或变得不可用。要让这个过程具备容错能力,就必须确保另一台机器能接手这项任务,既不漏掉任何本应投递的帖子,也不会重复投递。(这个思想称为 恰好一次语义(exactly-once semantics),我们会在“数据库的端到端原则”中详细讨论。)
容错能力总是针对特定类型、特定数量的故障而言。例如,一个系统也许最多能容忍两块硬盘同时失效,或三个节点中有一个崩溃。要求系统容忍任意数量的故障没有意义:如果所有节点都崩溃了,任何办法都无济于事。如果整个地球(以及上面的所有服务器)都被黑洞吞噬,要容忍这项故障就得把网站托管到太空中——祝你好运,看看这笔预算能不能获批。
反直觉的是,在这类容错系统中,通过故意触发故障来 提高 故障率有时反而是合理的,例如毫无预警地随机杀死某个进程。这称为 故障注入(fault injection)。许多严重缺陷其实源于糟糕的错误处理 [^38];故意制造故障,可以让容错机制不断得到演练和检验,从而增强我们的信心:当故障自然发生时,系统确实能够正确处理。混沌工程(chaos engineering) 就是一门通过故障注入等实验来增强人们对容错机制信心的学科 [^39]。
虽然比起预防故障,我们通常更倾向于容忍故障,但有时预防确实胜于治疗(例如根本无药可救时)。安全问题就是如此:如果攻击者已经攻破系统并获得敏感数据,这件事无法撤销。不过,本书主要讨论的是能够补救的故障类型,下面几节会作进一步说明。
硬件与软件故障 {#sec_introduction_hardware_faults}
说到系统失效的原因,人们很容易首先想到硬件故障:
- 每年大约有 2%~5% 的机械硬盘发生故障 [^40] [^41];因此,在拥有 10,000 块硬盘的存储集群中,平均每天应该会有一块硬盘失效。近期数据表明硬盘越来越可靠,但故障率仍不容忽视 [^42]。
- 每年大约有 0.5%~1% 的固态硬盘(SSD)发生故障 [^43]。少量比特错误会自动得到纠正 [^44],但每块硬盘大约每年仍会发生一次无法纠正的错误,即使硬盘相当新(也就是磨损很少)也不例外;这个错误率高于机械硬盘 [^45] [^46]。
- 电源、RAID 控制器和内存模块等其他硬件组件也会发生故障,只是没有硬盘那么频繁 [^47] [^48]。
- 大约每 1,000 台机器中,就有一台的某个 CPU 核心偶尔会算出错误结果,原因很可能是制造缺陷 [^49] [^50] [^51]。错误计算有时会导致崩溃,有时却只是让程序返回错误的结果。
- RAM 中的数据也可能损坏,原因既可能是宇宙射线等随机事件,也可能是永久性的物理缺陷。即使采用纠错码(ECC)内存,一年内仍会有超过 1% 的机器遇到无法纠正的错误,通常会导致机器崩溃,并且需要更换受影响的内存模块 [^52]。此外,某些病态的内存访问模式很可能导致比特翻转 [^53]。
- 整个数据中心可能变得不可用(例如停电或网络配置错误),甚至遭到永久摧毁(例如火灾、洪水或地震 [^54])。太阳喷发大量带电粒子所形成的太阳风暴,会在长距离导线中感应出很强的电流,可能破坏电网和海底网络电缆 [^55]。这类大规模失效虽然少见,但如果服务不能容忍整个数据中心的丢失,后果可能是灾难性的 [^56]。
这些事件相当少见,因此在小型系统中,只要故障硬件很容易更换,通常不必为之过分担心。然而,在大规模系统里,硬件故障发生得足够频繁,已经成了系统正常运行的一部分。
通过冗余容忍硬件故障 {#tolerating-hardware-faults-through-redundancy}
面对不可靠的硬件,我们通常首先想到为各个硬件组件增加冗余,以降低整个系统的失效率。磁盘可以组成 RAID(把数据分散到同一台机器的多块磁盘上,使单块磁盘失效不至于造成数据丢失);服务器可以配备双路电源和可热插拔的 CPU;数据中心可以用电池和柴油发电机提供备用电源。这些冗余措施往往能让一台机器连续运行多年而不中断。
当各个组件的故障彼此独立时,冗余最有效;所谓独立,就是一项故障的发生不会改变另一项故障发生的概率。然而,实践表明,组件失效之间往往存在显著的相关性 [^41] [^57] [^58];整个服务器机架乃至整个数据中心不可用的情况,仍然比我们希望的更加常见。
硬件冗余可以提高单台机器的正常运行时间。不过,正如“分布式与单节点系统”中所述,采用分布式系统还有其他好处,例如能够容忍整个数据中心中断。因此,云系统往往不那么强调单台机器的可靠性,而是力求在软件层面容忍节点故障,让服务实现高可用。云提供商用 可用区(availability zone) 来标明哪些资源在物理上位于同一处;与地理位置分散的资源相比,同一地点的资源更可能同时失效。
本书讨论的容错技术,旨在容忍整台机器、整个机架或整个可用区的丢失。它们通常允许一个数据中心内的机器,在另一个数据中心内的机器发生故障或变得不可达时接替其工作。我们会在第 6 章、第 10 章以及本书其他多处讨论这类容错技术。
能够容忍整台机器丢失的系统,在运维上也有优势。如果需要重启机器(例如安装操作系统安全补丁),单服务器系统必须安排停机;而多节点容错系统可以逐个重启节点来安装补丁,不影响向用户提供服务。这称为 滚动升级(rolling upgrade),我们会在第 5 章进一步讨论。
软件故障 {#software-faults}
尽管硬件失效之间可能存在较弱的相关性,但大体上仍然相互独立。例如,一块硬盘失效以后,同一台机器上的其他硬盘很可能还能继续正常工作一段时间。相比之下,软件故障往往高度相关,因为许多节点通常运行同一套软件,也就带有同样的缺陷 [^59] [^60]。这类故障更难预见,而且比互不相关的硬件故障更容易造成系统失效 [^47]。例如:
- 一个软件缺陷在特定情况下导致所有节点同时失效。例如,2012 年 6 月 30 日的一次闰秒触发了 Linux 内核中的缺陷,使许多 Java 应用程序同时挂起,大量互联网服务随之中断 [^61]。另一个例子是,由于固件缺陷,某些型号的 SSD 会在恰好运行 32,768 小时(不到 4 年)后突然全部失效,盘上的数据再也无法恢复 [^62]。
- 某个失控进程耗尽 CPU 时间、内存、磁盘空间、网络带宽或线程等共享而有限的资源 [^63]。例如,进程在处理大型请求时消耗了过多内存,可能被操作系统杀死;客户端库中的缺陷也可能产生远高于预期的请求量 [^64]。
- 系统所依赖的某项服务变慢、失去响应,或开始返回内容损坏的响应。
- 不同系统之间的交互产生涌现行为,而每个系统单独测试时都不会出现这种行为 [^65]。
- 发生级联失效:一个组件的问题导致另一个组件过载并变慢,后者继而又拖垮下一个组件 [^66] [^67]。
引发这类软件故障的缺陷往往会潜伏很久,直到一组不同寻常的条件将其触发。这时人们才发现,软件原来对运行环境作出了某种假设;这个假设通常都成立,却最终会出于某种原因不再成立 [^68] [^69]。
软件中的系统性故障没有速效药,但许多小措施都能有所帮助:认真思考系统中的假设和交互,开展彻底的测试,隔离进程,允许进程崩溃并重启,避免重试风暴之类的反馈环路(参见“当过载系统无法恢复时”),并在生产环境中度量、监控和分析系统行为。
人类与可靠性 {#id31}
软件系统由人设计和构建,维持系统运行的运维人员同样也是人。与机器不同,人类不只是照章行事;他们的长处正是能够发挥创造力、随机应变,把工作完成。不过,这一特点也会带来不可预测性:即使出发点再好,人也会犯错,有时还会导致系统失效。例如,一项针对大型互联网服务的研究发现,运维人员修改配置是服务中断的首要原因,而硬件故障(服务器或网络)只在 10%~25% 的中断中起了作用 [^70]。
人们很容易把这类问题归结为“人为错误”,并幻想通过更严格的流程和更严密的规则来约束人的行为,从而解决问题。然而,把错误归咎于个人往往适得其反。所谓“人为错误”其实并不是事故的根本原因,而是人与技术共同构成的 社会技术系统 出了问题的一种症状;身处其中的人只是在竭尽所能地完成工作 [^71]。复杂系统也常常表现出涌现行为,组件之间出人意料的交互同样可能导致失效 [^72]。
多种技术手段都能减小人为失误的影响,包括:彻底测试(既包括手写测试,也包括用大量随机输入进行的 属性测试)[^38];提供回滚机制,以便迅速撤销配置变更;逐步发布新代码;提供详细而清晰的监控,以及用于诊断生产问题的可观测性工具(参见“分布式系统的问题”);精心设计界面,使“做正确的事”更加容易,“做错误的事”更加困难。
不过,这些措施都要投入时间和金钱。在日常经营的现实压力下,组织往往优先考虑能够创造收入的工作,而不是提高自身抵御失误能力的措施。如果必须在开发更多功能和开展更多测试之间选择,许多组织选择功能也不难理解。既然作出了这样的选择,当本可避免的错误不可避免地发生时,再去责怪犯错的人便毫无道理——真正的问题在于组织如何设定优先级。
越来越多的组织开始形成 无责复盘(blameless postmortem) 的文化:事故发生后,鼓励所有参与者毫无保留地讲清事情经过,不必担心受到惩罚;这样,组织中的其他人才能从中学习,避免今后再发生类似问题 [^73]。复盘过程也许会发现,业务优先级需要调整,长期遭到忽视的领域需要投入,相关人员的激励机制需要改变,或者还有其他系统性问题需要提请管理层关注。
一般而言,调查事故时应当警惕过分简单的答案。“鲍勃部署这项变更时应该更加小心”无助于解决问题,“我们必须用 Haskell 重写后端”同样如此。管理层应当抓住机会,从每天使用这个社会技术系统的一线人员那里了解它究竟如何运作,再根据这些反馈采取措施加以改进 [^71]。
[!TIP] 可靠性有多重要?
可靠性并不只对核电站和空中交通管制系统重要;人们同样期望更平常的应用能够可靠工作。商务应用程序中的缺陷会降低生产率(如果报告的数字有误,还会带来法律风险),电子商务网站中断则可能造成巨额收入损失,并损害企业声誉。
对许多应用来说,暂时中断几分钟乃至几小时尚可容忍 [^74],但永久丢失或损坏数据却会是一场灾难。设想一位家长把孩子所有的照片和视频都保存在你的照片应用中 [^75]。如果数据库突然损坏,他们会有什么感受?他们知道怎样从备份恢复吗?
英国邮局的 Horizon 丑闻,是不可靠的软件伤害人的另一个例子。1999 至 2019 年间,数百名经营英国邮局网点的人被判盗窃或欺诈罪,只因会计软件显示他们的账目存在短缺。最终人们发现,其中许多短缺其实源于软件缺陷,此后已有许多判决被撤销 [^76]。这场或许是英国历史上最大的司法不公之所以发生,是因为英格兰法律假定计算机能够正确运行,因此也假定计算机产生的证据可靠,除非有人能提出反证 [^77]。软件工程师也许会觉得“软件没有任何缺陷”的想法十分可笑,但那些因为不可靠的计算机系统而被错误定罪、遭到监禁、宣告破产,甚至自杀的人,却无法从中得到丝毫安慰。
在有些情况下,我们可能会为了降低开发成本而牺牲可靠性(例如为尚未验证的市场开发产品原型)。但我们必须清楚地意识到自己何时正在走捷径,并始终牢记可能造成的后果。
可伸缩性 {#sec_introduction_scalability}
系统今天能可靠运行,并不意味着将来也一定能够可靠运行。系统退化的一个常见原因是负载增加:也许并发用户从 10,000 人增长到了 100,000 人,或者从 100 万人增长到了 1,000 万人;也许系统现在处理的数据量比过去大得多。
可伸缩性(scalability) 是描述系统应对负载增长能力的术语。讨论可伸缩性时,人们有时会说:“你又不是 Google 或 Amazon。别再担心规模问题了,用关系数据库就好。”这句话是否适用于你,要看你构建的究竟是哪一类应用。
如果你正在构建一个目前用户不多的新产品,例如初创公司的新业务,压倒一切的工程目标通常是让系统尽可能简单、灵活;这样,随着你逐渐了解客户的需求,就能轻松修改和调整产品功能 [^78]。在这种环境中,为将来或许才需要的假想规模忧心忡忡,只会适得其反:往好里说,对可伸缩性的投入是白费力气和过早优化;往坏里说,它会把你困在一个不灵活的设计中,让应用更难演化。
这是因为,可伸缩性并不是一个一维的标签。简单地说“X 是可伸缩的”或“Y 无法伸缩”毫无意义。讨论可伸缩性,真正要考虑的是下面这些问题:
- “如果系统按某种方式增长,我们有哪些应对选项?”
- “我们如何增加计算资源来承载额外负载?”
- “按当前的增长预期,什么时候会达到现有架构的极限?”
如果应用大受欢迎,因而需要处理不断增长的负载,你会逐渐知道性能瓶颈在哪里,也就清楚系统需要沿着哪些维度扩展。到那时,再开始认真考虑可伸缩性技术也不迟。
描述负载 {#id33}
首先,我们需要简明地描述系统当前的负载;只有这样,才能继续讨论增长问题(例如负载翻倍会发生什么)。这种描述通常是某项吞吐量指标,例如服务每秒收到的请求数、每天新增多少 GB 数据,或每小时完成的购物车结账次数。有时,我们关心的是某个变量的峰值,例如“案例研究:社交网络首页时间线”中的同时在线用户数。
负载往往还有其他统计特征,它们同样会影响访问模式,进而影响系统对可伸缩性的要求。例如,你可能需要知道数据库的读写比例、缓存命中率,或每位用户拥有的数据项数量(例如社交网络案例中的关注者人数)。有时平均情况最重要,有时瓶颈却由少数极端情况主导。一切都取决于具体应用的细节。
描述好系统负载以后,就可以研究负载增加时会发生什么。我们可以从两个角度来看:
- 以某种方式增加负载,而系统资源(CPU、内存、网络带宽等)保持不变,系统性能会受到什么影响?
- 以某种方式增加负载,而你希望性能保持不变,需要增加多少资源?
通常,我们的目标是在满足 SLA 性能要求(参见“响应时间指标的应用”)的同时,尽可能降低系统的运行成本。所需的计算资源越多,成本就越高。某些硬件也许比另一些更具性价比,而随着新型硬件出现,这些因素也会随时间变化。
如果资源增加一倍,就能在性能不变的情况下处理两倍负载,我们称系统具备 线性可伸缩性,这通常是一件好事。偶尔,由于规模经济或峰值负载分布得更加均匀,不到两倍的资源也能处理两倍的负载 [^79] [^80]。更常见的情况是,成本增长得比线性更快,造成这种低效的原因可能有很多。例如,系统拥有大量数据时,即使写入请求本身大小相同,处理一次写入所需的工作也可能多于数据量较小时。
共享内存、共享磁盘与无共享架构 {#sec_introduction_shared_nothing}
增加服务硬件资源最简单的办法,就是把服务迁移到更强大的机器上。单个 CPU 核心的速度已经不再显著提高,但你仍可以买到(或在云上租用)配有更多 CPU 核心、更大 RAM 和更多磁盘空间的机器。这种方法称为 纵向扩展(vertical scaling),也叫 向上扩展(scaling up)。
在单台机器上运行多个进程或线程,可以获得并行处理能力。同一进程中的所有线程都能访问同一块 RAM,因此这种方法也称为 共享内存架构(shared-memory architecture)。共享内存方案的问题在于,成本增长得比线性更快:硬件资源多一倍的高端机器,价格通常远远不止两倍;受各种瓶颈限制,一台规模翻倍的机器又往往处理不了两倍的负载。
另一种方案是 共享磁盘架构(shared-disk architecture):多台机器分别拥有独立的 CPU 和 RAM,却把数据存储在共同访问的一组磁盘阵列中,机器与磁盘通过高速网络连接,例如 网络附加存储(NAS) 或 存储区域网络(SAN)。这种架构过去常用于本地部署的数据仓库工作负载,但资源争用和加锁开销限制了共享磁盘方案的可伸缩性 [^81]。
相比之下,无共享架构(shared-nothing architecture) [^82](也称为 水平扩展(horizontal scaling) 或 向外扩展(scaling out))已经广受欢迎。这种方案采用包含多个节点的分布式系统,每个节点都拥有自己的 CPU、RAM 和磁盘;节点之间的一切协调,都通过普通网络在软件层完成。
无共享架构的优势是:它有望实现线性伸缩;可以使用任何性价比最好的硬件,在云端尤其如此;负载增减时更容易调整硬件资源;还可以把系统分布到多个数据中心和地域,以获得更强的容错能力。缺点则是必须显式进行分片(参见第 7 章),并且要面对分布式系统的全部复杂性(参见第 9 章)。
一些云原生数据库系统把存储和事务执行拆分成不同的服务(参见“存储与计算的分离”),让多个计算节点共享同一项存储服务。这个模型与共享磁盘架构有几分相似,但避开了老式系统的可伸缩性问题:存储服务提供的不是文件系统(NAS)或块设备(SAN)抽象,而是一套针对数据库具体需求设计的专用 API [^83]。
可伸缩性原则 {#id35}
大规模系统的架构通常高度依赖具体应用,不存在一套通用、放之四海而皆准的可伸缩架构(俗称 万金油(magic scaling sauce))。例如,处理每秒 100,000 个请求、每个请求 1 kB 的系统,与每分钟只处理 3 个请求、每个请求却有 2 GB 的系统,看起来会截然不同——尽管二者的数据吞吐量同为 100 MB/s。
而且,适合某个负载水平的架构,多半应付不了十倍于此的负载。如果你正在开发一个快速增长的服务,很可能每当负载增加一个数量级,就需要重新考虑架构。应用需求本身也很可能不断变化,因此提前为超过一个数量级之后的伸缩需求作规划,通常并不值得。
可伸缩性有一项很好的通用原则:把系统拆分成较小的组件,使它们大体上能够彼此独立地运行。这是微服务(参见“微服务与无服务器”)、分片(第 7 章)、流处理(第 12 章)和无共享架构背后的共同原则。不过,真正的挑战在于判断哪些东西应该放在一起,哪些东西应该拆开。其他书籍介绍了微服务的设计准则 [^84];本书则会在第 7 章讨论无共享系统中的分片。
另一项好原则是,不要让系统变得比必要的更加复杂。如果单机数据库足以完成任务,它很可能比复杂的分布式配置更可取。自动伸缩系统会根据需求自动增加或移除资源,的确很酷;但如果负载相当可预测,手动伸缩的系统在运维中也许更少出现意外(参见“运维:自动/手动再平衡”)。由 5 个服务组成的系统比由 50 个服务组成的系统更简单。优秀的架构通常务实地混合了多种方案。
可维护性 {#sec_introduction_maintainability}
软件不会磨损,也不会像机械装置那样发生材料疲劳,因此它不会以同样的方式损坏。不过,应用程序的需求经常变化,软件运行的环境也会变化(例如依赖项和底层平台),而且软件中总有缺陷需要修复。
众所周知,软件的大部分成本不在最初的开发阶段,而在持续的维护阶段,包括修复缺陷、保持系统正常运行、调查失效、适配新的平台、为新的使用场景进行修改、偿还技术债和添加新功能 [^85] [^86]。
然而,维护工作本身也很困难。一个成功运行多年的系统,很可能仍在使用如今已经没有多少工程师了解的过时技术,例如大型机和 COBOL 代码;随着人员离开组织,关于系统为何如此设计、怎样设计的组织知识可能已经丢失;维护者也许不得不修正前人留下的错误。而且,计算机系统往往与它所支撑的组织紧密交织在一起,这意味着维护这样的 遗留(legacy) 系统既是人的问题,也是技术问题 [^87]。
我们今天构建的每个系统,只要足够有价值、能够长期存续,终有一天都会成为遗留系统。为了尽量减轻以后维护软件的人所承受的痛苦,我们设计软件时就应该考虑维护问题。虽然无法事先断定哪些决定会在未来制造维护难题,但本书会特别关注几项具有广泛适用性的原则:
可运维性(operability)
: 便于组织保持系统平稳运行。
简单性(simplicity)
: 采用人们熟知且前后一致的模式和结构,避免不必要的复杂度,使新工程师也能轻松理解系统。
可演化性(evolvability)
: 便于工程师将来修改系统,在需求变化时调整和扩展系统,以适应事先没有预料到的使用场景。
可运维性:让运维更轻松 {#id37}
我们已经在“云时代的运维”中讨论过运维的作用,并且看到,要实现可靠运维,人的流程至少与软件工具同等重要。事实上,有人认为:“良好的运维往往能绕开糟糕(或不完整)软件的局限,但即使软件很好,糟糕的运维也无法让它可靠运行” [^60]。
大规模系统由成千上万台机器组成,纯靠人工维护,成本高得难以承受,因此自动化必不可少。然而,自动化是一把双刃剑:总会有一些边缘情况(例如罕见的故障场景)需要运维团队人工干预。自动化无法处理的恰恰是最复杂的问题,所以自动化程度越高,反而越需要一支技能 更强 的运维团队来解决这些问题 [^88]。
而且,自动化系统一旦出错,往往比依靠运维人员手工完成某些操作的系统更难排查。因此,对可运维性而言,自动化并非总是越多越好。一定程度的自动化依然很重要,最佳平衡点则取决于具体应用和组织的情况。
良好的可运维性意味着让日常工作更加轻松,使运维团队能把精力集中在高价值的任务上。数据系统可以通过多种方式简化日常工作 [^89]:
- 允许监控工具检查系统的关键指标,并支持可观测性工具(参见“分布式系统的问题”),以便深入了解系统运行时的行为。许多商业工具和开源工具都能在这方面提供帮助 [^90]。
- 避免依赖任何一台机器,使机器可以下线维护,而整个系统仍能不间断地运行。
- 提供良好的文档和易于理解的操作模型(“如果我做 X,就会发生 Y”)。
- 提供良好的默认行为,同时允许管理员在必要时覆盖默认设置。
- 在适当的时候自动修复,同时也允许管理员在必要时手动控制系统状态。
- 表现出可预测的行为,尽量避免出人意料。
简单性:管理复杂度 {#id38}
小型软件项目可以拥有简单讨喜、富有表现力的代码;但随着项目不断扩大,代码往往变得非常复杂,难以理解。这种复杂度拖慢了每一个需要在系统上工作的人,进一步增加了维护成本。一个陷入复杂泥潭的软件项目有时被称为 大泥球(big ball of mud) [^91]。
当复杂度使维护变得困难时,预算和进度安排往往都会超支。修改复杂软件也更容易引入缺陷:系统越难理解和推理,开发人员就越容易忽略隐藏的假设、无意的后果和意外的交互 [^69]。反过来,降低复杂度可以极大提高软件的可维护性,因此简单性应该成为我们构建系统时的一项关键目标。
简单的系统更容易理解,因此我们应该尽可能用最简单的办法解决给定的问题。可惜,说起来容易,做起来却很难。事物是否简单往往取决于主观品味,并不存在衡量简单性的客观标准 [^92]。例如,一个系统可能把复杂实现隐藏在简单接口背后,另一个系统的实现本身很简单,却向用户暴露了更多内部细节——究竟哪一个更简单?
人们曾尝试把复杂度分成 本质复杂度(essential complexity) 和 偶然复杂度(accidental complexity) 两类,以此对复杂度进行推理 [^93]。按照这种思路,本质复杂度是应用程序问题领域所固有的,而偶然复杂度只因工具的局限而产生。不幸的是,这种区分也有缺陷,因为随着工具不断演进,本质复杂度与偶然复杂度之间的界限也会发生变化 [^94]。
管理复杂度最好的工具之一是 抽象(abstraction)。一个好的抽象可以把大量实现细节隐藏在干净、简单易懂的外观之下,也可以广泛用于各种不同的应用。复用抽象不仅比一遍遍重新实现类似功能更加高效,也能带来更高质量的软件,因为抽象组件的质量得到改进,所有使用它的应用都会从中受益。
例如,高级编程语言是一种抽象,隐藏了机器码、CPU 寄存器和系统调用。SQL 也是一种抽象,隐藏了复杂的磁盘和内存数据结构、其他客户端发出的并发请求,以及崩溃后产生的不一致。当然,使用高级语言编程时,我们仍然用到了机器码;只不过没有 直接 使用它,因为编程语言的抽象让我们不必考虑这些细节。
为了降低应用程序代码的复杂度,可以借助 设计模式 [^95] 和 领域驱动设计(DDD) [^96] 等方法来构建抽象。本书讨论的不是这类应用专用的抽象,而是数据库事务、索引和事件日志等通用抽象;你可以在它们之上构建应用。如果你想采用 DDD 等方法,也可以把它们实现于本书所述的基础之上。
可演化性:让变化更容易 {#sec_introduction_evolvability}
系统的需求永远不变,基本是不可能的。更可能的情况是,需求总在变化:你了解了新的事实,出现了事先未曾预料的使用场景,业务优先级发生变化,用户要求新功能,新平台取代旧平台,法律或监管要求改变,系统增长迫使架构发生变化,等等。
在组织流程方面,敏捷(Agile) 工作模式为适应变化提供了框架。敏捷社区还发展出了适合在频繁变化的环境中开发软件的技术工具和流程,例如测试驱动开发(TDD)和重构。本书则会寻找一些办法,在由多个特性各异的应用程序或服务组成的系统层面上提高敏捷性。
修改数据系统、使其适应不断变化的需求有多容易,与系统的简单性和抽象密切相关:松耦合、简单的系统通常比紧耦合、复杂的系统更容易修改。这个概念如此重要,因此我们用一个不同的词来指代数据系统层面的敏捷性:可演化性(evolvability) [^97]。
大型系统中的某些操作不可逆,因此必须极为谨慎地执行;这是让变更变得困难的一个主要因素 [^98]。例如,假设你要从一个数据库迁移到另一个数据库:如果新系统出了问题却无法切回旧系统,风险就远高于能够轻松回退的情况。尽量减少不可逆性,可以提高系统的灵活性。
总结 {#summary}
本章考察了几种非功能性需求:性能、可靠性、可伸缩性和可维护性。在讨论这些主题的过程中,我们还遇到了贯穿全书都要用到的一些原则和术语。本章从社交网络首页时间线的实现案例入手,说明了系统规模增大时会出现的部分挑战。
我们讨论了如何衡量性能(例如采用响应时间分位数)、如何衡量系统负载(例如采用吞吐量指标),以及怎样在 SLA 中使用这些指标。可伸缩性与此密切相关:负载增加时,怎样确保性能保持不变。我们看到了一些关于可伸缩性的通用原则,例如把一项任务拆分成彼此可以独立运行的较小部分;后续章节还会深入探讨实现可伸缩性的技术细节。
为了实现可靠性,可以采用容错技术,使系统即使有某个组件(例如硬盘、机器或另一项服务)发生故障,仍能继续提供服务。我们考察了可能发生的各种硬件故障,并把它们与软件故障区分开来;软件故障往往高度相关,因此更难处理。提高可靠性的另一方面,是增强系统抵御人为失误的能力;我们还看到,无责复盘可以帮助组织从事故中学习。
最后,我们讨论了可维护性的几个方面,包括为运维团队的工作提供支持、管理复杂度,以及让应用程序的功能更容易随时间演化。实现这些目标没有简单的答案,但采用人们熟知、能够提供实用抽象的构件来搭建应用程序,确实会有所帮助。本书余下部分将介绍一系列已经在实践中证明颇有价值的构件。
参考文献
[^1]: Mike Cvet. How We Learned to Stop Worrying and Love Fan-In at Twitter. At QCon San Francisco, December 2016.
[^2]: Raffi Krikorian. Timelines at Scale. At QCon San Francisco, November 2012. Archived at perma.cc/V9G5-KLYK
[^3]: Twitter. Twitter’s Recommendation Algorithm. blog.twitter.com, March 2023. Archived at perma.cc/L5GT-229T
[^4]: Raffi Krikorian. New Tweets per second record, and how! blog.twitter.com, August 2013. Archived at perma.cc/6JZN-XJYN
[^5]: Jaz Volpert. When Imperfect Systems are Good, Actually: Bluesky’s Lossy Timelines. jazco.dev, February 2025. Archived at perma.cc/2PVE-L2MX
[^6]: Samuel Axon. 3% of Twitter’s Servers Dedicated to Justin Bieber. mashable.com, September 2010. Archived at perma.cc/F35N-CGVX
[^7]: Nathan Bronson, Abutalib Aghayev, Aleksey Charapko, and Timothy Zhu. Metastable Failures in Distributed Systems. At Workshop on Hot Topics in Operating Systems (HotOS), May 2021. doi:10.1145/3458336.3465286
[^8]: Marc Brooker. Metastability and Distributed Systems. brooker.co.za, May 2021. Archived at perma.cc/7FGJ-7XRK
[^9]: Marc Brooker. Exponential Backoff And Jitter. aws.amazon.com, March 2015. Archived at perma.cc/R6MS-AZKH
[^10]: Marc Brooker. What is Backoff For? brooker.co.za, August 2022. Archived at perma.cc/PW9N-55Q5
[^11]: Michael T. Nygard. Release It!, 2nd Edition. Pragmatic Bookshelf, January 2018. ISBN: 9781680502398
[^12]: Frank Chen. Slowing Down to Speed Up – Circuit Breakers for Slack’s CI/CD. slack.engineering, August 2022. Archived at perma.cc/5FGS-ZPH3
[^13]: Marc Brooker. Fixing retries with token buckets and circuit breakers. brooker.co.za, February 2022. Archived at perma.cc/MD6N-GW26
[^14]: David Yanacek. Using load shedding to avoid overload. Amazon Builders’ Library, aws.amazon.com. Archived at perma.cc/9SAW-68MP
[^15]: Matthew Sackman. Pushing Back. wellquite.org, May 2016. Archived at perma.cc/3KCZ-RUFY
[^16]: Dmitry Kopytkov and Patrick Lee. Meet Bandaid, the Dropbox service proxy. dropbox.tech, March 2018. Archived at perma.cc/KUU6-YG4S
[^17]: Haryadi S. Gunawi, Riza O. Suminto, Russell Sears, Casey Golliher, Swaminathan Sundararaman, Xing Lin, Tim Emami, Weiguang Sheng, Nematollah Bidokhti, Caitie McCaffrey, Gary Grider, Parks M. Fields, Kevin Harms, Robert B. Ross, Andree Jacobson, Robert Ricci, Kirk Webb, Peter Alvaro, H. Birali Runesha, Mingzhe Hao, and Huaicheng Li. Fail-Slow at Scale: Evidence of Hardware Performance Faults in Large Production Systems. At 16th USENIX Conference on File and Storage Technologies, February 2018.
[^18]: Marc Brooker. Is the Mean Really Useless? brooker.co.za, December 2017. Archived at perma.cc/U5AE-CVEM
[^19]: Giuseppe DeCandia, Deniz Hastorun, Madan Jampani, Gunavardhan Kakulapati, Avinash Lakshman, Alex Pilchin, Swaminathan Sivasubramanian, Peter Vosshall, and Werner Vogels. Dynamo: Amazon’s Highly Available Key-Value Store. At 21st ACM Symposium on Operating Systems Principles (SOSP), October 2007. doi:10.1145/1294261.1294281
[^20]: Kathryn Whitenton. The Need for Speed, 23 Years Later. nngroup.com, May 2020. Archived at perma.cc/C4ER-LZYA
[^21]: Greg Linden. Marissa Mayer at Web 2.0. glinden.blogspot.com, November 2005. Archived at perma.cc/V7EA-3VXB
[^22]: Jake Brutlag. Speed Matters for Google Web Search. services.google.com, June 2009. Archived at perma.cc/BK7R-X7M2
[^23]: Eric Schurman and Jake Brutlag. Performance Related Changes and their User Impact. Talk at Velocity 2009.
[^24]: Akamai Technologies, Inc. The State of Online Retail Performance. akamai.com, April 2017. Archived at perma.cc/UEK2-HYCS
[^25]: Xiao Bai, Ioannis Arapakis, B. Barla Cambazoglu, and Ana Freire. Understanding and Leveraging the Impact of Response Latency on User Behaviour in Web Search. ACM Transactions on Information Systems, volume 36, issue 2, article 21, April 2018. doi:10.1145/3106372
[^26]: Jeffrey Dean and Luiz André Barroso. The Tail at Scale. Communications of the ACM, volume 56, issue 2, pages 74–80, February 2013. doi:10.1145/2408776.2408794
[^27]: Alex Hidalgo. Implementing Service Level Objectives: A Practical Guide to SLIs, SLOs, and Error Budgets. O’Reilly Media, September 2020. ISBN: 1492076813
[^28]: Jeffrey C. Mogul and John Wilkes. Nines are Not Enough: Meaningful Metrics for Clouds. At 17th Workshop on Hot Topics in Operating Systems (HotOS), May 2019. doi:10.1145/3317550.3321432
[^29]: Tamás Hauer, Philipp Hoffmann, John Lunney, Dan Ardelean, and Amer Diwan. Meaningful Availability. At 17th USENIX Symposium on Networked Systems Design and Implementation (NSDI), February 2020.
[^30]: Ted Dunning. The t-digest: Efficient estimates of distributions. Software Impacts, volume 7, article 100049, February 2021. doi:10.1016/j.simpa.2020.100049
[^31]: David Kohn. How percentile approximation works (and why it’s more useful than averages). timescale.com, September 2021. Archived at perma.cc/3PDP-NR8B
[^32]: Heinrich Hartmann and Theo Schlossnagle. Circllhist — A Log-Linear Histogram Data Structure for IT Infrastructure Monitoring. arxiv.org, January 2020.
[^33]: Charles Masson, Jee E. Rim, and Homin K. Lee. DDSketch: A Fast and Fully-Mergeable Quantile Sketch with Relative-Error Guarantees. Proceedings of the VLDB Endowment, volume 12, issue 12, pages 2195–2205, August 2019. doi:10.14778/3352063.3352135
[^34]: Baron Schwartz. Why Percentiles Don’t Work the Way You Think. solarwinds.com, November 2016. Archived at perma.cc/469T-6UGB
[^35]: Walter L. Heimerdinger and Charles B. Weinstock. A Conceptual Framework for System Fault Tolerance. Technical Report CMU/SEI-92-TR-033, Software Engineering Institute, Carnegie Mellon University, October 1992. Archived at perma.cc/GD2V-DMJW
[^36]: Felix C. Gärtner. Fundamentals of fault-tolerant distributed computing in asynchronous environments. ACM Computing Surveys, volume 31, issue 1, pages 1–26, March 1999. doi:10.1145/311531.311532
[^37]: Algirdas Avižienis, Jean-Claude Laprie, Brian Randell, and Carl Landwehr. Basic Concepts and Taxonomy of Dependable and Secure Computing. IEEE Transactions on Dependable and Secure Computing, volume 1, issue 1, January 2004. doi:10.1109/TDSC.2004.2
[^38]: Ding Yuan, Yu Luo, Xin Zhuang, Guilherme Renna Rodrigues, Xu Zhao, Yongle Zhang, Pranay U. Jain, and Michael Stumm. Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-Intensive Systems. At 11th USENIX Symposium on Operating Systems Design and Implementation (OSDI), October 2014.
[^39]: Casey Rosenthal and Nora Jones. Chaos Engineering. O’Reilly Media, April 2020. ISBN: 9781492043867
[^40]: Eduardo Pinheiro, Wolf-Dietrich Weber, and Luiz Andre Barroso. Failure Trends in a Large Disk Drive Population. At 5th USENIX Conference on File and Storage Technologies (FAST), February 2007.
[^41]: Bianca Schroeder and Garth A. Gibson. Disk failures in the real world: What does an MTTF of 1,000,000 hours mean to you? At 5th USENIX Conference on File and Storage Technologies (FAST), February 2007.
[^42]: Andy Klein. Backblaze Drive Stats for Q2 2021. backblaze.com, August 2021. Archived at perma.cc/2943-UD5E
[^43]: Iyswarya Narayanan, Di Wang, Myeongjae Jeon, Bikash Sharma, Laura Caulfield, Anand Sivasubramaniam, Ben Cutler, Jie Liu, Badriddine Khessib, and Kushagra Vaid. SSD Failures in Datacenters: What? When? and Why? At 9th ACM International on Systems and Storage Conference (SYSTOR), June 2016. doi:10.1145/2928275.2928278
[^44]: Alibaba Cloud Storage Team. Storage System Design Analysis: Factors Affecting NVMe SSD Performance (1). alibabacloud.com, January 2019. Archived at archive.org
[^45]: Bianca Schroeder, Raghav Lagisetty, and Arif Merchant. Flash Reliability in Production: The Expected and the Unexpected. At 14th USENIX Conference on File and Storage Technologies (FAST), February 2016.
[^46]: Jacob Alter, Ji Xue, Alma Dimnaku, and Evgenia Smirni. SSD failures in the field: symptoms, causes, and prediction models. At International Conference for High Performance Computing, Networking, Storage and Analysis (SC), November 2019. doi:10.1145/3295500.3356172
[^47]: Daniel Ford, François Labelle, Florentina I. Popovici, Murray Stokely, Van-Anh Truong, Luiz Barroso, Carrie Grimes, and Sean Quinlan. Availability in Globally Distributed Storage Systems. At 9th USENIX Symposium on Operating Systems Design and Implementation (OSDI), October 2010.
[^48]: Kashi Venkatesh Vishwanath and Nachiappan Nagappan. Characterizing Cloud Computing Hardware Reliability. At 1st ACM Symposium on Cloud Computing (SoCC), June 2010. doi:10.1145/1807128.1807161
[^49]: Peter H. Hochschild, Paul Turner, Jeffrey C. Mogul, Rama Govindaraju, Parthasarathy Ranganathan, David E. Culler, and Amin Vahdat. Cores that don’t count. At Workshop on Hot Topics in Operating Systems (HotOS), June 2021. doi:10.1145/3458336.3465297
[^50]: Harish Dattatraya Dixit, Sneha Pendharkar, Matt Beadon, Chris Mason, Tejasvi Chakravarthy, Bharath Muthiah, and Sriram Sankar. Silent Data Corruptions at Scale. arXiv:2102.11245, February 2021.
[^51]: Diogo Behrens, Marco Serafini, Sergei Arnautov, Flavio P. Junqueira, and Christof Fetzer. Scalable Error Isolation for Distributed Systems. At 12th USENIX Symposium on Networked Systems Design and Implementation (NSDI), May 2015.
[^52]: Bianca Schroeder, Eduardo Pinheiro, and Wolf-Dietrich Weber. DRAM Errors in the Wild: A Large-Scale Field Study. At 11th International Joint Conference on Measurement and Modeling of Computer Systems (SIGMETRICS), June 2009. doi:10.1145/1555349.1555372
[^53]: Yoongu Kim, Ross Daly, Jeremie Kim, Chris Fallin, Ji Hye Lee, Donghyuk Lee, Chris Wilkerson, Konrad Lai, and Onur Mutlu. Flipping Bits in Memory Without Accessing Them: An Experimental Study of DRAM Disturbance Errors. At 41st Annual International Symposium on Computer Architecture (ISCA), June 2014. doi:10.1145/2678373.2665726
[^54]: Tim Bray. Worst Case. tbray.org, October 2021. Archived at perma.cc/4QQM-RTHN
[^55]: Sangeetha Abdu Jyothi. Solar Superstorms: Planning for an Internet Apocalypse. At ACM SIGCOMM Conferene, August 2021. doi:10.1145/3452296.3472916
[^56]: Adrian Cockcroft. Failure Modes and Continuous Resilience. adrianco.medium.com, November 2019. Archived at perma.cc/7SYS-BVJP
[^57]: Shujie Han, Patrick P. C. Lee, Fan Xu, Yi Liu, Cheng He, and Jiongzhou Liu. An In-Depth Study of Correlated Failures in Production SSD-Based Data Centers. At 19th USENIX Conference on File and Storage Technologies (FAST), February 2021.
[^58]: Edmund B. Nightingale, John R. Douceur, and Vince Orgovan. Cycles, Cells and Platters: An Empirical Analysis of Hardware Failures on a Million Consumer PCs. At 6th European Conference on Computer Systems (EuroSys), April 2011. doi:10.1145/1966445.1966477
[^59]: Haryadi S. Gunawi, Mingzhe Hao, Tanakorn Leesatapornwongsa, Tiratat Patana-anake, Thanh Do, Jeffry Adityatama, Kurnia J. Eliazar, Agung Laksono, Jeffrey F. Lukman, Vincentius Martin, and Anang D. Satria. What Bugs Live in the Cloud? At 5th ACM Symposium on Cloud Computing (SoCC), November 2014. doi:10.1145/2670979.2670986
[^60]: Jay Kreps. Getting Real About Distributed System Reliability. blog.empathybox.com, March 2012. Archived at perma.cc/9B5Q-AEBW
[^61]: Nelson Minar. Leap Second Crashes Half the Internet. somebits.com, July 2012. Archived at perma.cc/2WB8-D6EU
[^62]: Hewlett Packard Enterprise. Support Alerts – Customer Bulletin a00092491en_us. support.hpe.com, November 2019. Archived at perma.cc/S5F6-7ZAC
[^63]: Lorin Hochstein. awesome limits. github.com, November 2020. Archived at perma.cc/3R5M-E5Q4
[^64]: Caitie McCaffrey. Clients Are Jerks: AKA How Halo 4 DoSed the Services at Launch & How We Survived. caitiem.com, June 2015. Archived at perma.cc/MXX4-W373
[^65]: Lilia Tang, Chaitanya Bhandari, Yongle Zhang, Anna Karanika, Shuyang Ji, Indranil Gupta, and Tianyin Xu. Fail through the Cracks: Cross-System Interaction Failures in Modern Cloud Systems. At 18th European Conference on Computer Systems (EuroSys), May 2023. doi:10.1145/3552326.3587448
[^66]: Mike Ulrich. Addressing Cascading Failures. In Betsy Beyer, Jennifer Petoff, Chris Jones, and Niall Richard Murphy (ed). Site Reliability Engineering: How Google Runs Production Systems. O’Reilly Media, 2016. ISBN: 9781491929124
[^67]: Harri Faßbender. Cascading failures in large-scale distributed systems. blog.mi.hdm-stuttgart.de, March 2022. Archived at perma.cc/K7VY-YJRX
[^68]: Richard I. Cook. How Complex Systems Fail. Cognitive Technologies Laboratory, April 2000. Archived at perma.cc/RDS6-2YVA
[^69]: David D. Woods. STELLA: Report from the SNAFUcatchers Workshop on Coping With Complexity. snafucatchers.github.io, March 2017. Archived at archive.org
[^70]: David Oppenheimer, Archana Ganapathi, and David A. Patterson. Why Do Internet Services Fail, and What Can Be Done About It? At 4th USENIX Symposium on Internet Technologies and Systems (USITS), March 2003.
[^71]: Sidney Dekker. The Field Guide to Understanding ‘Human Error’, 3rd Edition. CRC Press, November 2017. ISBN: 9781472439055
[^72]: Sidney Dekker. Drift into Failure: From Hunting Broken Components to Understanding Complex Systems. CRC Press, 2011. ISBN: 9781315257396
[^73]: John Allspaw. Blameless PostMortems and a Just Culture. etsy.com, May 2012. Archived at perma.cc/YMJ7-NTAP
[^74]: Itzy Sabo. Uptime Guarantees — A Pragmatic Perspective. world.hey.com, March 2023. Archived at perma.cc/F7TU-78JB
[^75]: Michael Jurewitz. The Human Impact of Bugs. jury.me, March 2013. Archived at perma.cc/5KQ4-VDYL
[^76]: Mark Halper. How Software Bugs led to ‘One of the Greatest Miscarriages of Justice’ in British History. Communications of the ACM, January 2025. doi:10.1145/3703779
[^77]: Nicholas Bohm, James Christie, Peter Bernard Ladkin, Bev Littlewood, Paul Marshall, Stephen Mason, Martin Newby, Steven J. Murdoch, Harold Thimbleby, and Martyn Thomas. The legal rule that computers are presumed to be operating correctly – unforeseen and unjust consequences. Briefing note, benthamsgaze.org, June 2022. Archived at perma.cc/WQ6X-TMW4
[^78]: Dan McKinley. Choose Boring Technology. mcfunley.com, March 2015. Archived at perma.cc/7QW7-J4YP
[^79]: Andy Warfield. Building and operating a pretty big storage system called S3. allthingsdistributed.com, July 2023. Archived at perma.cc/7LPK-TP7V
[^80]: Marc Brooker. Surprising Scalability of Multitenancy. brooker.co.za, March 2023. Archived at perma.cc/ZZD9-VV8T
[^81]: Ben Stopford. Shared Nothing vs. Shared Disk Architectures: An Independent View. benstopford.com, November 2009. Archived at perma.cc/7BXH-EDUR
[^82]: Michael Stonebraker. The Case for Shared Nothing. IEEE Database Engineering Bulletin, volume 9, issue 1, pages 4–9, March 1986.
[^83]: Panagiotis Antonopoulos, Alex Budovski, Cristian Diaconu, Alejandro Hernandez Saenz, Jack Hu, Hanuma Kodavalla, Donald Kossmann, Sandeep Lingam, Umar Farooq Minhas, Naveen Prakash, Vijendra Purohit, Hugh Qu, Chaitanya Sreenivas Ravella, Krystyna Reisteter, Sheetal Shrotri, Dixin Tang, and Vikram Wakade. Socrates: The New SQL Server in the Cloud. At ACM International Conference on Management of Data (SIGMOD), pages 1743–1756, June 2019. doi:10.1145/3299869.3314047
[^84]: Sam Newman. Building Microservices, second edition. O’Reilly Media, 2021. ISBN: 9781492034025
[^85]: Nathan Ensmenger. When Good Software Goes Bad: The Surprising Durability of an Ephemeral Technology. At The Maintainers Conference, April 2016. Archived at perma.cc/ZXT4-HGZB
[^86]: Robert L. Glass. Facts and Fallacies of Software Engineering. Addison-Wesley Professional, October 2002. ISBN: 9780321117427
[^87]: Marianne Bellotti. Kill It with Fire. No Starch Press, April 2021. ISBN: 9781718501188
[^88]: Lisanne Bainbridge. Ironies of automation. Automatica, volume 19, issue 6, pages 775–779, November 1983. doi:10.1016/0005-1098(83)90046-8
[^89]: James Hamilton. On Designing and Deploying Internet-Scale Services. At 21st Large Installation System Administration Conference (LISA), November 2007.
[^90]: Dotan Horovits. Open Source for Better Observability. horovits.medium.com, October 2021. Archived at perma.cc/R2HD-U2ZT
[^91]: Brian Foote and Joseph Yoder. Big Ball of Mud. At 4th Conference on Pattern Languages of Programs (PLoP), September 1997. Archived at perma.cc/4GUP-2PBV
[^92]: Marc Brooker. What is a simple system? brooker.co.za, May 2022. Archived at perma.cc/U72T-BFVE
[^93]: Frederick P. Brooks. No Silver Bullet – Essence and Accident in Software Engineering. In The Mythical Man-Month, Anniversary edition, Addison-Wesley, 1995. ISBN: 9780201835953
[^94]: Dan Luu. Against essential and accidental complexity. danluu.com, December 2020. Archived at perma.cc/H5ES-69KC
[^95]: Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley Professional, October 1994. ISBN: 9780201633610
[^96]: Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley Professional, August 2003. ISBN: 9780321125217
[^97]: Hongyu Pei Breivold, Ivica Crnkovic, and Peter J. Eriksson. Analyzing Software Evolvability. at 32nd Annual IEEE International Computer Software and Applications Conference (COMPSAC), July 2008. doi:10.1109/COMPSAC.2008.50
[^98]: Enrico Zaninotto. From X programming to the X organisation. At XP Conference, May 2002. Archived at perma.cc/R9AR-QCKZ
File: content/zh/ch3.md
title: 数据模型与查询语言 book_kind: chapter book_number: "3" book_part: I weight: 103 breadcrumbs: false
我的语言的边界,意味着我的世界的边界。
路德维希・维特根斯坦,《逻辑哲学论》(1922)
数据模型可能是软件开发中最重要的部分了,因为它们的影响如此深远:不仅仅影响着软件的编写方式,而且影响着我们的 解题思路。
多数应用使用层层叠加的数据模型构建。对于每层数据模型的关键问题是:它是如何用低一层数据模型来 表示 的?例如:
- 作为一名应用开发人员,你观察现实世界(里面有人员、组织、货物、行为、资金流向、传感器等),并采用对象或数据结构,以及操控那些数据结构的 API 来进行建模。那些结构通常是特定于应用程序的。
- 当要存储那些数据结构时,你可以利用通用数据模型来表示它们,如 JSON 或 XML 文档、关系数据库中的表,或者图中的顶点和边。这些数据模型正是本章的主题。
- 构建数据库软件的工程师决定如何以内存、磁盘或网络上的字节来表示文档、关系或图数据。这类表示形式使数据有可能以各种方式来查询、搜索、操纵和处理。我们将在 第 4 章 讨论这些存储引擎的设计。
- 在更低的层次上,硬件工程师已经想出了使用电流、光脉冲、磁场或者其他东西来表示字节的方法。
一个复杂的应用程序可能会有更多的中间层次,比如基于 API 的 API,不过基本思想仍然是一样的:每个层都通过提供一个明确的数据模型来隐藏更低层次中的复杂性。这些抽象允许不同的人群有效地协作,例如数据库厂商的工程师和使用数据库的应用程序开发人员。
实践中广泛使用着几种不同的数据模型,通常各有用途。某些类型的数据和查询在一种模型中很容易表达,在另一种模型中却很别扭。本章将比较关系模型、文档模型、图数据模型、事件溯源和数据框,探讨其中的权衡。我们还将简要介绍操作这些模型的查询语言,帮助你判断何时应该使用哪种模型。
[!TIP] 术语:声明式查询语言
本章中的许多查询语言(如 SQL、Cypher、SPARQL 或 Datalog)都是 声明式 的。在声明式查询语言中,你只需指定所需数据的模式——结果必须符合哪些条件,以及数据应如何转换(例如排序、分组和聚合)——而不必说明 如何 实现这一目标。数据库系统的查询优化器决定使用哪些索引和连接算法,以及以何种顺序执行查询的各个部分。
相比之下,使用大多数编程语言时,你必须写出一套 算法,告诉计算机以特定顺序执行哪些操作。声明式查询语言通常比显式算法更加简洁,也更容易编写;但更重要的是,它隐藏了查询引擎的实现细节,使数据库系统可以在无须对查询做任何修改的情况下提升性能 [^1]。
例如,数据库或许能跨多个 CPU 核心和多台机器并行执行一条声明式查询,而你无须操心如何实现这种并行 [^2]。若是手写算法,自行实现这种并行执行将费不少功夫。
关系模型与文档模型 {#sec_datamodels_history}
如今最广为人知的数据模型或许是 SQL 所采用的关系模型,它由 Edgar Codd 于 1970 年提出 [^3]:数据被组织成 关系(SQL 称之为 表),每个关系都是由 元组(SQL 称之为 行)构成的无序集合。
关系模型最初只是一项理论提议,当时许多人怀疑它能否得到高效实现。然而到了 20 世纪 80 年代中期,对于大多数需要存储和查询具有某种规则结构的数据的人来说,关系数据库管理系统(RDBMS)和 SQL 已成为首选工具。几十年过去,关系数据仍主导着许多数据管理场景,例如商业分析(参见 “星型与雪花型:分析模式”)。
多年来,数据存储和查询领域涌现过许多彼此竞争的方法。20 世纪 70 年代至 80 年代初,网状模型 和 层次模型 是关系模型的主要对手,但最终都败下阵来。对象数据库在 20 世纪 80 年代末至 90 年代初兴起后又销声匿迹;XML 数据库于 21 世纪初出现,却始终只在少数场景中得到采用。关系模型的每个竞争者都曾盛极一时,但无一长久 [^4]。反倒是 SQL 在关系模型这个核心之上不断吸收其他数据类型,例如增加了对 XML、JSON 和图数据的支持 [^5]。
到了 2010 年代,NoSQL 成了试图撼动关系数据库统治地位的最新流行语。NoSQL 并非某项特定技术,而是围绕新数据模型、模式灵活性、可伸缩性和开源许可模式形成的一组宽泛理念。另一些数据库则以 NewSQL 自居,力图在保留传统关系数据库的数据模型和事务保证的同时,提供 NoSQL 系统的可伸缩性。NoSQL 和 NewSQL 的理念深刻影响了数据系统的设计;不过,随着这些原则被广泛吸收,两个术语本身已渐渐淡出。
NoSQL 运动留下的一项持久影响,是通常以 JSON 表示数据的 文档模型 广受欢迎。这个模型最初由 MongoDB、Couchbase 等专用文档数据库推广,如今大多数关系数据库也已加入 JSON 支持。关系表的模式常被视为严格而僵化,相比之下,JSON 文档被认为更加灵活。
文档数据与关系数据孰优孰劣,已经引发过大量争论。下面来看看其中几个关键问题。
对象关系不匹配 {#sec_datamodels_document}
如今,大量应用开发使用面向对象的编程语言,这也引出了针对 SQL 数据模型的一项常见批评:数据若存储在关系表中,就需要一个笨拙的转换层,在应用代码中的对象与数据库的表、行、列模型之间来回转换。两种模型之间的这种脱节,有时称为 阻抗不匹配。
[!NOTE]
阻抗不匹配 一词借自电子学。每个电路的输入和输出都有一定的阻抗(对交流电的阻力)。把一个电路的输出接到另一个电路的输入时,若两边阻抗匹配,连接处的功率传输就能达到最大;阻抗不匹配则可能造成信号反射等问题。
对象关系映射(ORM) {#object-relational-mapping-orm}
ActiveRecord、Hibernate 等对象关系映射(ORM)框架减少了转换层所需的样板代码,但也时常遭到批评 [^6]。常见问题包括:
- ORM 本身很复杂,也无法彻底掩盖两种模型的差异,开发者最终仍得同时考虑数据的关系表示和对象表示。
- ORM 一般只用于开发 OLTP 应用(参见 “事务处理与分析的特征”)。为了让数据可供分析,数据工程师仍须面对底层的关系表示,因此采用 ORM 并不意味着关系模式的设计不再重要。
- 许多 ORM 只面向关系型 OLTP 数据库。若组织还使用搜索引擎、图数据库、NoSQL 系统等多种数据系统,ORM 提供的支持可能远远不够。
- 有些 ORM 会自动生成关系模式,但生成的模式对直接访问关系数据的用户未必友好,在底层数据库上也可能效率不佳。要定制 ORM 生成模式与查询的方式,往往相当复杂,甚至会抵消采用 ORM 原本想获得的好处。
- 使用 ORM 很容易在无意中写出低效查询,例如触发 N+1 查询问题 [^7]。假设你要在页面上显示用户评论列表:先用一条查询取回 N 条评论,每条都含有作者 ID;为了显示作者姓名,还要用这个 ID 查询用户表。手写 SQL 时,你大概会直接在查询中连接用户表,让每条评论连同作者姓名一起返回;使用 ORM 时,却可能对 N 条评论逐条查询用户表,最终一共执行 N+1 条数据库查询。这比在数据库内完成连接要慢得多。为避免这个问题,你可能必须明确要求 ORM 在获取评论的同时一并取回作者信息。
不过,ORM 也自有其优势:
- 对适合关系模型的数据来说,持久化的关系表示与内存中的对象表示之间总要进行某种转换,ORM 可以减少这类转换所需的样板代码。复杂查询或许仍须绕开 ORM 处理,但简单、重复的场景正适合交给它。
- 有些 ORM 可以缓存数据库查询结果,从而减轻数据库负载。
- ORM 还可以协助管理模式迁移和其他数据库管理工作。
用于一对多关系的文档数据模型 {#the-document-data-model-for-one-to-many-relationships}
并非所有数据都适合用关系形式表示。下面用一个例子看看关系模型的局限。{{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 展示了如何用关系模式表示一份简历(LinkedIn 个人资料)。整份资料由唯一标识符 user_id 标识;first_name 和 last_name 等字段对每位用户只出现一次,因此可以建模为 users 表中的列。
大多数人的职业生涯中都不止有一份工作(即多个职位),每个人的教育经历数量也不相同,联系方式更可能有任意多项。这些 一对多关系 可以这样表示:把职位、教育经历和联系信息分别放在单独的表中,再通过外键引用 users 表,如 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 所示。
{{< fig num="3-1" id="fig_obama_relational" src="/fig/ddia_0301.png" caption="使用关系模式表示 LinkedIn 个人资料。" class="ddia-figure ddia-figure--standard" width="1772" height="1414" />}}
同一份信息还可以表示为 JSON 文档,如 {{< xref page="/ch3" anchor="fig_obama_json" >}}示例 3-1{{< /xref >}} 所示。这种形式可能更为自然,也更贴近应用代码中的对象结构。
{{< example num="3-1" id="fig_obama_json" caption="将 LinkedIn 个人资料表示为 JSON 文档" />}}
{
"user_id": 251,
"first_name": "Barack",
"last_name": "Obama",
"headline": "Former President of the United States of America",
"region_id": "us:91",
"photo_url": "/p/7/000/253/05b/308dd6e.jpg",
"positions": [
{"job_title": "President", "organization": "United States of America"},
{"job_title": "US Senator (D-IL)", "organization": "United States Senate"}
],
"education": [
{"school_name": "Harvard University", "start": 1988, "end": 1991},
{"school_name": "Columbia University", "start": 1981, "end": 1983}
],
"contact_info": {
"website": "https://barackobama.com",
"twitter": "https://twitter.com/barackobama"
}
}一些开发者认为,JSON 模型减轻了应用代码与存储层之间的阻抗不匹配。不过,正如我们将在 第 5 章 看到的,JSON 用作数据编码格式时也有不少问题。没有模式常被视为它的一项优势,我们将在 “文档模型中的模式灵活性” 进一步讨论。
与 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 的多表模式相比,JSON 表示具有更好的 局部性(参见 “读写的数据局部性”)。在关系模型的例子中,要取回一份个人资料,要么执行多次查询(按 user_id 分别查询每张表),要么在 users 表及其下属表之间进行繁琐的多路连接 [^8]。而在 JSON 表示中,所有相关信息都集中在一处,查询既简单又快捷。
个人资料与职位、教育经历、联系信息之间的一对多关系,在数据中形成了一棵树;JSON 表示则明确呈现了这种树状结构(见 {{< xref fig="3-2" page="/ch3" anchor="fig_json_tree" >}}图 3-2{{< /xref >}})。
{{< fig num="3-2" id="fig_json_tree" src="/fig/ddia_0302.png" caption="一对多关系形成树状结构。" class="ddia-figure ddia-figure--wide" width="1772" height="780" />}}
[!NOTE]
这种关系有时称为 一对少,而非 一对多,因为一份简历通常只有少数几个职位 [^9] [^10]。如果相关项目确实可能多到惊人——例如名人的社交媒体帖子可能收到成千上万条评论——把它们全部嵌进同一个文档就太过笨重,此时更适合采用 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 所示的关系方法。
规范化、反规范化与连接 {#sec_datamodels_normalization}
前一节的 {{< xref page="/ch3" anchor="fig_obama_json" >}}示例 3-1{{< /xref >}} 用 ID 表示 region_id,而没有直接写成纯文本字符串 "Washington, DC, United States"。为什么要这样做?
如果用户界面提供自由文本框让用户填写地区,那么把输入直接存成文本字符串很合理。不过,预先提供标准化的地理区域列表,让用户通过下拉列表或自动补全来选择,也有不少好处:
- 所有个人资料中的格式和拼写保持一致
- 避免同名地点造成歧义(若字符串只有 “Washington”,究竟是指华盛顿特区,还是华盛顿州?)
- 便于更新——名称只存储在一处,将来如有变动(例如因政治事件而更改城市名称),只改一处便能全局生效
- 支持本地化——网站翻译成其他语言时,可以本地化这份标准列表,让地区名称以浏览者使用的语言显示
- 改善搜索——例如,地区列表可以记录华盛顿位于美国东海岸这一事实(单看
"Washington, DC"字符串无法得知),于是搜索美国东海岸的人时也能匹配这份资料
选择存储 ID 还是文本字符串,实质上是在决定是否 规范化。使用 ID 时,数据更加规范化:对人有意义的信息(如 Washington, DC 这段文字)只存储一份,其他地方都用仅在数据库内有意义的 ID 来引用它。若直接存储文本,这段有意义的信息就会复制到每条使用它的记录中;这样的表示便是 反规范化 的。
ID 的好处在于,它本身对人没有意义,因而永远不必改变:即使 ID 所标识的信息发生了变化,ID 仍可保持不变。凡是对人有意义的信息,将来都有可能需要修改;一旦这类信息被复制,所有冗余副本就都得随之更新。这不仅需要更多代码、写入操作和磁盘空间,还会带来不一致的风险——有些副本已经更新,另一些却没有。
规范化表示也有代价:每次显示含有 ID 的记录时,都要多做一次查找,把 ID 解析成人能读懂的信息。在关系数据模型中,这项工作通过 连接 完成,例如:
SELECT users.*, regions.region_name
FROM users
JOIN regions ON users.region_id = regions.id
WHERE users.id = 251;文档数据库既能存储规范化数据,也能存储反规范化数据,但人们往往把它与反规范化联系在一起。一方面,JSON 数据模型很容易加入额外的反规范化字段;另一方面,许多文档数据库对连接支持较弱,采用规范化表示很不方便。有些文档数据库完全不支持连接,只能在应用代码中自行完成:先取回含有 ID 的文档,再发起第二次查询,用 ID 找到另一个文档。MongoDB 也可以通过聚合管道中的 $lookup 算子执行连接:
db.users.aggregate([
{ $match: { _id: 251 } },
{ $lookup: {
from: "regions",
localField: "region_id",
foreignField: "_id",
as: "region"
} }
])规范化的权衡 {#trade-offs-of-normalization}
在简历示例中,region_id 字段引用了标准化的地区集合,organization(任职的公司或政府机构)和 school_name(就读的学校)却只是字符串。这是一种反规范化表示:许多人或许曾在同一家公司工作,但这些记录并没有通过 ID 关联起来。
那么,是否应该把组织和学校也建模为实体,让个人资料引用其 ID,而不是直接写下名称?主张用 ID 引用地区的理由在这里同样成立。比如,假设除了名称之外,我们还想显示学校或公司的徽标:
- 采用反规范化表示时,每个人的个人资料都要包含徽标图片的 URL。这样固然让 JSON 文档自给自足,但徽标一旦更换,就必须找出旧 URL 出现的每个地方并逐一更新,十分麻烦 [^9]。
- 采用规范化表示时,可以建立一个代表组织或学校的实体,只在其中存储一次名称、徽标 URL,也许再加上简介、动态消息等属性。所有提及该组织的简历只引用其 ID,更新徽标便易如反掌。
一般来说,规范化数据写入较快(因为只有一个副本),查询却较慢(因为需要连接);反规范化数据通常读取较快(连接更少),写入代价却更高(要更新更多副本,也占用更多磁盘空间)。把反规范化看作一种衍生数据或许很有帮助(参见 “权威记录系统与衍生数据”),因为你必须建立相应流程来更新那些冗余的数据副本。
除了更新本身的成本,还要考虑进程在更新中途崩溃时,数据库能否保持一致。支持原子事务的数据库(参见 “原子性”)更容易维护一致性,但并非所有数据库都能为跨多个文档的操作提供原子性。也可以利用流处理来保证一致性,我们将在 “保持系统同步” 讨论这种方法。
规范化往往更适合读写都要迅速完成的 OLTP 系统;分析系统通常更适合反规范化数据,因为它们会批量更新,首要关心的是只读查询的性能。此外,中小规模系统通常也更适合规范化数据模型:既不必费心维持多个副本的一致,执行连接的成本也还可以接受。不过到了超大规模,连接的代价就可能成为问题。
社交网络案例研究中的反规范化 {#denormalization-in-the-social-networking-case-study}
在 “案例研究:社交网络首页时间线” 中,我们比较了规范化表示({{< xref fig="2-1" page="/ch2" anchor="fig_twitter_relational" >}}图 2-1{{< /xref >}})和反规范化表示(预计算并物化的时间线)。在那个例子里,连接 posts 与 follows 的成本太高,于是物化时间线充当了连接结果的缓存;把新帖子扇出到关注者的时间线,正是维持这种反规范化表示一致的手段。
不过,X(原 Twitter)的物化时间线实际上并不保存每条帖子的正文。每个条目只存储帖子 ID、发帖用户的 ID,以及少量用于识别转帖和回复的附加信息 [^11]。换句话说,它大致相当于预先算好了下面这条查询的结果:
SELECT posts.id, posts.sender_id
FROM posts
JOIN follows ON posts.sender_id = follows.followee_id
WHERE follows.follower_id = current_user
ORDER BY posts.timestamp DESC
LIMIT 1000因此,每次读取时间线时,服务仍要做两次连接:按帖子 ID 取回正文,以及点赞数、回复数等统计信息;再按发帖用户 ID 取回其用户名、头像和其他资料。这个把 ID 补全为人类可读信息的过程称为 补全 ID(hydrating IDs),本质上就是在应用代码中完成连接 [^11]。
预计算时间线之所以只存 ID,是因为 ID 所指向的数据变化很快:热门帖子的点赞数和回复数每秒都可能变化多次,有些用户也会经常更换用户名或头像。时间线在展示时应呈现最新的点赞数和头像,所以把这些信息反规范化到物化时间线中并不合理,而且还会大幅增加存储成本。
这个例子说明,读取数据时需要执行连接,并不像有些说法所称的那样,会妨碍我们构建高性能、可伸缩的服务。补全帖子 ID 和用户 ID 其实相当容易伸缩:这项工作很适合并行执行,而且成本既不取决于你关注了多少账户,也不取决于有多少人关注你。
如果要判断应用中的某项数据是否应该反规范化,社交网络案例表明答案并不显而易见:可伸缩性最好的方案,可能是把一部分数据反规范化,同时让另一部分保持规范化。你必须仔细权衡信息的变化频率与读写成本;而成本又可能由极端情况主导,例如社交网络中关注或被关注人数异常多的用户。规范化与反规范化本身无所谓好坏,不过是在读写性能和实现成本之间作取舍。
多对一与多对多关系 {#sec_datamodels_many_to_many}
{{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 中的 positions 和 education 是一对多(或一对少)关系:一份简历有多个职位,但每个职位只属于一份简历。相比之下,region_id 字段表示 多对一 关系:许多人住在同一个地区,而我们假设任一时刻每个人只住在一个地区。
如果进一步把组织和学校建模为实体,让简历通过 ID 引用它们,就会出现 多对多 关系:一个人曾在多个组织任职,一个组织也有多名现任或前任员工。在关系模型中,这类关系通常用 关联表(也称 连接表)表示,如 {{< xref fig="3-3" page="/ch3" anchor="fig_datamodels_m2m_rel" >}}图 3-3{{< /xref >}} 所示:每个职位把一个用户 ID 与一个组织 ID 关联起来。
{{< fig num="3-3" id="fig_datamodels_m2m_rel" src="/fig/ddia_0303.png" caption="关系模型中的多对多关系。" class="ddia-figure ddia-figure--wide" width="1772" height="745" />}}
多对一和多对多关系很难塞进一个自包含的 JSON 文档,它们更适合规范化表示。{{< xref page="/ch3" anchor="fig_datamodels_m2m_json" >}}示例 3-2{{< /xref >}} 给出了文档模型中的一种方案,{{< xref fig="3-4" page="/ch3" anchor="fig_datamodels_many_to_many" >}}图 3-4{{< /xref >}} 则以图示说明:每个虚线框内的数据可以组成一份文档,但指向组织和学校的链接最好表示为对其他文档的引用。
{{< example num="3-2" id="fig_datamodels_m2m_json" caption="通过 ID 引用组织的简历" />}}
{
"user_id": 251,
"first_name": "Barack",
"last_name": "Obama",
"positions": [
{"start": 2009, "end": 2017, "job_title": "President", "org_id": 513},
{"start": 2005, "end": 2008, "job_title": "US Senator (D-IL)", "org_id": 514}
],
...
}{{< fig num="3-4" id="fig_datamodels_many_to_many" src="/fig/ddia_0304.png" caption="文档模型中的多对多关系;每个虚线框内的数据可以组成一份文档。" class="ddia-figure ddia-figure--standard" width="1772" height="1121" />}}
多对多关系通常需要从“两个方向”查询:既要找出某人任职过的所有组织,也要找出曾在某组织任职的所有人。一种做法是在关系两端都保存 ID 引用:简历列出此人任职过的各个组织 ID,组织文档也列出提及该组织的简历 ID。由于同一关系存储了两份,这是一种反规范化表示,两边可能彼此不一致。
规范化表示只在一处存储关系,再依靠 二级索引(将在 第 4 章 讨论)从两个方向高效查询。{{< xref fig="3-3" page="/ch3" anchor="fig_datamodels_m2m_rel" >}}图 3-3{{< /xref >}} 的关系模式中,可以让数据库分别为 positions 表的 user_id 列和 org_id 列建立索引。
在 {{< xref page="/ch3" anchor="fig_datamodels_m2m_json" >}}示例 3-2{{< /xref >}} 的文档模型中,数据库则需要索引 positions 数组内各对象的 org_id 字段。许多文档数据库以及支持 JSON 的关系数据库,都能为文档内部的值建立这种索引。
星型与雪花型:分析模式 {#sec_datamodels_analytics}
数据仓库(参见 “数据仓库”)通常采用关系模型,其表结构有几种广泛使用的惯例:星型模式、雪花模式、维度建模 [^12],以及 一张大表(OBT)。这些结构针对业务分析师的需求进行了优化,ETL 过程则负责把事务型系统中的数据转换成这种模式。
{{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}图 3-5{{< /xref >}} 中的示例模式,可能出现在一家食品零售商的数据仓库中。模式的中心是所谓的 事实表(本例中名为 fact_sales)。事实表的每一行代表在特定时间发生的事件;在这里,每行代表客户购买了一件产品。如果分析的是网站流量而不是零售量,那么每行可能代表一次页面浏览或一次用户点击。
{{< fig num="3-5" id="fig_dwh_schema" src="/fig/ddia_0305.png" caption="用于数据仓库的星型模式示例。" class="ddia-figure ddia-figure--standard" width="2658" height="2223" />}}
通常会把每项事实记录为独立事件,因为这样能为日后的分析保留最大的灵活性。不过,这也意味着事实表可能变得极其庞大。大型企业的数据仓库可能保存着许多 PB 的交易历史,其中大部分都以事实表表示。
事实表中的一些列是属性,例如产品的售价和从供应商处购入的成本(据此可以计算利润率)。另一些列是指向其他表的外键引用,这些表称为 维度表。由于事实表的每一行表示一个事件,各个维度便代表事件发生的对象、内容、地点、时间、方式和原因。
例如,{{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}图 3-5{{< /xref >}} 中的一个维度是售出的产品。dim_product 表中的每一行代表一种待售产品,包括库存单位(SKU)、产品描述、品牌名称、类别、脂肪含量、包装尺寸等。fact_sales 表的每一行都用外键表明该笔交易售出了哪种产品。查询往往要连接多个维度表。
甚至日期和时间也常用维度表表示,以便编码公共假期等额外信息,让查询可以区分节假日与平日的销售情况。
{{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}图 3-5{{< /xref >}} 展示的便是星型模式。这个名称源自表关系的可视化形状:事实表位于中央,周围环绕着维度表;连接这些表的线条就像星星的光芒。
这个模板的变体称为 雪花模式,其中的维度会进一步分解成子维度。例如,可以为品牌和产品类别分别建表,让 dim_product 的每一行以外键引用品牌与类别,而不再把它们作为字符串直接存入 dim_product 表。雪花模式比星型模式更规范化,但星型模式通常更受青睐,因为分析师使用起来更简单 [^12]。
典型数据仓库中的表通常非常宽:事实表往往超过 100 列,有时甚至有数百列。维度表也可能很宽,因为它们会收录所有可能与分析相关的元数据。例如,dim_store 表可能记录每家商店提供哪些服务、是否设有店内面包房、店面面积、首次开业日期、最近一次改造时间,以及离最近的高速公路有多远,等等。
星型模式和雪花模式主要由多对一关系构成,例如许多笔销售对应同一种产品、同一家商店。这些关系表现为事实表指向维度表的外键,或维度表指向子维度表的外键。原则上也可以存在其他类型的关系,但为了简化查询,通常会把它们反规范化。例如,顾客一次买了几种不同产品时,这笔多商品交易不会得到显式表示;事实表只是为每件商品各存一行,而这些事实恰好具有相同的顾客 ID、商店 ID 和时间戳。
有些数据仓库模式更进一步,完全省去维度表,把维度信息放进事实表的反规范化列中——实质上就是预先计算事实表与维度表的连接。这种方法称为 一张大表(OBT);它虽然占用更多存储空间,有时却能让查询更快 [^13]。
在分析场景中,这样反规范化通常不成问题,因为数据往往是一份不会再改变的历史记录(偶尔纠正错误除外)。反规范化在 OLTP 系统中带来的数据一致性问题和写入开销,在分析系统里没有那么紧迫。
何时使用哪种模型 {#sec_datamodels_document_summary}
支持文档数据模型的主要论据是模式灵活性、因局部性而拥有更好的性能,以及对于某些应用程序而言,它更接近应用程序使用的对象模型。关系模型则以更好地支持连接、多对一和多对多关系作为回应。下面逐一详细考察这些论点。
如果应用程序中的数据具有类似文档的结构(即一对多关系树,通常一次性加载整棵树),那么使用文档模型可能是个好主意。把类似文档的结构 拆散 到多个表中(如 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 中的 positions、education 和 contact_info),可能导致繁琐的模式和不必要的复杂应用程序代码。
文档模型有一定的局限性:例如,不能直接引用文档中的嵌套项目,而是需要说“用户 251 的职位列表中的第二项”。如果确实需要引用嵌套项目,关系模型更合适,因为任何项目都能通过自身 ID 被直接引用。
有些应用允许用户自行安排项目顺序。例如,在待办事项清单或问题跟踪器中,用户可以拖放任务来重新排序。文档模型很适合这类应用,只需把项目(或项目 ID)按顺序存入 JSON 数组即可。关系数据库没有表示这种可重新排序列表的标准方式,只能借助各种技巧:按整数列排序(在中间插入时需要重新编号)、用 ID 构成链表,或者使用分数索引 [^14] [^15] [^16]。
文档模型中的模式灵活性 {#sec_datamodels_schema_flexibility}
大多数文档数据库以及关系数据库中的 JSON 支持,都不会强制文档中的数据采用何种模式。关系数据库的 XML 支持通常带有可选的模式验证。没有模式意味着可以向文档中添加任意键和值;读取时,客户端也无法确定文档究竟会包含哪些字段。
文档数据库有时称为 无模式(schemaless),但这具有误导性,因为读取数据的代码通常假定某种结构——即存在隐式模式,只是不由数据库强制执行 [^17]。一个更准确的术语是 读时模式(schema-on-read,数据结构是隐含的,只有读取时才会解释),与之相对的是 写时模式(schema-on-write,关系数据库的传统做法:模式是明确的,数据库确保写入的所有数据都符合模式)[^18]。
读时模式类似于编程语言中的动态(运行时)类型检查,而写时模式类似于静态(编译时)类型检查。就像静态与动态类型检查孰优孰劣一直争议不断 [^19],数据库是否应该强制执行模式也是个见仁见智的问题,通常并没有绝对的对错。
当应用程序想要改变数据格式时,两种方法的区别尤其明显。例如,假设原先把每位用户的全名存储在一个字段中,现在想把名和姓分开存储 [^20]。在文档数据库中,只需开始写入带有新字段的文档,并在应用程序中加入代码来处理旧文档即可。例如:
if (user && user.name && !user.first_name) {
// 2023 年 12 月 8 日之前写入的文档没有 first_name
user.first_name = user.name.split(" ")[0];
}这种方法的缺点是,应用中每个读取数据库的部分,从此都必须处理可能在很久以前写入的旧格式文档。另一方面,在写时模式数据库中,通常会执行下面这样的 迁移:
ALTER TABLE users ADD COLUMN first_name text DEFAULT NULL;
UPDATE users SET first_name = split_part(name, ' ', 1); -- PostgreSQL
UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL在大多数关系数据库中,即使面对大表,添加带默认值的列也又快又稳妥。不过,在大表上执行 UPDATE 可能很慢,因为每一行都必须重写;其他模式操作(例如修改某列的数据类型)通常也需要复制整张表。
有多种工具可以在后台完成这类模式变更而无须停机 [^21] [^22] [^23] [^24],但在大型数据库上进行这种迁移,运维起来依然颇具挑战。要避开复杂迁移,可以只快速添加一个默认值为 NULL 的 first_name 列,然后在读取时填充它,就像使用文档数据库那样。
如果集合中的项目因为某种原因并不都具有相同结构(即数据是异构的),读时模式更具优势。例如:
- 存在许多不同类型的对象,将每种对象分别放进一张表并不现实。
- 数据结构由你无法控制、且随时可能改变的外部系统决定。
在上述情况下,模式可能弊大于利,无模式文档反而是更自然的数据模型。但是,如果所有记录都应具有相同结构,那么模式就是记录并强制这种结构的有效机制。我们将在 第 5 章 更详细地讨论模式和模式演化。
读写的数据局部性 {#sec_datamodels_document_locality}
文档通常以单个连续字符串的形式存储,编码为 JSON、XML 或其二进制变体(如 MongoDB 的 BSON)。如果应用程序经常需要访问整个文档(例如把它渲染到网页上),这种 存储局部性 会带来性能优势。如果数据像 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 那样分散在多张表中,就需要多次查找索引才能检索完整,可能产生更多磁盘寻道并花费更长时间。
局部性优势只适用于同时需要文档绝大部分内容的情况。即使只访问大型文档的一小部分,数据库通常也要加载整个文档,这会造成浪费;更新时一般还要重写整个文档。因此,通常建议让文档保持较小,并避免频繁地对文档做小幅更新。
不过,为了局部性而把相关数据存储在一起,并非文档模型的专利。例如,Google 的 Spanner 数据库在关系模型中也提供同样的局部性属性,允许模式声明某张表的行应交错(嵌套)在父表之中 [^25]。Oracle 也用名为 多表索引集群表 的功能提供类似能力 [^26]。由 Google Bigtable 推广、并被 HBase 和 Accumulo 等系统采用的 宽列 数据模型,则以 列族 概念达到相似的局部性管理目的 [^27]。
文档的查询语言 {#query-languages-for-documents}
关系数据库和文档数据库的另一个区别,在于查询所用的语言或 API。大多数关系数据库使用 SQL,文档数据库则五花八门:有些只允许按主键进行键值访问,有些还提供二级索引来查询文档内部的值,还有些配有功能丰富的查询语言。
XML 数据库通常使用 XQuery 和 XPath;它们支持包括跨文档连接在内的复杂查询,还能把结果格式化为 XML [^28]。JSON Pointer [^29] 和 JSONPath [^30] 则为 JSON 提供了与 XPath 相当的功能。
MongoDB 的聚合管道就是一种面向 JSON 文档集合的查询语言;我们在 “规范化、反规范化与连接” 中已经见过它用于连接的 $lookup 算子。
再看一个例子来体会这种语言,这次考察分析中尤为常见的聚合。假设你是一名海洋生物学家,每当在海中看到动物,就向数据库添加一条观察记录。现在你想生成一份报告,说明每个月观察到多少条鲨鱼。在 PostgreSQL 中,可以像这样表述查询:
SELECT date_trunc('month', observation_timestamp) AS observation_month, ❶
sum(num_animals) AS total_animals
FROM observations
WHERE family = 'Sharks'
GROUP BY observation_month;❶:date_trunc('month', timestamp) 函数确定 timestamp 所在的日历月份,并返回代表该月起点的另一个时间戳。换句话说,它把时间戳向下舍入到最近的月份。
这个查询首先过滤观察记录,只保留鲨鱼科的物种;然后按观察发生的日历月份分组;最后,把该月所有观察记录中的动物数量相加。同一个查询可以用 MongoDB 的聚合管道表示如下:
db.observations.aggregate([
{ $match: { family: "Sharks" } },
{ $group: {
_id: {
year: { $year: "$observationTimestamp" },
month: { $month: "$observationTimestamp" }
},
totalAnimals: { $sum: "$numAnimals" }
} }
]);聚合管道语言的表达能力与 SQL 的一个子集相当,不过它采用基于 JSON 的语法,而不是 SQL 那种接近英语句式的语法;这种差异或许只是口味问题。
文档和关系数据库的融合 {#convergence-of-document-and-relational-databases}
文档数据库和关系数据库最初采用截然不同的数据管理方法,但随着时间推移,两者变得越来越相似 [^31]。关系数据库增加了对 JSON 类型和查询算子的支持,也能够为文档内部的属性建立索引;MongoDB、Couchbase、RethinkDB 等文档数据库,则增加了连接、二级索引和声明式查询语言。
这种融合对应用程序开发人员来说是件好事,因为关系模型和文档模型能够在同一个数据库中结合使用时,最能发挥各自所长。许多文档数据库需要以关系模型的方式引用其他文档,许多关系数据库也有些部分会受益于模式灵活性。关系模型与文档模型的混合是一种强大的组合。
[!NOTE]
Codd 对关系模型的原始描述 [^3] 实际上允许关系模式中出现类似 JSON 的结构,他称之为 非简单域。其思想是,一行中的值不一定只是数字或字符串之类的原始数据类型,也可以是嵌套的关系(表),因此可以把任意嵌套的树结构作为一个值。这与三十多年后加入 SQL 的 JSON 或 XML 支持非常相似。
图数据模型 {#sec_datamodels_graph}
如我们之前所见,关系的类型是区分不同数据模型的一项重要特征。如果应用程序中的关系大多是一对多关系(树状结构数据),而记录之间很少存在其他关系,那么文档模型是合适的。
但是,如果多对多关系在数据中十分常见呢?关系模型可以处理简单的多对多关系,但随着数据之间的连接变得越来越复杂,将数据建模为图就显得更加自然。
一个图由两种对象组成:顶点(vertices,也称为 节点,即 nodes,或 实体,即 entities)和 边(edges,也称为 关系,即 relationships,或 弧,即 arcs)。多种数据都可以建模为图,典型的例子包括:
社交图
: 顶点是人,边表示哪些人相互认识。
网页图
: 顶点是网页,边表示指向其他页面的 HTML 链接。
道路或铁路网络
: 顶点是交叉点,边表示它们之间的道路或铁路线。
可以把许多众所周知的算法运用到这些图上。例如,地图导航应用会搜索道路网络中两点之间的最短路径;PageRank 可以用在网页图上,判断网页的流行程度,进而决定它在搜索结果中的排名 [^32]。
图可以用几种不同的方式表示。在 邻接表 模型中,每个顶点都保存与它相隔一条边的相邻顶点 ID。另一种方式是 邻接矩阵:这是一个二维数组,每行、每列各对应一个顶点;行顶点与列顶点之间没有边时,值为 0,有边时则为 1。邻接表适合图遍历,邻接矩阵则适合机器学习(参见 “数据框、矩阵与数组”)。
在刚才给出的例子中,图里的所有顶点都表示同一种事物,分别是人、网页或道路交叉口。不过,图并不局限于这种 同质 数据:图还有一项同样强大的用途,就是以一致的方式在单个数据库中存储截然不同的对象。例如:
- Facebook 维护着一个包含许多不同类型顶点和边的图:顶点表示人、地点、事件、签到和用户评论;边表示哪些人是朋友、某次签到发生在哪里、谁评论了哪篇帖子、谁参加了哪场活动,等等 [^33]。
- 搜索引擎用知识图谱来记录查询中经常出现的组织、人物、地点等实体的事实 [^34]。这些信息来自对网站的抓取与文本分析;Wikidata 等网站也会以结构化形式发布图数据。
有几种不同但彼此相关的方式,可以用来组织和查询图中的数据。本节将讨论 属性图 模型(由 Neo4j、Memgraph、KùzuDB [^35] 等系统实现 [^36])和 三元组存储 模型(由 Datomic、AllegroGraph、Blazegraph 等系统实现)。两种模型的表达能力相当接近,Amazon Neptune 等图数据库还同时支持二者。
我们还将介绍四种图查询语言(Cypher、SPARQL、Datalog 和 GraphQL),以及 SQL 对图查询的支持。其他图查询语言还有 Gremlin 等 [^37],不过这里选取的几种已足以给出一幅有代表性的全景。
为了说明这些语言和模型,本节将以 {{< xref fig="3-6" page="/ch3" anchor="fig_datamodels_graph" >}}图 3-6{{< /xref >}} 为贯穿全节的例子。它可以取自社交网络或家谱数据库:图中有两个人,来自爱达荷州的 Lucy 和来自法国圣洛的 Alain。他们已经结婚,现居伦敦。每个人和每个地点都表示为顶点,彼此之间的关系则表示为边。这个例子将展示一些在图数据库中很容易、在其他模型中却很难表达的查询。
{{< fig num="3-6" id="fig_datamodels_graph" src="/fig/ddia_0306.png" caption="图结构数据示例(框表示顶点,箭头表示边)。" class="ddia-figure ddia-figure--wide" width="1772" height="1065" />}}
属性图 {#id56}
在 属性图(也称 带标签属性图)模型中,每个顶点包括:
- 唯一标识符
- 一个标签(字符串),描述该顶点所表示的对象类型
- 一组出边
- 一组入边
- 一组属性(键值对)
每条边包括:
- 唯一标识符
- 边的起点(尾部顶点,即 tail vertex)
- 边的终点(头部顶点,即 head vertex)
- 一个标签,描述两个顶点之间的关系类型
- 一组属性(键值对)
可以把图存储看成两个关系表:一张存储顶点,另一张存储边,如 {{< xref page="/ch3" anchor="fig_graph_sql_schema" >}}示例 3-3{{< /xref >}} 所示(该模式使用 PostgreSQL 的 jsonb 数据类型存储每个顶点或每条边的属性)。每条边都保存头部顶点和尾部顶点;如果想找出某个顶点的所有入边或出边,可以分别按 head_vertex 或 tail_vertex 查询 edges 表。
{{< example num="3-3" id="fig_graph_sql_schema" caption="使用关系模式表示属性图" />}}
CREATE TABLE vertices (
vertex_id integer PRIMARY KEY,
label text,
properties jsonb
);
CREATE TABLE edges (
edge_id integer PRIMARY KEY,
tail_vertex integer REFERENCES vertices (vertex_id),
head_vertex integer REFERENCES vertices (vertex_id),
label text,
properties jsonb
);
CREATE INDEX edges_tails ON edges (tail_vertex);
CREATE INDEX edges_heads ON edges (head_vertex);这个模型有几个重要特点:
- 任何顶点都可以通过边连接到任何其他顶点。没有模式限制哪些事物可以关联,哪些不可以。
- 给定任意顶点,都能高效地找到它的入边和出边,从而双向 遍历 图——即沿着一系列顶点构成的路径前后移动。(这正是 {{< xref page="/ch3" anchor="fig_graph_sql_schema" >}}示例 3-3{{< /xref >}} 同时为
tail_vertex和head_vertex列建立索引的原因。) - 为不同类型的顶点和关系使用不同的标签,就可以在一个图中存储多种不同的信息,同时仍保持清晰的数据模型。
边表就像我们在 “多对一与多对多关系” 看到的多对多关联表(连接表),只不过经过了泛化,可以在同一张表中存储许多不同类型的关系。标签和属性也可以建立索引,以便高效查找具有某种属性的顶点或边。
[!NOTE]
图模型有一项局限:一条边只能关联两个顶点,而关系模型中的连接表可以在一行中保存多个外键引用,从而表示三元甚至更高元的关系。在图中,可以为连接表的每一行另建一个顶点,再用边把它与其他顶点相连;也可以使用 超图 来表示这类关系。
这些特性为数据建模提供了很大的灵活性,如 {{< xref fig="3-6" page="/ch3" anchor="fig_datamodels_graph" >}}图 3-6{{< /xref >}} 所示。图中有一些传统关系模式难以表达的事物,例如不同国家采用不同的行政区划结构(法国有 省 和 大区,美国有 县 和 州)、国中之国这样的历史怪事(暂且忽略主权国家与民族错综复杂的关系),以及粒度不一的数据(Lucy 现在的住所具体到城市,而出生地只记录到州)。
可以想象,这个图还能够扩展出关于 Lucy、Alain 或其他人的许多事实。例如,可以用它表示食物过敏:为每种过敏原增加一个顶点,用人与过敏原之间的边表示过敏,再把过敏原连接到一组说明哪些食物含有哪些物质的顶点。这样就能写一条查询,找出每个人可以安全食用的东西。图在可演化性方面很有优势:随着应用程序不断增加功能,可以轻松扩展图来适应数据结构的变化。
Cypher 查询语言 {#id57}
Cypher 是属性图的查询语言,最初为 Neo4j 图数据库而创,后来以 openCypher 之名发展为开放标准 [^38]。除了 Neo4j,Memgraph、KùzuDB [^35]、Amazon Neptune、Apache AGE(数据存储在 PostgreSQL 中)等系统也支持 Cypher。它以电影《黑客帝国》中的角色命名,与密码学中的密码并无关系 [^39]。
{{< xref page="/ch3" anchor="fig_cypher_create" >}}示例 3-4{{< /xref >}} 展示了把 {{< xref fig="3-6" page="/ch3" anchor="fig_datamodels_graph" >}}图 3-6{{< /xref >}} 左侧部分插入图数据库的 Cypher 查询,图的其余部分也可以用同样方式加入。每个顶点都有一个 usa 或 idaho 之类的符号名称。该名称不会存入数据库,只在查询内部用来创建顶点之间的边。箭头记法 (idaho) -[:WITHIN]-> (usa) 会创建一条标签为 WITHIN 的边,以 idaho 为尾节点、usa 为头节点。
{{< example num="3-4" id="fig_cypher_create" caption="图 3-6 中的一部分数据,以 Cypher 查询表示" />}}
CREATE
(namerica :Location {name:'North America', type:'continent'}),
(usa :Location {name:'United States', type:'country' }),
(idaho :Location {name:'Idaho', type:'state' }),
(lucy :Person {name:'Lucy' }),
(idaho) -[:WITHIN ]-> (usa) -[:WITHIN]-> (namerica),
(lucy) -[:BORN_IN]-> (idaho)把 {{< xref fig="3-6" page="/ch3" anchor="fig_datamodels_graph" >}}图 3-6{{< /xref >}} 的所有顶点和边加入数据库后,就可以提出一些有趣的问题。例如:找出所有从美国移居欧洲的人的姓名。更确切地说,我们要找出同时具有一条指向美国境内某地的 BORN_IN 边,以及一条指向欧洲境内某地的 LIVES_IN 边的顶点,并返回这些顶点的 name 属性。
{{< xref page="/ch3" anchor="fig_cypher_query" >}}示例 3-5{{< /xref >}} 展示了如何用 Cypher 表述这个查询。MATCH 子句使用同样的箭头记法在图中寻找模式:(person) -[:BORN_IN]-> () 匹配由一条 BORN_IN 边相连的任意两个顶点;这条边的尾部顶点绑定到变量 person,头部顶点则不命名。
{{< example num="3-5" id="fig_cypher_query" caption="查找从美国移居欧洲者的 Cypher 查询" />}}
MATCH
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (:Location {name:'United States'}),
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (:Location {name:'Europe'})
RETURN person.name这条查询可以这样解读:
找出满足以下 两个 条件的所有顶点(称为
person):
person顶点有一条指向某个顶点的BORN_IN出边。从那里沿着一系列WITHIN出边前进,最终能到达一个类型为Location、name属性为"United States"的顶点。- 同一个
person顶点还有一条LIVES_IN出边。沿着该边,再沿一系列WITHIN出边前进,最终能到达一个类型为Location、name属性为"Europe"的顶点。对每个这样的
person顶点,返回其name属性。
执行这条查询有几种可行的方式。上面的描述暗示,可以先扫描数据库中的所有人,逐一检查出生地和居住地,只返回符合条件的人。
等价地,也可以从两个 Location 顶点开始反向查找。如果 name 属性建有索引,就能高效找到代表美国和欧洲的两个顶点。然后沿着所有 WITHIN 入边,分别找出美国和欧洲境内的所有地点(州、地区、城市等)。最后,再沿这些地点顶点的 BORN_IN 或 LIVES_IN 入边找到相应的人。
SQL 中的图查询 {#id58}
{{< xref page="/ch3" anchor="fig_graph_sql_schema" >}}示例 3-3{{< /xref >}} 表明,可以在关系数据库中表示图数据。但是,如果图数据采用关系结构存储,还能使用 SQL 查询它吗?
答案是肯定的,但有些困难。图查询每遍历一条边,实际上都相当于与 edges 表连接一次。在关系数据库中,通常事先就知道查询需要哪些连接;而在图查询中,找到目标顶点之前可能要遍历数量不定的边,也就是说,连接次数无法预先确定。
在我们的例子中,Cypher 查询里的 () -[:WITHIN*0..]-> () 模式便是如此。一个人的 LIVES_IN 边可能指向任何层级的地点:街道、城市、区、地区、州,等等。城市可能 WITHIN 某个地区,地区又 WITHIN 某个州,州再 WITHIN 某个国家。LIVES_IN 边可能直接指向待查地点,也可能还隔着好几层地点层次。
Cypher 用 :WITHIN*0.. 非常简洁地表达了这一点:“沿着 WITHIN 边走零次或多次”。它类似于正则表达式中的 * 运算符。
从 SQL:1999 开始,可以用所谓的 递归公用表表达式(WITH RECURSIVE 语法)在查询中表示长度可变的遍历路径。{{< xref page="/ch3" anchor="fig_graph_sql_query" >}}示例 3-6{{< /xref >}} 用这种技术在 SQL 中写出了同一个查询——查找从美国移居欧洲者的姓名。只不过,与 Cypher 相比,它的语法十分笨拙。
{{< example num="3-6" id="fig_graph_sql_query" caption="使用递归公用表表达式,以 SQL 写出与示例 3-5 相同的查询" />}}
WITH RECURSIVE
-- in_usa 是美国境内所有位置的顶点 ID 集合
in_usa(vertex_id) AS (
SELECT vertex_id FROM vertices
WHERE label = 'Location' AND properties->>'name' = 'United States' ❶
UNION
SELECT edges.tail_vertex FROM edges ❷
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'within'
),
-- in_europe 是欧洲境内所有位置的顶点 ID 集合
in_europe(vertex_id) AS (
SELECT vertex_id FROM vertices
WHERE label = 'location' AND properties->>'name' = 'Europe' ❸
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'within'
),
-- born_in_usa 是所有在美国出生的人的顶点 ID 集合
born_in_usa(vertex_id) AS ( ❹
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'born_in'
),
-- lives_in_europe 是所有居住在欧洲的人的顶点 ID 集合
lives_in_europe(vertex_id) AS ( ❺
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'lives_in'
)
SELECT vertices.properties->>'name'
FROM vertices
-- 连接以找到那些既在美国出生 *又* 居住在欧洲的人
JOIN born_in_usa ON vertices.vertex_id = born_in_usa.vertex_id ❻
JOIN lives_in_europe ON vertices.vertex_id = lives_in_europe.vertex_id;❶:首先找到 name 属性为 "United States" 的顶点,把它作为 in_usa 顶点集的第一个元素。
❷:从 in_usa 集合中的顶点出发,沿所有 within 入边反向前进,把到达的顶点加入同一集合,直到所有 within 入边都被访问。
❸:从 name 属性为 "Europe" 的顶点出发,执行同样的操作,建立 in_europe 顶点集。
❹:对 in_usa 集合中的每个顶点,沿 born_in 入边找到出生在美国境内某地的人。
❺:同理,对 in_europe 集合中的每个顶点,沿 lives_in 入边找到居住在欧洲的人。
❻:最后,通过连接让“出生在美国的人”与“居住在欧洲的人”两个集合取交集。
同一个查询用 Cypher 只需 4 行,用 SQL 却要写 31 行,这恰恰说明选对数据模型和查询语言会带来多大差别。而这还只是开始;还有更多细节需要考虑,例如如何处理环,以及选择广度优先还是深度优先遍历 [^40]。
Oracle 为递归查询提供了另一套 SQL 扩展,称为 层次查询 [^41]。
不过,情况可能正在改善:在本书写作时,已有计划把一种名为 GQL 的图查询语言加入 SQL 标准 [^42] [^43],其语法借鉴了 Cypher、GSQL [^44] 和 PGQL [^45]。
三元组存储与 SPARQL {#id59}
三元组存储模型大体上与属性图模型相同,只是用不同的词汇描述同样的思想。不过它仍然值得单独讨论,因为三元组存储有许多现成的工具和语言,可以成为构建应用程序时的宝贵补充。
在三元组存储中,所有信息都以非常简单的三部分陈述来存储:(主语、谓语、宾语)。例如,在三元组(Jim、喜欢、香蕉)中,Jim 是主语,喜欢 是谓语(动词),香蕉 是宾语。
三元组的主语相当于图中的一个顶点,宾语则是以下两者之一:
- 字符串、数字等原始数据类型的值。这时,三元组的谓语和宾语相当于主语顶点上某项属性的键和值。例如,沿用 {{< xref fig="3-6" page="/ch3" anchor="fig_datamodels_graph" >}}图 3-6{{< /xref >}} 的例子,三元组(lucy、birthYear、1989)相当于顶点
lucy拥有属性{"birthYear": 1989}。 - 图中的另一个顶点。这时,谓语相当于图中的边,主语是尾部顶点,宾语是头部顶点。例如,在(lucy、marriedTo、alain)中,lucy 和 alain 都是顶点,谓语 marriedTo 则是连接二者的边标签。
[!NOTE]
严格来说,提供类似三元组数据模型的数据库,通常还要为每个元组存储一些额外元数据。例如,AWS Neptune 使用四元组(4-tuple),即为每个三元组增加一个图 ID [^46];Datomic 使用五元组,为每个三元组再加上事务 ID 和一个表示删除的布尔值 [^47]。这些数据库仍然保留着上文所述的基本 主语—谓语—宾语 结构,因此本书仍将它们统称为三元组存储。
{{< xref page="/ch3" anchor="fig_graph_n3_triples" >}}示例 3-7{{< /xref >}} 把 {{< xref page="/ch3" anchor="fig_cypher_create" >}}示例 3-4{{< /xref >}} 中的同一份数据写成了三元组,使用的格式称为 Turtle,它是 Notation3(N3)的一个子集 [^48]。
{{< example num="3-7" id="fig_graph_n3_triples" caption="图 3-6 中的一部分数据,以 Turtle 三元组表示" />}}
@prefix : .
_:lucy a :Person.
_:lucy :name "Lucy".
_:lucy :bornIn _:idaho.
_:idaho a :Location.
_:idaho :name "Idaho".
_:idaho :type "state".
_:idaho :within _:usa.
_:usa a :Location.
_:usa :name "United States".
_:usa :type "country".
_:usa :within _:namerica.
_:namerica a :Location.
_:namerica :name "North America".
_:namerica :type "continent".在这个例子中,图的顶点写成 _:someName。名称在当前文件之外没有任何意义;之所以需要它,只是为了分辨哪些三元组引用了同一个顶点。当谓语表示边时,宾语是另一个顶点,如 _:idaho :within _:usa;当谓语表示属性时,宾语则是字符串字面量,如 _:usa :name "United States"。
一遍遍重复同一个主语显得相当啰嗦,好在可以用分号连续陈述关于同一主语的多件事。这使 Turtle 格式颇为清晰易读,如 {{< xref page="/ch3" anchor="fig_graph_n3_shorthand" >}}示例 3-8{{< /xref >}} 所示。
{{< example num="3-8" id="fig_graph_n3_shorthand" caption="示例 3-7 中数据的简洁写法" />}}
@prefix : .
_:lucy a :Person; :name "Lucy"; :bornIn _:idaho.
_:idaho a :Location; :name "Idaho"; :type "state"; :within _:usa.
_:usa a :Location; :name "United States"; :type "country"; :within _:namerica.
_:namerica a :Location; :name "North America"; :type "continent".[!TIP] 语义网
三元组存储的一部分研究与开发,源于 语义网 的推动。这项始于 21 世纪初的尝试,希望数据不仅以供人阅读的网页发布,也以标准化、机器可读的格式发布,从而促进整个互联网范围内的数据交换。最初设想的语义网并未成功 [^49] [^50],但这个项目仍留下了若干具体技术:JSON-LD 等 链接数据 标准 [^51]、生物医学中使用的 本体 [^52]、Facebook 的 Open Graph 协议 [^53](用于展开链接预览 [^54])、Wikidata 等知识图谱,以及由 schema.org 维护的结构化数据标准词汇表。
三元组存储也是一项走出语义网原始场景、在别处找到用武之地的技术:即使你对语义网毫无兴趣,三元组仍可以成为很好的应用内部数据模型。
RDF 数据模型 {#the-rdf-data-model}
{{< xref page="/ch3" anchor="fig_graph_n3_shorthand" >}}示例 3-8{{< /xref >}} 使用的 Turtle 语言,实际上是对 资源描述框架(RDF)数据进行编码的一种方式 [^55];RDF 是专为语义网设计的数据模型。RDF 数据也可以采用其他编码,例如用更为冗长的 XML 表示,如 {{< xref page="/ch3" anchor="fig_graph_rdf_xml" >}}示例 3-9{{< /xref >}} 所示。Apache Jena 等工具可以在不同 RDF 编码之间自动转换。
{{< example num="3-9" id="fig_graph_rdf_xml" caption="使用 RDF/XML 语法表示示例 3-8 中的数据" />}}
<rdf:RDF xmlns="urn:example:"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<Location rdf:nodeID="idaho">
<name>Idaho</name>
<type>state</type>
<within>
<Location rdf:nodeID="usa">
<name>United States</name>
<type>country</type>
<within>
<Location rdf:nodeID="namerica">
<name>North America</name>
<type>continent</type>
</Location>
</within>
</Location>
</within>
</Location>
<name>Lucy</name>
</rdf:RDF>RDF 有一些奇特之处,因为它是为互联网范围的数据交换而设计的。三元组的主语、谓语和宾语通常都是 URI。例如,谓语可能写成 <http://my-company.com/namespace#within> 或 <http://my-company.com/namespace#lives_in>,而不只是 WITHIN 或 LIVES_IN。这样设计是为了让不同来源的数据可以合并:即使别人赋予 within 或 lives_in 不同含义,也不会发生冲突,因为对方的谓语实际是 <http://other.org/foo#within> 和 <http://other.org/foo#lives_in>。
从 RDF 的角度看,URL <http://my-company.com/namespace> 不一定真能解析出什么内容,它不过是个命名空间。为避免与 http:// URL 混淆,本节示例使用 urn:example:within 之类不可解析的 URI。好在只须在文件开头声明一次前缀,后面便不必再操心。
SPARQL 查询语言 {#the-sparql-query-language}
SPARQL 是一种面向 RDF 数据模型的三元组存储查询语言 [^56]。(它是 SPARQL Protocol and RDF Query Language 的缩写,读作 “sparkle”。)SPARQL 早于 Cypher;Cypher 的模式匹配借鉴了 SPARQL,因此两者看起来十分相似。
之前那条查找从美国移居欧洲者的查询,用 SPARQL 表示时和用 Cypher 一样简洁(见 {{< xref page="/ch3" anchor="fig_sparql_query" >}}示例 3-10{{< /xref >}})。
{{< example num="3-10" id="fig_sparql_query" caption="与示例 3-5 相同的查询,用 SPARQL 表示" />}}
PREFIX :
SELECT ?personName WHERE {
?person :name ?personName.
?person :bornIn / :within* / :name "United States".
?person :livesIn / :within* / :name "Europe".
}二者结构十分相似。下面两个表达式是等价的(SPARQL 中的变量以问号开头):
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (location) # Cypher
?person :bornIn / :within* ?location. # SPARQL因为 RDF 不区分属性与边,而是把二者都当作谓语,所以匹配属性也可以使用同一种语法。在下面的表达式中,变量 usa 会绑定到任意 name 属性为字符串 "United States" 的顶点:
(usa {name:'United States'}) # Cypher
?usa :name "United States". # SPARQLAmazon Neptune、AllegroGraph、Blazegraph、OpenLink Virtuoso、Apache Jena 以及其他多种三元组存储都支持 SPARQL [^36]。
Datalog:递归关系查询 {#id62}
Datalog 是比 SPARQL 和 Cypher 更古老的语言,源自 20 世纪 80 年代的学术研究 [^57] [^58] [^59]。它在软件工程师中不太知名,主流数据库也很少支持;但它表达能力很强,尤其擅长复杂查询,理应得到更多关注。Datomic、LogicBlox、CozoDB 以及 LinkedIn 的 LIquid [^60] 等几种小众数据库,都使用 Datalog 作为查询语言。
Datalog 实际上基于关系数据模型,而不是图模型;之所以把它放在图数据库一节,是因为 Datalog 尤其擅长对图进行递归查询。
Datalog 数据库由 事实 组成,每项事实对应关系表中的一行。假设有一张存储地点的 location 表,包含 ID、name 和 type 三列;“美国是一个国家”这项事实就可以写成 location(2, "United States", "country"),其中 2 是美国的 ID。一般来说,table(val1, val2, …) 表示 table 中有这样一行:第一列为 val1,第二列为 val2,依此类推。
{{< xref page="/ch3" anchor="fig_datalog_triples" >}}示例 3-11{{< /xref >}} 展示了如何用 Datalog 写出 {{< xref fig="3-6" page="/ch3" anchor="fig_datamodels_graph" >}}图 3-6{{< /xref >}} 左侧的数据。图中的边(within、born_in 和 lives_in)表示为两列的连接表。例如,Lucy 的 ID 是 100,爱达荷州的 ID 是 3,因此“Lucy 出生在爱达荷州”这项关系表示为 born_in(100, 3)。
{{< example num="3-11" id="fig_datalog_triples" caption="图 3-6 中数据的子集,表示为 Datalog 事实" />}}
location(1, "North America", "continent").
location(2, "United States", "country").
location(3, "Idaho", "state").
within(2, 1). /* 美国在北美 */
within(3, 2). /* 爱达荷州在美国 */
person(100, "Lucy").
born_in(100, 3). /* Lucy 出生在爱达荷州 */定义好数据之后,就可以写出与之前相同的查询,如 {{< xref page="/ch3" anchor="fig_datalog_query" >}}示例 3-12{{< /xref >}} 所示。它看起来与 Cypher 或 SPARQL 中的等价查询颇为不同,但不必因此望而却步。Datalog 是 Prolog 的一个子集;如果学过计算机科学,你或许见过这种编程语言。
{{< example num="3-12" id="fig_datalog_query" caption="与示例 3-5 相同的查询,用 Datalog 表示" />}}
within_recursive(LocID, PlaceName) :- location(LocID, PlaceName, _). /* 规则 1 */
within_recursive(LocID, PlaceName) :- within(LocID, ViaID), /* 规则 2 */
within_recursive(ViaID, PlaceName).
migrated(PName, BornIn, LivingIn) :- person(PersonID, PName), /* 规则 3 */
born_in(PersonID, BornID),
within_recursive(BornID, BornIn),
lives_in(PersonID, LivingID),
within_recursive(LivingID, LivingIn).
us_to_europe(Person) :- migrated(Person, "United States", "Europe"). /* 规则 4 */
/* us_to_europe 包含行 "Lucy"。 */Cypher 和 SPARQL 一上来便使用 SELECT,Datalog 却每次只向前迈一小步。我们通过定义 规则,从底层事实派生出新的虚拟表。这些派生表类似于(虚拟的)SQL 视图:它们并不存储在数据库中,却可以像保存事实的表一样接受查询。
{{< xref page="/ch3" anchor="fig_datalog_query" >}}示例 3-12{{< /xref >}} 定义了三个派生表:within_recursive、migrated 和 us_to_europe。每条规则中 :- 符号之前的部分,定义了虚拟表的名称与各列。例如,migrated(PName, BornIn, LivingIn) 是一张三列表,分别包含姓名、出生地名称和居住地名称。
虚拟表的内容由规则中 :- 符号之后的部分定义,它会尝试在各表中找出匹配特定模式的行。例如,person(PersonID, PName) 可以匹配 person(100, "Lucy") 这一行,此时变量 PersonID 绑定为 100,PName 绑定为 "Lucy"。只要系统能为 :- 右侧的 所有 模式找到匹配,规则便可以应用。应用规则的效果,就好像把 :- 左侧的内容加入数据库,并将其中的变量替换为各自匹配的值。
因此,可以按下面的方式应用规则(如 {{< xref fig="3-7" page="/ch3" anchor="fig_datalog_naive" >}}图 3-7{{< /xref >}} 所示):
- 数据库中存在
location(1, "North America", "continent"),所以规则 1 可以应用,生成within_recursive(1, "North America")。 - 数据库中存在
within(2, 1),上一步又生成了within_recursive(1, "North America"),所以规则 2 可以应用,生成within_recursive(2, "North America")。 - 数据库中存在
within(3, 2),上一步又生成了within_recursive(2, "North America"),所以再次应用规则 2,生成within_recursive(3, "North America")。
反复应用规则 1 和规则 2,within_recursive 虚拟表就能告诉我们,数据库中的哪些地点位于北美(或任何其他地点)之内。
{{< fig num="3-7" id="fig_datalog_naive" src="/fig/ddia_0307.png" caption="使用示例 3-12 中的 Datalog 规则确定爱达荷州在北美。" link="#fig_datalog_query" class="ddia-figure ddia-figure--panorama" width="1772" height="585" />}}
图 3-7. 使用 {{< xref page="/ch3" anchor="fig_datalog_query" >}}示例 3-12{{< /xref >}} 中的 Datalog 规则确定爱达荷州在北美。
接下来,规则 3 可以找出出生在 BornIn、现居 LivingIn 的人。规则 4 以 BornIn = 'United States' 和 LivingIn = 'Europe' 调用规则 3,只返回符合条件者的姓名。最后查询虚拟表 us_to_europe,Datalog 系统便会给出与先前 Cypher 和 SPARQL 查询相同的答案。
Datalog 需要一种不同于本章其他查询语言的思维方式。它允许逐条规则地搭建复杂查询,让一条规则引用其他规则,就像把代码拆成彼此调用的函数。函数可以递归,Datalog 规则也同样可以调用自身;{{< xref page="/ch3" anchor="fig_datalog_query" >}}示例 3-12{{< /xref >}} 的规则 2 正是如此,由此实现了 Datalog 查询中的图遍历。
GraphQL {#id63}
GraphQL 也是一种查询语言,但它在设计上比本章介绍的其他语言限制更多。GraphQL 的用途,是让运行在用户设备上的客户端软件(例如移动应用或 JavaScript Web 应用的前端)请求一份特定结构的 JSON 文档,其中恰好包含渲染用户界面所需的字段。借助 GraphQL 接口,开发者可以迅速修改客户端代码中的查询,而无须改动服务端 API。
GraphQL 的灵活性并非没有代价。采用 GraphQL 的组织通常需要一套工具,把 GraphQL 查询转换成对内部服务的请求,而这些内部服务往往使用 REST 或 gRPC(参见 第 5 章)。此外还要应对授权、限流和性能等问题 [^61]。GraphQL 查询来自不受信任的来源,因此查询语言本身也受到严格限制:它不允许任何执行成本可能很高的操作,否则用户就能大量提交昂贵查询,对服务器发动拒绝服务攻击。具体来说,GraphQL 不允许递归查询(Cypher、SPARQL、SQL 和 Datalog 都允许),也不能随意给出“查找出生在美国、现居欧洲的人”这样的搜索条件,除非服务所有者特意提供了相应搜索功能。
尽管如此,GraphQL 仍然很有用。{{< xref page="/ch3" anchor="fig_graphql_query" >}}示例 3-13{{< /xref >}} 展示了如何用它实现 Discord 或 Slack 这类群聊应用。查询请求用户有权访问的所有频道,并取回每个频道的名称和最近 50 条消息。对每条消息,它请求时间戳、正文,以及发送者姓名和头像 URL。若某条消息是对另一条消息的回复,查询还会请求原消息的正文与发送者姓名;界面可以用较小字号把这些内容显示在回复上方,作为上下文。
{{< example num="3-13" id="fig_graphql_query" caption="群聊应用的 GraphQL 查询示例" />}}
query ChatApp {
channels {
name
recentMessages(latest: 50) {
timestamp
content
sender {
fullName
imageUrl
}
replyTo {
content
sender {
fullName
}
}
}
}
}{{< xref page="/ch3" anchor="fig_graphql_response" >}}示例 3-14{{< /xref >}} 给出了 {{< xref page="/ch3" anchor="fig_graphql_query" >}}示例 3-13{{< /xref >}} 中查询的一种可能响应。响应是一份与查询结构相呼应的 JSON 文档:请求了哪些属性,它就不多不少地返回哪些属性。这样一来,服务器无须预先知道客户端渲染界面需要什么;客户端直接提出所需内容即可。例如,这条查询没有请求 replyTo 消息发送者的头像 URL。若界面后来要显示该头像,客户端只需在查询中加入 imageUrl 属性,无须修改服务器。
{{< example num="3-14" id="fig_graphql_response" caption="对示例 3-13 中查询的一种可能响应" />}}
{
"data": {
"channels": [
{
"name": "#general",
"recentMessages": [
{
"timestamp": 1693143014,
"content": "Hey! How are y'all doing?",
"sender": {"fullName": "Aaliyah", "imageUrl": "https://..."},
"replyTo": null
},
{
"timestamp": 1693143024,
"content": "Great! And you?",
"sender": {"fullName": "Caleb", "imageUrl": "https://..."},
"replyTo": {
"content": "Hey! How are y'all doing?",
"sender": {"fullName": "Aaliyah"}
}
},
...{{< xref page="/ch3" anchor="fig_graphql_response" >}}示例 3-14{{< /xref >}} 把消息发送者的姓名和头像 URL 直接嵌入消息对象。同一个用户若发送多条消息,这些信息会在每条消息中重复。原则上当然可以减少这种重复,但 GraphQL 选择接受更大的响应,以换取根据数据渲染界面时更加简单。
replyTo 字段也是如此:{{< xref page="/ch3" anchor="fig_graphql_response" >}}示例 3-14{{< /xref >}} 中的第二条消息回复了第一条,因此第一条消息的内容(“Hey!…”)和发送者 Aaliyah 又在 replyTo 下重复了一遍。也可以只返回被回复消息的 ID,但如果该 ID 不在这次返回的最近 50 条消息之中,客户端就得向服务器再发一次请求。直接复制内容,处理起来简单得多。
服务器端数据库可以用更加规范化的形式存储数据,并在处理查询时执行必要的连接。例如,服务器可以把消息正文与发送者的用户 ID、被回复消息的 ID 存在一起;收到上述查询时,再解析这些 ID,找出它们引用的记录。不过,客户端只能要求服务器执行 GraphQL 模式中明确开放的连接。
尽管 GraphQL 的响应看起来很像文档数据库返回的结果,而且名称中还带有 “graph”,它其实可以构建在任何数据库之上,无论是关系数据库、文档数据库还是图数据库。
事件溯源与 CQRS {#sec_datamodels_events}
在我们迄今讨论过的所有数据模型中,数据都以写入时的形式接受查询——无论它是 JSON 文档、表中的行,还是图中的顶点和边。然而在复杂应用中,有时很难找到一种数据表示,能够满足所有查询和展现数据的需求。在这种情况下,可以用一种形式写入数据,再从中派生出多种针对不同读取方式优化的表示。
我们在 “权威记录系统与衍生数据” 中已经见过这种思路,ETL(参见 “数据仓库”)就是一种派生过程。现在让我们再往前走一步。既然无论如何都要由一种数据表示派生出另一种,那就可以分别选用针对写入和读取优化的表示。如果只需优化数据写入,丝毫不必考虑查询效率,你会如何对数据建模?
也许,写入数据最简单、最快且表意最清楚的方式,就是写入 事件日志:每次写入数据时,都将它编码成一个自包含的字符串(也许是 JSON),其中带有时间戳,再追加到事件序列中。日志中的事件是 不可变的:你永远不会修改或删除它们,只会向日志追加更多事件(后来的事件可以取代早先事件的效力)。事件可以包含任意属性。
{{< xref fig="3-8" page="/ch3" anchor="fig_event_sourcing" >}}图 3-8{{< /xref >}} 给出了一个可能来自会议管理系统的例子。会议管理是一个复杂的业务领域:不仅个人参会者可以报名并用信用卡付款,企业也可以批量预订座位,以发票结算,再把座位分配给个人。演讲者、赞助商和志愿者等人可能要占用一些预留座位。预订还可能取消;与此同时,会议组织者又可能因为更换场地,而改变活动的容量。这些事情叠加在一起,哪怕只是计算还有多少空余座位,也会变成一项颇具挑战的查询。
{{< fig num="3-8" id="fig_event_sourcing" src="/fig/ddia_0308.png" caption="以不可变事件日志作为权威数据源,并从中派生物化视图。" class="ddia-figure ddia-figure--standard" width="1772" height="1321" />}}
在 {{< xref fig="3-8" page="/ch3" anchor="fig_event_sourcing" >}}图 3-8{{< /xref >}} 中,会议状态的每次变化(例如组织者开放报名,或参会者报名和取消报名),首先都会被存储为事件。每当日志追加一个事件,几个 物化视图(也称为 投影 或 读模型)也会随之更新,以反映该事件带来的影响。在这个会议示例中,可以有一个物化视图汇总每笔预订状态的所有相关信息,另一个计算会议组织者仪表盘所需的图表,第三个则为制作参会者胸牌的打印机生成文件。
以事件作为权威数据源,并把每次状态变化都表达为事件,这种思路称为 事件溯源 [^62] [^63]。维护独立的读取优化表示,并从写入优化的表示中派生它们,这种原则称为 命令查询责任分离(CQRS) [^64]。这些术语源自领域驱动设计(DDD)社区,不过类似的思路由来已久,例如 状态机复制(参见 “使用共享日志”)。
当来自用户的请求刚到达时,它还是一个 命令,首先需要验证。只有在命令已经执行且确认有效之后(例如,请求的预订有足够的空余座位),它才会成为事实,相应的事件也才会追加到日志中。因此,事件日志中应当只有有效事件;消费事件日志来构建物化视图的组件,不允许拒绝事件。
以事件溯源的方式对数据建模时,建议用过去时来命名事件(例如“座位已预订”),因为事件记录的是已经发生的事实。即使用户后来更改或取消预订,他们曾经预订过的事实依然成立;更改或取消是之后另行追加的事件。
事件溯源与星型模式的事实表(参见 “星型与雪花型:分析模式”)有一个相似之处:它们都是过去所发生事件的集合。不过,事实表中每一行的列都相同,事件溯源中则可以有许多类型不同、属性各异的事件。此外,事实表是无序集合,而事件溯源中事件的先后顺序很重要:如果一笔预订先成立、后取消,颠倒顺序处理这两个事件就毫无意义。
事件溯源和 CQRS 有几个优点:
- 对系统开发者来说,事件能更清楚地表达某件事 为什么 发生。例如,理解“预订已取消”这个事件,要比理解“
bookings表第 4001 行的active列已设为false,seat_assignments表中与该预订相关的三行已被删除,payments表中又插入了一行表示退款”容易得多。物化视图处理取消事件时,依然可能会对这些行执行修改;但由事件驱动这些更新,其原因就清楚得多。 - 事件溯源的一项关键原则,是物化视图应以可重现的方式从事件日志中派生:你应当随时能够删除物化视图,然后用同一套代码,按同样的顺序处理同样的事件,从而重新计算出视图。如果视图维护代码存在错误,只需删除视图,再用修正后的代码重算。错误也更容易查找,因为你可以反复重新运行视图维护代码,并检查它的行为。
- 你可以维护多个物化视图,分别针对应用所需的特定查询进行优化。它们可以与事件保存在同一个数据库中,也可以根据需要存入不同的数据库。这些视图可以使用任何数据模型,也可以通过反规范化来加快读取。只要服务重启时可以从事件日志重算视图,甚至可以只把它保留在内存中,根本不做持久化。
- 如果决定以新方式展现现有信息,可以很容易地根据现有事件日志构建新的物化视图。添加新的事件类型,或给现有事件类型添加新属性(旧事件保持不变),就能让系统演化并支持新功能。还可以在现有事件后触发新的行为,例如参会者取消预订后,将座位提供给等候名单中的下一个人。
- 如果误写了某个事件,可以把它删除,再重建不包含该事件的视图。相比之下,在直接更新和删除数据的数据库中,已经提交的事务往往很难撤销。因此,事件溯源可以减少系统中不可逆操作的数量,让变更更容易(参见 “可演化性:让变化更容易”)。
- 事件日志还可以作为审计日志,记录系统中发生的一切。在必须提供审计能力的受监管行业中,这一点很有价值。
然而,事件溯源和 CQRS 也有缺点:
- 涉及外部信息时必须小心。例如,假设事件中有一个以某种货币计价的价格,而某个视图需要把它兑换成另一种货币。由于汇率会波动,处理事件时再从外部数据源获取汇率就有问题:换一天重新计算物化视图,得到的结果就可能不同。为了让事件处理逻辑具有确定性,要么把汇率写入事件本身,要么提供一种方法,能查询事件时间戳所对应的历史汇率,并确保同一时间戳始终返回同一结果。
- 事件不可变这一要求,在事件包含用户个人数据时会带来问题,因为用户可能行使自己的权利(例如 GDPR 规定的权利),要求删除个人数据。如果每个用户各有一份事件日志,只需删除该用户的整份日志;但如果日志中的事件关系到多个用户,这种做法就行不通。可以尝试把个人数据存在事件之外,或者用一把密钥加密,以便日后删除密钥;但这也会让按需重算派生状态变得更困难。
- 如果处理事件会带来外部可见的副作用,那么重新处理事件时必须小心。例如,你大概不会希望每次重建物化视图时,都再发一遍确认邮件。
事件溯源可以构建在任何数据库之上,不过也有一些系统是专为这种模式设计的,例如 EventStoreDB、MartenDB(基于 PostgreSQL)和 Axon Framework。也可以使用 Apache Kafka 之类的消息代理来存储事件日志,并通过流处理器使物化视图保持最新;我们将在 “变更数据捕获与事件溯源” 中再次讨论这些主题。
唯一一项重要要求是:事件存储系统必须保证,所有物化视图都以事件在日志中出现的顺序处理它们。正如我们将在 第 10 章 中看到的,这在分布式系统中并不总是一件容易的事。
数据框、矩阵与数组 {#sec_datamodels_dataframes}
本章迄今介绍的数据模型,通常既用于事务处理,也用于分析(参见 “分析型与事务型系统”)。还有一些数据模型常见于分析或科学场景,却很少出现在 OLTP 系统中:数据框,以及矩阵等多维数值数组。
R 语言、Python 的 pandas 库、Apache Spark、ArcticDB 和 Dask 等系统,都支持数据框这种数据模型。数据科学家经常用它为训练机器学习模型准备数据;它也广泛用于数据探索、统计分析和数据可视化等场景。
乍看之下,数据框很像关系数据库中的表,也像电子表格。它支持一系列类似关系运算符的批量操作:例如,对所有行应用某个函数,按条件筛选行,按某些列分组并聚合其他列,以及按某个键连接两个数据框中的行(关系数据库中的 连接,在数据框中通常称为 合并)。
数据框通常不是通过 SQL 之类的声明式查询来操作,而是通过一系列命令逐步修改其结构和内容。这恰好符合数据科学家的典型工作流程:一点点地“整理”数据,直至它变成一种适合回答当前问题的形式。这些操作通常在数据科学家私有的数据集副本上进行,而且往往就在本机上;不过最终结果也可能会分享给其他用户。
数据框 API 提供的许多操作远远超出关系数据库的能力,其使用方式也往往与典型的关系数据建模大不相同 [^65]。例如,数据框的一种常见用途,是把数据从类似关系模型的表示转换为矩阵或多维数组,而许多机器学习算法期望的输入正是这种形式。
{{< xref fig="3-9" page="/ch3" anchor="fig_dataframe_to_matrix" >}}图 3-9{{< /xref >}} 展示了一个简单的转换示例。左侧是一张关系表,记录不同用户给各种电影打出的分数(1 到 5 分);右侧则把这些数据转换成了矩阵,每一列代表一部电影,每一行代表一位用户(类似电子表格中的 数据透视表)。这个矩阵是 稀疏 的,也就是说,很多用户与电影的组合都没有数据,但这并不碍事。矩阵可能有成千上万列,不太适合放在关系数据库中;数据框以及 Python 的 NumPy 等支持稀疏数组的库,却能轻松处理这类数据。
{{< fig num="3-9" id="fig_dataframe_to_matrix" src="/fig/ddia_0309.png" caption="将电影评分的关系数据库转换为矩阵表示。" class="ddia-figure ddia-figure--wide" width="1772" height="690" />}}
矩阵只能包含数字,因此需要用各种技术把非数值数据转换为矩阵中的数字。例如:
- 日期({{< xref fig="3-9" page="/ch3" anchor="fig_dataframe_to_matrix" >}}图 3-9{{< /xref >}} 的示例矩阵中省略了日期)可以按比例缩放为某个合适范围内的浮点数。
- 对于只能从一小组固定值中取值的列(例如电影数据库中的电影类型),通常采用 独热编码:为每个可能的值建立一列(“喜剧”一列、“剧情”一列、“恐怖”一列,依此类推);对于代表某部电影的每一行,在对应其类型的列中填 1,其余列填 0。这种表示也很容易推广到同时属于多种类型的电影。
数据一旦变成数值矩阵,就适合进行线性代数运算,而线性代数正是许多机器学习算法的基础。例如,{{< xref fig="3-9" page="/ch3" anchor="fig_dataframe_to_matrix" >}}图 3-9{{< /xref >}} 中的数据可以用在向用户推荐其可能喜欢的电影的系统中。数据框十分灵活,可以让数据从关系形式逐步演变为矩阵表示,同时让数据科学家自行掌控哪种表示最适合达成数据分析或模型训练的目标。
还有一些数据库专门存储大型多维数值数组,例如 TileDB [^66]。这类系统称为 数组数据库,最常用于科学数据集,例如地理空间测量数据(规则间隔网格上的栅格数据)、医学影像或天文望远镜的观测结果 [^67]。金融行业也用数据框表示 时间序列数据,例如资产价格以及按时间记录的交易 [^68]。
总结 {#summary}
数据模型是一个巨大的课题,本章只是快速浏览了各种不同的模型。我们没有足够的篇幅详述每个模型,但希望这份概览足以引起你的兴趣,促使你进一步了解最适合应用需求的模型。
关系模型 尽管已有半个多世纪的历史,但对许多应用来说仍然是一种重要的数据模型——尤其是在数据仓库和商业分析领域,关系型星型或雪花型模式与 SQL 查询无处不在。不过,关系数据的几种替代方案也在其他领域流行起来:
- 文档模型 主要关注自包含的 JSON 数据文档,而且文档之间的关系非常稀少。
- 图数据模型 用于相反的场景:任意事物都可能与其他一切事物相关,查询可能需要跨越多跳才能找到感兴趣的数据(这类查询可以用 Cypher、SPARQL 或 Datalog 中的递归查询来表达)。
- 数据框 把关系数据推广到拥有大量列的情形,在数据库与多维数组之间架起了桥梁;而多维数组正是许多机器学习、统计分析和科学计算的基础。
一个模型可以用另一个模型来模拟——例如,图数据可以在关系数据库中表示——但结果往往很别扭,正如 SQL 对递归查询的支持所表明的那样。
因此,人们为每种数据模型开发了各式各样的专用数据库,提供针对该模型优化的查询语言和存储引擎。另一方面,数据库也在不断加入对其他数据模型的支持,向相邻领域扩展:例如,关系数据库通过 JSON 列支持文档数据,文档数据库加入类似关系模型的连接,而 SQL 对图数据的支持也在逐步改善。
我们还讨论了 事件溯源:它把数据表示为不可变事件的仅追加日志,这种形式可能很适合为复杂业务领域中的活动建模。仅追加日志有利于数据写入(正如我们将在 第 4 章 中看到的);为了支持高效查询,CQRS 会把事件日志转换为针对读取优化的物化视图。
非关系数据模型的一个共同点是,它们通常不会将存储的数据强制约束为特定模式,这可以使应用更容易适应不断变化的需求。但是应用很可能仍会假定数据具有一定的结构;区别仅在于模式是 明确的(写入时强制)还是 隐含的(读取时假定)。
虽然我们已经覆盖了很多层面,但仍有一些数据模型没有提到。举几个简单的例子:
- 研究基因组数据的研究人员通常需要执行 序列相似性搜索,这意味着取一个很长的字符串(代表一个 DNA 分子),在一个包含大量相似但不完全相同的字符串的数据库中寻找匹配。这里描述的数据库都不能处理这种用法,这就是研究人员编写了 GenBank [^69] 等专用基因组数据库软件的原因。
- 许多金融系统以采用复式记账法的 账本 作为数据模型。这类数据可以用关系数据库表示,但也有 TigerBeetle 这样专攻此类数据模型的数据库。加密货币和区块链通常基于分布式账本,并把价值转移也内置在数据模型之中。
- 全文检索 可以说是一种经常与数据库配合使用的数据模型。信息检索是一个很大的专业课题,本书不会深入介绍,但我们将在 “全文检索” 中谈到搜索索引与向量搜索。
我们暂且说到这里。在下一章中,我们将讨论在 实现 本章描述的数据模型时会遇到的一些权衡。
参考文献
[^1]: Jamie Brandon. Unexplanations: query optimization works because sql is declarative. scattered-thoughts.net, February 2024. Archived at perma.cc/P6W2-WMFZ
[^2]: Joseph M. Hellerstein. The Declarative Imperative: Experiences and Conjectures in Distributed Logic. Tech report UCB/EECS-2010-90, Electrical Engineering and Computer Sciences, University of California at Berkeley, June 2010. Archived at perma.cc/K56R-VVQM
[^3]: Edgar F. Codd. A Relational Model of Data for Large Shared Data Banks. Communications of the ACM, volume 13, issue 6, pages 377–387, June 1970. doi:10.1145/362384.362685
[^4]: Michael Stonebraker and Joseph M. Hellerstein. What Goes Around Comes Around. In Readings in Database Systems, 4th edition, MIT Press, pages 2–41, 2005. ISBN: 9780262693141
[^5]: Markus Winand. Modern SQL: Beyond Relational. modern-sql.com, 2015. Archived at perma.cc/D63V-WAPN
[^6]: Martin Fowler. OrmHate. martinfowler.com, May 2012. Archived at perma.cc/VCM8-PKNG
[^7]: Vlad Mihalcea. N+1 query problem with JPA and Hibernate. vladmihalcea.com, January 2023. Archived at perma.cc/79EV-TZKB
[^8]: Jens Schauder. This is the Beginning of the End of the N+1 Problem: Introducing Single Query Loading. spring.io, August 2023. Archived at perma.cc/6V96-R333
[^9]: William Zola. 6 Rules of Thumb for MongoDB Schema Design. mongodb.com, June 2014. Archived at perma.cc/T2BZ-PPJB
[^10]: Sidney Andrews and Christopher McClister. Data modeling in Azure Cosmos DB. learn.microsoft.com, February 2023. Archived at archive.org
[^11]: Raffi Krikorian. Timelines at Scale. At QCon San Francisco, November 2012. Archived at perma.cc/V9G5-KLYK
[^12]: Ralph Kimball and Margy Ross. The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling, 3rd edition. John Wiley & Sons, July 2013. ISBN: 9781118530801
[^13]: Michael Kaminsky. Data warehouse modeling: Star schema vs. OBT. fivetran.com, August 2022. Archived at perma.cc/2PZK-BFFP
[^14]: Joe Nelson. User-defined Order in SQL. begriffs.com, March 2018. Archived at perma.cc/GS3W-F7AD
[^15]: Evan Wallace. Realtime Editing of Ordered Sequences. figma.com, March 2017. Archived at perma.cc/K6ER-CQZW
[^16]: David Greenspan. Implementing Fractional Indexing. observablehq.com, October 2020. Archived at perma.cc/5N4R-MREN
[^17]: Martin Fowler. Schemaless Data Structures. martinfowler.com, January 2013.
[^18]: Amr Awadallah. Schema-on-Read vs. Schema-on-Write. At Berkeley EECS RAD Lab Retreat, Santa Cruz, CA, May 2009. Archived at perma.cc/DTB2-JCFR
[^19]: Martin Odersky. The Trouble with Types. At Strange Loop, September 2013. Archived at perma.cc/85QE-PVEP
[^20]: Conrad Irwin. MongoDB—Confessions of a PostgreSQL Lover. At HTML5DevConf, October 2013. Archived at perma.cc/C2J6-3AL5
[^21]: Percona Toolkit Documentation: pt-online-schema-change. docs.percona.com, 2023. Archived at perma.cc/9K8R-E5UH
[^22]: Shlomi Noach. gh-ost: GitHub’s Online Schema Migration Tool for MySQL. github.blog, August 2016. Archived at perma.cc/7XAG-XB72
[^23]: Shayon Mukherjee. pg-osc: Zero downtime schema changes in PostgreSQL. shayon.dev, February 2022. Archived at perma.cc/35WN-7WMY
[^24]: Carlos Pérez-Aradros Herce. Introducing pgroll: zero-downtime, reversible, schema migrations for Postgres. xata.io, October 2023. Archived at archive.org
[^25]: James C. Corbett, Jeffrey Dean, Michael Epstein, Andrew Fikes, Christopher Frost, JJ Furman, Sanjay Ghemawat, Andrey Gubarev, Christopher Heiser, Peter Hochschild, Wilson Hsieh, Sebastian Kanthak, Eugene Kogan, Hongyi Li, Alexander Lloyd, Sergey Melnik, David Mwaura, David Nagle, Sean Quinlan, Rajesh Rao, Lindsay Rolig, Dale Woodford, Yasushi Saito, Christopher Taylor, Michal Szymaniak, and Ruth Wang. Spanner: Google’s Globally-Distributed Database. At 10th USENIX Symposium on Operating System Design and Implementation (OSDI), October 2012.
[^26]: Donald K. Burleson. Reduce I/O with Oracle Cluster Tables. dba-oracle.com. Archived at perma.cc/7LBJ-9X2C
[^27]: Fay Chang, Jeffrey Dean, Sanjay Ghemawat, Wilson C. Hsieh, Deborah A. Wallach, Mike Burrows, Tushar Chandra, Andrew Fikes, and Robert E. Gruber. Bigtable: A Distributed Storage System for Structured Data. At 7th USENIX Symposium on Operating System Design and Implementation (OSDI), November 2006.
[^28]: Priscilla Walmsley. XQuery, 2nd Edition. O’Reilly Media, December 2015. ISBN: 9781491915080
[^29]: Paul C. Bryan, Kris Zyp, and Mark Nottingham. JavaScript Object Notation (JSON) Pointer. RFC 6901, IETF, April 2013.
[^30]: Stefan Gössner, Glyn Normington, and Carsten Bormann. JSONPath: Query Expressions for JSON. RFC 9535, IETF, February 2024.
[^31]: Michael Stonebraker and Andrew Pavlo. What Goes Around Comes Around… And Around…. ACM SIGMOD Record, volume 53, issue 2, pages 21–37. doi:10.1145/3685980.3685984
[^32]: Lawrence Page, Sergey Brin, Rajeev Motwani, and Terry Winograd. The PageRank Citation Ranking: Bringing Order to the Web. Technical Report 1999-66, Stanford University InfoLab, November 1999. Archived at perma.cc/UML9-UZHW
[^33]: Nathan Bronson, Zach Amsden, George Cabrera, Prasad Chakka, Peter Dimov, Hui Ding, Jack Ferris, Anthony Giardullo, Sachin Kulkarni, Harry Li, Mark Marchukov, Dmitri Petrov, Lovro Puzar, Yee Jiun Song, and Venkat Venkataramani. TAO: Facebook’s Distributed Data Store for the Social Graph. At USENIX Annual Technical Conference (ATC), June 2013.
[^34]: Natasha Noy, Yuqing Gao, Anshu Jain, Anant Narayanan, Alan Patterson, and Jamie Taylor. Industry-Scale Knowledge Graphs: Lessons and Challenges. Communications of the ACM, volume 62, issue 8, pages 36–43, August 2019. doi:10.1145/3331166
[^35]: Xiyang Feng, Guodong Jin, Ziyi Chen, Chang Liu, and Semih Salihoğlu. KÙZU Graph Database Management System. At 3th Annual Conference on Innovative Data Systems Research (CIDR 2023), January 2023.
[^36]: Maciej Besta, Emanuel Peter, Robert Gerstenberger, Marc Fischer, Michał Podstawski, Claude Barthels, Gustavo Alonso, Torsten Hoefler. Demystifying Graph Databases: Analysis and Taxonomy of Data Organization, System Designs, and Graph Queries. arxiv.org, October 2019.
[^37]: Apache TinkerPop 3.6.3 Documentation. tinkerpop.apache.org, May 2023. Archived at perma.cc/KM7W-7PAT
[^38]: Nadime Francis, Alastair Green, Paolo Guagliardo, Leonid Libkin, Tobias Lindaaker, Victor Marsault, Stefan Plantikow, Mats Rydberg, Petra Selmer, and Andrés Taylor. Cypher: An Evolving Query Language for Property Graphs. At International Conference on Management of Data (SIGMOD), pages 1433–1445, May 2018. doi:10.1145/3183713.3190657
[^39]: Emil Eifrem. Twitter correspondence, January 2014. Archived at perma.cc/WM4S-BW64
[^40]: Francesco Tisiot. Explore the new SEARCH and CYCLE features in PostgreSQL® 14. aiven.io, December 2021. Archived at perma.cc/J6BT-83UZ
[^41]: Gaurav Goel. Understanding Hierarchies in Oracle. towardsdatascience.com, May 2020. Archived at perma.cc/5ZLR-Q7EW
[^42]: Alin Deutsch, Nadime Francis, Alastair Green, Keith Hare, Bei Li, Leonid Libkin, Tobias Lindaaker, Victor Marsault, Wim Martens, Jan Michels, Filip Murlak, Stefan Plantikow, Petra Selmer, Oskar van Rest, Hannes Voigt, Domagoj Vrgoč, Mingxi Wu, and Fred Zemke. Graph Pattern Matching in GQL and SQL/PGQ. At International Conference on Management of Data (SIGMOD), pages 2246–2258, June 2022. doi:10.1145/3514221.3526057
[^43]: Alastair Green. SQL... and now GQL. opencypher.org, September 2019. Archived at perma.cc/AFB2-3SY7
[^44]: Alin Deutsch, Yu Xu, and Mingxi Wu. Seamless Syntactic and Semantic Integration of Query Primitives over Relational and Graph Data in GSQL. tigergraph.com, November 2018. Archived at perma.cc/JG7J-Y35X
[^45]: Oskar van Rest, Sungpack Hong, Jinha Kim, Xuming Meng, and Hassan Chafi. PGQL: a property graph query language. At 4th International Workshop on Graph Data Management Experiences and Systems (GRADES), June 2016. doi:10.1145/2960414.2960421
[^46]: Amazon Web Services. Neptune Graph Data Model. Amazon Neptune User Guide, docs.aws.amazon.com. Archived at perma.cc/CX3T-EZU9
[^47]: Cognitect. Datomic Data Model. Datomic Cloud Documentation, docs.datomic.com. Archived at perma.cc/LGM9-LEUT
[^48]: David Beckett and Tim Berners-Lee. Turtle – Terse RDF Triple Language. W3C Team Submission, March 2011.
[^49]: Sinclair Target. Whatever Happened to the Semantic Web? twobithistory.org, May 2018. Archived at perma.cc/M8GL-9KHS
[^50]: Gavin Mendel-Gleason. The Semantic Web is Dead – Long Live the Semantic Web! terminusdb.com, August 2022. Archived at perma.cc/G2MZ-DSS3
[^51]: Manu Sporny. JSON-LD and Why I Hate the Semantic Web. manu.sporny.org, January 2014. Archived at perma.cc/7PT4-PJKF
[^52]: University of Michigan Library. Biomedical Ontologies and Controlled Vocabularies, guides.lib.umich.edu/ontology. Archived at perma.cc/Q5GA-F2N8
[^53]: Facebook. The Open Graph protocol, ogp.me. Archived at perma.cc/C49A-GUSY
[^54]: Matt Haughey. Everything you ever wanted to know about unfurling but were afraid to ask /or/ How to make your site previews look amazing in Slack. medium.com, November 2015. Archived at perma.cc/C7S8-4PZN
[^55]: W3C RDF Working Group. Resource Description Framework (RDF). w3.org, February 2004.
[^56]: Steve Harris, Andy Seaborne, and Eric Prud’hommeaux. SPARQL 1.1 Query Language. W3C Recommendation, March 2013.
[^57]: Todd J. Green, Shan Shan Huang, Boon Thau Loo, and Wenchao Zhou. Datalog and Recursive Query Processing. Foundations and Trends in Databases, volume 5, issue 2, pages 105–195, November 2013. doi:10.1561/1900000017
[^58]: Stefano Ceri, Georg Gottlob, and Letizia Tanca. What You Always Wanted to Know About Datalog (And Never Dared to Ask). IEEE Transactions on Knowledge and Data Engineering, volume 1, issue 1, pages 146–166, March 1989. doi:10.1109/69.43410
[^59]: Serge Abiteboul, Richard Hull, and Victor Vianu. Foundations of Databases. Addison-Wesley, 1995. ISBN: 9780201537710, available online at webdam.inria.fr/Alice
[^60]: Scott Meyer, Andrew Carter, and Andrew Rodriguez. LIquid: The soul of a new graph database, Part 2. engineering.linkedin.com, September 2020. Archived at perma.cc/K9M4-PD6Q
[^61]: Matt Bessey. Why, after 6 years, I’m over GraphQL. bessey.dev, May 2024. Archived at perma.cc/2PAU-JYRA
[^62]: Dominic Betts, Julián Domínguez, Grigori Melnik, Fernando Simonazzi, and Mani Subramanian. Exploring CQRS and Event Sourcing. Microsoft Patterns & Practices, July 2012. ISBN: 1621140164, archived at perma.cc/7A39-3NM8
[^63]: Greg Young. CQRS and Event Sourcing. At Code on the Beach, August 2014.
[^64]: Greg Young. CQRS Documents. cqrs.wordpress.com, November 2010. Archived at perma.cc/X5R6-R47F
[^65]: Devin Petersohn, Stephen Macke, Doris Xin, William Ma, Doris Lee, Xiangxi Mo, Joseph E. Gonzalez, Joseph M. Hellerstein, Anthony D. Joseph, and Aditya Parameswaran. Towards Scalable Dataframe Systems. Proceedings of the VLDB Endowment, volume 13, issue 11, pages 2033–2046. doi:10.14778/3407790.3407807
[^66]: Stavros Papadopoulos, Kushal Datta, Samuel Madden, and Timothy Mattson. The TileDB Array Data Storage Manager. Proceedings of the VLDB Endowment, volume 10, issue 4, pages 349–360, November 2016. doi:10.14778/3025111.3025117
[^67]: Florin Rusu. Multidimensional Array Data Management. Foundations and Trends in Databases, volume 12, numbers 2–3, pages 69–220, February 2023. doi:10.1561/1900000069
[^68]: Ed Targett. Bloomberg, Man Group team up to develop open source “ArcticDB” database. thestack.technology, March 2023. Archived at perma.cc/M5YD-QQYV
[^69]: Dennis A. Benson, Ilene Karsch-Mizrachi, David J. Lipman, James Ostell, and David L. Wheeler. GenBank. Nucleic Acids Research, volume 36, database issue, pages D25–D30, December 2007. doi:10.1093/nar/gkm929