天天看點

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

一、簡介

WCDB 是微信團隊開源的一款基于 SQLite 的終端資料庫。自 2017 年 6 月開源以來,它在業界得到了廣泛認可并被大量應用,迄今已經推出了十多個版本。在這個過程中,WCDB 一直保持良好的向後相容性,不斷完善原有接口的細節并添加新功能。

二、挑戰

然而,作為國内乃至全球範圍内使用資料庫最頻繁的 App,微信内部涉及上百種不同業務的資料庫,存儲的消息條數可達百萬乃至千萬級别。這種龐大的資料量和日益豐富的應用場景,給 WCDB 帶來了不斷更新的需求和挑戰,原有的代碼架構逐漸難以應對。

三、重大更新

是以,自 2019 年起,我們決定放棄接口的向後相容性,全力打造一個更加強大的新版 WCDB。經過多次疊代,WCDB 的接口層和核心邏輯層已經得到了全面改進,同時也積累了許多新功能。如今,我們将該迎來重大更新的新版本WCDB進行開源,主要變化及更新包括:

  • • 更豐富的開發語言支援:新增支援了C++,完整支援了Java和Kotlin語言的ORM,覆寫更多終端平台;
  • • 更強大的SQL表達能力:對 Winq 進行了重寫、強化等;
  • • 更安全的資料存儲能力:全新的資料備份方案、修複方案等;
  • • 更靈活的資料擴充能力:資料遷移、資料壓縮等;
  • • 更細緻的性能優化能力:FTS5 優化、可中斷事務等。

變化一:更豐富的開發語言支援

WCDB 1.0 版本支援 Objective-C、Swift、Java 三種開發語言,但是三種語言的 WCDB 除了共用同一個版本的 SQLite 和共用同一套備份修複邏輯,其他代碼都是獨立開發的。随着 WCDB 不斷疊代,WCDB 的很多新能力都是在 ObjC 版本上開發完成和上線驗證,Swift和Java版本基本處于停止疊代的狀态,他們之間的差異也越來越大。在理想的狀态下,不同語言版本的 WCDB 應該擁有同樣的能力,但是如果把 ObjC 版本的新邏輯重新在 Swift 和 Java上實作一遍,不僅工作量大,還容易出錯,需要再次上線驗證,不太現實。

幸運的是,ObjC 版本的 WCDB 的核心邏輯都是用 C++ 實作的,ObjC 隻是用來實作接口層的邏輯。很多支援多種開發語言的庫都是使用 C++ 語言來實作核心邏輯,其他語言隻是用來實作接口層,比如很熱門的用戶端 NoSQL 資料庫元件realmDB就是如此。WCDB 也可以按照這個思路來設計,這樣 ObjC 版本的 WCDB 隻需小幅調整,将核心邏輯完全改用 C++ 來實作,Swift 和 Java 通過橋接方法來接入 C++ 核心邏輯。此外,為了充分支援微信各端不同場景的資料庫開發需求,WCDB還擴充支援了C++ 和 Kotlin,這樣就完整覆寫了現在終端開發的主流語言。

代碼架構

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖1:接口層代碼結構

在這種代碼架構下,不同語言的 WCDB 可以按需內建到同個項目中,有利于節省代碼和減少包大小,還可以避免不同語言接口邏輯的沖突,甚至使用不同語言的接口來使用同一個DB都不用擔心有任何邏輯沖突。

ORM 實作示例

在支援各個語言的過程中,要解決的關鍵問題是為每個語言分别設計 ORM(Object–relational mapping) 機制。有了 ORM,才能使用原生語言的對象來讀寫資料庫。以 C++ 為例,下面是個簡單的對象:

class Sample {
public:
      int id;
      std::string content;
      WCDB_CPP_ORM_DECLARATION(Sample)
};           

在 WCDB 中可以直接使用這個 C++對象 來讀寫資料庫,而且還可以用原生語言來寫表達式:

// INSERT INTO myTable(id, content) VALUES(1, 'text')
database.insertObjects<Sample>(Sample(1, "text"), myTable);
// SELECT id, content FROM myTable WHERE id > 0
auto objects = database.getAllObjects<Sample>(myTable, WCDB_FIELD(Sample::id) > 0);           

上面的用到的WCDB_FIELD(Sample::id),它既可以表示表中 id 這個字段,用來組成各種條件表達式,也可以用來通路Sample的執行個體中的id這個成員變量,進而可以實作将一個C++對象序列化寫到資料庫,或者從資料庫中反序列化讀出來,就像裡面包含了id這個成員變量的Getter和Setter。

這裡讀者可能會好奇,C++ 作為靜态語言,是沒法在運作時擷取到類的成員變量的資料類型和讀寫接口這些中繼資料的,那WCDB_FIELD(Sample::id)又如何生效呢?這恰是 C++ ORM 設計的難點。早期比較成熟的 C++ ORM 方案是用了預編譯的方法,将這些中繼資料通過代碼生成的方式 hardcode 到代碼中。

後來随着 C++ 模版類型推導能力逐漸完善之後,有些方案則是嘗試将這些中繼資料的内容全部記錄到變量的類型中,當要使用這些内容時,則使用模版推導能力從對象的類型中推導出來需要的資訊,非常巧妙。但這樣的弊端就是變量的類型變得十分複雜,而且這種方案都是以模版庫的方式實作,很難疊代,也會帶來代碼膨脹問題。以比較出名的 sqlite_orm 為例,用它來建立上面示例中Sample對應的表,DB 對象的類型就會變得非常複雜,模版膨脹問題可見一斑:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖2:sqlite_orm 示例

用類成員指針實作 C++ ORM

C++ 雖然無法在運作時擷取到類的中繼資料,但是在編譯期是很容易擷取到的,那就是 C++98 之前就有的類成員指針。類成員指針并不指向一個具體的記憶體位置,它指向的是一個類的特定成員,它的值跟這個成員在類的記憶體布局中的位置相關。類成員指針既可以用來讀寫類的成員變量,其類型中也包含了這個成員變量的資料類型和其所在類的類型,下面是個示例:

// 指向 id 成員變量的指針 memberPointer 中包含了 Sample 和 int 兩個類型
int Sample::* memberPointer = &Sample::id;
Sample obj;
// 用類成員指針 寫 成員變量
obj.*memberPointer = 1;
// 用類成員指針 讀 成員變量
int id = obj.*memberPointer;           

類成員指針所擁有的這些資訊都是 ORM 需要的,我們可以使用類成員的指針來實作 ORM。因為類成員指針的類型是非常多樣的,接收類成員指針的函數就必須寫成模版,不同成員指針的組合使用的場景也就更加容易帶來不同的模版執行個體化。為了避免代碼膨脹問題,我們先使用下面的這個方法将類成員指針的類型去掉:

template<typename T, typename O>
void* castMemberPointer(T O::*memberPointer) {
    union {
        T O::*memberp;
        void* voidp;
    };
    memberp = memberPointer;
    return voidp;
}           

無類型的類成員指針的值雖然不是全局唯一的,但是在一個指定類的範圍内是唯一的,它可以作為關聯資料表列名和成員變量的元資訊的 key:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖3:類成員指針作為key

有了這個映射關系之後,可以用類成員指針來擷取到列名,進而我們就可以用類成員指針來表示資料表中的列。

接下來還需要擷取成員變量的資料類型。類型隻是編譯期的資訊,在運作時是不存在的,我們需要将類型轉換成數值,才能在運作時使用。如果是要将任意類型都轉換成數值,這是做不到的,C++ 的資料類型可以有無數種。實際上,存儲在資料庫中的資料類型隻有整型、浮點型、文本、二進制和空值這五種類型,我們隻需要将這五種類型對應到數值。因為類成員指針上已經有成員變量的資料類型,我們可以将這個類型提取出來,然後使用 C++ 模版的 SFINAE 機制,将支援寫入資料庫的類型映射到這些數值上,就完成了類型到數值的轉換:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖4:資料類型轉換

最後還需要生成成員變量的讀寫方法。因為類成員指針可以直接讀寫成員變量,一個直接的想法是使用類成員指針來構造讀寫方法,然後将讀寫方法的函數指針儲存起來。但是這兩個函數指針的類型裡面是包含成員變量的資料類型的,如果保持這個類型,還是會在存儲的各個環節引入模版,是以要去掉這個類型,隻儲存一個無類型的指針。要讀寫的時候,如果直接調用無類型的函數指針,雖然能跳轉到正确的代碼位址,但是編譯器不知道出入參的類型,會導緻傳參出錯,是以調用的時候我們還需要想辦法恢複函數指針的參數類型。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖5:讀寫指針類型轉換

恢複讀寫函數指針的類型需要讀寫對象的類型和成員變量的類型,其中對象本身在讀寫時肯定是要用到的,那麼它的類型可以從上層調用邏輯中通過模版傳遞過來;但是成員變量的類型就無法傳遞了,也無法實時擷取,我們已有的存儲資訊中,隻有資料庫的資料類型的枚舉值。這個枚舉值隻能描述對應的成員變量的資料類型的類别,不能精準還原原來的資料類型。我們的做法是為每個類别的類型指定一個标準類型,比如整型的标準類型是long long,浮點型的标準類型是double,這個标準類型能夠不丢失精度地存儲這個類别裡面所有類型的所有值。這樣我們将标準類型作為Getter和Setter的出入參,在 Getter 和 Setter 内部實作中,再負責将标準類型的資料轉換成具體的類型。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖6:标準類型轉換

變化二:更強大的 SQL 表達能力

上面提到的 CRUD 操作都是用的便捷接口,可以覆寫大部分的 DB 使用場景,但是少部分複雜的DB操作還是要拼寫 SQL,其實上面寫的一些條件表達式其實也算是在拼寫 SQL 的一部分。WCDB 1.0 提供了Winq(WCDB Integrated Query,WCDB內建查詢)來友善資料庫開發者拼寫 SQL 語句。1.0 版本的 Winq 使用 C++ 語言抽象和實作了 SQLite 的 SQL 文法規則,使得開發者可以告别字元串拼接的膠水代碼。通過和接口層的 ORM 結合,使得即便是很複雜的查詢,也可以通過一行代碼完成,并借助 IDE 的代碼提示和編譯檢查的特性,大大提升了開發效率。比如一個SQLite_sequence表的查詢語句,使用 Winq 來編寫可以是這樣:

WCDB::StatementSelect().select({WCDB::ColumnResult(WCTSequence.seq)})
            .from("sqlite_sequence")
            .where(WCTSequence.seq > 1000)
            .orderBy({WCTSequence.seq.order(WCTOrderedAscending)})
            .limit(10)
            .offset(100)           

可以看到,Winq 将 SQL 語句中的Token抽象成C++類,将不同的 Token 的連接配接能力抽象成了C++類的接口,并通過鍊式調用的方式,讓Winq拼接出來的SQL語句讀起來跟實際的SQL語句接近,可讀性好。但随着在微信中的應用推廣,這一版的Winq還有下面幾個明顯的問題:

  1. 1. 每次接口調用之後,是立即 append 對應内容到 SQL 字元串。這就要求接口的調用順序必須嚴格符合 SQL 的文法順序,而且調用之後無法再修改原内容。這樣不符合鍊式調用的使用直覺,比較容易犯錯。
  2. 2. Winq 建立的語句沒有獨立儲存它内部各個Token的配置狀态,隻儲存一個 SQL 字元串。這樣當内部邏輯接收到業務邏輯調用的 Winq 語句時,它面對的隻是SQL 字元串,很難對 Winq 語句做一些文法分析或者修改 Winq 語句,限制了 WCDB 的功能擴充。
  3. 3. Java、Kotlin、Swift這些不能使用 C++ 的語言上也需要使用 Winq。
  4. 4. 不支援表達全部的 SQL 語句,一些少用的複雜語句就隻能手寫 SQL 字元串了。
  5. 5. 一些接收 Token 的接口在使用的時候還不夠簡潔,比如.select()中接收 ORM 的 Property 時需要先構造WCDB::ColumnResult,再顯式轉成數組傳入。

存儲 SQL 中各個Token的狀态

為了解決這些問題,我們完整重寫了 Winq,推出了新版 Winq。新版 Winq 在分為 接口層 和 核心層 兩層,這兩層的對象一一對應。核心層作為基層,提供 SQL 語句中各個 Token 的狀态存儲,并提供将目前 Token 轉成對應 SQL 字元串的能力,還可以校驗目前配置的 Token 狀态是否符合 SQL 的文法規則,防止輸出錯誤的 SQL。接口層對象則是持有對應核心層的對象,提供對核心層對象的高可讀性編輯接口,并且提供核心層對象所轉成 SQL 字元串的緩存的統一管理邏輯,避免多次擷取 SQL 字元串時重複拼接字元串。如下圖所示:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖7:Winq 2.0

将 Winq 橋接到其他語言

有了上面的設計,已經可以滿足 C++ 和 ObjC 兩種語言的 SQL 拼寫能力,但 Java、Kotlin、Swift這三種語言同樣需要 Winq,是不是可以照着 C++ 的樣子同樣實作一份呢?答案是否定的。一方面是因為工作量大,對齊也很麻煩。另一方面,也是最重要的原因,就是如果各個語言都照着實作一遍,那拼寫 Winq 形成的語句的記憶體結構是很難傳到 WCDB 核心邏輯層的,隻能傳一個字元串過來,這樣就讓 Winq 的作用大打折扣了。本次 Winq 重寫的一個重要目的是為了獨立儲存它内部各個 Token 的配置狀态,這樣就很容易對 Winq 語句做一些文法分析或者修改 Winq 語句,這些能力如何發揮作用讀者在後續的章節将會看到。

為了在 WCDB 在核心邏輯層能夠面對統一的 Winq 語句記憶體結構,也即是統一的核心層的對象,我們采用橋接的方式把 Winq 中每個 Token 對象及其接口都橋接到了 Java、Kotlin、Swift這三種語言(其實 Kotlin 直接調用了Java 的實作),這樣每次拼寫 Winq 語句的時候,其實都是操作 Winq 的核心層對象,這樣就能在核心邏輯層産生一樣的記憶體結構。以 StatementSelect 對象為例,整體結構如下:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖8:StatementSelect橋接示例

使用新版 Winq,上面的查詢語句可以寫成下面這樣,傳參可以得到簡化,調換鍊式調用的執行順序也可以輸出正确的 SQL 語句,更容易使用,符合直覺:

WCDB::StatementSelect().select(WCTSequence.seq)
        .from("sqlite_sequence")
        .offset(100)
        .limit(10)
        .order(WCTSequence.seq)
        .where(WCTSequence.seq > 1000)           

特别是 SQL 語句根據不同的條件有不同的組裝結果這種複雜場景,使用字元串拼接壓力就會更大一些,需要處理好上下的銜接,而使用 Winq 就沒有這些麻煩,Winq 在上層隻是接口的調用,底下會自動處理好 SQL 的銜接,靈活多了,下面是 Java 業務場景中的一個示例:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖9:winq按條件組裝示例

Winq 支援全部 SQL 文法

這次新版的 Winq 在各個語言完全封裝了 SQLite 支援的全部 26 種 SQL 語句,以及這些語句中涉及到的全部 23 種 Token,這樣開發者就可以在 WCDB 支援的五種語言中使用原生文法來拼寫任意的 SQL 語句,可以完全告别拼寫 SQL 字元串帶來的無輸入提示、容易出錯等問題。而且 Winq 中的字元串參數會全部加引号處理,進而能夠完全避免 SQL 注入問題,提高安全性。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖10:winq全部檔案

下面是一個 Java 中使用新版 Winq 來組裝複雜 SQL 的例子:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖11:winq複雜SQL示例

可以看到,即便是複雜 SQL 語句,Winq 語句的書寫順序也跟 SQL 基本一緻,了解 SQL 的人,也可以無門檻讀懂 Winq,而且也不會帶來多少代碼膨脹。

變化三:更安全的資料存儲能力

前面兩節讓大家對如何使用 WCDB 有了個整體感受,這部分的設計目标是讓大家能夠更便捷得存儲資料,而如何更安全地存儲資料,是資料庫設計更重要的目标,這一直是我們不斷思考的問題,也是我們需要擴充強化 SQLite 的最初動機。因為聊天記錄作為使用者在微信上産生的最重要數字資訊,隻存儲在使用者的終端裝置上。如果出現資料庫損壞,聊天記錄将會永久性丢失,這是絕大部分使用者無法接受的。為了提高資料安全性,新版 WCDB 有了下面兩個新設計。

1、新資料備份和修複方案

WCDB 1.0 中我們推出了一種資料庫備份和修複方案,這裡有詳細介紹,它的整體邏輯是這樣的:

SQLite 資料庫是以頁為機關的雙層的 BTree 的結構,上層是 SQLite 的 master 表,下層是每個使用者定義的表,其葉子頁就是真正的資料所在的地方。當資料庫損壞發生在某一中間節點時,它下面的所有支路的資料都将因為找不到而丢失。我們可以備份下層表的表名到根結點頁碼的映射,那麼可以解決最嚴重的問題,即上層表損壞。當下層表損壞時,也隻會丢失單個表。

WCDB 1.0 的備份和修複方案解決了當時資料庫損壞後資料就全部丢失的燃眉之急,平均修複率有 70~80%。但是資料庫損壞通常發生在磁盤損壞的時候,一般都是一大片資料壞了,是以經常修回來也依然是一片狼藉。是以新版 WCDB 就幹脆一點,除了備份 master 表,還增加備份普通表的表名到它葉子頁頁号和crc校驗值的映射,這樣就能一步到位,修複的時候根據頁号就可以直接找到普通表的資料,校驗 crc 值沒變,就可以确認資料沒有損壞或者變更,進而可以将未損壞的資料完整恢複到新資料庫。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖12:master表與使用者表

這個方案有兩個挑戰,性能和時效性。

性能問題

舊方案隻需要下層BTree的根結點頁碼,這個隻需要周遊 master 表就可以了,master 表很小,甚至大部分時候它是在記憶體裡的,是以很快。但是要擷取每個表對應的葉子結點,幾乎是需要周遊整個資料庫,這個耗時是很高的。除了 IO,大部分耗時是在申請記憶體消耗的。這類問題是解決的比較多了,基本立刻能想到用 mmap 來解決。但是又會進一步遇到三個問題:

  1. 1. 分段式、按需加載的 mmap。因為單個資料庫檔案可能會比較大,單次将它 map 到虛拟記憶體,可能會因為虛拟位址空間不夠,導緻失敗。是以這裡按需按記憶體的頁大小為機關,根據每次申請的頁,在其前後 map 共 1MB 的記憶體。這樣一方面小的虛拟記憶體塊,因為位址空間不足 map 失敗的可能性會降低很多,另一方面,map 1MB 一般會遠比裝置的預設記憶體頁的大很多,這樣可以減少 map 的次數。
  2. 2. LRU Cache + 引用計數。map 的虛拟記憶體雖然可以在記憶體不足的時候換頁出去,但是它會擠占虛拟位址空間。64 位機的虛拟位址空間很多,但是單個程序的可用空間不多。如果 WCDB 這邊消耗太多,會導緻其他地方位址空間不足,申請記憶體失敗進而 crash。是以這裡加了一重 LRU,限制使用位址空間上限。同時對 mmap 的記憶體引用計數,由最後持有者來釋放,以保證 map 記憶體指針可用。後續 map 如果直接命中已經 map 的記憶體,就不需要再次 mmap 了。
  3. 3. 事務備份。讓備份操作在 SQLite 的讀事務備份中進行。在讀備份進行過程中,資料庫不能進行 checkpoint,寫入操作隻會 append 到 WAL,換句話說,資料庫檔案本身不會發生改變。通常 IO 操作的時候,為了避免檔案上資料被修改,通常會将擷取到的資料拷貝到一塊新申請的記憶體,再進行操作。但是這裡保證了資料不會被修改的,那麼搭配上 LRU 和引用計數,就可以直接從 mmap 的虛拟記憶體裡解析資料,不用申請記憶體、拷貝資料。
  4. 圖13:MMap記憶體LRU Cache

這個方案實質提高的性能其實就兩個,一個是 mmap 從虛拟記憶體讀資料,一個是不需要申請記憶體。上面三個優化都是為了讓這兩者可行而做。同時這個備份資料屬于讀操作,可以在子線程進行,而且不影響其他讀寫操作。

這個備份方案上線之後,iPhone裝置上 500M 的DB平均每次備份耗時是 3秒,在絕大部分場景可以接受了,但是少數使用者會有達到 1G 以上的DB,這些使用者的資料備份操作還是很容易引起手機發燙問題,特别是在一些直播或者是視訊通話等高性能消耗的場景,如果恰好又執行資料備份,那微信的使用體驗就會受到明顯影響。

這個版本的備份邏輯算是一個整量備份的方式,還是需要把資料庫的所有内容都讀一遍,而且要給所有葉子頁算 crc 校驗值,IO 量和計算量還是比較高。為了解決這兩個問題,我們再次對備份邏輯做了更深度的優化,引入了增量備份能力。

WCDB的資料庫是開啟了WAL模式的,而且使用自己設計的異步 Checkpoint 模式,每次有新内容寫入資料庫的時候,是先将新内容 append 到 WAL 檔案的末尾,然後在異步 Checkpoint 的時候,再将 WAL 檔案中的内容,在回寫到主 DB。在這個過程中,每個有更新的頁,都會讀到記憶體,在這裡可以用非常低的成本,擷取到這些頁的頁号,和葉子頁的 crc 校驗值。先把這些資訊都存到一個增量備份檔案中。等到備份觸發的時候,再把這些新内容更新整合到主備份檔案,看起來就達成了增量備份葉子頁的效果。整體流程如下:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖14:增量備份整體流程

但這裡我們隻能知道有更新的頁号,不知道這些頁是屬于哪個表。因為 SQLite 的檔案内容中,隻會儲存BTree父節點到子節點的關系,并沒有儲存子節點到父節點的關系,是以我們要知道那些更新的頁屬于哪個表,隻能周遊這些表的根頁和中間頁來擷取。不過這個周遊不用讀取葉子頁。而根據統計,根頁和中間頁隻占了所有頁的1%-2% ,是以 IO 量和計算量比之前大幅減少了。同時周遊的時候,優先周遊上次備份時有更新的表,隻要找到所有有更新的頁,就可以停止周遊了,一些不常更新的表就很少周遊到,也能在一定程度上優化性能。

這個方案上線之後,500M 的DB備份平均耗時隻需 63ms,即便是大到 10G 的DB,備份耗時也不到 0.9秒,可以說已經充分滿足任意場景的資料備份需求。

時效性問題

舊方案備份的下層樹的根結點頁碼隻會在建表删表時改變,而新方案的葉子結點頁碼在任意一次寫操作都有可能變更。而且現在增量備份時,資料庫的新内容要經過 Checkpoint 和備份兩次異步操作,先傳到增量備份檔案之後才能傳遞到主備份檔案中,而且是以增量的形式傳遞,是很容易出錯的。這些操作都可能因為使用者殺掉微信或者裝置斷電,而中途斷掉,沒有原子性保證。備份過期或備份版本上下銜接失敗,可能會導緻資料錯亂或者修複率很低。是以這裡引入了資料庫的 savepoint 的概念。

Savepoint由 WAL 檔案的 salt 值和 nbackfill 值的組合來表示。Salt 值儲存在 WAL 檔案的檔案頭,每次 WAL 檔案完整回寫到主 DB 檔案之後,都會修改這個 salt 值來重置 WAL 檔案的内容;而 nbackfill 則表示 WAL 檔案中還沒回寫到主 DB 檔案的内容的偏移。每次 checkpoint 之後,這兩個值的組合都會更新,而且恰好具有單調性,可以作為資料庫内容的版本号,儲存到增量備份檔案和主備份檔案中。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖15:Savepoint

這樣就可以在每次更新備份檔案的時候,都要對齊彼此的 Savepoint 值,如果 Savepoint 對不上,就說明增量備份的過程中,有部分資訊傳遞丢失了,這時候就執行一次全量備份來重置所有savepoint。現網實踐下來,版本不對齊的發生機率隻有千分之一,是以少數情況下執行的全量備份不會給整體性能帶來多少影響。

新方案的性能資料

新方案在微信中上線之後,資料庫損壞時的資料修複率提升到了 99% 以上,備份内容大小約為資料庫大小的千分之一。這個方案可以極大得降低磁盤損壞給使用者帶來的資料損失。

2、防止外部邏輯寫壞資料庫

使用備份和修複來保護資料屬于比較被動的方法,資料出錯了才補救,修複率無法做到100%,還是不夠安全。在磁盤損壞這種低機率發生的場景,備份和修複方法還能應對,但是在外部邏輯出現Bug導緻大規模寫壞資料庫的場景,就難以應對了。我們需要一種更主動的方法來防止資料庫被寫壞,防患于未然。

外部邏輯寫壞資料庫的情況會有兩種,一種是誤用了資料庫的路徑或者誤删了資料庫,這個很難出現,要保護也是通過hook系統調用的方式來做,無法內建在WCDB内部;另一種是誤用了資料庫的檔案句柄,這種相對常見,微信就遇到了好幾次這種問題,要重點處理。

要防止檔案句柄被誤用時寫壞資料庫,一個簡單的想法是盡量打開資料庫檔案時都是隻讀打開,這樣外部邏輯就無法用這個句柄來更改資料庫了。對于大部分資料庫元件來講,要實作這點,還是挺複雜。打開句柄時要能夠判斷下這個操作會不會修改資料庫,隻讀打開之後還要遇到更改資料庫的操作時,又要重新打開資料庫檔案句柄。

而 WCDB 的 WAL 模式是采用獨立線程異步執行 checkpoint 的,在這種配置下,業務邏輯即便是要寫入資料到資料庫,也不需要修改到主 DB 檔案,隻需要修改 WAL 檔案,隻有到 checkpoint 時才需要修改主 DB 檔案。是以 WCDB 可以在業務邏輯讀寫資料庫時全部隻讀打開主 DB 檔案,隻有在 checkpoint 時才可寫打開主DB檔案。這樣就能最大限度地減少主 DB 檔案的可寫句柄的存活時間,防止外部邏輯誤寫。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖16:隻讀打開DB檔案

變化四:更靈活的資料擴充能力

随着使用者資料的積累和功能的複雜化,早期資料庫表設計會越來越難以滿足需求,微信在疊代的過程中也遇到了很多這類問題:

  • • 早期功能開發的時候為了友善,将多種沒有直接關聯的表格存放到同一個資料庫中存儲。因為 SQLite 不支援并行寫入,這樣也就限制了不同表格的并行更新性能,在資料積累多了和調用頻繁了之後,容易造成性能瓶頸;同時,因為資料庫是會損壞的,讀寫越頻繁越容易損壞,把資料都放到一個資料庫會大幅增加資料損壞和丢失的風險。
  • • 很多業務的表一般都會有一兩個字段用來存儲 XML、Json、PB 之類的序列化資料,這些資料容易随着業務發展,新增屬性越來越多,資料越來越長,資料庫也越來越大,我們的業務場景裡面有些xml長度都達到了 10k 以上。這不僅不會導緻空間占用問題,還會影響讀寫性能,而且這個問題發現的時候都是在已經積累了相當量資料之後的,存量資料已經難以處理了。
  • • 随着功能的擴充,不僅需要在 XML 或者 Json 字段中加内容,還可能需要對原有的資料表添加新列,如果在舊表沒有添加新字段之前就對新字段進行讀寫,就會出現讀寫錯誤。

針對這兩類場景,WCDB 給出了業界首創的解決方法,分别是資料遷移能力、資料壓縮能力和自動添加新列能力。

1、資料遷移能力

iOS微信早期在業務邏輯層面做過兩次資料遷移,一次是收消息操作指令資料遷移,因為資料量較小,可以阻塞式一步遷移到位之後再使用遷移後的資料;另一次是聯系人資料遷移,因為資料量較大,需要采用非阻塞式遷移的方案。非阻塞式遷移過程中,資料可能處于三種狀态,未遷移狀态隻有舊表,遷移完成後隻有新表,而在遷移中則兩張表都有,開發者需要對所有業務涉及的代碼都做這三種狀态的區分,并且在遷移中合并舊表和新表的資料。這部分代碼并不難,但是冗長、而且和業務耦合嚴重,比較難開發和維護,更尴尬的是,很難找到一個适合的删除相容代碼的時間,相容代碼可能需要一直存在,很影響後續的疊代。

為了解決這個問題,WCDB 就提出了一個概念,由 WCDB 來解決相容問題,讓開發者可以 以遷移已經完成為假定前提 進行開發。同時因為是架構層代碼,天然就是 code once, run everywhere,是以開發也不需要花費時間在灰階上。

WCDB 的資料遷移方案是這樣的,當資料庫操作的請求過來時,會先對其使用的資料庫句柄進行遷移配置,如果是跨 db 的遷移,會把另一個 db attach 到目前句柄,以實作跨 db 的 SQL。然後檢測舊表是否存在,如果不存在則說明遷移已經完成,直接執行 SQL。如果存在則建立一個 temp view,用作後續的相容。然後 WCDB 會預處理資料庫的操作請求,再進行真正的執行。這個預處理是類似于 hook 的邏輯,WCDB 會攔截開發者需要執行的 SQL,然後進行一些修改和處理,以給開發者提供一個遷移已經完成的假象。這裡主要針對增删查改中的操作進行處理。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖17:遷移流程

SQL 語句預處理方法

接下來把常用的 CRUD 語句的預處理原理做個介紹,首先看 SELECT 語句。假定對于新舊兩個表,oldTable 和 newTable。由于這裡開發者是假定遷移已經完成的,是以他進行的操作隻會是新表的查詢。那麼 WCDB 會預處理,将操作中的新表名替換為 unionView。這個 unionView 就是在遷移配置中建立的,它所對應的内容就是兩個表合并的結果。這樣開發者隻查詢新表,WCDB 就會将新舊表的合并後的結果傳回給他。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖18:預處理SELECT

對于 UPDATE、DELETE 操作,WCDB 也讓對新表的操作,同時對新舊表都生效。不過這裡稍有變化。由于 SQLite 一次隻能 update 或者 delete 一個表的資料,是以這裡的做法是,update 新表,然後将 sql 中的表名改為舊表,再 update 一次,并通過 事務 確定這個操作的原子性

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖19:預處理UPDATE

對于INSERT插入操作,則情況複雜一些了,有下面這些情況需要考慮:

  1. 1. SQLite 有一個隐藏的字段 行号 rowid,一般而言開發者不會手動進行插入,但是它可以是自增長的,如果在新舊表中 rowid 的不同,就可能導緻行為不符合遷移完成的情況。
  2. 2. 限制,SQLite 建表的時候可以使用一些比如唯一限制、主鍵限制,那麼插入的時候就可能發生:在新表插入成功,但是實際這個資料在舊表有相同主鍵之類的問題。
  3. 3. 備援,當資料插入到新表時,舊表可能已經存在相同的資料了。如果不删掉舊表的資料,那就會出現備援,導緻新的問題。比如 update 和 delete 的實際數量不一緻,或者 select 的結果出現備援。

為了解決上面這些問題,首先資料需要先在舊表插入一次,這裡解決了限制的問題。如果因為和舊資料存在沖突,這裡就會失敗并且退出了。然後儲存在舊表産生的 rowid,并将舊表的資料,連同 rowid 一起插入到新表。由于 rowid 是從舊表産生的,是以它總是按照舊表的方式自增。然後用 rowid 将剛才在舊表插入的資料删掉,同時也解決了資料備援的問題。最後進行送出。同時在性能上,由于這裡都是在一個 savepoint 之内進行的,送出時對于舊表的插入和删除互相抵消,最終隻有新表的插入操作寫入到檔案中,與原來期望的一樣,都是隻有一次插入操作,是以性能上也幾乎沒有影響。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖20:預處理INSERT

使用新版 Winq 進行預處理

通過預處理 SQL,将開發者執行的 SQL 替換為了相容新舊表的 SQL 來解決了這個問題,達到了給外層遷移完成的假象的效果。不過這個方案其實是知易行難的,這幾句 SQL 并不難,最大難點是修改 SQL。SQL 是具有一定複雜度的。在上述方案中,首先是區分 SQL 屬于 SELECT、INSERT、UPDATE、DELETE 的那種。對于稍有複雜度的 SQL,并不能通過字元串比對或者正則等簡單的方式來識别。一般的做法是和 SQLite 一樣的邏輯,開發一個 SQL 解析器将他們解析成虛拟機的操作碼,然後修改,再還原為修改後的 SQL。但是這種方式不僅複雜度很高,而且性能也不能保證。

WCDB 執行的所有 SQL 都是使用 Winq 來表達的,而新版 Winq 儲存了 SQL 所有文法的結構化資料,我們很容易就可以對 Winq 語句做文法分析,精準修改其中的各個部分,以達到修改所執行的 SQL 語句的效果。是以說這個讓開發者和使用者都無感覺的資料庫遷移方案,市面上是隻有 WCDB 做了,也隻有 WCDB 做得到。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖21:Winq文法分析

執行真正的資料遷移

有了預處理之後,開發者可以使用新表進行開發,剩下就是執行真正資料遷移。當開發者不太關心資料遷移的節奏時,可以直接使用 WCDB 自動遷移能力,WCDB 會每隔 2 秒花 10 毫秒執行一次資料遷移,直到資料遷移完整。如果要加快遷移速度,WCDB 也提供了手動執行遷移的接口。

資料遷移期間的讀寫性能

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖22:遷移資料庫性能

性能上,基本上無感。不管是否使用遷移,性能都不會有大的差别。Insert 的時候因為同一個 Savepoint 内,插入和删除抵消,送出後隻會有一個插入。而 update/delete/select 的操作,由于方案中資料不備援的設計,是以他們在遷移前中後操作的資料量都是一緻的,是以沒有性能損耗。同時,在遷移完成後,資料庫就退回了無遷移原來的邏輯,行為上就真正是一樣了,是以也不存在删除遺留代碼的問題。

更泛化的遷移能力

上面的介紹中新表和舊表的表配置一樣,而且都是有 rowid 的表,其實 WCDB 的遷移能力擴充了以下更泛化的能力:

  • • 支援新表和舊表的配置不一樣,隻要求新表的字段是舊表字段的子集,不要求兩者都有相同的限制和索引(當然新舊表的限制不能在資料上有沖突),不要求有新舊表都有rowid或者都沒有 rowid,不要求都有主鍵。
  • • 支援新舊資料庫的加密配置不一樣,這樣可以實作将未加密的資料庫加密,或者把加密的資料庫重加密。沒有遷移能力,更改資料庫的加密方式都需要重寫一次資料庫,是個非常重的操作。
  • • 支援給遷移的表配置一個 SQL 表達式來篩選遷移部分資料,使用這個特性可以實作把一張表的資料拆分到多張表的效果,或者是清理備援資料。

這些能力限于篇幅關系,就不展開介紹了,有興趣的開發者可以自己嘗試一下不同的可能性。

2、資料壓縮能力

要解決資料庫中 XML、Json、PB等序列化資料過長的問題,一個直接的方法是把這些資料都壓縮一下再寫入資料庫。一般來講,開發者要做資料壓縮,首先是是要選擇一個合适的壓縮算法,然後需要在資料讀寫的各個環節引入加解壓邏輯,要對壓縮的資料做好标記,然後要想辦法處理存量資料,要做極緻性能優化的話還要想辦法緩存加解壓過程的各種記憶體狀态。這些事情處理起來都是不小的工作量,而 WCDB 提出的資料壓縮能力可以幫助開發者一步到位解決這些麻煩,隻需要一個簡單的無侵入配置就好。

在壓縮算法方面,肯定是要選擇無損壓縮算法。早期的無損壓縮算法主要分為哈夫曼編碼和算術編碼兩大類。哈夫曼編碼相信大家都非常熟悉,它通過将高機率出現的字元編碼為更短的碼點來實作壓縮。這類算法的優勢在于編碼速度快,但隻有當各個字元的出現機率都是 2 的負整數次幂時,哈夫曼編碼的壓縮率才能達到香農極限,其他情況下都無法達到,是以壓縮率較低。

與之相對的是算術編碼,它根據整個字元串出現的機率,将整個字元串轉換為一個介于 0 到 1 之間的小數。由于這個小數能精确表示字元串的出現機率,是以算術編碼的壓縮率能夠逼近香農極限。然而,由于編解碼過程涉及大量乘除法,其性能相較于哈夫曼編碼較差。

ANS+FSE編碼是 2014 年釋出的一種新算法,它将整個字元串編碼成一個大于 1 的整數,這個整數與字元串的出現機率精确相關,是以這個算法的壓縮率也能夠逼近香農極限。同時,由于其計算過程僅涉及加法、移位和掩碼計算,性能上更接近哈夫曼編碼,是以它目前被認為是壓縮率和性能綜合最優的算法。這個算法的最佳實作便是衆所周知的 Zstd。

然而,Zstd 的普通壓縮模式僅能解決單個 XML 或 Json 内部的備援度。由于不同的 XML 或 Json 具有相似的标簽,不斷存儲這些标簽也會産生很多備援。為了解決這個問題,Zstd 的字典壓縮模式可以有效消除不同資料之間的相似部分,顯著提高壓縮率并提升性能。是以,Zstd 字典壓縮模式被認為是目前壓縮序列化資料的最優解。預計在未來,也不太可能出現能明顯提升壓縮率的壓縮算法。是以,WCDB 主要采用 Zstd 字典壓縮算法來進行資料壓縮。

确定了壓縮算法之後,我們看下資料壓縮的整體架構:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖23:資料壓縮整體流程

外部邏輯寫入的新資料的時候,在 WCDB 的内部會把資料壓縮了之後,再寫入檔案;讀取資料的時候,對于已經壓縮的資料,WCDB 也是解壓後再給到外部。同時,WCDB 也會在子線程處理存量資料,把未壓縮的資料讀取出來,壓縮後再更新回去。這樣外部隻需要配置資料庫的哪個表的哪個字段需要壓縮,CRUD的時候,都可以假定資料都是沒壓縮的來操作資料,不需要關注資料壓縮的實作細節和内部狀态,整個加解壓過程可以做到外部無感覺和無侵入。這樣資料壓縮就可以很友善在不同業務場景和不同平台擴充。

外部邏輯 CRUD 的時候,為了隐藏資料加解壓的細節,需要在WCDB的内部,對要執行的 SQL 做一些處理和轉換。首先,如果一個表有字段配置了壓縮字段的話,底下就會給這個表的壓縮字段逐個添加一個對應的,存儲壓縮狀态的字段,狀态字段存儲了是否壓縮的狀态,以及壓縮所用的算法,然後還要預處理 SQL,把SQL 中對壓縮字段的讀寫,轉換成對壓縮字段和壓縮狀态字段的合并讀寫,這樣就能把加解壓邏輯引入進來。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖24:資料壓縮CRUD相容方法

這裡預處理的原理跟上一章資料遷移中的類似,也是INSERT、UPDATE、SELECT 和 DELETE 這些語句的預處理是都不相同的,接下來逐個介紹。

SQL語句預處理方法

首先是看INSERT語句,如果語句比較簡單,它寫入的未壓縮資料,是很容易在 WCDB 的内部截取到的,那就将這個資料壓縮了之後,再連同壓縮狀态值一起寫入。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖25:壓縮預處理INSERT1

這裡WCDB_CT_content這個字段,就是content字段的壓縮狀态字段,它加了個字首。業務實踐中絕大部分插入語句都是這種簡單形式,都可以按照這個方法處理,性能影響上隻增加了壓縮資料的消耗。當然,偶爾也有一些複雜的insert語句,需要更複雜的處理。比如這個帶有沖突更新操作的 INSERT 語句,或者一些插入的值是從一個 SELECT 語句中讀取出來的 INSERT 語句。這些情況很難判斷它要寫入的資料的具體值,也就無法直接對它進行壓縮。如果逐個 case 單獨處理就太複雜了,WCDB 采取一個統一的方法來處理這些複雜 INSERT 語句:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖26:壓縮預處理INSERT2

先讓 INSERT 語句直接執行,進而可以擷取到新插入的資料的 rowid ,然後根據這個rowid,把新插入的未壓縮内容讀出來,壓縮了,再更新到表中。這樣執行的語句雖然多了,但是因為都在一個事務内,資料都還在記憶體中,是以并不會增加 IO 量,對性能的影響不會太明顯。

對于 UPDATE 語句,也是類似的。如果語句結構簡單,更新寫入的未壓縮資料也是能擷取到的,就跟 INSERT 語句一樣,就将這個資料壓縮了之後,再連同壓縮狀态值一起更新進去。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖27:壓縮預處理Update1

絕大部分 UPDATE 語句都可以按照這種方式來處理,當然也有一些複雜情況需要另外讨論。比如下面這個 UPDATE 語句中更新的值,它是從一個 SELECT 語句中讀取出來的,這樣就無法簡單擷取到要更新的具體值,這種少見的場景,也是用統一方法來處理:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖28:壓縮預處理Update2

先讀取出符合 UPDATE 條件的行的 rowid,然後用 rowid 逐行更新,再把更新的資料讀出來,壓縮完再寫進去。這樣執行語句看上去多了很多,但是因為都在一個事務内,每條更新的資料都還在記憶體中,是以也不會增加 IO 量,對性能的影響也是有限。

對于 SELECT 語句或者 DELETE 語句,則相對簡單很多。隻需要定義一個解壓函數,它接收壓縮字段和壓縮狀态字段來做解壓,比如下面示例中的decompress函數,然後再把 SELECT/DELETE 語句中用到壓縮字段的地方,全部替換成解壓函數,這樣就能把資料解壓之後再使用:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖29:壓縮預處理SELECT

壓縮存量資料

相容了 CRUD 之後,WCDB 隻需要把存量資料慢慢壓縮完就可以了。處理存量資料的大概過程,是先整行得讀出一批需要壓縮的資料,對它們進行壓縮,并緩存在記憶體中,然後把這些行全部删除之後,再逐行把壓縮過的資料重新插入進去。這裡之是以采用删掉再重新插入的方式,是為了觸發sqlite重新排版這些行的存儲位置,讓存儲布局更加緊緻。如果是壓縮後直接更新回原來的位置,那行與行之間的間隔還是會比較松散,壓縮出來的空間也無法得到充分利用。

但這樣也就要求,整批資料必須要完整得在一個事務中處理才行,不能在中途送出,否則就會有資料丢失了。為了能夠更好得重新排版存儲空間,這裡每批處理 100 行資料,整個事務的耗時可能會達到 100毫秒 級别,容易卡住UI線程的寫入邏輯,造成 UI 卡頓。好在 WCDB 有完整的 SQLite 鎖監控機制,可以很友善監控到是否有外部邏輯被目前線程的操作阻塞,這樣就可以每次執行一小段操作就檢測一下,如果有外部邏輯阻塞了,就可以先復原事務,下次再重做。這樣也就能夠避免給外部邏輯帶來性能影響。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖30:異步壓縮流程

性能表現

WCDB 的資料壓縮能力現在已經應用來壓縮微信中的公衆号消息,XML 字段的壓縮率高達 89%,相當高了。

在讀寫性能方面,如果是順序讀寫,性能大概會降低 3%~4%,主要是資料加解壓帶來性能消耗。因為是順序讀寫,IO 量減少帶來的性能增量不明顯。如果是随機讀寫,性能就有提升了,随機寫的性能有輕微提升,這個主要是随機寫在 WAL 模式下,都是在 WAL 檔案末尾 append,做不到真正的随機,是以性能提升還是不明顯。而随機讀就能做到真正随機 IO 了,是以IO量減少帶來的性能增量非常明顯,随機讀性能提升達到 30%以上,還是很可觀的。

是以說,資料壓縮在讀寫性能方面也是一個正向的優化效果,再加上可以大幅減少資料占用空間,資料壓縮是值得推廣到各個業務場景的,配置簡單,無侵入,而且各方面有利無害。

更多擴充壓縮能力

為了應對微信中多樣的需求場景,以及複雜的資料環境,我們還給資料壓縮擴充很多能力:

  • • 支援多種壓縮方式,其中就包括 Zstd 的預設壓縮方式、單字典壓縮方式和多字典壓縮方式,多字典壓縮可以根據表中某個字段的值來采用不同的壓縮字典,主要用于多種異構XML/JSON/PB等存儲到同一個字段的場景。
  • • 支援壓縮多字段,一個正在壓縮的表随時可以再添加新的壓縮字段,滿足擴充性的需求。
  • • 支援資料壓縮和資料遷移同時獨立進行,開發者可以給一個正在遷移的表同時配置上資料壓縮,這樣資料在遷移時會壓縮之後再寫入新表,壓縮和遷移可以各自獨立開始,獨立結束,互不幹擾。

這些能力的實作細節因為篇幅的關系就不展開介紹了,讀者如果有興趣,可以自己嘗試一下。

3、自動補全新列能力

業務邏輯在開發疊代的過程中可能會給原有的表格添加新列,SQLite 是支援給已有的表格添加新列的,WCDB 也會在調用 createTable 的時候自動添加 ORM 類中新配置的列,但是在我們實踐過程中這類錯誤還是很常見。一個原因是可能是開發同學的疏漏,必須要在使用表格之前先主動調用添加新列的邏輯,依賴開發同學的自覺,在多人協作開發時更容易疏漏;另一個原因也可能是确實找不到合适的時機添加新列,比如很多個表對應統一個 ORM 類的場景。如果要對這些表添加一個新列,是找不到一個統一的處理時機的,因為重度使用者可能有幾千個這樣的表,如果一起處理的話,會很耗時,容易造成卡頓;如果每次讀寫這些表時都判斷一下是否需要添加新列,又會明顯降低性能。

一個表格的所有列都是在其對應的 ORM 類中配置的。在理想的情況下,開發者在 ORM 類中配置了新列之後,就應該讓這個配置可以視為立即生效,開發者無需關心添加新列的時機。為了達到這個效果,WCDB 添加了自動補全新列的能力,其核心的思想是這樣,當讀寫資料庫的時候如果報錯有未識别的列,則立即檢查讀寫的表格對應的 ORM 類是否有新配置的列跟這個未識别的列同名,如果存在的話,就将新配置的列添加到這個表格,再重試出錯的邏輯。采用這種出錯再檢查的方式,可以将檢查新列的邏輯的調用時機降低到最少,又能全面處理新列沒及時添加資料庫時造成的問題。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖31:自動補全新列流程

自動補全新列的能力在性能影響和解決問題完整程度上看都比較理想,但實作起來也比較有難度。主要要解決兩個問題,一個是如何在執行出錯時擷取到這個表格對應的 ORM 類,一個是如何避免将錯誤的列添加到表格中。

對于第一個問題,因為要使用 ORM 類配置的列時,都是從這個類的内部資訊中去擷取這個列配置的列名,這樣才能用列名構造一個 Column 對象用于 Winq 中組裝語句,比如上文例子中用到的 WCTSequence.seq就是調用WCTSequence 這個類的方法來擷取 seq 屬性配置的列名來構造一個 Column對象。是以我們可以在使用這種途徑構造Column時,将整個 ORM 類的資料庫配置資訊一并傳入,并儲存在Column中,這樣就可以在 Winq 語句中擷取到其中所用到的列所在的 ORM 類的全部配置資訊。因為 ORM 資訊是儲存在堆上的全局量,是以這個改動實際上隻是多傳遞和儲存一個指針,并不會給 Winq 的使用帶來性能影響。

實作了這些之後還不夠,我們實際需要知道的是 Winq 語句中涉及到的表格對應的 ORM 資訊,而不是列的。這裡我們采用了舍棄部分場景的方法,隻處理讀寫單個表格的場景,缺失的列在 Winq 語句中對應兩個不同的 ORM 類也放棄處理,在一個 SQL 語句中操作多個表格或者使用多個 ORM 類的情況在實際應用中還是極少見。

對于第二個問題,主要存在下面兩種情況需要解決:

  1. 1. 防止 SQLite 誤報未識别列。比如SELECT city FROM China WHERE city MATCH '廣東: 廣州'會報錯no such column: 廣東,但實際并不存在這一列,隻是 fts 的搜尋文法誤把冒号前面這部分識别為列名。這種情況可以通過提取報錯資訊中的列名去比對 Winq 語句中的列名來解決。
  2. 2. 防止開發者用錯 ORM 類時把這個類配置的列都誤添加進來。開發者在編寫 Winq 語句時,即便是有輸入提示,編寫錯誤的情況還是無法完全避免。這種情況可以通過檢測比對的 ORM 類中配置的列必須有一半已經添加到這個表格來解決。極端情況下,即便誤添加一些列,隻要這些列不實際寫入資料,也不會占用存儲空間和影響讀寫性能。

變化五:更極緻的性能優化能力

1、FTS5 優化

iOS微信在 2020 年到 2021年期間,将聯系人搜尋、聊天記錄搜尋、收藏搜尋這三個主要的本地搜尋邏輯全部改用 SQLite 的 FTS5 元件來實作,WCDB 也借此機會完善了 FTS5 支援,優化了 FTS5 的讀寫性能,重新設計了 FTS5 分詞器,并豐富了分詞器的能力,還支援了拼音搜尋,具體見《iOS微信全文搜尋技術優化》:https://mp.weixin.qq.com/s/Ph0jykLr5CMF-xFgoJw5UQ 。

2、可中斷事務

在需要對資料庫進行大量資料更新的場景,我們的開發習慣一般是将這些更新操作統一到子線程處理,這樣可以避免阻塞主線程,影響使用者體驗。在微信中這種場景有收消息、清理朋友圈資料、清理視訊号資料等,收消息可能會一次性收取幾百上千條消息,朋友圈和視訊号的資料拉下來之後會存儲在資料庫中,但是不需要永久存儲,就需要定期清理過期資料。

對于這類場景,如果隻是将資料更新操作放到子線程執行,是不能完整解決問題的。因為 SQLite 的同個DB不支援并行寫入,如果子線程的資料更新操作耗時太久,而主線程又有資料寫入操作,比如使用者在收消息的同時還會發消息,這樣也會造成主線程阻塞。以前的做法是,将子線程的資料更新操作拆成一個個耗時很小的獨立操作分别執行,比如收消息是逐條寫入資料庫,這樣可以避免主線程阻塞問題,但是又會導緻磁盤 IO 量大和增加子線程耗時的問題。因為SQLite讀寫資料庫時以一個資料頁為機關的,一個資料頁的大小在 WCDB 中是 4kb,單個資料頁一般可以存多條消息,逐條消息寫入容易導緻同一個資料頁被讀寫多次。為了減少磁盤寫入量,隻能将所有的資料更新操作放到一個事務中執行,這樣又會造成主線程阻塞的問題。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖32:收消息寫入示例

為了解決大事務會阻塞主線程的問題,我們在 WCDB 中開發了一種可中斷事務。可中斷事務把一個流程很長的事務過程看成一個循環邏輯,每次循環執行一次短時間的DB操作,比如寫入一條新消息。操作之後根據外部傳入的參數判斷目前事務是否可以結束,如果可以結束的話,就直接Commit Transaction,将事務修改内容寫入磁盤。如果事務還不可以結束,再判斷主線程是否因為目前事務阻塞,沒有的話就回調外部邏輯,繼續執行後面的循環,直到外部邏輯處理完畢。如果檢測到主線程因為目前事務阻塞,則會立即 Commit Transaction,先将部分修改内容寫入磁盤,并喚醒主線程執行DB操作。等到主線程的DB操作執行完成之後,在重新開一個新事務,讓外部可以繼續執行之前中斷的邏輯。可中斷事務的整體邏輯如下圖所示:

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖33:可中斷事務

可中斷事務讓一系列DB操作盡量保持在一個事務中執行,同時能夠及時響應主線程的阻塞事件,避免了主線程的卡頓問題。因為事務可能會被分成多次送出,是以事務整體的原子性是不保證的,這個需要使用者注意,必要的時候需要有額外的機制來保證事務的原子性。

3、WAL 檔案頭更新優化

WAL 檔案的檔案頭儲存着 WAL 檔案的版本号、頁大小、salt 值、校驗值等關鍵資訊,每次寫入 WAL 檔案的第一頁資料的時候,都需要一起更新檔案頭的内容(隻有這個時機更新 WAL 檔案的頭部)。SQLite 的早期版本(WCDB 1.0.8版本之前用的 SQLite 版本)在寫入 WAL 檔案頭時,隻是将内容寫到磁盤緩存,沒有調用 fsync。SQLite 後來發現如果磁盤緩存是随機寫入到磁盤,那可能存在 WAL 檔案頭以外的内容已經寫入到磁盤但是檔案頭還沒更新的情況,會導緻資料庫損壞(具體見https://sqlite.org/src/info/ff5be73dee)。是以現在的 SQLite 版本寫入 WAL 檔案頭之後會調用 fsync 将磁盤緩存寫到磁盤上,這會導緻寫入 WAL 檔案第一個 frame 的耗時從 5ms 左右提升到 100ms,容易造成卡頓,這個曾經是 iOS 微信的資料庫卡頓的頭号共性問題。

為了解決這個問題,WCDB 修改 SQLite 源碼,對 WAL 檔案頭的更新做了個優化。在 WCDB 的配置下,寫入 WAL 檔案的第一頁有兩個時機,一個是建立資料庫後首次寫入資料,另一個是将 WAL 檔案中的内容完全 Checkpoint 完的時候。對于第一個時機,沒法做優化,對于第二個時機,則可以将 WAL 檔案頭内容的更新操作提前到 Checkpoint 時執行。

具體邏輯是這樣,Checkpoint 結束後,如果此時沒有其他線程在讀寫 WAL 檔案,則加鎖防止其他線程寫 WAL 檔案,sync 重寫 WAL 檔案的檔案頭,再釋放鎖。在寫入 WAL 檔案的第一個 frame,如果發現 WAL 檔案沒建立或者檔案頭沒有重寫時,才嘗試 sync 重寫檔案頭。因為 Checkpoint 都是子線程執行的,而且讀寫 WAL 檔案的時機不是很多,是以這個優化可以把絕大部分 WAL 檔案頭的更新操作放到子線程執行,避免造成 UI 卡頓。優化上線之後,iOS 微信的卡頓次數降低了5%~10%。

五年沉澱,微信全平台終端資料庫WCDB迎來重大更新!

圖34:wal 檔案頭更新優化

至此,關于新版本WCDB的主要變化及更新介紹完畢。

四、開源位址

新版 WCDB 已在 Github 開源:https://github.com/Tencent/wcdb,歡迎各位Star!

五、總結

在接口層面,新版 WCDB 全面支援了 C++、Java、Kotlin、Swift 和 ObjC 這五種主要的終端開發語言,覆寫了 Android、iOS、Windows 和 Linux 這四大終端平台。同時,我們還對 Winq 進行了重寫和強化,使開發者能夠在各種語言中使用原生文法編寫任意 SQL。

在功能層面,新版 WCDB 推出了全新的資料備份和修複方案,大幅提升了資料修複率,同時将資料備份的性能消耗降至可忽略不計。此外,我們還重點推出了資料遷移和資料壓縮這兩個新功能,讓開發者僅通過簡單的配置,就能高效處理複雜業務中的資料過度聚集和資料過度膨脹這兩大難題。新版 WCDB 還推出了 FTS5 優化和可中斷事務等新特性,使開發者在特定場景下可以實作更極緻的性能優化。

作者:qiuwen

來源:微信公衆号:微信用戶端技術團隊

出處:https://mp.weixin.qq.com/s/RWCqLD0M_WGCrCcz0oQIcQ

繼續閱讀