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

背景

在線廣告是互聯網行業常見的商業變現方式。從工程角度看,廣告索引的結構和實現方式直接決定了整個系統的服務性能。本文以美團點評的搜索廣告系統爲藍本,與讀者一塊兒探討廣告系統的工程奧祕。html

領域問題

廣告索引需具有如下基本特性:java

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

層次投放模型

通常地,廣告系統可抽象爲以下投放模型,並實現檢索、過濾等處理邏輯。算法

廣告投放模型

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

實時更新機制

層次結構能夠更準確、更及時地反應廣告主的投放控制需求。投放模型的每一層都會定義若干字段,用於實現各種投放控制。廣告系統的大部分字段須要支持實時更新,好比審覈狀態、預算上下線狀態等。例如,當一個推廣單元由可投放狀態變爲暫停狀態時,若該變動沒有在索引中及時生效,就會形成大量的無效投放。數據庫

業界調研

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

  • 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塊內頁數分別爲一、二、四、八、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與咱們聯繫。若是對咱們團隊感興趣,能夠關注咱們的專欄

相關文章
相關標籤/搜索