Categories
程式開發

Uber為什麼放棄Postgres選擇遷移到MySQL?


Uber 的早期架構包含了一個用Python 開發的單體後端應用程序,這個應用程序使用Postgres 作為數據存儲。 從那個時候開始,Uber 的架構已經發生了巨大變化,變成了微服務,並採用新的數據平台模型。 具體地說,之前使用Postgres 的地方,現在改用Schemaless,一種構建在MySQL 之上的新型數據庫分片層。 在本文中,我們將探討Postgres 的一些缺點,並解釋為什麼我們要在MySQL 之上構建Schemaless 和其他後端服務。

Postgres 架構

我們遭遇了Postgres 的諸多限制:

  • 低效的寫入操作;
  • 低效的數據複製;
  • 數據損壞問題;
  • 糟糕的副本MVCC 支持;
  • 難以升級到新版本。

我们将通过分析 Postgres 的表和索引在磁盘上的表示方式来探究以上这些限制,并将其与 MySQL 的 InnoDB 存储引擎进行比较。请注意,我们的分析主要是基于我们对较旧的 Postgres 9.2 版本系列的经验。据我们所知,在本文中讨论的内部架构在较新的 Postgres 发行版中并未发生显著变化,并且至少自 Postgres 8.3 发行版(现在已近 10 岁)以来,9.2 版本的基本设计都没有发生显著变化。

磁盤表示一個關係型數據庫必須能夠執行一些關鍵任務:

  • 提供插入、更新和刪除能力;
  • 提供修改模式的能力;
  • 支持MVCC,讓不同的數據庫連接具有各自的事務視圖。

這些功能如何協同工作是設計數據庫磁盤數據表示的重要部分。

Postgres 的一個核心設計是不可變數據行。 這些不可變數據行在Postgres 中被稱為“元組”。 這些元組通過ctid 來唯一標識。 從概念上看,ctid 表示元組在磁盤上的位置(即物理磁盤偏移)。 可能會有多個ctid 描述單個行(例如,為了支持MVCC,可能存在一個數據行的多個版本,或者一個數據行的舊版本還沒有被autovacuum 進程回收掉)。 元組集合構成一張表。 表本身是有索引的,這些索引被組織成某種數據結構(通常是B 樹),將索引字段映射到ctid。

通常,這些ctid 對用戶是透明的,但了解它們的工作原理有助於了解Postgres 表的磁盤結構。 要查看當前行的ctid,可以在語句中將“ctid”添加到列列表中:

[email protected][local] uber=> SELECT ctid, * FROM my_table LIMIT 1;
-[ RECORD 1 ]--------+------------------------------
ctid                 | (0,1)
...其他字段...

我們通過一個簡單的用戶表來解釋這個。 對於每個用戶,我們都有一個自動遞增的用戶ID 主鍵、用戶的名字和姓氏以及用戶的出生年份。 我們還針對用戶全名(名字和姓氏)定義了複合二級索引,並針對用戶的出生年份定義了另一個二級索引。 創建表的DDL 可能是這樣的:

CREATE TABLE users (
    id SERIAL,
    first TEXT,
    last TEXT,
    birth_year INTEGER,
    PRIMARY KEY (id)
);
 CREATE INDEX ix_users_first_last ON users (first, last);
 CREATE INDEX ix_users_birth_year ON users (birth_year);

這裡定義了三個索引:一個主鍵索引和兩個二級索引。

我們往表中插入以下這些數據,包括一些有影響力的歷史數學家:

Uber為什麼放棄Postgres選擇遷移到MySQL? 1

如前所述,這裡的每一行都有一個隱式、唯一的ctid。 因此,我們可以這樣考慮表的內部表示形式:

Uber為什麼放棄Postgres選擇遷移到MySQL? 2

主鍵索引(將id 映射到ctid)的定義如下:

Uber為什麼放棄Postgres選擇遷移到MySQL? 3

B 樹索引是在id 字段上定義的,並且B 樹中的每個節點都存有ctid 的值。 請注意,在這種情況下,由於使用了自動遞增的ID,B 樹中字段的順序恰好與表中的順序相同,但並不是一直都這樣。

二級索引看起來差不多,主要區別在於字段的存儲順序不同,因為B 樹必須按字典順序來組織。 (first,last) 索引從名字的字母表順序開始:

Uber為什麼放棄Postgres選擇遷移到MySQL? 4

類似的,birth_year 索引按照升序排列,如下所示:

Uber為什麼放棄Postgres選擇遷移到MySQL? 5

對於後兩種情況,二級索引中的ctid 字段不是按照字典順序遞增的,這與自動遞增主鍵的情況不同。

假設我們需要更新該表中的一條記錄,比如我們要更新al-Khwārizmī的出生年份。 如前所述,行的元組是不可變的。 因此,為了更新記錄,我們向表中添加了一個新的元組。 這個新的元組有一個新的ctid,我們將其稱為I。 Postgres 需要區分新元組I 與舊元組D。 在內部,Postgres 在每個元組中保存了一個版本字段和一個指向先前元組的指針(如果有的話)。 因此,表的最新結構如下所示:

Uber為什麼放棄Postgres選擇遷移到MySQL? 6

只要存在al-Khwārizmī行的兩個版本,索引中就必須同時包含兩個行的條目。 為簡便起見,我們省略了主鍵索引,只顯示了二級索引,如下所示:

Uber為什麼放棄Postgres選擇遷移到MySQL? 7

Uber為什麼放棄Postgres選擇遷移到MySQL? 8

我們用紅色表示舊數據行,用綠色表示新數據行。 Postgres 使用另一個版本字段來確定哪個元組是最新的。 數據庫根據這個字段確定哪個元組對不允許查看新版本數據的事務可見。

Uber為什麼放棄Postgres選擇遷移到MySQL? 9

在Postgres 中,主索引和二級索引都直接指向磁盤上的元組偏移量。 當元組位置發生變化時,必須更新所有索引。

複製

當我們在表中插入新行時,如果啟用了流式複制,Postgres 需要對其進行複制。 為了能夠在發生崩潰後恢復,數據庫維護了預寫日誌(WAL),並用它來實現兩階段提交。 即使未啟用流式複制,數據庫也必須維護WAL,因為WAL 可以保證ACID 中的原子性和持久性。

為了更好地理解WAL,我們可以想像一下如果數據庫意外發生崩潰(例如突然斷電)會發生什麼。 WAL 代表了一系列數據庫計劃對錶和索引在磁盤上內容做出的更改。 Postgres 守護進程在啟動時會將WAL 的數據與磁盤上的實際數據進行對比。 如果WAL 中包含未反映到磁盤上的數據,數據庫就會更正元組或索引數據,並回滾出現在WAL 中但在事務中沒有被提交的數據。

Postgres 通過將主數據庫上的WAL 發送給副本來實現流式複制。 每個副本數據庫就像是在進行崩潰恢復,不斷地應用WAL 更新。 流式複制和實際發生崩潰恢復之間的唯一區別是,處於“熱備用”模式的副本在應用WAL 時可以提供查詢服務,但真正處於崩潰恢復模式的Postgres 數據庫通常會拒絕提供查詢服務,直到數據庫實例完成崩潰恢復過程。

因為WAL 實際上是為實現崩潰恢復而設計的,所以它包含了底層的磁盤更新信息。 WAL 包含了元組及其磁盤偏移量(即行ctid)在磁盤上的表示。 如果副本完全與主數據庫同步,此時暫停Postgres 的主數據庫和副本,那麼副本的磁盤內容與主數據庫的磁盤內容將完全一致。 因此,如果副本與主數據庫不同步,可以用rsync 之類的工具來修復。

Postgres 的設計所帶來的後果

Postgres 的設計導致Uber 的數據效率低下,還讓我們遇到了很多麻煩。

寫入放大

Postgres 的第一個問題是寫入放大。 通常,寫入放大是指將數據寫入SSD 磁盤時遇到的問題:小的邏輯更新(例如,寫入幾個字節)在轉換到物理層時會放大,成本會變高。 在之前的示例中,如果我們對al-Khwārizmī的出生年份進行小的邏輯更新,必須進行至少四個物理更新:

  1. 將新的行元組寫入表空間;
  2. 更新主鍵索引;
  3. 更新(first,last) 索引;
  4. 更新birth_year 索引。

實際上,這四個更新也只反映了對主表空間的寫操作。 除此之外,這些寫操作也需要反映在WAL 中,因此磁盤上的寫操作總數會變得更多。

這裡值得注意的是更新2 和更新3。 在更新al-Khwārizmī的出生年份時,實際上並沒有修改它的主鍵,也沒有修改名字和姓氏。 但儘管如此,仍然必須在數據庫中創建新的行元組,以便更新這些索引。 對於具有大量二級索引的表,這些多餘的步驟可能會導致效率低下。 例如,如果我們在一張表中定義了十二個索引,即使只更新了單個索引對應的字段,也必須將該更新傳播給所有12 個索引,以便反映新行的ctid。

複製

這個寫入放大問題自然也轉化到了複製層,因為複制發生在磁盤級別。 數據庫並不會復制小的邏輯記錄,例如“將ctid D 的出生年份更改為770”,而是將之前的4 個WAL 條目傳播到網絡上。 因此,寫入放大問題也轉化為複制放大問題,Postgres 複製數據流很快變得非常冗長,可能佔用大量帶寬。

如果Postgres 複製僅發生在單個數據中心內,那麼複製帶寬可能就不是問題。 現代網絡設備和交換機可以處理大量帶寬,很多託管服務提供商還提供了免費或便宜的數據中心內部帶寬。 但是,如果要在數據中心之間進行複制,問題就會迅速升級。 例如,Uber 最初使用了西海岸託管中心裡的物理服務器。 為了進行災備,我們在東海岸託管中心添加了服務器。 於是,我們在西部數據中心裡有一個主Postgres 實例(加上副本),在東部也有一個副本集。

級聯複製將數據中心間的帶寬限制為只能滿足主數據庫和單個副本之間的帶寬需求,雖然第二個數據中心裡還有很多副本。 因為Postgres 複製協議的冗繁,使用了大量索引的數據庫會有很大的數據量。 購買跨地域大帶寬成本非常高昂,即使錢不成問題,也不可能獲得與本地帶寬類似的效果。 這個帶寬問題也給WAL 歸檔帶來了麻煩。 除了將所有WAL 更新從西海岸發送到東海岸之外,我們還要將所有WAL 都存檔到文件存儲服務中,這是為了確保在發生災難時我們可以還原數據。 在早期的流量高峰期間,我們寫入存儲服務的帶寬不夠快,無法跟上WAL 的寫入速度。

數據損壞

在例行升級主數據庫以便增加數據庫容量的過程中,我們遭遇了Postgres 9.2 個一個bug。 因為副本在切換時間方面出現了錯誤,導致其中一些副本錯誤地應用了一小部分WAL 記錄。 由於這個問題,一些本應由版本控制機制標記為無效的記錄實際上並未被標記為無效。

下面的查詢說明了這個錯誤將如何影響我們的用戶表:

SELECT * FROM users WHERE id = 4;

這個查詢將返回兩條記錄:初始的al-Khwārizmī行(出生年份為780 CE)和新的al-Khwārizmī行(出生年份為770 CE)。 如果將ctid 添加到WHERE 中,對於這兩條返回的記錄,我們將看到不同的ctid 值。

這個問題非常煩人。 首先,我們無法得知這個問題究竟影響了多少行數據。 數據庫返回的重複結果在很多情況下會導致應用程序邏輯故障。 我們最終添加了防禦性編程語句,用來檢測會出現這個問題的表。 這個錯誤影響到了所有服務器,而在不同的副本實例上損壞的數據行是不一樣的。 也就是說,在其中一個副本實例上,行X 可能是壞的,行Y 是好的,但是在另一副本實例上,行X 可能是好,行Y 可能是壞的。 我們無法確定數據損壞的副本數量以及問題是否影響了主數據庫。

據我們所知,每個數據庫只有幾行數據會出現這個問題,但我們擔心的是,由於復制發生在物理級別,最後可能會完全破壞數據庫索引。 B 樹索引很重要的一點是必須定期進行重新平衡(rebalance),並且當子樹移動到新的磁盤位置時,這些重新平衡操作可能會完全改變樹的結構。 如果移動了錯誤的數據,則可能導致樹的大部分完全無效。

最後,我們找到了問題所在,並確定新的主數據庫沒有損壞的數據行。 我們通過從主數據庫的最新快照重新同步所有副本(這是一個費力的過程)來修復副本的數據損壞問題。

我們遇到的錯誤只出現在Postgres 9.2 的某些版本中,並且已經修復了很長時間了。 但是,我們仍然擔心此類錯誤會再次發生。 新版本的Postgres 可能還會出現此類錯誤,並且由於數據複製的方式,這類問題有可能被傳播到所有的數據庫中。

副本MVCC

Postgres 沒有提供真正的副本MVCC 支持。 副本只應用WAL 更新,導致它們在任何時候都具有與主數據庫相同的磁盤數據副本。 這種設計給Uber 帶來了麻煩。

Postgres 需要為MVCC 維護舊數據的一個副本。 如果流式複制遇到一個正在執行的事務,而數據庫更新影響到了事務範圍內的行,那麼更新操作就會被阻塞。 在這種情況下,Postgres 會暫停WAL 線程,直到事務結束。 如果事務處理要花費很長時間,這就會是個問題,因為副本可能嚴重滯後於主數據庫。 因此,Postgres 在這種情況下應用超時策略:如果一個事務導致WAL 發生阻塞一定的時間,Postgres 將會終止這個事務。

這種設計意味著副本通常會比主數據庫落後幾秒鐘,很容易出現事務被終止的情況。 例如,假設開發人員寫了一些代碼,需要通過電子郵件將收據發送給用戶。 根據編寫方式的不同,代碼可能會隱式地讓數據庫事務處於打開狀態,直到電子郵件完成發送為止。 儘管在執行不相關的阻塞IO 時一直打開數據庫事務是很糟糕的做法,但大多數工程師並不是數據庫專家,他們可能也不知道有這個問題,特別是在使用隱藏了底層細節的ORM 框架時。

升級Postgres

由於復制發生在物理層面,所以我們無法在Postgres 的不同版本之間複製數據。 Postgres 9.3 的主數據庫不能被複製到Postgres 9.2 的副本,而Postgres 9.2 的主數據庫也不能被複製到Postgres 9.3 的副本。

我們按照以下這些步驟從一個Postgres GA 版本升級到另一個版本:

  • 關閉主數據庫。
  • 在主數據庫上運行pg_upgrade 命令,這個命令會就地更新主數據庫數據。 對於大型數據庫,通常需要花費數小時,並且在這個過程過程中無法從主數據庫讀取數據。
  • 再次啟動主數據庫。
  • 創建主數據庫的最新快照。 這一步驟完全複製了主數據庫的所有數據,因此大型數據庫也需要花費數小時。
  • 擦除所有副本,並將最新的快照從主數據庫還原到副本上。
  • 將副本帶回到復制層次結構中。 等待副本完全跟上主數據庫的所有更新。

我們從Postgres 9.1 開始,並成功完成了升級過程,遷移到了Postgres 9.2。 但是,這個過程花費了數小時,我們無力承擔再次執行這種升級過程的費用。 到Postgres 9.3 發佈時,Uber 的規模增長極大增加了我們的數據集,因此升級時間就變得更長了。 因此,即使Postgres 9.5 已經發布了,我們的Postgres 實例仍然是9.2 版本。

如果你的Postgres 是9.4 或更高版本,可以使用pgologic 之類的東西,它為Postgres 實現了一個邏輯複製層。 你可以用它在不同的Postgres 版本之間複製數據,這意味著可以從9.4 升級到9.5,而不會造成大面積停機。 不過,這個功能仍然是有問題的,因為它尚未被集成到Postgres 主線中。 而對於那些使用較舊版本的Postgres 的人來說,pgologic 並不適用。

MySQL 架構

上文解釋了Postgres 的一些局限性,接下來,我們將解釋為什麼MySQL 會成為Uber 工程團隊存儲項目(例如Schemaless)的新工具。 在很多情況下,我們發現MySQL 更適合我們的使用場景。 為了理解這些差異,我們研究了MySQL 的架構,並將其與Postgres 進行了對比。 我們專門分析了MySQL 的InnoDB 存儲引擎。

InnoDB 的磁盤表示

與Postgres 一樣,InnoDB 支持MVCC 和可變數據等高級功能。 關於InnoDB 磁盤表示的詳盡細節不在本文的討論範圍之內,我們將把重點放在它與Postgres 的主要區別上。

最主要的架構差異是:Postgres 直接將索引記錄映射到磁盤上的位置,而InnoDB 使用了二級結構。 InnoDB 的二級索引有一個指向主鍵值的指針,而不是指向磁盤位置的指針(如Postgres 中的ctid)。 因此,MySQL 會將二級索引將索引鍵與主鍵相關聯:

Uber為什麼放棄Postgres選擇遷移到MySQL? 10

要基於(first, last) 索引執行查詢,需要進行兩次查找。 第一次先搜索表,找到記錄的主鍵。 在找到主鍵之後,搜索主鍵索引,找到數據行對應的磁盤位置。

所以,在執行二級查找時,InnoDB 相比Postgres 略有不利,因為InnoDB 必須搜索兩個索引,而Postgres 只需要搜索一個。 但是,由於數據已經規範化,在更新行數據時只需要更新實際發生變化的索引記錄。 此外,InnoDB 通常會在原地進行行數據更新。 為了支持MVCC,如果舊事務需要引用一行數據,MySQL 會將舊行複製到一個叫作回滾段的特殊區域中。

我們來看看更新al-Khwārizmī的出生年份會發生什麼。 如果空間足夠,id 為4 的那一行數據中的出生年份字段會進行原地更新(實際上,這個更新總是發生在原地,因為出生年份是一個佔用固定空間量的整數)。 出生年份索引也進行原地更新。 舊數據行將被複製到回滾段。 主鍵索引不需要更新,(first, last) 索引也不需要更新。 即使這張表有大量索引,也只需要更新包含birth_year 字段的索引。 假設我們基於signup_date、last_login_time 等字段建立了索引,我們不需要更新這些索引,但在Postgres 中需要更新。

這種設計還讓數據清理和壓縮變得更加高效。 回滾段中的數據可以直接清除,相比之下,Postgres 的autovacuum 進程必須進行全表掃描來識別哪些行可以清除。

Uber為什麼放棄Postgres選擇遷移到MySQL? 11

MySQL 使用了額外的中間層:二級索引記錄指向主索引記錄,主索引保存了數據行在磁盤上的位置。 如果數據行偏移量發生變化,只需要更新主索引。

複製

MySQL 支持多種不同的複制模式:

  • 基於語句的複制將會復制邏輯SQL 語句(它將按字面意義復制SQL 語句,例如:UPDATE users SET birth_year = 770 WHERE id = 4);
  • 基於行的複制將會復制發生變化的行記錄;
  • 混合複製將這兩種模式混合在一起。

這幾種模式各有優缺點。 基於語句的複制通常是最緊湊的,但可能需要副本應用大量語句來更新少量數據。 另一方面,基於行的複制(與Postgres WAL 複製類似)雖然更為冗繁,但更具可預測性和在副本上的更新效率。

在MySQL 中,只有主索引有指向行的磁盤偏移量的指針。 在進行複制時,這具有重要的意義。 MySQL 複製流只需要包含有關行的邏輯更新信息。 對於類似“將行X 的時間戳從T_1 更改為T_2”這樣的更新,副本會自動推斷需要修改哪些索引。

相比之下,Postgres 複製流包含了物理變更,例如“在磁盤偏移量8,382,491 處寫入字節XYZ”。 在使用Postgres 時,對磁盤進行的每一個物理變更都需要包含在WAL 流中。 較小的邏輯修改(例如更新時間戳)也需要執行很多磁盤變更:Postgres 必須插入新的元組,並更新所有索引,讓它們指向這個元組,所以會有很多變更被放入WAL 流中。 這種設計差異意味著MySQL 複製二進制日誌比PostgreSQL WAL 流更緊湊。

複製方式也對副本的MVCC 產生重要影響。 由於MySQL 複製流具有邏輯更新,副本可以具有真正的MVCC 語義,所以對副本的讀取查詢不會阻塞複製流。 相比之下,Postgres WAL 流包含了磁盤上的物理更改,Postgres 副本無法應用與讀取查詢相衝突的複制更新,因此無法實現MVCC。

MySQL 的複制架構意味著即使有bug 導致表損壞,也不太可能會發生災難性故障。 因為複制發生在邏輯層,所以像重新平衡B 樹之類的操作永遠不會導致索引損壞。 一個典型的MySQL 複製問題是語句被跳過(或者被應用兩次),這可能導致數據丟失或無效,但不會導致數據庫中斷。

最後,MySQL 的複制架構可以很容易在不同的MySQL 版本之間進行複制。 MySQL 的邏輯複製格式還意味著存儲引擎層中的磁盤變更不會影響複製格式。 在進行MySQL 升級時,典型的做法是一次將更新應用於一個副本,在更新完所有副本後,將其中一個提升為新的主副本。 這幾乎可以實現零停機升級,很容易就可以讓MySQL 保持最新狀態。

MySQL 的其他優勢

到目前為止,我們介紹了Postgres 和MySQL 的磁盤架構。 MySQL 還有其他一些重要方面也讓它的​​性能明顯優於Postgres。

緩衝池

首先,兩個數據庫的緩存方式不同。 Postgres 為內部緩存分配了一些內存,但是與計算機上的內存總量相比,這些緩存通常很小。 為了提高性能,Postgres 允許內核通過頁面緩存自動緩存最近訪問的磁盤數據。 例如,我們最大的Postgres 副本有768 GB 的可用內存,但實際上只有25 GB 被用作Postgres 的進程RSS 內存,這樣就為Linux 頁面緩存留出了700 GB 以上的可用內存。

這種設計的問題在於,與訪問RSS 內存相比,通過頁面緩存訪問數據實際上開銷更大。 為了從磁盤上查找數據,Postgres 進程發出lseek 和read 系統調用來定位數據。 這些系統調用中的每一個都會引起上下文切換,這比從主存儲器訪問數據的開銷更大。 實際上,Postgres 在這方面甚至還沒有完全進行優化:Postgres 並未利用pread 系統調用,這個系統調用會將seek 和read 操作合併為一個系統調用。

相比之下,InnoDB 存儲引擎通過緩衝池實現了自己的LRU。 從邏輯上講,這與Linux 頁面緩存相似,但它是在用戶空間中實現的。 儘管InnoDB 緩衝池的設計比Postgres 的設計要復雜得多,但它具備一些優勢:

  1. 可以實現自定義LRU。 例如,可以檢測出可能會破壞LRU 的訪問模式,並防止其造成更大問題。
  2. 較少的上下文切換。 通過InnoDB 緩衝池訪問的數據不需要進行用戶/ 內核上下文切換。 最壞的情況是發生TLB 未命中,這些開銷相對較小,可以通過使用大頁面來緩解。

連接處理

MySQL 通過一個連接一個線程的方式來實現並發連接。 這種開銷相對較低,每個線程都有自己的棧內存和分配給特定連接的緩衝堆內存。 在MySQL 中使用10000 個左右的並發連接,這種情況並不少見,實際上,在我們現有的某些MySQL 實例上,連接數已經接近這個數字。

但是,Postgres 採用的是一個連接一個進程的設計,這比一個連接一個線程的設計要昂貴得多。 派生新進程比生成新線程佔用更多的內存。 此外,進程之間的IPC 比線程之間的IPC 也昂貴得多。 Postgres 9.2 通過System V IPC 原語實現IPC,而不是使用輕量級的futex。 futex 比System V IPC 更快,因為通常情況下,futex 不存在竟態條件,因此無需進行上下文切換。

除了內存和IPC 開銷,Postgres 似乎也無法很好地支持大量連接,即使有足夠的可用內存。 我們在Postgres 中使用數百個活動連接時遇到了大問題。 Postgres 文檔建議採用進程外連接池機制來處理大量連接,但沒有詳細說明是為什麼。 因此,我們使用pgbouncer 來處理Postgres 的連接池。 但是,我們的後端服務偶爾會出現bug,導致它們打開的活動連接過多,從而延長了宕機時間。

結論

在Uber 早期,Postgres 為我們提供了很好的服務,但是隨著公司規模的增長,我們遇到了伸縮性問題。 現在,我們仍然保留了一些舊的Postgres 實例,但大部分數據庫都建立在MySQL 之上(通常使用Schemaless 層),或者在某些特殊情況下會使用像Cassandra 這樣的NoSQL 數據庫。

英文原版

為什麼Uber Engineering從Postgres切換到MySQL