Raft / 事務與可串行化 / 兩階段提交 / Spanner ...
MapReduce (1): 如何用 MapReduce 找最大值?
這道題非常經(jīng)典,是理解 MapReduce 思想的敲門磚。
問題背景 :想象一下,我們有 100 個文件,每個文件里都寫滿了數(shù)字,一行一個。我們的任務是找出這所有數(shù)字里的最大值。
解讀
MapReduce 的核心思想是“分而治之”。
map階段 :map函數(shù)就像是“地方海選賽”。每個map任務會分配到一個或多個文件。它的工作很簡單:就在自己負責的這堆數(shù)里,找出那個最大的“地方冠軍”。然后,它會輸出一個鍵值對,比如( "max", 12345 ),其中12345就是它找到的那個局部最大值。這里用一個固定的key(比如空字符串""或者"max") 是個小技巧,目的是確保所有這些“地方冠軍”都能被送到同一個reduce任務那里去進行“總決賽”。reduce階段 :reduce函數(shù)就是“全國總決賽”。因為前面所有的map任務都用了同一個key,所以 MapReduce 框架會把所有map的輸出(也就是所有“地方冠軍”的數(shù)值)都集合起來,然后交給一個reduce任務。這個reduce任務的工作就更簡單了:在這些“地方冠軍”里,選出那個唯一的“全國總冠軍”,也就是全局最大值。
reduce 函數(shù)會被調(diào)用幾次?
只會有 1 次。因為我們巧妙地設(shè)計了讓所有 map 的輸出都使用同一個 key,所以這些數(shù)據(jù)只會被匯集到一個 reduce 任務中處理。
MapReduce (2): 為什么寫入中間文件需要“先寫臨時文件再重命名”?
問題背景 :有個同學叫 Alyssa,她在實現(xiàn) MapReduce 的 worker 時偷懶了。她直接用 os.Create() 來創(chuàng)建中間結(jié)果文件,而不是遵循“先寫到一個臨時文件,寫完后再用 os.Rename() 重命名”這個最佳實踐。這會出什么問題?
解讀
這個問題觸及了分布式系統(tǒng)中一個非常重要的概念:處理“慢節(jié)點”和任務的原子性。
在 MapReduce 中,master 有一個叫做 投機執(zhí)行 (speculative execution) 的機制。如果它發(fā)現(xiàn)某個 map 任務運行得特別慢,它可能會在另一臺機器上重新啟動一個一模一樣的任務,誰先跑完就用誰的結(jié)果。
現(xiàn)在,我們來想象一個災難場景:
master派任務M給worker W1。W1由于網(wǎng)絡、CPU 等原因,運行得非常慢。master等得不耐煩了,啟動了投機執(zhí)行,把同樣的任務M又派給了worker W2。W2身強力壯,很快就完成了計算,并用os.Create("mr-M-R")創(chuàng)建并寫好了中間文件。master收到W2的捷報,于是啟動了對應的reduce任務,這個reduce任務開始讀取W2生成的那個mr-M-R文件。- 就在此時,慢吞吞的
W1終于也完成了它的計算。它也執(zhí)行了os.Create("mr-M-R")。 - 關(guān)鍵點來了:
os.Create()在文件已存在時,會直接清空它!于是,W2辛辛苦苦生成的、reduce任務正在讀取的文件,瞬間被W1清空了。 reduce任務讀著讀著發(fā)現(xiàn)文件變空了,最終得出了錯誤的結(jié)果。
正確的做法是什么呢?
原子性的“寫入臨時文件后重命名”。W1 和 W2 都先寫入各自的臨時文件(比如 mr-M-R-temp-W1),寫完后,再用 os.Rename() 這個原子操作去搶占最終的文件名。這樣就能保證,無論誰快誰慢,reduce 任務讀到的文件一定是某個 worker 完整寫入的結(jié)果,而不是一個被中途清空的文件。
GFS: 同時讀同一個 GFS 文件,內(nèi)容一定相同嗎?
問題背景 :兩個客戶端,在沒有任何寫入操作的情況下,同時從頭到尾讀取 GFS 上的同一個文件。它們讀到的內(nèi)容保證會一樣嗎?
解讀
不保證。 GFS 在設(shè)計上為了性能和可用性,在某些一致性上做了妥協(xié)。
問題的根源在于 GFS 的一種特殊寫操作:記錄追加 (record append)。當一個客戶端執(zhí)行追加操作時,primary 副本會確定一個偏移量,然后通知所有 secondary 副本也寫入。但如果某個 secondary 副本當時正好網(wǎng)絡不通或者掛了,它可能就收不到這個寫入指令。GFS 的 primary 不會死等所有副本都成功,它只會把錯誤報告給客戶端。
這就導致了一個后果:同一個數(shù)據(jù)塊的不同副本(chunk replica),可能內(nèi)容不一樣了。一個副本有這次追加的數(shù)據(jù),另一個沒有。
所以,當那兩個客戶端來讀取文件時,如果它們不幸地連接到了持有不同數(shù)據(jù)副本的 chunkserver 上,它們讀到的內(nèi)容自然也就不一樣了。
Raft (1): currentTerm 必須持久化嗎?
問題背景 :Ben 同學覺得每次都持久化 currentTerm 太麻煩,他想了個“聰明”的辦法:當一個節(jié)點重啟時,不從持久化存儲里讀 currentTerm,而是直接讀取它日志里最后一條記錄的任期號,并把它作為自己的 currentTerm。這會出什么問題?
解讀
Ben 的這個改動會破壞 Raft 協(xié)議的根基——投票的正確性,從而可能導致“腦裂”(即同一任期出現(xiàn)兩個 leader)。
currentTerm 和 votedFor 這兩個狀態(tài),是 Raft 節(jié)點在選舉中的“記憶”。它們必須被持久化,以確保節(jié)點在崩潰重啟后不會“失憶”并做出矛盾的決定。
我們來看一個具體的失敗場景:
- 一個集群,節(jié)點
P1的日志里最后一條記錄的任期是 10。所以它當前的currentTerm也是 10。 - 候選人
P2發(fā)起了 term 11 的選舉。P1收到投票請求,它一看任期比自己的高,于是投票給了P2。同時,P1把自己的(內(nèi)存中的)currentTerm更新為 11,并持久化votedFor = P2。 - 在
P1還來不及持久化currentTerm = 11的時候,它突然崩潰了。 P1重啟。按照 Ben 的邏輯,它會讀取日志,發(fā)現(xiàn)最后一條記錄的任期是 10,于是它把自己的currentTerm初始化為 10。- 這時,另一個候選人
P3也發(fā)起了 term 11 的選舉。P3的投票請求到達了P1。P1檢查后發(fā)現(xiàn),請求的任期 11 比自己的當前任期 10 要高,并且(假設(shè)它沒持久化votedFor或者votedFor邏輯也有問題)它認為自己還沒在 term 11 里投過票。于是,它又投票給了P3!
災難發(fā)生了 :P1 在同一個任期 11 里,先后為 P2 和 P3 兩個不同的候選人投了票。這嚴重違反了 Raft 的選舉安全規(guī)則,完全可能導致 P2 和 P3 都分別獲得足夠選票成為 leader,系統(tǒng)出現(xiàn)“雙主”,狀態(tài)機將執(zhí)行不同的指令,數(shù)據(jù)一致性被破壞。
Raft (2): AppendEntries 時直接覆蓋日志行不行?
問題背景 :Bob 同學為了簡化代碼,修改了 AppendEntries RPC 的處理邏輯。他不再檢查日志沖突,而是簡單粗暴地直接用 leader 發(fā)來的日志覆蓋本地日志。這為什么是錯的?
解讀
這個改動破壞了 Raft 的日志匹配屬性 (Log Matching Property),這是確保安全性的核心。直接覆蓋會導致一個已提交的日志條目被錯誤地更改。
看這個例子:
- 有三個節(jié)點
S1,S2,S3。S1是 term 1 的leader。 S1在 index 1 追加了日志A,在 index 2 追加了日志B。它把[A, B]發(fā)給了S2和S3。S2成功收到了[A, B]。S1和S2構(gòu)成了多數(shù)派,所以A和B在S1上被提交了。S3可能因為網(wǎng)絡延遲只收到了A。- 現(xiàn)在,一個 之前 從
S1發(fā)出的、但被網(wǎng)絡延遲了的AppendEntriesRPC(這個 RPC 只包含A)終于到達了S2。 - 按照 Bob 的錯誤邏輯,
S2不做沖突檢查,直接用這個 RPC 的內(nèi)容來更新自己的日志。它會把自己的日志從[A, B]截斷回[A]。 S1掛了。S3發(fā)起 term 2 的選舉。S3的日志是[A],S2的日志現(xiàn)在也是[A],所以S2會投票給S3。S3成為 term 2 的新leader。它在 index 2 寫入了一個 不同 的日志C。S3把C復制給了S2,并且它們倆構(gòu)成了多數(shù)派,提交了C。
最終結(jié)果 :在 index 2 這個位置,S1 提交的日志是 B,而 S2 和 S3 提交的卻是 C。不同的節(jié)點在同一個日志索引上提交了不同的命令,狀態(tài)機不再一致,Raft 的安全性被徹底打破。
MIT 6.824 2020 年期末考試解析
事務與可串行化 (Transactions and Serializability)
問題背景 :有三個并發(fā)事務 T1, T2, T3。初始時,數(shù)據(jù)庫里的變量 x, y, z 都是 0。
T1: T2: T3:
begin() begin() begin()
put(y, 2) put(x, 99) tmpx = get(x)
end() put(y, 99) tmpy = get(y)
put(z, 99) tmpz = get(z)
end() print tmpx, tmpy, tmpz
end()問題 1:如果 T3 打印出 99, 2, 99,這個結(jié)果是可串行化的嗎?
解讀
是的,這是可串行化的。
可串行化 (Serializable) 的意思是,盡管事務是并發(fā)執(zhí)行的,但其最終結(jié)果必須等同于這些事務按照 某一個 串行順序執(zhí)行的結(jié)果。我們的任務就是去找到這個串行順序。
我們來試試 T2 -> T1 -> T3 這個順序:
- 先執(zhí)行
T2:x變成 99,y變成 99,z變成 99。 - 接著執(zhí)行
T1:y被更新為 2?,F(xiàn)在狀態(tài)是x=99, y=2, z=99。 - 最后執(zhí)行
T3:讀取x得到 99,讀取y得到 2,讀取z得到 99。打印結(jié)果99, 2, 99。
完全匹配!既然我們找到了一個能產(chǎn)生同樣結(jié)果的串行順序,那么這個結(jié)果就是可串行化的。
問題 2:如果 T3 打印出 0, 2, 99,這個結(jié)果是可串行化的嗎?
解讀
不,這不是可串行化的。
這次我們無法找到任何一個合法的串行執(zhí)行順序。我們可以用依賴關(guān)系來分析:
T3讀到了x = 0。而T2會把x改成 99。為了能讀到 0,T3的get(x)必須發(fā)生在T2的put(x, 99)之前。所以,在任何等價的串行順序中,必然有T3在T2之前。T3讀到了z = 99。z的初始值是 0,只有T2會把它改成 99。為了能讀到 99,T3的get(z)必須發(fā)生在T2的put(z, 99)之后。所以,在任何等價的串行順序中,必然有T2在T3之前。
這里就出現(xiàn)了致命的矛盾:T3 必須在 T2 之前,同時 T2 又必須在 T3 之前。這是不可能的。這種依賴環(huán)路意味著不存在任何一個串行順序能產(chǎn)生這個結(jié)果,因此它不是可串行化的。
兩階段提交 (Two-Phase Commit)
問題背景 :在兩階段提交 (Two-Phase Commit, 2PC) 協(xié)議中,worker 在投票 PREPARE 成功后,需要一直持有鎖,直到收到最終的 COMMIT 或 ABORT 消息。如果我們改動一下,讓 worker 在回復 PREPARE 后就立即釋放鎖,會發(fā)生什么?
解讀
這么做會徹底破壞事務的原子性和隔離性。PREPARE 階段結(jié)束后,worker 處于一個“不確定”的狀態(tài),它并不知道事務最終是會成功還是失敗。在這個節(jié)骨眼上釋放鎖,會引發(fā)兩種嚴重的問題:
- 讀到“臟數(shù)據(jù)” (Dirty Reads) :如果
worker釋放了鎖,并且讓其他事務能夠看到它本地“預提交”的修改(比如T1修改了x的值)。此時,另一個事務T_other進來讀到了這個新值。但萬一T1的協(xié)調(diào)者最終決定ABORT整個事務,那么T_other就相當于讀到了一個從未真實存在過的數(shù)據(jù),后續(xù)的所有計算都是基于這個“幻影”數(shù)據(jù),后果不堪設(shè)想。 - 破壞可串行化 :另一種情況是,
worker釋放了鎖,但很“聰明”地把修改先隱藏起來,不讓別的事務看見。但即使這樣,另一個事務T_other還是可以進來獲取T1剛剛釋放的鎖。T_other可能會讀取一些T1沒有修改過的數(shù)據(jù)(這是舊值),然后T1的COMMIT消息到達,T1的修改被應用。之后,T_other又讀取了T1修改過的數(shù)據(jù)(這是新值)。這樣一來,T_other在一個事務里,既看到了過去,又看到了未來,看到了一個數(shù)據(jù)不一致的“混合快照”,這同樣破壞了可串行化。
結(jié)論 :鎖必須持有到事務的最終狀態(tài)(COMMIT 或 ABORT)被確定為止,這是 2PC 保證隔離性的關(guān)鍵。
Spanner: 為什么所有寫操作要用同一個時間戳?
問題背景 :在 Spanner 中,一個讀寫事務里的所有寫操作,都會被賦予一個相同的提交時間戳。如果我們把它改成:每次客戶端調(diào)用寫操作時,就用當時的 TT.now().latest 作為這個寫操作的時間戳。這樣,一個事務內(nèi)的不同寫操作就會有不同的時間戳。這會破壞什么?
解讀
這會破壞只讀事務的可串行化保證。
Spanner 的一個核心特性是它能提供嚴格可串行化的只讀事務。它通過給只讀事務選擇一個時間戳 s_read,然后讀取在 s_read 時刻的數(shù)據(jù)庫快照來實現(xiàn)的。
在原版 Spanner 中,一個讀寫事務 T_rw 的所有寫操作共享一個提交時間戳 s_write。這樣一來,對于任何只讀事務,要么它的 s_read < s_write(完全看不到 T_rw 的修改),要么 s_read > s_write(能看到 T_rw 所有的修改)。這保證了原子性,T_rw 的修改對于只讀事務來說是“要么全有,要么全無”的。
但如果按照問題中的修改,T_rw 的多個寫操作 W1, W2, W3 會有各自不同的時間戳 ts1, ts2, ts3。這時,一個只讀事務的時間戳 s_read 就可能恰好落在這些寫操作之間,比如 ts1 < s_read < ts2。這意味著這個只讀事務會看到 W1 的修改,但看不到 W2 和 W3 的修改。它看到了一個“半成品”狀態(tài)的 T_rw,事務的原子性被打破,自然也就不再是可串行化的了。
Spark: for 循環(huán)為什么執(zhí)行那么快?
問題背景 :Ben 在用 Spark 跑 PageRank,他發(fā)現(xiàn)代碼里的那個 for 循環(huán),每次迭代都只花幾毫秒,整個循環(huán)跑完不到一秒。但整個 Spark 作業(yè)卻要跑好幾個小時。這是為什么?
解讀
這是因為 Spark 的惰性求值 (lazy evaluation) 機制。
在 Spark 中,操作被分為兩類:
- 轉(zhuǎn)換 (Transformation) :比如
map,filter,join等。這些操作并不會立即執(zhí)行計算。它們只是在構(gòu)建一個叫做 有向無環(huán)圖 (DAG) 的計算藍圖。你可以想象成你在畫一張建筑圖紙,而不是真的在蓋房子。 - 動作 (Action) :比如
count,collect,saveAsTextFile等。只有當一個action被調(diào)用時,Spark 才會根據(jù)之前構(gòu)建好的 DAG 圖,真正地開始分發(fā)任務、讀取數(shù)據(jù)、執(zhí)行計算。
PageRank 的那個 for 循環(huán)里,全都是 transformation 操作,比如 join 和 map。每次循環(huán),Spark 只是在圖紙上又加了幾筆,擴展了一下 DAG。這個過程當然非??欤驗闆]有涉及任何大規(guī)模的數(shù)據(jù)計算。真正耗時的計算,是在循環(huán)結(jié)束后,當某個 action(比如 collect() 或者把結(jié)果寫入文件)被調(diào)用時,才一次性觸發(fā)的。那幾個小時,就是花在了執(zhí)行這張龐大的計算圖紙上。






























