docs: refresh contributor credits and sync Traditional Chinese

This commit is contained in:
Feng Ruohang
2026-08-28 16:37:54 +08:00
parent 4622e80a44
commit 2c706dbd70
24 changed files with 617 additions and 454 deletions

View File

@@ -8,7 +8,7 @@
**译者**[冯若航](https://vonng.com) / [Vonng](https://github.com/Vonng) (rh@vonng.com) [Pigsty](https://pgsty.com) 创始人,[活跃](https://committers.top/china)[开源贡献者](https://gitstar-ranking.com/Vonng)PostgreSQL Hacker。开源 RDS PG 发行版 [Pigsty](https://pigsty.cc/zh/) 与公众号《[老冯云数](https://mp.weixin.qq.com/s/p4Ys10ZdEDAuqNAiRmcnIQ)》作者,[数据库老司机](https://pigsty.cc/zh/blog/db)[云计算泥石流](https://pigsty.cc/zh/blog/cloud)曾于阿里苹果探探担任架构师与DBA。
**校订** [@yingang](https://github.com/yingang) [**繁體中文**](content/tw/_index.md) by [@afunTW](https://github.com/afunTW) [完整贡献者列表](#贡献)
**校订** [@yingang](https://github.com/yingang) [**繁體中文**](content/tw/_index.md) by [@afunTW](https://github.com/afunTW) [完整贡献者列表](https://ddia.vonng.com/contrib/)
**阅读**:访问 [https://ddia.vonng.com](https://ddia.vonng.com) 阅读本书在线版本,或使用 [Hugo](https://gohugo.io/documentation/) / [OINK 0.8.0](https://oink.pgsty.com/) 主题自行构建。在线版支持稳定图表编号与交叉引用、顺序阅读、整书打印、Markdown / `llms.txt` 输出和 EPUB 导出。
@@ -104,13 +104,80 @@
5. 词汇表、后记关于野猪的部分 by [@cg-zhou](https://github.com/Vonng/ddia/commits?author=cg-zhou)
6. 繁體中文版本与转换脚本 by [@afunTW](https://github.com/afunTW)
7. 多处翻译修正 by [@songzhibin97](https://github.com/Vonng/ddia/commits?author=songzhibin97) [@MamaShip](https://github.com/Vonng/ddia/commits?author=MamaShip) [@FangYuan33](https://github.com/Vonng/ddia/commits?author=FangYuan33)
8. 感谢所有作出贡献,提出意见的朋友们
8. 感谢所有提交 Issue 或 PR 的朋友;无论是否采纳、合并或关闭,反馈本身即是贡献。完整名单见[贡献者页面](https://ddia.vonng.com/contrib/)
<details>
<summary>210 位贡献者</summary>
[@Vonng](https://github.com/Vonng) · [@yingang](https://github.com/yingang) · [@afunTW](https://github.com/afunTW) · [@117503445](https://github.com/117503445) · [@1ess](https://github.com/1ess) · [@1stcoderXiaoLin](https://github.com/1stcoderXiaoLin)
[@2997ms](https://github.com/2997ms) · [@2w1nd](https://github.com/2w1nd) · [@674019130](https://github.com/674019130) · [@abbychau](https://github.com/abbychau) · [@afredlyj](https://github.com/afredlyj) · [@aha-jansen](https://github.com/aha-jansen)
[@akxxsb](https://github.com/akxxsb) · [@AldenWangExis](https://github.com/AldenWangExis) · [@AlexZFX](https://github.com/AlexZFX) · [@AlphaWang](https://github.com/AlphaWang) · [@amber-moe](https://github.com/amber-moe) · [@anaer](https://github.com/anaer)
[@artiship](https://github.com/artiship) · [@atlas927](https://github.com/atlas927) · [@auula](https://github.com/auula) · [@avenarius-argus](https://github.com/avenarius-argus) · [@Axlgrep](https://github.com/Axlgrep) · [@b7woreo](https://github.com/b7woreo)
[@baijinping](https://github.com/baijinping) · [@bbwang-gl](https://github.com/bbwang-gl) · [@bearomorphism](https://github.com/bearomorphism) · [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) · [@bestgrc](https://github.com/bestgrc) · [@blindpirate](https://github.com/blindpirate)
[@bluebear4](https://github.com/bluebear4) · [@Bowser1704](https://github.com/Bowser1704) · [@bp4m4h94](https://github.com/bp4m4h94) · [@brucevoin](https://github.com/brucevoin) · [@brynne8](https://github.com/brynne8) · [@ButcherV](https://github.com/ButcherV)
[@c25423](https://github.com/c25423) · [@c2j](https://github.com/c2j) · [@cch123](https://github.com/cch123) · [@cclauss](https://github.com/cclauss) · [@ccxhwmy](https://github.com/ccxhwmy) · [@cg-zhou](https://github.com/cg-zhou)
[@chesha1](https://github.com/chesha1) · [@chlch](https://github.com/chlch) · [@chroming](https://github.com/chroming) · [@codexvn](https://github.com/codexvn) · [@cuprumz](https://github.com/cuprumz) · [@cwr31](https://github.com/cwr31)
[@daihaowxg](https://github.com/daihaowxg) · [@DavidZhiXing](https://github.com/DavidZhiXing) · [@dch1228](https://github.com/dch1228) · [@demo-zexuan](https://github.com/demo-zexuan) · [@demonkit](https://github.com/demonkit) · [@derekwu0101](https://github.com/derekwu0101)
[@duroey](https://github.com/duroey) · [@elsonLee](https://github.com/elsonLee) · [@enochii](https://github.com/enochii) · [@ethan-tan-stori](https://github.com/ethan-tan-stori) · [@EvanMu96](https://github.com/EvanMu96) · [@exzhawk](https://github.com/exzhawk)
[@FangYuan33](https://github.com/FangYuan33) · [@fantasyczl](https://github.com/fantasyczl) · [@feeeei](https://github.com/feeeei) · [@Flyraty](https://github.com/Flyraty) · [@fuxuemingzhu](https://github.com/fuxuemingzhu) · [@ganler](https://github.com/ganler)
[@Gezi-lzq](https://github.com/Gezi-lzq) · [@Gilbert1024](https://github.com/Gilbert1024) · [@goace](https://github.com/goace) · [@guaidaokakaxi](https://github.com/guaidaokakaxi) · [@haifeiWu](https://github.com/haifeiWu) · [@hanyu2](https://github.com/hanyu2)
[@hecenjie](https://github.com/hecenjie) · [@hezhengdong](https://github.com/hezhengdong) · [@hli988](https://github.com/hli988) · [@huang06](https://github.com/huang06) · [@huiscool](https://github.com/huiscool) · [@ibyte2011](https://github.com/ibyte2011)
[@imcheney](https://github.com/imcheney) · [@jacklightChen](https://github.com/jacklightChen) · [@jasonlei-chn](https://github.com/jasonlei-chn) · [@JCYoky](https://github.com/JCYoky) · [@jenac](https://github.com/jenac) · [@jiajiadebug](https://github.com/jiajiadebug)
[@jiong-han](https://github.com/jiong-han) · [@justlorain](https://github.com/justlorain) · [@Juude](https://github.com/Juude) · [@KAKXA](https://github.com/KAKXA) · [@kangni](https://github.com/kangni) · [@kehao-chen](https://github.com/kehao-chen)
[@kemingy](https://github.com/kemingy) · [@KevinZhangt](https://github.com/KevinZhangt) · [@kimi0230](https://github.com/kimi0230) · [@Klu5ure](https://github.com/Klu5ure) · [@krisjin](https://github.com/krisjin) · [@l1t1](https://github.com/l1t1)
[@leo-987](https://github.com/leo-987) · [@lewiszlw](https://github.com/lewiszlw) · [@LHRchina](https://github.com/LHRchina) · [@liangGTY](https://github.com/liangGTY) · [@LiminCode](https://github.com/LiminCode) · [@lis186](https://github.com/lis186)
[@lllliuliu](https://github.com/lllliuliu) · [@llmmddCoder](https://github.com/llmmddCoder) · [@longjiquan](https://github.com/longjiquan) · [@lpxxn](https://github.com/lpxxn) · [@lqbilbo](https://github.com/lqbilbo) · [@lroolle](https://github.com/lroolle)
[@luchao2424631502](https://github.com/luchao2424631502) · [@Lyianu](https://github.com/Lyianu) · [@lynkeib](https://github.com/lynkeib) · [@lyuxi99](https://github.com/lyuxi99) · [@lzwill](https://github.com/lzwill) · [@Makonike](https://github.com/Makonike)
[@MamaShip](https://github.com/MamaShip) · [@marvin263](https://github.com/marvin263) · [@mawenqi](https://github.com/mawenqi) · [@Max-Tortoise](https://github.com/Max-Tortoise) · [@meijies](https://github.com/meijies) · [@meilin96](https://github.com/meilin96)
[@MintBlue](https://github.com/MintBlue) · [@mrdrivingduck](https://github.com/mrdrivingduck) · [@msxfXF](https://github.com/msxfXF) · [@MuAlex](https://github.com/MuAlex) · [@mxdljwxx](https://github.com/mxdljwxx) · [@NageNalock](https://github.com/NageNalock)
[@narojay](https://github.com/narojay) · [@nevertiree](https://github.com/nevertiree) · [@NIL-zhuang](https://github.com/NIL-zhuang) · [@northmorn](https://github.com/northmorn) · [@omegaatt36](https://github.com/omegaatt36) · [@OneSizeFitsQuorum](https://github.com/OneSizeFitsQuorum)
[@Ozarklake](https://github.com/Ozarklake) · [@PanggNOTlovebean](https://github.com/PanggNOTlovebean) · [@Panmax](https://github.com/Panmax) · [@patricksuo](https://github.com/patricksuo) · [@Pcrab](https://github.com/Pcrab) · [@PragmaTwice](https://github.com/PragmaTwice)
[@q00218426](https://github.com/q00218426) · [@qig243](https://github.com/qig243) · [@quwang123](https://github.com/quwang123) · [@rabomen](https://github.com/rabomen) · [@rentiansheng](https://github.com/rentiansheng) · [@rovernerd](https://github.com/rovernerd)
[@saintube](https://github.com/saintube) · [@samwu4166](https://github.com/samwu4166) · [@scaugrated](https://github.com/scaugrated) · [@seagullbird](https://github.com/seagullbird) · [@secret4233](https://github.com/secret4233) · [@shiyiwan](https://github.com/shiyiwan)
[@shukebeta](https://github.com/shukebeta) · [@skyran1278](https://github.com/skyran1278) · [@smallyard](https://github.com/smallyard) · [@songzhibin97](https://github.com/songzhibin97) · [@soulrrrrr](https://github.com/soulrrrrr) · [@spike014](https://github.com/spike014)
[@SpikeWong](https://github.com/SpikeWong) · [@starsea](https://github.com/starsea) · [@Stephan14](https://github.com/Stephan14) · [@Style980102](https://github.com/Style980102) · [@sunbuhui](https://github.com/sunbuhui) · [@Sunt-ing](https://github.com/Sunt-ing)
[@SunVdong](https://github.com/SunVdong) · [@sunyiwei24601](https://github.com/sunyiwei24601) · [@sunzeren](https://github.com/sunzeren) · [@SXP-Simon](https://github.com/SXP-Simon) · [@taco-wang](https://github.com/taco-wang) · [@tankilo](https://github.com/tankilo)
[@tigerinus](https://github.com/tigerinus) · [@tisonkun](https://github.com/tisonkun) · [@tooloudwind](https://github.com/tooloudwind) · [@TrafalgarRicardoLu](https://github.com/TrafalgarRicardoLu) · [@TronYY](https://github.com/TronYY) · [@truenorth-lj](https://github.com/truenorth-lj)
[@ui-HookeyChiang](https://github.com/ui-HookeyChiang) · [@uncle-lv](https://github.com/uncle-lv) · [@undeflife](https://github.com/undeflife) · [@Valerie-space](https://github.com/Valerie-space) · [@Vermouth1995](https://github.com/Vermouth1995) · [@vult137](https://github.com/vult137)
[@WAangzE](https://github.com/WAangzE) · [@wafer-li](https://github.com/wafer-li) · [@walshzhang](https://github.com/walshzhang) · [@wangqiim](https://github.com/wangqiim) · [@wayne87140](https://github.com/wayne87140) · [@Waynting](https://github.com/Waynting)
[@woodpenker](https://github.com/woodpenker) · [@wwek](https://github.com/wwek) · [@wynn5a](https://github.com/wynn5a) · [@xianlaioy](https://github.com/xianlaioy) · [@xiekeyi98](https://github.com/xiekeyi98) · [@XIJINIAN](https://github.com/XIJINIAN)
[@xiyihan0](https://github.com/xiyihan0) · [@xyohn](https://github.com/xyohn) · [@yangshangde](https://github.com/yangshangde) · [@ych](https://github.com/ych) · [@yhao3](https://github.com/yhao3) · [@yhm138](https://github.com/yhm138)
[@yjhmelody](https://github.com/yjhmelody) · [@YKIsTheBest](https://github.com/YKIsTheBest) · [@Ynjxsjmh](https://github.com/Ynjxsjmh) · [@YunfengGao](https://github.com/YunfengGao) · [@z-soulx](https://github.com/z-soulx) · [@zenuo](https://github.com/zenuo)
[@zhangnew](https://github.com/zhangnew) · [@Zhayhp](https://github.com/Zhayhp) · [@zhtisi](https://github.com/zhtisi) · [@Zombo1296](https://github.com/Zombo1296) · [@ZvanYang](https://github.com/ZvanYang) · [@zydmayday](https://github.com/zydmayday)
</details>
<details>
<summary><a href="https://github.com/Vonng/ddia/pulls">Pull Requests</a> & <a href="https://github.com/Vonng/ddia/issues">Issues</a></summary>
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
| [414](https://github.com/Vonng/ddia/issues/414) | [@Valerie-space](https://github.com/Valerie-space) | 反馈字体选项 |
| [413](https://github.com/Vonng/ddia/pull/413) | [@luchao2424631502](https://github.com/luchao2424631502) | ch1修改错别字 |
| [412](https://github.com/Vonng/ddia/issues/412) | [@chesha1](https://github.com/chesha1) | 反馈网站使用体验 |
| [411](https://github.com/Vonng/ddia/pull/411) | [@Waynting](https://github.com/Waynting) | 修复 GDPR 引文括号渲染 |
| [410](https://github.com/Vonng/ddia/pull/410) | [@aha-jansen](https://github.com/aha-jansen) | 修复 EPUB 封面、目录与导航 |
| [409](https://github.com/Vonng/ddia/pull/409) | [@Klu5ure](https://github.com/Klu5ure) | 修正 README 翻译进度 |
| [408](https://github.com/Vonng/ddia/pull/408) | [@hezhengdong](https://github.com/hezhengdong) | 修复 ch8 列表排版 |
| [407](https://github.com/Vonng/ddia/pull/407) | [@AldenWangExis](https://github.com/AldenWangExis) | 统一简繁强调格式 |
| [406](https://github.com/Vonng/ddia/issues/406) | [@AldenWangExis](https://github.com/AldenWangExis) | 反馈简体强调格式不一致 |
| [405](https://github.com/Vonng/ddia/pull/405) | [@AldenWangExis](https://github.com/AldenWangExis) | 补全简繁术语缩写 |
| [404](https://github.com/Vonng/ddia/issues/404) | [@AldenWangExis](https://github.com/AldenWangExis) | 反馈简繁译文不同步 |
| [403](https://github.com/Vonng/ddia/issues/403) | [@AldenWangExis](https://github.com/AldenWangExis) | 反馈简繁译文不同步 |
| [402](https://github.com/Vonng/ddia/pull/402) | [@AldenWangExis](https://github.com/AldenWangExis) | 优化 ch1 简繁措辞 |
| [401](https://github.com/Vonng/ddia/pull/401) | [@AldenWangExis](https://github.com/AldenWangExis) | 修复 EPUB 目录跳转 |
| [400](https://github.com/Vonng/ddia/pull/400) | [@SXP-Simon](https://github.com/SXP-Simon) | 补译 ch13 标题 |
| [399](https://github.com/Vonng/ddia/issues/399) | [@1stcoderXiaoLin](https://github.com/1stcoderXiaoLin) | 反馈网站不可用 |
| [398](https://github.com/Vonng/ddia/pull/398) | [@c2j](https://github.com/c2j) | 支持整本 PDF 导出 |
| [397](https://github.com/Vonng/ddia/pull/397) | [@daihaowxg](https://github.com/daihaowxg) | 修正 ch2 笔误 |
| [395](https://github.com/Vonng/ddia/issues/395) | [@ethan-tan-stori](https://github.com/ethan-tan-stori) | 反馈 ch6 译法问题 |
| [394](https://github.com/Vonng/ddia/issues/394) | [@wayne87140](https://github.com/wayne87140) | 建议术语补充英文原文 |
| [393](https://github.com/Vonng/ddia/pull/393) | [@cg-zhou](https://github.com/cg-zhou) | 修正贡献者链接与名单 |
| [391](https://github.com/Vonng/ddia/issues/391) | [@demonkit](https://github.com/demonkit) | 反馈 ch1 译文不顺 |
| [390](https://github.com/Vonng/ddia/pull/390) | [@bearomorphism](https://github.com/bearomorphism) | 修正 ch1 文字与排版 |
| [389](https://github.com/Vonng/ddia/pull/389) | [@demo-zexuan](https://github.com/demo-zexuan) | 恢复 EPUB 导出并修复图片 |
| [388](https://github.com/Vonng/ddia/issues/388) | [@MintBlue](https://github.com/MintBlue) | 反馈 EPUB 导出失效 |
| [387](https://github.com/Vonng/ddia/pull/387) | [@ButcherV](https://github.com/ButcherV) | 修正 part-i 重复标点 |
| [386](https://github.com/Vonng/ddia/pull/386) | [@uncle-lv](https://github.com/uncle-lv) | ch2: 优化一处翻译 |
| [384](https://github.com/Vonng/ddia/pull/384) | [@PanggNOTlovebean](https://github.com/PanggNOTlovebean) | docs: 优化中文文档的措辞和表达 |
| [383](https://github.com/Vonng/ddia/pull/383) | [@PanggNOTlovebean](https://github.com/PanggNOTlovebean) | docs: 修正 ch4 中的术语和表达错误 |
@@ -159,7 +226,7 @@
| [281](https://github.com/Vonng/ddia/pull/281) | [@lyuxi99](https://github.com/lyuxi99) | 更正多处内部链接错误 |
| [280](https://github.com/Vonng/ddia/pull/280) | [@lyuxi99](https://github.com/lyuxi99) | ch9: 更正内部链接错误 |
| [279](https://github.com/Vonng/ddia/issues/279) | [@codexvn](https://github.com/codexvn) | ch9: 指出公式在 GitHub Pages 显示的问题 |
| [278](https://github.com/Vonng/ddia/pull/278) | [@LJlkdskdjflsa](https://github.com/LJlkdskdjflsa) | 发现了繁体中文版本中的错误翻译 |
| [278](https://github.com/Vonng/ddia/pull/278) | [@truenorth-lj](https://github.com/truenorth-lj) | 发现了繁体中文版本中的错误翻译 |
| [275](https://github.com/Vonng/ddia/pull/275) | [@117503445](https://github.com/117503445) | 更正 LICENSE 链接 |
| [274](https://github.com/Vonng/ddia/pull/274) | [@uncle-lv](https://github.com/uncle-lv) | ch7: 修正错别字 |
| [273](https://github.com/Vonng/ddia/pull/273) | [@quwang123](https://github.com/quwang123) | ch7: 统一了 write skew 的翻译 |
@@ -205,7 +272,7 @@
| [166](https://github.com/Vonng/ddia/pull/166) | [@bp4m4h94](https://github.com/bp4m4h94) | ch1: 发现错误的文献索引 |
| [164](https://github.com/Vonng/ddia/pull/164) | [@longjiquan](https://github.com/longjiquan) | preface: 更正错误的标点符号 |
| [163](https://github.com/Vonng/ddia/pull/163) | [@llmmddCoder](https://github.com/llmmddCoder) | ch1: 更正错误字 |
| [160](https://github.com/Vonng/ddia/pull/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建议将 network model 翻译为网状模型 |
| [160](https://github.com/Vonng/ddia/issues/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建议将 network model 翻译为网状模型 |
| [159](https://github.com/Vonng/ddia/pull/159) | [@1ess](https://github.com/1ess) | ch4: 更正错误字 |
| [157](https://github.com/Vonng/ddia/pull/157) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [155](https://github.com/Vonng/ddia/pull/155) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |

View File

@@ -17,15 +17,15 @@ breadcrumbs: false
少量資料可以在單臺機器上儲存和處理,通常比較容易應付。然而,隨著資料量或查詢速率增長,資料就需要分佈到多臺機器上,由此帶來許多挑戰。應用需求變得更加複雜之後,把所有資料放在一個系統裡也不再夠用,往往需要組合多個能力各異的儲存或處理系統。
如果資料管理是開發應用時面臨的主要挑戰之一,我們就稱這種應用為 *資料密集型* 應用 [^1]。*計算密集型* 系統的難點在於如何將某項極其龐大的計算並行化;而對資料密集型應用來說,我們通常更關心如何儲存和處理海量資料、如何管理資料變更、如何在故障和併發面前保證一致性,以及如何維持服務的高可用性。
如果資料管理是開發應用時面臨的主要挑戰之一,我們就稱這種應用為 *資料密集型**data-intensive*)應用 [^1]。*計算密集型**compute-intensive*系統的難點在於如何將某項極其龐大的計算並行化;而對資料密集型應用來說,我們通常更關心如何儲存和處理海量資料、如何管理資料變更、如何在故障和併發面前保證一致性,以及如何維持服務的高可用性。
這類應用通常由一些標準構件搭建而成,它們提供各種常用功能。例如,許多應用都需要:
* 儲存資料,以便自己或其他應用日後能夠再次找到(*資料庫*
* 記住開銷昂貴的操作結果,加快讀取速度(*快取*
* 允許使用者按關鍵字搜尋資料,或以各種方式過濾資料(*搜尋索引*
* 在事件和資料變更發生後立即處理(*流處理*
* 定期處理累積的大批次資料(*批處理*
* 儲存資料,以便自己或其他應用日後能夠再次找到(*資料庫**database*
* 記住開銷昂貴的操作結果,加快讀取速度(*快取**cache*
* 允許使用者按關鍵字搜尋資料,或以各種方式過濾資料(*搜尋索引**search index*
* 在事件和資料變更發生後立即處理(*流處理**stream processing*
* 定期處理累積的大批次資料(*批處理**batch processing*
構建應用時,我們通常會選用幾個軟體系統或服務(例如資料庫和 API再用應用程式碼把它們拼接起來。如果你的用途恰好是這些資料系統原本就為之設計的整個過程可能相當容易。
@@ -33,7 +33,7 @@ breadcrumbs: false
本書將幫助你決定採用哪些技術,以及如何組合這些技術。正如你將看到的,並不存在一種從根本上優於其他方案的做法;每種方案都有利有弊。本書會教你提出恰當的問題來評估和比較資料系統,從而找出最能滿足特定應用需求的方案。
我們的旅程從當今組織使用資料的一些典型方式開始。這裡的許多思想源自 *企業軟體*,也就是大型組織(例如大公司和政府機構)的軟體需求與工程實踐。因為在過去,只有大型組織才擁有如此龐大的資料量,需要複雜的技術方案;只要資料量足夠小,用電子表格儲存就行了!不過近年來,小公司和初創企業管理海量資料、構建資料密集型系統,也已變得十分普遍。
我們的旅程從當今組織使用資料的一些典型方式開始。這裡的許多思想源自 *企業軟體**enterprise software*,也就是大型組織(例如大公司和政府機構)的軟體需求與工程實踐。因為在過去,只有大型組織才擁有如此龐大的資料量,需要複雜的技術方案;只要資料量足夠小,用電子表格儲存就行了!不過近年來,小公司和初創企業管理海量資料、構建資料密集型系統,也已變得十分普遍。
資料系統的一項關鍵挑戰是:不同的人需要用資料做截然不同的事情。在一家公司裡,你和你的團隊有自己的一套優先事項;另一個團隊即使在處理同一份資料,也可能有完全不同的目標。而且,這些目標未必得到明確表達,因而很容易造成誤解,並引發對正確方案的爭論。
@@ -48,34 +48,34 @@ breadcrumbs: false
> [!TIP] 術語:前端與後端
>
> 本書討論的許多內容都與 *後端開發* 有關。以 Web 應用為例,執行在瀏覽器中的客戶端程式碼稱為 *前端*,處理使用者請求的伺服器端程式碼則稱為 *後端*。移動應用與前端相似:它們負責提供使用者介面,並且常常經由網際網路與伺服器端後端通訊。前端有時也會在使用者裝置上管理本地資料 [^2],但資料基礎設施面臨的最大挑戰往往在後端:前端只需處理一個使用者的資料,而後端要代表 *所有* 使用者管理資料。
> 本書討論的許多內容都與 *後端開發**backend development*有關。以 Web 應用為例,執行在瀏覽器中的客戶端程式碼稱為 *前端**frontend*,處理使用者請求的伺服器端程式碼則稱為 *後端**backend*。移動應用與前端相似:它們負責提供使用者介面,並且常常經由網際網路與伺服器端後端通訊。前端有時也會在使用者裝置上管理本地資料 [^2],但資料基礎設施面臨的最大挑戰往往在後端:前端只需處理一個使用者的資料,而後端要代表 *所有* 使用者管理資料。
>
> 後端服務通常可以透過 HTTP有時是 WebSocket訪問。它一般由一些應用程式碼組成這些程式碼在一個或多個資料庫中讀寫資料有時也與快取、訊息佇列等其他資料系統互動這些系統可以統稱為 *資料基礎設施*。應用程式碼通常是 *無狀態* 的,也就是說,處理完一個 HTTP 請求後,它就會忘掉有關該請求的一切。任何需要在請求之間持久儲存的資訊,都必須存放在客戶端或伺服器端的資料基礎設施中。
> 後端服務通常可以透過 HTTP有時是 WebSocket訪問。它一般由一些應用程式碼組成這些程式碼在一個或多個資料庫中讀寫資料有時也與快取、訊息佇列等其他資料系統互動這些系統可以統稱為 *資料基礎設施**data infrastructure*。應用程式碼通常是 *無狀態**stateless*的,也就是說,處理完一個 HTTP 請求後,它就會忘掉有關該請求的一切。任何需要在請求之間持久儲存的資訊,都必須存放在客戶端或伺服器端的資料基礎設施中。
## 分析型與事務型系統 {#sec_introduction_analytics}
如果你在企業中從事資料系統工作,很可能會遇到幾類與資料打交道的人。第一類是 *後端工程師*,負責構建處理資料讀取和更新請求的服務。這些服務通常直接面向外部使用者,或透過其他服務間接為外部使用者提供功能(參見[“微服務與無伺服器”](/tw/ch1#sec_introduction_microservices));有時也只供組織內其他部門使用。
如果你在企業中從事資料系統工作,很可能會遇到幾類與資料打交道的人。第一類是 *後端工程師**backend engineer*,負責構建處理資料讀取和更新請求的服務。這些服務通常直接面向外部使用者,或透過其他服務間接為外部使用者提供功能(參見[“微服務與無伺服器”](/tw/ch1#sec_introduction_microservices));有時也只供組織內其他部門使用。
除了管理後端服務的團隊,通常還有兩類人需要訪問組織的資料:*業務分析師* 根據組織的活動生成報表,幫助管理層做出更好的決策,這就是 *商業智慧*BI*資料科學家* 則從資料中尋找新的洞見,或者利用資料分析和機器學習/AI 構建面向使用者的產品功能,例如電商網站上“購買了 X 的人也購買了 Y”的推薦、風險評分或垃圾郵件過濾等預測分析以及搜尋結果排名。
除了管理後端服務的團隊,通常還有兩類人需要訪問組織的資料:*業務分析師**business analyst*根據組織的活動生成報表,幫助管理層做出更好的決策,這就是 *商業智慧*BI*資料科學家**data scientist*則從資料中尋找新的洞見,或者利用資料分析和機器學習/AI 構建面向使用者的產品功能,例如電商網站上“購買了 X 的人也購買了 Y”的推薦、風險評分或垃圾郵件過濾等預測分析以及搜尋結果排名。
業務分析師和資料科學家使用的工具不同,工作方式也不同,但仍有一些共同之處:兩者都要進行 *分析*,也就是檢視使用者和後端服務產生的資料,但通常不會修改這些資料(糾正錯誤或許除外)。他們可能會建立衍生資料集,以某種方式處理原始資料。由此形成了兩類相互分離的系統——本書將始終沿用這種區分:
業務分析師和資料科學家使用的工具不同,工作方式也不同,但仍有一些共同之處:兩者都要進行 *分析**analytics*,也就是檢視使用者和後端服務產生的資料,但通常不會修改這些資料(糾正錯誤或許除外)。他們可能會建立衍生資料集,以某種方式處理原始資料。由此形成了兩類相互分離的系統——本書將始終沿用這種區分:
* *事務型系統* 由建立資料的後端服務和資料基礎設施組成,例如直接為外部使用者提供服務。應用程式碼根據使用者執行的操作,讀取和修改資料庫中的資料。
* *分析型系統* 服務於業務分析師和資料科學家。它們儲存事務型系統資料的只讀副本,並針對分析所需的資料處理方式進行最佳化。
* *事務型系統**operational system*由建立資料的後端服務和資料基礎設施組成,例如直接為外部使用者提供服務。應用程式碼根據使用者執行的操作,讀取和修改資料庫中的資料。
* *分析型系統**analytical system*服務於業務分析師和資料科學家。它們儲存事務型系統資料的只讀副本,並針對分析所需的資料處理方式進行最佳化。
正如下一節將要說明的,事務型系統與分析型系統往往有充分的理由彼此分離。隨著這兩類系統日趨成熟,又出現了兩個專業角色:*資料工程師**分析工程師*。資料工程師懂得如何整合事務型系統和分析型系統,並對組織的資料基礎設施承擔更廣泛的責任 [^3]。分析工程師則對資料進行建模和轉換,使其更便於組織內的業務分析師和資料科學家使用 [^4]。
正如下一節將要說明的,事務型系統與分析型系統往往有充分的理由彼此分離。隨著這兩類系統日趨成熟,又出現了兩個專業角色:*資料工程師**data engineer*)與 *分析工程師**analytics engineer*。資料工程師懂得如何整合事務型系統和分析型系統,並對組織的資料基礎設施承擔更廣泛的責任 [^3]。分析工程師則對資料進行建模和轉換,使其更便於組織內的業務分析師和資料科學家使用 [^4]。
許多工程師專攻事務型或分析型系統中的一類。不過,本書會同時涵蓋兩者,因為它們都在組織的資料生命週期中扮演重要角色。我們將深入探討為內部和外部使用者提供服務所需的資料基礎設施,幫助你更好地與分界線另一側的同事合作。
### 事務處理與分析的特徵 {#sec_introduction_oltp}
在商業資料處理的早期,每次寫入資料庫通常都對應一筆 *商業交易*:完成一筆銷售、向供應商下訂單、發放員工工資,等等。後來,資料庫的應用擴充套件到不涉及金錢往來的領域,*事務* 這個名稱卻沿用下來,用來指構成一個邏輯單元的一組讀寫操作。
在商業資料處理的早期,每次寫入資料庫通常都對應一筆 *商業交易*:完成一筆銷售、向供應商下訂單、發放員工工資,等等。後來,資料庫的應用擴充套件到不涉及金錢往來的領域,*事務**transaction*這個名稱卻沿用下來,用來指構成一個邏輯單元的一組讀寫操作。
> [!NOTE]
> [第 8 章](/tw/ch8#ch_transactions)會詳細探討“事務”的含義。本章則寬泛地用這個詞指代低延遲的讀寫操作。
儘管資料庫開始處理形形色色的資料——社交媒體帖子、遊戲中的操作、地址簿聯絡人,等等——基本訪問模式仍與處理商業交易相似。事務型系統通常按某個鍵查詢少量記錄(稱為 *點查詢*),再根據使用者輸入插入、更新或刪除記錄。由於這些應用具有互動性,這種訪問模式稱為 *聯機事務處理*OLTP
儘管資料庫開始處理形形色色的資料——社交媒體帖子、遊戲中的操作、地址簿聯絡人,等等——基本訪問模式仍與處理商業交易相似。事務型系統通常按某個鍵查詢少量記錄(稱為 *點查詢**point query*),再根據使用者輸入插入、更新或刪除記錄。由於這些應用具有互動性,這種訪問模式稱為 *聯機事務處理*OLTP
與此同時,資料庫也越來越多地用於分析,而分析的訪問模式與 OLTP 大相徑庭。分析查詢通常會掃描海量記錄,計算計數、總和或平均值等聚合統計量,而不是把一條條記錄返回給使用者。例如,連鎖超市的業務分析師可能想回答下面的問題:
@@ -101,17 +101,17 @@ breadcrumbs: false
事務型系統一般不允許使用者自行編寫 SQL 查詢並提交給資料庫執行否則使用者可能讀取或修改自己無權訪問的資料。使用者也可能寫出執行開銷很高的查詢影響其他使用者使用資料庫。因此OLTP 系統主要執行寫在應用程式碼中的一組固定查詢,只在維護或排查故障時偶爾執行一次性自定義查詢。分析資料庫則不同:它通常允許使用者自由手寫任意 SQL 查詢,也可以透過 Tableau、Looker 或 Microsoft Power BI 等資料視覺化或儀表盤工具自動生成查詢。
還有一類系統專為分析型負載(即聚合大量記錄的查詢)而設計,卻嵌入在面向使用者的產品中。這類用途稱為 *產品分析**實時分析*,為此設計的系統包括 Pinot、Druid 和 ClickHouse [^6]。
還有一類系統專為分析型負載(即聚合大量記錄的查詢)而設計,卻嵌入在面向使用者的產品中。這類用途稱為 *產品分析**product analytics*)或 *實時分析**real-time analytics*,為此設計的系統包括 Pinot、Druid 和 ClickHouse [^6]。
### 資料倉儲 {#sec_introduction_dwh}
起初同一套資料庫既用於事務處理也用於分析查詢。事實證明SQL 在這方面非常靈活,兩類查詢都能勝任。不過到了 20 世紀 80 年代末和 90 年代初,企業開始不再使用 OLTP 系統進行分析,轉而在一個獨立的資料庫系統上執行分析查詢。這個獨立的資料庫稱為 *資料倉儲*
起初同一套資料庫既用於事務處理也用於分析查詢。事實證明SQL 在這方面非常靈活,兩類查詢都能勝任。不過到了 20 世紀 80 年代末和 90 年代初,企業開始不再使用 OLTP 系統進行分析,轉而在一個獨立的資料庫系統上執行分析查詢。這個獨立的資料庫稱為 *資料倉儲**data warehouse*
一家大型企業可能擁有幾十乃至上百個聯機事務處理系統:支撐面向客戶的網站,控制實體店的銷售終端(收銀系統),跟蹤倉庫庫存,規劃車輛路線,管理供應商和員工,以及執行許多其他任務。每個系統都很複雜,都需要專門的團隊維護,因此最終大多彼此獨立執行。
通常不宜讓業務分析師和資料科學家直接查詢這些 OLTP 系統,原因有以下幾點:
* 所需資料可能散落在多個事務型系統中,很難透過一條查詢組合這些資料集;這個問題稱為 *資料孤島*
* 所需資料可能散落在多個事務型系統中,很難透過一條查詢組合這些資料集;這個問題稱為 *資料孤島**data silo*
* 適合 OLTP 的模式和資料佈局不太適合分析(參見[“星型與雪花型:分析模式”](/tw/ch3#sec_datamodels_analytics))。
* 分析查詢的開銷可能很高;在 OLTP 資料庫上執行它們,會影響其他使用者的效能。
* 出於安全或合規方面的考慮OLTP 系統可能位於一個不允許使用者直接訪問的獨立網路中。
@@ -136,16 +136,16 @@ breadcrumbs: false
資料倉儲通常採用 *關係* 資料模型,並透過 SQL 查詢(參見[第 3 章](/tw/ch3#ch_datamodels)),有時還會配合專門的商業智慧軟體。這種模型很適合業務分析師所需的查詢,卻不太適合資料科學家的需求;他們可能需要完成下面的工作:
* 把資料轉換成適合訓練機器學習模型的形式。這通常要把資料庫表中的行列轉換為數值向量或矩陣,其中的數值稱為 *特徵*。以儘可能提高訓練後模型效能的方式完成這種轉換,稱為 *特徵工程*;它通常需要編寫難以用 SQL 表達的定製程式碼。
* 把資料轉換成適合訓練機器學習模型的形式。這通常要把資料庫表中的行列轉換為數值向量或矩陣,其中的數值稱為 *特徵**feature*。以儘可能提高訓練後模型效能的方式完成這種轉換,稱為 *特徵工程**feature engineering*;它通常需要編寫難以用 SQL 表達的定製程式碼。
* 對文字資料(例如商品評論)應用自然語言處理技術,嘗試從中提取結構化資訊(例如作者表達的情緒,或提到了哪些主題)。同樣,他們也可能要用計算機視覺技術從照片中提取結構化資訊。
儘管人們一直嘗試為 SQL 資料模型加入機器學習運算元 [^12],也在關係模型的基礎上構建高效的機器學習系統 [^13],許多資料科學家仍不願在資料倉儲這樣的關聯式資料庫中工作。他們往往更喜歡 pandas、scikit-learn 等 Python 資料分析庫R 等統計分析語言,以及 Spark 等分散式分析框架 [^14]。我們將在[“資料框、矩陣與陣列”](/tw/ch3#sec_datamodels_dataframes)中進一步討論這些工具。
因此,組織需要以適合資料科學家使用的形式提供資料。解決方案是 *資料湖*:一個集中的資料儲存庫,儲存一切可能對分析有用的資料副本,這些資料透過 ETL 流程從事務型系統取得。資料湖與資料倉儲的區別在於,它只儲存檔案,並不強制規定檔案格式或資料模型。資料湖中的檔案可以是一批資料庫記錄,以 Avro 或 Parquet 等檔案格式編碼(參見[第 5 章](/tw/ch5#ch_encoding));也完全可以是文字、影象、影片、感測器讀數、稀疏矩陣、特徵向量、基因組序列,或任何其他型別的資料 [^15]。資料湖不僅更加靈活,而且往往比關係資料儲存更便宜,因為它可以使用物件儲存等廉價通用的檔案儲存(參見[“雲原生系統架構”](/tw/ch1#sec_introduction_cloud_native))。
因此,組織需要以適合資料科學家使用的形式提供資料。解決方案是 *資料湖**data lake*:一個集中的資料儲存庫,儲存一切可能對分析有用的資料副本,這些資料透過 ETL 流程從事務型系統取得。資料湖與資料倉儲的區別在於,它只儲存檔案,並不強制規定檔案格式或資料模型。資料湖中的檔案可以是一批資料庫記錄,以 Avro 或 Parquet 等檔案格式編碼(參見[第 5 章](/tw/ch5#ch_encoding));也完全可以是文字、影象、影片、感測器讀數、稀疏矩陣、特徵向量、基因組序列,或任何其他型別的資料 [^15]。資料湖不僅更加靈活,而且往往比關係資料儲存更便宜,因為它可以使用物件儲存等廉價通用的檔案儲存(參見[“雲原生系統架構”](/tw/ch1#sec_introduction_cloud_native))。
ETL 流程已經泛化為 *資料管道*;在某些情況下,資料湖成為事務型系統通往資料倉儲途中的一站。資料湖以事務型系統產生的“原始”形態儲存資料,不把它們轉換成關係資料倉儲的模式。這種做法的好處是,每個資料消費者都可以把原始資料轉換成最適合自己需求的形式。它有一個詼諧的名字——*壽司原則*:“原始資料更好”[^16]。
ETL 流程已經泛化為 *資料管道**data pipeline*;在某些情況下,資料湖成為事務型系統通往資料倉儲途中的一站。資料湖以事務型系統產生的“原始”形態儲存資料,不把它們轉換成關係資料倉儲的模式。這種做法的好處是,每個資料消費者都可以把原始資料轉換成最適合自己需求的形式。它有一個詼諧的名字——*壽司原則**sushi principle*:“原始資料更好”[^16]。
除了把資料從資料湖載入獨立的資料倉儲也可以直接針對資料湖中的檔案執行典型的資料倉儲工作負載SQL 查詢和業務分析),並與資料科學和機器學習工作負載並存。這種架構稱為 *資料湖倉*;它需要在資料湖的檔案儲存之上增加查詢執行引擎和後設資料層(例如模式管理)[^17]。
除了把資料從資料湖載入獨立的資料倉儲也可以直接針對資料湖中的檔案執行典型的資料倉儲工作負載SQL 查詢和業務分析),並與資料科學和機器學習工作負載並存。這種架構稱為 *資料湖倉**data lakehouse*;它需要在資料湖的檔案儲存之上增加查詢執行引擎和後設資料層(例如模式管理)[^17]。
Apache Hive、Spark SQL、Presto 和 Trino 都採用了這種方法。
@@ -155,25 +155,25 @@ Apache Hive、Spark SQL、Presto 和 Trino 都採用了這種方法。
此外,分析資料的提供形式越來越多,不僅包括檔案和關係表,還包括事件流(參見[第 12 章](/tw/ch12#ch_stream))。採用基於檔案的分析時,可以定期(例如每天)重新執行分析,以響應資料的變化;流處理則能讓分析系統快得多,通常在幾秒內便對事件作出響應。具體是否值得采用流處理,要看應用對時效性的要求。例如,它可以用來識別並阻止潛在的欺詐或濫用活動。
有時,分析系統的輸出還會提供給事務型系統,這個過程有時稱為 *反向 ETL* [^19]。例如,在分析系統中訓練好的機器學習模型可以部署到生產環境,向終端使用者生成“購買了 X 的人也購買了 Y”之類的推薦。分析系統中這類投入實際應用的輸出也稱為 *資料產品* [^20]。機器學習模型可以藉助 TFX、Kubeflow 或 MLflow 等專用工具部署到事務型系統。
有時,分析系統的輸出還會提供給事務型系統,這個過程有時稱為 *反向 ETL**reverse ETL*[^19]。例如,在分析系統中訓練好的機器學習模型可以部署到生產環境,向終端使用者生成“購買了 X 的人也購買了 Y”之類的推薦。分析系統中這類投入實際應用的輸出也稱為 *資料產品**data product*[^20]。機器學習模型可以藉助 TFX、Kubeflow 或 MLflow 等專用工具部署到事務型系統。
### 權威記錄系統與衍生資料 {#sec_introduction_derived}
除了區分事務型系統與分析型系統,本書還區分 *權威記錄系統**衍生資料系統*。這組術語很有用,可以幫助你理清資料在系統中的流向:
除了區分事務型系統與分析型系統,本書還區分 *權威記錄系統**system of record*)與 *衍生資料系統**derived data system*。這組術語很有用,可以幫助你理清資料在系統中的流向:
權威記錄系統
: 權威記錄系統也稱 *權威資料來源*,儲存某類資料的權威或 *規範* 版本。新資料到來時,例如使用者輸入,首先寫入這裡。每項事實只表示一次通常採用 *正規化* 表示參見[“正規化、反正規化與連線”](/tw/ch3#sec_datamodels_normalization))。如果其他系統與權威記錄系統的資料不一致,那麼按照定義,應以權威記錄系統中的值為準。
: 權威記錄系統也稱 *權威資料來源**source of truth*,儲存某類資料的權威或 *規範**canonical*版本。新資料到來時,例如使用者輸入,首先寫入這裡。每項事實只表示一次通常採用 *正規化**normalized*表示參見[“正規化、反正規化與連線”](/tw/ch3#sec_datamodels_normalization))。如果其他系統與權威記錄系統的資料不一致,那麼按照定義,應以權威記錄系統中的值為準。
衍生資料系統
: 衍生系統中的資料,是以另一個系統中的現有資料為基礎,經過某種轉換或處理而得到的結果。衍生資料即使丟失,也可以從原始資料來源重新建立。快取就是一個典型例子:若資料在快取中,便可直接返回;若快取中沒有所需資料,則可以退回底層資料庫讀取。反正規化值、索引、物化檢視、轉換後的資料表示,以及在資料集上訓練的模型,也都屬於這一類。
從技術上講,衍生資料是 *冗餘* 的,因為它複製了現有資訊。但是,要讓讀查詢獲得良好效能,這種冗餘往往不可或缺。你可以從同一個資料來源衍生出多個不同的資料集,從不同“視角”觀察資料。
從技術上講,衍生資料是 *冗餘**redundant*的,因為它複製了現有資訊。但是,要讓讀查詢獲得良好效能,這種冗餘往往不可或缺。你可以從同一個資料來源衍生出多個不同的資料集,從不同“視角”觀察資料。
分析型系統通常屬於衍生資料系統,因為它們消費的是在別處建立的資料。事務型服務則可能同時包含權威記錄系統和衍生資料系統:權威記錄系統是資料首先寫入的主資料庫,衍生資料系統則是加快常見讀取操作的索引和快取,尤其適用於權威記錄系統無法高效回答的查詢。
大多數資料庫、儲存引擎和查詢語言,本身並不天然是權威記錄系統或衍生系統。資料庫只是工具,如何使用由你決定。一個系統究竟屬於哪一類,取決於它在應用中的用法,而不是採用了什麼工具。明確哪些資料衍生自哪些其他資料,可以讓原本令人困惑的系統架構變得清晰。
如果一個系統的資料衍生自另一個系統,那麼每當權威記錄系統中的原始資料發生變化,就需要有相應流程更新衍生資料。遺憾的是,許多資料庫在設計時都假定應用只會使用這一個資料庫,因而很難整合多個系統並傳播這類更新。我們將在[“資料整合”](/tw/ch13#sec_future_integration)中討論 *資料整合* 的各種方法;藉助這些方法,可以組合多個資料系統,完成單個系統無法獨力完成的任務。
如果一個系統的資料衍生自另一個系統,那麼每當權威記錄系統中的原始資料發生變化,就需要有相應流程更新衍生資料。遺憾的是,許多資料庫在設計時都假定應用只會使用這一個資料庫,因而很難整合多個系統並傳播這類更新。我們將在[“資料整合”](/tw/ch13#sec_future_integration)中討論 *資料整合**data integration*的各種方法;藉助這些方法,可以組合多個資料系統,完成單個系統無法獨力完成的任務。
至此,我們對分析與事務處理的比較告一段落。下一節要討論的另一項權衡,你可能已經見過許多人反覆爭論。
@@ -188,7 +188,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 都採用了這種方法。
{{< 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。這條連續譜上還有更多中間位置例如採用開源軟體但執行自己修改過的版本。
這條連續譜的中間,是由你 *自託管**self-hosted*的現成軟體(可以是開源軟體,也可以是商業軟體),也就是由你親自部署。例如,下載 MySQL 並安裝到一臺由你掌控的伺服器上。這臺伺服器可以是你自己的硬體——通常稱為 *本地部署**on-premises*,即使它實際位於租用的資料中心機架裡,並不真的在你的自有場所——也可以是雲中的虛擬機器,即 *基礎設施即服務*IaaS。這條連續譜上還有更多中間位置例如採用開源軟體但執行自己修改過的版本。
與這條連續譜相互獨立的另一個問題是:無論在雲端還是本地,究竟要 *如何* 部署服務,例如是否採用 Kubernetes 之類的編排框架。不過,部署工具的選擇不在本書討論範圍內,因為還有其他因素對資料系統架構的影響更大。
@@ -218,7 +218,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 都採用了這種方法。
### 雲原生系統架構 {#sec_introduction_cloud_native}
雲端計算不僅採用了不同的經濟模式——訂閱服務,而不是購買硬體和軟體許可證,再自行執行軟體——它的興起也從技術層面深刻影響了資料系統的實現方式。*雲原生* 一詞用來描述專為利用雲服務優勢而設計的架構。
雲端計算不僅採用了不同的經濟模式——訂閱服務,而不是購買硬體和軟體許可證,再自行執行軟體——它的興起也從技術層面深刻影響了資料系統的實現方式。*雲原生**cloud-native*一詞用來描述專為利用雲服務優勢而設計的架構。
原則上,幾乎任何可以自託管的軟體都能以雲服務的形式提供;事實上,許多流行的資料系統如今都有相應的託管服務。然而,從一開始就按雲原生思路設計的系統已經展現出若干優勢:在相同硬體上效能更好,故障恢復更快,能夠迅速調整計算資源以匹配負載,並且可以支援更大的資料集 [^25] [^26] [^27]。{{< xref tbl="1-2" page="/ch1" anchor="tab_cloud_native_dbs" >}}表 1-2{{< /xref >}}列出了兩類系統的一些例子。
@@ -232,11 +232,11 @@ Apache Hive、Spark SQL、Presto 和 Trino 都採用了這種方法。
許多自託管資料系統對執行環境的要求非常簡單:在 Linux 或 Windows 等常規作業系統上執行,把資料存成檔案系統中的檔案,並透過 TCP/IP 等標準網路協議通訊。少數系統依賴 GPU用於機器學習或 RDMA 網絡卡等特殊硬體但總體而言自託管軟體使用的都是十分通用的計算資源CPU、記憶體、檔案系統和 IP 網路。
在雲中這類軟體可以執行在基礎設施即服務IaaS環境裡使用一臺或多臺虛擬機器也稱為 *例項*),每臺例項分配一定數量的 CPU、記憶體、磁碟和網路頻寬。與物理機器相比雲例項的開通速度更快可選規格也更多但除此之外它們與傳統計算機相似你可以隨意執行任何軟體也要自行負責管理。
在雲中這類軟體可以執行在基礎設施即服務IaaS環境裡使用一臺或多臺虛擬機器也稱為 *例項**instance*),每臺例項分配一定數量的 CPU、記憶體、磁碟和網路頻寬。與物理機器相比雲例項的開通速度更快可選規格也更多但除此之外它們與傳統計算機相似你可以隨意執行任何軟體也要自行負責管理。
與之相對,雲原生服務的關鍵思想是:不僅使用作業系統管理的計算資源,還要在低層雲服務的基礎上構建更高層的服務。例如:
* Amazon S3、Azure Blob Storage 和 Cloudflare R2 等 *物件儲存* 服務用於儲存大型檔案。它們的 API 比普通檔案系統更受限,只提供基本的檔案讀寫;但好處是隱藏了底層物理機器。服務會自動把資料分佈到許多機器上,你不必擔心其中某臺機器的磁碟空間耗盡。即使某些機器或其磁碟徹底損壞,資料也不會丟失。
* Amazon S3、Azure Blob Storage 和 Cloudflare R2 等 *物件儲存**object storage*服務用於儲存大型檔案。它們的 API 比普通檔案系統更受限,只提供基本的檔案讀寫;但好處是隱藏了底層物理機器。服務會自動把資料分佈到許多機器上,你不必擔心其中某臺機器的磁碟空間耗盡。即使某些機器或其磁碟徹底損壞,資料也不會丟失。
* 許多其他服務又建立在物件儲存和其他雲服務之上。例如Snowflake 是一種雲端分析資料庫(資料倉儲),依靠 S3 儲存資料 [^27];還有一些服務進一步構建在 Snowflake 之上。
計算機領域的抽象一向如此:該選擇哪一層,並沒有唯一正確的答案。一般來說,層次越高的抽象,往往越面向特定用例。如果你的需求恰好符合某個高層系統的設計場景,那麼直接使用現成系統,通常比自己用低層系統搭建省心得多,也足以滿足需要。反過來,如果沒有任何高層系統符合需求,那就只能用低層元件自行構建。
@@ -247,13 +247,13 @@ Apache Hive、Spark SQL、Presto 和 Trino 都採用了這種方法。
雲中的計算例項(虛擬機器)也可以連線本地磁碟,但云原生系統通常更願意把它們當作臨時快取,而不是長期儲存。原因在於:一旦相應例項發生故障,本地磁碟就無法訪問;為了適應負載變化而把例項換成另一臺物理機上的更大或更小規格時,本地磁碟同樣無法訪問。
作為本地磁碟的替代方案,雲服務還提供虛擬磁碟儲存,可以從一個例項解除安裝,再掛載到另一個例項上,例如 Amazon EBS、Azure 託管磁碟和 Google Cloud 持久磁碟。這種虛擬磁碟並不是真正的物理磁碟,而是由另一組機器提供的雲服務,用來模擬磁碟的行為——也就是 *塊裝置*,其中每個塊通常為 4 KiB。這項技術讓傳統的磁碟軟體可以在雲中執行但塊裝置模擬會引入額外開銷如果系統從一開始便針對雲設計這些開銷本來可以避免 [^25]。它還使應用對網路異常極為敏感,因為虛擬塊裝置上的每次 I/O 實際上都是一次網路呼叫 [^28]。
作為本地磁碟的替代方案,雲服務還提供虛擬磁碟儲存,可以從一個例項解除安裝,再掛載到另一個例項上,例如 Amazon EBS、Azure 託管磁碟和 Google Cloud 持久磁碟。這種虛擬磁碟並不是真正的物理磁碟,而是由另一組機器提供的雲服務,用來模擬磁碟的行為——也就是 *塊裝置**block device*,其中每個塊通常為 4 KiB。這項技術讓傳統的磁碟軟體可以在雲中執行但塊裝置模擬會引入額外開銷如果系統從一開始便針對雲設計這些開銷本來可以避免 [^25]。它還使應用對網路異常極為敏感,因為虛擬塊裝置上的每次 I/O 實際上都是一次網路呼叫 [^28]。
為了解決這個問題雲原生服務通常避開虛擬磁碟轉而建立在針對特定工作負載最佳化的專用儲存服務之上。S3 等物件儲存服務適合長期儲存較大的檔案,大小從數百 KB 到數 GB 不等。資料庫中的單行或單個值通常遠小於這個範圍;因此,雲資料庫通常在一個獨立服務中管理較小的值,並把包含許多個值的較大資料塊存入物件儲存 [^26] [^29]。我們將在[第 4 章](/tw/ch4#ch_storage)介紹相應的實現方法。
在傳統系統架構中同一臺計算機同時負責儲存磁碟和計算CPU 與記憶體);而在雲原生系統中,這兩項職責在一定程度上相互分離,或者說被 *解耦* 了 [^9] [^27] [^30] [^31]。例如S3 只負責儲存檔案;如果要分析其中的資料,就必須在 S3 之外執行分析程式碼。這也意味著資料需要透過網路傳輸,我們將在[“分散式與單節點系統”](/tw/ch1#sec_introduction_distributed)中進一步討論。
在傳統系統架構中同一臺計算機同時負責儲存磁碟和計算CPU 與記憶體);而在雲原生系統中,這兩項職責在一定程度上相互分離,或者說被 *解耦**decoupled*了 [^9] [^27] [^30] [^31]。例如S3 只負責儲存檔案;如果要分析其中的資料,就必須在 S3 之外執行分析程式碼。這也意味著資料需要透過網路傳輸,我們將在[“分散式與單節點系統”](/tw/ch1#sec_introduction_distributed)中進一步討論。
此外,雲原生系統往往採用 *多租戶* 模式:它並不為每個客戶單獨分配一臺機器,而是由同一項服務在共享硬體上處理多個客戶的資料和計算 [^32]。
此外,雲原生系統往往採用 *多租戶**multitenant*模式:它並不為每個客戶單獨分配一臺機器,而是由同一項服務在共享硬體上處理多個客戶的資料和計算 [^32]。
多租戶可以提高硬體利用率,更容易實現可伸縮性,也便於雲服務商管理;但要保證一個客戶的活動不影響其他客戶的效能或安全,就必須經過周密的工程設計 [^33]。
@@ -263,7 +263,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 都採用了這種方法。
運維的職責是確保服務可靠地交付給使用者,包括配置基礎設施和部署應用;同時保障生產環境穩定,包括監控和診斷任何可能影響可靠性的問題。對自託管系統來說,傳統運維有大量工作落在單臺機器上,例如容量規劃(監控可用磁碟空間,並在耗盡前增加磁碟)、開通新機器、在機器之間遷移服務,以及安裝作業系統補丁。
許多雲服務透過 API 隱藏了實際承載服務的一臺臺機器。例如,雲端儲存不再提供固定容量的磁碟,而是採用 *按量計費*:你無需提前規劃容量,就可以儲存資料,然後按實際佔用的空間付費。此外,即使個別機器發生故障,許多雲服務仍然保持高可用性(參見[“可靠性與容錯”](/tw/ch2#sec_introduction_reliability))。
許多雲服務透過 API 隱藏了實際承載服務的一臺臺機器。例如,雲端儲存不再提供固定容量的磁碟,而是採用 *按量計費**metered billing*:你無需提前規劃容量,就可以儲存資料,然後按實際佔用的空間付費。此外,即使個別機器發生故障,許多雲服務仍然保持高可用性(參見[“可靠性與容錯”](/tw/ch2#sec_introduction_reliability))。
關注點從單臺機器轉向服務也伴隨著運維角色的變化。可靠地提供服務這一高層目標沒有改變但流程和工具已經演變。DevOps/SRE 理念更強調:
@@ -277,7 +277,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 都採用了這種方法。
雲服務的客戶仍然需要運維,只是關注的方面有所不同,例如為一項任務選擇最合適的服務、整合不同服務,以及從一項服務遷移到另一項服務。儘管按量計費消除了傳統意義上的容量規劃,你依然必須清楚哪些資源被用在什麼地方,免得為並不需要的雲資源白白花錢:容量規劃變成了財務規劃,效能最佳化變成了成本最佳化 [^37]。
而且,雲服務仍有資源上限或 *配額*,例如併發執行的程序數上限。你必須提前瞭解並做好規劃,不能等到撞上限額才處理 [^38]。
而且,雲服務仍有資源上限或 *配額**quota*,例如併發執行的程序數上限。你必須提前瞭解並做好規劃,不能等到撞上限額才處理 [^38]。
採用雲服務或許比自行執行基礎設施更加容易、快捷,但學習如何使用仍有成本,有時還得設法繞過它的限制。隨著供應商越來越多,面向各種用例的雲服務層出不窮,如何整合不同服務成了一項格外棘手的挑戰 [^39] [^40]。
@@ -288,7 +288,7 @@ ETL參見[“資料倉儲”](/tw/ch1#sec_introduction_dwh))只是其中一
## 分散式與單節點系統 {#sec_introduction_distributed}
由多臺機器透過網路通訊而構成的系統,稱為 *分散式系統*。參與分散式系統的每個程序稱為一個 *節點*。採用分散式系統可能出於以下各種原因:
由多臺機器透過網路通訊而構成的系統,稱為 *分散式系統**distributed system*。參與分散式系統的每個程序稱為一個 *節點**node*。採用分散式系統可能出於以下各種原因:
固有的分散式系統
: 如果一項應用涉及兩個或更多相互互動的使用者,而每個使用者都使用自己的裝置,那麼這個系統不可避免地是分散式的:裝置之間只能透過網路通訊。
@@ -329,7 +329,7 @@ ETL參見[“資料倉儲”](/tw/ch1#sec_introduction_dwh))只是其中一
節點更多也不一定更快:有些情況下,一臺計算機上簡單的單執行緒程式,效能可以顯著勝過擁有 100 多個 CPU 核心的叢集 [^46]。
分散式系統往往很難排查故障:如果系統響應緩慢,怎樣才能找出問題在哪裡?用於診斷分散式系統問題的技術統稱為 *可觀測性* [^47] [^48]。它收集系統的執行資料並允許人們查詢這些資料從而既能分析高層指標也能追查單個事件。OpenTelemetry、Zipkin 和 Jaeger 等 *追蹤* 工具可以記錄哪個客戶端為了什麼操作呼叫了哪個伺服器,以及每次呼叫花了多長時間 [^49]。
分散式系統往往很難排查故障:如果系統響應緩慢,怎樣才能找出問題在哪裡?用於診斷分散式系統問題的技術統稱為 *可觀測性**observability*[^47] [^48]。它收集系統的執行資料並允許人們查詢這些資料從而既能分析高層指標也能追查單個事件。OpenTelemetry、Zipkin 和 Jaeger 等 *追蹤**tracing*工具可以記錄哪個客戶端為了什麼操作呼叫了哪個伺服器,以及每次呼叫花了多長時間 [^49]。
資料庫提供了多種保證資料一致性的機制,我們將在[第 6 章](/tw/ch6#ch_replication)和[第 8 章](/tw/ch8#ch_transactions)中看到。但是,當每個服務都有自己的資料庫時,如何保持不同服務之間的資料一致,就成了應用自身的問題。分散式事務(參見[第 8 章](/tw/ch8#ch_transactions))是一種可能的手段,但很少用於微服務,因為它與服務彼此獨立的目標背道而馳,而且許多資料庫根本不支援分散式事務 [^50]。
@@ -339,11 +339,11 @@ ETL參見[“資料倉儲”](/tw/ch1#sec_introduction_dwh))只是其中一
把系統分佈到多臺機器上,最常見的做法是將它劃分為客戶端和伺服器,由客戶端向伺服器傳送請求。這種通訊最常使用 HTTP我們將在[“流經服務的資料流REST 與 RPC”](/tw/ch5#sec_encoding_dataflow_rpc)中進一步討論。同一個程序既可以是伺服器(處理傳入的請求),也可以是客戶端(向其他服務發出請求)。
這種構建應用的方式傳統上稱為 *面向服務架構*SOA近年來這一思想又進一步演化為 *微服務* 架構 [^52] [^53]。在這種架構中,每項服務都有明確的用途(例如 S3 的用途就是檔案儲存);服務透過 API 向客戶端開放能力,由客戶端經網路呼叫;每項服務還由一個團隊負責維護。這樣一來,複雜應用就可以拆分成多項相互互動的服務,分別由不同團隊管理。
這種構建應用的方式傳統上稱為 *面向服務架構*SOA近年來這一思想又進一步演化為 *微服務**microservices*架構 [^52] [^53]。在這種架構中,每項服務都有明確的用途(例如 S3 的用途就是檔案儲存);服務透過 API 向客戶端開放能力,由客戶端經網路呼叫;每項服務還由一個團隊負責維護。這樣一來,複雜應用就可以拆分成多項相互互動的服務,分別由不同團隊管理。
把複雜軟體拆成多項服務有幾個優點:各項服務可以獨立更新,減少團隊之間的協調;每項服務可以獲得符合自身需要的硬體資源;實現細節隱藏在 API 後面,服務負責人可以自由改變實現,而不影響客戶端。在資料儲存方面,通常每項服務都有自己的資料庫,服務之間不共享資料庫。否則,整個資料庫結構實際上都會成為服務 API 的一部分,很難再行修改;而且,一項服務發出的查詢也可能拖累其他服務的效能。
另一方面服務多了本身也會滋生複雜度每項服務都需要相應基礎設施用來部署新版本、根據負載調整硬體資源、收集日誌、監控服務健康狀況並在出現問題時向值班工程師告警。Kubernetes 等 *編排* 框架為這類基礎設施提供了基礎能力,因此成了部署服務的常用方式。在開發過程中測試一項服務也可能很麻煩,因為它所依賴的其他服務必須一併執行。
另一方面服務多了本身也會滋生複雜度每項服務都需要相應基礎設施用來部署新版本、根據負載調整硬體資源、收集日誌、監控服務健康狀況並在出現問題時向值班工程師告警。Kubernetes 等 *編排**orchestration*框架為這類基礎設施提供了基礎能力,因此成了部署服務的常用方式。在開發過程中測試一項服務也可能很麻煩,因為它所依賴的其他服務必須一併執行。
微服務 API 也很難演化。呼叫 API 的客戶端會期待其中存在某些欄位;隨著業務需求改變,開發者或許想要增刪 API 欄位,但這可能導致客戶端出錯。更糟的是,這類問題往往要到開發週期後期,更新後的服務 API 部署到預釋出或生產環境時才被發現。OpenAPI 和 gRPC 等 API 描述標準有助於管理客戶端 API 與伺服器 API 之間的關係,我們將在[第 5 章](/tw/ch5#ch_encoding)中進一步討論。
@@ -355,7 +355,7 @@ ETL參見[“資料倉儲”](/tw/ch1#sec_introduction_dwh))只是其中一
### 雲端計算與超級計算 {#id17}
雲端計算並非構建大規模計算系統的唯一方式,另一條路線是 *高效能運算*HPC也稱為 *超級計算*。儘管二者有所重疊但與雲端計算和企業資料中心繫統相比HPC 的側重點通常不同,採用的技術也不一樣。差異包括:
雲端計算並非構建大規模計算系統的唯一方式,另一條路線是 *高效能運算*HPC也稱為 *超級計算**supercomputing*。儘管二者有所重疊但與雲端計算和企業資料中心繫統相比HPC 的側重點通常不同,採用的技術也不一樣。差異包括:
* 超級計算機通常用於計算密集型的科學計算任務,例如天氣預報、氣候建模、分子動力學(模擬原子和分子的運動)、複雜最佳化問題,以及求解偏微分方程。雲端計算則更常用於線上服務、業務資料系統,以及其他需要以高可用性響應使用者請求的系統。
* 超級計算機通常執行大型批處理作業,並不時把計算狀態作為檢查點寫入磁碟。如果某個節點發生故障,一種常見做法是直接停止整個叢集的工作負載,修復故障節點,再從最近的檢查點重新開始計算 [^55] [^56]。雲服務通常不能這樣停掉整個叢集,因為服務必須持續響應使用者,儘量減少中斷。
@@ -375,7 +375,7 @@ ETL參見[“資料倉儲”](/tw/ch1#sec_introduction_dwh))只是其中一
每一個參與這類系統的人,都有責任考慮它們的倫理影響,並確保系統遵守相關法律。並非人人都要成為法律和倫理專家,但具備基本的法律與倫理常識,與掌握分散式系統的基礎知識同樣重要。
法律因素正在影響資料系統設計最根本的部分 [^61]。例如GDPR 賦予個人要求刪除其資料的權利,有時稱為 *被遺忘權*。然而,正如本書將要介紹的,許多資料系統的設計依賴僅追加日誌等不可變結構;一個本應不可變的檔案,要怎樣從中間刪除某些資料?如果資料已經納入衍生資料集(參見[“權威記錄系統與衍生資料”](/tw/ch1#sec_introduction_derived)),例如成為機器學習模型的訓練資料,又該如何刪除?回答這些問題帶來了新的工程挑戰。
法律因素正在影響資料系統設計最根本的部分 [^61]。例如GDPR 賦予個人要求刪除其資料的權利,有時稱為 *被遺忘權**right to be forgotten*。然而,正如本書將要介紹的,許多資料系統的設計依賴僅追加日誌等不可變結構;一個本應不可變的檔案,要怎樣從中間刪除某些資料?如果資料已經納入衍生資料集(參見[“權威記錄系統與衍生資料”](/tw/ch1#sec_introduction_derived)),例如成為機器學習模型的訓練資料,又該如何刪除?回答這些問題帶來了新的工程挑戰。
目前,還沒有明確指南說明哪些具體技術或系統架構算是“符合 GDPR”。法規有意不規定特定技術因為技術進步可能很快就會使這些規定過時。法律文字給出的只是有待解釋的高層原則。因此如何遵守隱私法規並沒有簡單答案不過在討論本書的一些技術時我們會從這個角度加以審視。
@@ -383,7 +383,7 @@ ETL參見[“資料倉儲”](/tw/ch1#sec_introduction_dwh))只是其中一
政府或警方也可能強迫企業交出資料。如果資料可能暴露某些在當地被法律定為犯罪的行為——例如,在一些中東和非洲國家,同性戀會受到刑事處罰;在美國一些州,尋求墮胎也可能受到刑事追究——那麼儲存這些資料會給使用者帶來切實的安全風險。例如,位置資料很容易暴露一個人曾前往墮胎診所;哪怕只是一段使用者 IP 地址的歷史日誌,也可能洩露其大致位置。
把所有風險都考慮在內之後,合理的結論可能是:某些資料根本不值得儲存,因此應當刪除。*資料最小化* 原則(有時也用德語 *Datensparsamkeit* 表示)與“大資料”理念背道而馳;後者傾向於先存下大量資料,指望它們將來也許會派上用場 [^62]。不過,資料最小化符合 GDPR個人資料只能為具體、明確的目的而收集日後不得用於其他目的也不得在超出原定目的所需期限後繼續保留 [^63]。
把所有風險都考慮在內之後,合理的結論可能是:某些資料根本不值得儲存,因此應當刪除。*資料最小化**data minimization*原則(有時也用德語 *Datensparsamkeit* 表示)與“大資料”理念背道而馳;後者傾向於先存下大量資料,指望它們將來也許會派上用場 [^62]。不過,資料最小化符合 GDPR個人資料只能為具體、明確的目的而收集日後不得用於其他目的也不得在超出原定目的所需期限後繼續保留 [^63]。
企業同樣開始重視隱私和安全問題。信用卡公司要求支付處理企業遵守嚴格的支付卡行業PCI標準支付處理商要頻繁接受獨立審計機構的評估驗證是否持續合規。軟體供應商面臨的審查也日趨嚴格許多采購方如今要求供應商符合服務組織控制SOC第 2 類標準。與 PCI 合規一樣,供應商要透過第三方審計來驗證是否符合要求。

View File

@@ -17,21 +17,21 @@ breadcrumbs: false
正如[第 9 章](/tw/ch9#ch_distributed)所述,分散式系統裡可能出錯的事情很多。要讓服務在這些故障發生時仍能正確執行,就必須設法容忍故障。
*複製* 是實現容錯最有力的工具之一。然而,正如[第 6 章](/tw/ch6#ch_replication)所示,把同一份資料複製到多個副本,也帶來了不一致的風險。讀請求可能由尚未追上進度的副本處理,返回陳舊結果;如果多個副本都能接受寫入,還必須解決不同副本上併發寫入的值之間的衝突。從總體上看,處理這類問題有兩種彼此競爭的思路:
*複製**replication*是實現容錯最有力的工具之一。然而,正如[第 6 章](/tw/ch6#ch_replication)所示,把同一份資料複製到多個副本,也帶來了不一致的風險。讀請求可能由尚未追上進度的副本處理,返回陳舊結果;如果多個副本都能接受寫入,還必須解決不同副本上併發寫入的值之間的衝突。從總體上看,處理這類問題有兩種彼此競爭的思路:
最終一致性
最終一致性eventual consistency
: 這種思路把系統採用複製這一事實暴露給應用,由應用開發者處理隨之而來的不一致與衝突。採用[“多主複製”](/tw/ch6#sec_replication_multi_leader)和[“無主複製”](/tw/ch6#sec_replication_leaderless)的系統常常使用這種方式。
強一致性
強一致性strong consistency
: 這種思路認為,應用不應操心複製的內部細節,系統應當表現得彷彿只有一個節點。它讓應用開發者的工作簡單得多,代價則是更強的一致性會損害效能,而且有些故障在最終一致系統中尚可容忍,卻會令強一致系統停擺。
一如既往,哪種方式更好取決於具體應用。如果應用允許使用者離線修改資料,那麼正如[“同步引擎與本地優先軟體”](/tw/ch6#sec_replication_offline_clients)所述,最終一致性不可避免。然而,應用要正確處理最終一致性也很困難。如果各副本位於通訊快速而可靠的資料中心,強一致性的成本通常可以接受,因而往往更合適。
本章將深入討論強一致性,重點考察三個方面:
1. “強一致性”這個說法相當含糊,因此我們先給出一個更精確的目標:*線性一致性*。
1. “強一致性”這個說法相當含糊,因此我們先給出一個更精確的目標:*線性一致性**linearizability*
2. 接著討論 ID 和時間戳的生成。這個問題看似與一致性無關,實際上二者關係密切。
3. 最後探討分散式系統如何既實現線性一致性,又保持容錯能力;答案在於 *共識* 演算法。
3. 最後探討分散式系統如何既實現線性一致性,又保持容錯能力;答案在於 *共識**consensus*演算法。
在此過程中,我們會看到,分散式系統中什麼可以做到、什麼無法做到,受到一些根本限制。
@@ -45,9 +45,9 @@ breadcrumbs: false
要讓複製資料庫儘可能簡單易用,最好讓它表現得彷彿根本沒有複製。這樣,使用者就不必操心複製延遲、衝突和其他不一致問題:既能獲得容錯的好處,又不必承擔思考多個副本所帶來的複雜性。
這就是 *線性一致性* [^1](也稱為 *原子一致性* [^2]*強一致性*、*即時一致性* 或 *外部一致性* [^3])背後的思想。線性一致性的精確定義相當微妙,本節餘下部分會逐步展開;其基本思想,是讓系統看起來彷彿只有一份資料,所有操作都原子地作用於這份資料。有了這種保證,即使實際上存在多個副本,應用也無需關心它們。
這就是 *線性一致性**linearizability*[^1](也稱為 *原子一致性**atomic consistency* [^2]*強一致性**strong consistency**即時一致性**immediate consistency*,或 *外部一致性**external consistency* [^3])背後的思想。線性一致性的精確定義相當微妙,本節餘下部分會逐步展開;其基本思想,是讓系統看起來彷彿只有一份資料,所有操作都原子地作用於這份資料。有了這種保證,即使實際上存在多個副本,應用也無需關心它們。
在線性一致的系統中,只要一個客戶端成功完成寫入,此後所有客戶端從資料庫讀取時,都必須能看到剛寫入的值。要維持“只有一份資料”的假象,就必須保證讀到的是最近寫入的最新值,而不是來自陳舊快取或副本的舊值。換句話說,線性一致性是一種 *新鮮度保證*。下面用一個不滿足線性一致性的系統來說明這一點。
在線性一致的系統中,只要一個客戶端成功完成寫入,此後所有客戶端從資料庫讀取時,都必須能看到剛寫入的值。要維持“只有一份資料”的假象,就必須保證讀到的是最近寫入的最新值,而不是來自陳舊快取或副本的舊值。換句話說,線性一致性是一種 *新鮮度保證**recency guarantee*。下面用一個不滿足線性一致性的系統來說明這一點。
{{< fig num="10-1" id="fig_consistency_linearizability_0" src="/fig/ddia_1001.png" caption="如果這個資料庫滿足線性一致性,那麼 Alice 的讀取應返回 1 而不是 0或者 Bob 的讀取應返回 0 而不是 1。" class="ddia-figure ddia-figure--standard" width="2658" height="1904" />}}
@@ -57,7 +57,7 @@ breadcrumbs: false
### 什麼使系統具有線性一致性? {#sec_consistency_lin_definition}
為了更好地理解線性一致性,我們再看幾個例子。{{< xref fig="10-2" page="/ch10" anchor="fig_consistency_linearizability_1" >}}圖 10-2{{< /xref >}}展示了三個客戶端如何併發讀寫線性一致資料庫中的同一個物件 *x*。在分散式系統理論中,*x* 稱為 *暫存器*;在實際系統裡,它可以是鍵值儲存中的一個鍵、關聯式資料庫中的一行,或文件資料庫中的一個文件。
為了更好地理解線性一致性,我們再看幾個例子。{{< xref fig="10-2" page="/ch10" anchor="fig_consistency_linearizability_1" >}}圖 10-2{{< /xref >}}展示了三個客戶端如何併發讀寫線性一致資料庫中的同一個物件 *x*。在分散式系統理論中,*x* 稱為 *暫存器**register*;在實際系統裡,它可以是鍵值儲存中的一個鍵、關聯式資料庫中的一行,或文件資料庫中的一個文件。
{{< fig num="10-2" id="fig_consistency_linearizability_1" src="/fig/ddia_1002.png" caption="Alice 觀察到 x = 0、y = 1而 Bob 觀察到 x = 1、y = 0彷彿兩人的計算機對寫入發生的順序各執一詞。" class="ddia-figure ddia-figure--panorama" width="2880" height="829" />}}
@@ -88,7 +88,7 @@ breadcrumbs: false
還可以進一步細化時序圖,把每個操作視為在某個時刻原子生效 [^5],如{{< xref fig="10-4" page="/ch10" anchor="fig_consistency_linearizability_3" >}}圖 10-4{{< /xref >}}這個更複雜的例子所示。除了 *read**write*,圖中又加入了第三種操作:
* *cas*(*x*, *v* old, *v* new) ⇒ *r* 表示客戶端請求執行原子 *比較並設定* 操作(參見[“條件寫入(比較並設定)”](/tw/ch8#sec_transactions_compare_and_set))。如果暫存器 *x* 的當前值等於 *v* old就原子地把它改為 *v* new否則保持不變並返回錯誤。*r* 是資料庫的響應(*ok* 或 *error*)。
* *cas*(*x*, *v* old, *v* new) ⇒ *r* 表示客戶端請求執行原子 *比較並設定**compare-and-set*操作(參見[“條件寫入(比較並設定)”](/tw/ch8#sec_transactions_compare_and_set))。如果暫存器 *x* 的當前值等於 *v* old就原子地把它改為 *v* new否則保持不變並返回錯誤。*r* 是資料庫的響應(*ok* 或 *error*)。
{{< xref fig="10-4" page="/ch10" anchor="fig_consistency_linearizability_3" >}}圖 10-4{{< /xref >}}在每次操作的橫條內畫了一根豎線,表示我們認為該操作實際生效的時刻。把這些標記依次連起來,必須得到暫存器的一條合法讀寫序列——每次讀取都應返回最近一次寫入所設定的值。
@@ -124,7 +124,7 @@ breadcrumbs: false
>
> *順序一致性* 又是另外一回事 [^8],但我們不會在這裡討論它。)
>
> 資料庫可以同時提供可序列化與線性一致性,這種組合稱為 *嚴格可序列化* 或 *強單副本可序列化**strong-1SR*[^11] [^12]。單節點資料庫通常滿足線性一致性。對於採用[“可序列化快照隔離SSI”](/tw/ch8#sec_transactions_ssi)等樂觀方法的分散式資料庫情況要複雜一些。例如CockroachDB 提供可序列化以及一定的讀取新鮮度保證,卻不提供嚴格可序列化 [^13],因為後者要求事務之間進行代價高昂的協調 [^14]。
> 資料庫可以同時提供可序列化與線性一致性,這種組合稱為 *嚴格可序列化**strict serializability*)或 *強單副本可序列化**strong one-copy serializability**strong-1SR*[^11] [^12]。單節點資料庫通常滿足線性一致性。對於採用[“可序列化快照隔離SSI”](/tw/ch8#sec_transactions_ssi)等樂觀方法的分散式資料庫情況要複雜一些。例如CockroachDB 提供可序列化以及一定的讀取新鮮度保證,卻不提供嚴格可序列化 [^13],因為後者要求事務之間進行代價高昂的協調 [^14]。
>
> 也可以把較弱的隔離級別與線性一致性組合,或把較弱的一致性模型與可序列化組合。事實上,一致性模型與隔離級別在很大程度上可以獨立選擇 [^15] [^16]。
@@ -228,7 +228,7 @@ Apache ZooKeeper [^18]、etcd 等協調服務經常用於實現分散式租約
{{< fig num="10-7" id="fig_consistency_cap_availability" src="/fig/ddia_1007.png" caption="如果網路分割槽使客戶端無法聯絡足夠多的副本,它們就無法處理寫入。" class="ddia-figure ddia-figure--wide" width="2658" height="1194" />}}
考慮兩個地區之間網路中斷時會發生什麼。假設各地區內部的網路仍然正常,客戶端也能訪問本地區域,但兩個地區彼此無法通訊。這種情況稱為 *網路分割槽*
考慮兩個地區之間網路中斷時會發生什麼。假設各地區內部的網路仍然正常,客戶端也能訪問本地區域,但兩個地區彼此無法通訊。這種情況稱為 *網路分割槽**network partition*
在多主資料庫中,每個地區都可以繼續正常工作:一地的寫入原本就非同步複製到另一地,因此網路中斷期間只需暫存排隊,連線恢復後再相互交換。
@@ -313,13 +313,13 @@ ID 生成器還有幾種替代方案:
正如[“用於事件排序的時間戳”](/tw/ch9#sec_distributed_lww)所述,日曆時鐘時間戳至多隻能給出近似順序:如果較早的寫入讀取了略快的時鐘,較晚的寫入讀取了略慢的時鐘,時間戳順序就可能與事件實際順序相反。非單調時鐘還可能突然跳變,甚至讓同一節點生成的時間戳順序出錯。因此,基於日曆時鐘的 ID 生成器通常不滿足線性一致性。
使用原子鐘或 GPS 接收器進行高精度時鐘同步,可以減少這種順序錯亂。但如果無需特殊硬體,也能生成唯一且順序正確的 ID當然更好。這正是 *邏輯時鐘* 要解決的問題。
使用原子鐘或 GPS 接收器進行高精度時鐘同步,可以減少這種順序錯亂。但如果無需特殊硬體,也能生成唯一且順序正確的 ID當然更好。這正是 *邏輯時鐘**logical clock*要解決的問題。
### 邏輯時鐘 {#sec_consistency_timestamps}
在[“不可靠的時鐘”](/tw/ch9#sec_distributed_clocks)中,我們討論了日曆時鐘和單調時鐘。二者都屬於 *物理時鐘*,度量的是經過了多少秒(或毫秒、微秒等)。
分散式系統還經常使用另一類時鐘,稱為 *邏輯時鐘*。物理時鐘是計算流逝秒數的硬體裝置;邏輯時鐘則是一種計算已發生事件數量的演算法。因此,邏輯時間戳不能告訴你現在是幾點,卻 *可以* 相互比較,判斷哪個較早、哪個較晚。
分散式系統還經常使用另一類時鐘,稱為 *邏輯時鐘**logical clock*。物理時鐘是計算流逝秒數的硬體裝置;邏輯時鐘則是一種計算已發生事件數量的演算法。因此,邏輯時間戳不能告訴你現在是幾點,卻 *可以* 相互比較,判斷哪個較早、哪個較晚。
邏輯時鐘的要求通常是:
@@ -351,7 +351,7 @@ Lamport 時間戳很適合表示事件發生順序,但也有一些侷限:
* 它與物理時間沒有直接關係,所以無法據此查詢某個具體日期釋出的所有訊息;物理時間必須另行儲存。
* 如果兩個節點從不通訊,一個節點的計數器增長就永遠不會反映到另一個節點上。因此,不同節點在大致同一時刻生成的事件,計數器值可能相差懸殊。
*混合邏輯時鐘* 兼具物理日曆時鐘的優點與 Lamport 時鐘的順序保證 [^55]。它像物理時鐘一樣以秒或微秒計數;又像 Lamport 時鐘一樣,在看到其他節點更大的時間戳時,把自己的本地值向前推進到對方的時間戳。因此,如果某個節點的時鐘偏快,其他節點與它通訊後也會相應地把時鐘向前推進。
*混合邏輯時鐘**hybrid logical clock*HLC兼具物理日曆時鐘的優點與 Lamport 時鐘的順序保證 [^55]。它像物理時鐘一樣以秒或微秒計數;又像 Lamport 時鐘一樣,在看到其他節點更大的時間戳時,把自己的本地值向前推進到對方的時間戳。因此,如果某個節點的時鐘偏快,其他節點與它通訊後也會相應地把時鐘向前推進。
混合邏輯時鐘每次生成時間戳時也會遞增,從而保證始終單調向前,即使底層物理時鐘因 NTP 校正而向後跳變也不例外。因此,混合邏輯時鐘可能略微領先於底層物理時鐘;演算法會盡量把這項誤差控制在最小範圍內。
@@ -363,7 +363,7 @@ Lamport 時間戳很適合表示事件發生順序,但也有一些侷限:
多個時間戳併發生成時,這些演算法會任意規定它們之間的順序。因此,只看兩個時間戳,通常無法判斷二者是併發生成,還是一個先於另一個發生。(在{{< xref fig="10-9" page="/ch10" anchor="fig_consistency_lamport_ts" >}}圖 10-9{{< /xref >}}中,因為 Aaliyah 與 Caleb 的訊息具有相同計數器值,可以斷定它們彼此併發;但計數器值不同時,就無法作出這種判斷。)
如果需要判斷記錄是否併發建立,就得采用另一種演算法,例如 *向量時鐘*。它的缺點是時間戳大得多,可能需要為系統中的每個節點儲存一個整數。有關併發檢測的更多細節,參見[“檢測併發寫入”](/tw/ch6#sec_replication_concurrent)。
如果需要判斷記錄是否併發建立,就得采用另一種演算法,例如 *向量時鐘**vector clock*。它的缺點是時間戳大得多,可能需要為系統中的每個節點儲存一個整數。有關併發檢測的更多細節,參見[“檢測併發寫入”](/tw/ch6#sec_replication_concurrent)。
### 線性一致的 ID 生成器 {#sec_consistency_linearizable_id}
@@ -414,7 +414,7 @@ ID 生成器很難分片:多個分片獨立發號後,就無法再保證整
* 單節點上的線性一致 ID 生成器,不過是一個帶有原子“獲取並增加”指令的計數器;但如果這個節點崩潰了呢?
* 原子比較並設定CAS操作用途廣泛例如多個程序爭搶鎖或租約時決定誰能獲得它或者確保給定名稱的檔案或使用者具有唯一性。在單個節點上CAS 可能只需一條 CPU 指令;但怎樣才能讓它容錯?
事實證明,這些都是同一個分散式系統基本問題的不同例項:*共識*。共識是分散式計算中最重要、最基本的問題之一;同時,它也出了名地難以正確實現 [^58] [^59],許多系統都曾在這裡栽過跟頭。至此,我們已經討論過複製([第 6 章](/tw/ch6#ch_replication))、事務([第 8 章](/tw/ch8#ch_transactions))、系統模型([第 9 章](/tw/ch9#ch_distributed))以及線性一致性(本章),終於可以正面處理共識問題了。
事實證明,這些都是同一個分散式系統基本問題的不同例項:*共識**consensus*。共識是分散式計算中最重要、最基本的問題之一;同時,它也出了名地難以正確實現 [^58] [^59],許多系統都曾在這裡栽過跟頭。至此,我們已經討論過複製([第 6 章](/tw/ch6#ch_replication))、事務([第 8 章](/tw/ch8#ch_transactions))、系統模型([第 9 章](/tw/ch9#ch_distributed))以及線性一致性(本章),終於可以正面處理共識問題了。
最著名的共識演算法包括檢視戳複製Viewstamped ReplicationVSR[^60] [^61]、Paxos [^58] [^62] [^63] [^64]、Raft [^23] [^65] [^66] 和 Zab [^18] [^22] [^67]。這些演算法有不少相似之處,但並不完全相同 [^68] [^69]。它們採用非拜占庭系統模型:網路通訊可以被任意延遲或丟棄,節點可以崩潰、重啟或斷開連線;但除此之外,演算法假定節點都會正確遵守協議,不會採取惡意行為。
@@ -432,9 +432,9 @@ ID 生成器很難分片:多個分片獨立發號後,就無法再保證整
共識可以用幾種不同的方式表達:
* *單值共識* 與原子 *比較並設定* 操作非常相似,可以用來實現鎖、租約和唯一性約束。
* 構建 *僅追加日誌* 同樣需要共識;這個問題通常形式化為 *全序廣播*。有了日誌,就可以構建 *狀態機複製*、基於領導者的複製、事件溯源以及其他許多有用的機制。
* 多資料庫或多分片事務的 *原子提交*,要求所有參與者就是否提交或中止事務達成一致。
* *單值共識**single-value consensus*與原子 *比較並設定* 操作非常相似,可以用來實現鎖、租約和唯一性約束。
* 構建 *僅追加日誌**append-only log*同樣需要共識;這個問題通常形式化為 *全序廣播**total order broadcast*)。有了日誌,就可以構建 *狀態機複製**state machine replication*、基於領導者的複製、事件溯源以及其他許多有用的機制。
* 多資料庫或多分片事務的 *原子提交**atomic commitment*,要求所有參與者就是否提交或中止事務達成一致。
下面很快就會逐一探討這些形式。事實上,這幾個問題彼此等價:只要有一種演算法能解決其中一個問題,就可以把它轉換成其他任意一種問題的解法。這是一個相當深刻、甚至有些出人意料的洞見!也正因為如此,儘管它們表面上截然不同,我們仍可以把它們統統歸入“共識”這一範疇。
@@ -487,7 +487,7 @@ ID 生成器很難分片:多個分片獨立發號後,就無法再保證整
#### 共享日誌作為共識 {#sec_consistency_shared_logs}
我們已經見過多種日誌,例如複製日誌、事務日誌和預寫日誌。日誌儲存一系列 *日誌條目*,任何讀取者都會以相同順序看到相同的條目。有時,只有一個寫入者有權向日志追加新條目;而在 *共享日誌* 中,多個節點都可以請求追加條目。單主複製就是一個例子:任何客戶端都可以請求領導者執行寫入,領導者把寫入追加到複製日誌,隨後所有追隨者都按照與領導者相同的順序應用這些寫入。
我們已經見過多種日誌,例如複製日誌、事務日誌和預寫日誌。日誌儲存一系列 *日誌條目**log entry*,任何讀取者都會以相同順序看到相同的條目。有時,只有一個寫入者有權向日志追加新條目;而在 *共享日誌**shared log*中,多個節點都可以請求追加條目。單主複製就是一個例子:任何客戶端都可以請求領導者執行寫入,領導者把寫入追加到複製日誌,隨後所有追隨者都按照與領導者相同的順序應用這些寫入。
更形式化地說,共享日誌支援兩種操作:請求把一個值加入日誌,以及讀取日誌條目。它必須滿足以下屬性:
@@ -508,7 +508,7 @@ ID 生成器很難分片:多個分片獨立發號後,就無法再保證整
> [!NOTE]
> 共享日誌在形式上稱為 *全序廣播*、*原子廣播* 或 *全序組播* 協議 [^26] [^76] [^77]。這些術語只是用不同說法描述同一件事:請求把一個值加入日誌稱為“廣播”這個值,讀取日誌條目則稱為“交付”這條日誌。
> 共享日誌在形式上稱為 *全序廣播**total order broadcast*)、*原子廣播**atomic broadcast*)或 *全序組播**total order multicast*協議 [^26] [^76] [^77]。這些術語只是用不同說法描述同一件事:請求把一個值加入日誌稱為“廣播”這個值,讀取日誌條目則稱為“交付”這條日誌。
有了共享日誌的實現,就很容易解決共識問題:每個想提議某個值的節點都請求把它加入日誌,而第一條日誌條目中讀出的值就是決定值。由於所有節點都按相同順序讀取日誌條目,它們必然會就哪個值最先交付達成一致 [^28]。
@@ -532,11 +532,11 @@ ID 生成器很難分片:多個分片獨立發號後,就無法再保證整
現在規定,讀到零的節點勝出,其提議值成為決定值。這對讀到零的節點當然沒問題,其他節點卻陷入了困境:它們知道自己沒有勝出,卻不知道其他節點中究竟誰贏了。勝者可以發訊息告知其他節點,但如果它還沒來得及傳送訊息就崩潰了呢?其餘節點將一直懸而未決,無法決定任何值,共識也就不能終止。它們也不能改選另一個節點,因為讀到零的節點日後仍可能恢復,並有充分理由決定自己提議的值。
有一個例外:我們能夠確定提議值的節點不超過兩個。此時,兩個節點可以先互相傳送各自的提議值,再分別執行獲取並增加。讀到零的節點決定自己的值,讀到一的節點則決定另一個節點的值。這樣就解決了兩個節點之間的共識問題,因此我們說獲取並增加的 *共識數* 為二 [^28]。相比之下CAS 和共享日誌可以在任意數量的節點提議值時解決共識,所以它們的共識數為 ∞(無窮大)。
有一個例外:我們能夠確定提議值的節點不超過兩個。此時,兩個節點可以先互相傳送各自的提議值,再分別執行獲取並增加。讀到零的節點決定自己的值,讀到一的節點則決定另一個節點的值。這樣就解決了兩個節點之間的共識問題,因此我們說獲取並增加的 *共識數**consensus number*為二 [^28]。相比之下CAS 和共享日誌可以在任意數量的節點提議值時解決共識,所以它們的共識數為 ∞(無窮大)。
#### 原子提交作為共識 {#atomic-commitment-as-consensus}
在 [“分散式事務”](/tw/ch8#sec_transactions_distributed) 中,我們見過 *原子提交* 問題:參與分散式事務的所有資料庫或分片,要麼全都提交事務,要麼全都中止。我們還見過 *兩階段提交* 演算法,它依賴一個構成單點故障的協調者。
在 [“分散式事務”](/tw/ch8#sec_transactions_distributed) 中,我們見過 *原子提交**atomic commitment*問題:參與分散式事務的所有資料庫或分片,要麼全都提交事務,要麼全都中止。我們還見過 *兩階段提交**two-phase commit*2PC演算法,它依賴一個構成單點故障的協調者。
共識與原子提交是什麼關係?乍看之下,兩者十分相似——都要求節點達成某種一致。不過,它們之間存在一項重要區別:共識可以決定任意一個被提議的值;原子提交則要求,只要 *任何* 參與者投票中止,演算法就 *必須* 中止。更準確地說,原子提交必須滿足以下屬性 [^78]
@@ -573,7 +573,7 @@ ID 生成器很難分片:多個分片獨立發號後,就無法再保證整
#### 使用共享日誌 {#sec_consistency_smr}
共享日誌與資料庫複製十分契合:如果每條日誌條目都代表一次資料庫寫入,而每個副本都以相同順序、使用確定性邏輯處理相同的寫入,那麼所有副本最終都會處於一致狀態。這個思想稱為 *狀態機複製* [^80],也是我們在 [“事件溯源與 CQRS”](/tw/ch3#sec_datamodels_events) 中見過的事件溯源原理。共享日誌對流處理也很有用,我們將在 [第 12 章](/tw/ch12#ch_stream) 看到這一點。
共享日誌與資料庫複製十分契合:如果每條日誌條目都代表一次資料庫寫入,而每個副本都以相同順序、使用確定性邏輯處理相同的寫入,那麼所有副本最終都會處於一致狀態。這個思想稱為 *狀態機複製**state machine replication*[^80],也是我們在 [“事件溯源與 CQRS”](/tw/ch3#sec_datamodels_events) 中見過的事件溯源原理。共享日誌對流處理也很有用,我們將在 [第 12 章](/tw/ch12#ch_stream) 看到這一點。
類似地,共享日誌還可以實現可序列化事務。正如 [“實際序列執行”](/tw/ch8#sec_transactions_serial) 所述,如果每條日誌條目都代表一個將以儲存過程形式執行的確定性事務,而且每個節點都以相同順序執行這些事務,那麼事務就是可序列化的 [^81] [^82]。
@@ -596,7 +596,7 @@ ID 生成器很難分片:多個分片獨立發號後,就無法再保證整
然而,這裡有一個難題。前面討論腦裂時說過,所有節點必須就誰是領導者達成一致,否則兩個不同節點可能都自認為是領導者,進而作出彼此矛盾的決定。如此看來,選舉領導者需要共識,解決共識又需要領導者。怎樣才能跳出這個先有雞還是先有蛋的困局?
事實上,共識演算法並不要求任何時刻都只有一個領導者。它們提供的是稍弱一些的保證:協議定義一個 *紀元編號*Paxos 稱為 *投票編號*,檢視戳複製稱為 *檢視編號*Raft 稱為 *任期編號*),並保證每個紀元內的領導者是唯一的。
事實上,共識演算法並不要求任何時刻都只有一個領導者。它們提供的是稍弱一些的保證:協議定義一個 *紀元編號**epoch number*Paxos 稱為 *投票編號**ballot number*檢視戳複製稱為 *檢視編號**view number*Raft 稱為 *任期編號**term number*),並保證每個紀元內的領導者是唯一的。
如果一個節點在指定的超時時間內始終沒有收到現任領導者的訊息,因而認為它已經失效,這個節點就可能發起投票,選舉新的領導者。這次選舉會取得一個大於以往所有紀元的新紀元編號。如果兩個不同紀元的領導者發生衝突——也許前任領導者其實並未失效——紀元編號較高的領導者說了算。
@@ -644,7 +644,7 @@ Raft、Multi-Paxos、Zab 和檢視戳複製都採用這一基本結構:先由
### 協調服務 {#sec_consistency_coordination}
任何想提供線性一致操作的分散式資料庫都能從共識演算法中受益許多現代分散式資料庫確實也用共識演算法做複製。不過有一類系統尤其倚重共識ZooKeeper、etcd、Consul 等 *協調服務*。它們表面上與普通鍵值儲存相似,卻不像大多數資料庫那樣以通用資料儲存為目標。
任何想提供線性一致操作的分散式資料庫都能從共識演算法中受益許多現代分散式資料庫確實也用共識演算法做複製。不過有一類系統尤其倚重共識ZooKeeper、etcd、Consul 等 *協調服務**coordination service*。它們表面上與普通鍵值儲存相似,卻不像大多數資料庫那樣以通用資料儲存為目標。
協調服務的用途是協調另一個分散式系統中的多個節點。例如Kubernetes 依賴 etcdSpark 和 Flink 在高可用模式下,則依賴後臺執行的 ZooKeeper。協調服務只儲存少量足以全部放入記憶體的資料同時仍會寫入磁碟以保證永續性再用容錯共識演算法把這些資料複製到多個節點。
@@ -657,7 +657,7 @@ Raft、Multi-Paxos、Zab 和檢視戳複製都採用這一基本結構:先由
: 正如 [“分散式鎖和租約”](/tw/ch9#sec_distributed_lock_fencing) 所述,以租約保護某項資源時,需要用 *柵欄機制* 防止客戶端在程序暫停或網路嚴重延遲時相互干擾。共識系統可以為每條日誌條目分配單調遞增的 ID以此生成柵欄令牌ZooKeeper 使用 `zxid``cversion`etcd 使用修訂號)。
故障檢測
: 客戶端與協調服務維持一個長期會話並定期交換心跳確認對方是否仍然存活。即使連線暫時中斷或某臺伺服器失效客戶端持有的租約仍然有效但如果心跳中斷的時間超過租約超時協調服務就會認定客戶端已經失效並釋放其租約。ZooKeeper 把這種隨會話到期自動消失的條目稱為 *臨時節點*。)
: 客戶端與協調服務維持一個長期會話並定期交換心跳確認對方是否仍然存活。即使連線暫時中斷或某臺伺服器失效客戶端持有的租約仍然有效但如果心跳中斷的時間超過租約超時協調服務就會認定客戶端已經失效並釋放其租約。ZooKeeper 把這種隨會話到期自動消失的條目稱為 *臨時節點**ephemeral node*。)
變更通知
: 客戶端可以要求協調服務在某些鍵發生變化時主動傳送通知。這樣一來,客戶端便能得知另一個客戶端何時加入叢集(根據它寫入協調服務的值),或何時失效(它的會話超時,臨時節點隨之消失),無須再頻繁輪詢服務來發現變化。
@@ -684,7 +684,7 @@ Raft、Multi-Paxos、Zab 和檢視戳複製都採用這一基本結構:先由
#### 服務發現 {#service-discovery}
ZooKeeper、etcd 和 Consul 也經常用於 *服務發現*,即找出應當連線哪個 IP 地址才能訪問特定服務(見 [“負載均衡器、服務發現和服務網格”](/tw/ch5#sec_encoding_service_discovery))。雲環境中的虛擬機器不斷建立和銷燬,通常無法預先知道服務的 IP 地址。常見做法是讓服務在啟動時把自己的網路端點註冊到服務登錄檔,供其他服務查詢。
ZooKeeper、etcd 和 Consul 也經常用於 *服務發現**service discovery*,即找出應當連線哪個 IP 地址才能訪問特定服務(見 [“負載均衡器、服務發現和服務網格”](/tw/ch5#sec_encoding_service_discovery))。雲環境中的虛擬機器不斷建立和銷燬,通常無法預先知道服務的 IP 地址。常見做法是讓服務在啟動時把自己的網路端點註冊到服務登錄檔,供其他服務查詢。
用協調服務做服務發現很方便:故障檢測與變更通知功能,讓客戶端很容易跟蹤服務例項的增減。如果系統已經用協調服務管理租約、鎖或領導者選舉,繼續用它做服務發現也順理成章,因為它本來就知道哪個節點應當接收服務請求。

View File

@@ -15,15 +15,15 @@ breadcrumbs: false
>
> 高德納
到目前為止,本書的大部分內容都在討論 *請求* *查詢*,以及相應的 *響應* *結果*。許多現代資料系統都預設採用這種處理方式:你請求某樣東西,或者發出一條指令,系統便儘量快速地給出答案。
到目前為止,本書的大部分內容都在討論 *請求**request**查詢**query*,以及相應的 *響應**response**結果**result*。許多現代資料系統都預設採用這種處理方式:你請求某樣東西,或者發出一條指令,系統便儘量快速地給出答案。
瀏覽器請求網頁、服務呼叫遠端 API以及資料庫、快取、搜尋索引等許多系統都是這樣工作的。我們稱它們為 *線上系統*。這類系統通常以響應時間作為主要效能指標,而且往往需要具備容錯能力,才能保證高可用性。
瀏覽器請求網頁、服務呼叫遠端 API以及資料庫、快取、搜尋索引等許多系統都是這樣工作的。我們稱它們為 *線上系統**online system*。這類系統通常以響應時間作為主要效能指標,而且往往需要具備容錯能力,才能保證高可用性。
然而,有些計算規模太大,或者需要處理的資料太多,無法放在一次互動式請求中完成。例如,你可能需要訓練 AI 模型,把大量資料從一種形式轉換成另一種形式,或者在非常大的資料集上進行分析。我們把這類任務稱為 *批處理* 作業,相應的系統有時也稱為 *離線系統*
然而,有些計算規模太大,或者需要處理的資料太多,無法放在一次互動式請求中完成。例如,你可能需要訓練 AI 模型,把大量資料從一種形式轉換成另一種形式,或者在非常大的資料集上進行分析。我們把這類任務稱為 *批處理**batch processing*作業,相應的系統有時也稱為 *離線系統**offline system*
批處理作業讀取一組輸入資料(只讀),併產生一組輸出資料(每次執行都從頭生成)。它通常不會像讀寫事務那樣修改現有資料。因此,輸出是由輸入 *衍生* 而來的(參見[“權威記錄系統與衍生資料”](/tw/ch1#sec_introduction_derived)):如果對輸出不滿意,只需把它刪除,調整作業邏輯,再執行一次。把輸入視為不可變資料,並避免產生副作用(例如寫入外部資料庫),不僅能讓批處理作業獲得良好的效能,還會帶來其他好處:
批處理作業讀取一組輸入資料(只讀),併產生一組輸出資料(每次執行都從頭生成)。它通常不會像讀寫事務那樣修改現有資料。因此,輸出是由輸入 *衍生**derived*而來的(參見[“權威記錄系統與衍生資料”](/tw/ch1#sec_introduction_derived)):如果對輸出不滿意,只需把它刪除,調整作業邏輯,再執行一次。把輸入視為不可變資料,並避免產生副作用(例如寫入外部資料庫),不僅能讓批處理作業獲得良好的效能,還會帶來其他好處:
- 如果程式碼中引入了錯誤,導致輸出有誤或遭到破壞,只要回滾到先前版本的程式碼並重新執行作業,輸出便能恢復正確。更簡單的辦法是把舊輸出儲存在另一個目錄中,需要時直接切換回去。大多數物件儲存和開放表格式(參見[“雲資料倉儲”](/tw/ch4#sec_cloud_data_warehouses))都支援這種稱為 *時間旅行* 的功能。大多數支援讀寫事務的資料庫卻不具備這種性質:如果錯誤程式碼把壞資料寫進資料庫,回滾程式碼並不能修復已經寫入的資料。這種從錯誤程式碼中恢復的能力稱為 *容忍人為失誤* [^1]。
- 如果程式碼中引入了錯誤,導致輸出有誤或遭到破壞,只要回滾到先前版本的程式碼並重新執行作業,輸出便能恢復正確。更簡單的辦法是把舊輸出儲存在另一個目錄中,需要時直接切換回去。大多數物件儲存和開放表格式(參見[“雲資料倉儲”](/tw/ch4#sec_cloud_data_warehouses))都支援這種稱為 *時間旅行**time travel*的功能。大多數支援讀寫事務的資料庫卻不具備這種性質:如果錯誤程式碼把壞資料寫進資料庫,回滾程式碼並不能修復已經寫入的資料。這種從錯誤程式碼中恢復的能力稱為 *容忍人為失誤**human fault tolerance*[^1]。
- 由於回滾很容易,功能開發可以比“犯錯就會造成不可逆損害”的環境推進得更快。這種 *儘量減少不可逆操作* 的原則有利於敏捷軟體開發 [^2]。
@@ -138,7 +138,7 @@ for count, url in top5: #5
Python 指令碼在記憶體中維護一個 URL 雜湊表,把每個 URL 對映到它出現的次數。Unix 管道沒有這樣的雜湊表,而是依靠排序 URL 列表;同一個 URL 出現多次時,它只是在列表中重複多次。
哪種方法更好?這取決於不同 URL 的數量。對於大多數中小型網站,你大概可以把所有不同的 URL 及其計數器放進 1 GB 左右的記憶體。這個作業的 *工作集*(即作業需要隨機訪問的記憶體量)只取決於不同 URL 的數量:即使日誌中同一個 URL 出現了一百萬次,雜湊表所需的空間仍然只是一個 URL 加一個計數器。只要工作集足夠小,記憶體雜湊表就能很好地工作——即便在膝上型電腦上也是如此。
哪種方法更好?這取決於不同 URL 的數量。對於大多數中小型網站,你大概可以把所有不同的 URL 及其計數器放進 1 GB 左右的記憶體。這個作業的 *工作集**working set*即作業需要隨機訪問的記憶體量)只取決於不同 URL 的數量:即使日誌中同一個 URL 出現了一百萬次,雜湊表所需的空間仍然只是一個 URL 加一個計數器。只要工作集足夠小,記憶體雜湊表就能很好地工作——即便在膝上型電腦上也是如此。
另一方面,如果作業的工作集大於可用記憶體,排序方法就有一個優勢:它可以高效利用磁碟。這與[“日誌結構儲存”](/tw/ch4#sec_storage_log_structured)中討論的原理相同:先在記憶體中對資料塊排序,並把它們作為段檔案寫入磁碟,再將多個有序段合併成一個更大的有序檔案。歸併排序採用順序訪問模式,在磁碟上表現很好(參見[“順序與隨機寫入”](/tw/ch4#sidebar_sequential))。
@@ -156,7 +156,7 @@ Unix 工具的侷限在於,它們只能在單臺機器上執行。如果資料
- 一系列 Unix 程式,其 `stdin` 和 `stdout` 透過管道連線在一起。
分散式資料處理框架中也存在同樣的元件。事實上,你可以把分散式處理框架看作一種分散式作業系統:它們擁有檔案系統和作業排程器,程式則透過檔案系統或其他通訊通道相互傳送資料。
分散式資料處理框架中也存在同樣的元件。事實上,你可以把 *分散式處理框架**distributed processing framework*看作一種分散式作業系統:它們擁有檔案系統和作業排程器,程式則透過檔案系統或其他通訊通道相互傳送資料。
### 分散式檔案系統 {#sec_batch_dfs}
@@ -170,11 +170,11 @@ Unix 工具的侷限在於,它們只能在單臺機器上執行。如果資料
- 最後作業系統透過名為虛擬檔案系統VFS的統一 API把不同檔案系統暴露給應用。無論底層採用哪一種檔案系統應用都可以用同樣的方式讀寫資料。
分散式檔案系統的工作方式與此非常相似。檔案同樣被拆分成塊只不過這些塊分佈在許多機器上。分散式檔案系統的塊通常比本地檔案系統大得多HDFSHadoop 分散式檔案系統)的預設塊大小為 128 MBJuiceFS 和許多物件儲存使用 4 MB 的塊,而 ext4 的塊只有 4,096 位元組。塊越大,需要跟蹤的後設資料就越少;對於 PB 級資料集,這種差異十分顯著。相對於讀取資料塊所需的時間,大塊也能降低尋道開銷所佔的比例。
*分散式檔案系統**distributed filesystem*的工作方式與此非常相似。檔案同樣被拆分成塊只不過這些塊分佈在許多機器上。分散式檔案系統的塊通常比本地檔案系統大得多HDFSHadoop 分散式檔案系統)的預設塊大小為 128 MBJuiceFS 和許多物件儲存使用 4 MB 的塊,而 ext4 的塊只有 4,096 位元組。塊越大,需要跟蹤的後設資料就越少;對於 PB 級資料集,這種差異十分顯著。相對於讀取資料塊所需的時間,大塊也能降低尋道開銷所佔的比例。
大多數物理儲存裝置無法寫入不完整的資料塊,因此即使資料沒有填滿一個塊,作業系統也必須為寫入使用整個塊。分散式檔案系統的塊更大,而且通常構建在作業系統檔案系統之上,所以沒有這種要求。例如,一個 900 MB 的檔案採用 128 MB 的分塊時,會由 7 個佔用 128 MB 的塊和 1 個佔用 4 MB 的塊組成。
讀取分散式檔案系統中的塊,需要向叢集中存放該塊的機器發出網路請求。每臺機器都執行一個守護程序,並公開一套 API讓遠端程序能夠讀寫以檔案形式儲存在其本地檔案系統中的資料塊。HDFS 把這些守護程序稱為 DataNodeGlusterFS 則稱為 glusterfsd。本書統一把它們稱為 *資料節點*。
讀取分散式檔案系統中的塊,需要向叢集中存放該塊的機器發出網路請求。每臺機器都執行一個守護程序,並公開一套 API讓遠端程序能夠讀寫以檔案形式儲存在其本地檔案系統中的資料塊。HDFS 把這些守護程序稱為 DataNodeGlusterFS 則稱為 glusterfsd。本書統一把它們稱為 *資料節點**data node*
分散式檔案系統還實現了分散式版本的頁快取。由於資料塊以檔案形式存放在資料節點上,讀寫操作會經過每個資料節點的作業系統,其中就包含記憶體頁快取。因此,經常讀取的資料塊會被快取在資料節點的記憶體中。有些分散式檔案系統還實現了更多快取層,例如 JuiceFS 提供的客戶端快取和本地磁碟快取。
@@ -187,11 +187,11 @@ ext4 和 XFS 等檔案系統會跟蹤空閒空間、檔案塊位置、目錄結
> [!TIP] 分散式檔案系統與網路儲存
> 分散式檔案系統以 *無共享* 原則為基礎(參見[“共享記憶體、共享磁碟與無共享架構”](/tw/ch2#sec_introduction_shared_nothing)不同於網路附加儲存NAS和儲存區域網路SAN架構採用的 *共享磁碟* 方法。共享磁碟儲存由集中式儲存裝置實現,往往需要定製硬體和光纖通道等專用網路基礎設施。相比之下,無共享方法不需要特殊硬體,只需用普通的資料中心網路連線計算機即可。
許多分散式檔案系統構建在普通商用硬體上。這種硬體比較便宜,但故障率也高於企業級硬體。為了容忍機器和磁碟故障,檔案塊會複製到多臺機器上。這樣也便於排程器更均勻地分配工作負載,因為任務可以在任何存有其輸入資料副本的節點上執行。這裡的複製可以像[第 6 章](/tw/ch6#ch_replication)所述,在多臺機器上儲存若干份完整副本;也可以採用 ReedSolomon 碼等 *糾刪碼*,以低於完整複製的儲存開銷恢復丟失的資料 [^10] [^11] [^12]。這些技術與 RAID 很相似,後者在連線到同一臺機器的多個磁碟之間提供冗餘;區別在於,分散式檔案系統透過普通的資料中心網路訪問和複製檔案,不需要特殊硬體。
許多分散式檔案系統構建在普通商用硬體上。這種硬體比較便宜,但故障率也高於企業級硬體。為了容忍機器和磁碟故障,檔案塊會複製到多臺機器上。這樣也便於排程器更均勻地分配工作負載,因為任務可以在任何存有其輸入資料副本的節點上執行。這裡的複製可以像[第 6 章](/tw/ch6#ch_replication)所述,在多臺機器上儲存若干份完整副本;也可以採用 ReedSolomon 碼等 *糾刪碼**erasure coding*,以低於完整複製的儲存開銷恢復丟失的資料 [^10] [^11] [^12]。這些技術與 RAID 很相似,後者在連線到同一臺機器的多個磁碟之間提供冗餘;區別在於,分散式檔案系統透過普通的資料中心網路訪問和複製檔案,不需要特殊硬體。
### 物件儲存 {#id277}
Amazon S3、Google Cloud Storage、Azure Blob Storage 和 OpenStack Swift 等物件儲存服務,已經成為批處理作業中分散式檔案系統的常用替代方案。事實上,兩者的界限有些模糊。正如上一節和[“以物件儲存為後端的資料庫”](/tw/ch6#sec_replication_object_storage)中所述使用者空間檔案系統FUSE驅動程式可以讓使用者把 S3 之類的物件儲存當作檔案系統使用。JuiceFS 和 Ceph 等分散式檔案系統實現也同時提供物件儲存與檔案系統 API。不過兩類系統的 API、效能和一致性保證可能大相徑庭。即使某個系統看似實現了所需的 API採用之前也必須仔細確認它的實際行為符合預期。
Amazon S3、Google Cloud Storage、Azure Blob Storage 和 OpenStack Swift 等 *物件儲存**object storage*服務,已經成為批處理作業中分散式檔案系統的常用替代方案。事實上,兩者的界限有些模糊。正如上一節和[“以物件儲存為後端的資料庫”](/tw/ch6#sec_replication_object_storage)中所述使用者空間檔案系統FUSE驅動程式可以讓使用者把 S3 之類的物件儲存當作檔案系統使用。JuiceFS 和 Ceph 等分散式檔案系統實現也同時提供物件儲存與檔案系統 API。不過兩類系統的 API、效能和一致性保證可能大相徑庭。即使某個系統看似實現了所需的 API採用之前也必須仔細確認它的實際行為符合預期。
物件儲存中的每個物件都有一個 URL例如 `s3://my-photo-bucket/2025/04/01/birthday.png`。URL 的主機部分(`my-photo-bucket`表示存放物件的儲存桶bucket後面的部分則是物件的 *鍵*(本例中為 `/2025/04/01/birthday.png`)。儲存桶的名稱在全域性範圍內唯一,而每個物件的鍵在所屬儲存桶內必須唯一。
@@ -264,7 +264,7 @@ Kubernetes 和 Hadoop YARNYet Another Resource Negotiator[^13] 等編排
這個例子非常簡單,卻已經暴露出許多艱難的權衡。以成組排程為例:如果排程器不斷預留 CPU 核,直到 100 個核能夠同時使用,那麼一些節點就會閒置,叢集的資源利用率也會下降;如果其他作業也試圖預留 CPU 核,甚至還可能發生死鎖。
另一方面,如果排程器只是等待 100 個核空閒,其他作業又可能在此期間搶走這些核。叢集或許會在很長時間內都湊不出 100 個可用核,從而導致 *飢餓*。排程器還可以 *搶佔* 第一項作業的部分任務,終止它們來為第二項作業騰出空間。不過,搶佔任務同樣會降低叢集效率,因為被終止的任務稍後需要重新啟動、重新執行。
另一方面,如果排程器只是等待 100 個核空閒,其他作業又可能在此期間搶走這些核。叢集或許會在很長時間內都湊不出 100 個可用核,從而導致 *飢餓**starvation*。排程器還可以 *搶佔**preempt*第一項作業的部分任務,終止它們來為第二項作業騰出空間。不過,搶佔任務同樣會降低叢集效率,因為被終止的任務稍後需要重新啟動、重新執行。
現在再設想一下,排程器必須為數百乃至數百萬項這樣的作業請求作出分配決策。要找到最優解似乎根本不可行。事實上,這個問題是 *NP-hard* 的;也就是說,除了規模最小的例子之外,計算最優解所需的時間長得令人無法接受 [^14] [^15]。
@@ -272,7 +272,7 @@ Kubernetes 和 Hadoop YARNYet Another Resource Negotiator[^13] 等編排
#### 工作流排程 {#sec_batch_workflows}
本章開頭的 Unix 工具示例由若干命令串聯而成。分散式批處理也經常採用相同的模式:一項作業的輸出需要成為另一項或多項作業的輸入,而一項作業又可能有多個輸入,分別由其他作業產生。這種作業結構稱為 *工作流*,也稱為作業的 *有向無環圖*DAG
本章開頭的 Unix 工具示例由若干命令串聯而成。分散式批處理也經常採用相同的模式:一項作業的輸出需要成為另一項或多項作業的輸入,而一項作業又可能有多個輸入,分別由其他作業產生。這種作業結構稱為 *工作流**workflow*,也稱為作業的 *有向無環圖**directed acyclic graph*DAG
> [!NOTE]
> 在[“持久化執行與工作流”](/tw/ch5#sec_encoding_dataflow_workflows)中,我們見過能夠持久執行一系列步驟的工作流引擎,這些步驟通常會發出 RPC。在批處理的語境中“工作流”有不同含義它是一系列批處理過程每個過程都接收輸入資料、產生輸出資料通常不會向外部服務發出 RPC。持久化執行引擎通常會比批處理系統在每個請求中處理更少的資料不過兩者之間的界限並不十分清晰。
@@ -346,7 +346,7 @@ Reducer
為了解決 MapReduce 的一些問題,人們開發了幾種新的分散式批處理執行引擎,其中最著名的是 Spark [^18] [^21] 和 Flink [^19]。它們的設計方式各有不同,卻有一個共同點:把整個工作流作為一項作業處理,而不是拆成彼此獨立的子作業。
這些系統顯式建模了資料流經多個處理階段的過程,因此稱為 *資料流引擎*。與 MapReduce 一樣,它們提供底層 API透過反覆呼叫使用者定義的函式每次處理一條記錄同時也提供 *連線* 和 *分組* 等高層運算元。它們透過對輸入分片來並行執行工作,並把一項任務的輸出複製到另一項任務,作為後者的輸入;如果兩項任務執行在不同機器上,複製就透過網路完成。與 MapReduce 不同,運算元不必嚴格交替扮演 map 和 reduce 的角色,而可以以更加靈活的方式組合。
這些系統顯式建模了資料流經多個處理階段的過程,因此稱為 *資料流引擎**dataflow engine*。與 MapReduce 一樣,它們提供底層 API透過反覆呼叫使用者定義的函式每次處理一條記錄同時也提供 *連線**join*)和 *分組**grouping*等高層運算元。它們透過對輸入分片來並行執行工作,並把一項任務的輸出複製到另一項任務,作為後者的輸入;如果兩項任務執行在不同機器上,複製就透過網路完成。與 MapReduce 不同,運算元不必嚴格交替扮演 map 和 reduce 的角色,而可以以更加靈活的方式組合。
這些資料流 API 通常使用關係模型風格的構件來表達計算:按照某個欄位的值連線資料集,按鍵對元組分組,根據某項條件過濾資料,以及透過計數、求和等函式聚合元組。這些操作在內部透過下一節討論的混洗演算法實現。
@@ -395,7 +395,7 @@ MapReduce 在 map 與 reduce 階段之間執行混洗,而現代資料流引擎
下面來看有序資料如何簡化分散式連線與聚合。為便於說明,我們仍以 MapReduce 為例,不過這些概念適用於大多數批處理系統。
{{< xref fig="11-2" page="/ch11" anchor="fig_batch_join_example" >}}圖 11-2{{< /xref >}} 展示了批處理作業中一個典型的連線示例。左側是一份事件日誌,記錄已登入使用者在網站上的操作,這些記錄稱為 *活動事件*,也叫 *點選流資料*;右側則是使用者資料庫。這個例子可以看作星型模式的一部分(參見[“星型與雪花型:分析模式”](/tw/ch3#sec_datamodels_analytics)):事件日誌是事實表,使用者資料庫則是其中一張維度表。
{{< xref fig="11-2" page="/ch11" anchor="fig_batch_join_example" >}}圖 11-2{{< /xref >}} 展示了批處理作業中一個典型的連線示例。左側是一份事件日誌,記錄已登入使用者在網站上的操作,這些記錄稱為 *活動事件**activity event*),也叫 *點選流資料**clickstream data*;右側則是使用者資料庫。這個例子可以看作星型模式的一部分(參見[“星型與雪花型:分析模式”](/tw/ch3#sec_datamodels_analytics)):事件日誌是事實表,使用者資料庫則是其中一張維度表。
{{< fig num="11-2" id="fig_batch_join_example" src="/fig/ddia_1102.png" caption="使用者活動日誌與使用者畫像資料庫的連線。" class="ddia-figure ddia-figure--wide" width="2880" height="1238" />}}
@@ -405,7 +405,7 @@ MapReduce 在 map 與 reduce 階段之間執行混洗,而現代資料流引擎
{{< fig num="11-3" id="fig_batch_join_reduce" src="/fig/ddia_1103.png" caption="基於使用者 ID 的排序合併連線。若輸入資料集由多個檔案分片組成,可並行啟動多個 mapper 處理。" class="ddia-figure ddia-figure--wide" width="2880" height="1308" />}}
混洗隨後確保 reducer 函式能夠同時訪問某位使用者的出生日期以及該使用者的所有頁面瀏覽事件。MapReduce 甚至可以安排記錄的順序,讓 reducer 總是先看到使用者資料庫中的記錄,接著再按時間戳順序看到活動事件;這種技術稱為 *二次排序* [^25]。
混洗隨後確保 reducer 函式能夠同時訪問某位使用者的出生日期以及該使用者的所有頁面瀏覽事件。MapReduce 甚至可以安排記錄的順序,讓 reducer 總是先看到使用者資料庫中的記錄,接著再按時間戳順序看到活動事件;這種技術稱為 *二次排序**secondary sort*[^25]。
這樣reducer 就能輕鬆完成實際的連線邏輯。第一個值應當是出生日期reducer 先把它儲存在區域性變數中,再遍歷具有相同使用者 ID 的活動事件,輸出每個瀏覽過的 URL 以及瀏覽者的出生日期。reducer 會一次處理某個使用者 ID 的全部記錄,所以任何時刻只需在記憶體中儲存一條使用者記錄,也完全不必發出網路請求。這種演算法稱為 *排序合併連線*sort-merge join因為 mapper 的輸出按鍵排序,而 reducer 隨後會合並連線兩側的有序記錄列表。
@@ -478,11 +478,11 @@ Daft 等框架甚至同時支援客戶端與服務端計算:規模較小的記
### 分析 {#sec_batch_olap}
在[“分析型與事務型系統”](/tw/ch1#sec_introduction_analytics)中我們看到分析查詢OLAP經常掃描大量記錄並執行分組與聚合。這樣的工作負載可以與其他批處理工作負載一起在批處理系統中執行。分析師編寫 SQL 查詢,由查詢引擎執行,並讀寫分散式檔案系統或物件儲存。表與檔案之間的對映、名稱和型別等表後設資料,則透過 Apache Iceberg 等表格式和 Unity 等目錄服務管理(參見[“雲資料倉儲”](/tw/ch4#sec_cloud_data_warehouses))。這種架構稱為 *資料湖倉* [^39]。
在[“分析型與事務型系統”](/tw/ch1#sec_introduction_analytics)中我們看到分析查詢OLAP經常掃描大量記錄並執行分組與聚合。這樣的工作負載可以與其他批處理工作負載一起在批處理系統中執行。分析師編寫 SQL 查詢,由查詢引擎執行,並讀寫分散式檔案系統或物件儲存。表與檔案之間的對映、名稱和型別等表後設資料,則透過 Apache Iceberg 等表格式和 Unity 等目錄服務管理(參見[“雲資料倉儲”](/tw/ch4#sec_cloud_data_warehouses))。這種架構稱為 *資料湖倉**data lakehouse*[^39]。
與 ETL 一樣SQL 查詢介面的改進使許多組織如今也使用 Spark 等批處理框架進行分析。這類查詢模式分為兩種:
- *預聚合查詢*:把資料彙總成 OLAP 多維資料集或資料集市,以加快查詢(參見[“物化檢視與多維資料集”](/tw/ch4#sec_storage_materialized_views))。預聚合資料可以在資料倉儲中查詢,也可以推送到 Apache Druid 或 Apache Pinot 等專用的實時 OLAP 系統。預聚合通常按固定週期進行,這類工作負載由[“工作流排程”](/tw/ch11#sec_batch_workflows)中討論的工作流排程器管理。
- *預聚合查詢**pre-aggregated query*:把資料彙總成 OLAP 多維資料集或資料集市,以加快查詢(參見[“物化檢視與多維資料集”](/tw/ch4#sec_storage_materialized_views))。預聚合資料可以在資料倉儲中查詢,也可以推送到 Apache Druid 或 Apache Pinot 等專用的實時 OLAP 系統。預聚合通常按固定週期進行,這類工作負載由[“工作流排程”](/tw/ch11#sec_batch_workflows)中討論的工作流排程器管理。
- *即席查詢*ad hoc query使用者執行查詢來回答特定業務問題、調查使用者行為、除錯執行故障以及完成其他許多工作。在這種場景中響應時間很重要。分析師會反覆執行查詢在得到響應、進一步瞭解正在研究的資料後繼續調整查詢。能夠快速執行查詢的批處理框架可以減少分析師的等待時間。
@@ -490,7 +490,7 @@ SQL 支援還讓批處理框架能夠與電子表格和 Tableau、Power BI、Loo
### 機器學習 {#id290}
機器學習ML經常用到批處理。資料科學家、機器學習工程師和 AI 工程師使用批處理框架來探索資料規律、轉換資料並訓練機器學習模型。常見用途包括:
*機器學習*ML經常用到批處理。資料科學家、機器學習工程師和 AI 工程師使用批處理框架來探索資料規律、轉換資料並訓練機器學習模型。常見用途包括:
- *特徵工程*:對原始資料進行過濾和轉換,使其能夠用於訓練模型。預測模型通常要求輸入數值資料,因此工程師必須把文字或離散值等其他形式的資料轉換成所需格式。
- *模型訓練*:訓練資料是批處理過程的輸入,訓練所得的模型權重則是輸出。
@@ -502,7 +502,7 @@ SQL 支援還讓批處理框架能夠與電子表格和 Tableau、Power BI、Loo
*批次同步並行*bulk synchronous parallelBSP計算模型 [^40] 已經成為批次處理圖資料時的常用模型。Apache Giraph [^20]、Spark 的 GraphX API 和 Flink 的 Gelly API [^41] 等系統都實現了這一模型。它也稱為 *Pregel* 模型,因為 Google 的 Pregel 論文推廣了這種圖處理方法 [^42]。
批處理也是大語言模型LLM資料準備與訓練的重要組成部分。網站等原始文字輸入通常存放在分散式檔案系統或物件儲存中必須先經過預處理才能用於訓練。適合交給批處理框架完成的預處理步驟包括
批處理也是 *大語言模型*LLM資料準備與訓練的重要組成部分。網站等原始文字輸入通常存放在分散式檔案系統或物件儲存中必須先經過預處理才能用於訓練。適合交給批處理框架完成的預處理步驟包括
- 從 HTML 中提取純文字,並修復格式損壞的文字;
- 檢測並刪除質量低劣、內容無關或重複的文件;

View File

@@ -17,9 +17,9 @@ breadcrumbs: false
在 [第 11 章](/tw/ch11#ch_batch) 中,我們討論了批處理技術:它以一組檔案作為輸入,並生成一組新的輸出檔案。輸出是 *衍生資料derived data* 的一種形式;也就是說,如有必要,可以再次執行批處理來重新建立這份資料集。我們看到,這個簡單而強大的思想可以用來構建搜尋索引、推薦系統和分析系統,等等。
然而,[第 11 章](/tw/ch11#ch_batch) 始終建立在一個重要假設之上:輸入是有界的,也就是大小已知且有限,因此批處理知道何時已經讀完輸入。例如,作為 MapReduce 核心環節的排序操作必須先讀完全部輸入,才能開始生成輸出。因為最後一條輸入記錄可能恰好擁有最小的鍵,因而需要成為第一條輸出記錄,所以不能提前開始輸出。
然而,[第 11 章](/tw/ch11#ch_batch) 始終建立在一個重要假設之上:輸入是 *有界的**bounded*,也就是大小已知且有限,因此批處理知道何時已經讀完輸入。例如,作為 MapReduce 核心環節的排序操作必須先讀完全部輸入,才能開始生成輸出。因為最後一條輸入記錄可能恰好擁有最小的鍵,因而需要成為第一條輸出記錄,所以不能提前開始輸出。
實際上,許多資料都是 *無界的*,因為它們會隨時間推移陸續到達:使用者昨天和今天產生了資料,明天還會繼續產生更多資料。只要你的企業還在經營,這個過程就不會結束,因此從任何有意義的角度來看,資料集都永遠不會“完整”[^1]。所以,批處理程式不得不人為地按固定時長切分資料,例如每天結束時處理當天的資料,或每小時結束時處理這一小時的資料。
實際上,許多資料都是 *無界的**unbounded*,因為它們會隨時間推移陸續到達:使用者昨天和今天產生了資料,明天還會繼續產生更多資料。只要你的企業還在經營,這個過程就不會結束,因此從任何有意義的角度來看,資料集都永遠不會“完整”[^1]。所以,批處理程式不得不人為地按固定時長切分資料,例如每天結束時處理當天的資料,或每小時結束時處理這一小時的資料。
每日批處理的問題在於,輸入中的變化要到一天後才會反映到輸出中,對許多沒有耐心的使用者來說實在太慢。為了縮短延遲,可以更頻繁地執行處理——比如每秒結束時處理這一秒的資料——也可以完全拋開固定的時間切片,改為連續處理,每個事件一發生就立即處理。這就是 *流處理stream processing* 的基本思想。
@@ -216,13 +216,13 @@ Apache Kafka[^20] 和 Amazon Kinesis Streams 都是以這種方式工作的基
前面我們對訊息代理與資料庫做過一些比較。傳統上,它們被視為兩類不同的工具,但基於日誌的訊息代理已經成功地把資料庫中的思想用於訊息傳遞。反過來也一樣:我們可以把訊息傳遞和流中的思想用於資料庫。
一種做法是用 *事件流充當儲存資料的權威記錄系統*(參閱 [“權威記錄系統與衍生資料”](/tw/ch1#sec_introduction_derived))。這正是我們在 [“事件溯源與 CQRS”](/tw/ch3#sec_datamodels_events) 中討論過的 *事件溯源*:不再用更新和刪除來改變資料模型,而是把每次狀態變化建模成不可變事件,寫入僅追加日誌;所有為讀取最佳化的物化檢視都從這些事件中衍生出來。基於日誌的訊息代理採用僅追加儲存,還能以低延遲通知消費者有新事件到來,因此很適合事件溯源——只要將其配置為永不刪除舊事件。
一種做法是用 *事件流充當儲存資料的權威記錄系統*(參閱 [“權威記錄系統與衍生資料”](/tw/ch1#sec_introduction_derived))。這正是我們在 [“事件溯源與 CQRS”](/tw/ch3#sec_datamodels_events) 中討論過的 *事件溯源**event sourcing*:不再用更新和刪除來改變資料模型,而是把每次狀態變化建模成不可變事件,寫入僅追加日誌;所有為讀取最佳化的物化檢視都從這些事件中衍生出來。基於日誌的訊息代理採用僅追加儲存,還能以低延遲通知消費者有新事件到來,因此很適合事件溯源——只要將其配置為永不刪除舊事件。
不過,你不必走到採用事件溯源這一步;即便資料模型是可變的,事件流對資料庫仍然很有用。事實上,每次資料庫寫入都是一個可以捕獲、儲存和處理的事件。資料庫與流的聯絡不只是日誌在磁碟上的物理儲存形式,而是更為根本。
例如,複製日誌(參閱 [“複製日誌的實現”](/tw/ch6#sec_replication_implementation))就是資料庫寫入事件組成的流,由領導者在處理事務時生成。追隨者把這股寫入流應用到自己的資料庫副本上,最終得到同一份資料的準確副本。複製日誌中的事件描述的正是已經發生的資料變化。
我們還在 [“使用共享日誌”](/tw/ch10#sec_consistency_smr) 中遇到過 *狀態機複製* 原理:如果每個事件都代表一次資料庫寫入,並且每個副本都以相同順序處理相同事件,那麼所有副本最終都會達到相同狀態(這裡假定事件處理是確定性的操作)。這又是事件流的一個例子!
我們還在 [“使用共享日誌”](/tw/ch10#sec_consistency_smr) 中遇到過 *狀態機複製**state machine replication*原理:如果每個事件都代表一次資料庫寫入,並且每個副本都以相同順序處理相同事件,那麼所有副本最終都會達到相同狀態(這裡假定事件處理是確定性的操作)。這又是事件流的一個例子!
本節先考察異構資料系統中會出現的一個問題,再探討如何把事件流的思想引入資料庫來解決它。
@@ -232,7 +232,7 @@ Apache Kafka[^20] 和 Amazon Kinesis Streams 都是以這種方式工作的基
相同或相關的資料分散在不同位置,就必須彼此保持同步:資料庫中的某個專案更新後,快取、搜尋索引和資料倉儲也要隨之更新。資料倉儲通常透過 ETL 流程完成同步(參閱 [“資料倉儲”](/tw/ch1#sec_introduction_dwh)):先取得資料庫的完整副本,轉換資料,再批次載入進資料倉儲——換言之,這是一個批處理過程。同樣,我們在 [“批處理用例”](/tw/ch11#sec_batch_output) 中看到,搜尋索引、推薦系統以及其他衍生資料系統也可以透過批處理來建立。
如果定期轉儲完整資料庫太慢,有時會改用 *雙寫*:資料發生變化時,應用程式碼顯式寫入每個系統,例如先寫資料庫,再更新搜尋索引,最後使相應的快取項失效(也可能併發執行這些寫入)。
如果定期轉儲完整資料庫太慢,有時會改用 *雙寫**dual write*:資料發生變化時,應用程式碼顯式寫入每個系統,例如先寫資料庫,再更新搜尋索引,最後使相應的快取項失效(也可能併發執行這些寫入)。
然而,雙寫存在一些嚴重問題,其中之一就是 {{< xref fig="12-4" page="/ch12" anchor="fig_stream_write_order" >}}圖 12-4{{< /xref >}} 所示的競態條件。在這個例子中,兩個客戶端併發更新專案 X客戶端 1 想把值設為 A客戶端 2 想把值設為 B。兩個客戶端都先把新值寫入資料庫再寫入搜尋索引。由於時序不巧請求交錯執行資料庫先收到客戶端 1 將值設為 A 的寫入,再收到客戶端 2 將值設為 B 的寫入,因此資料庫中的最終值為 B搜尋索引卻先收到客戶端 2 的寫入,再收到客戶端 1 的寫入,因此最終值為 A。雖然沒有發生任何錯誤兩個系統卻永久地不一致了。
@@ -262,11 +262,11 @@ Apache Kafka[^20] 和 Amazon Kinesis Streams 都是以這種方式工作的基
#### 變更資料捕獲的實現 {#id307}
按照 [“權威記錄系統與衍生資料”](/tw/ch1#sec_introduction_derived) 中的說法,我們可以把日誌消費者稱為 *衍生資料系統*:搜尋索引和資料倉儲中儲存的資料,不過是權威記錄系統中資料的另一種檢視。變更資料捕獲機制確保權威記錄系統中的所有變化也會反映到衍生資料系統中,使衍生系統持有準確的資料副本。
按照 [“權威記錄系統與衍生資料”](/tw/ch1#sec_introduction_derived) 中的說法,我們可以把日誌消費者稱為 *衍生資料系統**derived data system*:搜尋索引和資料倉儲中儲存的資料,不過是權威記錄系統中資料的另一種檢視。變更資料捕獲機制確保權威記錄系統中的所有變化也會反映到衍生資料系統中,使衍生系統持有準確的資料副本。
實質上,變更資料捕獲讓一個資料庫成為領導者(即從中捕獲變化的資料庫),讓其他系統成為追隨者。基於日誌的訊息代理能夠保持訊息順序,避免 {{< xref fig="12-2" page="/ch12" anchor="fig_stream_redelivery" >}}圖 12-2{{< /xref >}} 中的亂序問題,因此很適合把變更事件從源資料庫傳送到衍生系統。
邏輯複製日誌可以用來實現變更資料捕獲(參閱 [“邏輯(基於行)的日誌複製”](/tw/ch6#sec_replication_logical)),不過需要應對模式變更、恰當建模更新等挑戰。開源專案 Debezium 正是為解決這些問題而生。它為 MySQL、PostgreSQL、Oracle、SQL Server、Db2、Cassandra 以及其他許多資料庫提供了 *源聯結器*。這些聯結器接入資料庫複製日誌以標準事件模式呈現其中的變化隨後便可轉換訊息並將其寫入下游資料庫。Kafka Connect 框架也為各種資料庫提供了更多 CDC 聯結器。Maxwell 透過解析 binlog 為 MySQL 提供類似功能[^29]GoldenGate 為 Oracle 提供類似功能pgcapture 則面向 PostgreSQL。
邏輯複製日誌可以用來實現變更資料捕獲(參閱 [“邏輯(基於行)的日誌複製”](/tw/ch6#sec_replication_logical)),不過需要應對模式變更、恰當建模更新等挑戰。開源專案 Debezium 正是為解決這些問題而生。它為 MySQL、PostgreSQL、Oracle、SQL Server、Db2、Cassandra 以及其他許多資料庫提供了 *源聯結器**source connector*。這些聯結器接入資料庫複製日誌以標準事件模式呈現其中的變化隨後便可轉換訊息並將其寫入下游資料庫。Kafka Connect 框架也為各種資料庫提供了更多 CDC 聯結器。Maxwell 透過解析 binlog 為 MySQL 提供類似功能[^29]GoldenGate 為 Oracle 提供類似功能pgcapture 則面向 PostgreSQL。
與訊息代理一樣,變更資料捕獲通常也是非同步的:權威記錄資料庫提交變化之前,並不會等待消費者應用該變化。這種設計在運維上的好處是,增加一個緩慢的消費者不會對權威記錄系統造成太大影響;缺點則是複製延遲的所有問題同樣存在(參閱 [“複製延遲的問題”](/tw/ch6#sec_replication_lag))。
@@ -286,7 +286,7 @@ Apache Kafka[^20] 和 Amazon Kinesis Streams 都是以這種方式工作的基
{{< fig num="12-6" id="fig_stream_compaction" src="/fig/ddia_1206.png" caption="一個鍵值對日誌:鍵是貓咪影片的 IDmew、purr、scratch 或 yawn值是播放次數。日誌壓實只保留每個鍵的最新值。" class="ddia-figure ddia-figure--wide" width="2880" height="1477" />}}
在日誌結構儲存引擎中,帶有特殊空值的更新(稱為 *墓碑*)表示某個鍵已被刪除,並使該鍵在日誌壓實時被移除。但只要一個鍵沒有被覆蓋或刪除,它就會永久留在日誌中。壓實後的日誌所需磁碟空間只取決於資料庫當前的內容,而與資料庫有史以來發生過多少次寫入無關。如果同一個鍵經常被覆蓋,舊值最終會被垃圾回收,只留下最新值。
在日誌結構儲存引擎中,帶有特殊空值的更新(稱為 *墓碑**tombstone*)表示某個鍵已被刪除,並使該鍵在日誌壓實時被移除。但只要一個鍵沒有被覆蓋或刪除,它就會永久留在日誌中。壓實後的日誌所需磁碟空間只取決於資料庫當前的內容,而與資料庫有史以來發生過多少次寫入無關。如果同一個鍵經常被覆蓋,舊值最終會被垃圾回收,只留下最新值。
同樣的思路也適用於基於日誌的訊息代理和變更資料捕獲。如果 CDC 系統保證每次變化都有主鍵,而且對某個鍵的每次更新都會取代該鍵的舊值,那麼只保留這個鍵最近一次寫入就足夠了。
@@ -353,7 +353,7 @@ Kafka Connect[^33] 把許多資料庫系統的變更資料捕獲工具與 Kafka
#### 不可變事件的優點 {#sec_stream_immutability_pros}
資料庫中的不變性是一個古老的觀念。例如,會計師幾個世紀以來一直在財務記賬中運用不變性。一筆交易發生後,會被記入僅追加的 *分類賬*;分類賬本質上就是事件日誌,描述貨幣、商品或服務的轉手。損益表、資產負債表等賬目,則是彙總分類賬中的交易而衍生出來的[^40]。
資料庫中的不變性是一個古老的觀念。例如,會計師幾個世紀以來一直在財務記賬中運用不變性。一筆交易發生後,會被記入僅追加的 *分類賬**ledger*;分類賬本質上就是事件日誌,描述貨幣、商品或服務的轉手。損益表、資產負債表等賬目,則是彙總分類賬中的交易而衍生出來的[^40]。
如果出了差錯,會計師不會刪除或修改分類賬中的錯誤交易,而是另加一筆交易來抵消錯誤,例如退還一筆誤收的費用。錯誤交易會永遠保留在分類賬中,因為它對審計可能十分重要。如果根據錯誤分類賬得出的錯誤數字已經公佈,下一個會計期間的數字就會包含相應的更正。這在會計工作中再正常不過[^41]。
@@ -438,7 +438,7 @@ CQRS 最大的缺點是事件日誌的消費者通常以非同步方式執行。
*複合事件處理*complex event processingCEP是一種在 20 世紀 90 年代發展起來的事件流分析方法,特別適合需要搜尋特定事件模式的應用[^52]。正如正規表示式可以在字串中搜尋特定的字元模式CEP 允許你指定規則,在流中搜尋特定的事件模式。
CEP 系統通常使用 SQL 等高階宣告式查詢語言或圖形使用者介面,描述應該檢測哪些事件模式。這些查詢會提交給處理引擎;引擎消費輸入流,並在內部維護一個狀態機來執行所需的匹配。一旦找到匹配,引擎便發出一個 *複合事件*(名稱由此而來),其中包含檢測到的事件模式詳情[^53]。
CEP 系統通常使用 SQL 等高階宣告式查詢語言或圖形使用者介面,描述應該檢測哪些事件模式。這些查詢會提交給處理引擎;引擎消費輸入流,並在內部維護一個狀態機來執行所需的匹配。一旦找到匹配,引擎便發出一個 *複合事件**complex event*名稱由此而來),其中包含檢測到的事件模式詳情[^53]。
這類系統中查詢與資料的關係恰好和普通資料庫相反。資料庫通常持久儲存資料把查詢視為臨時物件查詢到來時資料庫搜尋與之匹配的資料查詢完成後便將其忘掉。CEP 引擎卻反過來長期儲存查詢;每個事件到來時,引擎都會檢查迄今所見的事件是否形成了與某個常駐查詢相匹配的模式[^54]。
@@ -454,7 +454,7 @@ CEP 的實現包括 Esper、Apama 和 TIBCO StreamBase。Flink、Spark Streaming
- 將當前統計值與先前時間段對比(例如檢測趨勢,或在某項指標與上週同一時間相比異常偏高或偏低時發出警報)。
這類統計值通常在固定時間區間內計算。例如,你可能想知道過去 5 分鐘內某項服務平均每秒收到多少次查詢,以及這段時間內響應時間的第 99 百分位點。在幾分鐘內取平均,可以抹平相鄰秒之間無關緊要的波動,同時仍能及時反映流量模式的變化。用於聚合的時間區間稱為 *視窗*,我們將在 [“時間推理”](/tw/ch12#sec_stream_time) 中詳細討論。
這類統計值通常在固定時間區間內計算。例如,你可能想知道過去 5 分鐘內某項服務平均每秒收到多少次查詢,以及這段時間內響應時間的第 99 百分位點。在幾分鐘內取平均,可以抹平相鄰秒之間無關緊要的波動,同時仍能及時反映流量模式的變化。用於聚合的時間區間稱為 *視窗**window*,我們將在 [“時間推理”](/tw/ch12#sec_stream_time) 中詳細討論。
流分析系統有時會使用機率演算法,例如用布隆過濾器(我們在 [“布隆過濾器”](/tw/ch4#sec_storage_bloom_filter) 中見過)判斷集合成員關係,用 HyperLogLog[^55] 估計基數,以及用各種演算法估計百分位點(參閱 [“計算百分位點”](/tw/ch2#sidebar_percentiles))。機率演算法給出近似結果,但與精確演算法相比,流處理器所需記憶體少得多。近似演算法的這種用途有時讓人誤以為流處理系統總是有損而不精確,其實不然:流處理本身並沒有任何近似性,使用機率演算法只是一項最佳化[^56]。
@@ -515,7 +515,7 @@ Actor 框架也可以用來處理流。不過,許多這類框架無法保證
一個批處理任務可能幾分鐘內就讀完一整年的歷史事件;大多數情況下,我們關心的是這一年的歷史時間線,而不是幾分鐘的處理時間。此外,使用事件中的時間戳還可以讓處理具有確定性:針對同一輸入重新執行同一個過程,會得到相同的結果。
另一方面,許多流處理框架使用處理機器的本地系統時鐘(即 *處理時間*)來劃分視窗[^64]。這種方法簡單明了;如果事件建立與事件處理之間的延遲短到可以忽略,也很合理。但只要處理延遲比較顯著——也就是事件實際發生後過了一段明顯可感的時間才得到處理——這種方法就會失效。
另一方面,許多流處理框架使用處理機器的本地系統時鐘(即 *處理時間**processing time*)來劃分視窗[^64]。這種方法簡單明了;如果事件建立與事件處理之間的延遲短到可以忽略,也很合理。但只要處理延遲比較顯著——也就是事件實際發生後過了一段明顯可感的時間才得到處理——這種方法就會失效。
#### 事件時間與處理時間 {#id322}
@@ -525,7 +525,7 @@ Actor 框架也可以用來處理流。不過,許多這類框架無法保證
不妨拿《星球大戰》系列電影來類比:第四部於 1977 年上映,第五部於 1980 年上映,第六部於 1983 年上映;隨後依次是 1999、2002 和 2005 年上映的第一、二、三部,以及 2015、2017 和 2019 年上映的第七、八、九部[^65]。如果按上映順序觀看,你處理這些電影的順序就與故事的敘事順序不同(集數好比事件時間戳,觀看日期則是處理時間)。人類能夠應對這種不連續性,但流處理演算法必須專門設計,才能處理這類時間與順序問題。
混淆事件時間與處理時間會產生錯誤資料。例如,假設有一個流處理器用來測量請求速率(統計每秒請求數)。重新部署流處理器時,它可能停機一分鐘,恢復執行後再處理積壓的事件。如果按處理時間計算速率,處理積壓期間看起來會突然出現異常的請求尖峰,而真實的請求速率其實一直很穩定({{< xref fig="12-8" page="/ch12" anchor="fig_stream_processing_time" >}}圖 12-8{{< /xref >}})。
混淆 *事件時間**event time*與處理時間會產生錯誤資料。例如,假設有一個流處理器用來測量請求速率(統計每秒請求數)。重新部署流處理器時,它可能停機一分鐘,恢復執行後再處理積壓的事件。如果按處理時間計算速率,處理積壓期間看起來會突然出現異常的請求尖峰,而真實的請求速率其實一直很穩定({{< xref fig="12-8" page="/ch12" anchor="fig_stream_processing_time" >}}圖 12-8{{< /xref >}})。
{{< fig num="12-8" id="fig_stream_processing_time" src="/fig/ddia_1208.png" caption="按處理時間劃分視窗,會因處理速率的變化而產生人為假象。" class="ddia-figure ddia-figure--wide" width="2880" height="1619" />}}
@@ -539,7 +539,7 @@ Actor 框架也可以用來處理流。不過,許多這類框架無法保證
1. 忽略滯留事件,因為正常情況下,它們可能只佔所有事件的很小一部分。可以把丟棄事件的數量作為指標跟蹤;如果開始丟棄大量資料,就發出警報。
2. 釋出 *更正*:為視窗釋出一個包含滯留事件的更新值。可能還需要撤回先前的輸出。
2. 釋出 *更正**correction*:為視窗釋出一個包含滯留事件的更新值。可能還需要撤回先前的輸出。
有時可以用一條特殊訊息表示:“從現在起,不會再有時間戳早於 *t* 的訊息。”消費者可以利用它觸發視窗[^66]。然而,如果多臺機器上的多個生產者都在生成事件,各自擁有不同的最小時間戳閾值,消費者就必須分別跟蹤每個生產者。這時,增加或移除生產者會更加棘手。
@@ -587,7 +587,7 @@ Actor 框架也可以用來處理流。不過,許多這類框架無法保證
在 [“JOIN 與 GROUP BY”](/tw/ch11#sec_batch_join) 中,我們討論過批處理作業如何按鍵連線資料集,以及這種連線為何是資料流水線的重要組成部分。流處理把資料流水線推廣到對無界資料集的增量處理,因此也同樣需要對流執行連線。
不過,流中隨時可能出現新事件,使流連線比批處理作業中的連線更具挑戰。為了看清這個問題,我們把連線分成三類:*流—流連線*、*流—表連線* 和 *表—表連線*。下面各用一個例子來說明。
不過,流中隨時可能出現新事件,使流連線比批處理作業中的連線更具挑戰。為了看清這個問題,我們把連線分成三類:*流—流連線**stream-stream join*)、*流—表連線**stream-table join*)和 *表—表連線**table-table join*。下面各用一個例子來說明。
#### 流流連線(視窗連線) {#id440}
@@ -597,7 +597,7 @@ Actor 框架也可以用來處理流。不過,許多這類框架無法保證
請注意,把搜尋詳情嵌入點選事件並不等同於連線兩類事件:這樣只能瞭解使用者點選搜尋結果的情況,卻無法瞭解使用者沒有點選任何結果的搜尋。衡量搜尋質量需要準確的點選率,因此搜尋事件和點選事件缺一不可。
為了實現這類連線,流處理器需要維護 *狀態*,例如按會話 ID 索引過去一小時內的所有事件。每當搜尋事件或點選事件到來,就把它加入相應索引,同時檢查另一個索引,看看相同會話 ID 的另一事件是否已經到達。找到匹配時,發出一個事件,說明哪項搜尋結果被點選;如果搜尋事件過期時仍未看到匹配的點選事件,則發出一個事件,說明哪些搜尋結果沒有被點選。
為了實現這類連線,流處理器需要維護 *狀態**state*,例如按會話 ID 索引過去一小時內的所有事件。每當搜尋事件或點選事件到來,就把它加入相應索引,同時檢查另一個索引,看看相同會話 ID 的另一事件是否已經到達。找到匹配時,發出一個事件,說明哪項搜尋結果被點選;如果搜尋事件過期時仍未看到匹配的點選事件,則發出一個事件,說明哪些搜尋結果沒有被點選。
#### 流表連線(流擴充) {#sec_stream_table_joins}
@@ -605,7 +605,7 @@ Actor 框架也可以用來處理流。不過,許多這類框架無法保證
執行這項連線時,流處理器要逐一檢視活動事件,在資料庫中查詢事件裡的使用者 ID再把檔案資訊加入活動事件。資料庫查詢可以透過查詢遠端資料庫來實現不過正如 [“JOIN 與 GROUP BY”](/tw/ch11#sec_batch_join) 中所討論的,這類遠端查詢很可能速度緩慢,還可能使資料庫過載[^58]。
另一種做法是把資料庫副本載入流處理器,在本地查詢,免去網路往返。由於資料庫的本地副本可能是記憶體雜湊表(如果足夠小),也可能是本地磁碟上的索引,因此這項技術稱為 *雜湊連線*
另一種做法是把資料庫副本載入流處理器,在本地查詢,免去網路往返。由於資料庫的本地副本可能是記憶體雜湊表(如果足夠小),也可能是本地磁碟上的索引,因此這項技術稱為 *雜湊連線**hash join*
它與批處理作業的區別在於:批處理作業把資料庫某個時間點的快照用作輸入;流處理器卻長期執行,而資料庫內容很可能隨時間變化,所以流處理器中的本地副本必須持續更新。變更資料捕獲可以解決這個問題:除了活動事件流,流處理器還可以訂閱使用者檔案資料庫的變更日誌。每當建立或修改檔案時,流處理器就更新本地副本。這樣,我們實際上得到了兩股流之間的連線:活動事件與檔案更新。
@@ -684,7 +684,7 @@ Apache Flink 採用一種變體:定期生成滾動的狀態檢查點,並將
#### 冪等性 {#sec_stream_idempotence}
我們的目標是丟棄所有失敗任務的部分輸出,以便能安全地重試,而不會生效兩次。分散式事務是實現這個目標的一種方法;另一種方法是依靠 *冪等性*,正如 [“持久化執行與工作流”](/tw/ch5#sec_encoding_dataflow_workflows) 中所見[^80]。
我們的目標是丟棄所有失敗任務的部分輸出,以便能安全地重試,而不會生效兩次。分散式事務是實現這個目標的一種方法;另一種方法是依靠 *冪等性**idempotence*,正如 [“持久化執行與工作流”](/tw/ch5#sec_encoding_dataflow_workflows) 中所見[^80]。
冪等操作可以執行多次,效果與只執行一次相同。例如,刪除鍵值儲存中的一個鍵是冪等的(再次刪除不會產生進一步影響);遞增計數器卻不是冪等的(再執行一次遞增,值就增加了兩次)。

View File

@@ -17,9 +17,9 @@ breadcrumbs: false
>
> 聖托馬斯・阿奎那《神學大全》1265—1274
我們在 [第 2 章](/tw/ch2#ch_nonfunctional) 中討論了構建 *可靠*、*可伸縮*、*可維護* 的應用與系統這一目標。這些主題貫穿全書各章:例如,我們討論了許多有助於提升可靠性的容錯演算法、提升可伸縮性的分片,以及提升可維護性的演化與抽象機制。
我們在 [第 2 章](/tw/ch2#ch_nonfunctional) 中討論了構建 *可靠**reliable*)、*可伸縮**scalable*)、*可維護**maintainable*的應用與系統這一目標。這些主題貫穿全書各章:例如,我們討論了許多有助於提升可靠性的容錯演算法、提升可伸縮性的分片,以及提升可維護性的演化與抽象機制。
在本章中,我們將把所有這些想法彙集起來,並特別以 [第 12 章](/tw/ch12#ch_stream) 的流處理與事件驅動架構為基礎,形成一套能夠實現上述目標的應用開發哲學。與前幾章相比,本章的立場更為鮮明:它將深入闡述一種特定的哲學,而不是比較多種不同的方法。
在本章中,我們將把所有這些想法彙集起來,並特別以 [第 12 章](/tw/ch12#ch_stream) 的 *流處理**stream processing*)與 *事件驅動架構**event-driven architecture*為基礎,形成一套能夠實現上述目標的應用開發哲學。與前幾章相比,本章的立場更為鮮明:它將深入闡述一種特定的哲學,而不是比較多種不同的方法。
## 資料整合 {#sec_future_integration}
@@ -37,7 +37,7 @@ breadcrumbs: false
例如,為了支援任意關鍵詞查詢,通常需要把 OLTP 資料庫與全文檢索索引整合起來。儘管某些資料庫(例如 PostgreSQL內建的全文索引功能足以滿足簡單應用的需要 [^1],但更複雜的檢索功能仍然需要專業的資訊檢索工具。反過來,搜尋索引通常又不適合作為持久的權威記錄系統,因此許多應用都需要組合使用這兩種工具,才能滿足全部需求。
我們在 [“保持系統同步”](/tw/ch12#sec_stream_sync) 中談到過資料系統的整合問題。資料表示越多,整合就越困難。除了資料庫和搜尋索引,你可能還需要在分析系統(資料倉儲,或者批處理與流處理系統)中儲存資料副本;維護由原始資料衍生而來的快取或反正規化物件;讓資料經過機器學習、分類、排名或推薦系統;或者根據資料變更傳送通知。
我們在 [“保持系統同步”](/tw/ch12#sec_stream_sync) 中談到過資料系統的整合問題。資料表示越多,整合就越困難。除了資料庫和搜尋索引,你可能還需要在分析系統中儲存資料副本,例如資料倉儲、*批處理**batch processing*)系統或流處理系統;維護由原始資料衍生而來的快取或反正規化物件;讓資料經過機器學習、分類、排名或推薦系統;或者根據資料變更傳送通知。
#### 理解資料流 {#id443}
@@ -49,7 +49,7 @@ breadcrumbs: false
如果能夠把所有使用者輸入都匯入一個系統,由它決定全部寫入的順序,那麼只需按相同順序處理這些寫入,就能更容易地衍生出資料的其他表示。這正是我們在 [“共識的實踐”](/tw/ch10#sec_consistency_total_order) 中見過的狀態機複製方法的一種應用。使用變更資料捕獲還是事件溯源日誌並不是最重要的,真正重要的是先確定一個全序。
根據事件日誌更新衍生資料系統,往往可以做到確定性與冪等性(參見 [“冪等性”](/tw/ch12#sec_stream_idempotence)),因而很容易從故障中恢復。
根據事件日誌更新衍生資料系統,往往可以做到 *確定性**determinism*)與 *冪等性**idempotence*參見 [“冪等性”](/tw/ch12#sec_stream_idempotence)),因而很容易從故障中恢復。
#### 衍生資料與分散式事務 {#sec_future_derived_vs_transactions}
@@ -77,7 +77,7 @@ breadcrumbs: false
- 一些應用會在客戶端維護狀態:使用者輸入後立即更新,不等待伺服器確認,甚至在離線時仍能繼續工作。在這樣的應用中,客戶端與伺服器很可能以不同的順序看到事件。
從形式上說,決定事件全序的問題稱為 *全序廣播*,它等價於共識(參見 [“共識的多面性”](/tw/ch10#sec_consistency_faces))。大多數共識演算法針對的是單個節點吞吐量足以處理整個事件流的情形,並沒有提供讓多個節點共同分擔事件排序工作的機制。
從形式上說,決定事件全序的問題稱為 *全序廣播**total order broadcast*,它等價於共識(參見 [“共識的多面性”](/tw/ch10#sec_consistency_faces))。大多數共識演算法針對的是單個節點吞吐量足以處理整個事件流的情形,並沒有提供讓多個節點共同分擔事件排序工作的機制。
#### 排序事件以捕獲因果關係 {#sec_future_capture_causality}
@@ -99,13 +99,13 @@ breadcrumbs: false
### 批處理與流處理 {#sec_future_batch_streaming}
資料整合的目標,是確保資料以正確的形式出現在所有正確的位置。為此,需要消費輸入,進行轉換、連線、過濾、聚合、模型訓練與評估,最終再寫入適當的輸出。批處理器和流處理器正是實現這一目標的工具。批處理與流處理的輸出是衍生資料集,例如搜尋索引、物化檢視、向使用者展示的推薦結果、聚合指標,等等。
*資料整合**data integration*的目標,是確保資料以正確的形式出現在所有正確的位置。為此,需要消費輸入,進行轉換、連線、過濾、聚合、模型訓練與評估,最終再寫入適當的輸出。批處理器和流處理器正是實現這一目標的工具。批處理與流處理的輸出是衍生資料集,例如搜尋索引、物化檢視、向使用者展示的推薦結果、聚合指標,等等。
正如我們在 [第 11 章](/tw/ch11#ch_batch) 和 [第 12 章](/tw/ch12#ch_stream) 中看到的,批處理與流處理有許多共同原則;兩者最根本的區別在於,流處理器處理的是無界資料集,而批處理的輸入大小已知且有限。
#### 維護衍生狀態 {#id446}
批處理帶有很強的函式式風格(即使程式碼並不是用函數語言程式設計語言編寫的):它鼓勵使用確定性的純函式,其輸出只取決於輸入,除了明確的輸出之外沒有其他副作用;輸入被視為不可變,輸出則只能追加。流處理與之類似,不過它擴充套件了運算元,使其能夠維護受管理且容錯的狀態。
批處理帶有很強的函式式風格(即使程式碼並不是用函數語言程式設計語言編寫的):它鼓勵使用確定性的 *純函式**pure function*),其輸出只取決於輸入,除了明確的輸出之外沒有其他 *副作用**side effect*;輸入被視為不可變,輸出則只能追加。流處理與之類似,不過它擴充套件了運算元,使其能夠維護受管理且容錯的狀態。
輸入和輸出定義明確的確定性函式,不僅有利於容錯,也能簡化對組織內部資料流的推理 [^5]。無論衍生資料是搜尋索引、統計模型還是快取,都可以把它看成資料管道:從一項事物衍生出另一項事物,讓一個系統的狀態變更經過函式式應用程式碼,再把相應效果施加到衍生系統上。這樣思考大有裨益。
@@ -138,7 +138,7 @@ breadcrumbs: false
- 能夠讓歷史事件透過處理近期事件流的同一個處理引擎重放。例如,基於日誌的訊息代理可以重放訊息,一些流處理器也能從分散式檔案系統或物件儲存中讀取輸入。
- 為流處理器提供恰好一次語義——也就是說,即使實際發生了故障,也要確保輸出與從未發生故障時相同。與批處理一樣,這要求丟棄所有失敗任務的部分輸出。
- 為流處理器提供 *恰好一次語義**exactly-once semantics*——也就是說,即使實際發生了故障,也要確保輸出與從未發生故障時相同。與批處理一樣,這要求丟棄所有失敗任務的部分輸出。
- 提供按事件時間而非處理時間劃分視窗的工具因為重新處理歷史事件時處理時間沒有任何意義。例如Apache Beam 提供了表達這類計算的 API之後可以用 Apache Flink 或 Google Cloud Dataflow 來執行。
@@ -191,7 +191,7 @@ Unix 與關聯式資料庫採用截然不同的哲學來處理資訊管理問題
聯合資料庫:統一讀取
: 可以為各種底層儲存引擎和處理方法提供統一的查詢介面——這種方法稱為 *聯合資料庫* *多模儲存* [^17]、[^18]。例如PostgreSQL 的 *外部資料封裝器* 功能符合這種模式Trino、Hoptimator 和 Xorq 等聯合查詢引擎也是如此。需要專用資料模型或查詢介面的應用仍可直接訪問底層儲存引擎;而希望組合不同位置資料的使用者,則可以透過聯合介面輕鬆完成。
: 可以為各種底層儲存引擎和處理方法提供統一的查詢介面——這種方法稱為 *聯合資料庫**federated database**多模儲存**polystore*[^17]、[^18]。例如PostgreSQL 的 *外部資料封裝器**foreign data wrapper*功能符合這種模式Trino、Hoptimator 和 Xorq 等聯合查詢引擎也是如此。需要專用資料模型或查詢介面的應用仍可直接訪問底層儲存引擎;而希望組合不同位置資料的使用者,則可以透過聯合介面輕鬆完成。
聯合查詢介面延續了關係模型的傳統:提供帶有高層查詢語言和優雅語義的單一整合系統,但其實現非常複雜。
@@ -209,7 +209,7 @@ Unix 與關聯式資料庫採用截然不同的哲學來處理資訊管理問題
例如,一些流處理器內部使用分散式事務來實現恰好一次語義,而且效果可以很好。然而,如果一項事務需要涉及由不同團隊編寫的系統(例如從流處理器把資料寫入分散式鍵值儲存或搜尋索引),缺少標準化事務協議就會使整合難上加難。帶有冪等消費者的有序事件日誌是一種簡單得多的抽象,因此更有可能跨異構系統實現 [^5]。
基於日誌的整合有一項巨大優勢:各個元件之間 *鬆散耦合*。這種優勢體現在兩個方面:
基於日誌的整合有一項巨大優勢:各個元件之間 *鬆散耦合**loose coupling*。這種優勢體現在兩個方面:
1. 在系統層面,非同步事件流使整個系統更能抵禦個別元件中斷或效能下降。如果某個消費者速度很慢或發生故障,事件日誌可以緩衝訊息,讓生產者和其他消費者不受影響地繼續執行。故障消費者修復後可以追趕進度,因此不會漏掉任何資料,而故障也被限制在區域性。相比之下,分散式事務中的同步互動往往會把區域性故障升級為大規模失效。
@@ -264,7 +264,7 @@ Unix 與關聯式資料庫採用截然不同的哲學來處理資訊管理問題
在這種典型的 Web 應用模型中,資料庫充當一種可以透過網路同步訪問的可變共享變數。應用可以讀取和更新這個變數,資料庫則負責將它持久儲存,並提供一定的併發控制與容錯能力。
然而,在大多數程式語言中,你無法訂閱可變變數的變化——只能定期讀取它。與電子表格不同,變數的值發生變化時,讀取者不會收到通知。(你可以在自己的程式碼中實現這種通知,這稱為 *觀察者模式*,但大多數語言都沒有把這種模式作為內建功能。)
然而,在大多數程式語言中,你無法訂閱可變變數的變化——只能定期讀取它。與電子表格不同,變數的值發生變化時,讀取者不會收到通知。(你可以在自己的程式碼中實現這種通知,這稱為 *觀察者模式**observer pattern*但大多數語言都沒有把這種模式作為內建功能。)
資料庫繼承了這種對待可變資料的被動方式:如果想知道資料庫內容是否發生變化,你通常只能輪詢(即定期重複查詢)。訂閱變更才剛剛開始成為資料庫的一項功能。
@@ -306,11 +306,11 @@ Unix 與關聯式資料庫採用截然不同的哲學來處理資訊管理問題
### 觀察衍生狀態 {#sec_future_observing}
從抽象層面看,上一節討論的資料流系統提供了一套建立並持續更新衍生資料集(例如搜尋索引、物化檢視和預測模型)的過程。我們把這個過程稱為 *寫路徑*:每當有資訊寫入系統,它可能經過多輪批處理和流處理,最終所有衍生資料集都會更新,納入這次寫入的資料。{{< xref fig="13-1" page="/ch13" anchor="fig_future_write_read_paths" >}}圖 13-1{{< /xref >}} 展示了更新搜尋索引的例子。
從抽象層面看,上一節討論的資料流系統提供了一套建立並持續更新衍生資料集(例如搜尋索引、物化檢視和預測模型)的過程。我們把這個過程稱為 *寫路徑**write path*:每當有資訊寫入系統,它可能經過多輪批處理和流處理,最終所有衍生資料集都會更新,納入這次寫入的資料。{{< xref fig="13-1" page="/ch13" anchor="fig_future_write_read_paths" >}}圖 13-1{{< /xref >}} 展示了更新搜尋索引的例子。
{{< fig num="13-1" id="fig_future_write_read_paths" src="/fig/ddia_1301.png" caption="在搜尋索引中,寫入(文件更新)與讀取(查詢)相遇。" class="ddia-figure ddia-figure--wide" width="2658" height="1199" />}}
但你最初為什麼要建立衍生資料集?很可能是為了日後查詢。這就是 *讀路徑*:處理使用者請求時,從衍生資料集中讀取資料,也許再對結果做些處理,最後構造返回給使用者的響應。
但你最初為什麼要建立衍生資料集?很可能是為了日後查詢。這就是 *讀路徑**read path*:處理使用者請求時,從衍生資料集中讀取資料,也許再對結果做些處理,最後構造返回給使用者的響應。
寫路徑和讀路徑合在一起,涵蓋了資料的完整旅程:從收集資料的地方,直至資料被消費的地方(很可能由另一個人消費)。寫路徑是預先計算的那一段——也就是資料一到達便立即完成,不管有沒有人要求檢視。讀路徑則只在有人請求時才發生。如果你熟悉函數語言程式設計語言,或許會發現寫路徑類似於立即求值,讀路徑則類似於惰性求值。
@@ -324,7 +324,7 @@ Unix 與關聯式資料庫採用截然不同的哲學來處理資訊管理問題
反過來,也可以設想預先計算所有可能查詢的搜尋結果。這樣一來,讀路徑的工作就少了:無需進行布林邏輯計算,只要找到相應查詢的結果並返回即可。然而,寫路徑會昂貴得多:可能提出的搜尋查詢集合是無限的(或者至少隨語料庫中的詞項數量呈指數增長),因此不可能預先計算所有搜尋結果。
另一種選擇是,只為一組固定的最常見查詢預先計算搜尋結果,使這些查詢無需訪問索引便能迅速得到響應;不常見的查詢仍由索引處理。這通常稱為常見查詢的 *快取*,不過也可以稱為物化檢視:一旦出現應當納入某項常見查詢結果的新文件,它就必須隨之更新。
另一種選擇是,只為一組固定的最常見查詢預先計算搜尋結果,使這些查詢無需訪問索引便能迅速得到響應;不常見的查詢仍由索引處理。這通常稱為常見查詢的 *快取**cache*,不過也可以稱為物化檢視:一旦出現應當納入某項常見查詢結果的新文件,它就必須隨之更新。
這個例子說明,索引並不是寫路徑與讀路徑之間唯一可能的邊界。既可以快取常見搜尋結果;文件數量較少時,也可以不用索引,進行類似 `grep` 的掃描。從這個角度看,快取、索引與物化檢視的作用很簡單:它們移動了讀路徑與寫路徑之間的邊界。我們透過預先計算結果,讓寫路徑多做一些工作,從而節省讀路徑的開銷。
@@ -475,7 +475,7 @@ COMMIT;
#### 端到端原則 {#sec_future_e2e_argument}
抑制重複事務的情形,只是一個更普遍原則的例子。這個原則稱為 *端到端原則*,由 Saltzer、Reed 和 Clark 於 1984 年提出 [^44]
抑制重複事務的情形,只是一個更普遍原則的例子。這個原則稱為 *端到端原則**end-to-end argument*,由 Saltzer、Reed 和 Clark 於 1984 年提出 [^44]
> 只有藉助位於通訊系統兩端的應用所掌握的知識與提供的協助,所討論的功能才能得到完整、正確的實現。因此,不可能把這一功能作為通訊系統本身的一項功能來提供。(有時,通訊系統提供的不完整版本可以用來提升效能。)
@@ -567,21 +567,21 @@ COMMIT;
### 及時性與完整性 {#sec_future_integrity}
許多事務系統都有一項便利的性質:一個事務提交後,其寫入立刻對其他事務可見。這項性質的形式化名稱是 *嚴格可序列化*(參見 [“線性一致性與可序列化”](/tw/ch10#sidebar_consistency_serializability))。
許多事務系統都有一項便利的性質:一個事務提交後,其寫入立刻對其他事務可見。這項性質的形式化名稱是 *嚴格可序列化**strict serializability*參見 [“線性一致性與可序列化”](/tw/ch10#sidebar_consistency_serializability))。
把一項操作分拆為流處理器的多個階段後,情況卻並非如此:日誌消費者在設計上就是非同步的,所以傳送者不會等待消費者處理完自己的訊息。不過,客戶端仍可以等待某條訊息出現在輸出流上。例如,{{< xref fig="13-2" page="/ch13" anchor="fig_future_multi_shard" >}}圖 13-2{{< /xref >}} 中的使用者可以等待出賬事件或付款被拒事件,這取決於源賬戶中是否有足夠資金。
在這個例子中,檢查源賬戶餘額是否正確,並不取決於發出請求的使用者是否等待結果。等待只是為了同步告知使用者付款是否成功,這項通知與處理請求產生的效果彼此解耦。
更一般地說,*一致性* 這個術語混合了兩種不同的需求,而它們值得分開考慮:
更一般地說,*一致性**consistency*這個術語混合了兩種不同的需求,而它們值得分開考慮:
及時性
及時性timeliness
: 及時性是指確保使用者觀察到系統的最新狀態。前面看到,如果使用者從陳舊的資料副本中讀取,可能觀察到不一致的系統狀態(參見 [“複製延遲的問題”](/tw/ch6#sec_replication_lag))。不過,這種不一致只是暫時的,只需等待並重試,最終便會消失。
CAP 定理中的一致性指線性一致性,這是實現及時性的一種強保證。*寫後讀一致性* 等較弱的及時性屬性同樣有用。
CAP 定理中的一致性指線性一致性,這是實現及時性的一種強保證。*寫後讀一致性**read-after-write consistency*等較弱的及時性屬性同樣有用。
完整性
完整性integrity
: 完整性是指沒有損壞:既不丟失資料,也沒有相互矛盾或虛假的資料。尤其是,如果某個衍生資料集作為底層資料之上的檢視來維護,衍生過程必須正確。例如,資料庫索引必須準確反映資料庫內容——漏掉某些記錄的索引沒什麼用。
@@ -599,7 +599,7 @@ ACID 事務通常同時提供及時性保證(例如線性一致性)和完整
另一方面,本章討論的基於事件的資料流系統有一項有趣性質:它們把及時性與完整性解耦了。非同步處理事件流時,除非明確構建消費者,讓它等到訊息到達後才返回,否則就沒有及時性保證。例如,使用者可以請求一筆付款,隨後在流處理器執行該請求之前讀取自己的賬戶狀態;此時,使用者看不到剛剛請求的付款。
然而,完整性事實上是流式系統的核心。*恰好一次* 或 *等效一次* 語義就是維護完整性的一種機制。事件丟失或生效兩次,都可能破壞資料系統的完整性。因此,面對故障時,容錯訊息傳遞與重複抑制(例如冪等操作)是維護資料系統完整性的關鍵。
然而,完整性事實上是流式系統的核心。*恰好一次**exactly-once*)或 *等效一次**effectively-once*語義就是維護完整性的一種機制。事件丟失或生效兩次,都可能破壞資料系統的完整性。因此,面對故障時,容錯訊息傳遞與重複抑制(例如冪等操作)是維護資料系統完整性的關鍵。
正如上一節所見,可靠的流處理系統無需分散式事務與原子提交協議也能保持完整性。這意味著它們有望實現同等程度的正確性,同時獲得好得多的效能與運維穩健性。我們透過組合以下機制實現了這種完整性:
@@ -625,7 +625,7 @@ ACID 事務通常同時提供及時性保證(例如線性一致性)和完整
- 在跨組織整合資料的系統中,不一致不可避免,因此必須有修正機制來處理它們。正如 [“批處理用例”](/tw/ch11#sec_batch_output) 所指出的,銀行之間的付款結算就是一個例子。
因此,在許多業務場景中,暫時違反約束、稍後再透過道歉修正,是可以接受的。這種用於糾正錯誤的變更稱為 *補償性事務* [^48]、[^49]。道歉的代價各不相同金錢或聲譽上的代價卻往往很低已經發出的電子郵件無法撤回但可以再發一封郵件更正信用卡不慎扣款兩次可以退回其中一筆代價只是手續費或許再加上一項顧客投訴。ATM 一旦吐出現金,確實無法直接收回;但原則上,如果賬戶已經透支而顧客拒絕還款,可以派催收人員追回欠款。
因此,在許多業務場景中,暫時違反約束、稍後再透過道歉修正,是可以接受的。這種用於糾正錯誤的變更稱為 *補償性事務**compensating transaction*[^48]、[^49]。道歉的代價各不相同金錢或聲譽上的代價卻往往很低已經發出的電子郵件無法撤回但可以再發一封郵件更正信用卡不慎扣款兩次可以退回其中一筆代價只是手續費或許再加上一項顧客投訴。ATM 一旦吐出現金,確實無法直接收回;但原則上,如果賬戶已經透支而顧客拒絕還款,可以派催收人員追回欠款。
道歉的代價能否接受,是一項業務決策。如果能夠接受,那麼“寫入資料前先檢查全部約束”的傳統模型就限制過多。完全可以先樂觀地執行寫入,再事後檢查約束。對於那些一旦出錯便很難挽回的事情,仍可以確保在執行前完成驗證;但這並不意味著,連資料寫入之前也必須先做驗證。
@@ -639,7 +639,7 @@ ACID 事務通常同時提供及時性保證(例如線性一致性)和完整
2. 儘管嚴格的唯一性約束需要及時性與協調,許多應用其實可以接受寬鬆約束:只要完整性始終得到維護,約束可以暫時遭到違反,稍後再修復。
把這兩點結合起來就意味著:資料流系統無需協調,便可為許多應用提供資料管理服務,同時仍給出強有力的完整性保證。這種 *避免協調* 的資料系統極具吸引力:與需要同步協調的系統相比,它們能獲得更好的效能與容錯能力 [^45]。
把這兩點結合起來就意味著:資料流系統無需協調,便可為許多應用提供資料管理服務,同時仍給出強有力的完整性保證。這種 *避免協調**coordination-avoiding*的資料系統極具吸引力:與需要同步協調的系統相比,它們能獲得更好的效能與容錯能力 [^45]。
例如,這類系統可以採用多主配置,分佈在多個資料中心,並在區域之間非同步複製。任何一個資料中心都能獨立於其他資料中心繼續執行,因為不需要跨區域同步協調。這樣的系統只提供較弱的及時性保證——不引入協調就不可能實現線性一致性——卻仍能提供強有力的完整性保證。
@@ -649,7 +649,7 @@ ACID 事務通常同時提供及時性保證(例如線性一致性)和完整
### 信任但驗證 {#sec_future_verification}
前面關於正確性、完整性與容錯的所有討論,都建立在一組假設之上:某些事情可能出錯,另一些事情不會。我們把這些假設稱為 *系統模型*(參見 [“系統模型與現實”](/tw/ch9#sec_distributed_system_model))。例如,我們應當假設程序可能崩潰、機器可能突然斷電、網路可能任意延遲或丟棄訊息;但也可能假設,寫入磁碟的資料經過 `fsync` 後不會丟失、記憶體中的資料不會損壞、CPU 的乘法指令總能返回正確結果。
前面關於正確性、完整性與容錯的所有討論,都建立在一組假設之上:某些事情可能出錯,另一些事情不會。我們把這些假設稱為 *系統模型**system model*參見 [“系統模型與現實”](/tw/ch9#sec_distributed_system_model))。例如,我們應當假設程序可能崩潰、機器可能突然斷電、網路可能任意延遲或丟棄訊息;但也可能假設,寫入磁碟的資料經過 `fsync` 後不會丟失、記憶體中的資料不會損壞、CPU 的乘法指令總能返回正確結果。
這些假設相當合理,因為絕大多數時候它們都成立;如果必須時刻擔心計算機會算錯,我們將寸步難行。傳統系統模型以二元方式看待故障:假設有些事情可能發生,另一些事情絕不可能發生。現實卻更像是機率問題:有些事情更常見,有些事情較少見。真正的問題是,違反假設的情況是否頻繁到我們會在實踐中遇見。
@@ -667,7 +667,7 @@ ACID 意義上的一致性,建立在這樣一種想法之上:資料庫從一
#### 不要盲信承諾 {#id364}
硬體和軟體都不總能達到理想狀態,因此資料損壞遲早似乎不可避免。至少,我們應該有辦法發現資料已經損壞,從而修復它,並努力追查錯誤來源。檢查資料完整性的過程稱為 *審計*
硬體和軟體都不總能達到理想狀態,因此資料損壞遲早似乎不可避免。至少,我們應該有辦法發現資料已經損壞,從而修復它,並努力追查錯誤來源。檢查資料完整性的過程稱為 *審計**auditing*
正如 [“不可變事件的優點”](/tw/ch12#sec_stream_immutability_pros) 所述,審計並不只適用於財務應用。不過,可審計性在金融領域格外重要,恰恰因為人人都知道錯誤難免發生,也都認可能夠發現並修復問題的必要性。
@@ -675,7 +675,7 @@ ACID 意義上的一致性,建立在這樣一種想法之上:資料庫從一
如果想確認資料仍然存在,就必須真正讀取並檢查。絕大多數時候資料依然完好;但萬一不是,你肯定希望越早發現越好。同理,不時嘗試從備份恢復也很重要——否則,你可能直到資料已經丟失、為時已晚,才發現備份根本無法使用。不要盲信一切都在正常工作。
HDFS 與 S3 仍然必須假設磁碟在絕大多數時候能夠正確工作——這個假設很合理,卻不同於假設磁碟 *始終* 正確工作。然而,目前採用這種“信任,但要驗證”方式持續自我審計的系統並不多。許多系統假定正確性保證是絕對的,完全沒有為罕見的資料損壞預作安排。未來,我們或許會看到更多 *自我驗證**自我審計* 系統:它們不斷檢查自身完整性,而不是依賴盲目信任 [^54]。
HDFS 與 S3 仍然必須假設磁碟在絕大多數時候能夠正確工作——這個假設很合理,卻不同於假設磁碟 *始終* 正確工作。然而,目前採用這種“信任,但要驗證”方式持續自我審計的系統並不多。許多系統假定正確性保證是絕對的,完全沒有為罕見的資料損壞預作安排。未來,我們或許會看到更多 *自我驗證**self-validating*)或 *自我審計**self-auditing*系統:它們不斷檢查自身完整性,而不是依賴盲目信任 [^54]。
#### 為可審計性而設計 {#id365}
@@ -683,7 +683,7 @@ HDFS 與 S3 仍然必須假設磁碟在絕大多數時候能夠正確工作—
相比之下,基於事件的系統可以提供更好的可審計性。在事件溯源方法中,系統的使用者輸入被表示為一條不可變事件,由此產生的所有狀態更新都衍生自這條事件。衍生過程可以做到確定性與可重複性,因此,用同一版本的衍生程式碼處理同一份事件日誌,便會得到相同的狀態更新。
明確表示資料流,能讓 *資料溯源* 清晰得多,從而使完整性檢查更切實可行。對於事件日誌,可以用雜湊檢查事件儲存是否遭到損壞;對於任何衍生狀態,可以重新執行當初從事件日誌衍生它的批處理器與流處理器,檢查是否得到同樣結果,甚至還可以並行執行一條冗餘的衍生流程。
明確表示資料流,能讓 *資料溯源**data provenance*清晰得多,從而使完整性檢查更切實可行。對於事件日誌,可以用雜湊檢查事件儲存是否遭到損壞;對於任何衍生狀態,可以重新執行當初從事件日誌衍生它的批處理器與流處理器,檢查是否得到同樣結果,甚至還可以並行執行一條冗餘的衍生流程。
具有確定性且定義明確的資料流,也讓系統執行過程更容易除錯和追蹤,從而查明系統 *為什麼* 做了某件事 [^4]、[^55]。如果發生意外,能夠重現導致意外事件的確切情境將極有價值——這是一種時間旅行式除錯能力。

View File

@@ -27,11 +27,11 @@ breadcrumbs: false
## 預測分析 {#id369}
例如,預測分析正是人們熱衷於大資料和 AI 的主要原因之一。用資料分析預測天氣或疾病傳播是一回事 [^8];預測一名已定罪者是否可能再犯、貸款申請人是否可能違約,或保險客戶是否可能提出高額索賠,則是另一回事 [^9]。後一類預測會直接影響個人的生活。
例如,*預測分析**predictive analytics*正是人們熱衷於大資料和 AI 的主要原因之一。用資料分析預測天氣或疾病傳播是一回事 [^8];預測一名已定罪者是否可能再犯、貸款申請人是否可能違約,或保險客戶是否可能提出高額索賠,則是另一回事 [^9]。後一類預測會直接影響個人的生活。
支付網路當然希望阻止欺詐交易,銀行希望避免不良貸款,航空公司希望避免劫機,企業也希望避免僱到能力不足或不可信賴的人。從它們的角度看,錯失商機的代價不大,不良貸款或問題員工造成的損失卻高得多,因此組織自然希望謹慎行事。拿不準時,拒絕總比答應穩妥。
然而,隨著演算法決策越來越普遍,被某種演算法標為高風險的人——無論判斷正確與否——可能接連遭到這樣的拒絕。一個人若被系統性地排除在就業、航空出行、保險、房屋租賃、金融服務以及社會生活的其他關鍵領域之外,其自由會受到極大限制,以至於這種處境被稱為“演算法監獄” [^10]。在尊重人權的國家,刑事司法制度堅持無罪推定;自動化系統卻可能在沒有任何罪證、幾乎無從申訴的情況下,系統性地、任意地剝奪一個人參與社會的機會。
然而,隨著演算法決策越來越普遍,被某種演算法標為高風險的人——無論判斷正確與否——可能接連遭到這樣的拒絕。一個人若被系統性地排除在就業、航空出行、保險、房屋租賃、金融服務以及社會生活的其他關鍵領域之外,其自由會受到極大限制,以至於這種處境被稱為“*演算法監獄**algorithmic prison*”[^10]。在尊重人權的國家,刑事司法制度堅持無罪推定;自動化系統卻可能在沒有任何罪證、幾乎無從申訴的情況下,系統性地、任意地剝奪一個人參與社會的機會。
### 偏見與歧視 {#id370}
@@ -39,13 +39,13 @@ breadcrumbs: false
開發預測分析和 AI 系統時,我們不只是用軟體規定何時同意、何時拒絕,把人的決策自動化;甚至連規則本身也交給系統從資料中推斷。然而,這些系統學到的模式並不透明:即使資料中確實存在某種相關性,我們也未必知道原因。如果演算法的輸入帶有系統性偏見,系統很可能會學到這種偏見,並在輸出中將其放大 [^12]。
許多國家的反歧視法禁止根據族裔、年齡、性別、性取向、殘障或信仰等受保護特徵區別對待他人。個人資料中的其他特徵或許可以分析,但如果它們與受保護特徵相關,又該怎麼辦?例如,在種族隔離的社群中,一個人的郵政編碼,甚至 IP 地址,都可以有力地預測其種族。如此看來,相信演算法能夠以帶有偏見的資料為輸入,卻產出公平公正的結果,實在荒謬 [^13]、[^14]。然而,資料驅動決策的支持者似乎經常預設這種信念;有人諷刺這種態度說,“機器學習就像為偏見洗錢” [^15]。
許多國家的 *反歧視法**anti-discrimination law*)禁止根據族裔、年齡、性別、性取向、殘障或信仰等 *受保護特徵**protected characteristic*區別對待他人。個人資料中的其他特徵或許可以分析,但如果它們與受保護特徵相關,又該怎麼辦?例如,在種族隔離的社群中,一個人的郵政編碼,甚至 IP 地址,都可以有力地預測其種族。如此看來,相信演算法能夠以帶有偏見的資料為輸入,卻產出公平公正的結果,實在荒謬 [^13]、[^14]。然而,資料驅動決策的支持者似乎經常預設這種信念;有人諷刺這種態度說,“機器學習就像為偏見洗錢” [^15]。
預測分析系統不過是在外推過去;如果過去充滿歧視,它們就會固化並放大這種歧視 [^16]。要讓未來比過去更好,需要道德想象力,而這隻有人類才能提供 [^17]。資料與模型應該是我們的工具,而不是我們的主人。
### 責任與問責 {#id371}
自動化決策引出了責任與問責問題 [^17]。如果人犯了錯,可以追究其責任,受決定影響的人也可以申訴。演算法同樣會犯錯,但出了問題,誰來負責 [^18]?自動駕駛汽車造成事故,責任由誰承擔?自動信用評分演算法若系統性地歧視某一種族或宗教的人,他們有沒有救濟途徑?如果機器學習系統作出的決定受到司法審查,你能否向法官解釋演算法是怎樣得出這一決定的?任何人都不應把責任推給演算法,藉此逃避問責。
自動化決策引出了 *責任**responsibility*)與 *問責**accountability*問題 [^17]。如果人犯了錯,可以追究其責任,受決定影響的人也可以申訴。演算法同樣會犯錯,但出了問題,誰來負責 [^18]?自動駕駛汽車造成事故,責任由誰承擔?自動信用評分演算法若系統性地歧視某一種族或宗教的人,他們有沒有救濟途徑?如果機器學習系統作出的決定受到司法審查,你能否向法官解釋演算法是怎樣得出這一決定的?任何人都不應把責任推給演算法,藉此逃避問責。
信用評級機構是收集個人資料並據此作出決定的一個早期例子。糟糕的信用評分會給生活帶來困難,但至少信用分通常基於當事人實際借貸歷史中的相關事實,記錄有誤時也能更正——儘管評級機構一般不會讓更正變得容易。相比之下,基於機器學習的評分演算法通常使用範圍廣得多的輸入資料,而且更加不透明,因而很難看清某項決定是如何得出的,也很難判斷某個人是否受到了不公平或歧視性對待 [^19]。
@@ -61,11 +61,11 @@ breadcrumbs: false
即便是推薦系統這類對人們生活的影響沒有那麼直接、深遠的預測應用,也有一些難題必須正視。當服務越來越善於預測使用者想看什麼時,最終可能只向人們展示他們已經認同的觀點,形成滋生刻板印象、錯誤資訊與社會極化的迴音室。我們已經看到了社交媒體迴音室對競選活動的影響。
當預測分析開始左右人們的生活時,自我強化的反饋迴圈會造成尤其惡劣的問題。例如,假設僱主用信用分評估求職者。你原本工作能力很強,信用記錄也很好,卻因一場自己無力控制的變故突然陷入財務困境。幾次賬單逾期之後,信用分隨之下降,找到工作的機會也越來越少。失業又把你推向貧困,進一步拉低評分,讓工作變得更加難找 [^17]。這是有毒假設造成的惡性迴圈,卻披著數學嚴謹性與資料客觀性的外衣。
當預測分析開始左右人們的生活時,自我強化的 *反饋迴圈**feedback loop*會造成尤其惡劣的問題。例如,假設僱主用信用分評估求職者。你原本工作能力很強,信用記錄也很好,卻因一場自己無力控制的變故突然陷入財務困境。幾次賬單逾期之後,信用分隨之下降,找到工作的機會也越來越少。失業又把你推向貧困,進一步拉低評分,讓工作變得更加難找 [^17]。這是有毒假設造成的惡性迴圈,卻披著數學嚴謹性與資料客觀性的外衣。
另一個反饋迴圈的例子是:經濟學家發現,德國的加油站引入演算法定價後,市場競爭反而減弱,消費者支付的價格隨之上漲,因為演算法學會了合謀 [^21]。
我們無法總是預見這種反饋迴圈何時出現。不過,只要思考整個系統——不僅包括計算機化的部分,也包括與之互動的人——許多後果仍然可以預判。這種方法稱為 *系統思維* [^22]。我們可以嘗試理解,資料分析系統會如何響應不同的行為、結構或特徵。它會鞏固並放大人與人之間既有的差異,例如讓富者愈富、貧者愈貧,還是會努力消除不公?而且,即使懷有最好的初衷,也必須提防意料之外的後果。
我們無法總是預見這種反饋迴圈何時出現。不過,只要思考整個系統——不僅包括計算機化的部分,也包括與之互動的人——許多後果仍然可以預判。這種方法稱為 *系統思維**systems thinking*[^22]。我們可以嘗試理解,資料分析系統會如何響應不同的行為、結構或特徵。它會鞏固並放大人與人之間既有的差異,例如讓富者愈富、貧者愈貧,還是會努力消除不公?而且,即使懷有最好的初衷,也必須提防意料之外的後果。
## 隱私與追蹤 {#id373}
@@ -77,7 +77,7 @@ breadcrumbs: false
然而,根據公司的商業模式,追蹤往往不會止步於此。如果一項服務靠廣告維持,廣告主才是真正的客戶,使用者的利益便退居其次。追蹤的資料越來越細,分析觸及的範圍越來越廣,資料也會長期保留,以便為營銷目的建立每個人的詳細畫像。
此時,公司與被收集資料的使用者之間,便呈現出一種截然不同的關係。使用者得到免費服務,又被誘導儘量多地參與其中;追蹤使用者主要不是為了服務這個人,而是為了滿足出資維持服務的廣告主的需求。用一個含義更陰暗的詞來描述這種關係再合適不過:*監視*。
此時,公司與被收集資料的使用者之間,便呈現出一種截然不同的關係。使用者得到免費服務,又被誘導儘量多地參與其中;追蹤使用者主要不是為了服務這個人,而是為了滿足出資維持服務的廣告主的需求。用一個含義更陰暗的詞來描述這種關係再合適不過:*監視**surveillance*
### 監視 {#id374}
@@ -95,7 +95,7 @@ breadcrumbs: false
### 同意與選擇自由 {#id375}
我們或許會說,使用者自願選擇使用追蹤其活動的服務,也接受了服務條款與隱私政策,因此已經同意收集資料。我們甚至可以聲稱,使用者用自己提供的資料換取了有價值的服務,而追蹤是提供服務所必需的。毫無疑問,社交網路、搜尋引擎以及其他各種免費線上服務的確對使用者很有價值——但這種說法存在問題。
我們或許會說,使用者自願選擇使用追蹤其活動的服務,也接受了服務條款與隱私政策,因此已經 *同意**consent*收集資料。我們甚至可以聲稱,使用者用自己提供的資料換取了有價值的服務,而追蹤是提供服務所必需的。毫無疑問,社交網路、搜尋引擎以及其他各種免費線上服務的確對使用者很有價值——但這種說法存在問題。
首先應該問清楚,追蹤究竟在哪種意義上不可或缺。有些追蹤的確直接用於改進面向使用者的功能:例如,追蹤搜尋結果的點選率,可以提升搜尋引擎的結果排名與相關性;追蹤顧客經常一起購買的商品,可以幫助網店推薦相關產品。然而,如果追蹤使用者互動是為了推薦內容,或是為廣告建立使用者畫像,就很難說這是否真正符合使用者的利益——還是因為廣告在為服務買單,追蹤才變得“必要”?
@@ -103,7 +103,7 @@ breadcrumbs: false
而且,從使用者身上抽取資料是一個單向過程,既不是真正互惠的關係,也不是公平的價值交換。雙方沒有對話,使用者也不能就提供多少資料、換取什麼服務進行協商:服務與使用者之間的關係高度不對稱,完全是一邊倒的。條件由服務制定,而不是由使用者決定 [^30]、[^31]。
歐盟《通用資料保護條例》GDPR要求同意必須是“自願作出、具體、知情且明確無誤的”使用者還必須能夠“拒絕或撤回同意而不受不利影響”否則就不能算“自願作出”。任何徵求同意的請求都必須“採用易於理解、便於獲取的形式並使用清晰明白的語言”。此外“沉默、預先勾選的選框或無行動均不構成同意” [^32]。同意並不是合法處理個人資料的唯一依據;例如,*合法利益* 也允許出於防範欺詐等目的使用某些資料 [^33]。
歐盟《通用資料保護條例》GDPR要求同意必須是“自願作出、具體、知情且明確無誤的”使用者還必須能夠“拒絕或撤回同意而不受不利影響”否則就不能算“自願作出”。任何徵求同意的請求都必須“採用易於理解、便於獲取的形式並使用清晰明白的語言”。此外“沉默、預先勾選的選框或無行動均不構成同意” [^32]。同意並不是合法處理個人資料的唯一依據;例如,*合法利益**legitimate interest*也允許出於防範欺詐等目的使用某些資料 [^33]。
你也許會說,不願接受監視的使用者只要選擇不使用這項服務即可。但這種選擇同樣算不上自由:如果一項服務普及到“被大多數人視為參與基本社會生活所必需” [^30],就不能合理地要求人們退出這項服務——使用它實際上已成為強制要求。例如,在多數西方社會中,隨身攜帶智慧手機、透過社交網路與人交往、使用 Google 查詢資訊,都已經成為常態。尤其是在一項服務具有網路效應時,選擇 ** 使用它需要付出社會代價。
@@ -111,7 +111,7 @@ breadcrumbs: false
### 隱私與資料使用 {#id457}
有時人們聲稱“隱私已死”,理由是一些使用者願意把生活中的種種事情釋出到社交媒體上,其中有日常瑣事,也有極其私密的內容。然而,這種說法是錯誤的,源於對 *隱私* 一詞的誤解。
有時人們聲稱“隱私已死”,理由是一些使用者願意把生活中的種種事情釋出到社交媒體上,其中有日常瑣事,也有極其私密的內容。然而,這種說法是錯誤的,源於對 *隱私**privacy*一詞的誤解。
擁有隱私並不意味著把一切都藏起來,而是有權自由選擇向誰透露什麼、公開什麼、保密什麼。隱私權是一種決定權:在每一種情境中,它都讓每個人自行決定,要站在從保密到透明這條光譜的什麼位置 [^30]。這是個人自由與自主的重要組成部分。
@@ -163,7 +163,7 @@ breadcrumbs: false
資料保護法或許能夠幫助維護個人權利。例如,歐盟 GDPR 規定,個人資料必須“為特定、明確且合法的目的而收集,不得以與這些目的不相容的方式作進一步處理”;而且,資料必須“相對於處理目的而言充分、相關,並僅限於必要範圍” [^32]。
然而,*資料最小化* 原則與大資料的哲學針鋒相對。大資料追求儘可能多地收集資料將其與其他資料集組合透過實驗和探索獲得新的洞見。探索意味著把資料用於未曾預料的目的這恰好與收集資料時必須宣告的“特定、明確”目的相反。GDPR 雖然給線上廣告行業帶來了一些影響 [^45],執行力度卻一直很弱 [^46],而且似乎沒有促使整個科技行業的文化與實踐發生多大改變。
然而,*資料最小化**data minimization*原則與大資料的哲學針鋒相對。大資料追求儘可能多地收集資料將其與其他資料集組合透過實驗和探索獲得新的洞見。探索意味著把資料用於未曾預料的目的這恰好與收集資料時必須宣告的“特定、明確”目的相反。GDPR 雖然給線上廣告行業帶來了一些影響 [^45],執行力度卻一直很弱 [^46],而且似乎沒有促使整個科技行業的文化與實踐發生多大改變。
收集大量個人資料的公司反對監管,認為它會增加負擔、妨礙創新。這種反對在一定程度上不無道理。例如,共享醫療資料顯然會帶來隱私風險,卻也蘊藏著機會:如果資料分析能幫助我們改進診斷、找到更好的治療方法,可以挽救多少生命 [^47]?監管過度也許會阻礙這類突破。要在潛在機會與風險之間找到平衡並不容易 [^41]。

View File

@@ -22,9 +22,9 @@ breadcrumbs: false
安全性等許多非功能性需求超出了本書的範圍。不過,本章會討論其中幾項,並幫助你準確表述自己的系統需要達到什麼要求:
* 如何定義和衡量系統的 *效能*(參見[“描述效能”](/tw/ch2#sec_introduction_percentiles)
* 服務 *可靠* 意味著什麼——也就是即使出了問題,仍能繼續正確工作(參見[“可靠性與容錯”](/tw/ch2#sec_introduction_reliability)
* 隨著系統負載增長,能否高效增加計算能力,使系統具備 *可伸縮性*(參見[“可伸縮性”](/tw/ch2#sec_introduction_scalability));以及
* 如何定義和衡量系統的 *效能**performance*參見[“描述效能”](/tw/ch2#sec_introduction_percentiles)
* 服務 *可靠**reliable*意味著什麼——也就是即使出了問題,仍能繼續正確工作(參見[“可靠性與容錯”](/tw/ch2#sec_introduction_reliability)
* 隨著系統負載增長,能否高效增加計算能力,使系統具備 *可伸縮性**scalability*參見[“可伸縮性”](/tw/ch2#sec_introduction_scalability));以及
* 如何讓系統在長期使用中更易維護(參見[“可維護性”](/tw/ch2#sec_introduction_maintainability))。
後續章節深入討論資料密集型系統的實現細節時,還會用到本章引入的術語。不過,抽象定義讀起來難免枯燥。為了讓這些概念更加具體,我們先從一個社交網路服務的實現案例講起,以此說明效能與可伸縮性在實踐中意味著什麼。
@@ -82,15 +82,15 @@ SELECT posts.*, users.* FROM posts
討論軟體效能時,通常會考慮兩類主要指標:
響應時間
響應時間response time
: 從使用者發出請求到收到所需響應所經過的時間。計量單位是秒(或毫秒、微秒)。
吞吐量
: 系統每秒處理的請求數或資料量。對於給定的硬體資源,系統能處理的吞吐量存在上限,也就是 *最大吞吐量*。計量單位通常寫成“每秒多少個……”。
吞吐量throughput
: 系統每秒處理的請求數或資料量。對於給定的硬體資源,系統能處理的吞吐量存在上限,也就是 *最大吞吐量**maximum throughput*。計量單位通常寫成“每秒多少個……”。
在社交網路案例中,“每秒帖子數”和“每秒時間線寫入數”是吞吐量指標;“載入首頁時間線所需的時間”和“帖子送達關注者所需的時間”則是響應時間指標。
吞吐量和響應時間之間往往存在聯絡,{{< xref fig="2-3" page="/ch2" anchor="fig_throughput" >}}圖 2-3{{< /xref >}}勾勒了線上服務中二者的一種典型關係。請求吞吐量較低時,服務的響應時間也很短;隨著負載增大,響應時間隨之上升。這是 *排隊* 造成的當請求到達負載很高的系統時CPU 很可能正在處理先前的請求,新來的請求只好等到前一個處理完畢。當吞吐量逐漸逼近硬體的處理極限時,排隊延遲會急劇增加。
吞吐量和響應時間之間往往存在聯絡,{{< xref fig="2-3" page="/ch2" anchor="fig_throughput" >}}圖 2-3{{< /xref >}}勾勒了線上服務中二者的一種典型關係。請求吞吐量較低時,服務的響應時間也很短;隨著負載增大,響應時間隨之上升。這是 *排隊**queueing*造成的當請求到達負載很高的系統時CPU 很可能正在處理先前的請求,新來的請求只好等到前一個處理完畢。當吞吐量逐漸逼近硬體的處理極限時,排隊延遲會急劇增加。
{{< fig num="2-3" id="fig_throughput" src="/fig/ddia_0203.png" caption="當服務的吞吐量接近其處理能力上限時,排隊會使響應時間急劇增加。" class="ddia-figure ddia-figure--wide" width="2953" height="1018" />}}
@@ -101,7 +101,7 @@ SELECT posts.*, users.* FROM posts
>
> 當系統瀕臨過載、吞吐量已被推到極限附近時,有時會陷入惡性迴圈:系統效率越來越低,因而變得更加過載。例如,等待處理的請求排起長隊,響應時間可能因此增長到客戶端超時並重發請求。請求速率隨之進一步上升,讓問題愈演愈烈——這就是 *重試風暴retry storm*。即使負載隨後下降,系統也可能一直停留在過載狀態,直到重啟或以其他方式重置。這種現象稱為 *亞穩態故障metastable failure*,它可能導致生產系統嚴重中斷 [^7] [^8]。
>
> 為了避免重試壓垮服務,可以在客戶端逐漸延長並隨機擾動連續重試之間的等待時間(*指數退避* [^9] [^10]還可以暫時停止向最近曾返回錯誤或發生超時的服務傳送請求(採用 *熔斷器* [^11] [^12] 或 *令牌桶* 演算法 [^13]。伺服器也可以在察覺自己接近過載時主動拒絕請求(*負載卸除* [^14]),並在響應中要求客戶端降低傳送速率(*背壓* [^1] [^15])。排隊演算法和負載均衡演算法的選擇同樣會產生影響 [^16]。
> 為了避免重試壓垮服務,可以在客戶端逐漸延長並隨機擾動連續重試之間的等待時間(*指數退避**exponential backoff* [^9] [^10]還可以採用 *熔斷器**circuit breaker*[^11] [^12] 或 *令牌桶**token bucket*)演算法 [^13]暫時停止向最近曾返回錯誤或發生超時的服務傳送請求。伺服器也可以在察覺自己接近過載時主動拒絕請求(*負載卸除**load shedding* [^14]),並在響應中要求客戶端降低傳送速率(*背壓**backpressure* [^1] [^15])。排隊演算法和負載均衡演算法的選擇同樣會產生影響 [^16]。
在各項效能指標中,使用者通常最關心響應時間;吞吐量則決定了所需的計算資源(例如伺服器數量),從而決定處理特定工作負載的成本。如果吞吐量可能增長到超出當前硬體的處理能力,就需要擴充容量。如果增加計算資源能夠顯著提高系統的最大吞吐量,我們就稱這個系統具有 *可伸縮性scalability*
@@ -111,10 +111,10 @@ SELECT posts.*, users.* FROM posts
“延遲”和“響應時間”有時會被混為一談,但本書將按下面的特定含義使用這幾個術語(如{{< xref fig="2-4" page="/ch2" anchor="fig_response_time" >}}圖 2-4{{< /xref >}}所示):
* *響應時間* 是客戶端看到的時間,其中包括系統各處產生的全部延誤。
* *服務時間* 是服務真正用於處理使用者請求的時間。
* *排隊延遲* 可能發生在流程中的多個位置。例如,請求到達後,也許必須等到 CPU 空閒才能開始處理;如果同一臺機器上的其他任務正在透過出站網路介面傳送大量資料,響應資料包也可能先在緩衝區中等待。
* *延遲* 泛指請求沒有得到實際處理的時間,也就是請求處於 *潛伏latent* 狀態的時間。具體來說,*網路延遲**網路時延* 是請求和響應在網路中傳輸所花的時間。
* *響應時間**response time*是客戶端看到的時間,其中包括系統各處產生的全部延誤。
* *服務時間**service time*是服務真正用於處理使用者請求的時間。
* *排隊延遲**queueing delay*可能發生在流程中的多個位置。例如,請求到達後,也許必須等到 CPU 空閒才能開始處理;如果同一臺機器上的其他任務正在透過出站網路介面傳送大量資料,響應資料包也可能先在緩衝區中等待。
* *延遲**latency*泛指請求沒有得到實際處理的時間,也就是請求處於 *潛伏latent* 狀態的時間。具體來說,*網路延遲**network latency*)或 *網路時延**network delay*是請求和響應在網路中傳輸所花的時間。
{{< fig num="2-4" id="fig_response_time" src="/fig/ddia_0204.png" caption="響應時間、服務時間、網路延遲和排隊延遲。" class="ddia-figure ddia-figure--wide" width="2953" height="1018" />}}
@@ -126,13 +126,13 @@ SELECT posts.*, users.* FROM posts
### 平均值、中位數與分位數 {#id24}
由於每次請求的響應時間都不一樣,我們不能只把它看成一個數字,而應將其視為一組可測量數值的 *分佈distribution*。在{{< xref fig="2-5" page="/ch2" anchor="fig_lognormal" >}}圖 2-5{{< /xref >}}中,每根灰色柱條代表一次服務請求,柱條的高度表示這次請求所花的時間。大多數請求都相當快,但偶爾會出現耗時長得多的 *異常值*。網路延遲的變化也稱為 *抖動jitter*
由於每次請求的響應時間都不一樣,我們不能只把它看成一個數字,而應將其視為一組可測量數值的 *分佈distribution*。在{{< xref fig="2-5" page="/ch2" anchor="fig_lognormal" >}}圖 2-5{{< /xref >}}中,每根灰色柱條代表一次服務請求,柱條的高度表示這次請求所花的時間。大多數請求都相當快,但偶爾會出現耗時長得多的 *異常值**outlier*。網路延遲的變化也稱為 *抖動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]。不過,如果你想知道“典型”的響應時間,平均值就不是很好的指標,因為它沒有告訴你究竟有多少使用者實際經歷了這樣的等待。
服務通常會報告 *平均**mean*)響應時間,也就是 *算術平均值**arithmetic mean*:把所有響應時間相加,再除以請求數。平均響應時間有助於估算吞吐量的上限 [^18]。不過,如果你想知道“典型”的響應時間,平均值就不是很好的指標,因為它沒有告訴你究竟有多少使用者實際經歷了這樣的等待。
通常,採用 *分位數percentile* 更合適。將響應時間從快到慢排列,*中位數* 就是位於正中間的值。例如,如果響應時間的中位數是 200 毫秒,就意味著一半請求用時不到 200 毫秒,另一半則需要更長時間。因此,如果想知道使用者通常要等多久,中位數是個很好的指標。中位數也稱為 *第 50 分位數*,有時縮寫為 *p50*
通常,採用 *分位數percentile* 更合適。將響應時間從快到慢排列,*中位數**median*就是位於正中間的值。例如,如果響應時間的中位數是 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 >}}對此作了說明。
@@ -243,9 +243,9 @@ SELECT posts.*, users.* FROM posts
軟體系統由人設計和構建,維持系統執行的運維人員同樣也是人。與機器不同,人類不只是照章行事;他們的長處正是能夠發揮創造力、隨機應變,把工作完成。不過,這一特點也會帶來不可預測性:即使出發點再好,人也會犯錯,有時還會導致系統失效。例如,一項針對大型網際網路服務的研究發現,運維人員修改配置是服務中斷的首要原因,而硬體故障(伺服器或網路)只在 10%25% 的中斷中起了作用 [^70]。
人們很容易把這類問題歸結為“人為錯誤”,並幻想透過更嚴格的流程和更嚴密的規則來約束人的行為,從而解決問題。然而,把錯誤歸咎於個人往往適得其反。所謂“人為錯誤”其實並不是事故的根本原因,而是人與技術共同構成的 *社會技術系統* 出了問題的一種症狀;身處其中的人只是在竭盡所能地完成工作 [^71]。複雜系統也常常表現出湧現行為,元件之間出人意料的互動同樣可能導致失效 [^72]。
人們很容易把這類問題歸結為“人為錯誤”,並幻想透過更嚴格的流程和更嚴密的規則來約束人的行為,從而解決問題。然而,把錯誤歸咎於個人往往適得其反。所謂“人為錯誤”其實並不是事故的根本原因,而是人與技術共同構成的 *社會技術系統**sociotechnical system*出了問題的一種症狀;身處其中的人只是在竭盡所能地完成工作 [^71]。複雜系統也常常表現出湧現行為,元件之間出人意料的互動同樣可能導致失效 [^72]。
多種技術手段都能減小人為失誤的影響,包括:徹底測試(既包括手寫測試,也包括用大量隨機輸入進行的 *屬性測試*[^38];提供回滾機制,以便迅速撤銷配置變更;逐步釋出新程式碼;提供詳細而清晰的監控,以及用於診斷生產問題的可觀測性工具(參見[“分散式系統的問題”](/tw/ch1#sec_introduction_dist_sys_problems));精心設計介面,使“做正確的事”更加容易,“做錯誤的事”更加困難。
多種技術手段都能減小人為失誤的影響,包括:徹底測試(既包括手寫測試,也包括用大量隨機輸入進行的 *屬性測試**property-based testing*[^38];提供回滾機制,以便迅速撤銷配置變更;逐步釋出新程式碼;提供詳細而清晰的監控,以及用於診斷生產問題的可觀測性工具(參見[“分散式系統的問題”](/tw/ch1#sec_introduction_dist_sys_problems));精心設計介面,使“做正確的事”更加容易,“做錯誤的事”更加困難。
不過,這些措施都要投入時間和金錢。在日常經營的現實壓力下,組織往往優先考慮能夠創造收入的工作,而不是提高自身抵禦失誤能力的措施。如果必須在開發更多功能和開展更多測試之間選擇,許多組織選擇功能也不難理解。既然作出了這樣的選擇,當本可避免的錯誤不可避免地發生時,再去責怪犯錯的人便毫無道理——真正的問題在於組織如何設定優先順序。
@@ -295,7 +295,7 @@ SELECT posts.*, users.* FROM posts
通常,我們的目標是在滿足 SLA 效能要求(參見[“響應時間指標的應用”](/tw/ch2#sec_introduction_slo_sla))的同時,儘可能降低系統的執行成本。所需的計算資源越多,成本就越高。某些硬體也許比另一些更具價效比,而隨著新型硬體出現,這些因素也會隨時間變化。
如果資源增加一倍,就能在效能不變的情況下處理兩倍負載,我們稱系統具備 *線性可伸縮性*,這通常是一件好事。偶爾,由於規模經濟或峰值負載分佈得更加均勻,不到兩倍的資源也能處理兩倍的負載 [^79] [^80]。更常見的情況是,成本增長得比線性更快,造成這種低效的原因可能有很多。例如,系統擁有大量資料時,即使寫入請求本身大小相同,處理一次寫入所需的工作也可能多於資料量較小時。
如果資源增加一倍,就能在效能不變的情況下處理兩倍負載,我們稱系統具備 *線性可伸縮性**linear scalability*,這通常是一件好事。偶爾,由於規模經濟或峰值負載分佈得更加均勻,不到兩倍的資源也能處理兩倍的負載 [^79] [^80]。更常見的情況是,成本增長得比線性更快,造成這種低效的原因可能有很多。例如,系統擁有大量資料時,即使寫入請求本身大小相同,處理一次寫入所需的工作也可能多於資料量較小時。
### 共享記憶體、共享磁碟與無共享架構 {#sec_introduction_shared_nothing}
@@ -371,7 +371,7 @@ SELECT posts.*, users.* FROM posts
例如高階程式語言是一種抽象隱藏了機器碼、CPU 暫存器和系統呼叫。SQL 也是一種抽象,隱藏了複雜的磁碟和記憶體資料結構、其他客戶端發出的併發請求,以及崩潰後產生的不一致。當然,使用高階語言程式設計時,我們仍然用到了機器碼;只不過沒有 *直接* 使用它,因為程式語言的抽象讓我們不必考慮這些細節。
為了降低應用程式程式碼的複雜度,可以藉助 *設計模式* [^95] 和 *領域驅動設計DDD* [^96] 等方法來構建抽象。本書討論的不是這類應用專用的抽象,而是資料庫事務、索引和事件日誌等通用抽象;你可以在它們之上構建應用。如果你想採用 DDD 等方法,也可以把它們實現於本書所述的基礎之上。
為了降低應用程式程式碼的複雜度,可以藉助 *設計模式**design pattern*[^95] 和 *領域驅動設計DDD* [^96] 等方法來構建抽象。本書討論的不是這類應用專用的抽象,而是資料庫事務、索引和事件日誌等通用抽象;你可以在它們之上構建應用。如果你想採用 DDD 等方法,也可以把它們實現於本書所述的基礎之上。
### 可演化性:讓變化更容易 {#sec_introduction_evolvability}

View File

@@ -26,35 +26,35 @@ breadcrumbs: false
一個複雜的應用程式可能會有更多的中間層次,比如基於 API 的 API不過基本思想仍然是一樣的每個層都透過提供一個明確的資料模型來隱藏更低層次中的複雜性。這些抽象允許不同的人群有效地協作例如資料庫廠商的工程師和使用資料庫的應用程式開發人員。
實踐中廣泛使用著幾種不同的資料模型,通常各有用途。某些型別的資料和查詢在一種模型中很容易表達,在另一種模型中卻很彆扭。本章將比較關係模型、文件模型、圖資料模型、事件溯源和資料框,探討其中的權衡。我們還將簡要介紹操作這些模型的查詢語言,幫助你判斷何時應該使用哪種模型。
實踐中廣泛使用著幾種不同的資料模型,通常各有用途。某些型別的資料和查詢在一種模型中很容易表達,在另一種模型中卻很彆扭。本章將比較 *關係模型**relational model*)、*文件模型**document model*)、*圖資料模型**graph data model*)、*事件溯源**event sourcing*)和 *資料框**dataframe*,探討其中的權衡。我們還將簡要介紹操作這些模型的查詢語言,幫助你判斷何時應該使用哪種模型。
> [!TIP] 術語:宣告式查詢語言
>
> 本章中的許多查詢語言(如 SQL、Cypher、SPARQL 或 Datalog都是 *宣告式* 的。在宣告式查詢語言中,你只需指定所需資料的模式——結果必須符合哪些條件,以及資料應如何轉換(例如排序、分組和聚合)——而不必說明 *如何* 實現這一目標。資料庫系統的查詢最佳化器決定使用哪些索引和連線演算法,以及以何種順序執行查詢的各個部分。
> 本章中的許多查詢語言(如 SQL、Cypher、SPARQL 或 Datalog都是 *宣告式**declarative*的。在宣告式查詢語言中,你只需指定所需資料的模式——結果必須符合哪些條件,以及資料應如何轉換(例如排序、分組和聚合)——而不必說明 *如何* 實現這一目標。資料庫系統的查詢最佳化器決定使用哪些索引和連線演算法,以及以何種順序執行查詢的各個部分。
>
> 相比之下,使用大多數程式語言時,你必須寫出一套 *演算法*,告訴計算機以特定順序執行哪些操作。宣告式查詢語言通常比顯式演算法更加簡潔,也更容易編寫;但更重要的是,它隱藏了查詢引擎的實現細節,使資料庫系統可以在無須對查詢做任何修改的情況下提升效能 [^1]。
> 相比之下,使用大多數程式語言時,你必須寫出一套 *演算法**algorithm*,告訴計算機以特定順序執行哪些操作。宣告式查詢語言通常比顯式演算法更加簡潔,也更容易編寫;但更重要的是,它隱藏了查詢引擎的實現細節,使資料庫系統可以在無須對查詢做任何修改的情況下提升效能 [^1]。
>
> 例如,資料庫或許能跨多個 CPU 核心和多臺機器並行執行一條宣告式查詢,而你無須操心如何實現這種並行 [^2]。若是手寫演算法,自行實現這種並行執行將費不少功夫。
## 關係模型與文件模型 {#sec_datamodels_history}
如今最廣為人知的資料模型或許是 SQL 所採用的關係模型,它由 Edgar Codd 於 1970 年提出 [^3]:資料被組織成 *關係*SQL 稱之為 **),每個關係都是由 *元組*SQL 稱之為 **)構成的無序集合。
如今最廣為人知的資料模型或許是 SQL 所採用的關係模型,它由 Edgar Codd 於 1970 年提出 [^3]:資料被組織成 *關係**relation*SQL 稱之為 ***table*),每個關係都是由 *元組**tuple*SQL 稱之為 ***row*)構成的無序集合。
關係模型最初只是一項理論提議,當時許多人懷疑它能否得到高效實現。然而到了 20 世紀 80 年代中期對於大多數需要儲存和查詢具有某種規則結構的資料的人來說關聯式資料庫管理系統RDBMS和 SQL 已成為首選工具。幾十年過去,關係資料仍主導著許多資料管理場景,例如商業分析(參見 [“星型與雪花型:分析模式”](/tw/ch3#sec_datamodels_analytics))。
多年來資料儲存和查詢領域湧現過許多彼此競爭的方法。20 世紀 70 年代至 80 年代初,*網狀模型**層次模型* 是關係模型的主要對手,但最終都敗下陣來。物件資料庫在 20 世紀 80 年代末至 90 年代初興起後又銷聲匿跡XML 資料庫於 21 世紀初出現,卻始終只在少數場景中得到採用。關係模型的每個競爭者都曾盛極一時,但無一長久 [^4]。反倒是 SQL 在關係模型這個核心之上不斷吸收其他資料型別,例如增加了對 XML、JSON 和圖資料的支援 [^5]。
多年來資料儲存和查詢領域湧現過許多彼此競爭的方法。20 世紀 70 年代至 80 年代初,*網狀模型**network model*)和 *層次模型**hierarchical model*是關係模型的主要對手,但最終都敗下陣來。物件資料庫在 20 世紀 80 年代末至 90 年代初興起後又銷聲匿跡XML 資料庫於 21 世紀初出現,卻始終只在少數場景中得到採用。關係模型的每個競爭者都曾盛極一時,但無一長久 [^4]。反倒是 SQL 在關係模型這個核心之上不斷吸收其他資料型別,例如增加了對 XML、JSON 和圖資料的支援 [^5]。
到了 2010 年代,*NoSQL* 成了試圖撼動關聯式資料庫統治地位的最新流行語。NoSQL 並非某項特定技術,而是圍繞新資料模型、模式靈活性、可伸縮性和開源許可模式形成的一組寬泛理念。另一些資料庫則以 *NewSQL* 自居,力圖在保留傳統關聯式資料庫的資料模型和事務保證的同時,提供 NoSQL 系統的可伸縮性。NoSQL 和 NewSQL 的理念深刻影響了資料系統的設計;不過,隨著這些原則被廣泛吸收,兩個術語本身已漸漸淡出。
NoSQL 運動留下的一項持久影響,是通常以 JSON 表示資料的 *文件模型* 廣受歡迎。這個模型最初由 MongoDB、Couchbase 等專用文件資料庫推廣,如今大多數關聯式資料庫也已加入 JSON 支援。關係表的模式常被視為嚴格而僵化相比之下JSON 文件被認為更加靈活。
NoSQL 運動留下的一項持久影響,是通常以 JSON 表示資料的 *文件模型**document model*廣受歡迎。這個模型最初由 MongoDB、Couchbase 等專用文件資料庫推廣,如今大多數關聯式資料庫也已加入 JSON 支援。關係表的模式常被視為嚴格而僵化相比之下JSON 文件被認為更加靈活。
文件資料與關係資料孰優孰劣,已經引發過大量爭論。下面來看看其中幾個關鍵問題。
### 物件關係不匹配 {#sec_datamodels_document}
如今,大量應用開發使用物件導向的程式語言,這也引出了針對 SQL 資料模型的一項常見批評:資料若儲存在關係表中,就需要一個笨拙的轉換層,在應用程式碼中的物件與資料庫的表、行、列模型之間來回轉換。兩種模型之間的這種脫節,有時稱為 *阻抗不匹配*
如今,大量應用開發使用物件導向的程式語言,這也引出了針對 SQL 資料模型的一項常見批評:資料若儲存在關係表中,就需要一個笨拙的轉換層,在應用程式碼中的物件與資料庫的表、行、列模型之間來回轉換。兩種模型之間的這種脫節,有時稱為 *阻抗不匹配**impedance mismatch*
> [!NOTE]
@@ -69,7 +69,7 @@ ActiveRecord、Hibernate 等物件關係對映ORM框架減少了轉換層
* ORM 一般只用於開發 OLTP 應用(參見 [“事務處理與分析的特徵”](/tw/ch1#sec_introduction_oltp))。為了讓資料可供分析,資料工程師仍須面對底層的關係表示,因此採用 ORM 並不意味著關係模式的設計不再重要。
* 許多 ORM 只面向關係型 OLTP 資料庫。若組織還使用搜尋引擎、圖資料庫、NoSQL 系統等多種資料系統ORM 提供的支援可能遠遠不夠。
* 有些 ORM 會自動生成關係模式,但生成的模式對直接訪問關係資料的使用者未必友好,在底層資料庫上也可能效率不佳。要定製 ORM 生成模式與查詢的方式,往往相當複雜,甚至會抵消採用 ORM 原本想獲得的好處。
* 使用 ORM 很容易在無意中寫出低效查詢,例如觸發 *N+1 查詢問題* [^7]。假設你要在頁面上顯示使用者評論列表:先用一條查詢取回 *N* 條評論,每條都含有作者 ID為了顯示作者姓名還要用這個 ID 查詢使用者表。手寫 SQL 時,你大概會直接在查詢中連線使用者表,讓每條評論連同作者姓名一起返回;使用 ORM 時,卻可能對 *N* 條評論逐條查詢使用者表,最終一共執行 *N*+1 條資料庫查詢。這比在資料庫內完成連線要慢得多。為避免這個問題,你可能必須明確要求 ORM 在獲取評論的同時一併取回作者資訊。
* 使用 ORM 很容易在無意中寫出低效查詢,例如觸發 *N+1 查詢問題**N+1 query problem*[^7]。假設你要在頁面上顯示使用者評論列表:先用一條查詢取回 *N* 條評論,每條都含有作者 ID為了顯示作者姓名還要用這個 ID 查詢使用者表。手寫 SQL 時,你大概會直接在查詢中連線使用者表,讓每條評論連同作者姓名一起返回;使用 ORM 時,卻可能對 *N* 條評論逐條查詢使用者表,最終一共執行 *N*+1 條資料庫查詢。這比在資料庫內完成連線要慢得多。為避免這個問題,你可能必須明確要求 ORM 在獲取評論的同時一併取回作者資訊。
不過ORM 也自有其優勢:
@@ -81,7 +81,7 @@ ActiveRecord、Hibernate 等物件關係對映ORM框架減少了轉換層
並非所有資料都適合用關係形式表示。下面用一個例子看看關係模型的侷限。{{< 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 >}} 所示。
大多數人的職業生涯中都不止有一份工作(即多個職位),每個人的教育經歷數量也不相同,聯絡方式更可能有任意多項。這些 *一對多關係**one-to-many relationship*可以這樣表示:把職位、教育經歷和聯絡資訊分別放在單獨的表中,再透過外來鍵引用 `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" />}}
@@ -122,7 +122,7 @@ ActiveRecord、Hibernate 等物件關係對映ORM框架減少了轉換層
> [!NOTE]
> 這種關係有時稱為 *一對少*,而非 *一對多*,因為一份簡歷通常只有少數幾個職位 [^9] [^10]。如果相關專案確實可能多到驚人——例如名人的社交媒體帖子可能收到成千上萬條評論——把它們全部嵌進同一個文件就太過笨重,此時更適合採用 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}圖 3-1{{< /xref >}} 所示的關係方法。
> 這種關係有時稱為 *一對少**one-to-few*,而非 *一對多*,因為一份簡歷通常只有少數幾個職位 [^9] [^10]。如果相關專案確實可能多到驚人——例如名人的社交媒體帖子可能收到成千上萬條評論——把它們全部嵌進同一個文件就太過笨重,此時更適合採用 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}圖 3-1{{< /xref >}} 所示的關係方法。
### 正規化、反正規化與連線 {#sec_datamodels_normalization}
@@ -137,11 +137,11 @@ ActiveRecord、Hibernate 等物件關係對映ORM框架減少了轉換層
* 支援本地化——網站翻譯成其他語言時,可以本地化這份標準列表,讓地區名稱以瀏覽者使用的語言顯示
* 改善搜尋——例如,地區列表可以記錄華盛頓位於美國東海岸這一事實(單看 `"Washington, DC"` 字串無法得知),於是搜尋美國東海岸的人時也能匹配這份資料
選擇儲存 ID 還是文字字串,實質上是在決定是否 *正規化*。使用 ID 時,資料更加正規化:對人有意義的資訊(如 *Washington, DC* 這段文字)只儲存一份,其他地方都用僅在資料庫內有意義的 ID 來引用它。若直接儲存文字,這段有意義的資訊就會複製到每條使用它的記錄中;這樣的表示便是 *反正規化* 的。
選擇儲存 ID 還是文字字串,實質上是在決定是否 *正規化**normalization*。使用 ID 時,資料更加正規化:對人有意義的資訊(如 *Washington, DC* 這段文字)只儲存一份,其他地方都用僅在資料庫內有意義的 ID 來引用它。若直接儲存文字,這段有意義的資訊就會複製到每條使用它的記錄中;這樣的表示便是 *反正規化**denormalized*的。
ID 的好處在於,它本身對人沒有意義,因而永遠不必改變:即使 ID 所標識的資訊發生了變化ID 仍可保持不變。凡是對人有意義的資訊,將來都有可能需要修改;一旦這類資訊被複制,所有冗餘副本就都得隨之更新。這不僅需要更多程式碼、寫入操作和磁碟空間,還會帶來不一致的風險——有些副本已經更新,另一些卻沒有。
正規化表示也有代價:每次顯示含有 ID 的記錄時,都要多做一次查詢,把 ID 解析成人能讀懂的資訊。在關係資料模型中,這項工作透過 *連線* 完成,例如:
正規化表示也有代價:每次顯示含有 ID 的記錄時,都要多做一次查詢,把 ID 解析成人能讀懂的資訊。在關係資料模型中,這項工作透過 *連線**join*完成,例如:
```sql
SELECT users.*, regions.region_name
@@ -204,9 +204,9 @@ SELECT posts.id, posts.sender_id
### 多對一與多對多關係 {#sec_datamodels_many_to_many}
{{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}圖 3-1{{< /xref >}} 中的 `positions` 和 `education` 是一對多(或一對少)關係:一份簡歷有多個職位,但每個職位只屬於一份簡歷。相比之下,`region_id` 欄位表示 *多對一* 關係:許多人住在同一個地區,而我們假設任一時刻每個人只住在一個地區。
{{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}圖 3-1{{< /xref >}} 中的 `positions` 和 `education` 是一對多(或一對少)關係:一份簡歷有多個職位,但每個職位只屬於一份簡歷。相比之下,`region_id` 欄位表示 *多對一**many-to-one*關係:許多人住在同一個地區,而我們假設任一時刻每個人只住在一個地區。
如果進一步把組織和學校建模為實體,讓簡歷透過 ID 引用它們,就會出現 *多對多* 關係:一個人曾在多個組織任職,一個組織也有多名現任或前任員工。在關係模型中,這類關係通常用 *關聯表*也稱 *連線表*)表示,如 {{< xref fig="3-3" page="/ch3" anchor="fig_datamodels_m2m_rel" >}}圖 3-3{{< /xref >}} 所示:每個職位把一個使用者 ID 與一個組織 ID 關聯起來。
如果進一步把組織和學校建模為實體,讓簡歷透過 ID 引用它們,就會出現 *多對多**many-to-many*關係:一個人曾在多個組織任職,一個組織也有多名現任或前任員工。在關係模型中,這類關係通常用 *關聯表**associative table*,也稱 *連線表**join table*)表示,如 {{< 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" />}}
@@ -231,21 +231,21 @@ SELECT posts.id, posts.sender_id
多對多關係通常需要從“兩個方向”查詢:既要找出某人任職過的所有組織,也要找出曾在某組織任職的所有人。一種做法是在關係兩端都儲存 ID 引用:簡歷列出此人任職過的各個組織 ID組織文件也列出提及該組織的簡歷 ID。由於同一關係儲存了兩份這是一種反正規化表示兩邊可能彼此不一致。
正規化表示只在一處儲存關係,再依靠 *二級索引*(將在 [第 4 章](/tw/ch4#ch_storage) 討論)從兩個方向高效查詢。{{< xref fig="3-3" page="/ch3" anchor="fig_datamodels_m2m_rel" >}}圖 3-3{{< /xref >}} 的關係模式中,可以讓資料庫分別為 `positions` 表的 `user_id` 列和 `org_id` 列建立索引。
正規化表示只在一處儲存關係,再依靠 *二級索引**secondary index*將在 [第 4 章](/tw/ch4#ch_storage) 討論)從兩個方向高效查詢。{{< xref fig="3-3" page="/ch3" anchor="fig_datamodels_m2m_rel" >}}圖 3-3{{< /xref >}} 的關係模式中,可以讓資料庫分別為 `positions` 表的 `user_id` 列和 `org_id` 列建立索引。
在 {{< xref eg="3-2" page="/ch3" anchor="fig_datamodels_m2m_json" >}}示例 3-2{{< /xref >}} 的文件模型中,資料庫則需要索引 `positions` 陣列內各物件的 `org_id` 欄位。許多文件資料庫以及支援 JSON 的關聯式資料庫,都能為文件內部的值建立這種索引。
### 星型與雪花型:分析模式 {#sec_datamodels_analytics}
資料倉儲(參見 [“資料倉儲”](/tw/ch1#sec_introduction_dwh))通常採用關係模型,其表結構有幾種廣泛使用的慣例:*星型模式*、*雪花模式*、*維度建模* [^12],以及 *一張大表*OBT。這些結構針對業務分析師的需求進行了最佳化ETL 過程則負責把事務型系統中的資料轉換成這種模式。
資料倉儲(參見 [“資料倉儲”](/tw/ch1#sec_introduction_dwh))通常採用關係模型,其表結構有幾種廣泛使用的慣例:*星型模式**star schema*)、*雪花模式**snowflake schema*)、*維度建模**dimensional modeling*[^12],以及 *一張大表*OBT。這些結構針對業務分析師的需求進行了最佳化ETL 過程則負責把事務型系統中的資料轉換成這種模式。
{{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}圖 3-5{{< /xref >}} 中的示例模式,可能出現在一家食品零售商的資料倉儲中。模式的中心是所謂的 *事實表*(本例中名為 `fact_sales`)。事實表的每一行代表在特定時間發生的事件;在這裡,每行代表客戶購買了一件產品。如果分析的是網站流量而不是零售量,那麼每行可能代表一次頁面瀏覽或一次使用者點選。
{{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}圖 3-5{{< /xref >}} 中的示例模式,可能出現在一家食品零售商的資料倉儲中。模式的中心是所謂的 *事實表**fact table*本例中名為 `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 的交易歷史,其中大部分都以事實表表示。
事實表中的一些列是屬性,例如產品的售價和從供應商處購入的成本(據此可以計算利潤率)。另一些列是指向其他表的外來鍵引用,這些表稱為 *維度表*。由於事實表的每一行表示一個事件,各個維度便代表事件發生的物件、內容、地點、時間、方式和原因。
事實表中的一些列是屬性,例如產品的售價和從供應商處購入的成本(據此可以計算利潤率)。另一些列是指向其他表的外來鍵引用,這些表稱為 *維度表**dimension table*。由於事實表的每一行表示一個事件,各個維度便代表事件發生的物件、內容、地點、時間、方式和原因。
例如,{{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}圖 3-5{{< /xref >}} 中的一個維度是售出的產品。`dim_product` 表中的每一行代表一種待售產品包括庫存單位SKU、產品描述、品牌名稱、類別、脂肪含量、包裝尺寸等。`fact_sales` 表的每一行都用外來鍵表明該筆交易售出了哪種產品。查詢往往要連線多個維度表。
@@ -315,7 +315,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
區域性優勢只適用於同時需要文件絕大部分內容的情況。即使只訪問大型文件的一小部分,資料庫通常也要載入整個文件,這會造成浪費;更新時一般還要重寫整個文件。因此,通常建議讓文件保持較小,並避免頻繁地對文件做小幅更新。
不過為了區域性而把相關資料儲存在一起並非文件模型的專利。例如Google 的 Spanner 資料庫在關係模型中也提供同樣的區域性屬性,允許模式宣告某張表的行應交錯(巢狀)在父表之中 [^25]。Oracle 也用名為 *多表索引叢集表* 的功能提供類似能力 [^26]。由 Google Bigtable 推廣、並被 HBase 和 Accumulo 等系統採用的 *寬列* 資料模型,則以 *列族* 概念達到相似的區域性管理目的 [^27]。
不過為了區域性而把相關資料儲存在一起並非文件模型的專利。例如Google 的 Spanner 資料庫在關係模型中也提供同樣的區域性屬性,允許模式宣告某張表的行應交錯(巢狀)在父表之中 [^25]。Oracle 也用名為 *多表索引叢集表**multi-table index cluster table*的功能提供類似能力 [^26]。由 Google Bigtable 推廣、並被 HBase 和 Accumulo 等系統採用的 *寬列**wide-column*)資料模型,則以 *列族**column family*概念達到相似的區域性管理目的 [^27]。
#### 文件的查詢語言 {#query-languages-for-documents}
@@ -362,7 +362,7 @@ db.observations.aggregate([
> [!NOTE]
> Codd 對關係模型的原始描述 [^3] 實際上允許關係模式中出現類似 JSON 的結構,他稱之為 *非簡單域*。其思想是,一行中的值不一定只是數字或字串之類的原始資料型別,也可以是巢狀的關係(表),因此可以把任意巢狀的樹結構作為一個值。這與三十多年後加入 SQL 的 JSON 或 XML 支援非常相似。
> Codd 對關係模型的原始描述 [^3] 實際上允許關係模式中出現類似 JSON 的結構,他稱之為 *非簡單域**nonsimple domain*。其思想是,一行中的值不一定只是數字或字串之類的原始資料型別,也可以是巢狀的關係(表),因此可以把任意巢狀的樹結構作為一個值。這與三十多年後加入 SQL 的 JSON 或 XML 支援非常相似。
@@ -385,14 +385,14 @@ db.observations.aggregate([
可以把許多眾所周知的演算法運用到這些圖上。例如地圖導航應用會搜尋道路網路中兩點之間的最短路徑PageRank 可以用在網頁圖上,判斷網頁的流行程度,進而決定它在搜尋結果中的排名 [^32]。
圖可以用幾種不同的方式表示。在 *鄰接表* 模型中,每個頂點都儲存與它相隔一條邊的相鄰頂點 ID。另一種方式是 *鄰接矩陣*:這是一個二維陣列,每行、每列各對應一個頂點;行頂點與列頂點之間沒有邊時,值為 0有邊時則為 1。鄰接表適合圖遍歷鄰接矩陣則適合機器學習參見 [“資料框、矩陣與陣列”](/tw/ch3#sec_datamodels_dataframes))。
圖可以用幾種不同的方式表示。在 *鄰接表**adjacency list*模型中,每個頂點都儲存與它相隔一條邊的相鄰頂點 ID。另一種方式是 *鄰接矩陣**adjacency matrix*:這是一個二維陣列,每行、每列各對應一個頂點;行頂點與列頂點之間沒有邊時,值為 0有邊時則為 1。鄰接表適合圖遍歷鄰接矩陣則適合機器學習參見 [“資料框、矩陣與陣列”](/tw/ch3#sec_datamodels_dataframes))。
在剛才給出的例子中,圖裡的所有頂點都表示同一種事物,分別是人、網頁或道路交叉口。不過,圖並不侷限於這種 *同質* 資料:圖還有一項同樣強大的用途,就是以一致的方式在單個資料庫中儲存截然不同的物件。例如:
在剛才給出的例子中,圖裡的所有頂點都表示同一種事物,分別是人、網頁或道路交叉口。不過,圖並不侷限於這種 *同質**homogeneous*資料:圖還有一項同樣強大的用途,就是以一致的方式在單個資料庫中儲存截然不同的物件。例如:
* Facebook 維護著一個包含許多不同型別頂點和邊的圖:頂點表示人、地點、事件、簽到和使用者評論;邊表示哪些人是朋友、某次簽到發生在哪裡、誰評論了哪篇帖子、誰參加了哪場活動,等等 [^33]。
* 搜尋引擎用知識圖譜來記錄查詢中經常出現的組織、人物、地點等實體的事實 [^34]。這些資訊來自對網站的抓取與文字分析Wikidata 等網站也會以結構化形式釋出圖資料。
有幾種不同但彼此相關的方式,可以用來組織和查詢圖中的資料。本節將討論 *屬性圖* 模型(由 Neo4j、Memgraph、KùzuDB [^35] 等系統實現 [^36])和 *三元組儲存* 模型(由 Datomic、AllegroGraph、Blazegraph 等系統實現。兩種模型的表達能力相當接近Amazon Neptune 等圖資料庫還同時支援二者。
有幾種不同但彼此相關的方式,可以用來組織和查詢圖中的資料。本節將討論 *屬性圖**property graph*模型(由 Neo4j、Memgraph、KùzuDB [^35] 等系統實現 [^36])和 *三元組儲存**triple store*模型(由 Datomic、AllegroGraph、Blazegraph 等系統實現。兩種模型的表達能力相當接近Amazon Neptune 等圖資料庫還同時支援二者。
我們還將介紹四種圖查詢語言Cypher、SPARQL、Datalog 和 GraphQL以及 SQL 對圖查詢的支援。其他圖查詢語言還有 Gremlin 等 [^37],不過這裡選取的幾種已足以給出一幅有代表性的全景。
@@ -402,7 +402,7 @@ db.observations.aggregate([
### 屬性圖 {#id56}
在 *屬性圖*(也稱 *帶標籤屬性圖*)模型中,每個頂點包括:
在 *屬性圖*(也稱 *帶標籤屬性圖**labeled property graph*)模型中,每個頂點包括:
* 唯一識別符號
* 一個標籤(字串),描述該頂點所表示的物件型別
@@ -512,7 +512,7 @@ RETURN person.name
Cypher 用 `:WITHIN*0..` 非常簡潔地表達了這一點:“沿著 `WITHIN` 邊走零次或多次”。它類似於正規表示式中的 `*` 運算子。
從 SQL:1999 開始,可以用所謂的 *遞迴公用表表示式*`WITH RECURSIVE` 語法)在查詢中表示長度可變的遍歷路徑。{{< xref eg="3-6" page="/ch3" anchor="fig_graph_sql_query" >}}示例 3-6{{< /xref >}} 用這種技術在 SQL 中寫出了同一個查詢——查詢從美國移居歐洲者的姓名。只不過,與 Cypher 相比,它的語法十分笨拙。
從 SQL:1999 開始,可以用所謂的 *遞迴公用表表示式**recursive common table expression*`WITH RECURSIVE` 語法)在查詢中表示長度可變的遍歷路徑。{{< xref eg="3-6" page="/ch3" anchor="fig_graph_sql_query" >}}示例 3-6{{< /xref >}} 用這種技術在 SQL 中寫出了同一個查詢——查詢從美國移居歐洲者的姓名。只不過,與 Cypher 相比,它的語法十分笨拙。
{{< eg num="3-6" id="fig_graph_sql_query" caption="使用遞迴公用表表示式,以 SQL 寫出與示例 3-5 相同的查詢" >}}
```sql
@@ -574,7 +574,7 @@ WITH RECURSIVE
同一個查詢用 Cypher 只需 4 行,用 SQL 卻要寫 31 行,這恰恰說明選對資料模型和查詢語言會帶來多大差別。而這還只是開始;還有更多細節需要考慮,例如如何處理環,以及選擇廣度優先還是深度優先遍歷 [^40]。
Oracle 為遞迴查詢提供了另一套 SQL 擴充套件,稱為 *層次查詢* [^41]。
Oracle 為遞迴查詢提供了另一套 SQL 擴充套件,稱為 *層次查詢**hierarchical query*[^41]。
不過,情況可能正在改善:在本書寫作時,已有計劃把一種名為 GQL 的圖查詢語言加入 SQL 標準 [^42] [^43],其語法借鑑了 Cypher、GSQL [^44] 和 PGQL [^45]。
@@ -636,7 +636,7 @@ _:namerica a :Location; :name "North America"; :type "continent".
#### RDF 資料模型 {#the-rdf-data-model}
{{< xref eg="3-8" page="/ch3" anchor="fig_graph_n3_shorthand" >}}示例 3-8{{< /xref >}} 使用的 Turtle 語言,實際上是對 *資源描述框架*RDF資料進行編碼的一種方式 [^55]RDF 是專為語義網設計的資料模型。RDF 資料也可以採用其他編碼,例如用更為冗長的 XML 表示,如 {{< xref eg="3-9" page="/ch3" anchor="fig_graph_rdf_xml" >}}示例 3-9{{< /xref >}} 所示。Apache Jena 等工具可以在不同 RDF 編碼之間自動轉換。
{{< xref eg="3-8" page="/ch3" anchor="fig_graph_n3_shorthand" >}}示例 3-8{{< /xref >}} 使用的 Turtle 語言,實際上是對 *資源描述框架*RDF*Resource Description Framework*)資料進行編碼的一種方式 [^55]RDF 是專為 *語義網**Semantic Web*設計的資料模型。RDF 資料也可以採用其他編碼,例如用更為冗長的 XML 表示,如 {{< xref eg="3-9" page="/ch3" anchor="fig_graph_rdf_xml" >}}示例 3-9{{< /xref >}} 所示。Apache Jena 等工具可以在不同 RDF 編碼之間自動轉換。
{{< eg num="3-9" id="fig_graph_rdf_xml" caption="使用 RDF/XML 語法表示示例 3-8 中的資料" >}}
```xml
@@ -850,17 +850,17 @@ query ChatApp {
我們在 [“權威記錄系統與衍生資料”](/tw/ch1#sec_introduction_derived) 中已經見過這種思路ETL參見 [“資料倉儲”](/tw/ch1#sec_introduction_dwh))就是一種派生過程。現在讓我們再往前走一步。既然無論如何都要由一種資料表示派生出另一種,那就可以分別選用針對寫入和讀取最佳化的表示。如果只需最佳化資料寫入,絲毫不必考慮查詢效率,你會如何對資料建模?
也許,寫入資料最簡單、最快且表意最清楚的方式,就是寫入 *事件日誌*:每次寫入資料時,都將它編碼成一個自包含的字串(也許是 JSON其中帶有時間戳再追加到事件序列中。日誌中的事件是 *不可變的*:你永遠不會修改或刪除它們,只會向日志追加更多事件(後來的事件可以取代早先事件的效力)。事件可以包含任意屬性。
也許,寫入資料最簡單、最快且表意最清楚的方式,就是寫入 *事件日誌**event log*:每次寫入資料時,都將它編碼成一個自包含的字串(也許是 JSON其中帶有時間戳再追加到事件序列中。日誌中的事件是 *不可變的**immutable*:你永遠不會修改或刪除它們,只會向日志追加更多事件(後來的事件可以取代早先事件的效力)。事件可以包含任意屬性。
{{< 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 >}} 中,會議狀態的每次變化(例如組織者開放報名,或參會者報名和取消報名),首先都會被儲存為事件。每當日誌追加一個事件,幾個 *物化檢視*也稱為 *投影* 或 *讀模型*)也會隨之更新,以反映該事件帶來的影響。在這個會議示例中,可以有一個物化檢視彙總每筆預訂狀態的所有相關資訊,另一個計算會議組織者儀表盤所需的圖表,第三個則為製作參會者胸牌的印表機生成檔案。
在 {{< xref fig="3-8" page="/ch3" anchor="fig_event_sourcing" >}}圖 3-8{{< /xref >}} 中,會議狀態的每次變化(例如組織者開放報名,或參會者報名和取消報名),首先都會被儲存為事件。每當日誌追加一個事件,幾個 *物化檢視**materialized view*,也稱為 *投影**projection*,或 *讀模型**read model*)也會隨之更新,以反映該事件帶來的影響。在這個會議示例中,可以有一個物化檢視彙總每筆預訂狀態的所有相關資訊,另一個計算會議組織者儀表盤所需的圖表,第三個則為製作參會者胸牌的印表機生成檔案。
以事件作為權威資料來源,並把每次狀態變化都表達為事件,這種思路稱為 *事件溯源* [^62] [^63]。維護獨立的讀取最佳化表示,並從寫入最佳化的表示中派生它們,這種原則稱為 *命令查詢責任分離CQRS* [^64]。這些術語源自領域驅動設計DDD社群不過類似的思路由來已久例如 *狀態機複製*(參見 [“使用共享日誌”](/tw/ch10#sec_consistency_smr))。
以事件作為權威資料來源,並把每次狀態變化都表達為事件,這種思路稱為 *事件溯源**event sourcing*[^62] [^63]。維護獨立的讀取最佳化表示,並從寫入最佳化的表示中派生它們,這種原則稱為 *命令查詢責任分離**Command Query Responsibility Segregation*CQRS[^64]。這些術語源自領域驅動設計DDD社群不過類似的思路由來已久例如 *狀態機複製**state machine replication*參見 [“使用共享日誌”](/tw/ch10#sec_consistency_smr))。
當來自使用者的請求剛到達時,它還是一個 *命令*,首先需要驗證。只有在命令已經執行且確認有效之後(例如,請求的預訂有足夠的空餘座位),它才會成為事實,相應的事件也才會追加到日誌中。因此,事件日誌中應當只有有效事件;消費事件日誌來構建物化檢視的元件,不允許拒絕事件。
當來自使用者的請求剛到達時,它還是一個 *命令**command*,首先需要驗證。只有在命令已經執行且確認有效之後(例如,請求的預訂有足夠的空餘座位),它才會成為事實,相應的事件也才會追加到日誌中。因此,事件日誌中應當只有有效事件;消費事件日誌來構建物化檢視的元件,不允許拒絕事件。
以事件溯源的方式對資料建模時,建議用過去時來命名事件(例如“座位已預訂”),因為事件記錄的是已經發生的事實。即使使用者後來更改或取消預訂,他們曾經預訂過的事實依然成立;更改或取消是之後另行追加的事件。
@@ -888,28 +888,28 @@ query ChatApp {
## 資料框、矩陣與陣列 {#sec_datamodels_dataframes}
本章迄今介紹的資料模型,通常既用於事務處理,也用於分析(參見 [“分析型與事務型系統”](/tw/ch1#sec_introduction_analytics))。還有一些資料模型常見於分析或科學場景,卻很少出現在 OLTP 系統中:資料框,以及矩陣等多維數值陣列。
本章迄今介紹的資料模型,通常既用於事務處理,也用於分析(參見 [“分析型與事務型系統”](/tw/ch1#sec_introduction_analytics))。還有一些資料模型常見於分析或科學場景,卻很少出現在 OLTP 系統中:*資料框**dataframe*,以及矩陣等多維數值陣列。
R 語言、Python 的 pandas 庫、Apache Spark、ArcticDB 和 Dask 等系統,都支援資料框這種資料模型。資料科學家經常用它為訓練機器學習模型準備資料;它也廣泛用於資料探索、統計分析和資料視覺化等場景。
乍看之下,資料框很像關聯式資料庫中的表,也像電子表格。它支援一系列類似關係運算子的批次操作:例如,對所有行應用某個函式,按條件篩選行,按某些列分組並聚合其他列,以及按某個鍵連線兩個資料框中的行(關聯式資料庫中的 *連線*,在資料框中通常稱為 *合併*)。
乍看之下,資料框很像關聯式資料庫中的表,也像電子表格。它支援一系列類似關係運算子的批次操作:例如,對所有行應用某個函式,按條件篩選行,按某些列分組並聚合其他列,以及按某個鍵連線兩個資料框中的行(關聯式資料庫中的 *連線*,在資料框中通常稱為 *合併**merge*)。
資料框通常不是透過 SQL 之類的宣告式查詢來操作,而是透過一系列命令逐步修改其結構和內容。這恰好符合資料科學家的典型工作流程:一點點地“整理”資料,直至它變成一種適合回答當前問題的形式。這些操作通常在資料科學傢俬有的資料集副本上進行,而且往往就在本機上;不過最終結果也可能會分享給其他使用者。
資料框 API 提供的許多操作遠遠超出關聯式資料庫的能力,其使用方式也往往與典型的關係資料建模大不相同 [^65]。例如,資料框的一種常見用途,是把資料從類似關係模型的表示轉換為矩陣或多維陣列,而許多機器學習演算法期望的輸入正是這種形式。
{{< xref fig="3-9" page="/ch3" anchor="fig_dataframe_to_matrix" >}}圖 3-9{{< /xref >}} 展示了一個簡單的轉換示例。左側是一張關係表記錄不同使用者給各種電影打出的分數1 到 5 分);右側則把這些資料轉換成了矩陣,每一列代表一部電影,每一行代表一位使用者(類似電子表格中的 *資料透視表*)。這個矩陣是 *稀疏* 的,也就是說,很多使用者與電影的組合都沒有資料,但這並不礙事。矩陣可能有成千上萬列,不太適合放在關聯式資料庫中;資料框以及 Python 的 NumPy 等支援稀疏陣列的庫,卻能輕鬆處理這類資料。
{{< xref fig="3-9" page="/ch3" anchor="fig_dataframe_to_matrix" >}}圖 3-9{{< /xref >}} 展示了一個簡單的轉換示例。左側是一張關係表記錄不同使用者給各種電影打出的分數1 到 5 分);右側則把這些資料轉換成了矩陣,每一列代表一部電影,每一行代表一位使用者(類似電子表格中的 *資料透視表**pivot table*)。這個矩陣是 *稀疏**sparse*的,也就是說,很多使用者與電影的組合都沒有資料,但這並不礙事。矩陣可能有成千上萬列,不太適合放在關聯式資料庫中;資料框以及 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。這種表示也很容易推廣到同時屬於多種型別的電影。
* 對於只能從一小組固定值中取值的列(例如電影資料庫中的電影型別),通常採用 *獨熱編碼**one-hot encoding*:為每個可能的值建立一列(“喜劇”一列、“劇情”一列、“恐怖”一列,依此類推);對於代表某部電影的每一行,在對應其型別的列中填 1其餘列填 0。這種表示也很容易推廣到同時屬於多種型別的電影。
資料一旦變成數值矩陣,就適合進行線性代數運算,而線性代數正是許多機器學習演算法的基礎。例如,{{< xref fig="3-9" page="/ch3" anchor="fig_dataframe_to_matrix" >}}圖 3-9{{< /xref >}} 中的資料可以用在向使用者推薦其可能喜歡的電影的系統中。資料框十分靈活,可以讓資料從關係形式逐步演變為矩陣表示,同時讓資料科學家自行掌控哪種表示最適合達成資料分析或模型訓練的目標。
還有一些資料庫專門儲存大型多維數值陣列,例如 TileDB [^66]。這類系統稱為 *陣列資料庫*,最常用於科學資料集,例如地理空間測量資料(規則間隔網格上的柵格資料)、醫學影像或天文望遠鏡的觀測結果 [^67]。金融行業也用資料框表示 *時間序列資料*,例如資產價格以及按時間記錄的交易 [^68]。
還有一些資料庫專門儲存大型多維數值陣列,例如 TileDB [^66]。這類系統稱為 *陣列資料庫**array database*,最常用於科學資料集,例如地理空間測量資料(規則間隔網格上的柵格資料)、醫學影像或天文望遠鏡的觀測結果 [^67]。金融行業也用資料框表示 *時間序列資料**time-series data*,例如資產價格以及按時間記錄的交易 [^68]。
## 總結 {#summary}

View File

@@ -20,9 +20,9 @@ breadcrumbs: false
在 [第 3 章](/tw/ch3#ch_datamodels) 中,我們討論了資料模型和查詢語言,即你將資料交給資料庫時採用的格式,以及日後向資料庫取回資料時使用的介面。在本章中,我們會從資料庫的視角來討論同樣的問題:資料庫如何儲存我們提供的資料,以及如何在我們需要時重新找到資料。
作為應用開發者,為什麼要關心資料庫內部儲存與檢索的機理?你可能不會從頭開始實現自己的儲存引擎,但是你 *確實* 需要從許多可用的儲存引擎中選擇一個適合應用的。為了讓儲存引擎能在你的工作負載上執行良好,你也需要大致瞭解它在底層究竟做了什麼。
作為應用開發者,為什麼要關心資料庫內部儲存與檢索的機理?你可能不會從頭開始實現自己的 *儲存引擎**storage engine*,但是你 *確實* 需要從許多可用的儲存引擎中選擇一個適合應用的。為了讓儲存引擎能在你的工作負載上執行良好,你也需要大致瞭解它在底層究竟做了什麼。
尤其需要注意針對事務型工作負載OLTP最佳化的儲存引擎與針對分析型工作負載最佳化的儲存引擎之間存在巨大差異這種區別已在 [“分析型與事務型系統”](/tw/ch1#sec_introduction_analytics) 中介紹)。本章首先考察 OLTP 儲存引擎的兩大類:寫出不可變資料檔案的 *日誌結構* 儲存引擎,以及像 *B 樹* 這樣就地更新資料的儲存引擎。鍵值儲存和二級索引都可以採用這兩類結構。
尤其需要注意針對事務型工作負載OLTP最佳化的儲存引擎與針對分析型工作負載最佳化的儲存引擎之間存在巨大差異這種區別已在 [“分析型與事務型系統”](/tw/ch1#sec_introduction_analytics) 中介紹)。本章首先考察 OLTP 儲存引擎的兩大類:寫出不可變資料檔案的 *日誌結構**log-structured*儲存引擎,以及像 *B 樹* 這樣就地更新資料的儲存引擎。*鍵值儲存**key-value store*)和 *二級索引**secondary index*都可以採用這兩類結構。
稍後在 [“分析型資料儲存”](/tw/ch4#sec_storage_analytics) 中,我們會討論一類針對分析最佳化的儲存引擎;在 [“多維索引與全文索引”](/tw/ch4#sec_storage_multidimensional) 中,還會簡要介紹用於文字檢索等複雜查詢的索引。
@@ -70,7 +70,7 @@ $ cat database
```
`db_set` 函式對於如此簡單的實現其實有著相當不錯的效能,因為在檔案末尾追加寫入通常非常高效。與 `db_set` 所做的事情類似,許多資料庫在內部使用 *日誌*,也就是僅追加的資料檔案。真正的資料庫還要處理更多問題(例如併發寫入、回收磁碟空間以免日誌無限增長,以及崩潰恢復時處理只寫了一部分的記錄),但基本原理是一樣的。日誌極其有用,我們還會在本書中多次遇到它。
`db_set` 函式對於如此簡單的實現其實有著相當不錯的效能,因為在檔案末尾追加寫入通常非常高效。與 `db_set` 所做的事情類似,許多資料庫在內部使用 *日誌**log*,也就是僅追加的資料檔案。真正的資料庫還要處理更多問題(例如併發寫入、回收磁碟空間以免日誌無限增長,以及崩潰恢復時處理只寫了一部分的記錄),但基本原理是一樣的。日誌極其有用,我們還會在本書中多次遇到它。
> [!NOTE]
@@ -80,7 +80,7 @@ $ cat database
另一方面,如果資料庫中有大量記錄,`db_get` 函式的效能就會非常糟糕。每次查詢一個鍵,`db_get` 都必須從頭到尾掃描整個資料庫檔案,尋找這個鍵。用演算法的語言來說,查詢開銷是 *O*(*n*):如果資料庫中的記錄數 *n* 翻了一倍,查詢時間也要翻一倍。這就不好了。
為了高效查詢資料庫中特定鍵的值,我們需要一種資料結構:*索引*。本章將介紹一系列索引結構,並比較它們之間的差異。索引背後的大致思想,是以某種特定方式組織資料(例如按某個鍵排序),從而更快地定位想要的資料。如果想以幾種不同的方式搜尋同一份資料,那麼也許需要在資料的不同部分建立多個索引。
為了高效查詢資料庫中特定鍵的值,我們需要一種資料結構:*索引**index*。本章將介紹一系列索引結構,並比較它們之間的差異。索引背後的大致思想,是以某種特定方式組織資料(例如按某個鍵排序),從而更快地定位想要的資料。如果想以幾種不同的方式搜尋同一份資料,那麼也許需要在資料的不同部分建立多個索引。
索引是從主資料衍生出的 *額外* 結構。許多資料庫允許新增和刪除索引,這不會影響資料庫的內容,只會影響查詢效能。維護額外結構會產生開銷,特別是在寫入時。寫入效能很難超過簡單地向檔案末尾追加,因為追加是最簡單的寫入操作。任何型別的索引通常都會拖慢寫入速度,因為每次寫入資料時還必須更新索引。
@@ -107,7 +107,7 @@ $ cat database
{{< fig num="4-2" id="fig_storage_sstable_index" src="/fig/ddia_0402.png" caption="帶有稀疏索引的 SSTable查詢可以直接跳到正確的資料塊。" class="ddia-figure ddia-figure--wide" width="2658" height="1229" />}}
這樣便不必在記憶體中保留所有鍵。可以把 SSTable 中的鍵值對分成若干個幾千位元組大小的 **,索引只儲存每個塊的第一個鍵。這種只收錄部分鍵的索引稱為 *稀疏索引*。索引儲存在 SSTable 的一個獨立區域中,可以採用不可變 B 樹、字典樹或其他能快速查詢特定鍵的資料結構 [^4]。
這樣便不必在記憶體中保留所有鍵。可以把 SSTable 中的鍵值對分成若干個幾千位元組大小的 ***block*,索引只儲存每個塊的第一個鍵。這種只收錄部分鍵的索引稱為 *稀疏索引**sparse index*。索引儲存在 SSTable 的一個獨立區域中,可以採用不可變 B 樹、字典樹或其他能快速查詢特定鍵的資料結構 [^4]。
以 {{< xref fig="4-2" page="/ch4" anchor="fig_storage_sstable_index" >}}圖 4-2{{< /xref >}} 為例,一個塊的第一個鍵是 `handbag`,下一個塊的第一個鍵是 `handsome`。假設要查詢沒有出現在稀疏索引中的 `handiwork`。根據排序關係可知,`handiwork` 必定在 `handbag``handsome` 之間。因此,可以尋道至 `handbag` 的偏移量,再從那裡開始掃描檔案,直到找到 `handiwork`;如果一直掃到下一個塊仍未找到,就說明檔案中沒有這個鍵。幾千位元組的資料塊很快就能掃描完。
@@ -120,7 +120,7 @@ SSTable 檔案格式比僅追加日誌更利於讀取,卻讓寫入變得困難
解決辦法是採用 *日誌結構* 方法,將僅追加日誌與排序檔案結合起來:
1. 收到寫入時,將其加入記憶體中的有序對映資料結構,例如紅黑樹、跳錶 [^5] 或字典樹 [^6]。這類資料結構可以按任意順序插入鍵、高效查詢鍵,並按排序順序讀出鍵。這個記憶體資料結構稱為 *記憶體表**memtable*)。
2. 當記憶體表超過某個閾值(通常為幾兆位元組)時,按排序順序將它寫成磁碟上的 SSTable 檔案。這個新的 SSTable 檔案稱為資料庫最新的 **它與較舊的段分別存放在獨立檔案中每個段都有自己的索引。向磁碟寫出新段期間資料庫可以繼續向新的記憶體表例項寫入SSTable 寫完後,舊記憶體表佔用的記憶體即可釋放。
2. 當記憶體表超過某個閾值(通常為幾兆位元組)時,按排序順序將它寫成磁碟上的 SSTable 檔案。這個新的 SSTable 檔案稱為資料庫最新的 ***segment*它與較舊的段分別存放在獨立檔案中每個段都有自己的索引。向磁碟寫出新段期間資料庫可以繼續向新的記憶體表例項寫入SSTable 寫完後,舊記憶體表佔用的記憶體即可釋放。
3. 讀取某個鍵的值時,先在記憶體表和磁碟上最新的段中查詢。如果沒有找到,就依次檢視更舊的段,直到找到這個鍵或查完最舊的段。如果任何段中都沒有這個鍵,它就不存在於資料庫中。
4. 後臺不時執行合併與壓實過程,將段檔案合併起來,並丟棄已經覆蓋或刪除的值。
@@ -182,7 +182,7 @@ LSM 儲存的一項重要設計細節,是何時執行壓實,以及每次壓
> [!TIP] 嵌入式儲存引擎
>
> 許多資料庫以服務形式執行,透過網路接收查詢;但也有一些 *嵌入式* 資料庫並不提供網路 API。它們是與應用程式碼執行在同一程序中的庫通常讀寫本地磁碟上的檔案應用則透過普通函式呼叫與之互動。RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB 都是嵌入式儲存引擎 [^19]。
> 許多資料庫以服務形式執行,透過網路接收查詢;但也有一些 *嵌入式**embedded*資料庫並不提供網路 API。它們是與應用程式碼執行在同一程序中的庫通常讀寫本地磁碟上的檔案應用則透過普通函式呼叫與之互動。RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB 都是嵌入式儲存引擎 [^19]。
>
> 嵌入式資料庫在移動應用中十分常見,可用於儲存本地使用者的資料。在後端,如果資料小到單機足以容納,併發事務又不多,嵌入式資料庫也可能是合適的選擇。例如在多租戶系統中,如果每個租戶的資料量都很小,且彼此完全隔離(即不需要查詢多個租戶的合併資料),就可以考慮為每個租戶分別執行一個嵌入式資料庫例項 [^20]。
>
@@ -196,17 +196,17 @@ B 樹自 1970 年問世 [^21],不到 10 年就被稱為“無處不在”[^22]
與 SSTable 一樣B 樹按鍵儲存有序的鍵值對因而能夠高效地查詢鍵值和執行範圍查詢。但相似之處也到此為止B 樹有著截然不同的設計理念。
前面看到的日誌結構索引把資料庫分成大小可變的 **通常每段為幾兆位元組或更大段只寫入一次此後便不可變。相比之下B 樹把資料庫分成大小固定的 ****,並允許就地覆蓋頁。傳統的頁大小是 4 KiB不過 PostgreSQL 目前預設使用 8 KiBMySQL 預設使用 16 KiB。
前面看到的日誌結構索引把資料庫分成大小可變的 ***segment*通常每段為幾兆位元組或更大段只寫入一次此後便不可變。相比之下B 樹把資料庫分成大小固定的 ****,並允許就地覆蓋頁。傳統的頁大小是 4 KiB不過 PostgreSQL 目前預設使用 8 KiBMySQL 預設使用 16 KiB。
每一頁都有頁號作為標識,因此一頁可以引用另一頁——類似於指標,只不過位於磁碟而非記憶體。如果所有頁都儲存在同一個檔案中,頁號乘以頁大小,就是該頁在檔案中的位元組偏移量。利用這些頁引用可以構造一棵頁組成的樹,如 {{< xref fig="4-5" page="/ch4" anchor="fig_storage_b_tree" >}}圖 4-5{{< /xref >}} 所示。
{{< fig num="4-5" id="fig_storage_b_tree" src="/fig/ddia_0405.png" caption="使用 B 樹索引查詢鍵 251。先從根頁沿引用進入鍵 200300 所在的頁,再進入鍵 250270 所在的頁。" class="ddia-figure ddia-figure--wide" width="2658" height="1472" />}}
其中一頁被指定為 B 樹的 **;在索引中查詢鍵時,總是從這裡開始。根頁包含若干個鍵和對子頁的引用。每個子頁負責一段連續的鍵範圍,引用之間的鍵標示出相鄰範圍的邊界。(這種結構有時稱為 B+ 樹,不過這裡不必把它與其他 B 樹變體區分開來。)
其中一頁被指定為 B 樹的 ***root*;在索引中查詢鍵時,總是從這裡開始。根頁包含若干個鍵和對子頁的引用。每個子頁負責一段連續的鍵範圍,引用之間的鍵標示出相鄰範圍的邊界。(這種結構有時稱為 B+ 樹,不過這裡不必把它與其他 B 樹變體區分開來。)
在 {{< xref fig="4-5" page="/ch4" anchor="fig_storage_b_tree" >}}圖 4-5{{< /xref >}} 的例子中,我們要尋找鍵 251因此沿著邊界 200 與 300 之間的頁引用向下走。接下來的一頁結構相似,只是把 200300 進一步劃分成更小的子範圍。最終會到達包含各個鍵的 *葉頁*;葉頁或者直接儲存每個鍵的值,或者儲存指向值所在頁的引用。
在 {{< xref fig="4-5" page="/ch4" anchor="fig_storage_b_tree" >}}圖 4-5{{< /xref >}} 的例子中,我們要尋找鍵 251因此沿著邊界 200 與 300 之間的頁引用向下走。接下來的一頁結構相似,只是把 200300 進一步劃分成更小的子範圍。最終會到達包含各個鍵的 *葉頁**leaf page*;葉頁或者直接儲存每個鍵的值,或者儲存指向值所在頁的引用。
B 樹一頁中對子頁的引用數稱為 *分支因子*。例如,{{< xref fig="4-5" page="/ch4" anchor="fig_storage_b_tree" >}}圖 4-5{{< /xref >}} 中的分支因子為 6。實踐中的分支因子取決於頁引用和範圍邊界所需的空間不過通常可達幾百。
B 樹一頁中對子頁的引用數稱為 *分支因子**branching factor*。例如,{{< xref fig="4-5" page="/ch4" anchor="fig_storage_b_tree" >}}圖 4-5{{< /xref >}} 中的分支因子為 6。實踐中的分支因子取決於頁引用和範圍邊界所需的空間不過通常可達幾百。
如果要更新 B 樹中已有鍵的值,就先找到包含該鍵的葉頁,再用含有新值的版本覆蓋磁碟上的這一頁。如果要新增新鍵,則找到範圍涵蓋該鍵的頁,並把鍵加入其中。如果頁內沒有足夠的空閒空間容納新鍵,就把它拆成兩個半滿的頁,並更新父頁,以反映鍵範圍的新劃分。
@@ -214,13 +214,13 @@ B 樹一頁中對子頁的引用數稱為 *分支因子*。例如,{{< xref fig
在 {{< xref fig="4-6" page="/ch4" anchor="fig_storage_b_tree_split" >}}圖 4-6{{< /xref >}} 中,我們想插入鍵 334但負責 333345 範圍的頁已經裝滿。於是把它拆成兩頁:一頁負責 333337幷包含新鍵另一頁負責 337344。父頁也必須更新增加對兩個子頁的引用並以 337 作為二者的邊界。如果父頁容不下新的引用,它也要拆分;這種拆分可能一路向上傳播到樹根。根頁拆分時,則在其上方建立一個新根。刪除鍵還可能需要合併節點,處理起來更加複雜 [^5]。
這個演算法可以確保樹始終 *平衡*:包含 *n* 個鍵的 B 樹深度總是 *O*(log *n*)。大多數資料庫只需要三四層深的 B 樹,因此不必沿著很多頁引用就能找到目標頁。(一棵四層深、頁大小為 4 KiB、分支因子為 500 的樹,最多可以儲存 250 TB 資料。)
這個演算法可以確保樹始終 *平衡**balanced*:包含 *n* 個鍵的 B 樹深度總是 *O*(log *n*)。大多數資料庫只需要三四層深的 B 樹,因此不必沿著很多頁引用就能找到目標頁。(一棵四層深、頁大小為 4 KiB、分支因子為 500 的樹,最多可以儲存 250 TB 資料。)
#### 使 B 樹可靠 {#sec_storage_btree_wal}
B 樹最基本的底層寫操作,是用新資料覆寫磁碟上的頁,並假定覆寫不會改變頁的位置:也就是說,頁被覆寫後,所有指向它的引用仍然有效。這與 LSM 樹一類日誌結構索引形成鮮明對比;後者只向檔案追加寫入(並最終刪除過時檔案),從不就地修改檔案。
一次覆寫多個頁——例如拆分頁時——是很危險的操作。如果資料庫只寫完其中一部分就崩潰,最終會留下損壞的樹(例如出現不屬於任何父頁的 *孤兒頁*)。如果硬體不能原子地寫入整頁,還可能留下只寫了一部分的頁,這稱為 *頁撕裂**torn page*[^23]。
一次覆寫多個頁——例如拆分頁時——是很危險的操作。如果資料庫只寫完其中一部分就崩潰,最終會留下損壞的樹(例如出現不屬於任何父頁的 *孤兒頁**orphan page*)。如果硬體不能原子地寫入整頁,還可能留下只寫了一部分的頁,這稱為 *頁撕裂**torn page*[^23]。
為了讓資料庫能夠從崩潰中恢復B 樹實現通常會在磁碟上維護一個額外的資料結構:*預寫日誌**write-ahead log*WAL。這是一個僅追加檔案對 B 樹的每項修改,都必須先寫入 WAL才能應用到樹本身的頁上。資料庫在崩潰後重新啟動時會用這個日誌把 B 樹恢復到一致狀態 [^2] [^24]。檔案系統中的對應機制稱為 *日誌機制**journaling*)。
@@ -245,7 +245,7 @@ B 樹最基本的底層寫操作,是用新資料覆寫磁碟上的頁,並假
B 樹本身有序因此範圍查詢簡單而快速。LSM 儲存也能利用 SSTable 的排序,但必須並行掃描所有段,再把結果合併起來。布隆過濾器對範圍查詢無能為力,因為不可能計算範圍內每個潛在鍵的雜湊;所以在 LSM 儲存中,範圍查詢的成本高於點查詢 [^29]。
在日誌結構儲存引擎中,高寫入吞吐量可能在記憶體表填滿時引發延遲尖峰。如果資料來不及寫入磁碟——或許因為壓實速度趕不上新增寫入——就會出現這種情況。包括 RocksDB 在內的許多儲存引擎會在此時施加 *背壓*:暫停所有讀寫,直到記憶體表寫入磁碟 [^30] [^31]。
在日誌結構儲存引擎中,高寫入吞吐量可能在記憶體表填滿時引發延遲尖峰。如果資料來不及寫入磁碟——或許因為壓實速度趕不上新增寫入——就會出現這種情況。包括 RocksDB 在內的許多儲存引擎會在此時施加 *背壓**backpressure*:暫停所有讀寫,直到記憶體表寫入磁碟 [^30] [^31]。
至於讀取吞吐量,現代 SSD尤其是 NVMe可以並行處理許多相互獨立的讀取請求。LSM 樹和 B 樹都能提供很高的讀取吞吐量,但儲存引擎必須經過精心設計,才能利用這種並行能力 [^32]。
@@ -253,7 +253,7 @@ B 樹本身有序因此範圍查詢簡單而快速。LSM 儲存也能利用 S
使用 B 樹時,如果應用寫入的鍵散佈在整個鍵空間,產生的磁碟操作也會四處分散,因為儲存引擎要覆寫的頁可能位於磁碟任何位置。日誌結構儲存引擎則一次寫出整個段檔案——無論是把記憶體表寫出,還是壓實現有段——其大小遠遠超過 B 樹中的一頁。
這種數量多、規模小而位置分散的寫入模式(如 B 樹)稱為 *隨機寫入*;數量少、規模較大的寫入模式(如 LSM 樹)則稱為 *順序寫入*。磁碟的順序寫入吞吐量通常高於隨機寫入,因此在相同硬體上,日誌結構儲存引擎一般能承受比 B 樹更高的寫入吞吐量。這種差異在機械硬碟HDD上尤其顯著如今多數資料庫使用固態硬碟SSD差距有所縮小但依然不可忽略參見 [“SSD 上的順序與隨機寫入”](/tw/ch4#sidebar_sequential))。
這種數量多、規模小而位置分散的寫入模式(如 B 樹)稱為 *隨機寫入**random write*;數量少、規模較大的寫入模式(如 LSM 樹)則稱為 *順序寫入**sequential write*。磁碟的順序寫入吞吐量通常高於隨機寫入,因此在相同硬體上,日誌結構儲存引擎一般能承受比 B 樹更高的寫入吞吐量。這種差異在機械硬碟HDD上尤其顯著如今多數資料庫使用固態硬碟SSD差距有所縮小但依然不可忽略參見 [“SSD 上的順序與隨機寫入”](/tw/ch4#sidebar_sequential))。
> [!TIP] SSD 上的順序與隨機寫入
>
@@ -271,7 +271,7 @@ B 樹本身有序因此範圍查詢簡單而快速。LSM 儲存也能利用 S
B 樹索引也必須把每份資料至少寫兩次:一次寫入預寫日誌,一次寫入樹頁本身。為了確保 B 樹能在崩潰或斷電後正確恢復,有時即使頁內只有幾個位元組發生變化,也必須寫出整頁 [^38] [^39]。
把某個工作負載實際寫入磁碟的總位元組數,除以不帶索引、只寫僅追加日誌時所需的位元組數,得到的比值就是 *寫放大*。(寫放大有時也按 I/O 操作次數而非位元組數定義。)在寫入密集型應用中,瓶頸可能是資料庫向磁碟寫入的速度。此時寫放大越高,在有限磁碟頻寬內每秒能處理的寫入就越少。
把某個工作負載實際寫入磁碟的總位元組數,除以不帶索引、只寫僅追加日誌時所需的位元組數,得到的比值就是 *寫放大**write amplification*。(寫放大有時也按 I/O 操作次數而非位元組數定義。)在寫入密集型應用中,瓶頸可能是資料庫向磁碟寫入的速度。此時寫放大越高,在有限磁碟頻寬內每秒能處理的寫入就越少。
LSM 樹和 B 樹都有寫放大問題。孰優孰劣取決於許多因素例如鍵和值的長度以及覆蓋現有鍵與插入新鍵各有多頻繁。對典型工作負載而言LSM 樹往往具有更低的寫放大,因為它不必寫出整頁,還可以壓縮 SSTable 中的資料塊 [^40]。這也是 LSM 儲存引擎適合寫入密集型工作負載的原因之一。
@@ -303,8 +303,8 @@ B 樹可能隨著時間推移逐漸 *碎片化*。例如,刪除大量鍵之後
索引中的鍵是查詢要搜尋的內容,而值可以採用以下幾種形式:
* 如果實際資料(行、文件或頂點)直接儲存在索引結構中,這就是 *聚簇索引*。例如MySQL 的 InnoDB 儲存引擎總是把表的主鍵作為聚簇索引SQL Server 則允許每張表指定一個聚簇索引 [^43]。
* 另一種選擇是讓值引用實際資料它可以是相應行的主鍵InnoDB 的二級索引便是如此),也可以直接引用磁碟上的位置。後一種情況下,存放行的地方稱為 *堆檔案*堆檔案中的資料沒有特定順序可以是僅追加的也可以記錄已刪除的行以便日後用新資料覆寫。例如Postgres 就使用堆檔案 [^44]。
* 兩者之間的折中稱為 *覆蓋索引**包含列的索引*:完整的行仍儲存在堆檔案或主鍵聚簇索引中,但索引也會儲存表的 *部分* 列 [^45]。這樣,一些查詢只用索引就能得到答案,不必再解析主鍵或訪問堆檔案;此時稱該索引 *覆蓋* 了查詢。覆蓋索引可以加快某些查詢,但重複資料會佔用更多磁碟空間,也會拖慢寫入。
* 另一種選擇是讓值引用實際資料它可以是相應行的主鍵InnoDB 的二級索引便是如此),也可以直接引用磁碟上的位置。後一種情況下,存放行的地方稱為 *堆檔案**heap file*堆檔案中的資料沒有特定順序可以是僅追加的也可以記錄已刪除的行以便日後用新資料覆寫。例如Postgres 就使用堆檔案 [^44]。
* 兩者之間的折中稱為 *覆蓋索引**covering index*)或 *包含列的索引**index with included columns*:完整的行仍儲存在堆檔案或主鍵聚簇索引中,但索引也會儲存表的 *部分* 列 [^45]。這樣,一些查詢只用索引就能得到答案,不必再解析主鍵或訪問堆檔案;此時稱該索引 *覆蓋* 了查詢。覆蓋索引可以加快某些查詢,但重複資料會佔用更多磁碟空間,也會拖慢寫入。
到目前為止討論的索引,都只是把單個鍵對映到值。如果需要同時查詢表中的多個列(或文件中的多個欄位),請參見 [“多維索引與全文索引”](/tw/ch4#sec_storage_multidimensional)。
@@ -381,11 +381,11 @@ GROUP BY
怎樣才能高效執行這個查詢?
大多數 OLTP 資料庫都以 *面向行* 的方式佈置儲存:表中同一行的所有值相鄰存放。文件資料庫也很相似,通常把整個文件存成一段連續的位元組序列。{{< xref fig="4-1" page="/ch4" anchor="fig_storage_csv_hash_index" >}}圖 4-1{{< /xref >}} 的 CSV 示例就是如此。
大多數 OLTP 資料庫都以 *面向行**row-oriented*的方式佈置儲存:表中同一行的所有值相鄰存放。文件資料庫也很相似,通常把整個文件存成一段連續的位元組序列。{{< xref fig="4-1" page="/ch4" anchor="fig_storage_csv_hash_index" >}}圖 4-1{{< /xref >}} 的 CSV 示例就是如此。
為了處理 {{< xref eg="4-1" page="/ch4" anchor="fig_storage_analytics_query" >}}示例 4-1{{< /xref >}} 這樣的查詢,可以在 `fact_sales.date_key` 和(或)`fact_sales.product_sk` 上建立索引,告訴儲存引擎去哪裡尋找某一天或某種產品的所有銷售記錄。但面向行的儲存引擎仍要把這些完整的行(每行有 100 多個屬性)從磁碟載入記憶體,逐一解析,再過濾掉不滿足條件的行。這個過程可能十分耗時。
*面向列*(或 *列式*)儲存背後的想法很簡單:不要把同一行中的所有值放在一起,而要把同一 ** 中的所有值放在一起 [^56]。每列分別儲存後,查詢只需讀取和解析自己用到的列,能省下大量工作。{{< xref fig="4-7" page="/ch4" anchor="fig_column_store" >}}圖 4-7{{< /xref >}} 用 {{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}圖 3-5{{< /xref >}} 中事實表的擴充套件版本展示了這一原理。
*面向列**column-oriented**列式**columnar*)儲存背後的想法很簡單:不要把同一行中的所有值放在一起,而要把同一 ** 中的所有值放在一起 [^56]。每列分別儲存後,查詢只需讀取和解析自己用到的列,能省下大量工作。{{< xref fig="4-7" page="/ch4" anchor="fig_column_store" >}}圖 4-7{{< /xref >}} 用 {{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}圖 3-5{{< /xref >}} 中事實表的擴充套件版本展示了這一原理。
> [!NOTE]
@@ -424,7 +424,7 @@ GROUP BY
> [!NOTE]
> 不要把列式資料庫與 *寬列*又稱 *列族*)資料模型混為一談。寬列模型的一行可以有數千列,各行也不必擁有相同的列 [^9]。儘管名字相似寬列資料庫其實是面向行的因為它會把同一行的所有值存放在一起。Google Bigtable、Apache Accumulo 和 HBase 都屬於寬列模型。
> 不要把列式資料庫與 *寬列**wide-column*,又稱 *列族**column-family*)資料模型混為一談。寬列模型的一行可以有數千列,各行也不必擁有相同的列 [^9]。儘管名字相似寬列資料庫其實是面向行的因為它會把同一行的所有值存放在一起。Google Bigtable、Apache Accumulo 和 HBase 都屬於寬列模型。
#### 列儲存中的排序順序 {#sort-order-in-column-storage}
@@ -454,16 +454,16 @@ GROUP BY
### 查詢執行:編譯與向量化 {#sec_storage_vectorized}
複雜的分析型 SQL 查詢會被分解成一個 *查詢計劃*,其中包含多個稱為 *運算元* 的執行階段;這些運算元可能分佈到多臺機器上並行執行。查詢規劃器可以決定選用哪些運算元、以什麼順序執行,以及每個運算元在哪裡執行,從而完成大量最佳化。
複雜的分析型 SQL 查詢會被分解成一個 *查詢計劃**query plan*,其中包含多個稱為 *運算元**operator*的執行階段;這些運算元可能分佈到多臺機器上並行執行。查詢規劃器可以決定選用哪些運算元、以什麼順序執行,以及每個運算元在哪裡執行,從而完成大量最佳化。
在每個運算元內部,查詢引擎都要對列中的值執行各種操作,例如找出值屬於某個集合的所有行(或許是連線的一部分),或者判斷值是否大於 15。查詢引擎還要同時檢視同一行的多個列例如找出產品是香蕉、門店又恰好是目標門店的所有銷售記錄。
資料倉儲查詢需要掃描數百萬行,因此不僅要關注從磁碟讀取的資料量,還要關注執行複雜運算元所需的 CPU 時間。最簡單的運算元就像程式語言直譯器:遍歷每一行時,檢視錶示查詢的資料結構,弄清要對哪些列做什麼比較或計算。遺憾的是,這種方式對許多分析場景來說太慢。實踐中出現了兩種高效執行查詢的方法 [^77]
查詢編譯
查詢編譯query compilation
: 查詢引擎根據 SQL 查詢生成執行程式碼。程式碼逐行迭代,讀取相關列中的值,完成所需的比較或計算;如果條件滿足,就把必要的值複製到輸出緩衝區。隨後,查詢引擎把生成的程式碼編譯成機器碼(往往藉助 LLVM 等現有編譯器),再對已經載入記憶體的列編碼資料執行。這種程式碼生成方式類似 Java 虛擬機器JVM等執行時採用的即時JIT編譯。
向量化處理
向量化處理vectorized processing
: 查詢仍然採用解釋執行,而非編譯執行;但它不再逐行迭代,而是成批處理一列中的許多值,從而提高速度。資料庫內建一組固定的預定義運算元,向運算元傳入引數,就會得到一批結果 [^50] [^75]。
例如,把 `product_sk` 列和“香蕉”的 ID 傳給相等比較運算元,會得到一張點陣圖:輸入列的每個值對應一位,是香蕉則為 1。再把 `store_sk` 列和目標門店的 ID 傳給同一個運算元,得到另一張點陣圖。最後把兩張點陣圖傳給“按位與”運算元,如 {{< xref fig="4-9" page="/ch4" anchor="fig_bitmap_and" >}}圖 4-9{{< /xref >}} 所示。結果點陣圖中,特定門店售出的每一筆香蕉都對應一個 1。
@@ -479,11 +479,11 @@ GROUP BY
### 物化檢視與多維資料集 {#sec_storage_materialized_views}
我們曾在 [“時間線的物化與更新”](/tw/ch2#sec_introduction_materializing) 中遇到 *物化檢視*。在關係資料模型中它是一種類似表的物件內容是某個查詢的結果。物化檢視是實際寫入磁碟的查詢結果副本而虛擬檢視只是編寫查詢的快捷方式。從虛擬檢視讀取時SQL 引擎會即時把它展開成底層查詢,再處理展開後的查詢。
我們曾在 [“時間線的物化與更新”](/tw/ch2#sec_introduction_materializing) 中遇到 *物化檢視**materialized view*。在關係資料模型中它是一種類似表的物件內容是某個查詢的結果。物化檢視是實際寫入磁碟的查詢結果副本而虛擬檢視只是編寫查詢的快捷方式。從虛擬檢視讀取時SQL 引擎會即時把它展開成底層查詢,再處理展開後的查詢。
底層資料變化時物化檢視也必須隨之更新。有些資料庫可以自動完成這項工作Materialize 等系統則專門負責維護物化檢視 [^81]。更新檢視會增加寫入工作量,但如果工作負載反覆執行相同查詢,物化檢視可以改善讀取效能。
*物化聚合* 是一類對資料倉儲很有用的物化檢視。如前所述,資料倉儲查詢經常使用 SQL 中的 `COUNT``SUM``AVG``MIN``MAX` 等聚合函式。如果許多查詢都使用相同的聚合,每次重新處理原始資料就太浪費了,何不把最常用的計數或總和快取起來?*多維資料集*(或 *OLAP 多維資料集*)會建立一個按不同維度分組的聚合網格,正是為了實現這種快取 [^82]。{{< xref fig="4-10" page="/ch4" anchor="fig_data_cube" >}}圖 4-10{{< /xref >}} 展示了一個例子。
*物化聚合**materialized aggregate*是一類對資料倉儲很有用的物化檢視。如前所述,資料倉儲查詢經常使用 SQL 中的 `COUNT``SUM``AVG``MIN``MAX` 等聚合函式。如果許多查詢都使用相同的聚合,每次重新處理原始資料就太浪費了,何不把最常用的計數或總和快取起來?*多維資料集**data cube**OLAP 多維資料集**OLAP cube*)會建立一個按不同維度分組的聚合網格,正是為了實現這種快取 [^82]。{{< xref fig="4-10" page="/ch4" anchor="fig_data_cube" >}}圖 4-10{{< /xref >}} 展示了一個例子。
{{< fig num="4-10" id="fig_data_cube" src="/fig/ddia_0410.png" caption="多維資料集的兩個維度,透過求和聚合資料。" class="ddia-figure ddia-figure--wide" width="2880" height="1484" />}}
@@ -500,9 +500,9 @@ GROUP BY
本章前半部分介紹的 B 樹和 LSM 樹,可以對單個屬性執行範圍查詢。例如,如果鍵是使用者名稱,就能用它們建立索引,高效找出所有以 L 開頭的名字。但有時,只按一個屬性搜尋並不夠用。
最常見的多列索引稱為 *聯合索引*。它把一列接在另一列之後,將多個欄位組合成一個鍵;欄位的連線順序由索引定義指定。這就像老式紙質電話簿提供的索引:從(*姓*、*名*)對映到電話號碼。由於索引按這個順序排列,可以找出某個姓氏對應的所有人,也可以找出特定 *姓—名* 組合對應的所有人。但如果只想查詢某個名字對應的所有人,這個索引就毫無用處。
最常見的多列索引稱為 *聯合索引**concatenated index*。它把一列接在另一列之後,將多個欄位組合成一個鍵;欄位的連線順序由索引定義指定。這就像老式紙質電話簿提供的索引:從(*姓*、*名*)對映到電話號碼。由於索引按這個順序排列,可以找出某個姓氏對應的所有人,也可以找出特定 *姓—名* 組合對應的所有人。但如果只想查詢某個名字對應的所有人,這個索引就毫無用處。
*多維索引* 則允許同時查詢多個列,這對地理空間資料尤其重要。例如,餐廳搜尋網站的資料庫可能儲存了每家餐廳的經緯度。使用者檢視地圖時,網站需要找出當前矩形地圖區域內的所有餐廳。這就需要下面這樣的二維範圍查詢:
*多維索引**multidimensional index*則允許同時查詢多個列,這對地理空間資料尤其重要。例如,餐廳搜尋網站的資料庫可能儲存了每家餐廳的經緯度。使用者檢視地圖時,網站需要找出當前矩形地圖區域內的所有餐廳。這就需要下面這樣的二維範圍查詢:
```sql
SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
@@ -517,11 +517,11 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
### 全文檢索 {#sec_storage_full_text}
全文檢索允許按關鍵詞搜尋一組文字文件(網頁、產品描述等),關鍵詞可以出現在文字中的任意位置 [^88]。資訊檢索是一門龐大而專門的學科,往往還要針對具體語言進行處理。例如,一些亞洲語言書寫時不會在詞與詞之間新增空格或標點,因此把文字切分成詞需要藉助模型,判斷哪些字元序列構成一個詞。全文檢索還經常需要匹配相似但不完全相同的詞(例如拼寫錯誤或同一個詞的不同語法形式),以及同義詞。這些問題都超出了本書的範圍。
*全文檢索**full-text search*允許按關鍵詞搜尋一組文字文件(網頁、產品描述等),關鍵詞可以出現在文字中的任意位置 [^88]。資訊檢索是一門龐大而專門的學科,往往還要針對具體語言進行處理。例如,一些亞洲語言書寫時不會在詞與詞之間新增空格或標點,因此把文字切分成詞需要藉助模型,判斷哪些字元序列構成一個詞。全文檢索還經常需要匹配相似但不完全相同的詞(例如拼寫錯誤或同一個詞的不同語法形式),以及同義詞。這些問題都超出了本書的範圍。
不過從核心原理看,全文檢索也可以視為一種多維查詢:文字中可能出現的每個詞(即一個 *詞項*)都是一個維度。包含詞項 *x* 的文件在維度 *x* 上取值為 1不包含 *x* 則取值為 0。搜尋提到“紅蘋果”的文件就是同時尋找 ** 維度和 *蘋果* 維度都為 1 的文件。這樣一來,維度數可能非常龐大。
不過從核心原理看,全文檢索也可以視為一種多維查詢:文字中可能出現的每個詞(即一個 *詞項**term*)都是一個維度。包含詞項 *x* 的文件在維度 *x* 上取值為 1不包含 *x* 則取值為 0。搜尋提到“紅蘋果”的文件就是同時尋找 ** 維度和 *蘋果* 維度都為 1 的文件。這樣一來,維度數可能非常龐大。
許多搜尋引擎使用 *倒排索引* 回答這類查詢。它是一種鍵值結構:鍵是詞項,值是所有包含該詞項的文件 ID 列表,即 *倒排列表*。如果文件 ID 是連續數字,倒排列表也可以表示成 {{< xref fig="4-8" page="/ch4" anchor="fig_bitmap_index" >}}圖 4-8{{< /xref >}} 那樣的稀疏點陣圖:如果 ID 為 *n* 的文件包含詞項 *x*,那麼詞項 *x* 的點陣圖中第 *n* 位就是 1 [^89]。
許多搜尋引擎使用 *倒排索引**inverted index*回答這類查詢。它是一種鍵值結構:鍵是詞項,值是所有包含該詞項的文件 ID 列表,即 *倒排列表**postings list*。如果文件 ID 是連續數字,倒排列表也可以表示成 {{< xref fig="4-8" page="/ch4" anchor="fig_bitmap_index" >}}圖 4-8{{< /xref >}} 那樣的稀疏點陣圖:如果 ID 為 *n* 的文件包含詞項 *x*,那麼詞項 *x* 的點陣圖中第 *n* 位就是 1 [^89]。
現在,查詢同時包含詞項 *x**y* 的所有文件,就類似於用向量化資料倉儲查詢尋找滿足兩個條件的行({{< xref fig="4-9" page="/ch4" anchor="fig_bitmap_and" >}}圖 4-9{{< /xref >}}):載入 *x**y* 對應的兩張點陣圖,再計算按位與。即使點陣圖經過遊程編碼,這個操作也可以高效完成。
@@ -529,14 +529,14 @@ Elasticsearch 和 Solr 使用的全文索引引擎 Lucene 就採用這種方法
除了把文字切分成詞,還可以找出所有長度為 *n* 的子串,稱為 *n*-gram。例如字串 `"hello"` 的 3-gram*n* = 3`"hel"``"ell"``"llo"`。如果為所有 3-gram 建立倒排索引,就能搜尋任意長度至少為三個字元的子串。這樣的索引甚至支援在搜尋查詢中使用正規表示式,缺點是體積相當大 [^94]。
為了應對文件或查詢中的拼寫錯誤Lucene 可以搜尋與目標詞相差一定編輯距離的詞(編輯距離為 1表示增加、刪除或替換了一個字母[^95]。它把詞項集合儲存成一個以鍵中字元為邊的有限狀態自動機,結構類似 *字典樹* [^96];再將其轉換成 *萊文斯坦自動機*,從而高效搜尋給定編輯距離以內的詞 [^97]。
為了應對文件或查詢中的拼寫錯誤Lucene 可以搜尋與目標詞相差一定編輯距離的詞(編輯距離為 1表示增加、刪除或替換了一個字母[^95]。它把詞項集合儲存成一個以鍵中字元為邊的有限狀態自動機,結構類似 *字典樹**trie*[^96];再將其轉換成 *萊文斯坦自動機**Levenshtein automaton*,從而高效搜尋給定編輯距離以內的詞 [^97]。
### 向量嵌入 {#id92}
語義搜尋不只處理同義詞和拼寫錯誤,還試圖理解文件表達的概念和使用者的意圖。例如,幫助中心有一頁標題是“取消訂閱”,那麼使用者搜尋“如何關閉賬戶”或“終止合同”時也應當找到它:這些說法用詞完全不同,意思卻十分接近。
為了理解文件的語義,也就是它表達的含義,語義搜尋索引會使用嵌入模型,把文件轉換成由浮點陣列成的向量,稱為 *向量嵌入*。這個向量表示多維空間中的一個點,每個浮點數表示文件在某一維座標軸上的位置。如果輸入文件的語義相似,嵌入模型就會生成在多維空間中彼此接近的向量。
為了理解文件的語義,也就是它表達的含義,語義搜尋索引會使用嵌入模型,把文件轉換成由浮點陣列成的向量,稱為 *向量嵌入**vector embedding*。這個向量表示多維空間中的一個點,每個浮點數表示文件在某一維座標軸上的位置。如果輸入文件的語義相似,嵌入模型就會生成在多維空間中彼此接近的向量。
> [!NOTE]
@@ -547,7 +547,7 @@ Elasticsearch 和 Solr 使用的全文索引引擎 Lucene 就採用這種方法
實際的嵌入模型使用大得多的向量,往往包含 1,000 多個數字,但原理相同。我們不會試圖理解每個數字各自代表什麼;它們只是嵌入模型用來指向抽象多維空間中某個位置的方式。搜尋引擎透過餘弦相似度、歐幾里得距離等距離函式衡量向量間的距離。餘弦相似度計算兩個向量夾角的餘弦,判斷它們有多接近;歐幾里得距離則計算空間中兩點間的直線距離。
Word2Vec [^98]、BERT [^99] 和 GPT [^100] 等許多早期嵌入模型都處理文字資料,通常以神經網路實現。後來,研究者又為影片、音訊和影象建立了嵌入模型。近來,模型架構進一步走向 *多模態*:同一個模型可以為文字、影象等多種模態生成向量嵌入。
Word2Vec [^98]、BERT [^99] 和 GPT [^100] 等許多早期嵌入模型都處理文字資料,通常以神經網路實現。後來,研究者又為影片、音訊和影象建立了嵌入模型。近來,模型架構進一步走向 *多模態**multimodal*:同一個模型可以為文字、影象等多種模態生成向量嵌入。
使用者輸入查詢時,語義搜尋引擎會把查詢及其相關上下文(例如使用者位置)交給嵌入模型,生成查詢的向量嵌入。隨後,搜尋引擎還必須透過向量索引,找出向量嵌入與查詢相似的文件。
@@ -557,7 +557,7 @@ Word2Vec [^98]、BERT [^99] 和 GPT [^100] 等許多早期嵌入模型都處理
: 向量原樣儲存在索引中。查詢必須讀取每個向量,並測量它與查詢向量的距離。平面索引結果精確,但逐一計算查詢與每個向量的距離很慢。
倒排檔案IVF索引
: 把向量空間聚類成若干向量分割槽,以減少必須比較的向量數;這些分割槽稱為 *質心*。IVF 索引比平面索引更快,卻只能給出近似結果:查詢向量和某個文件向量可能十分接近,卻恰好落入不同分割槽。查詢 IVF 索引時,首先要指定 *探測數*probes也就是需要檢查多少個分割槽。探測數越大查詢越準確但也越慢因為必須比較更多向量。
: 把向量空間聚類成若干向量分割槽,以減少必須比較的向量數;這些分割槽稱為 *質心**centroid*。IVF 索引比平面索引更快,卻只能給出近似結果:查詢向量和某個文件向量可能十分接近,卻恰好落入不同分割槽。查詢 IVF 索引時,首先要指定 *探測數*probes也就是需要檢查多少個分割槽。探測數越大查詢越準確但也越慢因為必須比較更多向量。
分層可導航小世界HNSW
: HNSW 索引維護向量空間的多個層級,如 {{< xref fig="4-11" page="/ch4" anchor="fig_vector_hnsw" >}}圖 4-11{{< /xref >}} 所示。每層都表示成一張圖:節點代表向量,邊表示向量彼此接近。查詢先在節點很少的最頂層找到最近向量,再進入下一層的同一節點;下一層連線更密集,查詢沿邊尋找更接近查詢向量的向量。這個過程不斷重複,直至最底層。與 IVF 一樣HNSW 也是近似索引。

View File

@@ -16,7 +16,7 @@ breadcrumbs: false
>
> 以弗所的赫拉克利特,引自柏拉圖《克拉提魯斯》(公元前 360 年)
應用程式不可避免地會隨時間而變化。隨著新產品推出、對使用者需求的理解日益深入,或者商業環境發生變化,應用程式總要增添或修改功能。在 [第 2 章](/tw/ch2#ch_nonfunctional) 中,我們介紹了 *可演化性* 的概念:應該盡力構建能夠靈活適應變化的系統(參見[“可演化性:讓變化更容易”](/tw/ch2#sec_introduction_evolvability))。
應用程式不可避免地會隨時間而變化。隨著新產品推出、對使用者需求的理解日益深入,或者商業環境發生變化,應用程式總要增添或修改功能。在 [第 2 章](/tw/ch2#ch_nonfunctional) 中,我們介紹了 *可演化性**evolvability*的概念:應該盡力構建能夠靈活適應變化的系統(參見[“可演化性:讓變化更容易”](/tw/ch2#sec_introduction_evolvability))。
在大多數情況下,修改應用程式的功能也意味著需要更改其儲存的資料:可能需要記錄新的欄位或記錄型別,也可能需要以新的方式呈現現有資料。
@@ -24,15 +24,15 @@ breadcrumbs: false
當資料格式或模式發生變化時,通常也需要相應地修改應用程式程式碼(例如,為記錄新增新欄位,然後讓應用程式開始讀寫該欄位)。但在大型應用程式中,程式碼變更往往無法瞬間完成:
* 對於服務端應用程式,可能需要執行 *滾動升級*也稱為 *逐步釋出*):每次只把新版本部署到少數幾個節點,確認執行正常後,再逐步部署到所有節點。這樣無需中斷服務即可上線新版本,有利於更頻繁地釋出,也讓系統更容易演化。
* 對於服務端應用程式,可能需要執行 *滾動升級**rolling upgrade*,也稱為 *逐步釋出**staged rollout*):每次只把新版本部署到少數幾個節點,確認執行正常後,再逐步部署到所有節點。這樣無需中斷服務即可上線新版本,有利於更頻繁地釋出,也讓系統更容易演化。
* 對於客戶端應用程式,是否升級只能任由使用者決定,而使用者可能很長時間都不安裝更新。
這意味著,新舊版本的程式碼以及新舊資料格式,可能同時存在於系統中。系統要繼續順利執行,就需要保持雙向相容:
向後相容
向後相容backward compatibility
: 較新的程式碼可以讀取由較舊程式碼寫入的資料。
向前相容
向前相容forward compatibility
: 較舊的程式碼可以讀取由較新程式碼寫入的資料。
向後相容通常不難實現:新程式碼的作者知道舊程式碼寫入的資料格式,因此可以顯式地處理它(必要時,只要保留讀取舊資料的舊程式碼即可)。向前相容則可能棘手得多,因為舊程式碼必須忽略新版本程式碼新增的部分。
@@ -50,7 +50,7 @@ breadcrumbs: false
1. 在記憶體中,資料儲存在物件、結構體、列表、陣列、雜湊表、樹等資料結構中。這些結構通常使用指標,針對 CPU 的高效訪問與操作進行了最佳化。
2. 如果要將資料寫入檔案或透過網路傳送,就必須將其編碼成某種自包含的位元組序列(例如 JSON 文件)。由於指標對其他程序沒有意義,這種位元組序列表示通常與記憶體中的資料結構大不相同。
因此,需要在兩種表示之間進行轉換。從記憶體表示轉換為位元組序列,稱為 *編碼*(也稱為 *序列化**編組*);反過來則稱為 *解碼*(也稱為 *解析*、*反序列化* 或 *解組*)。
因此,需要在兩種表示之間進行轉換。從記憶體表示轉換為位元組序列,稱為 *編碼**encoding*也稱為 *序列化**serialization*,或 *編組**marshalling*);反過來則稱為 *解碼**decoding*也稱為 *解析**parsing**反序列化**deserialization*,或 *解組**unmarshalling*)。
> [!TIP] 術語衝突
@@ -58,7 +58,7 @@ breadcrumbs: false
> 遺憾的是,*序列化* 一詞也用於事務的語境,而且含義完全不同(參見 [第 8 章](/tw/ch8#ch_transactions))。雖然“序列化”可能更常用,但為了避免一詞多義,本書在這裡始終使用 *編碼*。
也有一些情況不需要編碼和解碼。例如,[“查詢執行:編譯與向量化”](/tw/ch4#sec_storage_vectorized) 中介紹過,資料庫可以直接操作從磁碟載入的壓縮資料。還有一些 *零複製* 資料格式,例如 Capn Proto 和 FlatBuffers它們既可用於執行時也可直接用於磁碟或網路上的資料無需顯式的轉換步驟。
也有一些情況不需要編碼和解碼。例如,[“查詢執行:編譯與向量化”](/tw/ch4#sec_storage_vectorized) 中介紹過,資料庫可以直接操作從磁碟載入的壓縮資料。還有一些 *零複製**zero-copy*資料格式,例如 Capn Proto 和 FlatBuffers它們既可用於執行時也可直接用於磁碟或網路上的資料無需顯式的轉換步驟。
不過,大多數系統仍需在記憶體物件與扁平的位元組序列之間轉換。這是個極其常見的問題,因而有數不清的庫和編碼格式可供選擇。下面先來簡要概覽一下。
@@ -173,7 +173,7 @@ Protocol Buffers 自帶程式碼生成工具。它接收上述模式定義,生
與 {{< xref fig="5-2" page="/ch5" anchor="fig_encoding_messagepack" >}}圖 5-2{{< /xref >}} 類似每個欄位都有型別註解用來說明它是字串、整數還是其他型別必要時還會給出長度例如字串長度。資料中的字串“Martin”“daydreaming”“hacking”也和之前一樣編碼為 ASCII——準確地說是 UTF-8。
與 {{< xref fig="5-2" page="/ch5" anchor="fig_encoding_messagepack" >}}圖 5-2{{< /xref >}} 相比,最大的區別在於這裡沒有欄位名(`userName``favoriteNumber``interests`)。編碼資料包含的是數字形式的 *欄位標籤*`1``2``3`),也就是模式定義中的那些數字。欄位標籤好比欄位的別名:無需寫出欄位名,就能以緊湊的方式指出所說的是哪個欄位。
與 {{< xref fig="5-2" page="/ch5" anchor="fig_encoding_messagepack" >}}圖 5-2{{< /xref >}} 相比,最大的區別在於這裡沒有欄位名(`userName``favoriteNumber``interests`)。編碼資料包含的是數字形式的 *欄位標籤**field tag*`1``2``3`),也就是模式定義中的那些數字。欄位標籤好比欄位的別名:無需寫出欄位名,就能以緊湊的方式指出所說的是哪個欄位。
Protocol Buffers 把欄位型別和標籤號塞進同一個位元組,進一步節省了空間。它還使用變長整數:數字 1337 編碼成兩個位元組,每個位元組的最高位表示後面是否還有更多位元組。這樣,-64 到 63 之間的數字用一個位元組編碼,-8192 到 8191 之間的數字用兩個位元組編碼,依此類推;數字越大,佔用的位元組就越多。
@@ -181,7 +181,7 @@ Protocol Buffers 沒有顯式的列表或陣列資料型別。`interests` 欄位
#### 欄位標籤與模式演化 {#field-tags-and-schema-evolution}
前面說過,模式不可避免地會隨時間改變,這稱為 *模式演化*。Protocol Buffers 如何在保持向後和向前相容的同時處理模式變更?
前面說過,模式不可避免地會隨時間改變,這稱為 *模式演化**schema evolution*。Protocol Buffers 如何在保持向後和向前相容的同時處理模式變更?
從示例可以看出,一條編碼後的記錄,就是各個已編碼欄位的拼接。每個欄位由標籤號(示例模式中的 `1``2``3`)標識,並帶有資料型別註解(例如字串或整數)。如果某個欄位沒有值,就直接從編碼記錄中省略。由此可見,欄位標籤對編碼資料的含義至關重要。模式中的欄位名可以修改,因為編碼資料從不引用欄位名;但欄位標籤不能修改,否則現有的所有編碼資料都會失效。
@@ -236,9 +236,9 @@ record Person {
#### 寫入者模式與讀取者模式 {#the-writers-schema-and-the-readers-schema}
當應用程式要編碼資料——例如寫入檔案或資料庫,或者透過網路傳送——它會使用自己所知版本的模式;這個模式可能已經編譯進應用程式。這稱為 *寫入者模式*
當應用程式要編碼資料——例如寫入檔案或資料庫,或者透過網路傳送——它會使用自己所知版本的模式;這個模式可能已經編譯進應用程式。這稱為 *寫入者模式**writers schema*
當應用程式要解碼資料——例如從檔案或資料庫讀取,或者從網路接收——它會使用兩個模式:一個是與編碼時完全相同的寫入者模式,另一個是可能有所不同的 *讀取者模式*,如 {{< xref fig="5-5" page="/ch5" anchor="fig_encoding_avro_schemas" >}}圖 5-5{{< /xref >}} 所示。讀取者模式定義了應用程式程式碼期望每條記錄包含哪些欄位,以及這些欄位的型別。
當應用程式要解碼資料——例如從檔案或資料庫讀取,或者從網路接收——它會使用兩個模式:一個是與編碼時完全相同的寫入者模式,另一個是可能有所不同的 *讀取者模式**readers schema*,如 {{< xref fig="5-5" page="/ch5" anchor="fig_encoding_avro_schemas" >}}圖 5-5{{< /xref >}} 所示。讀取者模式定義了應用程式程式碼期望每條記錄包含哪些欄位,以及這些欄位的型別。
{{< fig num="5-5" id="fig_encoding_avro_schemas" src="/fig/ddia_0505.png" caption="Protocol Buffers 的編碼與解碼可以使用不同版本的模式。Avro 解碼時使用兩個模式:寫入者模式必須與編碼時所用模式完全相同,讀取者模式則可以是較舊或較新的版本。" class="ddia-figure ddia-figure--wide" width="2658" height="1269" />}}
@@ -256,7 +256,7 @@ record Person {
如果新增的欄位沒有預設值,新讀取者就無法讀取舊寫入者產生的資料,因而破壞向後相容。如果刪除的欄位沒有預設值,舊讀取者就無法讀取新寫入者產生的資料,因而破壞向前相容。
在某些程式語言中,任何變數都可以預設取 `null`Avro 卻並非如此:如果希望欄位允許為 `null`,就必須使用 *聯合型別*。例如,`union { null, long, string } field;` 表示 `field` 可以是數字、字串或 `null`。而且,只有當 `null` 是聯合型別的第一個分支時,才能把它用作預設值。這種寫法比預設所有內容都可為 `null` 略顯冗長,卻明確說明了什麼可以、什麼不可以為 `null`,從而有助於避免錯誤 [^18]。
在某些程式語言中,任何變數都可以預設取 `null`Avro 卻並非如此:如果希望欄位允許為 `null`,就必須使用 *聯合型別**union type*。例如,`union { null, long, string } field;` 表示 `field` 可以是數字、字串或 `null`。而且,只有當 `null` 是聯合型別的第一個分支時,才能把它用作預設值。這種寫法比預設所有內容都可為 `null` 略顯冗長,卻明確說明了什麼可以、什麼不可以為 `null`,從而有助於避免錯誤 [^18]。
只要 Avro 能完成相應的型別轉換,就可以更改欄位的資料型別。欄位名也能更改,不過稍微麻煩一些:讀取者模式可以為欄位名宣告別名,從而讓舊寫入者模式中的欄位名與別名匹配。因此,更改欄位名向後相容,卻不向前相容。同樣,給聯合型別增加一個分支向後相容,卻不向前相容。
@@ -351,7 +351,7 @@ record Person {
### 流經服務的資料流REST 與 RPC {#sec_encoding_dataflow_rpc}
當多個程序需要透過網路通訊時,可以採用幾種不同的組織方式。最常見的方式包含兩個角色:*客戶端* *伺服器*。伺服器透過網路公開 API客戶端連線伺服器並向 API 發出請求。伺服器公開的這個 API 稱為 *服務*
當多個程序需要透過網路通訊時,可以採用幾種不同的組織方式。最常見的方式包含兩個角色:*客戶端**client**伺服器**server*。伺服器透過網路公開 API客戶端連線伺服器並向 API 發出請求。伺服器公開的這個 API 稱為 *服務**service*
Web 正是這樣工作的客戶端Web 瀏覽器)向 Web 伺服器發出請求,用 `GET` 請求下載 HTML、CSS、JavaScript、圖片等內容`POST` 請求向伺服器提交資料。這個 API 由一套標準化的協議和資料格式組成,包括 HTTP、URL、SSL/TLS、HTML 等。因為 Web 瀏覽器、Web 伺服器和網站作者基本都遵循這些標準,所以理論上可以用任何 Web 瀏覽器訪問任何網站。
@@ -363,7 +363,7 @@ Web 瀏覽器並不是唯一的客戶端。例如,執行在移動裝置或桌
#### Web 服務 {#sec_web_services}
如果以 HTTP 作為與服務通訊的底層協議,就稱為 *Web 服務*。Web 服務常用於構建面向服務或微服務架構(前文[“微服務與無伺服器”](/tw/ch1#sec_introduction_microservices)已經討論過。不過“Web 服務”這個名字並不十分貼切,因為它不只用於 Web還出現在其他幾種場景中。例如
如果以 HTTP 作為與服務通訊的底層協議,就稱為 *Web 服務**Web service*。Web 服務常用於構建面向服務或微服務架構(前文[“微服務與無伺服器”](/tw/ch1#sec_introduction_microservices)已經討論過。不過“Web 服務”這個名字並不十分貼切,因為它不只用於 Web還出現在其他幾種場景中。例如
1. 執行在使用者裝置上的客戶端應用程式(例如移動裝置上的原生應用,或者瀏覽器中的 JavaScript Web 應用)透過 HTTP 請求服務。這些請求通常經由公共網際網路傳輸。
2. 作為面向服務或微服務架構的一部分,一項服務請求同一組織擁有的另一項服務;兩者通常位於同一個資料中心。
@@ -426,11 +426,11 @@ async def ping():
Web 服務只是透過網路發起 API 請求這一系列技術的最新化身。此前許多技術都曾被大肆炒作卻存在嚴重問題Enterprise JavaBeansEJB和 Java 的遠端方法呼叫RMI侷限於 Java分散式元件物件模型DCOM侷限於 Microsoft 平臺公共物件請求代理架構CORBA過度複雜又不支援向後或向前相容 [^33]。SOAP 和 WS-\* Web 服務框架試圖實現跨廠商互操作,卻同樣飽受複雜性和相容性問題困擾 [^34] [^35] [^36]。
所有這些技術都建立在 *遠端過程呼叫*RPC的思想之上而 RPC 早在 20 世紀 70 年代便已出現 [^37]。RPC 模型試圖讓遠端網路服務請求,看起來就像在同一程序內呼叫程式語言中的函式或方法一樣(這種抽象稱為 *位置透明性*。RPC 乍看十分方便,這種思路卻有根本性的缺陷 [^38] [^39]。網路請求與本地函式呼叫大不相同:
所有這些技術都建立在 *遠端過程呼叫**remote procedure call*RPC的思想之上而 RPC 早在 20 世紀 70 年代便已出現 [^37]。RPC 模型試圖讓遠端網路服務請求,看起來就像在同一程序內呼叫程式語言中的函式或方法一樣(這種抽象稱為 *位置透明性**location transparency*。RPC 乍看十分方便,這種思路卻有根本性的缺陷 [^38] [^39]。網路請求與本地函式呼叫大不相同:
* 本地函式呼叫是可預測的,成功還是失敗只取決於你能控制的引數。網路請求卻不可預測:請求或響應可能因網路問題而丟失,遠端機器也可能很慢或不可用,這些情況都完全不受你控制。網路問題很常見,必須預先做好準備,例如重試失敗的請求。
* 本地函式呼叫要麼返回結果,要麼丟擲異常,要麼永遠不返回(因為陷入死迴圈或程序崩潰)。網路請求還有另一種結果:它可能因 *超時* 而返回,卻沒有結果。此時你根本不知道發生了什麼;如果遠端服務沒有響應,就無法判斷請求究竟有沒有送達([第 9 章](/tw/ch9#ch_distributed) 會更詳細地討論這個問題)。
* 重試失敗的網路請求時,原請求可能其實已經成功,只是響應丟失了。此時重試會讓同一操作執行多次,除非協議內建了去重機制,也就是 *冪等性* [^40]。本地函式呼叫沒有這個問題(參見[“冪等性”](/tw/ch12#sec_stream_idempotence))。
* 本地函式呼叫要麼返回結果,要麼丟擲異常,要麼永遠不返回(因為陷入死迴圈或程序崩潰)。網路請求還有另一種結果:它可能因 *超時**timeout*而返回,卻沒有結果。此時你根本不知道發生了什麼;如果遠端服務沒有響應,就無法判斷請求究竟有沒有送達([第 9 章](/tw/ch9#ch_distributed) 會更詳細地討論這個問題)。
* 重試失敗的網路請求時,原請求可能其實已經成功,只是響應丟失了。此時重試會讓同一操作執行多次,除非協議內建了去重機制,也就是 *冪等性**idempotence*[^40]。本地函式呼叫沒有這個問題(參見[“冪等性”](/tw/ch12#sec_stream_idempotence))。
* 本地函式每次呼叫通常耗時相近。網路請求不僅比函式呼叫慢得多,延遲還會劇烈波動:順利時可能不到一毫秒就完成;網路擁塞或遠端服務過載時,同一個操作卻可能花上好幾秒。
* 呼叫本地函式時,可以高效傳遞指向本地記憶體物件的引用(指標)。發起網路請求時,所有引數都必須編碼成可以透過網路傳送的位元組序列。對於數字、短字串等不可變基本值,這不成問題;但資料量一大,或者涉及可變物件,麻煩很快就會出現。
* 客戶端和服務可能由不同的程式語言實現,因此 RPC 框架必須在語言之間轉換資料型別。各語言的型別並不完全相同轉換結果可能十分難看——例如前面提到過JavaScript 無法準確表示大於 2⁵³ 的整數(參見[“JSON、XML 及其二進位制變體”](/tw/ch5#sec_encoding_json))。如果單個程序只使用一種語言,就沒有這個問題。
@@ -439,17 +439,17 @@ Web 服務只是透過網路發起 API 請求這一系列技術的最新化身
#### 負載均衡器、服務發現和服務網格 {#sec_encoding_service_discovery}
所有服務都透過網路通訊,因此客戶端必須知道目標服務的地址,這個問題稱為 *服務發現*。最簡單的做法,是把執行服務的 IP 地址和埠配置到客戶端中。這樣確實能工作,但伺服器一旦離線、遷移到另一臺機器或負載過高,就必須手工重新配置客戶端。
所有服務都透過網路通訊,因此客戶端必須知道目標服務的地址,這個問題稱為 *服務發現**service discovery*。最簡單的做法,是把執行服務的 IP 地址和埠配置到客戶端中。這樣確實能工作,但伺服器一旦離線、遷移到另一臺機器或負載過高,就必須手工重新配置客戶端。
為了提高可用性和可伸縮性,一項服務通常會在不同機器上執行多個例項,任一例項都能處理傳入的請求。把請求分攤到這些例項上的過程稱為 *負載均衡* [^41]。負載均衡和服務發現有許多實現方案:
為了提高可用性和可伸縮性,一項服務通常會在不同機器上執行多個例項,任一例項都能處理傳入的請求。把請求分攤到這些例項上的過程稱為 *負載均衡**load balancing*[^41]。負載均衡和服務發現有許多實現方案:
* *硬體負載均衡器* 是安裝在資料中心的專用裝置。客戶端只連線一個主機和埠,裝置再把傳入連線路由到執行該服務的某臺伺服器。此類負載均衡器會在連線下游伺服器時檢測網路故障,並將流量轉移到其他伺服器。
* *軟體負載均衡器* 的行為與硬體負載均衡器大體相同只是不需要專用裝置。Nginx 和 HAProxy 等軟體負載均衡器就是可以安裝在普通機器上的應用程式。
* *硬體負載均衡器**hardware load balancer*是安裝在資料中心的專用裝置。客戶端只連線一個主機和埠,裝置再把傳入連線路由到執行該服務的某臺伺服器。此類負載均衡器會在連線下游伺服器時檢測網路故障,並將流量轉移到其他伺服器。
* *軟體負載均衡器**software load balancer*的行為與硬體負載均衡器大體相同只是不需要專用裝置。Nginx 和 HAProxy 等軟體負載均衡器就是可以安裝在普通機器上的應用程式。
* *域名系統DNS* 用於在網際網路上解析域名,例如開啟網頁時就會用到。它允許一個域名關聯多個 IP 地址,從而實現負載均衡。客戶端可以配置為按域名而非 IP 地址連線服務,再由客戶端的網路層在建立連線時選擇某個 IP 地址。這種方法的缺點是DNS 原本就允許變更經過較長時間才完全傳播,而且會快取 DNS 條目。如果伺服器頻繁啟動、停止或遷移,客戶端可能拿到過期的 IP 地址,而該地址上已經沒有伺服器執行。
* *服務發現系統* 不使用 DNS而是透過集中式登錄檔跟蹤哪些服務端點可用。新服務例項啟動時會向發現系統註冊自己宣告正在監聽的主機和埠以及分片歸屬資訊參見 [第 7 章](/tw/ch7#ch_sharding))、資料中心位置等相關後設資料。隨後,服務定期向發現系統傳送心跳,表示自己仍然可用。
* *服務發現系統**service discovery system*不使用 DNS而是透過集中式登錄檔跟蹤哪些服務端點可用。新服務例項啟動時會向發現系統註冊自己宣告正在監聽的主機和埠以及分片歸屬資訊參見 [第 7 章](/tw/ch7#ch_sharding))、資料中心位置等相關後設資料。隨後,服務定期向發現系統傳送心跳,表示自己仍然可用。
客戶端要連線服務時,先向發現系統查詢可用端點列表,再直接連線某個端點。與 DNS 相比,服務發現更適合例項頻繁變化的動態環境。發現系統還會向客戶端提供更多服務後設資料,使客戶端能做出更明智的負載均衡決策。
* *服務網格* 是一種更複雜的負載均衡方案,把軟體負載均衡器與服務發現結合起來。傳統軟體負載均衡器執行在獨立機器上,服務網格的負載均衡器則通常部署為程序內客戶端庫,或者部署為伴隨客戶端和伺服器的程序或“邊車”容器。客戶端應用程式連線本機的服務負載均衡器,後者再連線伺服器一側的負載均衡器,最終把連線路由到本機的伺服器程序。
* *服務網格**service mesh*是一種更複雜的負載均衡方案,把軟體負載均衡器與服務發現結合起來。傳統軟體負載均衡器執行在獨立機器上,服務網格的負載均衡器則通常部署為程序內客戶端庫,或者部署為伴隨客戶端和伺服器的程序或“邊車”容器。客戶端應用程式連線本機的服務負載均衡器,後者再連線伺服器一側的負載均衡器,最終把連線路由到本機的伺服器程序。
這種拓撲雖然複雜,卻有不少優點。客戶端和伺服器應用程式都只需建立本地連線,因此連線加密可以完全由負載均衡器處理,讓應用程式不必面對 SSL 證書和 TLS 的複雜性。服務網格還提供了強大的可觀測性,能夠實時跟蹤服務間的呼叫關係、檢測故障、監測流量負載等。
@@ -472,28 +472,28 @@ RPC 經常用於跨組織邊界通訊,這讓服務相容性變得更加困難
按照定義,基於服務的架構由多項服務組成,每項服務負責應用程式的一部分。以支付處理應用為例,它要從信用卡扣款,再把資金存入銀行賬戶。系統很可能分別用不同服務負責欺詐檢測、信用卡整合、銀行系統整合等工作。
在這個例子中,處理一筆付款需要多次服務呼叫。支付處理服務可能先呼叫欺詐檢測服務檢查風險,再呼叫信用卡服務扣款,最後呼叫銀行服務把扣下的款項存入賬戶,如 {{< xref fig="5-7" page="/ch5" anchor="fig_encoding_workflow" >}}圖 5-7{{< /xref >}} 所示。這一系列步驟稱為 *工作流*,其中每一步稱為 *任務*。工作流通常定義成一張任務圖其定義可以使用通用程式語言、領域特定語言DSL也可以使用業務流程執行語言BPEL之類的標記語言 [^44]。
在這個例子中,處理一筆付款需要多次服務呼叫。支付處理服務可能先呼叫欺詐檢測服務檢查風險,再呼叫信用卡服務扣款,最後呼叫銀行服務把扣下的款項存入賬戶,如 {{< xref fig="5-7" page="/ch5" anchor="fig_encoding_workflow" >}}圖 5-7{{< /xref >}} 所示。這一系列步驟稱為 *工作流**workflow*,其中每一步稱為 *任務**task*。工作流通常定義成一張任務圖其定義可以使用通用程式語言、領域特定語言DSL也可以使用業務流程執行語言BPEL之類的標記語言 [^44]。
> [!TIP] 任務、活動與函式
>
> 不同的工作流引擎對任務有不同稱呼。例如Temporal 使用 *活動* 一詞,另一些引擎則稱之為 *持久函式*。名稱雖異,概念相同。
> 不同的工作流引擎對任務有不同稱呼。例如Temporal 使用 *活動**activity*一詞,另一些引擎則稱之為 *持久函式**durable function*。名稱雖異,概念相同。
{{< fig num="5-7" id="fig_encoding_workflow" src="/fig/ddia_0507.png" caption="使用圖形化的業務流程模型與標記法BPMN表示工作流的示例。" class="ddia-figure ddia-figure--panorama" width="2658" height="720" />}}
工作流由 *工作流引擎* 執行或執行。引擎決定每項任務何時執行、在哪臺機器上執行、任務失敗時該怎麼辦(例如執行任務的機器崩潰),以及允許多少任務並行執行等。
工作流由 *工作流引擎**workflow engine*執行或執行。引擎決定每項任務何時執行、在哪臺機器上執行、任務失敗時該怎麼辦(例如執行任務的機器崩潰),以及允許多少任務並行執行等。
工作流引擎通常由編排器和執行器組成編排器負責排程執行器負責真正執行任務。工作流被觸發後執行便開始。如果使用者定義了按時間執行的計劃例如每小時執行一次編排器可以自行觸發工作流Web 服務等外部來源,甚至人,也可以觸發工作流。一旦觸發,執行器就會受命執行任務。
工作流引擎種類繁多面向的使用場景也各不相同。Airflow、Dagster 和 Prefect 等引擎與資料系統整合,用於編排 ETL 任務。Camunda 和 Orkes 等引擎提供圖形化工作流表示,例如 {{< xref fig="5-7" page="/ch5" anchor="fig_encoding_workflow" >}}圖 5-7{{< /xref >}} 中的 BPMN讓非工程師也能更方便地定義和執行工作流。Temporal 和 Restate 等引擎則提供 *持久化執行*
工作流引擎種類繁多面向的使用場景也各不相同。Airflow、Dagster 和 Prefect 等引擎與資料系統整合,用於編排 ETL 任務。Camunda 和 Orkes 等引擎提供圖形化工作流表示,例如 {{< xref fig="5-7" page="/ch5" anchor="fig_encoding_workflow" >}}圖 5-7{{< /xref >}} 中的 BPMN讓非工程師也能更方便地定義和執行工作流。Temporal 和 Restate 等引擎則提供 *持久化執行**durable execution*
#### 持久化執行 {#durable-execution}
對於需要事務語義的服務架構,持久化執行框架已經成為一種流行的構建方式。在支付示例中,我們希望每筆付款都恰好處理一次。但工作流執行期間一旦發生故障,就可能出現信用卡已經扣款,銀行賬戶卻沒有收到相應款項的情況。在基於服務的架構中,無法簡單地把這兩項任務包進一個資料庫事務;況且,系統可能還要與我們無法充分控制的第三方支付閘道器互動。
持久化執行框架可以為工作流提供 *恰好一次語義*。任務失敗後,框架會重新執行它,但會跳過失敗前已經成功完成的 RPC 呼叫或狀態變更:框架表面上再次發起呼叫,實際上卻直接返回上一次呼叫的結果。這之所以可行,是因為框架把所有 RPC 和狀態變更都記錄在預寫日誌WAL之類的持久儲存中 [^45] [^46]。{{< xref eg="5-5" page="/ch5" anchor="fig_temporal_workflow" >}}示例 5-5{{< /xref >}} 展示了用 Temporal 定義支援持久化執行的工作流。
持久化執行框架可以為工作流提供 *恰好一次語義**exactly-once semantics*。任務失敗後,框架會重新執行它,但會跳過失敗前已經成功完成的 RPC 呼叫或狀態變更:框架表面上再次發起呼叫,實際上卻直接返回上一次呼叫的結果。這之所以可行,是因為框架把所有 RPC 和狀態變更都記錄在預寫日誌WAL之類的持久儲存中 [^45] [^46]。{{< xref eg="5-5" page="/ch5" anchor="fig_temporal_workflow" >}}示例 5-5{{< /xref >}} 展示了用 Temporal 定義支援持久化執行的工作流。
{{< eg num="5-5" id="fig_temporal_workflow" caption="用於圖 5-7 所示支付工作流的 Temporal 工作流定義片段" >}}
```python
@@ -528,7 +528,7 @@ Temporal 之類的框架並非沒有難題。外部服務——例如示例中
### 事件驅動的架構 {#sec_encoding_dataflow_msg}
最後,我們來簡要介紹 *事件驅動架構*,這是編碼資料在程序間流動的另一種方式。請求在這裡稱為 *事件* *訊息*;與 RPC 不同,傳送者通常不會等待接收者處理事件。事件一般也不會透過直接網路連線發給接收者,而是先經過一個臨時儲存訊息的中介,稱為 *訊息代理*,也叫 *事件代理*、*訊息佇列* 或 *面向訊息的中介軟體* [^50]。
最後,我們來簡要介紹 *事件驅動架構**event-driven architecture*,這是編碼資料在程序間流動的另一種方式。請求在這裡稱為 *事件**event**訊息**message*;與 RPC 不同,傳送者通常不會等待接收者處理事件。事件一般也不會透過直接網路連線發給接收者,而是先經過一個臨時儲存訊息的中介,稱為 *訊息代理**message broker*),也叫 *事件代理**event broker*)、*訊息佇列**message queue*)或 *面向訊息的中介軟體**message-oriented middleware* [^50]。
與直接使用 RPC 相比,訊息代理有幾個優點:
@@ -538,7 +538,7 @@ Temporal 之類的框架並非沒有難題。外部服務——例如示例中
* 它可以把同一條訊息傳送給多個接收者。
* 它從邏輯上解耦傳送者與接收者:傳送者只管釋出訊息,不必關心誰來消費。
透過訊息代理進行的通訊是 *非同步的*:傳送者不等待訊息送達,只管發出訊息,然後就將其忘掉。不過,也可以讓傳送者在另一條通道上等待響應,從而實現類似同步 RPC 的模型。
透過訊息代理進行的通訊是 *非同步的**asynchronous*:傳送者不等待訊息送達,只管發出訊息,然後就將其忘掉。不過,也可以讓傳送者在另一條通道上等待響應,從而實現類似同步 RPC 的模型。
#### 訊息代理 {#message-brokers}
@@ -546,8 +546,8 @@ Temporal 之類的框架並非沒有難題。外部服務——例如示例中
具體的傳遞語義因實現和配置而異,但最常見的是以下兩種訊息分發模式:
* 一個程序把訊息加入某個命名 *佇列*,代理再把訊息交給該佇列的一個 *消費者*。如果有多個消費者,其中只有一個會收到這條訊息。
* 一個程序把訊息釋出到某個命名 *主題*,代理再把訊息交給該主題的所有 *訂閱者*。如果有多個訂閱者,每個都會收到這條訊息。
* 一個程序把訊息加入某個命名 *佇列**queue*,代理再把訊息交給該佇列的一個 *消費者**consumer*。如果有多個消費者,其中只有一個會收到這條訊息。
* 一個程序把訊息釋出到某個命名 *主題**topic*,代理再把訊息交給該主題的所有 *訂閱者**subscriber*。如果有多個訂閱者,每個都會收到這條訊息。
訊息代理通常不強制使用特定資料模型:訊息只是附帶少量後設資料的位元組序列,因此可以採用任何編碼格式。常見做法是使用 Protocol Buffers、Avro 或 JSON並在訊息代理旁部署模式登錄檔用來儲存所有有效的模式版本並檢查相容性 [^19] [^21]。也可以使用 AsyncAPI——面向訊息傳遞、與 OpenAPI 對應的規範——來規定訊息模式。
@@ -557,9 +557,9 @@ Temporal 之類的框架並非沒有難題。外部服務——例如示例中
#### 分散式 actor 框架 {#distributed-actor-frameworks}
*Actor 模型* 是一種用於單程序併發的程式設計模型。它不直接處理執行緒以及隨之而來的競態條件、鎖和死鎖,而是把邏輯封裝在 *actor* 中。每個 actor 通常代表一個客戶端或實體,可以擁有不與其他 actor 共享的本地狀態,並透過收發非同步訊息與其他 actor 通訊。訊息傳遞並無保證:在某些錯誤場景下,訊息會丟失。由於每個 actor 一次只處理一條訊息,所以無需操心執行緒問題,而框架可以獨立排程每個 actor。
*Actor 模型**actor model*是一種用於單程序併發的程式設計模型。它不直接處理執行緒以及隨之而來的競態條件、鎖和死鎖,而是把邏輯封裝在 *actor* 中。每個 actor 通常代表一個客戶端或實體,可以擁有不與其他 actor 共享的本地狀態,並透過收發非同步訊息與其他 actor 通訊。訊息傳遞並無保證:在某些錯誤場景下,訊息會丟失。由於每個 actor 一次只處理一條訊息,所以無需操心執行緒問題,而框架可以獨立排程每個 actor。
Akka、Orleans [^51] 和 Erlang/OTP 等 *分散式 actor 框架*,用這種程式設計模型把應用程式擴充套件到多個節點。無論傳送者和接收者位於同一節點還是不同節點,都使用同一種訊息傳遞機制。如果雙方位於不同節點,訊息會被透明地編碼成位元組序列,透過網路傳送,再由另一端解碼。
Akka、Orleans [^51] 和 Erlang/OTP 等 *分散式 actor 框架**distributed actor framework*,用這種程式設計模型把應用程式擴充套件到多個節點。無論傳送者和接收者位於同一節點還是不同節點,都使用同一種訊息傳遞機制。如果雙方位於不同節點,訊息會被透明地編碼成位元組序列,透過網路傳送,再由另一端解碼。
位置透明性在 actor 模型中比在 RPC 中效果更好,因為 actor 模型本來就假設訊息可能丟失,即使訊息只在單個程序內傳遞也一樣。網路延遲固然可能高於程序內延遲,但在 actor 模型中,本地通訊與遠端通訊之間的根本差異要小得多。

View File

@@ -15,19 +15,19 @@ breadcrumbs: false
>
> —— 道格拉斯・亞當斯《基本無害》1992
*複製* 意味著在透過網路連線的多臺機器上保留相同資料的副本。正如 [“分散式與單節點系統”](/tw/ch1#sec_introduction_distributed) 中所討論的,我們希望複製資料,可能出於以下原因:
*複製**replication*意味著在透過網路連線的多臺機器上保留相同資料的副本。正如 [“分散式與單節點系統”](/tw/ch1#sec_introduction_distributed) 中所討論的,我們希望複製資料,可能出於以下原因:
* 使資料在地理上更接近使用者(從而降低訪問延遲)
* 即使系統的一部分發生故障,系統仍能繼續工作(從而提高可用性)
* 增加能夠處理讀查詢的機器數量(從而提高讀取吞吐量)
本章假設資料集足夠小,每臺機器都能儲存整個資料集的副本。在 [第 7 章](/tw/ch7#ch_sharding) 中,我們將放寬這一假設,討論單臺機器無法容納的大型資料集如何進行 *分片**分割槽*)。再往後的章節會討論複製資料系統中可能出現的各種故障,以及應對這些故障的方法。
本章假設資料集足夠小,每臺機器都能儲存整個資料集的副本。在 [第 7 章](/tw/ch7#ch_sharding) 中,我們將放寬這一假設,討論單臺機器無法容納的大型資料集如何進行 *分片**sharding*,也稱 *分割槽**partitioning*)。再往後的章節會討論複製資料系統中可能出現的各種故障,以及應對這些故障的方法。
如果要複製的資料不隨時間變化,複製就很簡單:只需把資料複製到每個節點一次,便大功告成。複製的全部難點都在於處理被複制資料的 *變更*,這正是本章的主題。我們將討論三類在節點之間複製變更的演算法:*單主複製*、*多主複製* 和 *無主複製*。幾乎所有分散式資料庫都採用其中一種。三者各有利弊,本章將逐一詳述。
如果要複製的資料不隨時間變化,複製就很簡單:只需把資料複製到每個節點一次,便大功告成。複製的全部難點都在於處理被複制資料的 *變更*,這正是本章的主題。我們將討論三類在節點之間複製變更的演算法:*單主複製**single-leader replication*)、*多主複製**multi-leader replication*)和 *無主複製**leaderless replication*。幾乎所有分散式資料庫都採用其中一種。三者各有利弊,本章將逐一詳述。
複製需要考慮許多權衡,例如採用同步複製還是非同步複製,以及如何處理失效的副本。這些往往都是資料庫的配置選項;具體細節因資料庫而異,但不同實現背後的基本原理大體相通。本章將討論這些選擇帶來的後果。
複製需要考慮許多權衡,例如採用 *同步複製**synchronous replication*)還是 *非同步複製**asynchronous replication*,以及如何處理失效的副本。這些往往都是資料庫的配置選項;具體細節因資料庫而異,但不同實現背後的基本原理大體相通。本章將討論這些選擇帶來的後果。
資料庫複製算得上是老生常談:自 20 世紀 70 年代有人開始研究以來,其基本原理並沒有太大變化 [^1],因為網路的根本約束也一直未變。即便如此,*最終一致性* 等概念仍常常引起困惑。在 [“複製延遲的問題”](/tw/ch6#sec_replication_lag) 中,我們會更精確地說明最終一致性,並討論 *讀己之寫*、*單調讀* 等保證。
資料庫複製算得上是老生常談:自 20 世紀 70 年代有人開始研究以來,其基本原理並沒有太大變化 [^1],因為網路的根本約束也一直未變。即便如此,*最終一致性**eventual consistency*等概念仍常常引起困惑。在 [“複製延遲的問題”](/tw/ch6#sec_replication_lag) 中,我們會更精確地說明最終一致性,並討論 *讀己之寫**read-your-writes*)、*單調讀**monotonic reads*等保證。
> [!TIP] 備份與複製
@@ -41,12 +41,12 @@ breadcrumbs: false
## 單主複製 {#sec_replication_leader}
每個儲存資料庫複製的節點都稱為一個 *副本*。存在多個副本時,一個問題不可避免:如何確保所有資料最終都出現在所有副本上?
每個儲存資料庫複製的節點都稱為一個 *副本**replica*。存在多個副本時,一個問題不可避免:如何確保所有資料最終都出現在所有副本上?
資料庫的每次寫入都必須由每個副本處理,否則各副本就會包含不同的資料。最常見的解決方案稱為 *基於領導者的複製*,也稱 *主備複製**主動/被動複製*。其工作原理如下(見 {{< xref fig="6-1" page="/ch6" anchor="fig_replication_leader_follower" >}}圖 6-1{{< /xref >}}
資料庫的每次寫入都必須由每個副本處理,否則各副本就會包含不同的資料。最常見的解決方案稱為 *基於領導者的複製**leader-based replication*),也稱 *主備複製**primary-backup*)或 *主動/被動複製**active/passive*。其工作原理如下(見 {{< xref fig="6-1" page="/ch6" anchor="fig_replication_leader_follower" >}}圖 6-1{{< /xref >}}
1. 其中一個副本被指定為 *領導者*(也稱 *主庫* ** [^2])。客戶端要寫入資料庫時,必須把請求發給領導者;領導者首先將新資料寫入本地儲存。
2. 其他副本稱為 *追隨者*(也稱 *只讀副本*、*備庫* 或 *熱備*)。領導者每次把新資料寫入本地儲存,也會把資料變更作為 *複製日誌**變更流* 傳送給所有追隨者。每個追隨者取得領導者的日誌,按照領導者處理寫入的相同順序應用所有寫入,從而更新本地的資料庫副本。
1. 其中一個副本被指定為 *領導者**leader*也稱 *主庫**primary****source* [^2])。客戶端要寫入資料庫時,必須把請求發給領導者;領導者首先將新資料寫入本地儲存。
2. 其他副本稱為 *追隨者**follower*也稱 *只讀副本**read replica**備庫**secondary*,或 *熱備**hot standby*)。領導者每次把新資料寫入本地儲存,也會把資料變更作為 *複製日誌**replication log*)或 *變更流**change stream*傳送給所有追隨者。每個追隨者取得領導者的日誌,按照領導者處理寫入的相同順序應用所有寫入,從而更新本地的資料庫副本。
3. 客戶端讀取資料庫時,可以查詢領導者,也可以查詢任意追隨者;但只有領導者接受寫入(從客戶端的角度看,追隨者是隻讀的)。
{{< fig num="6-1" id="fig_replication_leader_follower" src="/fig/ddia_0601.png" caption="單主複製把所有寫入都發往指定的領導者,再由領導者將變更流傳送給各追隨者副本。" class="ddia-figure ddia-figure--panorama" width="2880" height="925" />}}
@@ -62,7 +62,7 @@ breadcrumbs: false
### 同步複製與非同步複製 {#sec_replication_sync_async}
複製系統的一個重要細節是,複製究竟 *同步* 進行還是 *非同步* 進行。(在關聯式資料庫中,這通常是一個配置項;其他系統往往固定採用其中一種。)
複製系統的一個重要細節是,複製究竟 *同步**synchronous*進行還是 *非同步**asynchronous*進行。(在關聯式資料庫中,這通常是一個配置項;其他系統往往固定採用其中一種。)
設想 {{< xref fig="6-1" page="/ch6" anchor="fig_replication_leader_follower" >}}圖 6-1{{< /xref >}} 中的情形:某網站使用者更新個人頭像。客戶端在某個時刻向領導者發出更新請求,不久後領導者收到請求;領導者隨後在某個時刻把資料變更轉發給追隨者,並最終通知客戶端更新成功。{{< xref fig="6-2" page="/ch6" anchor="fig_replication_sync_replication" >}}圖 6-2{{< /xref >}} 展示了其中一種可能的時序。
@@ -74,7 +74,7 @@ breadcrumbs: false
同步複製的優點是,追隨者保證擁有與領導者一致的最新資料副本。領導者突然失效時,可以確信資料仍可從追隨者取得。缺點是,如果同步追隨者沒有響應——無論因為崩潰、網路故障還是其他原因——寫入就無法繼續。領導者必須阻塞所有寫入,直至同步副本重新可用。
因此,把所有追隨者都設為同步並不現實:任意一個節點停機都會拖垮整個系統。實踐中,資料庫所謂的同步複製,通常是指 *一個* 追隨者同步,其餘追隨者非同步。如果同步追隨者不可用或過慢,就把某個非同步追隨者切換為同步。這樣可以保證至少有兩個節點持有最新資料:領導者和一個同步追隨者。這種配置有時也稱為 *半同步*
因此,把所有追隨者都設為同步並不現實:任意一個節點停機都會拖垮整個系統。實踐中,資料庫所謂的同步複製,通常是指 *一個* 追隨者同步,其餘追隨者非同步。如果同步追隨者不可用或過慢,就把某個非同步追隨者切換為同步。這樣可以保證至少有兩個節點持有最新資料:領導者和一個同步追隨者。這種配置有時也稱為 *半同步**semi-synchronous*
有些系統會同步更新 *多數* 副本(例如含領導者在內的 5 個副本中更新 3 個),其餘少數副本非同步更新。這就是 *法定人數* 的一個例子,我們會在 [“讀寫仲裁”](/tw/ch6#sec_replication_quorum_condition) 中進一步討論。採用共識協議自動選舉領導者的系統經常使用多數法定人數,[第 10 章](/tw/ch10#ch_consistency) 將再次談到這個問題。
@@ -92,7 +92,7 @@ breadcrumbs: false
1. 取得領導者資料庫在某個時刻的一致快照;如果可能,不要鎖住整個資料庫。大多數資料庫都提供這一功能,因為備份同樣需要它。有些情況下需要藉助第三方工具,例如 MySQL 的 Percona XtraBackup。
2. 把快照複製到新的追隨者節點。
3. 追隨者連線領導者請求從快照生成之後發生的所有資料變更。這要求快照與領導者複製日誌中的準確位置相關聯。不同系統對這個位置有不同稱呼PostgreSQL 稱之為 *日誌序列號*MySQL 則有 *binlog 位點**全域性事務識別符號*GTID兩套機制。
3. 追隨者連線領導者請求從快照生成之後發生的所有資料變更。這要求快照與領導者複製日誌中的準確位置相關聯。不同系統對這個位置有不同稱呼PostgreSQL 稱之為 *日誌序列號**log sequence number*LSNMySQL 則有 *binlog 位點**binlog coordinates*)和 *全域性事務識別符號**global transaction identifiers*GTIDs)兩套機制。
4. 追隨者處理完快照之後積壓的資料變更時,就稱它已經 *趕上進度*。此後,它可以繼續隨時處理領導者產生的資料變更。
設定追隨者的實際步驟因資料庫而異。有些系統完全自動完成這一過程,另一些系統則需要管理員手工執行一套頗為晦澀的多步驟流程。
@@ -134,7 +134,7 @@ breadcrumbs: false
#### 領導者失效:故障切換 {#leader-failure-failover}
領導者失效處理起來更加棘手:必須把一個追隨者提升為新領導者,重新配置客戶端以便把寫入發給新領導者,並讓其他追隨者開始消費新領導者的資料變更。這個過程稱為 *故障切換*
領導者失效處理起來更加棘手:必須把一個追隨者提升為新領導者,重新配置客戶端以便把寫入發給新領導者,並讓其他追隨者開始消費新領導者的資料變更。這個過程稱為 *故障切換**failover*
故障切換可以手工完成——通知管理員領導者已經失效,再由管理員執行必要步驟選出新領導者;也可以自動完成。自動故障切換通常包括以下步驟:
@@ -146,7 +146,7 @@ breadcrumbs: false
* 如果使用非同步複製,新領導者可能沒有收到舊領導者失效前的全部寫入。選出新領導者後,原領導者若重新加入叢集,那些未複製的寫入該怎麼辦?與此同時,新領導者可能已經收到與之衝突的寫入。最常見的辦法是直接丟棄舊領導者尚未複製的寫入,這意味著你原以為已經提交的寫入其實並未持久儲存。
* 如果資料庫內容還需要與資料庫之外的儲存系統協調丟棄寫入尤其危險。例如GitHub 曾發生過一起事故 [^14]:一個資料過時的 MySQL 追隨者被提升為領導者。資料庫用自增計數器為新行分配主鍵;新領導者的計數器落後於舊領導者,因而重複使用了舊領導者已經分配過的一些主鍵。這些主鍵同時用於 Redis 儲存,主鍵重用造成 MySQL 與 Redis 資料不一致,最終使一些私有資料洩露給了錯誤的使用者。
* 在某些故障場景下(見 [第 9 章](/tw/ch9#ch_distributed)),可能有兩個節點都認為自己是領導者。這種情況稱為 *腦裂*,非常危險:如果兩個領導者都接受寫入,而系統又沒有衝突解決流程(參見 [“多主複製”](/tw/ch6#sec_replication_multi_leader)),資料很可能丟失或損壞。有些系統設有保險機制,一旦發現兩個領導者便關閉其中一個節點;但機制設計不當時,也可能把兩個節點都關閉 [^15]。而且,等系統發現腦裂並關閉舊節點時,可能已經為時過晚,資料早已損壞。
* 在某些故障場景下(見 [第 9 章](/tw/ch9#ch_distributed)),可能有兩個節點都認為自己是領導者。這種情況稱為 *腦裂**split brain*,非常危險:如果兩個領導者都接受寫入,而系統又沒有衝突解決流程(參見 [“多主複製”](/tw/ch6#sec_replication_multi_leader)),資料很可能丟失或損壞。有些系統設有保險機制,一旦發現兩個領導者便關閉其中一個節點;但機制設計不當時,也可能把兩個節點都關閉 [^15]。而且,等系統發現腦裂並關閉舊節點時,可能已經為時過晚,資料早已損壞。
* 宣佈領導者失效前,超時應該設為多長?超時越長,領導者確實失效時恢復所需的時間就越長;超時太短,又容易觸發不必要的故障切換。例如,短暫的負載尖峰可能使節點響應時間超過超時值,網路抖動也可能延遲資料包。如果系統已經飽受高負載或網路問題困擾,不必要的故障切換隻會讓情況更糟。
@@ -174,7 +174,7 @@ breadcrumbs: false
* 如果語句使用自增列,或依賴資料庫中的現有資料(例如 `UPDATE …​ WHERE <some condition>`),就必須在每個副本上按完全相同的順序執行,否則可能產生不同結果。有多個事務併發執行時,這會成為限制。
* 帶有副作用的語句(例如觸發器、儲存過程、使用者定義函式),可能在各副本上產生不同的副作用,除非這些副作用完全確定。
這些問題可以繞開。例如,領導者在記錄語句時,可以用固定的返回值替換非確定性函式呼叫,從而讓所有追隨者得到相同的值。按固定順序執行確定性語句,與 [“事件溯源與 CQRS”](/tw/ch3#sec_datamodels_events) 中介紹的事件溯源模型很相似。這種方法也稱為 *狀態機複製*[“使用共享日誌”](/tw/ch10#sec_consistency_smr) 將討論其理論基礎。
這些問題可以繞開。例如,領導者在記錄語句時,可以用固定的返回值替換非確定性函式呼叫,從而讓所有追隨者得到相同的值。按固定順序執行確定性語句,與 [“事件溯源與 CQRS”](/tw/ch3#sec_datamodels_events) 中介紹的事件溯源模型很相似。這種方法也稱為 *狀態機複製**state machine replication*[“使用共享日誌”](/tw/ch10#sec_consistency_smr) 將討論其理論基礎。
MySQL 5.1 以前使用基於語句的複製。由於日誌相當緊湊如今有時仍會採用這種方式不過預設情況下只要語句中存在任何非確定性MySQL 就會切換到稍後介紹的基於行的複製。VoltDB 也採用基於語句的複製,並要求事務必須是確定性的,以保證安全 [^16]。然而,實踐中很難確保確定性,因此許多資料庫更傾向於其他複製方式。
@@ -190,7 +190,7 @@ PostgreSQL、Oracle 等資料庫採用這種複製方式 [^17] [^18]。其主要
#### 邏輯(基於行)日誌複製 {#logical-row-based-log-replication}
另一種方法是讓複製和儲存引擎使用不同的日誌格式,從而使複製日誌與儲存引擎的內部實現解耦。這種複製日誌稱為 *邏輯日誌*,以區別於儲存引擎的(*物理*)資料表示。
另一種方法是讓複製和儲存引擎使用不同的日誌格式,從而使複製日誌與儲存引擎的內部實現解耦。這種複製日誌稱為 *邏輯日誌**logical log*,以區別於儲存引擎的(*物理**physical*)資料表示。
關聯式資料庫的邏輯日誌通常由一系列記錄組成,以行的粒度描述對資料庫表的寫入:
@@ -202,7 +202,7 @@ PostgreSQL、Oracle 等資料庫採用這種複製方式 [^17] [^18]。其主要
由於邏輯日誌與儲存引擎的內部實現解耦,更容易保持向後相容,因而領導者與追隨者可以執行不同版本的資料庫軟體,也就能以極少的停機時間升級到新版本 [^20]。
邏輯日誌格式也更容易由外部應用程式解析。如果要把資料庫內容傳送到外部系統,例如送入資料倉儲做離線分析,或構建自定義索引和快取 [^21],這一點會很有用。這種技術稱為 *變更資料捕獲*,我們會在 [“變更資料捕獲”](/tw/ch12#sec_stream_cdc) 中再次談到它。
邏輯日誌格式也更容易由外部應用程式解析。如果要把資料庫內容傳送到外部系統,例如送入資料倉儲做離線分析,或構建自定義索引和快取 [^21],這一點會很有用。這種技術稱為 *變更資料捕獲**change data capture*CDC,我們會在 [“變更資料捕獲”](/tw/ch12#sec_stream_cdc) 中再次談到它。
## 複製延遲的問題 {#sec_replication_lag}
@@ -212,7 +212,7 @@ PostgreSQL、Oracle 等資料庫採用這種複製方式 [^17] [^18]。其主要
在這種 *讀擴充套件* 架構中,只需增加追隨者,便可提高只讀請求的處理能力。不過,這種辦法實際上只適用於非同步複製。如果嘗試同步複製到所有追隨者,任意一個節點失效或網路中斷都會讓整個系統無法寫入。節點越多,越可能有某個節點停機,因此完全同步的配置會極不可靠。
不幸的是,應用程式從 *非同步* 追隨者讀取時,如果追隨者落後,就可能看到過時的資訊。資料庫於是顯得不一致:同時在領導者和追隨者上執行同一查詢,結果可能不同,因為追隨者尚未反映所有寫入。這種不一致只是暫時的——如果停止寫入並等待一段時間,追隨者最終會趕上領導者,恢復一致。因此,這種現象稱為 *最終一致性* [^22]。
不幸的是,應用程式從 *非同步**asynchronous*追隨者讀取時,如果追隨者落後,就可能看到過時的資訊。資料庫於是顯得不一致:同時在領導者和追隨者上執行同一查詢,結果可能不同,因為追隨者尚未反映所有寫入。這種不一致只是暫時的——如果停止寫入並等待一段時間,追隨者最終會趕上領導者,恢復一致。因此,這種現象稱為 *最終一致性**eventual consistency*[^22]。
> [!NOTE]
@@ -231,7 +231,7 @@ PostgreSQL、Oracle 等資料庫採用這種複製方式 [^17] [^18]。其主要
{{< fig num="6-3" id="fig_replication_read_your_writes" src="/fig/ddia_0603.png" caption="使用者寫入後,又從陳舊副本讀取。要防止這種異常,需要寫後讀一致性。" class="ddia-figure ddia-figure--wide" width="2880" height="1128" />}}
這種情況下需要 *寫後讀一致性*,也稱 *讀己之寫一致性* [^23]。它保證使用者重新載入頁面時,總能看到自己提交的更新。至於其他使用者則不作保證:他們的更新可能過一段時間才會出現。但至少使用者可以確信,自己的輸入已經正確儲存。
這種情況下需要 *寫後讀一致性**read-after-write consistency*),也稱 *讀己之寫一致性**read-your-writes consistency*[^23]。它保證使用者重新載入頁面時,總能看到自己提交的更新。至於其他使用者則不作保證:他們的更新可能過一段時間才會出現。但至少使用者可以確信,自己的輸入已經正確儲存。
如何在基於領導者的複製系統中實現寫後讀一致性?有多種辦法,例如:
@@ -291,7 +291,7 @@ Poons 先生
{{< fig num="6-5" id="fig_replication_consistent_prefix" src="/fig/ddia_0605.png" caption="如果某些分片的複製速度慢於其他分片,觀察者可能先看到答案,後看到問題。" class="ddia-figure ddia-figure--standard" width="2880" height="1843" />}}
防止這種異常需要另一種保證:*一致字首讀* [^22]。它保證,如果一系列寫入按某個順序發生,那麼任何人讀取這些寫入時,也會看到它們以相同順序出現。
防止這種異常需要另一種保證:*一致字首讀**consistent prefix reads*[^22]。它保證,如果一系列寫入按某個順序發生,那麼任何人讀取這些寫入時,也會看到它們以相同順序出現。
這在分片(分割槽)資料庫中尤其容易成為問題,我們將在 [第 7 章](/tw/ch7#ch_sharding) 討論這類資料庫。如果資料庫始終按相同順序應用寫入,讀取就總會看到一致字首,這種異常也不會發生。然而,在許多分散式資料庫中,不同分片彼此獨立執行,沒有全域性寫入順序。使用者讀取資料庫時,可能看到一部分處於較舊狀態,另一部分卻處於較新狀態。
@@ -316,7 +316,7 @@ Poons 先生
單主複製有一個主要缺點:所有寫入都必須經過唯一的領導者。無論出於什麼原因,只要連線不上領導者——例如客戶端與領導者之間的網路中斷——就無法寫入資料庫。
單主複製模型可以自然地擴充套件為允許多個節點接受寫入。複製仍以同樣方式進行:每個處理寫入的節點都必須把資料變更轉發給其他所有節點。我們把這種配置稱為 *多主複製*,也稱 *主動/主動複製**雙向複製*。在這種配置中,每個領導者同時也是其他領導者的追隨者。
單主複製模型可以自然地擴充套件為允許多個節點接受寫入。複製仍以同樣方式進行:每個處理寫入的節點都必須把資料變更轉發給其他所有節點。我們把這種配置稱為 *多主複製**multi-leader replication*),也稱 *主動/主動複製**active/active*)或 *雙向複製**bidirectional replication*。在這種配置中,每個領導者同時也是其他領導者的追隨者。
與單主複製一樣,多主複製也可以選擇同步或非同步。假設有兩個領導者 *A**B*,現在要向 *A* 寫入。如果寫入必須從 *A* 同步複製到 *B*,那麼兩者之間的網路一旦中斷,在網路恢復前就無法向 *A* 寫入。這種同步多主複製提供的模型與單主複製極為相似:也就是說,這等同於把 *B* 設為領導者,由 *A* 把所有寫請求轉發給 *B* 執行。
@@ -326,7 +326,7 @@ Poons 先生
在單個地區內使用多主配置通常沒有多少意義,因為所得好處很少能抵消額外的複雜性。不過,在某些場景中,這種配置確實合理。
設想一個資料庫在多個地區都有副本,也許是為了在整個地區失效時仍能執行,也許是為了在地理上更接近使用者。這種部署稱為 *地理分散式*、*跨地域分散式* 或 *跨地域複製*。採用單主複製時,領導者必須位於其中 *一個* 地區,所有寫入都要經過該地區。
設想一個資料庫在多個地區都有副本,也許是為了在整個地區失效時仍能執行,也許是為了在地理上更接近使用者。這種部署稱為 *地理分散式**geographically distributed*)、*跨地域分散式**geo-distributed*)或 *跨地域複製**geo-replicated*。採用單主複製時,領導者必須位於其中 *一個* 地區,所有寫入都要經過該地區。
在多主配置中,*每個* 地區都可以有一個領導者。{{< xref fig="6-6" page="/ch6" anchor="fig_replication_multi_dc" >}}圖 6-6{{< /xref >}} 展示了這種架構:每個地區內部使用常規的領導者—追隨者複製(追隨者可以位於與領導者不同的可用區);地區之間,則由各地區的領導者把變更復制給其他地區的領導者。
@@ -356,11 +356,11 @@ Poons 先生
#### 多主複製拓撲 {#sec_replication_topologies}
*複製拓撲* 描述寫入從一個節點傳播到另一個節點時所經過的通訊路徑。如果只有兩個領導者,如 {{< xref fig="6-9" page="/ch6" anchor="fig_replication_write_conflict" >}}圖 6-9{{< /xref >}} 所示,那麼只有一種合理的拓撲:領導者 1 必須把所有寫入傳送給領導者 2反之亦然。領導者超過兩個時則有多種拓撲可供選擇。{{< xref fig="6-7" page="/ch6" anchor="fig_replication_topologies" >}}圖 6-7{{< /xref >}} 給出了三個例子。
*複製拓撲**replication topology*描述寫入從一個節點傳播到另一個節點時所經過的通訊路徑。如果只有兩個領導者,如 {{< xref fig="6-9" page="/ch6" anchor="fig_replication_write_conflict" >}}圖 6-9{{< /xref >}} 所示,那麼只有一種合理的拓撲:領導者 1 必須把所有寫入傳送給領導者 2反之亦然。領導者超過兩個時則有多種拓撲可供選擇。{{< xref fig="6-7" page="/ch6" anchor="fig_replication_topologies" >}}圖 6-7{{< /xref >}} 給出了三個例子。
{{< fig num="6-7" id="fig_replication_topologies" src="/fig/ddia_0607.png" caption="多主複製可以採用的三種拓撲示例。" class="ddia-figure ddia-figure--panorama" width="2880" height="800" />}}
最通用的是 {{< xref fig="6-7" page="/ch6" anchor="fig_replication_topologies" >}}圖 6-7{{< /xref >}}(c) 所示的 *全對全* 拓撲,每個領導者都會把寫入傳送給其他所有領導者。不過,實際系統也會採用限制更多的拓撲。例如在 *環形拓撲* 中,每個節點從一個節點接收寫入,再把這些寫入連同自己的寫入轉發給另一個節點。另一種常見拓撲呈 *星形*:指定一個根節點,由它把寫入轉發給所有其他節點。星形拓撲還可以推廣成樹形。
最通用的是 {{< xref fig="6-7" page="/ch6" anchor="fig_replication_topologies" >}}圖 6-7{{< /xref >}}(c) 所示的 *全對全**all-to-all*拓撲,每個領導者都會把寫入傳送給其他所有領導者。不過,實際系統也會採用限制更多的拓撲。例如在 *環形拓撲**circular topology*中,每個節點從一個節點接收寫入,再把這些寫入連同自己的寫入轉發給另一個節點。另一種常見拓撲呈 *星形**star topology*:指定一個根節點,由它把寫入轉發給所有其他節點。星形拓撲還可以推廣成樹形。
> [!NOTE]
@@ -381,7 +381,7 @@ Poons 先生
這也是一個因果關係問題,與 [“一致字首讀”](/tw/ch6#sec_replication_consistent_prefix) 中看到的情況相似。更新依賴先前的插入,因此必須保證所有節點先處理插入,再處理更新。僅僅給每次寫入附加時間戳並不夠,因為不能相信各節點的時鐘同步得足以讓領導者 2 正確排列這些事件(見 [第 9 章](/tw/ch9#ch_distributed))。
要正確排列這些事件,可以採用本章稍後介紹的 *版本向量*(見 [“檢測併發寫入”](/tw/ch6#sec_replication_concurrent))。不過,許多多主複製系統並未使用可靠的更新排序技術,因而容易遇到 {{< xref fig="6-8" page="/ch6" anchor="fig_replication_causality" >}}圖 6-8{{< /xref >}} 所示的問題。如果使用多主複製,應當瞭解這些風險,仔細閱讀文件,並充分測試資料庫,確認它確實提供了你以為它會提供的保證。
要正確排列這些事件,可以採用本章稍後介紹的 *版本向量**version vector*見 [“檢測併發寫入”](/tw/ch6#sec_replication_concurrent))。不過,許多多主複製系統並未使用可靠的更新排序技術,因而容易遇到 {{< xref fig="6-8" page="/ch6" anchor="fig_replication_causality" >}}圖 6-8{{< /xref >}} 所示的問題。如果使用多主複製,應當瞭解這些風險,仔細閱讀文件,並充分測試資料庫,確認它確實提供了你以為它會提供的保證。
### 同步引擎與本地優先軟體 {#sec_replication_offline_clients}
@@ -401,7 +401,7 @@ Poons 先生
離線編輯與實時協作需要相似的複製基礎設施:應用程式必須捕獲使用者對檔案作出的所有變更,線上時立即發給協作者,離線時則先儲存在本地,稍後再傳送。同時,應用程式還要接收協作者的變更,將其合併到使用者的本地檔案副本,並更新介面以顯示最新版本。多個使用者併發修改檔案時,還可能需要用衝突解決邏輯合併這些變更。
支援這一過程的軟體庫稱為 *同步引擎*。這個想法由來已久,但“同步引擎”一詞近來才受到關注 [^35] [^36] [^37]。允許使用者離線時繼續編輯檔案的應用程式稱為 *離線優先* 應用 [^38],它可以用同步引擎來實現。*本地優先軟體* 則不僅要支援離線優先,還要保證即使軟體開發者關閉所有線上服務,協作應用仍能繼續工作 [^39]。一種實現方式是採用開放標準的同步協議,並讓多個服務提供商都能支援這一協議 [^40]。例如Git 就是本地優先的協作系統(雖然它不支援實時協作),因為可以透過 GitHub、GitLab 或其他任意程式碼倉庫託管服務進行同步。
支援這一過程的軟體庫稱為 *同步引擎**sync engine*。這個想法由來已久,但“同步引擎”一詞近來才受到關注 [^35] [^36] [^37]。允許使用者離線時繼續編輯檔案的應用程式稱為 *離線優先**offline-first*應用 [^38],它可以用同步引擎來實現。*本地優先軟體**local-first software*則不僅要支援離線優先,還要保證即使軟體開發者關閉所有線上服務,協作應用仍能繼續工作 [^39]。一種實現方式是採用開放標準的同步協議,並讓多個服務提供商都能支援這一協議 [^40]。例如Git 就是本地優先的協作系統(雖然它不支援實時協作),因為可以透過 GitHub、GitLab 或其他任意程式碼倉庫託管服務進行同步。
#### 同步引擎的利弊 {#pros-and-cons-of-sync-engines}
@@ -410,7 +410,7 @@ Poons 先生
* 資料在本地,使用者介面的響應速度可以遠快於等待服務呼叫返回資料。有些應用追求在圖形系統的 *下一幀* 響應使用者輸入:對於重新整理率為 60 Hz 的顯示器,這意味著要在 16 毫秒內完成渲染。
* 允許使用者離線工作很有價值,尤其是在連線時斷時續的移動裝置上。使用同步引擎後,應用程式無需另設離線模式:離線不過是網路延遲變得非常大。
* 與在應用程式碼中顯式呼叫服務相比,同步引擎簡化了前端應用的程式設計模型。正如 [“遠端過程呼叫RPC的問題”](/tw/ch5#sec_problems_with_rpc) 中所述,每次服務呼叫都要處理錯誤。例如,更新伺服器資料的請求失敗後,使用者介面必須以某種方式反映錯誤。同步引擎讓應用直接讀寫幾乎不會失敗的本地資料,從而形成更具宣告性的程式設計風格 [^41]。
* 要實時顯示其他使用者所作的編輯,需要接收變更通知,並據此高效更新使用者介面。同步引擎與 *響應式程式設計* 模型結合,是實現這一功能的好辦法 [^42]。
* 要實時顯示其他使用者所作的編輯,需要接收變更通知,並據此高效更新使用者介面。同步引擎與 *響應式程式設計**reactive programming*模型結合,是實現這一功能的好辦法 [^42]。
如果能事先下載使用者可能需要的全部資料,並持久儲存在客戶端,同步引擎的效果最好。這樣一來,需要時就能離線訪問;但也意味著,如果使用者可以訪問的資料量非常大,同步引擎便不適用。例如,下載使用者自己建立的全部檔案通常沒問題(單個使用者一般不會產生那麼多資料),下載一個電子商務網站的全部商品目錄則多半不合理。
@@ -519,7 +519,7 @@ OT 最常用於文字的實時協作編輯,例如 Google Docs [^32]CRDT 則
本章此前討論的單主複製與多主複製,都基於同一個思路:客戶端把寫請求發給某個節點(領導者),再由資料庫系統負責把寫入複製到其他副本。領導者決定處理寫入的順序,追隨者則按相同順序應用領導者的寫入。
另一些資料儲存系統採取了不同辦法:放棄領導者概念,允許任何副本直接接受客戶端寫入。最早的一些複製資料系統採用的就是無主模型 [^1] [^50]但在關聯式資料庫佔據主導地位的年代這個思路幾乎被遺忘。2007 年,亞馬遜把它用於內部的 *Dynamo* 系統 [^45]無主架構由此再度流行。Riak、Cassandra 和 ScyllaDB 都是受 Dynamo 啟發、採用無主複製模型的開源資料儲存,因此這類資料庫也稱為 *Dynamo 風格* 資料庫。
另一些資料儲存系統採取了不同辦法:放棄領導者概念,允許任何副本直接接受客戶端寫入。最早的一些複製資料系統採用的就是無主模型 [^1] [^50]但在關聯式資料庫佔據主導地位的年代這個思路幾乎被遺忘。2007 年,亞馬遜把它用於內部的 *Dynamo* 系統 [^45]無主架構由此再度流行。Riak、Cassandra 和 ScyllaDB 都是受 Dynamo 啟發、採用無主複製模型的開源資料儲存,因此這類資料庫也稱為 *Dynamo 風格**Dynamo-style*資料庫。
> [!NOTE]
@@ -547,14 +547,14 @@ OT 最常用於文字的實時協作編輯,例如 Google Docs [^32]CRDT 則
複製系統應當保證所有資料最終都會複製到每個副本。不可用節點恢復上線後怎樣補上停機期間錯過的寫入Dynamo 風格的資料儲存會使用以下幾種機制:
讀修復
讀修復read repair
: 客戶端並行讀取多個節點時,可以發現陳舊響應。例如在 {{< xref fig="6-12" page="/ch6" anchor="fig_replication_quorum_node_outage" >}}圖 6-12{{< /xref >}} 中,使用者 2345 從副本 3 得到版本 6 的值,從副本 1 和副本 2 得到版本 7 的值。客戶端發現副本 3 的值已經過時,於是把較新的值寫回這個副本。對於經常讀取的值,這種方法很有效。
提示移交
提示移交hinted handoff
: 某個副本不可用時,另一個副本可以替它儲存寫入,並把這些寫入記錄為 *提示*。原本應接收這些寫入的副本恢復後,儲存提示的副本會將它們傳送過去,然後刪除提示。即使某些值從未被讀取、無法透過讀修復更新,這個 *移交* 過程也能讓副本趕上進度。
反熵
: 此外,還有一個後臺程序定期查詢副本之間的資料差異,把缺失的資料從一個副本複製到另一個。與基於領導者的複製日誌不同,這個 *反熵過程* 並不按特定順序複製寫入,資料得到複製之前可能有很長延遲。
反熵anti-entropy
: 此外,還有一個後臺程序定期查詢副本之間的資料差異,把缺失的資料從一個副本複製到另一個。與基於領導者的複製日誌不同,這個 *反熵過程**anti-entropy process*並不按特定順序複製寫入,資料得到複製之前可能有很長延遲。
#### 讀寫仲裁 {#sec_replication_quorum_condition}
@@ -562,7 +562,7 @@ OT 最常用於文字的實時協作編輯,例如 Google Docs [^32]CRDT 則
如果能保證每次成功寫入至少儲存在三個副本中的兩個上,那麼最多隻有一個副本是陳舊的。因此,只要讀取至少兩個副本,就可以確信其中至少一個是最新的。即使第三個副本停機或響應緩慢,讀取仍能返回最新值。
更一般地說,假設有 *n* 個副本,每次寫入必須得到 *w* 個節點確認才算成功,每次讀取則至少查詢 *r* 個節點。(上述例子中,*n* = 3、*w* = 2、*r* = 2。只要 *w* + *r* > *n*,讀取時就有望得到最新值,因為所查詢的 *r* 個節點中,至少有一個必然是最新的。遵守這些 *r*、*w* 取值的操作稱為 *仲裁讀**仲裁寫* [^50]。可以把 *r**w* 看作一次讀或寫要成立所需的最低票數。
更一般地說,假設有 *n* 個副本,每次寫入必須得到 *w* 個節點確認才算成功,每次讀取則至少查詢 *r* 個節點。(上述例子中,*n* = 3、*w* = 2、*r* = 2。只要 *w* + *r* > *n*,讀取時就有望得到最新值,因為所查詢的 *r* 個節點中,至少有一個必然是最新的。遵守這些 *r*、*w* 取值的操作稱為 *仲裁讀**quorum read*)和 *仲裁寫**quorum write*[^50]。可以把 *r**w* 看作一次讀或寫要成立所需的最低票數。
在 Dynamo 風格的資料庫中,引數 *n*、*w*、*r* 通常都可以配置。常見做法是讓 *n* 取奇數(通常為 3 或 5並令 *w* = *r* = (*n* + 1) / 2向上取整不過也可以根據需要調整。例如寫少讀多的工作負載可能適合設為 *w* = *n*、*r* = 1。這樣讀取更快缺點是隻要一個節點失效所有資料庫寫入都會失敗。
@@ -625,15 +625,15 @@ OT 最常用於文字的實時協作編輯,例如 Google Docs [^32]CRDT 則
* 領導者失效後,必須等系統檢測到故障並完成故障切換,才能繼續處理請求。即使故障切換很快,響應時間的短暫上升也會被使用者察覺;如果耗時很長,系統就會在此期間不可用。
* 系統對領導者的效能問題極其敏感。如果領導者因過載或資源爭用而響應緩慢,使用者的響應時間也會立即增加。
無主架構的一大優點,是面對這些問題時韌性更強。系統無需故障切換,而且請求原本就會並行發往多個副本,因此一個副本變慢或不可用,對響應時間的影響很小:客戶端只需採用響應較快的其他副本所返回的結果。採用最快響應的做法稱為 *請求對沖*,可以顯著降低尾延遲 [^55]。
無主架構的一大優點,是面對這些問題時韌性更強。系統無需故障切換,而且請求原本就會並行發往多個副本,因此一個副本變慢或不可用,對響應時間的影響很小:客戶端只需採用響應較快的其他副本所返回的結果。採用最快響應的做法稱為 *請求對沖**request hedging*,可以顯著降低尾延遲 [^55]。
無主系統之所以有這種韌性,根本原因是它不區分正常情況和故障情況。這對於處理所謂的 *灰色失效* 尤其有利:節點並未徹底停機,卻處於降級狀態,處理請求異常緩慢 [^56];節點單純過載時也是如此(例如節點離線一段時間後,靠提示移交恢復可能產生大量額外負載)。基於領導者的系統必須判斷情況是否嚴重到需要故障切換,而故障切換本身又可能帶來進一步中斷;無主系統根本不需要作出這項判斷。
無主系統之所以有這種韌性,根本原因是它不區分正常情況和故障情況。這對於處理所謂的 *灰色失效**gray failure*尤其有利:節點並未徹底停機,卻處於降級狀態,處理請求異常緩慢 [^56];節點單純過載時也是如此(例如節點離線一段時間後,靠提示移交恢復可能產生大量額外負載)。基於領導者的系統必須判斷情況是否嚴重到需要故障切換,而故障切換本身又可能帶來進一步中斷;無主系統根本不需要作出這項判斷。
當然,無主系統也可能遇到效能問題:
* 即使不需要執行故障切換,也必須由一個副本發現另一個副本不可用,才能替它儲存錯過寫入的提示。不可用副本恢復後,移交過程還要把這些提示發給它。在系統本已承壓時,這會給副本增加額外負載 [^54]。
* 副本越多,法定人數越大,請求完成前必須等待的響應也越多。即使只等待最快的 *r**w* 個副本,即使所有請求並行發出,更大的 *r**w* 仍會提高遇到慢副本的機率,從而增加總體響應時間(參見 [“響應時間指標的應用”](/tw/ch2#sec_introduction_slo_sla))。
* 大範圍網路中斷使客戶端與大量副本斷開時可能根本無法組成法定人數。有些無主資料庫允許任何可達副本接受寫入即使它不屬於該鍵通常所在的副本集合Riak 和 Dynamo 稱之為 *寬鬆仲裁* [^45]Cassandra 和 ScyllaDB 稱之為 *一致性級別 ANY*)。後續讀取不保證能看到這次寫入,但對某些應用而言,這仍好過寫入直接失敗。
* 大範圍網路中斷使客戶端與大量副本斷開時可能根本無法組成法定人數。有些無主資料庫允許任何可達副本接受寫入即使它不屬於該鍵通常所在的副本集合Riak 和 Dynamo 稱之為 *寬鬆仲裁**sloppy quorum* [^45]Cassandra 和 ScyllaDB 稱之為 *一致性級別 ANY*)。後續讀取不保證能看到這次寫入,但對某些應用而言,這仍好過寫入直接失敗。
多主複製抵禦網路中斷的能力甚至可能強於無主複製,因為讀寫只需與一個領導者通訊,而領導者可以與客戶端位於同一地區。不過,一個領導者上的寫入會非同步傳播給其他領導者,讀取結果因而可能任意陳舊。仲裁讀寫提供了一種折中:既有良好的容錯能力,也有很高機率讀到最新資料。
@@ -672,7 +672,7 @@ Riak 則把客戶端與資料庫節點之間的所有通訊限制在本地地區
* 在 {{< xref fig="6-8" page="/ch6" anchor="fig_replication_causality" >}}圖 6-8{{< /xref >}} 中兩次寫入併不併發A 的插入 *先發生於* B 的遞增,因為 B 所遞增的值正是 A 插入的值。換句話說B 的操作建立在 A 的操作之上,所以 B 必然發生得更晚。也可以說B *因果依賴* 於 A。
* {{< xref fig="6-14" page="/ch6" anchor="fig_replication_concurrency" >}}圖 6-14{{< /xref >}} 中的兩次寫入則是併發的:每個客戶端開始操作時,都不知道另一個客戶端也在操作同一個鍵。因此,兩次操作之間沒有因果依賴。
如果操作 B 知道 A、依賴 A或以某種方式建立在 A 之上,就稱操作 A *先發生於* 操作 B。一項操作是否先發生於另一項操作是定義併發的關鍵。事實上只要兩個操作誰也不先發生於另一個——也就是說誰都不知道對方——就可以稱它們 *併發* [^57]。
如果操作 B 知道 A、依賴 A或以某種方式建立在 A 之上,就稱操作 A *先發生於**happens before*操作 B。一項操作是否先發生於另一項操作是定義併發的關鍵。事實上只要兩個操作誰也不先發生於另一個——也就是說誰都不知道對方——就可以稱它們 *併發**concurrent*[^57]。
因此,對於任意兩個操作 A 與 B只有三種可能A 先發生於 BB 先發生於 A或者 A 與 B 併發。我們需要一種演算法判斷兩次操作是否併發。如果一項操作先發生於另一項,後發生的操作就應覆蓋先前操作;如果兩者併發,則出現了需要解決的衝突。
@@ -721,16 +721,16 @@ Riak 則把客戶端與資料庫節點之間的所有通訊限制在本地地區
{{< xref fig="6-15" page="/ch6" anchor="fig_replication_causality_single" >}}圖 6-15{{< /xref >}} 用一個版本號捕獲操作之間的依賴關係,但多個副本併發接受寫入時,一個版本號就不夠了。此時必須針對每個鍵,給 *每個副本* 分別維護版本號。副本處理寫入時遞增自己的版本號,同時記錄自己見過的其他副本版本號。這些資訊表明哪些值應當覆蓋,哪些值應作為兄弟值保留。
所有副本的版本號集合稱為 *版本向量* [^58]。這種思路有若干變體,其中最值得關注的也許是 *點化版本向量* [^59] [^60]Riak 2.0 採用了這種變體 [^61] [^62]。這裡不展開細節;它的工作方式與購物車例子非常相似。
所有副本的版本號集合稱為 *版本向量* [^58]。這種思路有若干變體,其中最值得關注的也許是 *點化版本向量**dotted version vector*[^59] [^60]Riak 2.0 採用了這種變體 [^61] [^62]。這裡不展開細節;它的工作方式與購物車例子非常相似。
與 {{< xref fig="6-15" page="/ch6" anchor="fig_replication_causality_single" >}}圖 6-15{{< /xref >}} 中的版本號一樣讀取時資料庫副本會把版本向量發給客戶端隨後寫入時客戶端必須再把它帶回資料庫。Riak 把版本向量編碼成一個字串,稱為 *因果上下文*。)版本向量讓資料庫能夠區分覆蓋寫入和併發寫入。
與 {{< xref fig="6-15" page="/ch6" anchor="fig_replication_causality_single" >}}圖 6-15{{< /xref >}} 中的版本號一樣讀取時資料庫副本會把版本向量發給客戶端隨後寫入時客戶端必須再把它帶回資料庫。Riak 把版本向量編碼成一個字串,稱為 *因果上下文**causal context*。)版本向量讓資料庫能夠區分覆蓋寫入和併發寫入。
版本向量還保證:先從一個副本讀取,再把寫入發給另一個副本,是安全的。這樣做可能產生兄弟值,但只要正確合併兄弟值,就不會丟失資料。
> [!TIP] 版本向量與向量時鐘
>
> *版本向量* 有時也稱為 *向量時鐘*,儘管兩者並不完全相同。區別十分微妙,細節請參閱相關文獻 [^60] [^63] [^64]。簡而言之,比較副本狀態時,應當使用版本向量。
> *版本向量* 有時也稱為 *向量時鐘**vector clock*,儘管兩者並不完全相同。區別十分微妙,細節請參閱相關文獻 [^60] [^63] [^64]。簡而言之,比較副本狀態時,應當使用版本向量。
## 總結 {#summary}
@@ -850,4 +850,4 @@ Riak 則把客戶端與資料庫節點之間的所有通訊限制在本地地區
[^61]: Sean Cribbs. [A Brief History of Time in Riak](https://speakerdeck.com/seancribbs/a-brief-history-of-time-in-riak). At *RICON*, October 2014. Archived at [perma.cc/7U9P-6JFX](https://perma.cc/7U9P-6JFX)
[^62]: Russell Brown. [Vector Clocks Revisited Part 2: Dotted Version Vectors](https://riak.com/posts/technical/vector-clocks-revisited-part-2-dotted-version-vectors/). *riak.com*, November 2015. Archived at [perma.cc/96QP-W98R](https://perma.cc/96QP-W98R)
[^63]: Carlos Baquero. [Version Vectors Are Not Vector Clocks](https://haslab.wordpress.com/2011/07/08/version-vectors-are-not-vector-clocks/). *haslab.wordpress.com*, July 2011. Archived at [perma.cc/7PNU-4AMG](https://perma.cc/7PNU-4AMG)
[^64]: Reinhard Schwarz and Friedemann Mattern. [Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail](https://disco.ethz.ch/courses/hs08/seminar/papers/mattern4.pdf). *Distributed Computing*, volume 7, issue 3, pages 149174, March 1994. [doi:10.1007/BF02277859](https://doi.org/10.1007/BF02277859)
[^64]: Reinhard Schwarz and Friedemann Mattern. [Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail](https://disco.ethz.ch/courses/hs08/seminar/papers/mattern4.pdf). *Distributed Computing*, volume 7, issue 3, pages 149174, March 1994. [doi:10.1007/BF02277859](https://doi.org/10.1007/BF02277859)

View File

@@ -17,7 +17,7 @@ breadcrumbs: false
分散式資料庫通常透過兩種方式在節點間分佈資料:
1. 在多個節點上儲存相同資料的副本:這就是 *複製*,我們已在 [第 6 章](/tw/ch6#ch_replication) 中討論過。
1. 在多個節點上儲存相同資料的副本:這就是 *複製**replication*,我們已在 [第 6 章](/tw/ch6#ch_replication) 中討論過。
2. 如果不想讓每個節點都儲存全部資料,可以將大規模資料集拆成更小的 *分片shard**分割槽partition*,再把不同分片存放到不同節點上。本章討論的就是分片。
通常情況下,每條資料(每條記錄、每行或每個文件)屬於且僅屬於一個分片。實現這一點有多種方法,本章將深入討論其中幾種。實際上,每個分片都是自己的小型資料庫,儘管有些資料庫支援同時涉及多個分片的操作。
@@ -42,23 +42,23 @@ breadcrumbs: false
## 分片的利與弊 {#sec_sharding_reasons}
對資料庫進行分片,主要是為了獲得 *可伸縮性*:當資料量或寫入吞吐量大到單個節點無法承受時,分片可以把資料和寫入分散到多個節點上。(如果瓶頸是讀取吞吐量,則未必需要分片,可以採用 [第 6 章](/tw/ch6#ch_replication) 介紹的 *讀擴充套件*。)
對資料庫進行分片,主要是為了獲得 *可伸縮性**scalability*:當資料量或寫入吞吐量大到單個節點無法承受時,分片可以把資料和寫入分散到多個節點上。(如果瓶頸是讀取吞吐量,則未必需要分片,可以採用 [第 6 章](/tw/ch6#ch_replication) 介紹的 *讀擴充套件**read scaling*。)
事實上,分片是實現 *水平擴充套件**橫向擴充套件* 架構)的主要手段之一,正如 [“共享記憶體、共享磁碟與無共享架構”](/tw/ch2#sec_introduction_shared_nothing) 所述:系統不必換用更大的機器,而是透過增加更多(較小的)機器來擴充容量。如果能合理劃分工作負載,讓每個分片承擔大致相等的份額,就可以把這些分片分配給不同機器,並行處理其中的資料和查詢。
事實上,分片是實現 *水平擴充套件**horizontal scaling*,也稱 *橫向擴充套件**scale-out* 架構)的主要手段之一,正如 [“共享記憶體、共享磁碟與無共享架構”](/tw/ch2#sec_introduction_shared_nothing) 所述:系統不必換用更大的機器,而是透過增加更多(較小的)機器來擴充容量。如果能合理劃分工作負載,讓每個分片承擔大致相等的份額,就可以把這些分片分配給不同機器,並行處理其中的資料和查詢。
複製可以提供容錯和離線執行能力,因而無論規模大小都有用;分片卻是一種重量級方案,主要適用於大規模場景。如果資料量和寫入吞吐量仍可由單臺機器處理(如今單機的能力可不容小覷!),通常最好避免分片,堅持使用單分片資料庫。
之所以這樣建議,是因為分片往往會增加複雜性。通常需要選擇一個 *分割槽鍵*,據此決定每條記錄應放入哪個分片;分割槽鍵相同的記錄都會進入同一分片 [^4]。這個選擇十分重要:如果知道記錄在哪個分片,訪問就很快;如果不知道,就只能低效地搜尋所有分片,而且日後很難更改分片方案。
之所以這樣建議,是因為分片往往會增加複雜性。通常需要選擇一個 *分割槽鍵**partition key*,據此決定每條記錄應放入哪個分片;分割槽鍵相同的記錄都會進入同一分片 [^4]。這個選擇十分重要:如果知道記錄在哪個分片,訪問就很快;如果不知道,就只能低效地搜尋所有分片,而且日後很難更改分片方案。
因此,分片通常很適合鍵值資料,因為可以直接按鍵分片;關係資料則比較棘手,因為你可能需要透過二級索引搜尋,或連線散落在不同分片中的記錄。我們將在 [“分片與二級索引”](/tw/ch7#sec_sharding_secondary_indexes) 中進一步討論這個問題。
分片還有一個問題:一次寫入可能需要更新多個不同分片中的相關記錄。單節點事務相當普遍(參見 [第 8 章](/tw/ch8#ch_transactions)),但要保證多個分片之間的一致性,就需要 *分散式事務*。正如 [第 8 章](/tw/ch8#ch_transactions) 將要說明的,有些資料庫支援分散式事務,但這類事務通常比單節點事務慢得多,可能成為整個系統的瓶頸;還有些系統根本不支援分散式事務。
分片還有一個問題:一次寫入可能需要更新多個不同分片中的相關記錄。單節點事務相當普遍(參見 [第 8 章](/tw/ch8#ch_transactions)),但要保證多個分片之間的一致性,就需要 *分散式事務**distributed transaction*。正如 [第 8 章](/tw/ch8#ch_transactions) 將要說明的,有些資料庫支援分散式事務,但這類事務通常比單節點事務慢得多,可能成為整個系統的瓶頸;還有些系統根本不支援分散式事務。
有些系統甚至會在單臺機器上使用分片,通常是在每個 CPU 核心上執行一個單執行緒程序,以利用 CPU 的並行能力;或者利用 *非一致性記憶體訪問*NUMA架構因為其中某些記憶體區域離特定 CPU 比其他 CPU 更近 [^5]。例如Redis、VoltDB 和 FoundationDB 都採用每個核心一個程序的方式,並依靠分片把負載分攤到同一臺機器的各個 CPU 核心上 [^6]。
### 面向多租戶的分片 {#sec_sharding_multitenancy}
軟體即服務SaaS產品和雲服務通常採用 *多租戶* 模式,每個租戶對應一個客戶。同一租戶可以有多個使用者賬號,但每個租戶擁有一份自成一體、與其他租戶隔離的資料集。例如在電子郵件營銷服務中,每家註冊企業通常都是一個獨立租戶,因為各家企業的簡報訂閱資訊、投遞資料等彼此無關。
軟體即服務SaaS產品和雲服務通常採用 *多租戶**multitenant*模式,每個租戶對應一個客戶。同一租戶可以有多個使用者賬號,但每個租戶擁有一份自成一體、與其他租戶隔離的資料集。例如在電子郵件營銷服務中,每家註冊企業通常都是一個獨立租戶,因為各家企業的簡報訂閱資訊、投遞資料等彼此無關。
多租戶系統有時透過分片來實現:可以為每個租戶分配一個獨立分片,也可以把多個小租戶歸入一個較大的分片。這些分片可以是物理上相互獨立的資料庫(我們曾在 [“嵌入式儲存引擎”](/tw/ch4#sidebar_embedded) 中提到),也可以是一個更大邏輯資料庫中能夠單獨管理的組成部分 [^7]。用分片實現多租戶有以下優點:
@@ -69,7 +69,7 @@ breadcrumbs: false
: 如果訪問控制邏輯存在漏洞,只要各租戶的資料集在物理上彼此隔離,意外讓一個租戶訪問另一租戶資料的可能性就會降低。
單元化架構
: 分片不僅可以用在資料儲存層,也可以用來劃分執行應用程式碼的服務。在 *單元化架構* 中,為一組特定租戶服務的應用與儲存會組成一個自包含的 *單元*,不同單元大體可以彼此獨立地執行。這種方法能夠實現 *故障隔離*:一個單元裡的故障只影響該單元,不會殃及其他單元中的租戶 [^8]。
: 分片不僅可以用在資料儲存層,也可以用來劃分執行應用程式碼的服務。在 *單元化架構**cell-based architecture*中,為一組特定租戶服務的應用與儲存會組成一個自包含的 *單元**cell*,不同單元大體可以彼此獨立地執行。這種方法能夠實現 *故障隔離**fault isolation*:一個單元裡的故障只影響該單元,不會殃及其他單元中的租戶 [^8]。
按租戶備份和恢復
: 分別備份每個租戶的分片,就能從備份中恢復某個租戶的狀態,而不影響其他租戶。租戶意外刪除或覆蓋重要資料時,這一能力很有用 [^9]。
@@ -95,9 +95,9 @@ breadcrumbs: false
假設你有大量資料並且想要分片,如何決定在哪些節點上儲存哪些記錄呢?
分片的目標是將資料和查詢負載均勻分佈在各個節點上。如果每個節點公平分擔資料和負載那麼理論上10 個節點應該能夠處理單個節點 10 倍的資料量和 10 倍的讀寫吞吐量(暫時忽略複製)。此外,在新增或移除節點時,我們希望能夠 *再平衡* 負載,使它均勻分佈在增加後的 11 個節點上,或移除節點後剩餘的 9 個節點上。
分片的目標是將資料和查詢負載均勻分佈在各個節點上。如果每個節點公平分擔資料和負載那麼理論上10 個節點應該能夠處理單個節點 10 倍的資料量和 10 倍的讀寫吞吐量(暫時忽略複製)。此外,在新增或移除節點時,我們希望能夠 *再平衡**rebalance*負載,使它均勻分佈在增加後的 11 個節點上,或移除節點後剩餘的 9 個節點上。
如果分片不公平,某些分片承載的資料或查詢比其他分片更多,我們就稱其為 *傾斜*。傾斜會大幅降低分片的效果。在極端情況下全部負載都可能集中到一個分片上10 個節點中有 9 個閒置,瓶頸卻卡在唯一繁忙的節點上。負載高得不成比例的分片稱為 *熱分片**熱點*;如果某個鍵的負載特別高(例如社交網路中的名人賬號),則稱為 *熱鍵*
如果分片不公平,某些分片承載的資料或查詢比其他分片更多,我們就稱其為 *傾斜**skew*。傾斜會大幅降低分片的效果。在極端情況下全部負載都可能集中到一個分片上10 個節點中有 9 個閒置,瓶頸卻卡在唯一繁忙的節點上。負載高得不成比例的分片稱為 *熱分片**hot shard*)或 *熱點**hot spot*;如果某個鍵的負載特別高(例如社交網路中的名人賬號),則稱為 *熱鍵**hot key*
因此,我們需要一種演算法,以記錄的分割槽鍵為輸入,指出這條記錄屬於哪個分片。在鍵值儲存中,分割槽鍵通常就是鍵或鍵的第一部分;在關係模型中,它可以是表中的某一列,不一定非得是主鍵。為了緩解熱點,這種演算法還必須便於再平衡。
@@ -193,14 +193,14 @@ YugabyteDB 和 DynamoDB 採用雜湊範圍分片 [^17]MongoDB 也把它作為
#### 一致性雜湊 {#sec_sharding_consistent_hashing}
*一致性雜湊* 演算法是一種雜湊函式,它把鍵對映到指定數量的分片,並滿足兩個性質:
*一致性雜湊**consistent hashing*演算法是一種雜湊函式,它把鍵對映到指定數量的分片,並滿足兩個性質:
1. 對映到各個分片的鍵數大致相等;
2. 分片數量改變時,儘可能少地在分片之間遷移鍵。
注意,這裡的 *一致性* 與副本一致性(參見 [第 6 章](/tw/ch6#ch_replication))或 ACID 一致性(參見 [第 8 章](/tw/ch8#ch_transactions))毫無關係;它描述的是讓一個鍵儘量留在原分片中的傾向。
Cassandra 和 ScyllaDB 的分片演算法與一致性雜湊的原始定義相似 [^20],此外還有人提出了多種其他一致性雜湊演算法 [^21],例如 *最高隨機權重*也稱 *約會雜湊*[^22] 和 *跳躍一致性雜湊* [^23]。採用 Cassandra 的演算法時,加入一個節點會把少量現有分片拆成若干子範圍;採用約會雜湊或跳躍一致性雜湊時,新節點得到的則是此前散佈在所有其他節點上的一個個鍵。哪種方式更合適,取決於具體應用。
Cassandra 和 ScyllaDB 的分片演算法與一致性雜湊的原始定義相似 [^20],此外還有人提出了多種其他一致性雜湊演算法 [^21],例如 *最高隨機權重**highest random weight*,也稱 *約會雜湊**rendezvous hashing*[^22] 和 *跳躍一致性雜湊**jump consistent hash*[^23]。採用 Cassandra 的演算法時,加入一個節點會把少量現有分片拆成若干子範圍;採用約會雜湊或跳躍一致性雜湊時,新節點得到的則是此前散佈在所有其他節點上的一個個鍵。哪種方式更合適,取決於具體應用。
### 傾斜的工作負載與緩解熱點 {#sec_sharding_skew}
@@ -216,7 +216,7 @@ Cassandra 和 ScyllaDB 的分片演算法與一致性雜湊的原始定義相似
負載還會隨時間變化,使問題更加複雜。例如,某條突然爆火的社交媒體帖子可能連續幾天承受很高負載,之後又很快歸於平靜。此外,有些鍵是寫入熱點,有些則是讀取熱點,二者需要採用不同的處理策略。
一些系統尤其是面向大規模場景設計的雲服務能夠自動處理熱分片例如Amazon 把相關機制稱為 *熱度管理* [^28] 或 *自適應容量* [^17]。這些系統的具體工作方式超出了本書的討論範圍。
一些系統尤其是面向大規模場景設計的雲服務能夠自動處理熱分片例如Amazon 把相關機制稱為 *熱度管理**heat management*[^28] 或 *自適應容量**adaptive capacity*[^17]。這些系統的具體工作方式超出了本書的討論範圍。
### 運維:自動/手動再平衡 {#sec_sharding_operations}
@@ -238,7 +238,7 @@ Cassandra 和 ScyllaDB 的分片演算法與一致性雜湊的原始定義相似
我們已經討論了如何把資料集分片到多個節點,以及如何在增刪節點時再平衡這些分片。現在來看下一個問題:如果想讀寫某個特定的鍵,怎樣知道應該連線哪個節點——也就是哪個 IP 地址和埠?
這個問題稱為 *請求路由*,與前文 [“負載均衡器、服務發現和服務網格”](/tw/ch5#sec_encoding_service_discovery) 討論的 *服務發現* 十分相似。二者最大的區別在於:執行應用程式碼的服務例項通常是無狀態的,負載均衡器可以把請求發給任意例項;而在分片資料庫中,某個鍵的請求只能交給持有該鍵所在分片副本的節點處理。
這個問題稱為 *請求路由**request routing*,與前文 [“負載均衡器、服務發現和服務網格”](/tw/ch5#sec_encoding_service_discovery) 討論的 *服務發現**service discovery*十分相似。二者最大的區別在於:執行應用程式碼的服務例項通常是無狀態的,負載均衡器可以把請求發給任意例項;而在分片資料庫中,某個鍵的請求只能交給持有該鍵所在分片副本的節點處理。
因此,請求路由必須瞭解鍵到分片、以及分片到節點的對映。概括來說,有以下幾種辦法(如 {{< xref fig="7-7" page="/ch7" anchor="fig_sharding_routing" >}}圖 7-7{{< /xref >}} 所示):
@@ -258,9 +258,9 @@ Cassandra 和 ScyllaDB 的分片演算法與一致性雜湊的原始定義相似
{{< fig num="7-8" id="fig_sharding_zookeeper" src="/fig/ddia_0708.png" caption="使用 ZooKeeper 跟蹤分片到節點的分配。" class="ddia-figure ddia-figure--wide" width="2658" height="1163" />}}
例如HBase 和 SolrCloud 使用 ZooKeeper 管理分片分配Kubernetes 使用 etcd 記錄每個服務例項的執行位置。MongoDB 的架構與之相似,不過它依靠自有的 *配置伺服器* 實現,並以 *mongos* 守護程序作為路由層。Kafka、YugabyteDB 和 TiDB 則使用內建的 Raft 共識協議實現這項協調功能。
例如HBase 和 SolrCloud 使用 ZooKeeper 管理分片分配Kubernetes 使用 etcd 記錄每個服務例項的執行位置。MongoDB 的架構與之相似,不過它依靠自有的 *配置伺服器**config server*實現,並以 *mongos* 守護程序作為路由層。Kafka、YugabyteDB 和 TiDB 則使用內建的 Raft 共識協議實現這項協調功能。
Cassandra、ScyllaDB 和 Riak 採用另一種辦法:節點之間透過 *流言協議* 傳播叢集狀態的變化。它提供的一致性比共識協議弱得多,因而可能出現腦裂,使叢集的不同部分對同一個分片持有不同的節點分配。無主資料庫可以容忍這種情況,因為它們本就只提供較弱的一致性保證(參見 [“仲裁一致性的侷限”](/tw/ch6#sec_replication_quorum_limitations))。
Cassandra、ScyllaDB 和 Riak 採用另一種辦法:節點之間透過 *流言協議**gossip protocol*傳播叢集狀態的變化。它提供的一致性比共識協議弱得多,因而可能出現腦裂,使叢集的不同部分對同一個分片持有不同的節點分配。無主資料庫可以容忍這種情況,因為它們本就只提供較弱的一致性保證(參見 [“仲裁一致性的侷限”](/tw/ch6#sec_replication_quorum_limitations))。
無論使用路由層還是把請求傳送給隨機節點,客戶端仍然要先找到可供連線的 IP 地址。IP 地址的變化沒有分片到節點的分配那麼頻繁,因此通常用 DNS 就足夠了。
@@ -278,7 +278,7 @@ Cassandra、ScyllaDB 和 Riak 採用另一種辦法:節點之間透過 *流言
假設你正在運營一個二手車交易網站(如 {{< xref fig="7-9" page="/ch7" anchor="fig_sharding_local_secondary" >}}圖 7-9{{< /xref >}} 所示)。每條車輛資訊都有唯一 ID並以該 ID 作為分割槽鍵進行分片例如ID 0 到 499 歸分片 0ID 500 到 999 歸分片 1依此類推
如果要讓使用者搜尋車輛,並按顏色與品牌篩選,就需要在 `color``make` 上建立二級索引(在文件資料庫中它們是欄位,在關聯式資料庫中則是列)。宣告索引後,資料庫會自動維護它。例如,每增加一輛紅色汽車,所在分片就會自動把它的 ID 加入索引條目 `color:red` 對應的 ID 列表。正如 [第 4 章](/tw/ch4#ch_storage) 所述,這種 ID 列表也稱為 *倒排列表*
如果要讓使用者搜尋車輛,並按顏色與品牌篩選,就需要在 `color``make` 上建立二級索引(在文件資料庫中它們是欄位,在關聯式資料庫中則是列)。宣告索引後,資料庫會自動維護它。例如,每增加一輛紅色汽車,所在分片就會自動把它的 ID 加入索引條目 `color:red` 對應的 ID 列表。正如 [第 4 章](/tw/ch4#ch_storage) 所述,這種 ID 列表也稱為 *倒排列表**postings list*
{{< fig num="7-9" id="fig_sharding_local_secondary" src="/fig/ddia_0709.png" caption="本地二級索引:每個分片只索引其自己分片內的記錄。" class="ddia-figure ddia-figure--wide" width="2658" height="1260" />}}
@@ -286,7 +286,7 @@ Cassandra、ScyllaDB 和 Riak 採用另一種辦法:節點之間透過 *流言
>
> 如果資料庫只支援鍵值模型,你也許會想在應用程式碼中建立值到 ID 的對映,自行實現二級索引。如果選擇這條路,務必萬分小心,確保索引與底層資料始終一致。競態條件和間歇性寫入失敗(有些變更儲存成功,另一些卻沒有)很容易讓兩者失去同步——參見 [“多物件事務的需求”](/tw/ch8#sec_transactions_need)。
在這種索引方式中,每個分片都完全獨立:各自維護自己的二級索引,只覆蓋本分片中的記錄,而不關心其他分片儲存了什麼資料。每次寫入資料庫——新增、刪除或更新記錄——只需處理包含該記錄的分片。因此,這種二級索引稱為 *本地索引*;在資訊檢索領域,它也稱為 *按文件分割槽的索引* [^30]。
在這種索引方式中,每個分片都完全獨立:各自維護自己的二級索引,只覆蓋本分片中的記錄,而不關心其他分片儲存了什麼資料。每次寫入資料庫——新增、刪除或更新記錄——只需處理包含該記錄的分片。因此,這種二級索引稱為 *本地索引**local index*;在資訊檢索領域,它也稱為 *按文件分割槽的索引**document-partitioned index*[^30]。
讀取本地二級索引時,如果已經知道目標記錄的分割槽鍵,就只需在對應分片上搜尋。如果只想獲得 *部分* 結果而不要求全部,也可以把請求發給任意分片。
@@ -298,13 +298,13 @@ Cassandra、ScyllaDB 和 Riak 採用另一種辦法:節點之間透過 *流言
### 全域性二級索引 {#id167}
除了讓每個分片各自維護本地二級索引,也可以構建一個覆蓋所有分片資料的 *全域性索引*。不過,不能只把這個索引存放在單個節點上,否則它很可能成為瓶頸,使分片失去意義。因此全域性索引本身也必須分片,但可以採用與主鍵索引不同的分片方式。
除了讓每個分片各自維護本地二級索引,也可以構建一個覆蓋所有分片資料的 *全域性索引**global index*。不過,不能只把這個索引存放在單個節點上,否則它很可能成為瓶頸,使分片失去意義。因此全域性索引本身也必須分片,但可以採用與主鍵索引不同的分片方式。
{{< xref fig="7-10" page="/ch7" anchor="fig_sharding_global_secondary" >}}圖 7-10{{< /xref >}} 展示了它可能採用的形式:來自所有分片的紅色汽車 ID 都列在索引的 `color:red` 條目下;索引本身則經過分片,以字母 *a**r* 開頭的顏色歸分片 0*s**z* 開頭的顏色歸分片 1。汽車品牌索引也以類似方式分片邊界位於 *f**h* 之間。
{{< fig num="7-10" id="fig_sharding_global_secondary" src="/fig/ddia_0710.png" caption="全域性二級索引反映來自所有分片的資料,並且本身按索引值進行分片。" class="ddia-figure ddia-figure--wide" width="2658" height="1129" />}}
這種索引也稱為 *按詞項分割槽* [^30]。回顧 [“全文檢索”](/tw/ch4#sec_storage_full_text):在全文檢索中,*詞項* 是文字中可供搜尋的關鍵字;這裡我們把它推廣為二級索引中任何可供搜尋的值。
這種索引也稱為 *按詞項分割槽**term-partitioned*[^30]。回顧 [“全文檢索”](/tw/ch4#sec_storage_full_text):在全文檢索中,*詞項**term*是文字中可供搜尋的關鍵字;這裡我們把它推廣為二級索引中任何可供搜尋的值。
全域性索引以詞項作為分割槽鍵,因此查詢某個詞項或值時,可以直接確定需要查詢哪個分片。和前面一樣,每個分片可以包含一段連續的詞項範圍(如 {{< xref fig="7-10" page="/ch7" anchor="fig_sharding_global_secondary" >}}圖 7-10{{< /xref >}} 所示),也可以根據詞項的雜湊值把詞項分配到各個分片。

View File

@@ -27,17 +27,17 @@ breadcrumbs: false
要做到可靠,系統就必須處理這些故障,確保它們不會導致整個系統災難性地失效。然而,實現容錯機制的工作量很大:既要審慎考慮所有可能出錯的情況,又要經過大量測試,才能確保解決方案真正有效。
數十年來,*事務* 一直是簡化這些問題的首選機制。應用程式透過事務將多個讀寫操作組合成一個邏輯單元。從概念上講,事務中的所有讀寫被當作一次操作執行:整個事務要麼成功(*提交*),要麼失敗(*中止*、*回滾*)。如果失敗,應用程式可以安全重試。有了事務,應用程式的錯誤處理就簡單多了,因為它不必擔心部分失效——即無論出於何種原因,有些操作成功、有些操作失敗。
數十年來,*事務**transaction*一直是簡化這些問題的首選機制。應用程式透過事務將多個讀寫操作組合成一個邏輯單元。從概念上講,事務中的所有讀寫被當作一次操作執行:整個事務要麼成功(*提交**commit*),要麼失敗(*中止**abort**回滾**rollback*)。如果失敗,應用程式可以安全重試。有了事務,應用程式的錯誤處理就簡單多了,因為它不必擔心部分失效——即無論出於何種原因,有些操作成功、有些操作失敗。
如果你與事務打了多年交道,可能會覺得這一切理所當然,但事務並非自然法則。人們創造事務自有其目的:*簡化* 訪問資料庫的 *應用程式設計模型*。有了事務,應用程式便可以不去考慮某些潛在的錯誤場景和併發問題,因為資料庫會代為處理這些問題(我們稱之為 *安全保證*)。
如果你與事務打了多年交道,可能會覺得這一切理所當然,但事務並非自然法則。人們創造事務自有其目的:*簡化* 訪問資料庫的 *應用程式設計模型**programming model*。有了事務,應用程式便可以不去考慮某些潛在的錯誤場景和併發問題,因為資料庫會代為處理這些問題(我們稱之為 *安全保證**safety guarantee*)。
並非所有應用程式都需要事務;有時弱化事務保證,甚至徹底放棄事務也有好處(例如為了提高效能或可用性)。有些安全屬性也可以在沒有事務的情況下實現。另一方面,事務能夠避免許多麻煩:例如,郵局 Horizon 醜聞(參見[“可靠性有多重要?”](/tw/ch2#sidebar_reliability_importance))背後的技術原因,很可能就是底層會計系統缺少 ACID 事務[^1]。
怎樣判斷自己是否需要事務?要回答這個問題,首先必須確切理解事務能提供哪些安全保證,以及需要為此付出什麼代價。事務乍看簡單,其中卻有許多微妙而重要的細節。
本章將考察許多可能出錯的案例,並探討資料庫用來防範這些問題的演算法。我們尤其會深入 *併發控制* 領域,討論可能出現的各種競態條件,以及資料庫如何實現 *讀已提交*、*快照隔離* 和 *可序列化* 等隔離級別。
本章將考察許多可能出錯的案例,並探討資料庫用來防範這些問題的演算法。我們尤其會深入 *併發控制**concurrency control*領域,討論可能出現的各種競態條件,以及資料庫如何實現 *讀已提交**read committed*)、*快照隔離**snapshot isolation*)和 *可序列化**serializable*等隔離級別。
無論對單節點資料庫還是分散式資料庫,併發控制都很重要。在本章稍後的[“分散式事務”](/tw/ch8#sec_transactions_distributed)一節中,我們將考察 *兩階段提交* 協議,以及在分散式事務中實現原子性所面臨的挑戰。
無論對單節點資料庫還是分散式資料庫,併發控制都很重要。在本章稍後的[“分散式事務”](/tw/ch8#sec_transactions_distributed)一節中,我們將考察 *兩階段提交**two-phase commit*2PC協議,以及在分散式事務中實現原子性所面臨的挑戰。
## 事務到底是什麼? {#sec_transactions_overview}
@@ -69,7 +69,7 @@ ACID 的原子性描述的是另一類情況:客戶端想執行多次寫入,
如果沒有原子性,在多項變更執行到一半時發生錯誤,應用程式很難知道哪些變更已經生效、哪些尚未生效。它可以再試一次,卻可能把同一項變更執行兩遍,造成重複或錯誤的資料。原子性簡化了這個問題:如果事務已經中止,應用程式便能確定它沒有改變任何內容,因此可以安全重試。
遇到錯誤時中止事務,並丟棄該事務的所有寫入,正是 ACID 原子性的定義性特徵。或許稱為 *可中止性* *原子性* 更貼切,但既然“原子性”是慣用說法,本書仍沿用這一術語。
遇到錯誤時中止事務,並丟棄該事務的所有寫入,正是 ACID 原子性的定義性特徵。或許稱為 *可中止性**abortability**原子性* 更貼切,但既然“原子性”是慣用說法,本書仍沿用這一術語。
#### 一致性 {#sec_transactions_acid_consistency}
@@ -83,9 +83,9 @@ ACID 的原子性描述的是另一類情況:客戶端想執行多次寫入,
遺憾的是,同一個詞竟有至少五種不同的含義。
ACID 一致性的基本思想是:關於資料的某些陳述(即 *不變式*)必須始終成立。例如,在會計系統中,所有賬戶的貸記與借記必須始終相抵。如果事務開始時資料庫滿足這些不變式,事務中的所有寫入又能維持其有效性,就可以確信不變式始終成立。(不變式在事務執行期間可以暫時被打破,但到事務提交時必須重新得到滿足。)
ACID 一致性的基本思想是:關於資料的某些陳述(即 *不變式**invariant*)必須始終成立。例如,在會計系統中,所有賬戶的貸記與借記必須始終相抵。如果事務開始時資料庫滿足這些不變式,事務中的所有寫入又能維持其有效性,就可以確信不變式始終成立。(不變式在事務執行期間可以暫時被打破,但到事務提交時必須重新得到滿足。)
如果希望由資料庫強制執行不變式,就需要把它們宣告為模式中的 *約束*。外來鍵約束、唯一性約束或檢查約束(限制單行中可以出現的值)常用來表達特定型別的不變式。更複雜的一致性要求有時也可以用觸發器或物化檢視來表達[^12]。
如果希望由資料庫強制執行不變式,就需要把它們宣告為模式中的 *約束**constraint*。外來鍵約束、唯一性約束或檢查約束(限制單行中可以出現的值)常用來表達特定型別的不變式。更複雜的一致性要求有時也可以用觸發器或物化檢視來表達[^12]。
不過資料庫通常提供的約束可能很難、甚至根本無法表達複雜的不變式。此時應用程式就有責任正確定義事務使其維持一致性。如果應用寫入了違反不變式的錯誤資料卻沒有事先宣告這些不變式資料庫也無從阻止。因此ACID 中的 C 往往取決於應用程式如何使用資料庫,並非資料庫自身的屬性。
@@ -176,7 +176,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
> [!NOTE]
> 嚴格來說,*原子遞增* 中的“原子”採用的是多執行緒程式設計中的含義。在 ACID 的語境下,它其實應稱為 *隔離遞增* 或 *可序列化遞增*,但通常並不這樣叫。
> 嚴格來說,*原子遞增**atomic increment*中的“原子”採用的是多執行緒程式設計中的含義。在 ACID 的語境下,它其實應稱為 *隔離遞增**isolated increment*)或 *可序列化遞增**serializable increment*,但通常並不這樣叫。
這些單物件操作很有用,因為它們能避免多個客戶端同時寫入同一物件時發生丟失更新(參見[“防止丟失更新”](/tw/ch8#sec_transactions_lost_update)。但它們並不是通常意義上的事務。例如Cassandra 和 ScyllaDB 的“輕量級事務”功能,以及 Aerospike 的“強一致性”模式,都能在單個物件上提供線性一致的讀取和條件寫入(參見[“線性一致性”](/tw/ch10#sec_consistency_linearizability)),卻不為多個物件之間的操作提供保證。
@@ -217,7 +217,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
併發缺陷很難透過測試發現,因為它們只有在時序碰巧不利時才會觸發。這種時序問題可能極少發生,通常也難以重現。併發行為本身同樣難以推理,尤其是在大型應用中,你未必知道還有哪些程式碼正在訪問資料庫。即使每次只有一個使用者,應用開發也已經不容易;面對許多併發使用者則更為困難,因為任何資料都可能隨時發生意料之外的變化。
正因如此,資料庫長期以來一直試圖透過 *事務隔離* 嚮應用開發者隱藏併發問題。理論上,隔離讓你可以假裝併發根本不存在:*可序列化* 隔離意味著,資料庫保證事務產生的效果與 *序列* 執行(即逐個執行、毫無併發)相同。
正因如此,資料庫長期以來一直試圖透過 *事務隔離**transaction isolation*嚮應用開發者隱藏併發問題。理論上,隔離讓你可以假裝併發根本不存在:*可序列化* 隔離意味著,資料庫保證事務產生的效果與 *序列* 執行(即逐個執行、毫無併發)相同。
遺憾的是,實踐中的隔離並沒有這麼簡單。可序列化隔離有效能代價,許多資料庫不願為此買單[^10]。因此,系統普遍採用較弱的隔離級別,只防範 *一部分* 而非全部併發問題。這些隔離級別更難理解,也可能引發微妙的錯誤,卻仍在實踐中廣泛使用[^29]。
@@ -234,10 +234,10 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
### 讀已提交 {#sec_transactions_read_committed}
最基本的事務隔離級別是 *讀已提交*,它提供兩項保證:
最基本的事務隔離級別是 *讀已提交**read committed*,它提供兩項保證:
1. 從資料庫讀取時,只能看到已經提交的資料(沒有 *髒讀*)。
2. 向資料庫寫入時,只能覆蓋已經提交的資料(沒有 *髒寫*)。
1. 從資料庫讀取時,只能看到已經提交的資料(沒有 *髒讀**dirty read*)。
2. 向資料庫寫入時,只能覆蓋已經提交的資料(沒有 *髒寫**dirty write*)。
有些資料庫還支援更弱的 *讀未提交* 隔離級別。它能防止髒寫,卻不能防止髒讀。下面詳細討論這兩項保證。
@@ -293,7 +293,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
假設 Aaliyah 在銀行有 1,000 美元存款,分別存在兩個賬戶中,每個賬戶 500 美元。現在有一筆事務從其中一個賬戶向另一個賬戶轉賬 100 美元。如果她偏偏在轉賬事務處理的同時檢視賬戶餘額可能會先看到一個賬戶尚未收到轉入款項時的餘額500 美元再看到另一個賬戶已經轉出款項後的餘額400 美元)。在 Aaliyah 看來,兩個賬戶總共只剩 900 美元——彷彿有 100 美元憑空消失了。
這種異常稱為 *讀偏差*,是 *不可重複讀* 的一種:如果 Aaliyah 在轉賬事務結束後再次讀取賬戶 1 的餘額,會得到 600 美元,與上一次查詢看到的值不同。讀已提交隔離允許讀偏差,因為 Aaliyah 讀到各賬戶餘額的那一刻,它們確實都已經提交。
這種異常稱為 *讀偏差**read skew*,是 *不可重複讀**nonrepeatable read*的一種:如果 Aaliyah 在轉賬事務結束後再次讀取賬戶 1 的餘額,會得到 600 美元,與上一次查詢看到的值不同。讀已提交隔離允許讀偏差,因為 Aaliyah 讀到各賬戶餘額的那一刻,它們確實都已經提交。
> [!NOTE]
@@ -318,7 +318,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
和讀已提交一樣,快照隔離通常用寫鎖來防止髒寫(參見[“實現讀已提交”](/tw/ch8#sec_transactions_read_committed_impl))。因此,一個寫事務可以阻塞另一個寫入同一行的事務。不過,讀取無須取得任何鎖。從效能角度看,快照隔離的一項關鍵原則是:*讀不阻塞寫,寫也不阻塞讀*。這樣,資料庫可以一邊在一致快照上執行長期讀查詢,一邊正常處理寫入,二者不會爭用鎖。
為實現快照隔離,資料庫把{{< xref fig="8-4" page="/ch8" anchor="fig_transactions_read_committed" >}}圖 8-4{{< /xref >}}中的防髒讀機制推廣開來。資料庫不再只為每行保留兩個版本(已提交版本,以及覆蓋它但尚未提交的新版本),而是可能需要保留多個不同的已提交版本,因為正在執行的不同事務可能要觀察資料庫在不同時刻的狀態。由於同一行的多個版本並存,這項技術稱為 *多版本併發控制*MVCC
為實現快照隔離,資料庫把{{< xref fig="8-4" page="/ch8" anchor="fig_transactions_read_committed" >}}圖 8-4{{< /xref >}}中的防髒讀機制推廣開來。資料庫不再只為每行保留兩個版本(已提交版本,以及覆蓋它但尚未提交的新版本),而是可能需要保留多個不同的已提交版本,因為正在執行的不同事務可能要觀察資料庫在不同時刻的狀態。由於同一行的多個版本並存,這項技術稱為 *多版本併發控制**multi-version concurrency control*MVCC
{{< xref fig="8-7" page="/ch8" anchor="fig_transactions_mvcc" >}}圖 8-7{{< /xref >}}展示了 PostgreSQL 如何基於 MVCC 實現快照隔離[^40] [^42] [^43](其他實現與之類似)。事務啟動時會獲得一個唯一且單調遞增的事務 ID`txid`)。事務寫入資料庫的任何資料,都會以寫入者的事務 ID 標記。嚴格來說PostgreSQL 的事務 ID 是 32 位整數,大約經過 40 億個事務就會溢位;`vacuum` 程序負責清理,確保溢位不會影響資料。)
@@ -363,7 +363,7 @@ CouchDB、Datomic 和 LMDB 採用另一種方法。它們同樣使用 B 樹(
#### 快照隔離、可重複讀和命名混淆 {#snapshot-isolation-repeatable-read-and-naming-confusion}
MVCC 是資料庫常用的實現技術,也經常用來實現快照隔離。不過,不同資料庫有時會用不同術語指代同一件事:例如,快照隔離在 PostgreSQL 中稱為“可重複讀”,在 Oracle 中稱為“可序列化”[^29]。反過來不同系統也會用同一個術語表示不同事物PostgreSQL 的“可重複讀”指快照隔離MySQL 的“可重複讀”卻是一個一致性弱於快照隔離的 MVCC 實現[^41]。
MVCC 是資料庫常用的實現技術,也經常用來實現快照隔離。不過,不同資料庫有時會用不同術語指代同一件事:例如,快照隔離在 PostgreSQL 中稱為“可重複讀repeatable read”,在 Oracle 中稱為“可序列化”[^29]。反過來不同系統也會用同一個術語表示不同事物PostgreSQL 的“可重複讀”指快照隔離MySQL 的“可重複讀”卻是一個一致性弱於快照隔離的 MVCC 實現[^41]。
之所以會出現這種命名混亂,是因為 SQL 標準沒有快照隔離的概念。該標準建立在 System R 於 1975 年定義的隔離級別之上[^3]而那時快照隔離尚未問世。標準定義的是表面上與快照隔離相似的“可重複讀”。PostgreSQL 的快照隔離符合這項標準要求,於是將它稱為“可重複讀”,從而可以宣稱符合標準。
@@ -375,9 +375,9 @@ MVCC 是資料庫常用的實現技術,也經常用來實現快照隔離。不
到目前為止,讀已提交和快照隔離主要保證的是:存在併發寫入時,只讀事務能夠看到什麼。我們基本沒有討論兩個事務併發寫入的問題,只講過一種特定的寫—寫衝突——髒寫(參見[“沒有髒寫”](/tw/ch8#sec_transactions_dirty_write))。
併發寫事務之間還可能出現其他幾類值得關注的衝突,其中最著名的便是 *丟失更新*。{{< xref fig="8-1" page="/ch8" anchor="fig_transactions_increment" >}}圖 8-1{{< /xref >}}以兩個併發遞增計數器的事務為例,展示了這個問題。
併發寫事務之間還可能出現其他幾類值得關注的衝突,其中最著名的便是 *丟失更新**lost update*。{{< xref fig="8-1" page="/ch8" anchor="fig_transactions_increment" >}}圖 8-1{{< /xref >}}以兩個併發遞增計數器的事務為例,展示了這個問題。
應用程式從資料庫讀取一個值,修改後再寫回去,這個過程稱為 *讀取—修改—寫入迴圈*,可能發生丟失更新。如果兩個事務併發執行這樣的迴圈,其中一項修改可能丟失,因為後一次寫入沒有包含前一個事務的修改。(有時也說後一次寫入 *抹掉* 了前一次寫入。)這種模式會出現在許多場景中:
應用程式從資料庫讀取一個值,修改後再寫回去,這個過程稱為 *讀取—修改—寫入迴圈**read-modify-write cycle*,可能發生丟失更新。如果兩個事務併發執行這樣的迴圈,其中一項修改可能丟失,因為後一次寫入沒有包含前一個事務的修改。(有時也說後一次寫入 *抹掉* 了前一次寫入。)這種模式會出現在許多場景中:
* 遞增計數器或更新賬戶餘額(讀取當前值、計算新值,再把新值寫回);
* 區域性修改複雜值,例如向 JSON 文件內的列表新增元素(解析文件、作出修改,再把修改後的文件寫回);
@@ -387,7 +387,7 @@ MVCC 是資料庫常用的實現技術,也經常用來實現快照隔離。不
#### 原子寫操作 {#atomic-write-operations}
許多資料庫提供原子更新操作,使應用程式不必自行實現讀取—修改—寫入迴圈。如果業務邏輯可以用這些操作表達,它們通常是最佳方案。例如,下面這條語句在大多數關係型資料庫中都能安全地併發執行:
許多資料庫提供 *原子寫操作**atomic write operation*,使應用程式不必自行實現讀取—修改—寫入迴圈。如果業務邏輯可以用這些操作表達,它們通常是最佳方案。例如,下面這條語句在大多數關係型資料庫中都能安全地併發執行:
```sql
UPDATE counters SET value = value + 1 WHERE key = 'foo';
@@ -395,13 +395,13 @@ UPDATE counters SET value = value + 1 WHERE key = 'foo';
類似地MongoDB 等文件資料庫提供原子操作,供應用區域性修改 JSON 文件Redis 則提供修改優先佇列等資料結構的原子操作。並非所有寫入都容易表示成原子操作——例如,更新 wiki 頁面涉及任意文字編輯,可以用[“CRDT 與操作變換”](/tw/ch6#sec_replication_crdts)介紹的演算法處理——但只要能夠使用,原子操作通常就是最佳選擇。
原子操作通常這樣實現:讀取物件時取得排他鎖,使其他事務在更新完成之前無法讀取該物件。另一種辦法則是強制所有原子操作都在單個執行緒上執行。
原子操作通常這樣實現:讀取物件時取得 *排他鎖**exclusive lock*,使其他事務在更新完成之前無法讀取該物件。另一種辦法則是強制所有原子操作都在單個執行緒上執行。
遺憾的是物件關係對映ORM框架很容易讓人無意中寫出不安全的讀取—修改—寫入迴圈而沒有使用資料庫提供的原子操作[^49] [^50] [^51]。由此產生的缺陷往往十分隱蔽,很難透過測試發現。
#### 顯式鎖定 {#explicit-locking}
如果資料庫內建的原子操作無法滿足需求,還可以由應用程式顯式鎖定即將更新的物件,再執行讀取—修改—寫入迴圈。其他事務若試圖併發更新或鎖定同一物件,就必須等前一個迴圈完成。
如果資料庫內建的原子操作無法滿足需求,還可以由應用程式 *顯式鎖定**explicit locking*即將更新的物件,再執行讀取—修改—寫入迴圈。其他事務若試圖併發更新或鎖定同一物件,就必須等前一個迴圈完成。
以多人遊戲為例,幾個玩家都可以移動同一個棋子。這時原子操作可能還不夠,因為應用還要確保走法符合遊戲規則,而其中一些邏輯很難合理地寫成資料庫查詢。此時可以加鎖,防止兩個玩家同時移動同一個棋子,如{{< xref eg="8-1" page="/ch8" anchor="fig_transactions_select_for_update" >}}示例 8-1{{< /xref >}}所示。
@@ -437,7 +437,7 @@ COMMIT;
#### 條件寫入(比較並設定) {#sec_transactions_compare_and_set}
不提供事務的資料庫有時會提供 *條件寫入* 操作(前文[“單物件寫入”](/tw/ch8#sec_transactions_single_object)已經提到):只有當值自上次讀取後未發生變化,才允許更新,從而防止丟失更新。如果當前值與先前讀到的值不符,更新便不生效,應用必須重試讀取—修改—寫入迴圈。它相當於資料庫版本的原子 *比較並設定**比較並交換*CAS指令許多 CPU 都支援此類指令。
不提供事務的資料庫有時會提供 *條件寫入* 操作(前文[“單物件寫入”](/tw/ch8#sec_transactions_single_object)已經提到):只有當值自上次讀取後未發生變化,才允許更新,從而防止丟失更新。如果當前值與先前讀到的值不符,更新便不生效,應用必須重試讀取—修改—寫入迴圈。它相當於資料庫版本的原子 *比較並設定**compare-and-set*)或 *比較並交換**compare-and-swap*CAS指令許多 CPU 都支援此類指令。
例如,為防止兩個使用者同時更新同一個 wiki 頁面,可以嘗試如下寫法:只有當頁面內容在使用者開始編輯後沒有變化時,更新才會發生。
@@ -447,7 +447,7 @@ UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content';
```
如果內容已經變化,不再與 `'old content'` 匹配,更新就不會生效;因此需要檢查更新結果,必要時重試。也可以不比較完整內容,而是使用一個每次更新都遞增的版本號列,只在當前版本號未變化時應用更新。這種方法有時稱為 *樂觀鎖定*[^52]。
如果內容已經變化,不再與 `'old content'` 匹配,更新就不會生效;因此需要檢查更新結果,必要時重試。也可以不比較完整內容,而是使用一個每次更新都遞增的版本號列,只在當前版本號未變化時應用更新。這種方法有時稱為 *樂觀鎖定**optimistic locking*[^52]。
如果另一個事務併發修改了 `content`,按照 MVCC 可見性規則,新內容可能並不可見(參見[“觀察一致快照的可見性規則”](/tw/ch8#sec_transactions_mvcc_visibility))。許多 MVCC 實現會針對這種場景作出例外:其他事務寫入的值即便在快照中不可見,計算 `UPDATE``DELETE` 查詢的 `WHERE` 子句時仍然可見。
@@ -480,7 +480,7 @@ UPDATE wiki_pages SET content = 'new content'
#### 寫偏差的特徵 {#characterizing-write-skew}
這種異常稱為 *寫偏差*[^36]。它既不是髒寫,也不是丟失更新,因為兩個事務更新的是不同物件——分別是 Aaliyah 和 Bryce 的值班記錄。這裡的衝突不那麼明顯,卻確實是一種競態條件:如果兩個事務依次執行,第二位醫生就無法退出值班。只有併發執行事務,才會出現這種異常行為。
這種異常稱為 *寫偏差**write skew*[^36]。它既不是髒寫,也不是丟失更新,因為兩個事務更新的是不同物件——分別是 Aaliyah 和 Bryce 的值班記錄。這裡的衝突不那麼明顯,卻確實是一種競態條件:如果兩個事務依次執行,第二位醫生就無法退出值班。只有併發執行事務,才會出現這種異常行為。
可以把寫偏差看作丟失更新的推廣:兩個事務讀取相同的一組物件,隨後各自更新其中一些物件(不同事務可以更新不同物件),就可能發生寫偏差。若不同事務碰巧更新同一個物件,則會表現為髒寫或丟失更新,具體取決於時序。
@@ -557,7 +557,7 @@ UPDATE wiki_pages SET content = 'new content'
在醫生值班的例子中,步驟 3 修改的是步驟 1 返回的某一行,因此可以在步驟 1 中鎖定這些行(`SELECT FOR UPDATE`),使事務安全並避免寫偏差。但其餘四個例子不同:它們檢查的是 *不存在* 符合某項條件的行,隨後的寫入則 *新增* 了一行,使其符合相同條件。如果步驟 1 的查詢沒有返回任何行,`SELECT FOR UPDATE` 便無處加鎖[^56]。
一個事務的寫入改變了另一個事務中搜尋查詢的結果,這種現象稱為 *幻讀*[^4]。快照隔離可以避免只讀查詢中的幻讀但在上述讀寫事務中幻讀可能引發格外棘手的寫偏差。ORM 生成的 SQL 也很容易遇到寫偏差[^50] [^51]。
一個事務的寫入改變了另一個事務中搜尋查詢的結果,這種現象稱為 *幻讀**phantom*[^4]。快照隔離可以避免只讀查詢中的幻讀但在上述讀寫事務中幻讀可能引發格外棘手的寫偏差。ORM 生成的 SQL 也很容易遇到寫偏差[^50] [^51]。
#### 物化衝突 {#materializing-conflicts}
@@ -567,7 +567,7 @@ UPDATE wiki_pages SET content = 'new content'
現在,建立預訂的事務可以鎖定(`SELECT FOR UPDATE`)表中與目標房間和時段對應的行。取得鎖後,再照常檢查重疊預訂並插入新預訂。請注意,這張附加表並不儲存預訂資訊——它純粹是一組鎖,用來防止同一房間、同一時間範圍內的預訂被併發修改。
這種方法稱為 *物化衝突*:它把幻讀轉化為資料庫中一組具體行上的鎖衝突[^14]。遺憾的是,怎樣物化衝突既難設計又容易出錯,而且讓併發控制機制滲入應用資料模型也很不美觀。因此,物化衝突只能在別無選擇時作為最後手段;大多數情況下,可序列化隔離要好得多。
這種方法稱為 *物化衝突**materializing conflicts*:它把幻讀轉化為資料庫中一組具體行上的鎖衝突[^14]。遺憾的是,怎樣物化衝突既難設計又容易出錯,而且讓併發控制機制滲入應用資料模型也很不美觀。因此,物化衝突只能在別無選擇時作為最後手段;大多數情況下,可序列化隔離要好得多。
@@ -612,7 +612,7 @@ VoltDB/H-Store、Redis 和 Datomic 等系統採用了序列執行事務的方式
這種互動式事務把大量時間耗在應用與資料庫之間的網路通訊上。如果資料庫禁止併發、每次只處理一個事務,吞吐量會低得可怕:資料庫大部分時間都在等待應用發出當前事務的下一條查詢。採用這種模式的資料庫必須併發處理多個事務,才能獲得合理效能。
因此,單執行緒序列處理事務的系統不允許互動式多語句事務。應用要麼只使用單語句事務,要麼提前把整個事務的程式碼作為 *儲存過程* 提交給資料庫[^61]。
因此,單執行緒序列處理事務的系統不允許互動式多語句事務。應用要麼只使用單語句事務,要麼提前把整個事務的程式碼作為 *儲存過程**stored procedure*提交給資料庫[^61]。
互動式事務與儲存過程的差別如{{< xref fig="8-9" page="/ch8" anchor="fig_transactions_stored_proc" >}}圖 8-9{{< /xref >}}所示。只要事務所需的全部資料都在記憶體中,儲存過程就能快速執行,無須等待任何網路或磁碟 I/O。
@@ -633,7 +633,7 @@ VoltDB/H-Store、Redis 和 Datomic 等系統採用了序列執行事務的方式
儲存過程配合記憶體資料,使所有事務都在單執行緒上執行成為可行方案。只要儲存過程無須等待 I/O又能避開其他併發控制機制的開銷單執行緒也可以達到相當可觀的吞吐量。
VoltDB 還利用儲存過程進行復制它不把事務寫入從一個節點複製到另一個節點而是在每個副本上執行同一個儲存過程。因此VoltDB 要求儲存過程必須是 *確定性的*,即在不同節點執行時產生相同結果。例如,事務若要使用當前日期和時間,就必須透過特殊的確定性 API 獲取(關於確定性操作的更多細節,參見[“持久化執行與工作流”](/tw/ch5#sec_encoding_dataflow_workflows))。這種方法稱為 *狀態機複製*,我們將在[第 10 章](/tw/ch10#ch_consistency)再次討論它。
VoltDB 還利用儲存過程進行復制它不把事務寫入從一個節點複製到另一個節點而是在每個副本上執行同一個儲存過程。因此VoltDB 要求儲存過程必須是 *確定性的**deterministic*,即在不同節點執行時產生相同結果。例如,事務若要使用當前日期和時間,就必須透過特殊的確定性 API 獲取(關於確定性操作的更多細節,參見[“持久化執行與工作流”](/tw/ch5#sec_encoding_dataflow_workflows))。這種方法稱為 *狀態機複製**state machine replication*,我們將在[第 10 章](/tw/ch10#ch_consistency)再次討論它。
#### 分片 {#sharding}
@@ -658,7 +658,7 @@ VoltDB 還利用儲存過程進行復制:它不把事務寫入從一個節點
### 兩階段鎖定2PL {#sec_transactions_2pl}
大約 30 年間,資料庫領域只有一種得到廣泛應用的可序列化演算法:*兩階段鎖定*2PL。它有時也稱為 *強嚴格兩階段鎖定*SS2PL以區別於 2PL 的其他變體。
大約 30 年間,資料庫領域只有一種得到廣泛應用的可序列化演算法:*兩階段鎖定**two-phase locking*2PL。它有時也稱為 *強嚴格兩階段鎖定**strong strict two-phase locking*SS2PL以區別於 2PL 的其他變體。
> [!TIP] 2PL 不是 2PC
@@ -678,14 +678,14 @@ VoltDB 還利用儲存過程進行復制:它不把事務寫入從一個節點
MySQLInnoDB和 SQL Server 的可序列化隔離級別,以及 Db2 的可重複讀隔離級別,都採用 2PL[^29]。
資料庫透過為每個物件設定一把鎖來阻塞讀寫操作。鎖可以處於 *共享模式* 或 *獨佔模式*,這也稱為 *多讀者單寫者* 鎖。具體規則如下:
資料庫透過為每個物件設定一把鎖來阻塞讀寫操作。鎖可以處於 *共享模式**shared mode*)或 *獨佔模式**exclusive mode*),這也稱為 *多讀者單寫者**multi-reader single-writer*鎖。具體規則如下:
* 事務要讀取物件,必須先以共享模式取得鎖。多個事務可以同時持有共享鎖;但如果另一個事務已持有該物件的獨佔鎖,讀事務就必須等待。
* 事務要寫入物件,必須先以獨佔模式取得鎖。此時不允許其他事務以任何模式持鎖;只要物件上已有鎖,寫事務就必須等待。
* 事務先讀後寫時,可以把共享鎖升級為獨佔鎖。升級規則與直接取得獨佔鎖相同。
* 事務取得鎖後,必須一直持有到事務結束(提交或中止)。這正是“兩階段”名稱的由來:第一階段在事務執行期間獲取鎖,第二階段在事務結束時一次釋放所有鎖。
由於大量使用鎖,很容易出現事務 A 等待事務 B 釋放鎖、事務 B 又反過來等待事務 A 的情況,這稱為 *死鎖*。資料庫會自動檢測事務之間的死鎖並中止其中一個,讓其他事務能夠繼續推進;被中止的事務則由應用程式重試。
由於大量使用鎖,很容易出現事務 A 等待事務 B 釋放鎖、事務 B 又反過來等待事務 A 的情況,這稱為 *死鎖**deadlock*。資料庫會自動檢測事務之間的死鎖並中止其中一個,讓其他事務能夠繼續推進;被中止的事務則由應用程式重試。
#### 兩階段鎖定的效能 {#performance-of-two-phase-locking}
@@ -705,7 +705,7 @@ MySQLInnoDB和 SQL Server 的可序列化隔離級別,以及 Db2 的可
在會議室預訂的例子中,如果一個事務已經搜尋某個房間在特定時段內的現有預訂(參見{{< xref eg="8-2" page="/ch8" anchor="fig_transactions_meeting_rooms" >}}示例 8-2{{< /xref >}}),另一個事務就不能併發插入或更新同一房間、同一時段的預訂。(併發預訂其他房間,或者預訂同一房間但互不影響的其他時段,則沒有問題。)
怎樣實現這一點?從概念上講,需要使用 *謂詞鎖*[^4]。它的工作方式類似前面介紹的共享鎖和獨佔鎖,卻不屬於某個特定物件(例如表中的一行),而是屬於所有符合某項搜尋條件的物件,例如:
怎樣實現這一點?從概念上講,需要使用 *謂詞鎖**predicate lock*[^4]。它的工作方式類似前面介紹的共享鎖和獨佔鎖,卻不屬於某個特定物件(例如表中的一行),而是屬於所有符合某項搜尋條件的物件,例如:
```
SELECT * FROM bookings
@@ -723,7 +723,7 @@ SELECT * FROM bookings
#### 索引範圍鎖 {#sec_transactions_2pl_range}
遺憾的是,謂詞鎖效能不佳:活躍事務持有大量鎖時,檢查是否有匹配的鎖會非常耗時。因此,大多數採用 2PL 的資料庫實際實現的是 *索引範圍鎖*(也稱為 *next-key locking*),即謂詞鎖的一種簡化近似[^54] [^64]。
遺憾的是,謂詞鎖效能不佳:活躍事務持有大量鎖時,檢查是否有匹配的鎖會非常耗時。因此,大多數採用 2PL 的資料庫實際實現的是 *索引範圍鎖**index-range lock*也稱為 *next-key locking*),即謂詞鎖的一種簡化近似[^54] [^64]。
把謂詞放寬到匹配更大的物件集合,是一種安全的簡化。例如,要鎖定 123 號房間從中午 12 點到下午 1 點的預訂,可以近似為鎖定 123 號房間全天所有預訂;也可以近似為鎖定中午 12 點到下午 1 點所有房間的預訂。這樣做是安全的,因為任何符合原始謂詞的寫入,必然也符合近似後的條件。
@@ -742,17 +742,17 @@ SELECT * FROM bookings
到目前為止,資料庫併發控制的前景顯得頗為黯淡:一邊是效能不佳的兩階段鎖定,以及伸縮性不佳的序列執行;另一邊是效能雖好,卻容易出現丟失更新、寫偏差、幻讀等競態條件的弱隔離。可序列化隔離與良好效能從根本上就無法兼得嗎?
看來並非如此。一種名為 *可序列化快照隔離*SSI的演算法能夠提供完整的可序列化與快照隔離相比只付出很小的效能代價。SSI 相對較新,最早於 2008 年提出[^53] [^65]。
看來並非如此。一種名為 *可序列化快照隔離**serializable snapshot isolation*SSI的演算法能夠提供完整的可序列化與快照隔離相比只付出很小的效能代價。SSI 相對較新,最早於 2008 年提出[^53] [^65]。
如今SSI 及類似演算法已經用於單節點資料庫PostgreSQL 的可序列化隔離級別[^54]、SQL Server 的記憶體 OLTP/Hekaton[^66] 和 HyPer[^67]、分散式資料庫CockroachDB[^5] 和 FoundationDB[^8]),以及 BadgerDB 等嵌入式儲存引擎。
#### 悲觀併發控制與樂觀併發控制 {#pessimistic-versus-optimistic-concurrency-control}
兩階段鎖定屬於所謂的 *悲觀* 併發控制機制。它遵循這樣的原則:只要有任何出錯的可能(例如另一個事務持有鎖),就先等待,直到局面重新安全後再行動。這類似於多執行緒程式設計中用來保護資料結構的 *互斥*。
兩階段鎖定屬於所謂的 *悲觀**pessimistic*併發控制機制。它遵循這樣的原則:只要有任何出錯的可能(例如另一個事務持有鎖),就先等待,直到局面重新安全後再行動。這類似於多執行緒程式設計中用來保護資料結構的 *互斥**mutual exclusion*
從某種意義上說,序列執行把悲觀推到了極致:本質上,每個事務在執行期間都像是對整個資料庫(或一個資料庫分片)持有獨佔鎖。作為補償,每個事務都要儘快執行,使這把“鎖”只持有很短時間。
相比之下,可序列化快照隔離是一種 *樂觀* 併發控制技術。這裡的“樂觀”是指:即使出現潛在危險,也不阻塞事務,而是讓它繼續執行,寄望最終不會出問題。等事務準備提交時,資料庫再檢查是否發生了壞事(即隔離是否遭到破壞);若有,就中止並重試事務。只有執行結果可序列化的事務才能提交。
相比之下,可序列化快照隔離是一種 *樂觀**optimistic*併發控制技術。這裡的“樂觀”是指:即使出現潛在危險,也不阻塞事務,而是讓它繼續執行,寄望最終不會出問題。等事務準備提交時,資料庫再檢查是否發生了壞事(即隔離是否遭到破壞);若有,就中止並重試事務。只有執行結果可序列化的事務才能提交。
樂觀併發控制是個古老的思想[^68],其利弊也已爭論多年[^69]。存在嚴重爭用(許多事務訪問相同物件)時,它表現很差,因為很大比例的事務都不得不中止。若系統已經接近最大吞吐量,重試事務帶來的額外負載還會進一步拖累效能。
@@ -838,7 +838,7 @@ SELECT * FROM bookings
如果有些節點提交、有些節點中止,節點之間便會出現不一致。而事務一旦在某個節點上提交,即便後來發現它在另一個節點上中止,也不能再撤回。因為資料提交後,在 *讀已提交* 或更強隔離下就會對其他事務可見。例如,在{{< xref fig="8-12" page="/ch8" anchor="fig_transactions_non_atomic" >}}圖 8-12{{< /xref >}}中,當使用者 1 發現資料庫 1 提交失敗時,使用者 2 已經在資料庫 2 讀到了同一事務寫入的資料。如果事後再中止使用者 1 的事務,就連使用者 2 的事務也必須撤銷,因為它所依據的資料被追溯宣佈為從未存在。
更好的辦法是確保參與事務的節點要麼全部提交,要麼全部中止,絕不允許兩種結果混雜。這就是所謂的 *原子提交* 問題。
更好的辦法是確保參與事務的節點要麼全部提交,要麼全部中止,絕不允許兩種結果混雜。這就是所謂的 *原子提交**atomic commitment*問題。
### 兩階段提交2PC {#sec_transactions_2pc}
@@ -849,9 +849,9 @@ SELECT * FROM bookings
{{< fig num="8-13" id="fig_transactions_two_phase_commit" src="/fig/ddia_0813.png" caption="兩階段提交2PC的成功執行。" class="ddia-figure ddia-figure--wide" width="2880" height="969" />}}
2PC 引入了一個單節點事務中通常沒有的元件:*協調者*也稱為 *事務管理器*)。協調者通常實現為一個庫,與發起事務的應用執行在同一程序中(例如嵌入 Java EE 容器也可以作為獨立程序或服務執行。Narayana、JOTM、BTM 和 MSDTC 都屬於此類協調者。
2PC 引入了一個單節點事務中通常沒有的元件:*協調者**coordinator*,也稱為 *事務管理器**transaction manager*)。協調者通常實現為一個庫,與發起事務的應用執行在同一程序中(例如嵌入 Java EE 容器也可以作為獨立程序或服務執行。Narayana、JOTM、BTM 和 MSDTC 都屬於此類協調者。
使用 2PC 時,分散式事務照常開始:應用在多個資料庫節點上讀寫資料,這些節點稱為事務的 *參與者*。應用準備提交時,協調者進入第一階段,向每個參與者傳送 *準備* 請求,詢問它是否能夠提交,並收集各參與者的響應:
使用 2PC 時,分散式事務照常開始:應用在多個資料庫節點上讀寫資料,這些節點稱為事務的 *參與者**participant*。應用準備提交時,協調者進入第一階段,向每個參與者傳送 *準備**prepare*請求,詢問它是否能夠提交,並收集各參與者的響應:
* 如果所有參與者都回答“是”,表示已準備好提交,協調者就在第二階段發出 *提交* 請求,真正執行提交;
* 如果任何參與者回答“否”,協調者就在第二階段向所有節點傳送 *中止* 請求。
@@ -868,7 +868,7 @@ SELECT * FROM bookings
2. 應用在每個參與者上啟動一個單節點事務,併為其附上這個全域性事務 ID。所有讀寫都在這些單節點事務中完成。如果此階段出現任何問題例如節點崩潰或請求超時協調者或任何參與者都可以中止事務。
3. 應用準備提交時,協調者向所有參與者傳送帶全域性事務 ID 的準備請求。任何一個請求失敗或超時,協調者都會向所有參與者傳送該事務 ID 的中止請求。
4. 參與者收到準備請求後,必須確保自己在任何情況下都一定能提交事務。這既包括把全部事務資料寫入磁碟(崩潰、斷電或磁碟空間耗盡都不能成為以後拒絕提交的理由),也包括檢查衝突和約束違規。節點向協調者回答“是”,就等於承諾:只要收到要求,事務一定能夠無誤提交。換句話說,參與者尚未真正提交,卻已經放棄了自行中止事務的權利。
5. 協調者收到所有準備請求的響應後,便對提交還是中止作出最終決定(只有所有參與者都投“是”才會提交)。協調者必須把決定寫入磁碟上的事務日誌,這樣即使隨後崩潰,恢復後也知道自己作過什麼決定。這一時刻稱為 *提交點*。
5. 協調者收到所有準備請求的響應後,便對提交還是中止作出最終決定(只有所有參與者都投“是”才會提交)。協調者必須把決定寫入磁碟上的事務日誌,這樣即使隨後崩潰,恢復後也知道自己作過什麼決定。這一時刻稱為 *提交點**commit point*
6. 協調者的決定一旦落盤,就向所有參與者傳送提交或中止請求。請求若失敗或超時,協調者必須不斷重試,直到成功為止。此時再無回頭路:如果決定提交,就必須執行到底,無論需要重試多少次。參與者若在此期間崩潰,恢復後也必須提交事務——既然它已經投了“是”,就不能反悔。
因此,協議中有兩個關鍵的“不歸點”:參與者投“是”時,承諾自己日後一定能夠提交(儘管協調者仍可決定中止);協調者一旦作出決定,該決定便不可撤銷。正是這套承諾保證了 2PC 的原子性。(單節點原子提交把這兩件事合併成一步:將提交記錄寫入事務日誌。)
@@ -879,7 +879,7 @@ SELECT * FROM bookings
我們已經討論過 2PC 期間參與者或網路發生故障時會怎樣:任何準備請求失敗或超時,協調者都會中止事務;任何提交或中止請求失敗,協調者都會無限重試。但協調者自身崩潰時會發生什麼,就沒那麼清楚了。
如果協調者在傳送準備請求前失效,參與者可以安全中止事務。但參與者一旦收到準備請求並投出“是”,便不能再單方面中止,必須等待協調者告知事務究竟提交還是中止。如果協調者此時崩潰或網路發生故障,參與者只能等待。這種狀態下的事務稱為 *存疑* 或 *不確定* 事務。
如果協調者在傳送準備請求前失效,參與者可以安全中止事務。但參與者一旦收到準備請求並投出“是”,便不能再單方面中止,必須等待協調者告知事務究竟提交還是中止。如果協調者此時崩潰或網路發生故障,參與者只能等待。這種狀態下的事務稱為 *存疑**in doubt*)或 *不確定**uncertain*事務。
{{< xref fig="8-14" page="/ch8" anchor="fig_transactions_2pc_crash" >}}圖 8-14{{< /xref >}}展示了這種情況。在圖中的例子裡,協調者實際決定提交,資料庫 2 也收到了提交請求;但協調者還沒來得及把提交請求發給資料庫 1 就崩潰了,所以資料庫 1 不知道該提交還是中止。超時對此也無濟於事:資料庫 1 若在超時後自行中止,就會與已經提交的資料庫 2 不一致;自行提交同樣不安全,因為另一個參與者可能已經中止。
@@ -892,9 +892,9 @@ SELECT * FROM bookings
#### 三階段提交 {#three-phase-commit}
由於 2PC 可能卡住並一直等待協調者恢復,兩階段提交也稱為 *阻塞式* 原子提交協議。理論上可以設計 *非阻塞式* 原子提交協議,使它在節點發生故障時不會卡住;但要在實踐中做到這一點並不容易。
由於 2PC 可能卡住並一直等待協調者恢復,兩階段提交也稱為 *阻塞式**blocking*原子提交協議。理論上可以設計 *非阻塞式**nonblocking*原子提交協議,使它在節點發生故障時不會卡住;但要在實踐中做到這一點並不容易。
有人提出 *三階段提交*3PC作為 2PC 的替代方案[^13] [^77]。然而3PC 假設網路延遲有上界、節點響應時間也有上界;大多數現實系統都存在無界網路延遲和程序暫停(參見[第 9 章](/tw/ch9#ch_distributed)3PC 在其中無法保證原子性。
有人提出 *三階段提交**three-phase commit*3PC作為 2PC 的替代方案[^13] [^77]。然而3PC 假設網路延遲有上界、節點響應時間也有上界;大多數現實系統都存在無界網路延遲和程序暫停(參見[第 9 章](/tw/ch9#ch_distributed)3PC 在其中無法保證原子性。
實踐中更好的辦法,是用容錯共識協議替代單節點協調者。[第 10 章](/tw/ch10#ch_consistency)將介紹如何做到這一點。
@@ -918,7 +918,7 @@ SELECT * FROM bookings
異構分散式事務可以用強有力的方式整合不同系統。例如,當且僅當處理訊息的資料庫事務成功提交,才確認訊息佇列中的那條訊息已經處理。實現方法是在同一個事務中原子提交 *訊息確認* 和 *資料庫寫入*。有了分散式事務,即使訊息代理與資料庫是執行在不同機器上的兩種互不相關的技術,也能做到這一點。
如果訊息傳遞或資料庫事務任一失敗,兩者都會中止,訊息代理稍後便可安全地重新投遞。透過原子提交訊息及其處理副作用,可以確保訊息實際上 *恰好處理一次*,即使成功之前重試了好幾次。每次中止都會丟棄未完成事務產生的所有副作用。這就是 *恰好一次語義*。
如果訊息傳遞或資料庫事務任一失敗,兩者都會中止,訊息代理稍後便可安全地重新投遞。透過原子提交訊息及其處理副作用,可以確保訊息實際上 *恰好處理一次*,即使成功之前重試了好幾次。每次中止都會丟棄未完成事務產生的所有副作用。這就是 *恰好一次語義**exactly-once semantics*
不過,只有事務影響的所有系統都能採用同一種原子提交協議,這類分散式事務才有可能實現。例如,假設處理訊息的副作用是傳送郵件,而郵件伺服器不支援兩階段提交;一旦訊息處理失敗並重試,郵件就可能傳送兩次甚至更多次。反之,如果事務中止時,訊息處理產生的所有副作用都能回滾,那麼處理過程便可安全重試,就像什麼也沒有發生一樣。
@@ -954,7 +954,7 @@ XA 假定應用透過網路驅動或客戶端庫,與參與者資料庫或訊
唯一的出路,是由管理員手動決定提交還是回滾。管理員必須檢查每個存疑事務的所有參與者,確定是否已有參與者提交或中止,再把同樣的結果應用到其餘參與者。這項工作可能耗費大量人力,而且往往要在嚴重生產中斷期間,承受巨大的精神與時間壓力來完成——否則協調者也不會陷入如此糟糕的狀態。
許多 XA 實現都留有一個稱為 *啟發式決策* 的緊急出口:即使協調者沒有給出明確決定,也允許參與者單方面中止或提交存疑事務[^73]。需要直說的是,這裡的“*啟發式*”只是“*很可能破壞原子性*”的委婉說法,因為它違背了兩階段提交的承諾體系。因此,啟發式決策只用於擺脫災難性局面,絕不能作為常規手段。
許多 XA 實現都留有一個稱為 *啟發式決策**heuristic decision*的緊急出口:即使協調者沒有給出明確決定,也允許參與者單方面中止或提交存疑事務[^73]。需要直說的是,這裡的“*啟發式*”只是“*很可能破壞原子性*”的委婉說法,因為它違背了兩階段提交的承諾體系。因此,啟發式決策只用於擺脫災難性局面,絕不能作為常規手段。
#### XA 事務的問題 {#problems-with-xa-transactions}
@@ -998,7 +998,7 @@ XA 最嚴重的幾個問題可以這樣解決:
如果訊息處理器在提交資料庫事務前崩潰,事務會中止,訊息代理隨後重試。如果它在提交後、向代理確認前崩潰,代理同樣會重試;但重試時能在資料庫中看到訊息 ID於是直接丟棄該訊息。如果它在確認後、從資料庫刪除訊息 ID 前崩潰,資料庫只會殘留一條舊訊息 ID除了佔用少量空間不會造成其他危害。重試也可能發生在原資料庫事務中止之前——例如訊息處理器與資料庫之間的通訊中斷——此時訊息 ID 表上的唯一性約束應能防止兩個併發事務插入相同 ID。
因此,實現恰好一次處理只需要資料庫內部的事務;這個用例並不要求資料庫與訊息代理之間具備原子性。把訊息 ID 記入資料庫,可以讓訊息處理具備 *冪等性*因而能夠安全重試而不重複產生副作用。Kafka Streams 等流處理框架也用類似辦法實現恰好一次語義,詳見[“容錯”](/tw/ch12#sec_stream_fault_tolerance)。
因此,實現恰好一次處理只需要資料庫內部的事務;這個用例並不要求資料庫與訊息代理之間具備原子性。把訊息 ID 記入資料庫,可以讓訊息處理具備 *冪等性**idempotence*因而能夠安全重試而不重複產生副作用。Kafka Streams 等流處理框架也用類似辦法實現恰好一次語義,詳見[“容錯”](/tw/ch12#sec_stream_fault_tolerance)。
不過,資料庫內部的分散式事務仍有助於擴充套件這類模式。例如,訊息 ID 可以存放在一個分片上,訊息處理所更新的業務資料放在其他分片上,再由內部事務保證跨分片提交的原子性。
@@ -1143,4 +1143,4 @@ XA 最嚴重的幾個問題可以這樣解決:
[^82]: Clemens Vasters. [Transactions in Windows Azure (with Service Bus) An Email Discussion](https://learn.microsoft.com/en-gb/archive/blogs/clemensv/transactions-in-windows-azure-with-service-bus-an-email-discussion). *learn.microsoft.com*, July 2012. Archived at [perma.cc/4EZ9-5SKW](https://perma.cc/4EZ9-5SKW)
[^83]: Ajmer Dhariwal. [Orphaned MSDTC Transactions (-2 spids)](https://www.eraofdata.com/posts/2008/orphaned-msdtc-transactions-2-spids/). *eraofdata.com*, December 2008. Archived at [perma.cc/YG6F-U34C](https://perma.cc/YG6F-U34C)
[^84]: Paul Randal. [Real World Story of DBCC PAGE Saving the Day](https://www.sqlskills.com/blogs/paul/real-world-story-of-dbcc-page-saving-the-day/). *sqlskills.com*, June 2013. Archived at [perma.cc/2MJN-A5QH](https://perma.cc/2MJN-A5QH)
[^85]: Guozhang Wang, Lei Chen, Ayusman Dikshit, Jason Gustafson, Boyang Chen, Matthias J. Sax, John Roesler, Sophie Blee-Goldman, Bruno Cadonna, Apurva Mehta, Varun Madan, and Jun Rao. [Consistency and Completeness: Rethinking Distributed Stream Processing in Apache Kafka](https://dl.acm.org/doi/pdf/10.1145/3448016.3457556). At *ACM International Conference on Management of Data* (SIGMOD), June 2021. [doi:10.1145/3448016.3457556](https://doi.org/10.1145/3448016.3457556)
[^85]: Guozhang Wang, Lei Chen, Ayusman Dikshit, Jason Gustafson, Boyang Chen, Matthias J. Sax, John Roesler, Sophie Blee-Goldman, Bruno Cadonna, Apurva Mehta, Varun Madan, and Jun Rao. [Consistency and Completeness: Rethinking Distributed Stream Processing in Apache Kafka](https://dl.acm.org/doi/pdf/10.1145/3448016.3457556). At *ACM International Conference on Management of Data* (SIGMOD), June 2021. [doi:10.1145/3448016.3457556](https://doi.org/10.1145/3448016.3457556)

View File

@@ -19,7 +19,7 @@ breadcrumbs: false
如果希望系統在發生故障時依然可靠,就必須從根本上轉變思維方式,把注意力放在各種可能出錯的地方,即使出錯的機率很低。某件事只有百萬分之一的機率出錯並不意味著可以不管:系統足夠大時,百萬分之一的事件每天都會發生。經驗豐富的系統運維人員會告訴你,任何 *可能* 出錯的事情,*終究都會* 出錯。
而且,使用分散式系統與在單臺計算機上編寫軟體有著根本區別——最主要的區別,就是事情有了許多新奇而刺激的出錯方式 [^1] [^2]。本章將帶你領略實踐中會遇到的問題,並幫助你理解哪些東西可以依賴,哪些不可以。
而且,使用 *分散式系統**distributed system*與在單臺計算機上編寫軟體有著根本區別——最主要的區別,就是事情有了許多新奇而刺激的出錯方式 [^1] [^2]。本章將帶你領略實踐中會遇到的問題,並幫助你理解哪些東西可以依賴,哪些不可以。
為了理解我們面對的挑戰,接下來讓我們把悲觀主義發揮到極致,考察分散式系統裡可能出錯的種種事情。我們將討論網路問題([“不可靠的網路”](/tw/ch9#sec_distributed_networks)),以及時鐘和時序問題([“不可靠的時鐘”](/tw/ch9#sec_distributed_clocks))。這些問題造成的後果往往令人迷失方向,因此我們還要探討如何認識分散式系統的狀態,以及如何推斷已經發生過的事情([“知識、真相和謊言”](/tw/ch9#sec_distributed_truth))。隨後在 [第 10 章](/tw/ch10#ch_consistency) 中,我們將透過一些例子看看,面對這些故障時如何實現容錯。
@@ -27,7 +27,7 @@ breadcrumbs: false
當你在一臺計算機上編寫程式時,它通常會以相當可預測的方式執行:要麼正常工作,要麼徹底罷工。有缺陷的軟體可能會讓人覺得計算機偶爾也會“狀態不好”(而重啟往往能解決問題),但這通常只是軟體寫得糟糕所造成的表象。
從根本上說,單臺計算機上的軟體不應該時靈時不靈:只要硬體正常,同樣的操作總會產生同樣的結果(也就是 *確定性的*)。如果硬體出了問題(例如記憶體損壞或聯結器鬆動),後果通常是整個系統失效(例如核心恐慌、“藍色畫面宕機”或無法啟動)。一臺執行著良好軟體的計算機,通常要麼功能完好,要麼完全失效,而不會停留在兩者之間。
從根本上說,單臺計算機上的軟體不應該時靈時不靈:只要硬體正常,同樣的操作總會產生同樣的結果(也就是 *確定性的**deterministic*)。如果硬體出了問題(例如記憶體損壞或聯結器鬆動),後果通常是整個系統失效(例如核心恐慌、“藍色畫面宕機”或無法啟動)。一臺執行著良好軟體的計算機,通常要麼功能完好,要麼完全失效,而不會停留在兩者之間。
這是計算機設計中的有意選擇發生內部故障時我們寧願讓計算機徹底崩潰也不願讓它返回錯誤結果因為後者既難處理又容易造成混亂。於是計算機把其底層模糊而混亂的物理現實隱藏起來呈現出一個以數學般的完美方式執行的理想化系統模型。CPU 指令每次都會做同樣的事情;寫入記憶體或磁碟的資料會原樣保留,不會隨機損壞。正如 [“硬體與軟體故障”](/tw/ch2#sec_introduction_hardware_faults) 中所討論的事實並非真的如此——現實中資料的確可能在沒有任何警告的情況下損壞CPU 偶爾也會悄無聲息地給出錯誤結果——只不過這些情況足夠罕見,通常可以忽略。
@@ -37,7 +37,7 @@ breadcrumbs: false
>
> —— 柯達黑爾
在分散式系統中,即使其他部分工作正常,系統的某些部分也很可能以不可預知的方式發生故障。這叫作 *部分失效*。棘手之處在於,部分失效是 *非確定性的*:任何涉及多個節點及其網路的操作,有時能夠成功,有時卻會莫名其妙地失敗。正如我們稍後會看到的,你甚至可能根本 *不知道* 某件事究竟成功了沒有!
在分散式系統中,即使其他部分工作正常,系統的某些部分也很可能以不可預知的方式發生故障。這叫作 *部分失效**partial failure*。棘手之處在於,部分失效是 *非確定性的**nondeterministic*:任何涉及多個節點及其網路的操作,有時能夠成功,有時卻會莫名其妙地失敗。正如我們稍後會看到的,你甚至可能根本 *不知道* 某件事究竟成功了沒有!
正是這種非確定性和部分失效的可能性,讓分散式系統如此難以駕馭 [^4]。不過,如果分散式系統能夠容忍部分失效,也會由此獲得強大的能力。例如,你可以執行滾動升級:每次重啟一個節點來安裝軟體更新,同時讓整個系統始終不間斷地工作。因此,藉助容錯,我們可以用不可靠的元件構建出比單節點系統更可靠的分散式系統。
@@ -47,7 +47,7 @@ breadcrumbs: false
正如 [“共享記憶體、共享磁碟與無共享架構”](/tw/ch2#sec_introduction_shared_nothing) 中所討論的,本書關注的分散式系統大多是 *無共享系統*,也就是一組透過網路連線的機器。網路是這些機器彼此通訊的唯一途徑——我們假定每臺機器都有自己的記憶體和磁碟,一臺機器無法直接訪問另一臺機器的記憶體或磁碟,只能透過網路向服務傳送請求。即使儲存本身是共享的(例如 Amazon S3機器也仍然要透過網路與共享儲存服務通訊。
網際網路和資料中心裡的大多數內部網路(通常是乙太網)都是 *非同步分組網路*。在這種網路中,一個節點可以向另一個節點傳送訊息(即資料包),但網路既不保證訊息何時到達,也不保證它一定能夠到達。如果你發出請求並等待響應,可能會發生許多問題(其中一些如 {{< xref fig="9-1" page="/ch9" anchor="fig_distributed_network" >}}圖 9-1{{< /xref >}} 所示):
網際網路和資料中心裡的大多數內部網路(通常是乙太網)都是 *非同步分組網路**asynchronous packet network*。在這種網路中,一個節點可以向另一個節點傳送訊息(即資料包),但網路既不保證訊息何時到達,也不保證它一定能夠到達。如果你發出請求並等待響應,可能會發生許多問題(其中一些如 {{< xref fig="9-1" page="/ch9" anchor="fig_distributed_network" >}}圖 9-1{{< /xref >}} 所示):
1. 請求可能已經丟失(或許有人拔掉了網線)。
2. 請求可能還在佇列中等待,稍後才會送達(或許網路或接收方過載了)。
@@ -61,18 +61,18 @@ breadcrumbs: false
傳送方甚至無法判斷資料包是否送達:唯一的辦法是由接收方發回響應訊息,而這個響應同樣可能丟失或延遲。在非同步網路中,這些情形無法區分:你掌握的唯一資訊只是“尚未收到響應”。向另一個節點傳送請求卻沒有收到響應時,*不可能* 判斷原因究竟是什麼。
處理這個問題的慣常辦法是設定 *超時*:等待一段時間後便放棄,並假定響應不會再來。然而,即使發生超時,你仍然不知道遠端節點是否收到了請求(如果請求仍在某處排隊,那麼即便傳送方已經放棄,它仍可能在稍後送達接收方)。
處理這個問題的慣常辦法是設定 *超時**timeout*:等待一段時間後便放棄,並假定響應不會再來。然而,即使發生超時,你仍然不知道遠端節點是否收到了請求(如果請求仍在某處排隊,那麼即便傳送方已經放棄,它仍可能在稍後送達接收方)。
### TCP 的侷限性 {#sec_distributed_tcp}
網路資料包有大小上限(通常只有幾千位元組),但許多應用程式需要傳送無法裝進單個資料包的訊息,例如請求和響應。這類應用程式通常使用 TCP傳輸控制協議建立一條 *連線*,把較大的資料流拆成一個個資料包,再在接收端重新組裝起來。
網路資料包有大小上限(通常只有幾千位元組),但許多應用程式需要傳送無法裝進單個資料包的訊息,例如請求和響應。這類應用程式通常使用 TCP傳輸控制協議建立一條 *連線**connection*,把較大的資料流拆成一個個資料包,再在接收端重新組裝起來。
> [!NOTE]
> 下面關於 TCP 的大部分討論,也適用於較新的替代方案 QUIC、WebRTC 使用的流控制傳輸協議SCTP、BitTorrent 的 uTP 協議,以及其他傳輸協議。關於它與 UDP 的比較,參見 [“TCP 與 UDP”](/tw/ch9#sidebar_distributed_tcp_udp)。
TCP 常被說成能提供“可靠”的傳輸這裡的“可靠”是指它能檢測並重傳丟失的資料包發現順序錯亂的資料包並將其恢復為正確順序還能用簡單的校驗和檢測資料包損壞。TCP 也會判斷應當以多快的速度傳送資料,既儘可能快速傳輸,又不至於壓垮網路或接收節點;這叫作 *擁塞控制*、*流量控制* 或 *背壓* [^5]。
TCP 常被說成能提供“可靠”的傳輸這裡的“可靠”是指它能檢測並重傳丟失的資料包發現順序錯亂的資料包並將其恢復為正確順序還能用簡單的校驗和檢測資料包損壞。TCP 也會判斷應當以多快的速度傳送資料,既儘可能快速傳輸,又不至於壓垮網路或接收節點;這叫作 *擁塞控制**congestion control*)、*流量控制**flow control*)或 *背壓**backpressure*[^5]。
當你把資料寫入套接字來“傳送”時,資料其實不會立即發出,只會先進入作業系統管理的緩衝區。擁塞控制演算法判斷目前有能力傳送資料包後,才會從緩衝區取出一個資料包大小的資料,交給網路介面。資料包會經過若干交換機和路由器,最終由接收節點的作業系統把資料放進接收緩衝區,並向傳送方發回確認包。直到這時,接收端作業系統才會通知應用程式又有資料到達 [^6]。
@@ -96,14 +96,14 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
> [!TIP] 網路分割槽
>
> 當網路的一部分因網路故障而與其餘部分隔絕時,這種情況有時稱為 *網路分割槽* 或 *網路分裂*,但它與其他型別的網路中斷並沒有本質區別。網路分割槽與儲存系統的分片無關,後者有時也稱為 *分割槽*(參見 [第 7 章](/tw/ch7#ch_sharding))。
> 當網路的一部分因網路故障而與其餘部分隔絕時,這種情況有時稱為 *網路分割槽**network partition*)或 *網路分裂**netsplit*,但它與其他型別的網路中斷並沒有本質區別。網路分割槽與儲存系統的分片無關,後者有時也稱為 *分割槽*(參見 [第 7 章](/tw/ch7#ch_sharding))。
即使你的環境很少遇到網路故障,故障 *有可能* 發生這一事實也意味著軟體必須能夠處理它。只要透過網路通訊,就有可能失敗——這一點無可迴避。
如果沒有明確定義並測試網路故障的處理方式,後果可能糟到沒有下限。例如,即使網路已經恢復,叢集仍可能陷入死鎖,從此無法再處理請求 [^24];甚至還可能刪掉你的全部資料 [^25]。一旦軟體落入設計者未曾預料的情形,它就可能做出任何出人意料的事情。
處理網路故障不一定意味著要 *容忍* 它。如果網路通常相當可靠,那麼在發生網路問題時直接向使用者顯示錯誤訊息,也可以是一種合理策略。不過,你必須知道軟體會如何應對網路問題,並確保系統能夠從中恢復。可以考慮有意觸發網路問題,測試系統會如何反應(這叫作 *故障注入*;參見 [“故障注入”](/tw/ch9#sec_fault_injection))。
處理網路故障不一定意味著要 *容忍* 它。如果網路通常相當可靠,那麼在發生網路問題時直接向使用者顯示錯誤訊息,也可以是一種合理策略。不過,你必須知道軟體會如何應對網路問題,並確保系統能夠從中恢復。可以考慮有意觸發網路問題,測試系統會如何反應(這叫作 *故障注入**fault injection*;參見 [“故障注入”](/tw/ch9#sec_fault_injection))。
### 故障檢測 {#id307}
@@ -133,7 +133,7 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
設想一個虛構的系統,其網路保證資料包的最大延遲:每個資料包要麼在時間 *d* 以內送達,要麼丟失,絕不會在超過 *d* 之後才送達。再假定我們能夠保證,任何尚未失效的節點總會在時間 *r* 以內處理完請求。在這種情況下,每個成功的請求都能保證在 2 *d* + *r* 以內收到響應;如果過了這麼久仍未收到響應,就可以斷定網路或遠端節點沒有正常工作。假如這些保證真的成立,那麼 2 *d* + *r* 就是合理的超時時間。
遺憾的是,我們使用的大多數系統都不具備上述任何一項保證:非同步網路具有 *無界延遲*(也就是說,它會盡快嘗試送達資料包,但資料包所需的傳輸時間沒有上限),大多數伺服器實現也不能保證一定在某個最長時間內處理完請求(參見 [“響應時間保證”](/tw/ch9#sec_distributed_clocks_realtime))。對故障檢測而言,系統僅僅在大多數時候執行得很快還不夠:如果超時時間設得很短,一次短暫的往返時間尖峰就足以打亂整個系統。
遺憾的是,我們使用的大多數系統都不具備上述任何一項保證:非同步網路具有 *無界延遲**unbounded delay*也就是說,它會盡快嘗試送達資料包,但資料包所需的傳輸時間沒有上限),大多數伺服器實現也不能保證一定在某個最長時間內處理完請求(參見 [“響應時間保證”](/tw/ch9#sec_distributed_clocks_realtime))。對故障檢測而言,系統僅僅在大多數時候執行得很快還不夠:如果超時時間設得很短,一次短暫的往返時間尖峰就足以打亂整個系統。
<a id="sec_distributed_congestion"></a>
@@ -141,7 +141,7 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
開車出行時,交通擁堵往往是造成行程時間波動的最大因素。同樣,在計算機網路上,資料包延遲的變化通常也是排隊造成的 [^27]
* 如果多個節點同時向同一個目的地傳送資料包,網路交換機就必須讓這些資料包排隊,再逐一送入通往目的地的網路鏈路(如 {{< xref fig="9-2" page="/ch9" anchor="fig_distributed_switch_queueing" >}}圖 9-2{{< /xref >}} 所示)。網路鏈路繁忙時,資料包可能需要等待一段時間才能獲得傳送機會,這叫作 *網路擁塞*。如果流入的資料太多,交換機佇列被塞滿,資料包就會被丟棄,因而必須重發——即使網路本身仍在正常工作。
* 如果多個節點同時向同一個目的地傳送資料包,網路交換機就必須讓這些資料包排隊,再逐一送入通往目的地的網路鏈路(如 {{< xref fig="9-2" page="/ch9" anchor="fig_distributed_switch_queueing" >}}圖 9-2{{< /xref >}} 所示)。網路鏈路繁忙時,資料包可能需要等待一段時間才能獲得傳送機會,這叫作 *網路擁塞**network congestion*。如果流入的資料太多,交換機佇列被塞滿,資料包就會被丟棄,因而必須重發——即使網路本身仍在正常工作。
* 資料包抵達目標機器時,如果所有 CPU 核心都在忙,作業系統會把來自網路的請求放進佇列,直到應用程式有能力處理為止。等待時間取決於機器的負載,可能任意之長 [^28]。
* 在虛擬化環境中,當另一臺虛擬機器佔用某個 CPU 核心時,正在執行的作業系統常常會暫停數十毫秒。在此期間,虛擬機器無法消費任何網路資料,因此虛擬機器監控器會將流入的資料排隊(緩衝)[^29],進一步增大網路延遲的波動。
* 如前所述為避免網路過載TCP 會限制資料的傳送速率。這意味著資料甚至還沒進入網路,就已經在傳送方額外排了一次隊。
@@ -162,11 +162,11 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
上述因素都會造成網路延遲的波動。當系統接近最大容量時,排隊延遲的變化範圍尤其大:擁有充足餘量的系統可以輕鬆排空佇列,而利用率很高的系統則可能迅速積起長隊。
在公共雲和多租戶資料中心中,許多客戶共享同一批資源:網路鏈路和交換機是共享的,甚至每臺機器的網路介面和 CPU使用虛擬機器時也是共享的。處理大量資料時可能耗盡網路鏈路的全部容量使其達到 *飽和*。你既無法控制,也無法瞭解其他客戶如何使用共享資源;如果附近某個 *吵鬧的鄰居* 正在大量消耗資源,網路延遲就可能劇烈波動 [^30] [^31]。
在公共雲和多租戶資料中心中,許多客戶共享同一批資源:網路鏈路和交換機是共享的,甚至每臺機器的網路介面和 CPU使用虛擬機器時也是共享的。處理大量資料時可能耗盡網路鏈路的全部容量使其達到 *飽和**saturation*。你既無法控制,也無法瞭解其他客戶如何使用共享資源;如果附近某個 *吵鬧的鄰居**noisy neighbor*正在大量消耗資源,網路延遲就可能劇烈波動 [^30] [^31]。
在這樣的環境中,只能透過實驗來選擇超時時間:長期測量大量機器之間網路往返時間的分佈,確定延遲通常會有多大波動。然後結合應用程式自身的特點,在故障檢測的延遲與過早超時的風險之間做出適當權衡。
更好的辦法是不用固定配置的超時時間,而讓系統持續測量響應時間及其波動(*抖動*再根據觀測到的響應時間分佈自動調整超時。Phi 累積故障檢測器 [^32] 就採用了這種方法Akka 和 Cassandra 等系統都在使用它 [^33]。TCP 的重傳超時也以類似方式工作 [^5]。
更好的辦法是不用固定配置的超時時間,而讓系統持續測量響應時間及其波動(*抖動**jitter*再根據觀測到的響應時間分佈自動調整超時。Phi 累積故障檢測器 [^32] 就採用了這種方法Akka 和 Cassandra 等系統都在使用它 [^33]。TCP 的重傳超時也以類似方式工作 [^5]。
### 同步網路與非同步網路 {#sec_distributed_sync_networks}
@@ -174,9 +174,9 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
要回答這個問題,不妨把資料中心網路與傳統的固定電話網路(非蜂窩網路,也非 VoIP比較一下。傳統電話網路極其可靠音訊幀延遲和通話中斷都很罕見。電話通話需要持續保持較低的端到端延遲並提供足夠頻寬來傳輸語音取樣。如果計算機網路也能具備類似的可靠性與可預測性豈不是很好
透過電話網路撥號時,網路會建立一條 *電路*:從一名通話者到另一名通話者的整條路徑上,都會為這次通話分配固定且有保證的頻寬。這條電路會一直保留到通話結束 [^34]。例如ISDN 網路以每秒 4,000 幀的固定速率執行。建立通話時,每一幀都會在兩個方向上各分配 16 位空間。因此在整個通話期間,雙方都能保證每 250 微秒恰好傳送 16 位音訊資料 [^35]。
透過電話網路撥號時,網路會建立一條 *電路**circuit*:從一名通話者到另一名通話者的整條路徑上,都會為這次通話分配固定且有保證的頻寬。這條電路會一直保留到通話結束 [^34]。例如ISDN 網路以每秒 4,000 幀的固定速率執行。建立通話時,每一幀都會在兩個方向上各分配 16 位空間。因此在整個通話期間,雙方都能保證每 250 微秒恰好傳送 16 位音訊資料 [^35]。
這類網路是 *同步的*:即使資料要經過多臺路由器,也不會排隊,因為每一跳都已經為這次通話預留了 16 位的空間。由於無需排隊,網路的最大端到端延遲是固定的。我們稱之為 *有界延遲*
這類網路是 *同步的**synchronous*:即使資料要經過多臺路由器,也不會排隊,因為每一跳都已經為這次通話預留了 16 位的空間。由於無需排隊,網路的最大端到端延遲是固定的。我們稱之為 *有界延遲**bounded delay*
#### 我們不能簡單地讓網路延遲可預測嗎? {#can-we-not-simply-make-network-delays-predictable}
@@ -184,11 +184,11 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
如果資料中心網路和網際網路採用電路交換,那麼建立電路時就可以同時確定有保證的最大往返時間。但它們並非如此:乙太網和 IP 都是分組交換協議,資料包會排隊,因而網路延遲沒有上界。這些協議中根本沒有“電路”的概念。
為什麼資料中心網路和網際網路要使用分組交換?因為它們針對 *突發流量* 做了最佳化。音訊或視訊通話在整個通話期間每秒傳輸的位數相當穩定,很適合使用電路。相比之下,請求網頁、傳送電子郵件或傳輸檔案並沒有特定的頻寬要求——我們只希望它們儘快完成。
為什麼資料中心網路和網際網路要使用分組交換?因為它們針對 *突發流量**bursty traffic*做了最佳化。音訊或視訊通話在整個通話期間每秒傳輸的位數相當穩定,很適合使用電路。相比之下,請求網頁、傳送電子郵件或傳輸檔案並沒有特定的頻寬要求——我們只希望它們儘快完成。
如果要透過電路傳輸檔案就必須先猜測應當分配多少頻寬。猜得太低傳輸速度會慢得毫無必要同時還有網路容量閒置猜得太高電路又無法建立因為網路不能為無法保證頻寬分配的電路放行。因此用電路傳輸突發資料既浪費網路容量又使傳輸無謂地變慢。TCP 則不同,它會根據可用的網路容量動態調整資料傳輸速率。
人們也曾嘗試構建兼具電路交換與分組交換的混合網路。*非同步傳輸模式*ATM在 20 世紀 80 年代曾與乙太網競爭但除了電話網路的核心交換機外並未得到廣泛採用。InfiniBand 與之有些相似 [^36]:它在鏈路層實現端到端流量控制,減少了網路排隊的必要,不過鏈路擁塞仍可能帶來延遲 [^37]。如果謹慎運用 *服務質量*QoS即資料包的優先順序與排程*准入控制*(對傳送方限速),就可以在分組網路上類比電路交換,或提供統計意義上的有界延遲 [^27] [^34]。低延遲、低損耗和可伸縮吞吐量L4S等新型網路演算法試圖從客戶端和路由器兩端緩解部分排隊與擁塞控制問題。Linux 的流量控制器TC也允許應用程式為實現 QoS 而重新安排資料包的優先順序。
人們也曾嘗試構建兼具電路交換與分組交換的混合網路。*非同步傳輸模式**Asynchronous Transfer Mode*ATM在 20 世紀 80 年代曾與乙太網競爭但除了電話網路的核心交換機外並未得到廣泛採用。InfiniBand 與之有些相似 [^36]:它在鏈路層實現端到端流量控制,減少了網路排隊的必要,不過鏈路擁塞仍可能帶來延遲 [^37]。如果謹慎運用 *服務質量**quality of service*QoS即資料包的優先順序與排程*准入控制**admission control*,即對傳送方限速),就可以在分組網路上類比電路交換,或提供統計意義上的有界延遲 [^27] [^34]。低延遲、低損耗和可伸縮吞吐量L4S等新型網路演算法試圖從客戶端和路由器兩端緩解部分排隊與擁塞控制問題。Linux 的流量控制器TC也允許應用程式為實現 QoS 而重新安排資料包的優先順序。
<a id="sidebar_distributed_latency_utilization"></a>
@@ -227,7 +227,7 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
7. 這個快取條目什麼時候過期?
8. 日誌檔案裡這條錯誤訊息的時間戳是什麼?
問題 14 測量的是 *持續時間*(例如從傳送請求到收到響應之間的時間間隔),問題 58 描述的則是 *時間點*(在某個具體日期和時刻發生的事件)。
問題 14 測量的是 *持續時間**duration*例如從傳送請求到收到響應之間的時間間隔),問題 58 描述的則是 *時間點**point in time*在某個具體日期和時刻發生的事件)。
在分散式系統中,時間是個棘手的問題,因為通訊並非瞬間完成:訊息從一臺機器經網路傳到另一臺機器需要時間。收到訊息的時刻必然晚於發出訊息的時刻,但由於網路延遲會發生變化,我們不知道究竟晚了多少。涉及多臺機器時,這一事實有時會讓事件的先後順序難以確定。
@@ -235,11 +235,11 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
### 單調時鐘與日曆時鐘 {#sec_distributed_monotonic_timeofday}
現代計算機至少配有兩種不同的時鐘:*日曆時鐘**單調時鐘*。它們雖然都用來度量時間,卻服務於不同目的,因此必須加以區分。
現代計算機至少配有兩種不同的時鐘:*日曆時鐘**time-of-day clock*)和 *單調時鐘**monotonic clock*。它們雖然都用來度量時間,卻服務於不同目的,因此必須加以區分。
#### 日曆時鐘 {#time-of-day-clocks}
日曆時鐘所做的,正是人們直覺上認為時鐘應該做的事:按照某種曆法返回當前日期和時間(也稱為 *牆上時鐘時間*。例如Linux 的 `clock_gettime(CLOCK_REALTIME)` 和 Java 的 `System.currentTimeMillis()` 返回從 *紀元* 至今經過的秒數(或毫秒數):這裡的紀元是格里高利曆中的 1970 年 1 月 1 日 UTC 零時且不計閏秒。有些系統使用其他日期作為參考點。Linux 雖然把這個時鐘稱為 *實時* 時鐘,但它與實時作業系統毫無關係,參見 [“響應時間保證”](/tw/ch9#sec_distributed_clocks_realtime)。)
日曆時鐘所做的,正是人們直覺上認為時鐘應該做的事:按照某種曆法返回當前日期和時間(也稱為 *牆上時鐘時間**wall-clock time*。例如Linux 的 `clock_gettime(CLOCK_REALTIME)` 和 Java 的 `System.currentTimeMillis()` 返回從 *紀元**epoch*至今經過的秒數(或毫秒數):這裡的紀元是格里高利曆中的 1970 年 1 月 1 日 UTC 零時且不計閏秒。有些系統使用其他日期作為參考點。Linux 雖然把這個時鐘稱為 *實時* 時鐘,但它與實時作業系統毫無關係,參見 [“響應時間保證”](/tw/ch9#sec_distributed_clocks_realtime)。)
日曆時鐘通常會與 NTP 同步,因此理想情況下,一臺機器上的某個時間戳與另一臺機器上的同一時間戳表示同一時刻。不過,日曆時鐘也有各種怪異之處,下一節將進一步說明。尤其是當本地時鐘比 NTP 伺服器快得太多時,它可能會被強制重置,看起來就像突然跳回了過去。這樣的跳變,以及閏秒造成的類似跳變,使日曆時鐘不適合測量已經過去了多長時間 [^40]。
@@ -253,7 +253,7 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
在有多個 CPU 插槽的伺服器上,每顆 CPU 都可能有自己的計時器,而且未必與其他 CPU 同步 [^43]。作業系統會補償其中的差異,盡力讓應用程式執行緒看到單調遞增的時鐘,即使執行緒在不同 CPU 之間排程也是如此。不過,對這樣的單調性保證最好還是有所保留 [^44]。
如果 NTP 發現計算機的本地石英鐘比 NTP 伺服器走得更快或更慢,可以調節單調時鐘向前推進的速率,這叫作對時鐘進行 *漸進校準*。預設情況下NTP 最多可以把時鐘速率加快或減慢 0.05%,但不能讓單調時鐘突然向前或向後跳變。單調時鐘通常有很好的解析度:在大多數系統上,它能測量微秒級甚至更短的時間間隔。
如果 NTP 發現計算機的本地石英鐘比 NTP 伺服器走得更快或更慢,可以調節單調時鐘向前推進的速率,這叫作對時鐘進行 *漸進校準**slewing*。預設情況下NTP 最多可以把時鐘速率加快或減慢 0.05%,但不能讓單調時鐘突然向前或向後跳變。單調時鐘通常有很好的解析度:在大多數系統上,它能測量微秒級甚至更短的時間間隔。
在分散式系統中,用單調時鐘測量經過的時間(例如超時)通常沒有問題,因為這不要求不同節點的時鐘彼此同步,而且測量中的輕微誤差也不會造成太大影響。
@@ -266,7 +266,7 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
* 如果防火牆意外阻斷了節點與 NTP 伺服器的通訊,這項錯誤配置可能很長時間都無人察覺;在此期間,漂移不斷累積,不同節點的時鐘最終可能相差甚遠。軼事證據表明,實踐中確實發生過這種情況。
* NTP 同步的準確性不可能優於網路延遲。因此,在資料包延遲波動不定的擁塞網路上,它的準確性必然有限。一項實驗表明,經由網際網路同步所能達到的最小誤差為 35 毫秒 [^46],而網路延遲偶爾出現尖峰時,誤差會達到一秒左右。具體取決於配置,過大的網路延遲甚至可能讓 NTP 客戶端徹底放棄同步。
* 有些 NTP 伺服器本身不正確或配置有誤,報告的時間能相差數小時 [^47] [^48]。NTP 客戶端會查詢多臺伺服器並忽略離群值,以減輕這類錯誤的影響。即便如此,把系統的正確性押在某個網際網路陌生人報給你的時間上,多少還是令人不安。
* 閏秒會讓一分鐘變成 59 秒或 61 秒,從而打亂那些設計時未考慮閏秒的系統對時序所作的假設 [^49]。閏秒已經導致許多大型系統崩潰 [^40] [^50],足見關於時鐘的錯誤假設有多麼容易悄然混入系統。處理閏秒的最佳辦法,或許是讓 NTP 伺服器“撒謊”:把閏秒調整分攤到一整天內逐漸完成,這稱為 *平滑處理* [^51] [^52];不過實踐中各 NTP 伺服器的實際行為並不一致 [^53]。好在從 2035 年起將不再使用閏秒,這個問題也會隨之消失。
* 閏秒會讓一分鐘變成 59 秒或 61 秒,從而打亂那些設計時未考慮閏秒的系統對時序所作的假設 [^49]。閏秒已經導致許多大型系統崩潰 [^40] [^50],足見關於時鐘的錯誤假設有多麼容易悄然混入系統。處理閏秒的最佳辦法,或許是讓 NTP 伺服器“撒謊”:把閏秒調整分攤到一整天內逐漸完成,這稱為 *平滑處理**smearing*[^51] [^52];不過實踐中各 NTP 伺服器的實際行為並不一致 [^53]。好在從 2035 年起將不再使用閏秒,這個問題也會隨之消失。
* 在虛擬機器中,硬體時鐘也是虛擬化的,這給需要精確計時的應用程式帶來了額外挑戰 [^54]。多個虛擬機器共享 CPU 核心時,一臺虛擬機器執行,其他虛擬機器就可能暫停數十毫秒。從應用程式的視角看,這種暫停表現為時鐘突然向前跳躍 [^29]。如果虛擬機器暫停了幾秒,其時鐘隨後可能比實際時間慢幾秒,但 NTP 仍可能報告時鐘幾乎完全同步 [^55]。
* 如果軟體執行在你無法完全控制的裝置上(例如移動裝置或嵌入式裝置),那麼裝置的硬體時鐘可能根本不值得信任。有些使用者會故意把硬體時鐘設定成錯誤的日期和時間,例如藉此在遊戲中作弊 [^56]。因此,時鐘可能被設到離譜的過去或未來。
@@ -309,7 +309,7 @@ TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:
能否把 NTP 同步做得足夠精確從而徹底避免這類錯誤排序恐怕不能。除了石英鐘漂移等其他誤差源以外NTP 的同步精度本身就受網路往返時間限制。若要保證順序正確,時鐘誤差必須顯著小於網路延遲,而這是不可能做到的。
所謂的 *邏輯時鐘* [^66] 以遞增計數器為基礎,而不是以振盪的石英晶體為基礎,因此是為事件排序時更安全的選擇(參見 [“檢測併發寫入”](/tw/ch6#sec_replication_concurrent))。邏輯時鐘既不度量一天中的時刻,也不度量經過了多少秒,只記錄事件之間的相對順序(一個事件發生在另一個事件之前還是之後)。與之相對,日曆時鐘和單調時鐘度量真實流逝的時間,因此也稱為 *物理時鐘*。我們將在 [“ID 生成器和邏輯時鐘”](/tw/ch10#sec_consistency_logical) 中更詳細地討論邏輯時鐘。
所謂的 *邏輯時鐘**logical clock*[^66] 以遞增計數器為基礎,而不是以振盪的石英晶體為基礎,因此是為事件排序時更安全的選擇(參見 [“檢測併發寫入”](/tw/ch6#sec_replication_concurrent))。邏輯時鐘既不度量一天中的時刻,也不度量經過了多少秒,只記錄事件之間的相對順序(一個事件發生在另一個事件之前還是之後)。與之相對,日曆時鐘和單調時鐘度量真實流逝的時間,因此也稱為 *物理時鐘**physical clock*。我們將在 [“ID 生成器和邏輯時鐘”](/tw/ch10#sec_consistency_logical) 中更詳細地討論邏輯時鐘。
#### 帶置信區間的時鐘讀數 {#clock-readings-with-a-confidence-interval}
@@ -343,7 +343,7 @@ Spanner 正是以這種方式實現跨資料中心的快照隔離 [^68] [^69]。
再來看一個在分散式系統中危險使用時鐘的例子。假設某個資料庫的每個分片都只有一個領導者,而且只有領導者可以接受寫入。一個節點怎麼知道自己仍是領導者(沒有被其他節點宣告死亡),因而可以安全地接受寫入呢?
一種辦法是由領導者向其他節點取得一份 *租約*,它類似於帶有超時的鎖 [^73]。任何時刻只能有一個節點持有租約。因此,節點拿到租約後,就知道自己在租約到期以前的一段時間內仍是領導者。為保持領導者身份,節點必須在租約到期前定期續租。如果節點失效,就會停止續租;租約到期後,另一個節點便可接管。
一種辦法是由領導者向其他節點取得一份 *租約**lease*,它類似於帶有超時的鎖 [^73]。任何時刻只能有一個節點持有租約。因此,節點拿到租約後,就知道自己在租約到期以前的一段時間內仍是領導者。為保持領導者身份,節點必須在租約到期前定期續租。如果節點失效,就會停止續租;租約到期後,另一個節點便可接管。
可以想象,請求處理迴圈大致如下:
@@ -374,9 +374,9 @@ while (true) {
* 許多程式語言執行時(例如 Java 虛擬機器)都帶有 *垃圾回收器*GC偶爾需要停止所有正在執行的執行緒。過去這類 *STW GC 暫停* 有時會讓程式停上幾分鐘 [^75]!現代 GC 演算法已經大大緩解了這個問題,但 GC 暫停仍可能相當明顯(參見 [“限制垃圾回收的影響”](/tw/ch9#sec_distributed_gc_impact))。
* 在虛擬化環境中,虛擬機器可以被 *掛起*(暫停所有程序並把記憶體內容儲存到磁碟),隨後再 *恢復*(還原記憶體內容並從原處繼續執行)。這種暫停可能發生在程序執行的任何時刻,持續時間也沒有上限。這個功能有時用於把虛擬機器從一臺宿主機 *實時遷移* 到另一臺宿主機而無需重啟;在這種情況下,暫停多久取決於程序寫入記憶體的速率 [^76]。
* 在膝上型電腦和手機等終端使用者裝置上,執行也可能隨時掛起並恢復,例如使用者合上膝上型電腦螢幕時。
* 當作業系統切換到另一個執行緒,或者虛擬機器監控器切換到另一臺虛擬機器時,當前執行緒可能停在程式碼中的任意位置。對虛擬機器而言,被其他虛擬機器佔用的 CPU 時間稱為 *竊取時間*。如果機器負載很高——也就是有很長的執行緒佇列在等待執行——暫停的執行緒可能要過一陣子才能再次獲得執行機會。
* 當作業系統切換到另一個執行緒,或者虛擬機器監控器切換到另一臺虛擬機器時,當前執行緒可能停在程式碼中的任意位置。對虛擬機器而言,被其他虛擬機器佔用的 CPU 時間稱為 *竊取時間**steal time*。如果機器負載很高——也就是有很長的執行緒佇列在等待執行——暫停的執行緒可能要過一陣子才能再次獲得執行機會。
* 如果應用程式執行同步磁碟訪問,執行緒可能暫停下來,等待緩慢的磁碟 I/O 操作完成 [^77]。在許多語言中即使程式碼沒有明確讀寫檔案磁碟訪問也可能出人意料地發生。例如Java 類載入器會在類第一次使用時才載入類檔案而這可能出現在程式執行的任何時刻。I/O 暫停與 GC 暫停甚至可能相互疊加 [^78]。如果所謂的磁碟其實是網路檔案系統或網路塊裝置(例如 Amazon EBSI/O 延遲還會受到網路延遲波動的影響 [^31]。
* 如果作業系統允許 *換頁到磁碟**分頁*),一次簡單的記憶體訪問也可能觸發缺頁錯誤,必須從磁碟把某個頁面載入記憶體。在這項緩慢的 I/O 操作完成以前,執行緒會一直暫停。如果記憶體壓力很大,還可能需要先把另一個頁面換出到磁碟。極端情況下,作業系統會把大部分時間耗在記憶體頁面的換入換出上,幾乎不做實際工作,這叫作 *抖動*。為了避免這種情況,伺服器通常會禁用分頁——與其冒著發生抖動的風險,不如終止一個程序來釋放記憶體。
* 如果作業系統允許 *換頁到磁碟**分頁*),一次簡單的記憶體訪問也可能觸發缺頁錯誤,必須從磁碟把某個頁面載入記憶體。在這項緩慢的 I/O 操作完成以前,執行緒會一直暫停。如果記憶體壓力很大,還可能需要先把另一個頁面換出到磁碟。極端情況下,作業系統會把大部分時間耗在記憶體頁面的換入換出上,幾乎不做實際工作,這叫作 *抖動**thrashing*。為了避免這種情況,伺服器通常會禁用分頁——與其冒著發生抖動的風險,不如終止一個程序來釋放記憶體。
* 向 Unix 程序傳送 `SIGSTOP` 訊號也會令其暫停,例如在 shell 中按 Ctrl-Z。這個訊號會立即停止給程序分配 CPU 週期,直到 `SIGCONT` 令它恢復;隨後,它會從先前停下的位置繼續執行。即使你的環境通常不用 `SIGSTOP`,運維人員也可能不小心發出這個訊號。
上述任何事件都可能在任意位置 *搶佔* 正在執行的執行緒,過一段時間再讓它恢復,而執行緒對此毫無察覺。這個問題類似於保證單機多執行緒程式碼的執行緒安全:不能對時序作任何假定,因為上下文切換與並行執行隨時都可能發生。
@@ -389,7 +389,7 @@ while (true) {
如上所述,在許多程式語言與作業系統中,執行緒和程序都可能暫停任意長的時間。不過,只要投入足夠努力,這些暫停的原因 *確實可以* 消除。
有些軟體執行在這樣的環境中:如果不能在規定時間內響應,就可能造成嚴重損害。控制飛機、火箭、機器人、汽車及其他實體裝置的計算機,必須快速而且可預測地響應感測器輸入。在這些系統中,軟體必須趕在明確規定的 *截止時間* 之前響應;錯過截止時間,就可能導致整個系統失效。這樣的系統稱為 *硬實時* 系統。
有些軟體執行在這樣的環境中:如果不能在規定時間內響應,就可能造成嚴重損害。控制飛機、火箭、機器人、汽車及其他實體裝置的計算機,必須快速而且可預測地響應感測器輸入。在這些系統中,軟體必須趕在明確規定的 *截止時間**deadline*之前響應;錯過截止時間,就可能導致整個系統失效。這樣的系統稱為 *硬實時**hard real-time*系統。
> [!NOTE]
@@ -426,7 +426,7 @@ while (true) {
關於這類系統的討論已經近乎哲學:在系統中,我們究竟知道什麼為真、什麼為假?如果感知和測量的機制都不可靠,我們又能在多大程度上確信自己的認知 [^83]?軟體系統是否應當服從我們認為物理世界必然遵循的法則,例如因果律?
好在我們不必一路追問到生命的意義。在分散式系統中,可以明確寫出對系統行為所作的假設(即 *系統模型*),再把實際系統設計成符合這些假設。我們還可以證明某個演算法在特定系統模型中能夠正確執行。這意味著,即使底層系統模型只提供極少保證,也仍然可以實現可靠的行為。
好在我們不必一路追問到生命的意義。在分散式系統中,可以明確寫出對系統行為所作的假設(即 *系統模型**system model*),再把實際系統設計成符合這些假設。我們還可以證明某個演算法在特定系統模型中能夠正確執行。這意味著,即使底層系統模型只提供極少保證,也仍然可以實現可靠的行為。
不過,要讓軟體在不可靠的系統模型中表現良好,絕非輕而易舉。本章餘下部分將進一步探討分散式系統中的知識與真相,幫助我們思考可以作出哪些假設,以及希望提供哪些保證。在 [第 10 章](/tw/ch10#ch_consistency) 中,我們將繼續考察一些分散式演算法:它們在特定假設下提供特定保證。
@@ -438,7 +438,7 @@ while (true) {
第三種場景是,假設某個節點暫停執行一分鐘。在此期間,它既不處理請求,也不傳送響應。其他節點一邊等待、一邊重試,終於失去耐心,宣告它死亡並把它抬上靈車。最終暫停結束,節點的執行緒若無其事地繼續執行。其他節點驚訝地看到,那個本應死去的節點突然精神抖擻地從棺材裡探出頭來,興高采烈地與旁人聊天。剛恢復時,這個節點甚至不知道整整一分鐘已經過去,也不知道自己已經被宣告死亡——在它看來,距離上次和其他節點交談彷彿只過了一瞬間。
這些故事告訴我們,節點未必能相信自己對處境的判斷。分散式系統不能只依賴某一個節點,因為節點隨時可能失效,使系統陷入僵局而無法恢復。因此,許多分散式演算法依賴 *法定人數*,也就是讓多個節點投票(參見 [“讀寫仲裁”](/tw/ch6#sec_replication_quorum_condition)):一項決策必須獲得若干節點的最低票數,藉此減少對任何單個節點的依賴。
這些故事告訴我們,節點未必能相信自己對處境的判斷。分散式系統不能只依賴某一個節點,因為節點隨時可能失效,使系統陷入僵局而無法恢復。因此,許多分散式演算法依賴 *法定人數**quorum*,也就是讓多個節點投票(參見 [“讀寫仲裁”](/tw/ch6#sec_replication_quorum_condition)):一項決策必須獲得若干節點的最低票數,藉此減少對任何單個節點的依賴。
宣告節點死亡的決定也是如此。如果達到法定人數的節點宣告另一個節點已經死亡,那麼即使它覺得自己還活得好好的,也必須被視為死亡。單個節點必須服從法定人數作出的決定並下臺。
@@ -470,7 +470,7 @@ while (true) {
#### 用柵欄機制隔離殭屍與延遲請求 {#sec_distributed_fencing_tokens}
*殭屍* 一詞有時用來形容這樣的原租約持有者:它還不知道自己已經失去租約,仍把自己當作當前持有者行事。既然無法徹底杜絕殭屍,就必須確保它們不能以腦裂的形式造成任何破壞。這稱為用 *柵欄機制* 隔離殭屍。
*殭屍**zombie*一詞有時用來形容這樣的原租約持有者:它還不知道自己已經失去租約,仍把自己當作當前持有者行事。既然無法徹底杜絕殭屍,就必須確保它們不能以腦裂的形式造成任何破壞。這稱為用 *柵欄機制**fencing*隔離殭屍。
有些系統試圖透過關停殭屍來隔離它,例如斷開它的網路連線 [^9]、透過雲服務商的管理介面關閉虛擬機器,甚至直接切斷機器電源 [^87]。這種做法稱為 STONITH即“擊斃另一個節點”。遺憾的是它有幾個問題無法防範 {{< xref fig="9-5" page="/ch9" anchor="fig_distributed_lease_delay" >}}圖 9-5{{< /xref >}} 所示的超長網路延遲;所有節點可能彼此關停 [^19];而等到殭屍被發現並關閉時,也許早已為時過晚,資料已經損壞。
@@ -479,18 +479,18 @@ while (true) {
{{< fig num="9-6" id="fig_distributed_fencing" src="/fig/ddia_0906.png" caption="只允許寫入按照遞增的柵欄令牌順序執行,從而保證儲存訪問安全。" class="ddia-figure ddia-figure--wide" width="2658" height="975" />}}
假設鎖服務每次授予鎖或租約時,還會返回一個 *柵欄令牌*。這是一個每次授予鎖都會增大的數字,例如由鎖服務負責遞增。隨後可以要求客戶端每次向儲存服務傳送寫請求時,都必須帶上自己當前的柵欄令牌。
假設鎖服務每次授予鎖或租約時,還會返回一個 *柵欄令牌**fencing token*。這是一個每次授予鎖都會增大的數字,例如由鎖服務負責遞增。隨後可以要求客戶端每次向儲存服務傳送寫請求時,都必須帶上自己當前的柵欄令牌。
> [!NOTE]
> 柵欄令牌還有幾種別稱。在 Google 的鎖服務 Chubby 中,它叫作 *序列器* [^88];在 Kafka 中則叫作 *紀元編號*。在我們將在 [第 10 章](/tw/ch10#ch_consistency) 討論的共識演算法中Paxos 的 *投票編號* 或 Raft 的 *任期編號* 也發揮著類似作用。
> 柵欄令牌還有幾種別稱。在 Google 的鎖服務 Chubby 中,它叫作 *序列器**sequencer*[^88];在 Kafka 中則叫作 *紀元編號**epoch number*。在我們將在 [第 10 章](/tw/ch10#ch_consistency) 討論的共識演算法中Paxos 的 *投票編號**ballot number*)或 Raft 的 *任期編號**term number*也發揮著類似作用。
在 {{< xref fig="9-6" page="/ch9" anchor="fig_distributed_fencing" >}}圖 9-6{{< /xref >}} 中,客戶端 1 取得租約及令牌 33隨後卻長時間暫停導致租約過期。客戶端 2 接著取得租約及令牌 34令牌值始終遞增並向儲存服務傳送帶令牌 34 的寫請求。稍後,客戶端 1 恢復執行,也向儲存服務傳送寫請求,其中帶著自己的令牌 33。然而儲存服務記得自己已經處理過令牌值更高34的寫入因此會拒絕令牌 33 的請求。剛取得租約的客戶端必須立刻向儲存服務執行一次寫入;一旦這次寫入完成,所有殭屍都會被隔離在外。
如果使用 ZooKeeper 作為鎖服務,可以把事務 ID `zxid` 或節點版本 `cversion` 用作柵欄令牌 [^85]。在 etcd 中,修訂號與租約 ID 共同發揮類似作用 [^89]。Hazelcast 的 FencedLock API 則會顯式生成柵欄令牌 [^90]。
這種機制要求儲存服務能夠檢查寫入所攜帶的令牌是否已經過時。另一種辦法是讓服務支援類似原子比較並設定CAS的寫入只有從當前客戶端上次讀取物件以後沒有其他客戶端寫過該物件寫入才會成功。物件儲存服務就支援這類檢查Amazon S3 稱之為 *條件寫入*Azure Blob Storage 稱之為 *條件標頭*Google Cloud Storage 則稱之為 *請求前置條件*
這種機制要求儲存服務能夠檢查寫入所攜帶的令牌是否已經過時。另一種辦法是讓服務支援類似原子比較並設定CAS的寫入只有從當前客戶端上次讀取物件以後沒有其他客戶端寫過該物件寫入才會成功。物件儲存服務就支援這類檢查Amazon S3 稱之為 *條件寫入**conditional write*Azure Blob Storage 稱之為 *條件標頭**conditional header*Google Cloud Storage 則稱之為 *請求前置條件**request precondition*
#### 多副本隔離 {#fencing-with-multiple-replicas}
@@ -513,18 +513,18 @@ while (true) {
本書假定節點雖然不可靠,卻是誠實的:它們可能因為故障而響應緩慢或永不響應,也可能因為 GC 暫停或網路延遲而持有過時狀態;但我們假定,只要節點 *確實* 作出響應,它說的就是“真話”——至少據它自己所知,它是在遵守協議規則。
如果節點有可能“撒謊”,也就是發出任意錯誤或損壞的響應,分散式系統問題就會困難得多。例如,一個節點可能在同一輪選舉中投出多張互相矛盾的票。這種行為叫作 *拜占庭故障*,而在這種互不信任的環境中達成共識的問題,則稱為 *拜占庭將軍問題* [^94]。
如果節點有可能“撒謊”,也就是發出任意錯誤或損壞的響應,分散式系統問題就會困難得多。例如,一個節點可能在同一輪選舉中投出多張互相矛盾的票。這種行為叫作 *拜占庭故障**Byzantine fault*,而在這種互不信任的環境中達成共識的問題,則稱為 *拜占庭將軍問題**Byzantine Generals Problem*[^94]。
> [!TIP] 拜占庭將軍問題
>
> 拜占庭將軍問題是所謂 *兩將軍問題* [^95] 的推廣。兩將軍問題設想,兩名軍隊將領必須就作戰計劃達成一致,但他們駐紮在不同地點,只能派信使互通訊息,而信使有時會遲到或失蹤(就像網路資料包一樣)。我們將在 [第 10 章](/tw/ch10#ch_consistency) 中討論這個 *共識* 問題。
> 拜占庭將軍問題是所謂 *兩將軍問題**Two Generals Problem*[^95] 的推廣。兩將軍問題設想,兩名軍隊將領必須就作戰計劃達成一致,但他們駐紮在不同地點,只能派信使互通訊息,而信使有時會遲到或失蹤(就像網路資料包一樣)。我們將在 [第 10 章](/tw/ch10#ch_consistency) 中討論這個 *共識**consensus*問題。
>
> 在拜占庭版本的問題中,需要達成一致的將軍有 *n* 名,但其中混入了若干叛徒。大多數將軍都忠誠可靠,會傳送真實訊息;叛徒卻可能傳送虛假訊息,企圖欺騙並迷惑其他人。而誰是叛徒,事先無從得知。
>
> 拜占庭原是古希臘的一座城市,後來成為君士坦丁堡,所在地就是今天土耳其的伊斯坦布林。沒有任何歷史證據表明,拜占庭的將軍比其他地方的將軍更愛陰謀詭計。這個名稱其實來自 *拜占庭式* 一詞在政治語境中的含義:*過度複雜、官僚而詭詐*;這種用法遠在計算機出現以前便已存在 [^96]。Lamport 想選擇一個不會冒犯讀者的國籍,而別人勸他最好不要把問題叫作 *阿爾巴尼亞將軍問題* [^97]。
如果某些節點發生故障、不遵守協議,或有惡意攻擊者干擾網路時,系統仍能繼續正確執行,就稱這個系統具有 *拜占庭容錯* 能力。這類問題在某些特定情形下確實很重要。例如:
如果某些節點發生故障、不遵守協議,或有惡意攻擊者干擾網路時,系統仍能繼續正確執行,就稱這個系統具有 *拜占庭容錯**Byzantine fault tolerance*BFT能力。這類問題在某些特定情形下確實很重要。例如:
* 在航空航天環境中,輻射可能破壞計算機記憶體或 CPU 暫存器裡的資料,使節點以任意且不可預測的方式回應其他節點。系統失效的代價極其高昂——例如飛機墜毀導致機上人員全部遇難,或火箭撞上國際空間站——因此飛行控制系統必須能夠容忍拜占庭故障 [^98] [^99]。
* 在有多方參與的系統中,部分參與者可能企圖欺騙或詐騙其他人。此時,節點不能輕信另一個節點發來的訊息,因為訊息可能帶有惡意。比特幣等加密貨幣及其他區塊鏈,就可以看作一種無需依賴中央權威,讓彼此不信任的各方就某筆交易是否發生達成一致的方式 [^100]。
@@ -551,29 +551,29 @@ Web 應用程式的確必須預料到Web 瀏覽器等由終端使用者控制
人們已經設計了許多演算法來解決分散式系統問題。例如,我們將在 [第 10 章](/tw/ch10#ch_consistency) 中考察共識問題的解決方案。要真正有用,這些演算法必須能夠容忍本章討論的分散式系統中的各種故障。
演算法的編寫方式不應過度依賴其執行環境中具體的硬體與軟體配置。這又要求我們以某種方式,把系統中預期會發生的故障型別形式化。為此,我們定義 *系統模型*:它是一種抽象,用來說明演算法可以作出哪些假設。
演算法的編寫方式不應過度依賴其執行環境中具體的硬體與軟體配置。這又要求我們以某種方式,把系統中預期會發生的故障型別形式化。為此,我們定義 *系統模型**system model*:它是一種抽象,用來說明演算法可以作出哪些假設。
關於時序假設,通常使用以下三種系統模型:
同步模型
同步模型synchronous model
: 同步模型假定網路延遲、程序暫停和時鐘誤差都有上界。這並不表示時鐘完全同步,也不表示網路延遲為零;它只是意味著,我們知道網路延遲、暫停和時鐘漂移永遠不會超過某個固定上限 [^108]。對大多數實際系統而言,同步模型並不現實,因為正如本章所討論的,無界延遲和暫停確實可能發生。
部分同步模型
部分同步模型partially synchronous model
: 部分同步是指系統在 *大多數時候* 都像同步系統一樣執行,但偶爾會突破網路延遲、程序暫停和時鐘漂移的界限 [^108]。對許多系統來說,這是一個現實的模型:絕大多數時候,網路與程序表現得相當規矩,否則我們什麼事情也做不成;但我們也必須正視這樣一個事實——任何時序假設都有可能偶爾失效。發生這種情況時,網路延遲、程序暫停和時鐘誤差都可能變得任意之大。
非同步模型
非同步模型asynchronous model
: 在這種模型中,演算法不能作出任何時序假設——事實上,它甚至沒有時鐘可用(因而也不能使用超時)。有些演算法可以針對非同步模型來設計,但這種模型限制極大。
除了時序問題,還必須考慮節點失效。常見的節點系統模型包括:
崩潰停止故障
: 在 *崩潰停止*(或 *故障停止*)模型中,演算法可以假定節點只有一種失效方式:崩潰 [^109]。節點可能在任意時刻突然停止響應,此後便永遠消失,再也不會回來。
崩潰停止故障crash-stop fault
: 在 *崩潰停止**crash-stop**故障停止**fail-stop*)模型中,演算法可以假定節點只有一種失效方式:崩潰 [^109]。節點可能在任意時刻突然停止響應,此後便永遠消失,再也不會回來。
崩潰恢復故障
崩潰恢復故障crash-recovery fault
: 我們假定節點可能在任意時刻崩潰,也可能在一段未知時間後重新開始響應。在崩潰恢復模型中,節點擁有能在崩潰後保留資料的穩定儲存(即非易失性磁碟儲存),但記憶體中的狀態會丟失。
效能下降和功能不全
: 除了崩潰與重啟,節點還可能變慢:它們也許仍能響應健康檢查,卻慢得無法完成任何實際工作。例如,千兆網路介面可能因為驅動程式缺陷,吞吐量突然跌到 1 Kb/s [^110];面臨記憶體壓力的程序可能把大部分時間花在垃圾回收上 [^111];磨損的 SSD 可能表現得極不穩定;高溫、聯結器鬆動、機械振動、電源問題、韌體缺陷等也會影響硬體 [^112]。這類情形稱為 *跛行節點*、*灰色失效* 或 *慢失效* [^113],甚至可能比徹底失效的節點更難處理。還有一種相關問題:程序不再執行原本應做的某些工作,其他功能卻仍在繼續,例如後臺執行緒崩潰或死鎖時 [^114]。
: 除了崩潰與重啟,節點還可能變慢:它們也許仍能響應健康檢查,卻慢得無法完成任何實際工作。例如,千兆網路介面可能因為驅動程式缺陷,吞吐量突然跌到 1 Kb/s [^110];面臨記憶體壓力的程序可能把大部分時間花在垃圾回收上 [^111];磨損的 SSD 可能表現得極不穩定;高溫、聯結器鬆動、機械振動、電源問題、韌體缺陷等也會影響硬體 [^112]。這類情形稱為 *跛行節點**limping node*)、*灰色失效**gray failure*)或 *慢失效**fail-slow*[^113],甚至可能比徹底失效的節點更難處理。還有一種相關問題:程序不再執行原本應做的某些工作,其他功能卻仍在繼續,例如後臺執行緒崩潰或死鎖時 [^114]。
拜占庭(任意)故障
: 節點可能做出任何行為,包括像上一節所述那樣欺騙其他節點。
@@ -599,7 +599,7 @@ Web 應用程式的確必須預料到Web 瀏覽器等由終端使用者控制
#### 安全性與活性 {#sec_distributed_safety_liveness}
為了說清這個問題,有必要區分兩類不同的屬性:*安全性* *活性*。在剛才的例子中,*唯一性* 和 *單調序列* 屬於安全屬性,*可用性* 則屬於活性屬性。
為了說清這個問題,有必要區分兩類不同的屬性:*安全性**safety**活性**liveness*。在剛才的例子中,*唯一性* 和 *單調序列* 屬於安全屬性,*可用性* 則屬於活性屬性。
兩者究竟有何區別?一個明顯線索是,活性屬性的定義中往往含有“最終”二字。(沒錯,你已經猜到了:*最終一致性* 就是一項活性屬性 [^115]。)
@@ -630,11 +630,11 @@ Web 應用程式的確必須預料到Web 瀏覽器等由終端使用者控制
一種辦法是對演算法進行形式化驗證:用數學語言描述演算法,再運用證明技術,證明它在系統模型允許的所有情形下都滿足所需屬性。證明演算法正確,並不表示它在真實系統中的 *實現* 必然始終行為正確。不過,這是非常好的一步,因為理論分析能夠發現演算法中的隱患;這些問題在真實系統中可能潛伏很久,直到某些異常情況打破了你的假設(例如時序假設)才突然發作。
把理論分析與經驗性測試結合起來,驗證實現的行為是否符合預期,是一種穩妥做法。基於屬性的測試、模糊測試和確定性模擬測試(DST等技術都利用隨機化在各種不同情形下測試系統。Amazon Web Services 等公司已經成功地把這些技術組合運用於許多產品 [^120] [^121]。
把理論分析與經驗性測試結合起來,驗證實現的行為是否符合預期,是一種穩妥做法。基於屬性的測試*property-based testing*)、模糊測試(*fuzz testing*)和確定性模擬測試(*deterministic simulation testing*DST等技術都利用隨機化在各種不同情形下測試系統。Amazon Web Services 等公司已經成功地把這些技術組合運用於許多產品 [^120] [^121]。
#### 模型檢查與規範語言 {#model-checking-and-specification-languages}
*模型檢查器* 是幫助驗證演算法或系統行為是否符合預期的工具。演算法規範要用 TLA+、Gallina 或 FizzBee 等專門設計的語言來編寫。藉助這類語言,我們可以專注於演算法行為,不必糾纏於程式碼實現細節。模型檢查器隨後會系統地嘗試各種可能發生的情況,利用模型來驗證不變數是否在演算法的所有狀態中都成立。
*模型檢查器**model checker*是幫助驗證演算法或系統行為是否符合預期的工具。演算法規範要用 TLA+、Gallina 或 FizzBee 等專門設計的語言來編寫。藉助這類語言,我們可以專注於演算法行為,不必糾纏於程式碼實現細節。模型檢查器隨後會系統地嘗試各種可能發生的情況,利用模型來驗證不變數是否在演算法的所有狀態中都成立。
嚴格來說,模型檢查無法證明演算法的不變數在每一種可能狀態下都成立,因為大多數現實演算法的狀態空間是無限的。要真正驗證所有狀態,需要給出形式化證明;這雖然可行,但通常比執行模型檢查器困難得多。因此,使用模型檢查器時,通常要把演算法模型縮減成一個可以完全驗證的近似版本,或是給執行設定某種上限(例如限制最多可以傳送多少條訊息)。這樣一來,只會在更長執行過程中出現的缺陷就無法被發現。
@@ -644,9 +644,9 @@ Web 應用程式的確必須預料到Web 瀏覽器等由終端使用者控制
#### 故障注入 {#sec_fault_injection}
許多缺陷只有在機器或網路發生失效時才會觸發。故障注入是一種有效(有時也相當嚇人)的技術,用來驗證系統實現遇到問題時是否仍會按預期工作。思路很簡單:向正在執行的系統環境注入故障,再觀察系統如何反應。注入的故障可以是網路失效、機器崩潰、磁碟損壞、程序暫停——凡是你能想到的計算機出錯方式都可以嘗試。
許多缺陷只有在機器或網路發生失效時才會觸發。*故障注入**fault injection*是一種有效(有時也相當嚇人)的技術,用來驗證系統實現遇到問題時是否仍會按預期工作。思路很簡單:向正在執行的系統環境注入故障,再觀察系統如何反應。注入的故障可以是網路失效、機器崩潰、磁碟損壞、程序暫停——凡是你能想到的計算機出錯方式都可以嘗試。
故障注入測試通常在與系統實際生產環境十分相似的環境中執行有些團隊甚至直接在生產環境中注入故障。Netflix 透過 Chaos Monkey 工具推廣了這種做法 [^128]。在生產環境中注入故障通常稱為 *混沌工程*,我們在 [“可靠性與容錯”](/tw/ch2#sec_introduction_reliability) 中已經討論過。
故障注入測試通常在與系統實際生產環境十分相似的環境中執行有些團隊甚至直接在生產環境中注入故障。Netflix 透過 Chaos Monkey 工具推廣了這種做法 [^128]。在生產環境中注入故障通常稱為 *混沌工程**chaos engineering*,我們在 [“可靠性與容錯”](/tw/ch2#sec_introduction_reliability) 中已經討論過。
執行故障注入測試時首先要部署被測系統以及故障注入協調者和指令碼。協調者負責決定注入哪些故障、何時注入本地或遠端指令碼則負責讓單個節點或程序發生失效。注入指令碼會利用許多不同工具來觸發故障Linux 程序可以用 `kill` 命令暫停或終止,磁碟可以用 `umount` 解除安裝,網路連線可以透過防火牆規則中斷。檢查故障注入期間以及之後的系統行為,就能確認系統是否一如預期。

View File

@@ -25,11 +25,11 @@ YinGang [@yingang](https://github.com/yingang) 對本書進行了全文校訂,
## 貢獻列表
下面的頭像牆彙集了本專案在 GitHub 上留下貢獻記錄的朋友;點選頭像可檢視其主頁
頭像牆收錄提交過 Issue 或 PR 的朋友;無論是否採納、合併或關閉,反饋本身即是貢獻
{{< contributors data="contributors" >}}
[GitHub 貢獻者列表](https://github.com/Vonng/ddia/graphs/contributors)
[GitHub 提交貢獻者](https://github.com/Vonng/ddia/graphs/contributors)
0. 全文校訂 by [@yingang](https://github.com/Vonng/ddia/commits?author=yingang)
1. [序言初翻修正](https://github.com/Vonng/ddia/commit/afb5edab55c62ed23474149f229677e3b42dfc2c) by [@seagullbird](https://github.com/Vonng/ddia/commits?author=seagullbird)
@@ -41,13 +41,27 @@ YinGang [@yingang](https://github.com/yingang) 對本書進行了全文校訂,
7. 多處翻譯修正 by [@songzhibin97](https://github.com/Vonng/ddia/commits?author=songzhibin97) [@MamaShip](https://github.com/Vonng/ddia/commits?author=MamaShip) [@FangYuan33](https://github.com/Vonng/ddia/commits?author=FangYuan33)
感謝所有提出意見,作出貢獻的朋友們,您可以在這裡找到所有貢獻的 [Issue 列表](https://github.com/Vonng/ddia/issues) [PR 列表](https://github.com/Vonng/ddia/pulls)
完整記錄:[Issues](https://github.com/Vonng/ddia/issues) · [Pull Requests](https://github.com/Vonng/ddia/pulls)
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
| [414](https://github.com/Vonng/ddia/issues/414) | [@Valerie-space](https://github.com/Valerie-space) | 反饋字型選項 |
| [413](https://github.com/Vonng/ddia/pull/413) | [@luchao2424631502](https://github.com/luchao2424631502) | ch1修改錯別字 |
| [412](https://github.com/Vonng/ddia/issues/412) | [@chesha1](https://github.com/chesha1) | 反饋網站使用體驗 |
| [411](https://github.com/Vonng/ddia/pull/411) | [@Waynting](https://github.com/Waynting) | 修復 GDPR 引文括號渲染 |
| [410](https://github.com/Vonng/ddia/pull/410) | [@aha-jansen](https://github.com/aha-jansen) | 修復 EPUB 封面、目錄與導航 |
| [409](https://github.com/Vonng/ddia/pull/409) | [@Klu5ure](https://github.com/Klu5ure) | 修正 README 翻譯進度 |
| [408](https://github.com/Vonng/ddia/pull/408) | [@hezhengdong](https://github.com/hezhengdong) | ch8: 刪除多餘空行,修復有序列表顯示問題 |
| [407](https://github.com/Vonng/ddia/pull/407) | [@AldenWangExis](https://github.com/AldenWangExis) | 統一簡繁強調格式 |
| [406](https://github.com/Vonng/ddia/issues/406) | [@AldenWangExis](https://github.com/AldenWangExis) | 指出簡體中文譯文中的粗體與斜體格式不一致 |
| [405](https://github.com/Vonng/ddia/pull/405) | [@AldenWangExis](https://github.com/AldenWangExis) | 補全簡繁術語縮寫 |
| [404](https://github.com/Vonng/ddia/issues/404) | [@AldenWangExis](https://github.com/AldenWangExis) | 核對簡體與繁體中文譯文的同步狀態 |
| [403](https://github.com/Vonng/ddia/issues/403) | [@AldenWangExis](https://github.com/AldenWangExis) | 反饋簡繁譯文不同步 |
| [402](https://github.com/Vonng/ddia/pull/402) | [@AldenWangExis](https://github.com/AldenWangExis) | 最佳化 ch1 簡繁措辭 |
| [401](https://github.com/Vonng/ddia/pull/401) | [@AldenWangExis](https://github.com/AldenWangExis) | 修復 EPUB 目錄跳轉 |
| [400](https://github.com/Vonng/ddia/pull/400) | [@SXP-Simon](https://github.com/SXP-Simon) | 補譯 ch13 標題 |
| [399](https://github.com/Vonng/ddia/issues/399) | [@1stcoderXiaoLin](https://github.com/1stcoderXiaoLin) | 反饋網站不可用 |
| [398](https://github.com/Vonng/ddia/pull/398) | [@c2j](https://github.com/c2j) | 支援整本 PDF 匯出 |
| [397](https://github.com/Vonng/ddia/pull/397) | [@daihaowxg](https://github.com/daihaowxg) | ch2: 修正一處筆誤 |
| [395](https://github.com/Vonng/ddia/issues/395) | [@ethan-tan-stori](https://github.com/ethan-tan-stori) | ch6: 指出“兄弟值”譯法的可讀性問題 |
| [394](https://github.com/Vonng/ddia/issues/394) | [@wayne87140](https://github.com/wayne87140) | 建議在專有名詞後補充英文原文 |
@@ -151,7 +165,7 @@ YinGang [@yingang](https://github.com/yingang) 對本書進行了全文校訂,
| [166](https://github.com/Vonng/ddia/pull/166) | [@bp4m4h94](https://github.com/bp4m4h94) | ch1: 發現錯誤的文獻索引 |
| [164](https://github.com/Vonng/ddia/pull/164) | [@longjiquan](https://github.com/longjiquan) | preface: 更正錯誤的標點符號 |
| [163](https://github.com/Vonng/ddia/pull/163) | [@llmmddCoder](https://github.com/llmmddCoder) | ch1: 更正錯誤字 |
| [160](https://github.com/Vonng/ddia/pull/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建議將 network model 翻譯為網狀模型 |
| [160](https://github.com/Vonng/ddia/issues/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建議將 network model 翻譯為網狀模型 |
| [159](https://github.com/Vonng/ddia/pull/159) | [@1ess](https://github.com/1ess) | ch4: 更正錯誤字 |
| [157](https://github.com/Vonng/ddia/pull/157) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通順的翻譯 |
| [155](https://github.com/Vonng/ddia/pull/155) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通順的翻譯 |

View File

@@ -3541,4 +3541,4 @@ breadcrumbs: false
- 觀察員, [服務發現](/tw/ch10#service-discovery)
- 用於服務發現, [負載均衡器、服務發現和服務網格](/tw/ch5#sec_encoding_service_discovery), [服務發現](/tw/ch10#service-discovery)
- 用於硬性轉讓, [請求路由](/tw/ch7#sec_sharding_routing)
- 使用 Zab 演算法, [共識](/tw/ch10#sec_consistency_consensus)
- 使用 Zab 演算法, [共識](/tw/ch10#sec_consistency_consensus)

View File

@@ -25,11 +25,11 @@ Yin Gang [@yingang](https://github.com/yingang) 对本书进行了全文校订
## 贡献列表
下面的头像墙汇集了本项目在 GitHub 上留下贡献记录的朋友;点击头像可查看其主页
头像墙收录提交过 Issue 或 PR 的朋友;无论是否采纳、合并或关闭,反馈本身即是贡献
{{< contributors data="contributors" >}}
[GitHub 贡献者列表](https://github.com/Vonng/ddia/graphs/contributors)
[GitHub 提交贡献者](https://github.com/Vonng/ddia/graphs/contributors)
0. 全文校订 by [@yingang](https://github.com/Vonng/ddia/commits?author=yingang)
1. [序言初翻修正](https://github.com/Vonng/ddia/commit/afb5edab55c62ed23474149f229677e3b42dfc2c) by [@seagullbird](https://github.com/Vonng/ddia/commits?author=seagullbird)
@@ -41,7 +41,7 @@ Yin Gang [@yingang](https://github.com/yingang) 对本书进行了全文校订
7. 多处翻译修正 by [@songzhibin97](https://github.com/Vonng/ddia/commits?author=songzhibin97) [@MamaShip](https://github.com/Vonng/ddia/commits?author=MamaShip) [@FangYuan33](https://github.com/Vonng/ddia/commits?author=FangYuan33)
感谢所有提出意见,作出贡献的朋友们,您可以在这里找到所有贡献的 [Issue 列表](https://github.com/Vonng/ddia/issues) [PR 列表](https://github.com/Vonng/ddia/pulls)
完整记录:[Issues](https://github.com/Vonng/ddia/issues) · [Pull Requests](https://github.com/Vonng/ddia/pulls)
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
@@ -77,7 +77,7 @@ Yin Gang [@yingang](https://github.com/yingang) 对本书进行了全文校订
| [281](https://github.com/Vonng/ddia/pull/281) | [@lyuxi99](https://github.com/lyuxi99) | 更正多处内部链接错误 |
| [280](https://github.com/Vonng/ddia/pull/280) | [@lyuxi99](https://github.com/lyuxi99) | ch9: 更正内部链接错误 |
| [279](https://github.com/Vonng/ddia/issues/279) | [@codexvn](https://github.com/codexvn) | ch9: 指出公式在 GitHub Pages 显示的问题 |
| [278](https://github.com/Vonng/ddia/pull/278) | [@LJlkdskdjflsa](https://github.com/LJlkdskdjflsa) | 发现了繁体中文版本中的错误翻译 |
| [278](https://github.com/Vonng/ddia/pull/278) | [@truenorth-lj](https://github.com/truenorth-lj) | 发现了繁体中文版本中的错误翻译 |
| [275](https://github.com/Vonng/ddia/pull/275) | [@117503445](https://github.com/117503445) | 更正 LICENSE 链接 |
| [274](https://github.com/Vonng/ddia/pull/274) | [@uncle-lv](https://github.com/uncle-lv) | ch7: 修正错别字 |
| [273](https://github.com/Vonng/ddia/pull/273) | [@quwang123](https://github.com/quwang123) | ch7: 统一了 write skew 的翻译 |
@@ -123,7 +123,7 @@ Yin Gang [@yingang](https://github.com/yingang) 对本书进行了全文校订
| [166](https://github.com/Vonng/ddia/pull/166) | [@bp4m4h94](https://github.com/bp4m4h94) | ch1: 发现错误的文献索引 |
| [164](https://github.com/Vonng/ddia/pull/164) | [@longjiquan](https://github.com/longjiquan) | preface: 更正错误的标点符号 |
| [163](https://github.com/Vonng/ddia/pull/163) | [@llmmddCoder](https://github.com/llmmddCoder) | ch1: 更正错误字 |
| [160](https://github.com/Vonng/ddia/pull/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建议将 network model 翻译为网状模型 |
| [160](https://github.com/Vonng/ddia/issues/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建议将 network model 翻译为网状模型 |
| [159](https://github.com/Vonng/ddia/pull/159) | [@1ess](https://github.com/1ess) | ch4: 更正错误字 |
| [157](https://github.com/Vonng/ddia/pull/157) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [155](https://github.com/Vonng/ddia/pull/155) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |

View File

@@ -792,4 +792,4 @@ LWW 實現了最終收斂的目標,但以 **永續性** 為代價:如果同
1. Sean Cribbs: “[A Brief History of Time in Riak](https://speakerdeck.com/seancribbs/a-brief-history-of-time-in-riak),” at *RICON*, October 2014.
1. Russell Brown: “[Vector Clocks Revisited Part 2: Dotted Version Vectors](https://riak.com/posts/technical/vector-clocks-revisited-part-2-dotted-version-vectors/),” *basho.com*, November 10, 2015.
1. Carlos Baquero: “[Version Vectors Are Not Vector Clocks](https://haslab.wordpress.com/2011/07/08/version-vectors-are-not-vector-clocks/),” *haslab.wordpress.com*, July 8, 2011.
1. Reinhard Schwarz and Friedemann Mattern: “[Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail](http://dcg.ethz.ch/lectures/hs08/seminar/papers/mattern4.pdf),” *Distributed Computing*, volume 7, number 3, pages 149174, March 1994. [doi:10.1007/BF02277859](http://dx.doi.org/10.1007/BF02277859)
1. Reinhard Schwarz and Friedemann Mattern: “[Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail](http://dcg.ethz.ch/lectures/hs08/seminar/papers/mattern4.pdf),” *Distributed Computing*, volume 7, number 3, pages 149174, March 1994. [doi:10.1007/BF02277859](http://dx.doi.org/10.1007/BF02277859)

View File

@@ -896,4 +896,4 @@ WHERE room_id = 123 AND
1. Michael J. Cahill: “[Serializable Isolation for Snapshot Databases](https://ses.library.usyd.edu.au/bitstream/handle/2123/5353/michael-cahill-2009-thesis.pdf),” PhD Thesis, University of Sydney, July 2009.
1. D. Z. Badal: “[Correctness of Concurrency Control and Implications in Distributed Databases](http://ieeexplore.ieee.org/abstract/document/762563/),” at *3rd International IEEE Computer Software and Applications Conference* (COMPSAC), November 1979.
1. Rakesh Agrawal, Michael J. Carey, and Miron Livny: “[Concurrency Control Performance Modeling: Alternatives and Implications](http://www.eecs.berkeley.edu/~brewer/cs262/ConcControl.pdf),” *ACM Transactions on Database Systems* (TODS), volume 12, number 4, pages 609654, December 1987. [doi:10.1145/32204.32220](http://dx.doi.org/10.1145/32204.32220)
1. Dave Rosenthal: “[Databases at 14.4MHz](http://web.archive.org/web/20150427041746/http://blog.foundationdb.com/databases-at-14.4mhz),” *blog.foundationdb.com*, December 10, 2014.
1. Dave Rosenthal: “[Databases at 14.4MHz](http://web.archive.org/web/20150427041746/http://blog.foundationdb.com/databases-at-14.4mhz),” *blog.foundationdb.com*, December 10, 2014.

View File

@@ -25,11 +25,11 @@ Yin Gang [@yingang](https://github.com/yingang) 對本書進行了全文校訂
## 貢獻列表
下面的頭像牆彙集了本專案在 GitHub 上留下貢獻記錄的朋友;點選頭像可檢視其主頁
頭像牆收錄提交過 Issue 或 PR 的朋友;無論是否採納、合併或關閉,反饋本身即是貢獻
{{< contributors data="contributors" >}}
[GitHub 貢獻者列表](https://github.com/Vonng/ddia/graphs/contributors)
[GitHub 提交貢獻者](https://github.com/Vonng/ddia/graphs/contributors)
0. 全文校訂 by [@yingang](https://github.com/Vonng/ddia/commits?author=yingang)
1. [序言初翻修正](https://github.com/Vonng/ddia/commit/afb5edab55c62ed23474149f229677e3b42dfc2c) by [@seagullbird](https://github.com/Vonng/ddia/commits?author=seagullbird)
@@ -41,7 +41,7 @@ Yin Gang [@yingang](https://github.com/yingang) 對本書進行了全文校訂
7. 多處翻譯修正 by [@songzhibin97](https://github.com/Vonng/ddia/commits?author=songzhibin97) [@MamaShip](https://github.com/Vonng/ddia/commits?author=MamaShip) [@FangYuan33](https://github.com/Vonng/ddia/commits?author=FangYuan33)
感謝所有提出意見,作出貢獻的朋友們,您可以在這裡找到所有貢獻的 [Issue 列表](https://github.com/Vonng/ddia/issues) [PR 列表](https://github.com/Vonng/ddia/pulls)
完整記錄:[Issues](https://github.com/Vonng/ddia/issues) · [Pull Requests](https://github.com/Vonng/ddia/pulls)
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
@@ -77,7 +77,7 @@ Yin Gang [@yingang](https://github.com/yingang) 對本書進行了全文校訂
| [281](https://github.com/Vonng/ddia/pull/281) | [@lyuxi99](https://github.com/lyuxi99) | 更正多處內部連結錯誤 |
| [280](https://github.com/Vonng/ddia/pull/280) | [@lyuxi99](https://github.com/lyuxi99) | ch9: 更正內部連結錯誤 |
| [279](https://github.com/Vonng/ddia/issues/279) | [@codexvn](https://github.com/codexvn) | ch9: 指出公式在 GitHub Pages 顯示的問題 |
| [278](https://github.com/Vonng/ddia/pull/278) | [@LJlkdskdjflsa](https://github.com/LJlkdskdjflsa) | 發現了繁體中文版本中的錯誤翻譯 |
| [278](https://github.com/Vonng/ddia/pull/278) | [@truenorth-lj](https://github.com/truenorth-lj) | 發現了繁體中文版本中的錯誤翻譯 |
| [275](https://github.com/Vonng/ddia/pull/275) | [@117503445](https://github.com/117503445) | 更正 LICENSE 連結 |
| [274](https://github.com/Vonng/ddia/pull/274) | [@uncle-lv](https://github.com/uncle-lv) | ch7: 修正錯別字 |
| [273](https://github.com/Vonng/ddia/pull/273) | [@quwang123](https://github.com/quwang123) | ch7: 統一了 write skew 的翻譯 |
@@ -123,7 +123,7 @@ Yin Gang [@yingang](https://github.com/yingang) 對本書進行了全文校訂
| [166](https://github.com/Vonng/ddia/pull/166) | [@bp4m4h94](https://github.com/bp4m4h94) | ch1: 發現錯誤的文獻索引 |
| [164](https://github.com/Vonng/ddia/pull/164) | [@longjiquan](https://github.com/longjiquan) | preface: 更正錯誤的標點符號 |
| [163](https://github.com/Vonng/ddia/pull/163) | [@llmmddCoder](https://github.com/llmmddCoder) | ch1: 更正錯誤字 |
| [160](https://github.com/Vonng/ddia/pull/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建議將 network model 翻譯為網狀模型 |
| [160](https://github.com/Vonng/ddia/issues/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建議將 network model 翻譯為網狀模型 |
| [159](https://github.com/Vonng/ddia/pull/159) | [@1ess](https://github.com/1ess) | ch4: 更正錯誤字 |
| [157](https://github.com/Vonng/ddia/pull/157) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通順的翻譯 |
| [155](https://github.com/Vonng/ddia/pull/155) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通順的翻譯 |

View File

@@ -25,11 +25,11 @@ YinGang [@yingang](https://github.com/yingang) 对本书进行了全文校订,
## 贡献列表
下面的头像墙汇集了本项目在 GitHub 上留下贡献记录的朋友;点击头像可查看其主页
头像墙收录提交过 Issue 或 PR 的朋友;无论是否采纳、合并或关闭,反馈本身即是贡献
{{< contributors data="contributors" >}}
[GitHub 贡献者列表](https://github.com/Vonng/ddia/graphs/contributors)
[GitHub 提交贡献者](https://github.com/Vonng/ddia/graphs/contributors)
0. 全文校订 by [@yingang](https://github.com/Vonng/ddia/commits?author=yingang)
1. [序言初翻修正](https://github.com/Vonng/ddia/commit/afb5edab55c62ed23474149f229677e3b42dfc2c) by [@seagullbird](https://github.com/Vonng/ddia/commits?author=seagullbird)
@@ -41,13 +41,27 @@ YinGang [@yingang](https://github.com/yingang) 对本书进行了全文校订,
7. 多处翻译修正 by [@songzhibin97](https://github.com/Vonng/ddia/commits?author=songzhibin97) [@MamaShip](https://github.com/Vonng/ddia/commits?author=MamaShip) [@FangYuan33](https://github.com/Vonng/ddia/commits?author=FangYuan33)
感谢所有提出意见,作出贡献的朋友们,您可以在这里找到所有贡献的 [Issue 列表](https://github.com/Vonng/ddia/issues) [PR 列表](https://github.com/Vonng/ddia/pulls)
完整记录:[Issues](https://github.com/Vonng/ddia/issues) · [Pull Requests](https://github.com/Vonng/ddia/pulls)
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
| [414](https://github.com/Vonng/ddia/issues/414) | [@Valerie-space](https://github.com/Valerie-space) | 反馈字体选项 |
| [413](https://github.com/Vonng/ddia/pull/413) | [@luchao2424631502](https://github.com/luchao2424631502) | ch1修改错别字 |
| [412](https://github.com/Vonng/ddia/issues/412) | [@chesha1](https://github.com/chesha1) | 反馈网站使用体验 |
| [411](https://github.com/Vonng/ddia/pull/411) | [@Waynting](https://github.com/Waynting) | 修复 GDPR 引文括号渲染 |
| [410](https://github.com/Vonng/ddia/pull/410) | [@aha-jansen](https://github.com/aha-jansen) | 修复 EPUB 封面、目录与导航 |
| [409](https://github.com/Vonng/ddia/pull/409) | [@Klu5ure](https://github.com/Klu5ure) | 修正 README 翻译进度 |
| [408](https://github.com/Vonng/ddia/pull/408) | [@hezhengdong](https://github.com/hezhengdong) | ch8: 删除多余空行,修复有序列表显示问题 |
| [407](https://github.com/Vonng/ddia/pull/407) | [@AldenWangExis](https://github.com/AldenWangExis) | 统一简繁强调格式 |
| [406](https://github.com/Vonng/ddia/issues/406) | [@AldenWangExis](https://github.com/AldenWangExis) | 指出简体中文译文中的粗体与斜体格式不一致 |
| [405](https://github.com/Vonng/ddia/pull/405) | [@AldenWangExis](https://github.com/AldenWangExis) | 补全简繁术语缩写 |
| [404](https://github.com/Vonng/ddia/issues/404) | [@AldenWangExis](https://github.com/AldenWangExis) | 核对简体与繁体中文译文的同步状态 |
| [403](https://github.com/Vonng/ddia/issues/403) | [@AldenWangExis](https://github.com/AldenWangExis) | 反馈简繁译文不同步 |
| [402](https://github.com/Vonng/ddia/pull/402) | [@AldenWangExis](https://github.com/AldenWangExis) | 优化 ch1 简繁措辞 |
| [401](https://github.com/Vonng/ddia/pull/401) | [@AldenWangExis](https://github.com/AldenWangExis) | 修复 EPUB 目录跳转 |
| [400](https://github.com/Vonng/ddia/pull/400) | [@SXP-Simon](https://github.com/SXP-Simon) | 补译 ch13 标题 |
| [399](https://github.com/Vonng/ddia/issues/399) | [@1stcoderXiaoLin](https://github.com/1stcoderXiaoLin) | 反馈网站不可用 |
| [398](https://github.com/Vonng/ddia/pull/398) | [@c2j](https://github.com/c2j) | 支持整本 PDF 导出 |
| [397](https://github.com/Vonng/ddia/pull/397) | [@daihaowxg](https://github.com/daihaowxg) | ch2: 修正一处笔误 |
| [395](https://github.com/Vonng/ddia/issues/395) | [@ethan-tan-stori](https://github.com/ethan-tan-stori) | ch6: 指出“兄弟值”译法的可读性问题 |
| [394](https://github.com/Vonng/ddia/issues/394) | [@wayne87140](https://github.com/wayne87140) | 建议在专有名词后补充英文原文 |
@@ -151,7 +165,7 @@ YinGang [@yingang](https://github.com/yingang) 对本书进行了全文校订,
| [166](https://github.com/Vonng/ddia/pull/166) | [@bp4m4h94](https://github.com/bp4m4h94) | ch1: 发现错误的文献索引 |
| [164](https://github.com/Vonng/ddia/pull/164) | [@longjiquan](https://github.com/longjiquan) | preface: 更正错误的标点符号 |
| [163](https://github.com/Vonng/ddia/pull/163) | [@llmmddCoder](https://github.com/llmmddCoder) | ch1: 更正错误字 |
| [160](https://github.com/Vonng/ddia/pull/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建议将 network model 翻译为网状模型 |
| [160](https://github.com/Vonng/ddia/issues/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建议将 network model 翻译为网状模型 |
| [159](https://github.com/Vonng/ddia/pull/159) | [@1ess](https://github.com/1ess) | ch4: 更正错误字 |
| [157](https://github.com/Vonng/ddia/pull/157) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [155](https://github.com/Vonng/ddia/pull/155) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |

View File

@@ -8,14 +8,23 @@ items:
role: 繁体中文版本维护
- github: "117503445"
- github: "1ess"
- github: "1stcoderXiaoLin"
- github: "2997ms"
- github: "2w1nd"
- github: "674019130"
- github: "abbychau"
- github: "afredlyj"
- github: "aha-jansen"
- github: "akxxsb"
- github: "AldenWangExis"
- github: "AlexZFX"
- github: "AlphaWang"
- github: "amber-moe"
- github: "anaer"
- github: "artiship"
- github: "atlas927"
- github: "auula"
- github: "avenarius-argus"
- github: "Axlgrep"
- github: "b7woreo"
- github: "baijinping"
@@ -24,15 +33,23 @@ items:
- github: "BeBraveBeCurious"
- github: "bestgrc"
- github: "blindpirate"
- github: "bluebear4"
- github: "Bowser1704"
- github: "bp4m4h94"
- github: "brucevoin"
- github: "brynne8"
- github: "ButcherV"
- github: "c25423"
- github: "c2j"
- github: "cch123"
- github: "cclauss"
- github: "ccxhwmy"
- github: "cg-zhou"
- github: "chesha1"
- github: "chlch"
- github: "chroming"
- github: "codexvn"
- github: "cuprumz"
- github: "cwr31"
- github: "daihaowxg"
- github: "DavidZhiXing"
@@ -46,93 +63,144 @@ items:
- github: "ethan-tan-stori"
- github: "EvanMu96"
- github: "exzhawk"
- github: "FangYuan33"
- github: "fantasyczl"
- github: "feeeei"
- github: "Flyraty"
- github: "fuxuemingzhu"
- github: "ganler"
- github: "Gezi-lzq"
- github: "Gilbert1024"
- github: "goace"
- github: "guaidaokakaxi"
- github: "haifeiWu"
- github: "hanyu2"
- github: "hecenjie"
- github: "hezhengdong"
- github: "hli988"
- github: "huang06"
- github: "huiscool"
- github: "ibyte2011"
- github: "imcheney"
- github: "jacklightChen"
- github: "jasonlei-chn"
- github: "JCYoky"
- github: "jenac"
- github: "jiajiadebug"
- github: "jiong-han"
- github: "justlorain"
- github: "Juude"
- github: "KAKXA"
- github: "kangni"
- github: "kehao-chen"
- github: "kemingy"
- github: "KevinZhangt"
- github: "kimi0230"
- github: "Klu5ure"
- github: "krisjin"
- github: "l1t1"
- github: "leo-987"
- github: "lewiszlw"
- github: "LHRchina"
- github: "liangGTY"
- github: "LiminCode"
- github: "lis186"
- github: "lllliuliu"
- github: "llmmddCoder"
- github: "longjiquan"
- github: "lpxxn"
- github: "lqbilbo"
- github: "lroolle"
- github: "luchao2424631502"
- github: "Lyianu"
- github: "lynkeib"
- github: "lyuxi99"
- github: "lzwill"
- github: "Makonike"
- github: "MamaShip"
- github: "marvin263"
- github: "mawenqi"
- github: "Max-Tortoise"
- github: "meijies"
- github: "meilin96"
- github: "MintBlue"
- github: "mrdrivingduck"
- github: "msxfXF"
- github: "MuAlex"
- github: "mxdljwxx"
- github: "NageNalock"
- github: "narojay"
- github: "nevertiree"
- github: "NIL-zhuang"
- github: "northmorn"
- github: "omegaatt36"
- github: "OneSizeFitsQuorum"
- github: "Ozarklake"
- github: "PanggNOTlovebean"
- github: "Panmax"
- github: "patricksuo"
- github: "Pcrab"
- github: "PragmaTwice"
- github: "q00218426"
- github: "qig243"
- github: "quwang123"
- github: "rabomen"
- github: "rentiansheng"
- github: "rovernerd"
- github: "saintube"
- github: "samwu4166"
- github: "scaugrated"
- github: "seagullbird"
- github: "secret4233"
- github: "shiyiwan"
- github: "shukebeta"
- github: "skyran1278"
- github: "smallyard"
- github: "songzhibin97"
- github: "soulrrrrr"
- github: "spike014"
- github: "SpikeWong"
- github: "starsea"
- github: "Stephan14"
- github: "Style980102"
- github: "sunbuhui"
- github: "Sunt-ing"
- github: "SunVdong"
- github: "sunyiwei24601"
- github: "sunzeren"
- github: "SXP-Simon"
- github: "taco-wang"
- github: "tankilo"
- github: "tigerinus"
- github: "tisonkun"
- github: "tooloudwind"
- github: "TrafalgarRicardoLu"
- github: "TronYY"
- github: "truenorth-lj"
- github: "ui-HookeyChiang"
- github: "uncle-lv"
- github: "undeflife"
- github: "Valerie-space"
- github: "Vermouth1995"
- github: "vult137"
- github: "WAangzE"
- github: "wafer-li"
- github: "walshzhang"
- github: "wangqiim"
- github: "wayne87140"
- github: "Waynting"
- github: "woodpenker"
- github: "wwek"
- github: "wynn5a"
- github: "xianlaioy"
- github: "xiekeyi98"
- github: "XIJINIAN"
- github: "xiyihan0"
- github: "xyohn"
- github: "yangshangde"
- github: "ych"
- github: "yhao3"
- github: "yhm138"
- github: "yjhmelody"
- github: "YKIsTheBest"
- github: "Ynjxsjmh"

View File

@@ -143,8 +143,8 @@ contributors:
type: contributors
partial: landing/sections/ddia-contributors.html
eyebrow: 贡献者
title: 不是一个人的译本,来自 142 位贡献者
desc: 142 位贡献者,从初翻、校订到繁体维护与排版修正,每一次提交都让这部中文译本更准确、更完整
title: '[不是一个人的译本,来自 210 位贡献者](contrib/)'
desc: 无论 Issue 或 PR 是否采纳、合并或关闭,每一份反馈都值得感谢
data: contributors
featured:
- github: yingang