當數據庫遇到分佈式

數據庫通常有着完善的事務支持,但是侷限於單機的存儲和性能,於是就出現了各種分佈式解決方案。最近讀了《Designing Data-Intensive Applications》這本書,所以做一個總結,供大家做個參考,有什麼不對的請大家指正,一起討論。

數據模型

對象由各類屬性組成,對象的關係通常有一對多 / 多對一和多對多。

關係模型

關係模型使用表、行、字段分別表示一類實體的集合、一個實體以及一個實體的一個屬性;在其中一個實體的字段中存儲另一實體的 Id 標識來表示實體之間多對一的關係,使用單獨的關聯表存儲兩個實體的 Id 標識來表示實體建多對多的關係。

關係模型具有強模式,必須在寫數據前定義好,即寫模式,類似編程語言的靜態(編譯時)類型檢查。

下面的示例是 Linked 的一個簡歷的關係型表示:

文檔模型

相對於關係模型,文檔模型減少了應用程序代碼和存儲層之間的阻抗不匹配,在一對多關係下,具有更好的局部性。

文檔模型具有讀時模式,對寫入沒有模式要求。類似編程語言的動態(運行時)類型檢查。

對於上面簡歷的例子,使用文檔模型的表示如下:

**圖模型
**

圖模型強調是對象之間的連接,當應用是圍繞衆多對象連接以及對這些連接進行的查詢和計算時,就需要考慮使用圖模型的數據庫。

一個圖由頂點(表示的是實體)和邊(實體之間關係)組成,一個複雜的圖模型通常由數十億的頂點和千億的邊組成。

以下是社交網絡的一個示例:表示的是兩個人之間的以及居住地點。

每種數據模型都有其對應的查詢語言,關係型使用 SQL,而圖模型也有相應的查詢語言來描述圖模型的特點,但是還沒有形成業界標準。

qm1PjA

存儲引擎

上面我們熟悉了數據模型,但是瞭解數據內部的存儲和檢索原理,對於我們設計和開發應用以及數據庫的選型也是非常有幫助的。

數據庫的主要功能是存儲數據以及後續進行查詢和更新,目前主要有兩大類數據庫:傳統關係型數據庫(面向頁面 page-oriented) 和 NoSQL 數據庫(基於日誌結構 log-structured)。

面向頁面

B 樹是幾乎是數據庫標準的索引實現,B 數將數據庫分解成固定大小的塊或頁面,通常在 4k-32k 範圍,一次只能讀取或寫入一個頁面。這種設計更接近與底層的硬件,因爲磁盤也是由固定大小的塊組成的。

每個頁面都可以使用地址來標識,一個頁面引用另一個頁面,類似於指針,但是在磁盤而不是在內存中,如圖所示:

在 B 樹的頁面中對子頁面的引用的數量稱爲分支因子,分支因子取決於頁面大小和索引 key 的大小,分支因子越大越好。(分支因子爲 500 的 4KB 頁面的四級樹可以存儲多大 256TB)

數據查詢時,從根頁面(通常緩存在內存)出發,根據頁面引用尋找滿足條件範圍的頁面,一直到葉子節點。

數據更新時,定位到葉子結點,用新數據覆蓋磁盤的頁面。

數據插入和刪除時,會涉及到頁面的拆分和合並,來保持 B 樹的平衡

爲了保證數據查詢和寫入的高性能,數據庫通常會對頁面數據進行內存緩存,當數據有更新時,不會立即更新磁盤數據,而是先更新內存緩存的頁面數據,同步追加寫入 WAL 日誌(write-ahead-log),異步將內存中的髒頁刷到磁盤上(將磁盤隨機寫變爲順序寫)。當數據庫崩潰後恢復時,這個日誌用來是 B 樹恢復到一致的狀態。

日誌結構

基於日誌結構的存儲模式,每次數據新增或更新時,僅僅將數據追加到特定日誌文件中,當文件超過一定大小時,則打開一個新的文件寫入。

每個日誌結構存儲段都是一系列鍵值對,但是爲了後續便於查詢數據,要求鍵值對在文件中按照鍵排序,這種排序的字符串表 (Sorted String Table) 稱爲 SSTable。

爲了保證日誌文件保持在一定的個數,多個文件段進行合併(歸併算法),當出現多個同一鍵值時,用新的值覆蓋老的,保證一個合併段同一個鍵出現一次。

內存中維護者鍵到日誌文件的索引,該索引是稀疏的,每幾千個字節的段文件就有一個鍵就足夠了,因爲幾千字節可以很快被掃描。(可以將部分記錄分組到塊,壓縮寫入磁盤)

如何構建和維護 SSTable 呢(保證按照鍵排序存儲)

數據查詢時,首先嚐試在內存表中查找,然後在多個文件段中進行查找。(通過合併文件段使其維持在一定的個數,保證查找效率)

這種基於合併和壓縮排序文件原理的存儲引擎通常被稱爲 LSM 存儲引擎。

當查找不存在的鍵時,LSM 樹算法可能會很慢。爲了優化這種訪問,通常使用額外的 Bloom 過濾器。

LSM 樹的基本思想

保存一系列在後臺合併的 SSTables,即使數據集比可用內存大得多,仍能繼續工作。由於數據按序存儲,因此可以高效地執行範圍查詢(掃描所有高於某些最小值和最高值的所有鍵),並且磁盤寫入時連續的,所以可以支持非常高的寫入吞吐量。

事務

在數據庫系統中,會遇到各種問題:

事務一直是簡化這些問題的首選機制。事務是應用程序將多個讀寫操作組合成一個邏輯單元的一種方式。從概念上講,事務中的所有讀寫操作被視爲單個操作來執行:整個事務要麼成功,要麼失敗後回滾。如果失敗,應用可以安全地重試。對於事務來說,應用的錯誤處理簡單多了,不用擔心部分失敗的情況了。

事務提供的安全保障,由 ACID 來描述。即原子性 Atomicity, 一致性 Consistency,隔離性 Isolation,持久性 Durability,旨在爲數據庫中的容錯性建立精確的術語。

單對象 vs 多對象

事務通常被理解爲,將對多個對象上的多個操作合併爲一個執行單元的機制。但許多分佈式數據庫只提供了單對象的原子性和隔離性(原子性通過同步寫日誌實現崩潰恢復;隔離性通過每個對象上鎖實現單線程訪問),以及更復雜的原子操作,如自增 和 CAS。所以要注意這一點,看是否滿足自己的應用場景。

多對象事務,除了要處理複雜原子性和隔離性,分佈式場景下,還會涉及到跨分區(不能分區可能在不同的機器上),即分佈式事務。

隔離級別

如果兩個事務不觸及相同的數據,它們可以安全地並行執行,因爲兩者都不依賴對方。當一個事務讀取另一個事務同時修改的數據,或者兩個事務試圖同時修改相同的數據,併發問題纔會出現。

併發 bug 很難通過測試找到,因爲這樣的錯誤只有在特殊時機下才會觸發,很難重現。出於這個原因,數據庫一直試圖通過提供事務隔離來隱藏應用開發者的併發問題。事務隔離級別越強越能夠避免發生的併發問題,比如可序列化保證事務的效果與串行執行是一樣的,但這意味着併發性能的犧牲。所以數據庫系統通常使用較弱的隔離級別,來防止一部分併發問題,而不是全部,所以瞭解這些對於開發出正確的應用非常重要。

qCEnD9

髒寫

髒寫是指一個事務覆蓋另一個事務未提交的數據,現有的隔離級別都會保證沒有髒寫。數據庫通常使用行鎖來防止髒寫。

髒讀

髒讀是指一個事務寫了部分數據,未提交,這是另一個事務讀取到了這部分未提交的數據。

不可重複讀

同一個事務兩次讀取的數據(讀偏差) 或者 讀取的記錄數(幻讀)不一致

丟失更新

兩個事務同時讀取數據,並進行更新,兩個事務都更新成功,更新邏輯都是基於原先讀取的值,但是事務提交會改變先前讀取的值,導致丟失更新。典型的場景就是 讀 -> 改 -> 寫。

寫偏差

可以將寫入偏差視爲丟失更新問題的一般化。如果兩個事務讀取相同的對象,然後更新其中的一些對象(不同的事務可能更新不同的對象),則可能發生寫入偏差。

讀已提交

讀已提交提供兩種保證

可重複讀 / 快照隔離

支持快照隔離的數據庫保留了一個對象的不同的提交版本,因爲各種正在進行的事務可能需要看到數據庫在不同時間點的狀態。這種技術被稱爲多版本併發控制(MVCC,multi-version concurrency control)。

當一個事務開始,它被賦予一個唯一個的,永遠增長的事務 ID(txid)。每當事務向數據庫寫入任何內容時,它所寫入的數據都會被標記上寫入者的事務 ID。

一個事務能查到一個對象,滿足以下兩個條件:

對於丟失更新和有數據交叉的寫偏差,數據庫可以結合快照隔,可以自動檢測到丟失更新,中止相應的事務。但是 MySQL/InnoDB 的可重複讀並不會檢測丟失更新。有些作者認爲,數據能防止丟失更新才能稱得上快照隔離,所以這種定義下,MySQL 並不提供快照隔離

MySQL/InnoDB 可重複讀隔離級別下,可以使用 鎖定讀 (select for update)或者 比較並設置 CAS 來避免丟失更新。

需要注意的是,如果數據庫允許 where 字句從舊快照中讀取,則此語句可能無法防止丟失更新,因爲即使發生了另一個併發寫入,where 條件也可能是真的。

序列化

但對於寫入數據無交叉的寫偏差,只能通過序列化的隔離級別來避免,但是可以在應用層面通過 物化衝突的方式,人爲的在數據庫中引入一個鎖對象。

序列化隔離級別有三種實現方式:

分佈式事務

在多對象事務中,如果不同對象存在不同的分區中,則就需要處理分佈式事務。提到分佈式事務,就不得不介紹兩階段提交,兩階段提交是分佈式事務的基本思想。

**兩階段提交
**

兩階段提交 2PC(two-phase commit)是一種用於實現跨多個節點的原子事務提交的算法。可以在數據庫內部使用,也可以以 XA 事務的形式對應用可用。

兩階段提交引入了協調者的角色,整體分爲兩個階段,具體的過程如下:

兩階段提交固有的成本:由於崩潰恢復所需的強制刷盤以及額外的網絡往返,另外整個過程會進行資源的鎖定。

Percolator

Percolator 是由 Google 公司開發的、爲大數據集羣進行增量處理更新的系統,主要用於 google 網頁搜索索引服務。使用基於 Percolator 的增量處理系統代替原有的批處理索引系統後,Google 在處理同樣數據量的文檔時,將文檔的平均搜索延時降低了 50%。

Percolator 是一個無中心化(沒有協調者)的兩階段提交,基於 BigTable 的單行事務,實現了跨行的事務引擎。另外借助 BigTable 的多時間戳版本,可以實現快照隔離級別。

Percolator 依賴中心的授時器,沒有單點 Coordinator 的角色,交由所有客戶端來協調上鎖協議,但是趕上崩潰鎖會泄露。Percolator 選擇了惰性地回收泄露的鎖:其他客戶端在 Get() 到這行數據時,如果遇到鎖,則選擇等待退避重試,或者清理鎖。

但是由於 Percolator 使用樂觀鎖檢測機制,對於熱點數據的併發更新不友好。我覺得這一點可以通過在 Percolator 之上實現悲觀鎖機制來解決。

分區

分區(partitions)也叫分片(sharding),是將數據集進行拆分成多個分區,每個分區存儲在不同的機器上,擴展了整體的存儲量,提高了寫入和讀取的性能。但也帶來了新的困難,數據庫要支持跨分區的寫入和讀取。

**分區方式
**

分區的目標是將數據和查詢負載均勻的分佈在各個節點上。如果分區是不公平的,或者沒有考慮熱點數據,那麼一些分區比其他分區有更多的數據或查詢,我們稱之爲偏斜(skew)。數據分區通常基於 Key 進行拆分,在考慮數據偏斜的情況,要根據數據庫的特定的分區算法,特別注意 Key 的設計。

根據 Key 的範圍分區爲每個分區指定一塊連續的 Key 範圍,分區 Key 的邊界一般由數據庫自動選擇。好處是範圍掃描非常簡單。但是如果 Key 的設計不合理,會到熱點數據,影響查詢效率。

根據 Key 的散列分區通過一個散列函數對 Key 進行計算後,再進行分區。這樣可以消除偏斜和熱點的風險,但是失去了原有 Key 的範圍查詢的屬性。

有些數據庫,如 Cassandra,採取了折中的策略,使用多個列組成的複合主鍵來聲明。鍵中只有第一列會作爲散列的依據,而其他列則被用作 Cassandra 的 SSTables 中排序數據的連接索引。儘管查詢無法在複合主鍵的第一列中按掃描掃表,但如果第一列已經指定了固定值,則可以對該鍵的其他列執行有效的範圍掃描。組合索引的方法爲一對多關係提供了一個優雅的數據模型。

索引構建

上面我們討論了主鍵的分區策略,實際情況上輔助索引 / 二級索引也是很有必要的,特別是在關係模型中。

輔助索引的構建方式有兩種:本地索引和全局索引

本地索引文檔分區所以,在這種索引方法中,每個分區是完全獨立的,每個分區維護自己的二級索引,僅覆蓋該分區中的文檔。當數據寫入時(添加、刪除、更新),只需要處理分區內數據的索引更新。數據查詢時,則需要將查詢發送到所有的分區,併合並所有返回的結果。

這種查詢分區數據庫的方法有時被稱爲分散 / 聚集(scatter/gather),並且可能會是二級索引上的讀取查詢相當昂貴。即使並行查詢分區,已容易導致尾部延遲放大。MongoDB、Cassandra、ElasticSearch、SolrCloud 都是使用這種文檔分區二級索引。

全局索引關鍵詞分區,這種索引方法跟主鍵分區的方式是一樣的。相對於文檔分區索引,讀取更有效率,不需要分散 / 聚集所有分區,客戶端只需要向包含關鍵詞的分區發出請求。缺點在於寫入速度較慢且較爲複雜,因爲寫入單個文檔可能會影響索引的多個分區。

理想情況下,索引總是最新的。寫入數據庫的每個文檔都會立即反映在索引中。在基於關鍵詞的全局索引中,這需要跨分區的分佈式事務,並不是所有的數據庫都支持。在實踐中,對全局二級索引的更新通常是異步的。

分區再平衡

隨着數據集大小增加、查詢吞吐量的增加,需要更多的機器來處理。這些都需要數據和請求從一個節點移動到另一個節點,這一過程稱爲再平衡(reblancing)。

再平衡通常要滿足以下幾點要求:

平衡策略可以分爲幾種:固定數量的分區、動態數量的分區和按節點比例分區

固定數量的分區創建比節點更多的分區,併爲每個節點分配多個分區。如果一個節點被添加到集羣中,新節點可以從當前每個節點中竊取一些分區,直到分區再次公平分配。ElasticSearch 使用這種方式分區策略。

只有分區在節點間移動,分區的數量不會改變,鍵所對應的分區也不會改變,唯一改變的是分區所在的節點。這種變更不是實時的(網絡上傳輸數據需要時間),傳輸過程中,原有分區仍然會接手讀寫請求。

分區的數量通常在數據庫第一次建立時確定,之後不會改變。每個分區包含了總數據量固定比率的數據,因此每個分區的大小與集羣中的數據總量成比例增長。如果數據集的總大小難以預估,選擇正確的分區數是困難的。分區太大,再平衡和節點故障恢復變得昂貴;分區太小,則會產生太多的開銷。

動態數量的分區對於使用鍵範圍進行分區的數據庫,具有固定邊界的固定數量的分區將非常不方便:如果出現邊界錯誤,則可能會導致某些分區的沒有數據。按鍵範圍進行分區的數據庫通常會動態創建分區。

當分區增長到超過配置的大小時,會被拆分成兩個分區,每個分區約佔一半的數據。動態分區的優點是分區數量適應總數據量,能夠平衡各方面的開銷。HBase 和 MongoDB 採用的就是這種策略。

數據集開始時很小,直到達到第一個分區的分隔點,所有寫入操作都必須由單個節點處理,而其他節點處於空閒狀態。爲了解決這個問題,HBase 和 MongoDB 允許在一個空的數據庫上配置一組初始分區(預分隔,pre-splitting)。在鍵範圍分區的情況下,預分隔需要提前知道鍵時如何分配的。

按照節點比例分區分區數與節點數量成正比,即每個節點具有固定數量的分區。每個分區的大小與數據集大小成比例的增長。當增加節點時,隨機選擇固定數量的現有分區進行拆分,然後佔有這些拆分分區中的每個分區的一半。

請求路由

現在我們已經數據集分割到多個節點上運行的多個分片上,客戶端發起請求時,如何知道連接哪個結點。隨着分區再平衡,分區對節點的分配也發生變化。

不僅限於數據庫,這個問題可以概括爲服務發現(service discovery),通常有以下三種方案:

以上問題的關鍵在於:做出路由決策的組件如何瞭解分區 - 節點之間的分配關係變化?這是一個具有挑戰性的問題,因爲需要所有的參與者達成一致。

很多分佈式系統都依賴於一個獨立的協調服務,比如 ZooKeeper 來跟蹤集羣元數據。

複製

複製意味着在通過網絡連接的多臺機器上保留相同數據的副本,複製數據能帶來以下的好處:

複製的困難之處在於處理複製數據的變更。目前流行有三種變更復制算法:單領導者(single leader),多領導(multi leader)和無領導者(leaderless),幾乎所有的分佈式數據庫都使用這三種方法之一。

單領導者複製過程

同步 or 異步

複製系統的一個重要細節是 複製 是 同步發生 還是 異步發生。同步複製會使得數據寫入時間變長,而異步複製會使得副本之間的數據不一致,客戶端可能會讀取到歷史的數據,並且在主庫故障時有可能會丟失數據。所以複製系統的核心就是如何讓副本保持一致,並且在主庫故障時能夠自動切換。

一致性模型

一致性模型(consistency model)實質上是進程和數據存儲存儲之間的一個約定。即,如果進程同意遵守某些規則,那麼數據存儲將正常運行。正常情況下,一個進程在一個數據項執行讀操作時,它期待該操作返回的是該數據在其最後一次寫操作之後的結果。

在沒有全局時鐘的情況下,精確地定義哪次寫操作時最後一次寫操作是十分困難的。作爲替代的方法,我們需要提供其他的定義,因此產生了一系列的一致性模型。每種模型都有效地限制了在一個數據項上執行一次讀操作所應返回的值。

注意:不將數據庫事務的一致性與其混淆,分佈式副本的一致性指的是單個對象的寫入和讀取。

**以數據爲中心
**

線性一致性

線性一致性也稱爲嚴格一致性(Strict Consistency)或者原子一致性(Atomic Consistency),需要滿足以下兩個條件:

線性一致性的想法是讓一個系統看起來只有一個數據副本,而且所有的操作都是原子性的。應用不用擔心多個副本帶來諸多問題,是一個完美的理想模型,作爲其他模型的參考(最強一致性模型)。

在線性一致性的數據存儲中不存在併發操作:必須有且僅有一條時間線,所有的操作都在這條時間線上,構成一個全序關係。

順序一致性

順序一致性最早出現在 Shared-Memory Multi-Processor System 單機模型中,爲程序員提供了極強的內存可見性保證。順序一致性內存模型有兩大特性:

在時間順序上,C1 發生於 B2 之後。對於線性一致性來說,C1 一定在 B2 之後,但是對於順序一致性 B2 可以發生在 C1 之後。

順序一致性可能會產生不確定的結果。這是因爲在程序的不同運行期間,處理器之間的順序操作的順序可能會有所不同。

對於順序一致性來說,它要找到一個合法的順序執行過程,該執行過程要保留線程 / 進程內部原有的順序

對於線性一致性來說,它也是要找到一個合法的順序執行過程。但是這個順序執行過程,不僅要保留線程 / 進程內部的先後順序,還要保留線程 / 進程之間的操作的先後順序。

線性一致性可以定義爲具有實時約束(real-time constraint)的順序一致性。

個人理解,在分佈式副本的領域中,不太可能找到 除了時序之外,各個進程能夠一致認可的順序。所以在分佈式副本領域參考意義不大,更容易造成疑惑。

因果一致性

相對於線性一致性保證讀寫具有全局順序,而因果一致性只需要保證具有相互依賴的讀寫操作保持相同的順序即可。實際上因果一致性是性能和可用最高的強一致性模型

因果一致性實現的難點在於如何定義和捕獲因果關係,你需要知道哪個操作發生在哪個操作之前(happen before)。但是這種因果關係更多是來自上層應用,底層存儲是無法感知的,所以跟蹤所有的因果關係是不及實際的。

因果關係的操作在時序上一定是有先後,所以通過全序的的序列化或時間戳(邏輯時鐘)來排序操作,這樣所有的操作都有了時間上的因果先後關係。所以線性一致性是所有操作都滿足因果一致性(即使大部分操作沒有依賴關係)。

最終一致性

最終一致性不能算是一致性模型,沒有任何一致性保證,只是說在沒有更新的情況下,副本之間會在一定時間內保持一致。因爲由於網絡延遲的存在,應用任何時候都可能讀取到不一致的數據。可以說是可接受的最弱的一致性模型。

以客戶端爲中心

上面討論的以數據存儲爲視角的一致性,在因果一致性以及更強的一致性模型中,從客戶端而言是不會發生預料之外的讀寫問題的。但是在更弱的一致性模型而言,出現各種讀寫問題。

以客戶端爲中心的一致性爲單一客戶端提供一致性保證,保證該客戶端對數據存儲的訪問的一致性,但是它不爲不同客戶端的併發訪問提供任何一致性保證

以客戶端爲中心的一致性包含了四種模型:

但是真實情況是,由於服務器負載均衡以及服務器故障的存在,會導致客戶端會話會發生轉移,因此基於客戶端訪問的一致性模型是不靠譜的。

共識協議

**Lamport 時間戳

**

我們知道分佈式系統中,各個機器擁有相同的時鐘(全局時鐘)是不太可能的。1978 年 Lamport 在一篇論文中提出了一種邏輯時間戳,來解決分佈式系統中區分事件發生的時序問題。這篇論文是分佈式系統領域被引用最多的論文之一。

Lamport 時間戳就是兩者的簡單結合:時間戳 / 計數器 + 節點 ID,規則如下:

上圖,ABC 節點的所有事件的全序關係如下:

Lamport 時間戳背後的思想是:兩個事件可以建立時序(因果)關係的前提是兩個事件之間是否發生過信息傳遞。因此 Lamport 時間戳只保證因果關係(偏序)的正確性,不保證絕對時序的正確性。

全序廣播

Lamport 時間戳通過消息的傳遞來確定事件的時序關係,引出了全序廣播(在節點間交換消息的協議)。全序廣播需要滿足兩個安全屬性:

全序廣播正是數據庫複製所需要的:如果每個消息都代表一次數據庫寫入,且每個副本都按照相同的順序處理相同的寫入,那麼副本相互保持一致(除了臨時的複製延遲,可以將讀操作也作爲消息,來實現一致讀)。這個原理被稱爲狀態機複製(state machine replication)

因爲數據庫的寫入和讀取操作都是通過消息交互達成一致,依據 Lamport 時間戳,所有的操作是全序的,因此可以實現線性一致性存儲。

Raft 協議

Raft 是一種共識算法,旨在使其易於理解。它在容錯和性能上與 Paxos 等效。不同之處在於它被分解爲相對獨立的子問題,並且乾淨地解決了實際系統所需的所有主要部分,實際將上面的 全序廣播 / 狀態機複製 的工程化。

Raft 協議動畫演示:thesecretlivesofdata.com/raft/

在 Raft 集羣裏,服務器可能會是這三種身份其中一個:領導(leader)、追隨者(follower),或是候選人(candidate)。在正常情況下只會有一個領導,其他都是追隨者。而領導會負責所有外部的請求,如果不是領導的機器收到時,請求會被導到領袖。

Raft 將問題拆成數個子問題分開解決:

詳細的介紹請移步:

Raft 論文: https://Framcloud.atlassian.net/Fwik/download/attachments/6586375/raft.pdf

Raft 中文翻譯: https://github.com/maemual/raft-zh_cn/blob/master/raft-zh_cn.md

TODO

後面有時間分析下 TiDB(分佈式開源 HTAP 數據庫,兼顧事務性和分析性)。可惜 OceanBase 不開源啊。

參考資料

en.wikipedia.org/wiki/Consis…

en.wikipedia.org/wiki/Sequen…

github.com/ept/hermita…

duanple.com/?p=964

jin-yang.github.io/post/raft-c…

juejin.cn/post/684490…

github.com/ngaut/build…

tikv.org/deep-dive/d…

zhuanlan.zhihu.com/p/47299592

pingcap.com/blog-cn/per…

作者:VectorJin

來源:https://juejin.cn/post/6844904080670720008

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nhn3IE69v0y2CVYEljncAQ