在線廣告是互聯網行業常見的商業變現方式。從工程角度看,廣告索引的結構和實現方式直接決定了整個系統的服務性能。本文以美團點評的搜索廣告系統爲藍本,與讀者一塊兒探討廣告系統的工程奧祕。html
廣告索引需具有如下基本特性:java
通常地,廣告系統可抽象爲以下投放模型,並實現檢索、過濾等處理邏輯。算法
該層次結構的上下層之間是一對多的關係。一個廣告主一般建立若干個推廣計劃,每一個計劃對應一個較長週期的KPI,好比一個月的預算和投放地域。一個推廣計劃中的多個推廣單元分別用於更精細的投放控制,好比一次點擊的最高出價、每日預算、定向條件等。廣告創意是廣告曝光使用的素材,根據業務特色,它能夠從屬於廣告主或推廣計劃層級。sql
層次結構能夠更準確、更及時地反應廣告主的投放控制需求。投放模型的每一層都會定義若干字段,用於實現各種投放控制。廣告系統的大部分字段須要支持實時更新,好比審覈狀態、預算上下線狀態等。例如,當一個推廣單元由可投放狀態變爲暫停狀態時,若該變動沒有在索引中及時生效,就會形成大量的無效投放。數據庫
目前,生產化的開源索引系統大部分爲通用搜索引擎設計,基本沒法同時知足上述條件。apache
所以,廣告業界要麼基於開源方案進行定製,要麼從頭開發本身的閉源系統。在通過再三考慮成本收益後,咱們決定自行設計廣告系統的索引系統。編程
工程實踐重點關注穩定性、擴展性、高性能等指標。數組
設計階段可分解爲如下子需求。緩存
廣告場景的更新流,涉及索引字段和各種屬性的實時更新。特別是與上下線狀態相關的屬性字段,須要在若干毫秒內完成更新,對實時性有較高要求。性能優化
用於召回條件的索引字段,其更新能夠滯後一些,如在幾秒鐘以內完成更新。採用分而治之的策略,可極大下降系統複雜度。
此外,經過按期切換全量索引並追加增量索引,由索引快照確保數據的正確性。
投放模型的主要實體是廣告主(Advertiser)、推廣計劃(Campaign)、廣告組(Adgroup)、創意(Creative)等。其中:
通常地,廣告檢索、排序等均基於廣告組粒度,廣告的倒排索引也是創建在廣告組層面。借鑑關係數據庫的概念,能夠把廣告組做爲正排主表(即一個Adgroup是一個doc),並對其創建倒排索引;把廣告主、推廣計劃等做爲輔表。主表與輔表之間經過外鍵關聯。
檢索流程中實現對各種字段值的同步過濾。
廣告索引結構相對穩定且與具體業務場景耦合較弱,爲避免Java虛擬機因爲動態內存管理和垃圾回收機制帶來的性能抖動,最終採用C++11做爲開發語言。雖然Java可以使用堆外內存,可是堆外堆內的數據拷貝對高併發訪問還是較大開銷。項目嚴格遵循《Google C++ Style》,大幅下降了編程門檻。
在「讀多寫少」的業務場景,須要優先保證「讀」的性能。檢索是內存查找過程,屬於計算密集型服務,爲保證CPU的高併發,通常設計爲無鎖結構。可採用「一寫多讀」和延遲刪除等技術,確保系統高效穩定運轉。此外,巧妙利用數組結構,也進一步優化了讀取性能。
正排表、主輔表間的關係等是相對穩定的,而表內的字段類型須要支持擴展,好比用戶自定義數據類型。甚至,倒排表類型也須要支持擴展,例如地理位置索引、關鍵詞索引、攜帶負載信息的倒排索引等。經過繼承接口,實現更多的定製化功能。
從功能角度,索引由Table和Index兩部分組成。如上圖所示,Index實現由Term到主表docID的轉換;Table實現正排數據的存儲,並經過docID實現主表與輔表的關聯。
索引庫分爲三層:
本節將自底向上,從存儲層開始,逐一描述各層的設計細節和挑戰點。
存儲層負責內存分配以及數據的持久化,可以使用mmap實現到虛擬內存空間的映射,由操做系統實現內存與文件的同步。此外,mmap也便於外部工具訪問或校驗數據的正確性。
將存儲層抽象爲分配器(Allocator)。針對不一樣的內存使用場景,如對內存連續性的要求、內存是否須要回收等,可定製實現不一樣的分配器。
如下均爲基於mmap的各種分配器,這裏的「內存」是指調用進程的虛擬地址空間。實際的代碼邏輯還涉及複雜的Metadata管理,下文並未說起。
LinearAllocator
分配連續地址空間的內存,即一整塊大內存;當空間須要擴展時,會採用新的mmap文件映射,並延遲卸載舊的文件映射
新映射會致使頁表從新裝載,大塊內存映射會致使由物理內存裝載帶來的性能抖動
通常用於空間需求相對固定的場景,如HashMap的bucket數組
SegmentAllocator
爲解決LinearAllocator在擴展時的性能抖動問題,可將內存區分段存儲,即每次擴展只涉及一段,保證性能穩定
分段致使內存空間不連續,但通常應用場景,如倒排索引的存儲,很適合此法
默認的段大小爲64MB
頻繁的增長、刪除、修改等數據操做將致使大量的外部碎片。採用壓縮操做,可使佔用的內存更緊湊,但帶來的對象移動成本卻很難在性能和複雜度之間找到平衡點。在工程實踐中,借鑑Linux物理內存的分配策略,自主實現了更適於業務場景的多個分配器。
在此簡要闡述夥伴分配器的處理過程,爲有效管理空閒塊,每一級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)。
對象的內存分配過程,即從對應的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實例,類圖以下:
當前實現了三種經常使用的索引器
上述索引器的設計思路相似,僅闡述其共性的兩個特徵:
出於業務考慮,沒有采用Lucene的Skip list結構,由於廣告場景的doc數量沒有搜索引擎多,且一般爲單個倒排列表的操做。此外,若後續doc數量增加過快且索引變動頻繁,可考慮對倒排列表的元素構建B+樹結構,實現倒排元素的快速定位和修改。
接口層經過API與外界交互,並屏蔽內部的處理細節,其核心功能是提供檢索和更新服務。
配置文件用於描述整套索引的Schema,包括Metadata、Table、Index的定義,格式和內容以下:
可見,Index是構建在Table中的,但不是必選項;Table中各個字段的定義是Schema的核心。當Schema變化時,如增長字段、增長索引等,須要從新構建索引。篇幅有限,此處不展開定義的細節。
檢索由查找和過濾組成,前者產出查找到的docID集合,後者逐個對doc作各種基礎過濾和業務過濾。
通常僅需調用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++模板類,主要接口定義以下:
更新包括對doc的增長、修改、刪除等操做。參數類型Document,表示一條doc記錄,內容爲待更新的doc的字段內容,key爲字段名,value爲對應的字段值。操做成功返回0,失敗返回非0,可經過GetErrorString接口獲取錯誤信息。
更新服務對接實時更新流,實現真正的廣告實時索引。
除以上描述的索引實現機制,生產系統還須要打通在線投放引擎與商家端、預算控制、反做弊等的更新流。
數據更新系統的主要工做是將原始多個維度的信息進行聚合、平鋪、計算後,最終輸出線上檢索引擎須要的維度和內容。
業務場景緻使上游觸發可能極不規律。爲避免更新流出現的抖動,必須對實時更新的吞吐量作優化,留出充足的性能餘量來應對觸發的尖峯。此外,更新系統涉及多對多的維度轉換,保持計算、更新觸發等邏輯的可維護性是系統面臨的主要挑戰。
雖然更新系統須要大量的計算資源,但因爲須要對幾十種外部數據源進行查詢,所以仍屬於IO密集型應用。優化外部數據源訪問機制,是吞吐量優化的主要目標。
在此,採起經典的批量化方法,即集羣內部,對於能夠批量查詢的一類數據源,所有收攏到一類特定的worker上來處理。在短期內,worker聚合數據源並逐次返回給各個須要數據的數據流。處理一種數據源的worker能夠有多個,根據同類型的查詢聚集到同一個worker批量查詢後返回。在這個劃分後,就能夠作一系列的邏輯優化來提高吞吐量。
除生成商家端的投放模型數據,更新系統還需處理針對各類業務場景的過濾,以及廣告呈現的各種專屬信息。業務變動可能涉及多個數據源的邏輯調整,只有簡潔清晰的分紅抽象,才能應對業務迭代的複雜度。
工程實踐中,將外部數據源抽象爲統一的Schema,既作到了數據源對業務邏輯透明,也可藉助編譯器和類型系統來實現完整的校驗,將更多問題提早到編譯期解決。
將數據定義爲表(Table)、記錄(Record)、字段(Field)、值(Value)等抽象類型,並將其定義爲Scala Path Dependent Type,方便編譯器對程序內部的邏輯進行校驗。
多對多維度的計算場景中,每一個字段的處理函數(DFP)應該儘量地簡單、可複用。例如,每一個輸出字段(DF)的DFP只描述須要的源數據字段(SF)和該字段計算邏輯,並不描述所需的SF(1)到SF(n)之間的查詢或路由關係。
此外,DFP也不與最終輸出的層級綁定。層級綁定在定義輸出消息包含的字段時完成,即定義消息的時候須要定義這個消息的主鍵在哪個層級上,同時綁定一系列的DFP到消息上。
這樣,DFP只需單純地描述字段內容的生成邏輯。若是業務場景須要將同一個DF平鋪到不一樣層級,只要在該層級的輸出消息上引用同一個DFP便可。
更新系統須要接收數據源的狀態變更,判斷是否觸發更新,並須要更新哪些索引字段、最終生成更新消息。
爲實現數據源變更的自動觸發機制,須要描述如下信息:
早期的搜索廣告是基於天然搜索的系統架構建的,隨着業務的發展,須要根據廣告特色進行系統改造。新的廣告索引實現了純粹的實時更新和層次化結構,已經在美團點評搜索廣告上線。該架構也適用於各種非搜索的業務場景。
做爲整個系統的核心,基於實時索引構建的廣告檢索過濾服務(RS),承擔了廣告檢索和各種業務過濾功能。平常的業務迭代,都可經過升級索引配置完成。
此外,爲提高系統的吞吐量,多個模塊已實現服務端異步化。
如下爲監控系統的性能曲線,索引中的doc數量爲百萬級別,時延的單位是毫秒。
爲便於實時索引與其餘生產系統的結合,除進一步的性能優化和功能擴展外,咱們還計劃完成多項功能支持。
經過JNI,將Table做爲單獨的NoSQL,爲Java提供本地緩存。如廣告系統的實時預估模塊,可以使用Table存儲模型使用的廣告特徵。
提供SQL語法,提供簡單的SQL支持,進一步下降使用門檻。提供JDBC,進一步簡化Java的調用。
倉魁:廣告平臺搜索廣告引擎組架構師,主導實時廣告索引系統的設計與實現。擅長C++、Java等多種編程語言,對異步化系統、後臺服務調優等有深刻研究。
曉暉:廣告平臺搜索廣告引擎組核心開發,負責實時更新流的設計與實現。在廣告平臺率先嚐試Scala語言,並將其用於大規模工程實踐。
劉錚:廣告平臺搜索廣告引擎組負責人,具備多年互聯網後臺開發經驗,曾領導屢次系統重構。
蔡平:廣告平臺搜索廣告引擎組點評側負責人,全面負責點評側系統的架構和優化。
有志於從事Linux後臺開發,對計算廣告、高性能計算、分佈式系統等有興趣的朋友,請經過郵箱 liuzheng04@meituan.com與咱們聯繫。若是對咱們團隊感興趣,能夠關注咱們的專欄。