超大規模檢索中的索引設計

超大規模檢索中的索引設計

一 問題背景

1.1 業務背景

精準廣告場景中,人羣定向的經常使用方法是:根據各類不一樣的規則,將每個用戶(User)打上豐富的標籤。與此同時,廣告主(Member)在根據規則圈選投放人羣時,系統也會將廣告(Ad)打上各類的標籤。當一個Ad和一個User被打上同一個標籤(Tag)時,就表示該Ad圈定了這個User,即該Ad會參與對該User的展示競價。
本次優化的難點出如今一個特定業務場景下,咱們須要明確的知道一個Ad圈選了哪些User,而咱們惟一知道的就是這個Ad被打上的一組Tag(Tag List)。此時,咱們須要一個存儲 + 索引的技術產品,讓咱們可以快速地經過Tag查詢到每一個Tag下掛載的全部User(User List)的id。此外,因爲廣告業務對實時性要求很高,系統還要保證User的實時行爲可以快速在投放系統中生效,當一個User的某個行爲致使該User身上被追加 或 刪除某些Tag時,全部受到影響Tag下掛載的User List都會發生變化。
image.png
業務中的數據規模:微信

  • User總數大約4億
  • Tag總數約100w個
  • 平均每一個Tag下會掛載100w個User
  • 平均每秒鐘會對2000個tag進行查詢
  • 平均每秒鐘會有5w個User會觸發Tag更新,每次更新平均會更新10個Tag

可見,不論是存儲規模、每秒檢索結果的數據量 仍是 每秒須要進行更新的數據量都很是龐大。這裏須要特別說明的是,User的id信息是一個uint64數字,後面的優化會利用這個特性。網絡

1.2 技術選型引入的問題

咱們須要一個強大的檢索引擎來支持數據的檢索 + 更新,這個檢索引擎須要支持「tag->多個數值型userid」的數據存儲結構:數據結構

  • 查詢時,經過多個tag查詢獲得tag下掛載的全部userid
  • 更新時,經過變動的tag 以及 須要add 或 delete 的userid就能夠完成變動

可是,在梳理技術選型時,咱們發現沒有任何一個現有的技術選型能夠支持這種數據結構。現有的檢索引擎大都是基於「文檔(doc)搜索」而開發的,每每將數據結構抽象爲「doc id->doc」範式的正排查找 和 「關鍵詞(keyword)-> doc id list」範式的倒排鏈查找。全部的倒排索引都是基於正排數據構建的。
舉個例子,必須先有以下文檔性能

doc id doc內容
1 我 在 吃 飯
2 羊 在 吃 草
3 你 在 吃 飯

纔可能有以下倒排索引優化

關鍵詞 doc id list
1
1,2,3
1,2,3
1,3
2
2
3

能夠看出,咱們指望的是相似於倒排表的這種數據結構,可是爲了產出這樣一種數據結構,咱們不得不創建一張咱們根本不須要的正排數據表。
ui

1.3 索引結構設計中的問題

結合業務背景,爲了得到一個「tag->多個數值型userid」的鏈式結構,咱們不得不創建一張以userid爲docid,以該userid下全部tag爲doc內容的正排結構。spa

userid tag信息
1 女裝 男裝 寵物
2 男裝  電子 影音
3 書籍 影音 女裝

而後經過創建倒排的方式獲得咱們須要的表結構設計

tag信息 userid
女裝 1,3
男裝 1,2
寵物 1
電子 2
影音 2,3
書籍 3

這樣一來,咱們就能夠經過tag去檢索User List了。
看上去,咱們經過一些冗餘存儲解決了數據結構的問題,可是實際上咱們只知足了查詢時的要求,尚未考慮更新。這樣的索引結構有一個嚴重的問題:倒排表是正排表的輔助表,所以必須經過更新正排表來觸發倒排表的更新;而正排表中的tag信息做爲「doc內容」,又必須是總體更新的。
舉個例子,假如此時user id爲1的User新增了一個「電子」的標籤,咱們必須經過將正排表中user id爲1的一行總體更新爲3d

1 女裝 男裝 寵物 電子

以此來觸發倒排表中「電子」一行變動爲blog

電子 1,2

這不知足咱們在本小節開始時提到的「更新時,經過變動的tag 以及 須要add 或 delete 的userid就能夠完成變動」。在這裏,若咱們想更新tag下的User List,咱們必須提供該tag下的全部user id。因爲tag下有百萬量級的user id,這種更新方式帶來的網絡帶寬開銷、cpu開銷是巨大的,以致於沒法接受。

二 技術難點分析

2.1 索引結構初步設計

本節將介紹一種巧妙的索引結構設計方式,能夠在技術選型受限的狀況下,經過犧牲部分存儲來實現「tag->多個數值型userid」的鏈式結構。雖然這種設計方式沒有真正的解決如此大規模數據下的檢索問題,但對一些中小型場景仍有借鑑意義。
當咱們遇到1.3節中提到的問題時,一種可行的思路是「拆分正排表doc粒度」。嘗試將1.3節中的正排表拆分紅以下結構

主鍵(userid_tag) userid tag
1_女裝 1 女裝
1_男裝 1 男裝
1_寵物 1 寵物
2_男裝 2 男裝
2_電子 2 電子
2_影音 2 影音
3_書籍 3 書籍
3_影音 3 影音
3_女裝 3 女裝

而後對tag創建倒排索引(忽略主鍵)

tag userid
女裝 1,3
男裝 1,2
寵物 1
電子 2
影音 2,3
書籍 3

能夠發現,創建獲得倒排索引與1.3節中的一致,知足查詢需求。
再來看更新,當咱們須要給user id爲1的User新增了一個「電子」標籤,只須要在正排表中插入一行

1_電子 1 電子

倒排表中「電子」一行就會變爲

電子 1,2

這樣一來,就同時解決了檢索和更新的問題。

2.2 數據規模的難題

2.1節中描述的拆表的方式,本質上是將「tag->多個數值型userid」拆成了多個「user_tag插入記錄」,而後再創建倒排索引。前面1.1節中提到:有100w個不一樣的tag,平均每一個tag下有100w個userid,計算下來,user_tag插入記錄的記錄數量在1萬億左右。雖然每個記錄佔用的存儲空間很是小(64B左右),可是因爲檢索內核出於自身設計的一些考慮,每每會設置單機存儲doc數量的上限閾值。如此龐大的記錄數量超出了上限閾值,即便是採用集羣式存儲,因爲單閾值的存在,也會致使大量的機器資源開銷與浪費。
舉個例子,假設單機最多存20億個doc,採用水平分列的方式,須要500列才能存下1萬億條記錄,若考慮到主備容災,則須要至少1000臺機器。而實際上,每臺機器的磁盤都沒有佔滿,僅使用了128GB(20億 * 64B = 128GB)。
爲了解決這個問題,先要梳理下到底是什麼緣由致使了doc數量如此之大。拋開咱們沒法決定的業務背景,致使doc數量膨脹的主因是大量的tag下有大量的userid,拆分以後出現了驚人數量的tag_userid主鍵。可是,不一樣的下掛載的userid之間是有大量重複的,若是咱們能消除這種重複現象,就能在很大程度上減輕膨脹。
image.png
一個很容易地想到的方法是分組。因爲業務上要求咱們用tag進行檢索,所以咱們只能將userid分組。分組以後,每一個tag下掛載的是userGroupId,一個UserGroup下能夠再次掛載不少userid。這樣,doc主鍵就變成了tag_userGroupId,預期數量能夠顯著減小(注意「預期」這個詞,將在2.4節中說明)。
image.png
但還有一個問題須要解決,那就是雖然Tag0和Tag1下面都掛了userGroup1,可是Tag0下掛載的是user一、user9,Tag1下掛載的是user五、user7。因此,不一樣tag下掛載同一個userGroup時,group中包含的user是不同的。因爲咱們的主鍵是tag_userGroupId,這個問題能夠很好的獲得解決。
仍是接2.1節中的例子,假設user1和user2屬於group1,user3屬於group2,那麼以下創建正排表。

主鍵(groupid_tag) userid tag
group1_女裝 1 女裝
group1_男裝 1,2 男裝
group1_寵物 1 寵物
group1_電子 2 電子
group1_影音 2 影音
group2_書籍 3 書籍
group2_影音 3 影音
group2_女裝 3 女裝

倒排表對應的是以下結構:

tag 主鍵(groupid_tag) userid(合併後)
女裝 group1_女裝,group2_女裝 1,3
男裝 group1_男裝 1,2
寵物 group1_寵物 1
電子 group1_電子 2
影音 group1_影音,group2_影音 2,3
書籍 group2_書籍 3

能夠看出,檢索出的userid結果和2.1節中是同樣的。

截止到目前爲止,檢索上的功能已經打通。這種方法的本質上將一張複合表拆分紅了多張簡單表,中間經過一個虛擬外鍵進行關聯。從工程上來看,這種方式的本質是用時間去換取空間:犧牲每次檢索時進行關聯的計算開銷,去換取存儲上的壓力減輕。
接下來在看更新鏈路中的問題。在這個場景中,索引設計要考慮的最關鍵的問題就是更新時的doc粒度。如1.3節中提到的,因爲更新是以doc爲粒度更新的,若doc中的內容過大,則沒法進行有效更新。拆表後,doc中的內容是「當前tag_userGroupId下包含的userid list」,其doc規模介於1.3節和2.1節中提出的兩種方案之間,且具備必定的隨機性。若tag_userGroupId下包含的userid較多,則更新時壓力較大,效率較低。

2.3 數值型數據的存儲優化

爲了解決tag_userGroupId下包含的userid較多致使更新效率較低的問題,咱們須要找到一種優化的存儲方式,對多值userid進行存儲壓縮。這裏介紹的一種方式是bitmap壓縮。
當數據是數值型,且數值分佈比較集中時,能夠創建bitmap存儲,每一個bit表示一個userid是否存在(0或1),userid則做爲尋址bit位置的索引。這種方法仍然是時間換空間,用bitmap反解的計算開銷去換取存儲空間。
image.png
這裏影響壓縮效率的關鍵點是映射計算後,bit是否集中分佈,若分佈過於離散,則會出現不少bit空洞。當出現不少bit空洞時,通常有兩種辦法:

  • 優化映射計算的方法
    在進行映射的時候,要保證映射後數據儘量的集中
  • 對空洞進行再次壓縮
    因爲通常不必定能找到一種理想的映射計算方法,比較常見的是對bitmap空間進行再次壓縮,壓縮方法有不少種,這部分讀者能夠自行設計,這裏簡述兩種壓縮方法。
    (1)自適應存儲
    當空洞較多時,bitmap存儲的效果有可能劣於暴力存儲,能夠根據業務特色計算出兩種方案的臨界值,自適應判斷使用哪一種存儲
    (2)bitmap再壓縮
    當出現較多字節空洞的時候,能夠採用<字節id,字節值>的方式存儲非空洞字節

image.png

2.4 數據分組規則設計

2.2節中提到,將userid分組變爲UserGroup的核心目的在於減小doc數量,這裏須要設計分組的規則。一個比較極端的案例就是4億user分組後,變成了4億個UserGroup,每一個UserGroup下一個User。爲了達成儘量減小doc數量的目的,在設計規則時,應關注userid的bit分佈特性,將公共部分提取出來看成userGroupId,並將這些user劃入該組下。
這本質上是一次「特徵提取」的過程,將數值樣本中的共性儘量豐富的提取出來。
image.png

三 總結

至此,超大規模檢索中的索引設計告一段落,如今總結下問題分析過程當中的一些關鍵點。
一、在進行索引設計時,不能只考慮檢索上的功能與性能是否知足要求,還要考慮更新時的功能與性能,給出綜合的解決方案
二、當遇到數據表過大的場景時,第一時間要反思是否本身對錶的設計不合理,可否對錶進行拆分
三、當須要基於規則對數值型數據進行分組時,並非只有取整和取模兩種簡單的分組方式,要結合數據特徵,給出效果較好的分組方式
四、當暴力存儲佔用存儲空間較大時,能夠考慮可否用bitmap的方式壓縮存儲;若bitmap空洞較多,能夠考慮進行進一步的壓縮

若是感興趣,歡迎關注微信技術公衆號
clipboard.png

相關文章
相關標籤/搜索