美團點評廣告實時索引的設計與實現

美團技術團隊發表於2018-05-11

背景

線上廣告是網際網路行業常見的商業變現方式。從工程角度看,廣告索引的結構和實現方式直接決定了整個系統的服務效能。本文以美團點評的搜尋廣告系統為藍本,與讀者一起探討廣告系統的工程奧祕。

領域問題

廣告索引需具備以下基本特性:

  1. 層次化的索引結構
  2. 實時化的索引更新

層次投放模型

一般地,廣告系統可抽象為如下投放模型,並實現檢索、過濾等處理邏輯。

廣告投放模型

該層次結構的上下層之間是一對多的關係。一個廣告主通常建立若干個推廣計劃,每個計劃對應一個較長週期的KPI,比如一個月的預算和投放地域。一個推廣計劃中的多個推廣單元分別用於更精細的投放控制,比如一次點選的最高出價、每日預算、定向條件等。廣告創意是廣告曝光使用的素材,根據業務特點,它可以從屬於廣告主或推廣計劃層級。

實時更新機制

層次結構可以更準確、更及時地反應廣告主的投放控制需求。投放模型的每一層都會定義若干欄位,用於實現各類投放控制。廣告系統的大部分欄位需要支援實時更新,比如稽核狀態、預算上下線狀態等。例如,當一個推廣單元由可投放狀態變為暫停狀態時,若該變更沒有在索引中及時生效,就會造成大量的無效投放。

業界調研

目前,生產化的開源索引系統大部分為通用搜尋引擎設計,基本無法同時滿足上述條件。

  • Apache Lucene
  • 全文檢索、支援動態指令碼;實現為一個Library
  • 支援實時索引,但不支援層次結構
  • Sphinx
  • 全文檢索;實現為一個完整的Binary,二次開發難度大
  • 支援實時索引,但不支援層次結構

因此,廣告業界要麼基於開源方案進行定製,要麼從頭開發自己的閉源系統。在經過再三考慮成本收益後,我們決定自行設計廣告系統的索引系統。

索引設計

工程實踐重點關注穩定性、擴充套件性、高效能等指標。

設計分解

設計階段可分解為以下子需求。

實時索引

廣告場景的更新流,涉及索引欄位和各類屬性的實時更新。特別是與上下線狀態相關的屬性欄位,需要在若干毫秒內完成更新,對實時性有較高要求。

用於召回條件的索引欄位,其更新可以滯後一些,如在幾秒鐘之內完成更新。採用分而治之的策略,可極大降低系統複雜度。

  • 屬性欄位的更新:直接修改正排表的欄位值,可以保證毫秒級完成
  • 索引欄位的更新:涉及更新流實時計算、倒排索引等的處理過程,只需保證秒級完成

此外,通過定期切換全量索引並追加增量索引,由索引快照確保資料的正確性。

層次結構

投放模型的主要實體是廣告主(Advertiser)、推廣計劃(Campaign)、廣告組(Adgroup)、創意(Creative)等。其中:

  • 廣告主和推廣計劃:定義用於控制廣告投放的各類狀態欄位
  • 廣告組:描述廣告相關屬性,例如競價關鍵詞、最高出價等
  • 創意:與廣告的呈現、點選等相關的欄位,如標題、創意地址、點選地址等

一般地,廣告檢索、排序等均基於廣告組粒度,廣告的倒排索引也是建立在廣告組層面。借鑑關聯式資料庫的概念,可以把廣告組作為正排主表(即一個Adgroup是一個doc),並對其建立倒排索引;把廣告主、推廣計劃等作為輔表。主表與輔表之間通過外來鍵關聯。

廣告檢索流程

  1. 通過查詢條件,從倒排索引中查詢相關docID列表
  2. 對每個docID,可從主表獲取相關欄位資訊
  3. 使用外來鍵欄位,分別獲取對應輔表的欄位資訊

檢索流程中實現對各類欄位值的同步過濾。

可靠高效

廣告索引結構相對穩定且與具體業務場景耦合較弱,為避免Java虛擬機器由於動態記憶體管理和垃圾回收機制帶來的效能抖動,最終採用C++11作為開發語言。雖然Java可使用堆外記憶體,但是堆外堆內的資料拷貝對高併發訪問仍是較大開銷。專案嚴格遵循《Google C++ Style》,大幅降低了程式設計門檻。

在“讀多寫少”的業務場景,需要優先保證“讀”的效能。檢索是記憶體查詢過程,屬於計算密集型服務,為保證CPU的高併發,一般設計為無鎖結構。可採用“一寫多讀”和延遲刪除等技術,確保系統高效穩定運轉。此外,巧妙利用陣列結構,也進一步優化了讀取效能。

靈活擴充套件

正排表、主輔表間的關係等是相對穩定的,而表內的欄位型別需要支援擴充套件,比如使用者自定義資料型別。甚至,倒排表型別也需要支援擴充套件,例如地理位置索引、關鍵詞索引、攜帶負載資訊的倒排索引等。通過繼承介面,實現更多的定製化功能。

邏輯結構

廣告檢索流程

從功能角度,索引由Table和Index兩部分組成。如上圖所示,Index實現由Term到主表docID的轉換;Table實現正排資料的儲存,並通過docID實現主表與輔表的關聯。

分層架構

索引庫分為三層:

  1. 介面層:以API方式對外提供索引的構建、更新、檢索、過濾等功能
  2. 能力層:實現基於倒排表和正排表的索引功能,是系統的核心
  3. 儲存層:索引資料的記憶體佈局和到檔案的持久化儲存

索引實現

本節將自底向上,從儲存層開始,逐一描述各層的設計細節和挑戰點。

儲存層

儲存層負責記憶體分配以及資料的持久化,可使用mmap實現到虛擬記憶體空間的對映,由作業系統實現記憶體與檔案的同步。此外,mmap也便於外部工具訪問或校驗資料的正確性。

將儲存層抽象為分配器(Allocator)。針對不同的記憶體使用場景,如對記憶體連續性的要求、記憶體是否需要回收等,可定製實現不同的分配器。

記憶體分配器

以下均為基於mmap的各類分配器,這裡的“記憶體”是指呼叫程式的虛擬地址空間。實際的程式碼邏輯還涉及複雜的Metadata管理,下文並未提及。

簡單的分配策略

  • LinearAllocator

  • 分配連續地址空間的記憶體,即一整塊大記憶體;當空間需要擴充套件時,會採用新的mmap檔案對映,並延遲解除安裝舊的檔案對映

  • 新對映會導致頁表重新裝載,大塊記憶體對映會導致由實體記憶體裝載帶來的效能抖動

  • 一般用於空間需求相對固定的場景,如HashMap的bucket陣列

  • SegmentAllocator

  • 為解決LinearAllocator在擴充套件時的效能抖動問題,可將記憶體區分段儲存,即每次擴充套件只涉及一段,保證效能穩定

  • 分段導致記憶體空間不連續,但一般應用場景,如倒排索引的儲存,很適合此法

  • 預設的段大小為64MB

集約的分配策略

頻繁的增加、刪除、修改等資料操作將導致大量的外部碎片。採用壓縮操作,可以使佔用的記憶體更緊湊,但帶來的物件移動成本卻很難在效能和複雜度之間找到平衡點。在工程實踐中,借鑑Linux實體記憶體的分配策略,自主實現了更適於業務場景的多個分配器。

  • PageAllocator
  • 頁的大小為4KB,使用夥伴系統(Buddy System)的思想實現頁的分配和回收
  • 頁的分配基於SegmentAllocator,即先分段再分頁

在此簡要闡述夥伴分配器的處理過程,為有效管理空閒塊,每一級order持有一個空閒塊的FreeList。設定最大級別order=4,即從order=0開始,由低到高,每級order塊內頁數分別為1、2、4、8、16等。分配時先找滿足條件的最小塊;若找不到則在上一級查詢更大的塊,並將該塊分為兩個“夥伴”,其中一個分配使用,另一個置於低一級的FreeList。

下圖呈現了分配一個頁大小的記憶體塊前後的狀態變化,分配前,分配器由order=0開始查詢FreeList,直到order=4才找到空閒塊。

夥伴分配器策略

將該空閒塊分為頁數為8的2個夥伴,使用前一半,並將後一半掛載到order=3的FreeList;逐級重複此過程,直到返回所需的記憶體塊,並將頁數為1的空閒塊掛在到order=0的FreeList。

當塊釋放時,會及時檢視其夥伴是否空閒,並儘可能將兩個空閒夥伴合併為更大的空閒塊。這是分配過程的逆過程,不再贅述。

雖然PageAllocator有效地避免了外部碎片,卻無法解決內部碎片的問題。為解決這類小物件的分配問題,實現了物件快取分配器(SlabAllocator)。

  • SlabAllocator
  • 基於PageAllocator分配物件快取,slab大小以頁為單位
  • 空閒物件按記憶體大小定義為多個SlabManager,每個SlabManager持有一個PartialFreeList,用於放置含有空閒物件的slab

物件的記憶體分配過程,即從對應的PartialFreeList獲取含有空閒物件的slab,並從該slab分配物件。反之,釋放過程為分配的逆過程。

物件快取分配器

綜上,實時索引儲存結合了PageAllocator和SlabAllocator,有效地解決了記憶體管理的外部碎片和內部碎片問題,可確保系統高效穩定地長期執行。

能力層

能力層實現了正排表、倒排表等基礎的儲存能力,並支援索引能力的靈活擴充套件。

正向索引

也稱為正排索引(Forward Index),即通過主鍵(Key)檢索到文件(Doc)內容,以下簡稱正排表或Table。不同於搜尋引擎的正排表資料結構,Table也可以單獨用於NoSQL場景,類似於Kyoto Cabinet的雜湊表。

Table不僅提供按主鍵的增加、刪除、修改、查詢等操作,也配合倒排表實現檢索、過濾、讀取等功能。作為核心資料結構,Table必須支援頻繁的欄位讀取和各型別的正排過濾,需要高效和緊湊的實現。

正排儲存結構

為支援按docID的隨機訪問,把Table設計為一個大陣列結構(data區)。每個doc是陣列的一個元素且長度固定。變長欄位儲存在擴充套件區(ext區),僅在doc中儲存其在擴充套件區的偏移量和長度。與大部分搜尋引擎的列儲存不同,將data區按行儲存,這樣可針對業務場景,儘可能利用CPU與記憶體之間的快取來提高訪問效率。

此外,針對NoSQL場景,可通過HashMap實現主鍵到docID的對映(idx檔案),這樣就可支援主鍵到文件的隨機訪問。由於倒排索引的docID列表可以直接訪問正排表,因此倒排檢索並不會使用該idx。

反向索引

也稱作倒排索引(Inverted Index),即通過關鍵詞(Keyword)檢索到文件內容。為支援複雜的業務場景,如遍歷索引表時的演算法粗排邏輯,在此抽象了索引器介面Indexer。

索引器介面定義

具體的Indexer僅需實現各介面方法,並將該型別註冊到IndexerFactory,可通過工廠的NewIndexer方法獲取Indexer例項,類圖如下:

索引器介面類圖

當前實現了三種常用的索引器

  • NoPayloadIndexer:最簡單的倒排索引,倒排表為單純的docID列表
  • DefaultPayloadIndexer:除docID外,倒排表還儲存keyword在每個doc的負載資訊。針對業務場景,可儲存POI在每個Node粒度的靜態質量分或最高出價。這樣在訪問正排表之前,就可完成一定的倒排優選過濾
  • GEOHashIndexer:即基於地理位置的Hash索引

上述索引器的設計思路類似,僅闡述其共性的兩個特徵:

  • 詞典檔案term:儲存關鍵詞、簽名雜湊、posting檔案的偏移量和長度等。與Lucene採用的字首壓縮的樹結構不同,在此實現為雜湊表,雖然空間有所浪費,但可保證穩定的訪問效能
  • 倒排表檔案posting:儲存docID列表、Payload等資訊。檢索操作是順序掃描倒排列表,並在掃描過程中做一些基於Payload的過濾或倒排鏈間的布林運算,如何充分利用快取記憶體實現高效能的索引讀取是設計和實現需要考慮的重要因素。在此基於segmentAllocator實現分段的記憶體分配,達到了效率和複雜度之間的微妙平衡

倒排儲存結構

出於業務考慮,沒有采用Lucene的Skip list結構,因為廣告場景的doc數量沒有搜尋引擎多,且通常為單個倒排列表的操作。此外,若後續doc數量增長過快且索引變更頻繁,可考慮對倒排列表的元素構建B+樹結構,實現倒排元素的快速定位和修改。

介面層

介面層通過API與外界互動,並遮蔽內部的處理細節,其核心功能是提供檢索和更新服務。

配置檔案

配置檔案用於描述整套索引的Schema,包括Metadata、Table、Index的定義,格式和內容如下:

索引配置檔案

可見,Index是構建在Table中的,但不是必選項;Table中各個欄位的定義是Schema的核心。當Schema變化時,如增加欄位、增加索引等,需要重新構建索引。篇幅有限,此處不展開定義的細節。

檢索介面

檢索由查詢和過濾組成,前者產出查詢到的docID集合,後者逐個對doc做各類基礎過濾和業務過濾。

檢索介面定義

  • Search:返回正排過濾後的ResultSet,內部組合了對DoSearch和DoFilter的呼叫
  • DoSearch:查詢doc,返回原始的ResultSet,但並未對結果進行正排過濾
  • DoFilter:對DoSearch返回的ResultSet做正排過濾

一般僅需呼叫Search就可實現全部功能;DoSearch和DoFilter可用於實現更復雜的業務邏輯。

以下為檢索的語法描述:

/{table}/{indexer|keyfield}?query=xxxxxx&filter=xxxxx

第一部分為路徑,用於指定表和索引。第二部分為引數,多個引數由&分隔,與URI引數格式一致,支援query、filter、Payload_filter、index_filter等。

由query引數定義對倒排索引的檢索規則。目前僅支援單型別索引的檢索,可通過index_filter實現組合索引的檢索。可支援AND、OR、NOT等布林運算,如下所示:

query=(A&B|C|D)!E

查詢語法樹基於Bison生成程式碼。針對業務場景常用的多個term求docID並集操作,通過修改Bison文法規則,消除了用於儲存相鄰兩個term的doc合併結果的臨時儲存,直接將前一個term的doc併入當前結果集。該優化極大地減少了臨時物件開銷。

由filter引數定義各類正排表欄位值過濾,多個鍵值對由“;”分割,支援單值欄位的關係運算和多值欄位的集合運算。

由Payload_filter引數定義Payload索引的過濾,目前僅支援單值欄位的關係運算,多個鍵值對由“;”分割。

詳細的過濾語法如下:

過濾語法格式

此外,由index_filter引數定義的索引過濾將直接操作倒排鏈。由於構造檢索資料結構比正排過濾更復雜,此引數僅適用於召回的docList特別長但通過索引過濾的docList很短的場景。

結果集

結果集ResultSet的實現,參考了java.sql.ResultSet介面。通過cursor遍歷結果集,採用inline函式頻繁呼叫的開銷。

實現為C++模板類,主要介面定義如下:

結果集介面定義

  • Next:移動cursor到下一個doc,成功返回true,否則返回false。若已經是集合的最後一條記錄,則返回false
  • GetValue:讀取單值欄位的值,欄位型別由泛型引數T指定。如果獲取失敗返回預設值def_value
  • GetMultiValue:讀取多值欄位的值,返回指向值陣列的指標,陣列大小由size引數返回。讀取失敗返回null,size等於0

更新介面

更新包括對doc的增加、修改、刪除等操作。引數型別Document,表示一條doc記錄,內容為待更新的doc的欄位內容,key為欄位名,value為對應的欄位值。操作成功返回0,失敗返回非0,可通過GetErrorString介面獲取錯誤資訊。

更新介面定義

  • 增加介面Add:將新的doc新增到Table和Index中
  • 修改介面Update:修改已存在的doc內容,涉及Table和Index的變更
  • 刪除介面Delete:刪除已存在的doc,涉及從Table和Index刪除資料

更新服務對接實時更新流,實現真正的廣告實時索引。

更新系統

除以上描述的索引實現機制,生產系統還需要打通線上投放引擎與商家端、預算控制、反作弊等的更新流。

挑戰與目標

資料更新系統的主要工作是將原始多個維度的資訊進行聚合、平鋪、計算後,最終輸出線上檢索引擎需要的維度和內容。

業務場景導致上游觸發可能極不規律。為避免更新流出現的抖動,必須對實時更新的吞吐量做優化,留出充足的效能餘量來應對觸發的尖峰。此外,更新系統涉及多對多的維度轉換,保持計算、更新觸發等邏輯的可維護性是系統面臨的主要挑戰。

吞吐設計

雖然更新系統需要大量的計算資源,但由於需要對幾十種外部資料來源進行查詢,因此仍屬於IO密集型應用。優化外部資料來源訪問機制,是吞吐量優化的主要目標。

在此,採取經典的批量化方法,即叢集內部,對於可以批量查詢的一類資料來源,全部收攏到一類特定的worker上來處理。在短時間內,worker聚合資料來源並逐次返回給各個需要資料的資料流。處理一種資料來源的worker可以有多個,根據同型別的查詢彙集到同一個worker批量查詢後返回。在這個劃分後,就可以做一系列的邏輯優化來提升吞吐量。

query-batch

分層抽象

除生成商家端的投放模型資料,更新系統還需處理針對各種業務場景的過濾,以及廣告呈現的各類專屬資訊。業務變更可能涉及多個資料來源的邏輯調整,只有簡潔清晰的分成抽象,才能應對業務迭代的複雜度。

工程實踐中,將外部資料來源抽象為統一的Schema,既做到了資料來源對業務邏輯透明,也可藉助編譯器和型別系統來實現完整的校驗,將更多問題提前到編譯期解決。

將資料定義為表(Table)、記錄(Record)、欄位(Field)、值(Value)等抽象型別,並將其定義為Scala Path Dependent Type,方便編譯器對程式內部的邏輯進行校驗。

type-relation

可複用設計

多對多維度的計算場景中,每個欄位的處理函式(DFP)應該儘可能地簡單、可複用。例如,每個輸出欄位(DF)的DFP只描述需要的源資料欄位(SF)和該欄位計算邏輯,並不描述所需的SF(1)到SF(n)之間的查詢或路由關係。

此外,DFP也不與最終輸出的層級繫結。層級繫結在定義輸出訊息包含的欄位時完成,即定義訊息的時候需要定義這個訊息的主鍵在哪一個層級上,同時繫結一系列的DFP到訊息上。

這樣,DFP只需單純地描述欄位內容的生成邏輯。如果業務場景需要將同一個DF平鋪到不同層級,只要在該層級的輸出訊息上引用同一個DFP即可。

觸發機制

更新系統需要接收資料來源的狀態變動,判斷是否觸發更新,並需要更新哪些索引欄位、最終生成更新訊息。

為實現資料來源變動的自動觸發機制,需要描述以下資訊:

  • 資料間的關聯關係:實現描述關聯關係的語法,即在描述外部資料來源的同時就描述關聯關係,後續欄位查詢時的路由將由框架處理
  • DFP依賴的SF資訊:僅對單子段處理的簡單DFP,可通過配置化方式,將依賴的SF固化在編譯期;對多種資料來源的複雜DFP,可通過原始碼分析來獲取該DFP依賴的SF,無需使用者維護依賴關係

生產實踐

早期的搜尋廣告是基於自然搜尋的系統架構建的,隨著業務的發展,需要根據廣告特點進行系統改造。新的廣告索引實現了純粹的實時更新和層次化結構,已經在美團點評搜尋廣告上線。該架構也適用於各類非搜尋的業務場景。

系統架構

作為整個系統的核心,基於實時索引構建的廣告檢索過濾服務(RS),承擔了廣告檢索和各類業務過濾功能。日常的業務迭代,均可通過升級索引配置完成。

系統架構

此外,為提升系統的吞吐量,多個模組已實現服務端非同步化。

效能優化

以下為監控系統的效能曲線,索引中的doc數量為百萬級別,時延的單位是毫秒。

索引查詢效能

後續規劃

為便於實時索引與其他生產系統的結合,除進一步的效能優化和功能擴充套件外,我們還計劃完成多項功能支援。

JNI

通過JNI,將Table作為單獨的NoSQL,為Java提供本地快取。如廣告系統的實時預估模組,可使用Table儲存模型使用的廣告特徵。

索引庫JNI

SQL

提供SQL語法,提供簡單的SQL支援,進一步降低使用門檻。提供JDBC,進一步簡化Java的呼叫。

參考資料

  • Apache Lucene http://lucene.apache.org/
  • Sphinx http://sphinxsearch.com/
  • "Understanding the Linux Virtual Memory Manager" https://www.kernel.org/doc/gorman/html/understand/
  • Kyoto Cabinet http://fallabs.com/kyotocabinet/
  • GNU Bison https://www.gnu.org/software/bison/

作者簡介

倉魁:廣告平臺搜尋廣告引擎組架構師,主導實時廣告索引系統的設計與實現。擅長C++、Java等多種程式語言,對非同步化系統、後臺服務調優等有深入研究。

曉暉:廣告平臺搜尋廣告引擎組核心開發,負責實時更新流的設計與實現。在廣告平臺率先嚐試Scala語言,並將其用於大規模工程實踐。

劉錚:廣告平臺搜尋廣告引擎組負責人,具有多年網際網路後臺開發經驗,曾領導多次系統重構。

蔡平:廣告平臺搜尋廣告引擎組點評側負責人,全面負責點評側系統的架構和優化。

招賢納士

有志於從事Linux後臺開發,對計算廣告、高效能運算、分散式系統等有興趣的朋友,請通過郵箱 liuzheng04@meituan.com與我們聯絡。如果對我們團隊感興趣,可以關注我們的專欄

美團點評廣告實時索引的設計與實現

相關文章