天天看點

删庫時,我後悔沒早學會的資料庫知識

作者 | Jaana Dogan

譯者 | 無名

策劃 | 小智

曾經有一份真摯的資料庫知識擺在我的面前,我沒有珍惜,等到删庫時才後悔莫及。人世間最悲痛的事莫過于此。如果再給我一次重來的機會,我一定會好好讀這篇文章,并把它收藏、分享給有需要的人。

大多數計算機系統都是有狀态的,并且可能會依賴存儲系統。随着時間的推移,我對資料庫的了解程度不斷加深,這是以我們的設計錯誤導緻資料丢失和中斷為代價。在資料量很大的系統中,資料庫是系統設計目标的核心。盡管開發人員不可能對資料庫一無所知,但他們所預見和所經曆的問題往往隻是冰山一角。在本文中,我将分享一些見解,這些見解對于不擅長資料庫領域的開發人員來說非常有用。

如果在 99.999% 的時間裡網絡不出問題,那你很幸運

現如今,一方面人們認為網絡很可靠,一方面由于網絡中斷而導緻系統當機的情況卻又很普遍。這方面的研究工作并不多,而且通常由大公司主導,而這些公司使用了配備定制硬體的專用網絡和專門的從業人員。

谷歌服務的可用性為 99.999%,他們聲稱隻有 7.6% 的 Spanner(谷歌的分布式資料庫) 問題是因為網絡導緻的,盡管他們一直認為專用網絡是其可用性背後的核心支撐。2014 年,Bailis 和 Kingsbury 的一份調查報告對 Peter Deutsch 在 1994 年提出的分布式計算謬論之一提出了挑戰——網絡真的可靠嗎?

我們無法進行全面的調查,供應商們也不會提供足夠的資料來說明有多少客戶的問題是因為網絡導緻的。我們經常會遭遇大型雲供應商網絡發生當機,導緻部分網絡癱瘓數小時,這些事件有大量可見的受影響客戶,還有很多是我們看不到的。網絡中斷可能會影響到更多方面,盡管并非所有事件都産生了很大的影響。雲計算客戶也不一定能看到這些問題所在。當問題出現時,他們不太可能認為與供應商的網絡錯誤有關。對他們來說,第三方服務就是黑盒。如果你不是供應商,要估計出真實的影響程度是不太可能的。

與供應商的報告相比,如果你的系統隻有一小部分當機與網絡問題有關,那你是幸運的。網絡仍然受傳統問題的影響,比如硬體故障、拓撲變更、管理配置變更和電源故障。但最近我才知道,一些新發現的問題(比如鲨魚咬斷海底光纜)也成了主要影響因素。

ACID 沒有表面看上去的那麼簡單

ACID 代表原子性、一緻性、隔離性和持久性。即使在發生崩潰、錯誤、硬體故障等類似事件時,資料庫也需要保證這些屬性是有效的。大多數關系型事務資料庫都盡量提供 ACID 保證,但很多 NoSQL 資料庫是沒有 ACID 事務保證的,因為實作成本很高。

在我剛進入這個行業時,我們的技術主管懷疑 ACID 是不是一個過時的概念。可以說,ACID 被認為是一個種泛泛而談的概念,而不是一個嚴格的執行标準。現在,我發現它非常有用,因為它提供了一類問題和一類潛在的解決方案。

并不是每個資料庫都相容 ACID,而且在相容 ACID 的資料庫當中,對 ACID 的解釋也可能存在差異。之是以存在差異,其中一個原因是在實作 ACID 時涉及的權衡程度的不同。資料庫可能宣稱自己相容 ACID,但對于一些邊緣情況,或者在面對“不太可能”出現的問題時,處理方式有所不同。

MongoDB 的 ACID 表現一直飽受争議,即使是在釋出了 v4 版本之後。MongoDB 在很長一段時間内都不支援日志記錄。對于下面這種情況,應用程式進行了兩次寫操作 (w1 和 w2),MongoDB 能夠持久化 w1,但因為發生硬體故障,導緻無法持久化 w2。

删庫時,我後悔沒早學會的資料庫知識

MongoDB 在将資料寫入實體磁盤之前發生崩潰,造成資料丢失

将資料送出到磁盤是一個開銷很大的過程,它們聲稱寫入性能良好,卻是以避免頻繁送出資料為代價,進而犧牲了持久性。現在,MongoDB 有了日志記錄,但髒寫仍然會影響資料的持久性,因為預設情況下每 100 毫秒才送出一次日志。即使風險大大降低,日記記錄的持久性和變更仍然有可能出現同樣的問題。

不同的資料庫有不同的一緻性和隔離能力

在 ACID 這幾個屬性中,一緻性和隔離級别的實作方式是最多的,因為權衡範圍最大。為了保持資料一緻性,資料庫需要進行協調,争用資源的情況會增加。當需要在多個資料中心之間進行水準伸縮時 (特别是在不同的地理區域之間),就變得非常困難。随着可用性的降低和網絡分區的頻繁出現,提供高水準的一緻性是非常困難的。關于這一問題的深入解釋,請參見 CAP 定理。不過需要注意的是,應用程式可以在資料一緻性方面做一些處理,或者程式員可能對這個問題有足夠的了解,可以在應用程式中添加額外的邏輯來處理,而不是嚴重依賴資料庫。

資料庫通常會提供各種隔離級别,應用程式開發人員可以根據權衡選擇最經濟有效的隔離級别。較弱的隔離級别可能速度更快,但可能會引入資料競态問題。更強的隔離級别消除了一些潛在的資料競态問題,但速度較慢,并且可能會引入資源争用,使資料庫慢到當機。

删庫時,我後悔沒早學會的資料庫知識

現有并發模型及其之間關系的概覽

SQL 标準隻定義了 4 個隔離級别,盡管在理論方面和實際當中都還有更多可用的級别。如果你想進一步了解,jepson.io 提供了更多對現有并發模型的介紹。谷歌的 Spanner 保證了時鐘同步的外部串行性,即使這是一個更嚴格的隔離級别,但它在标準隔離級别中并沒有定義。

SQL 标準中提到的隔離級别是:

  • 串行化 (最嚴格、成本最高):串行化執行的效果與事務的串行執行是一樣的。串行執行是指每個事務在下一個事務開始之前執行完成。需要注意的是,由于在解釋上的差異,串行化通常被實作成“快照隔離”(例如 Oracle),但在 SQL 标準中并沒有“快照隔離”。
  • 可重複讀:目前事務中未送出的讀取對目前事務可見,但其他事務所做的更改 (如新插入的行) 不可見。
  • 讀已送出:未送出的讀取對事務不可見。隻有送出的寫是可見的,但可能會發生幻讀取。如果另一個事務插入和送出新行,目前事務在查詢時可以看到它們。
  • 讀未送出 (最不嚴格、成本最低):允許髒讀,事務可以看到其他事務未送出的更改。實際上,這個級别對于傳回近似聚合很有用,比如 COUNT(*) 查詢。

串行化級别将發生資料競争的機會降到最低,盡管它的開銷最大,并給系統帶來了最多的争用。其他隔離級别開銷較小,但增加了資料競争的可能性。有些資料庫允許設定隔離級别,有些資料庫不一定支援所有的隔離級别。

删庫時,我後悔沒早學會的資料庫知識

各種資料庫對隔離級别的支援情況

使用樂觀鎖

使用資料庫鎖的成本是非常高的,它們不僅引入了更多的争用,而且要求應用程式伺服器和資料庫之間保持穩定的連接配接。排它鎖受網絡分區的影響更大,并會導緻難以識别和解決的死鎖。在這種情況下,可以考慮使用樂觀鎖。

樂觀鎖是指在讀取一行資料時,記下它的版本号、最近修改的時間戳或校驗和。然後,你可以在修改記錄之前檢查版本有沒有發生變化。

UPDATE products
SET name = 'Telegraph receiver', version = 2 
WHERE id = 1 AND version = 1           

複制

如果之前有一個更新操作修改了 products 表,那麼目前的更新操作将不修改任何資料。如果之前沒有被修改,目前的更新操作将修改一行資料。

除了髒讀和資料丢失之外,還有其他異常

在讨論資料一緻性時,我們主要關注可能會導緻髒讀和資料丢失的競态條件。但除了這些,我們還要注意異常資料。

這類異常的一個例子是寫傾斜(write skew)。寫傾斜并不是在進行寫操作時發生髒讀或資料丢失時出現的,而是在資料的邏輯限制被破壞時出現的。

例如,假設有一個監控應用程式,要求至少有一個運維人員可以随叫随到。

删庫時,我後悔沒早學會的資料庫知識

對于上述情況,如果兩個事務成功送出,就會出現寫傾斜。即使沒有發生髒讀或資料丢失,資料的完整性也會丢失,因為有兩個人被指派随叫随到。

串行化化隔離級别、模式設計或資料庫限制可能有助于消除寫傾斜。開發人員需要在開發期間識别出這些異常,避免在生産環境中出現這個問題。話雖如此,直接從代碼中識别出寫傾斜是非常困難的。特别是在大型的系統中,如果不同的團隊使用相同的表,但沒有互相溝通,也沒有檢查如何通路資料,就更難發現問題了。

順序問題

資料庫提供的核心功能之一是順序保證,但這也是讓應用程式開發人員感到驚訝的一個地方。資料庫按照接收事務的順序來安排順序,而不是按照代碼中所寫的事務順序來安排順序。事務執行的順序很難預測,特别是在大規模并發系統中。

在開發過程中,特别是在使用非阻塞開發庫時,糟糕的可讀性可能會導緻出現這樣的問題:使用者認為事務是按順序執行的,但事務可能以任意順序到達資料庫。下面的代碼看起來像是要順序地調用 T1 和 T2,但如果這些函數是非阻塞的,并且會立即傳回 promise,那麼實際的調用順序将由它們到達資料庫的時間決定。

result1 = T1() // 傳回的是promise
result2 = T2()           

複制

如果原子性是必需的 (完全送出或中止所有操作),而且順序很重要,那麼 T1 和 T2 應該包含在單個資料庫事務中。

應用程式級别的分片可在應用程式之外進行

分片是對資料庫進行水準分區的一種方法。盡管有些資料庫可以自動對資料進行水準分區,但有些資料庫不會這麼做,或者可能不擅長這麼做。當資料架構師或開發人員能夠預測資料的通路模式時,他們可能會在使用者端進行水準分區,而不是在資料庫端,這叫作應用程式級别的分片。

“應用程式級别的分片”通常給人一種錯誤的印象,即認為分片應該存在于應用程式中。實際上,分片功能可以作為資料庫前面的一個層。随着資料增長和模式的疊代,分片需求可能會變得越來越複雜。

删庫時,我後悔沒早學會的資料庫知識

一個應用伺服器與分片服務分離的示例架構

将分片作為單獨的服務,可以在不重新部署應用程式的前提下提升分片政策的疊代能力。Vitess 是這方面的一個很好的例子。Vitess 為 MySQL 提供了水準分片能力,用戶端可以通過 MySQL 協定連接配接到 Vitess,Vitess 會在各個 MySQL 節點上對資料進行分片。

https://youtu.be/OCS45iy5v1M?t=204

自動遞增 ID 有“毒”

自動遞增是生成主鍵的常用方法。使用資料庫作為 ID 生成器,并在資料庫中建立帶有 ID 生成的表,這種情況并不少見。但是,通過自動遞增生成主鍵可能不是理想的方法,原因如下:

  • 在分布式資料庫系統中,自動遞增是一個難題。你需要一個全局鎖來生成 ID,但如果可以生成 UUID,就不需要協調資料庫節點。使用帶鎖的自動遞增可能會引入争用,并且可能會顯著降低分布式寫入性能。像 MySQL 這樣的資料庫可能需要特定的配置,并且要保證主主複制的正确性。但是,配置很容易出錯,并可能導緻寫入中斷。
  • 一些資料庫有基于主鍵的分區算法。順序 ID 可能會導緻不可預測的熱點,導緻某些分區資料量過大,而其他分區處于空閑狀态。
  • 通路資料庫最快方法是使用主鍵。如果你使用了其他列來辨別記錄,那麼順序 ID 可能會變得毫無意義。是以,請盡可能選擇一個全局唯一的自然主鍵 (例如使用者名)。

在決定哪種方法更适合自己之前,請考慮一下自動遞增 ID 與 UUID 對索引、分區和分片的影響。

無鎖的陳舊資料很有用

多版本并發控制 (MVCC) 可以支援上述的很多一緻性方面的能力。一些資料庫 (如 Postgres、Spanner) 借助 MVCC 讓每個事務可以檢視快照,即資料庫的舊版本。這些事務可以串行化,以此來保持一緻性。從舊快照讀取資料時,讀取的是陳舊的資料。

讀取稍微陳舊一點的資料也是很有用的,例如,基于資料生成分析報告或計算近似聚合值。

讀取陳舊資料的第一個好處是延時 (特别是當資料庫分布在不同的地理區域時)。MVCC 資料庫的第二個優點是它允許隻讀事務是無鎖的。如果讀取陳舊資料是可接受的,那麼對于偏重讀取很大的應用程式來說,這就是一個主要的優點。

删庫時,我後悔沒早學會的資料庫知識

應用伺服器從本地副本讀取 5 秒前的陳舊資料,即使在太平洋的另一端有可用的最新版本

資料庫會自動清除舊版本,在某些情況下,它們允許按需進行清理。例如,Postgres 允許使用者按需清理,或者每隔一段時間自動清理一次,而 Spanner 則使用垃圾回收器來清除超過一小時的陳舊資料。

任何與時鐘有關的資源之間都會發生時鐘傾斜

計算系統最隐秘的秘密是所有的時間 API 都會“撒謊”。計算機無法準确地知道目前時間,它們都有一個石英晶體,會産生計時信号,但石英晶體無法準确地計時,不是比實際時鐘快就是比實際時鐘慢。每天出現的時間漂移最多可長達 20 秒。為了準确起見,計算機上的時間需要時不時地與實際時間同步。

NTP 伺服器用于同步時間,但同步本身可能會因為網絡而出現延遲。在同一個資料中心中進行 NTP 伺服器同步需要花費一點時間,而與公共 NTP 伺服器同步有可能出現更大的傾斜。

原子時鐘和 GPS 時鐘是用來确定目前時間更好的一種來源,但它們昂貴,而且需要複雜的設定,無法在每台機器上安裝。考慮到這些限制,資料中心使用了多層方法。雖然原子時鐘和 GPS 時鐘提供了準确的時間,但它們的時間是通過輔助伺服器廣播到其他的機器上的。這意味着每台機器都會與實際的時間發生某種量級的傾斜。

應用程式和資料庫通常位于不同的機器上,不僅分布在多台機器上的資料庫節點無法就時間達成一緻,應用伺服器時鐘和資料庫節點時鐘也無法達成一緻。

谷歌的 TrueTime 采用了不同的方法。大多數人認為谷歌在時鐘方面的進步要歸功于他們使用了原子時鐘和 GPS 時鐘,但這隻是其中的部分原因。TrueTime 實際上做了這些事情:

  • TrueTime 使用兩種不同的來源:GPS 和原子時鐘。這些時鐘有不同的故障模式,是以同時使用它們提高了可靠性。
  • TrueTime 有一個非正常的 API,它以間隔的形式傳回時間,時間可以是下限和上限之間的任意點。谷歌的分布式資料庫 Spanner 可以等待,直到确定目前時間超過了特定時間。
删庫時,我後悔沒早學會的資料庫知識

Spanner 元件使用了 TrueTime,TT.now() 傳回一個時間間隔,Spanner 可以進行 sleep,以確定目前時間已經通過了一個特定的時間戳。

延遲沒有看上去的那麼簡單

如果你在一個房間裡問 10 個人“延遲”是什麼意思,他們可能會有不同的答案。在資料庫中,延遲通常是指“資料庫延遲”,而不是用戶端所感覺到的延遲。用戶端可以看到資料庫的延遲和網絡延遲。在調試問題時,能夠識别用戶端延遲和資料庫延遲是非常重要的。在收集和顯示名額時,始終都要考慮到兩者。

評估每個事務的性能需求

有時候,資料庫會說明它們在讀寫吞吐量和延遲方面的性能特征和限制。但在評估資料庫性能時,更全面的做法是對每一個關鍵操作 (查詢或事務) 進行評估。例如:

  • 往一張表 X(已經有 5 千萬行記錄)插入新行,并更新相關表,此時的寫入吞吐量和延遲是怎麼樣的?
  • 當平均朋友數量為 500 人時,查詢某個使用者的朋友的朋友,此時的延遲是怎樣的?
  • 當使用者訂閱了 500 個帳号 (每小時有 X 項更新) 時,查詢使用者時間軸的前 100 條記錄,此時的延遲是怎樣的?

性能評估可能包含了這些情況,直到你确信資料庫能夠滿足你的性能需求為止。

在收集名額時,要小心高基數。如果你需要高基數調試資料,請使用日志,甚至是分布式跟蹤資訊。

嵌套事務有風險

并不是每一種資料庫都支援嵌套事務。嵌套事務可能會導緻意外的程式設計錯誤,這些錯誤不容易識别,直到抛出異常。

嵌套事務可以在用戶端檢測和避免。如果無法避免,就要注意避免出現意外情況,即已送出的事務由于子事務而意外中止。

在不同的層封裝事務可能會出現意外的嵌套事務,而從可讀性角度來看,可能很難了解其意圖。看看下面這個例子:

with newTransaction():
    Accounts.create("609-543-222")
    with newTransaction():
        Accounts.create("775-988-322")
        throw Rollback();           

複制

這段代碼的結果是什麼?它是復原兩個事務還是隻復原内部事務?如果我們使用多層庫來封裝事務,會發生什麼呢?我們是否能夠識别并改善這種情況?

假設一個資料層已經在一個事務中實作了多個操作 (例如 newAccount),在業務邏輯的事務中運作它們時會發生什麼?此時具有怎樣的隔離性和一緻性特征?

function newAccount(id string) {
   with newTransaction():
       Accounts.create(id)
}           

複制

與其要處理這種問題,不如避免使用嵌套事務。資料層仍然可以實作自己的操作,但無需建立事務。然後,業務邏輯可以啟動、執行、送出或中止事務。

function newAccount(id string) {
    Accounts.create(id)
}
// 在主程式中:
with newTransaction():
    // 從資料庫讀取一些配置資料
    // 調用ID服務生成ID
    Accounts.create(id)
    Uploads.create(id) // 建立使用者上傳隊列           

複制

事務不應該依賴應用程式狀态

應用程式開發人員可能會在事務中使用應用程式狀态來更新某些值或設定查詢參數,這個時候要注意作用域。當發生網絡問題時,用戶端經常會重試事務。如果事務依賴的狀态在其他地方被修改,就使用了錯誤的值。

var seq int64
with newTransaction():
     newSeq := atomic.Increment(&seq)
     Entries.query(newSeq)
     // 其他操作           

複制

無論最終結果如何,上面的事務每次運作時都會增加序列号。如果由于網絡原因送出失敗,在第二次重試時,它将使用不同的序列号進行查詢。

查詢計劃的作用

查詢計劃決定了資料庫将會如何執行查詢。它們還會在執行查詢之前對其進行分析和優化。查詢計劃隻能根據某些信号提供一些可能的估計。例如下面這個查詢:

SELECT * FROM articles where author = "rakyll" order by title;           

複制

擷取結果有兩種方法:

  • 全表掃描:我們可以周遊表中的每條記錄,并傳回與作者姓名比對的文章,然後根據标題排序。
  • 索引掃描:我們可以使用一個索引來查找比對的 ID,擷取這些行,然後排序。

查詢計劃的作用是确定最佳執行政策。但可用于預測的信号是有限的,是以可能會導緻做出錯誤的決策。DBA 或開發人員可以用它們來診斷和調優性能較差的查詢。慢查詢日志、延遲問題或執行時間統計資訊可用于識别需要優化的查詢。

查詢計劃提供的一些度量可能不會很準确,特别是在估計延遲或 CPU 時間方面。作為查詢計劃的補充,跟蹤和執行路徑工具在診斷這些問題方面更有用,但并不是每種資料庫都會提供這些工具。

線上遷移雖複雜,但還是有迹可循

線上或實時遷移就是在不停機、不影響資料正确性的情況下從一個資料庫遷移到另一個資料庫。如果要遷移到同一個資料庫或引擎,實時遷移會容易一些,但要遷移到具有不同性能特征和模式需求的新資料庫,就要複雜得多。

線上遷移有一些可遵循的模式:

  • 在兩個資料庫上執行雙重寫操作。在這個階段,新的資料庫不包含所有資料,但會包含新資料。在這一步穩妥之後,就可以進入第二步。啟用針對兩個資料庫的查詢路徑。
  • 讓新資料庫承擔主要的讀寫任務。
  • 停止對舊資料庫的寫入,但可以繼續從舊資料庫讀取資料。此時,新資料庫仍然不包含所有資料,要讀取舊資料,仍然需要從舊資料庫獲得。
  • 此時,舊資料庫是隻讀的。用舊資料庫中的資料填充新資料庫缺失的資料。遷移完成後,所有讀寫路徑都可以使用新資料庫,舊資料庫可以從系統中移除。

資料庫規模增長帶來的不可預測性

資料庫的增長會帶來不可預測的伸縮性問題。

随着資料庫的增長,之前對資料大小和網絡容量的假設或預期可能會過時,比如大型 scheme 重構、大規模的運維改進、容量問題、部署計劃改變或遷移到其他資料庫以避免當機。

不要以為了解資料庫的内部結構就足夠了,因為伸縮性會帶來新的未知問題。不可預測的資料熱點、不均勻的資料分布、意外的容量和硬體問題、不斷增長的流量和新的網絡分區,這些都會迫使你重新考慮資料庫、資料模型、部署模型和部署規模。

參考閱讀:

https://medium.com/@rakyll/things-i-wished-more-developers-knew-about-databases-2d0178464f78