領英重寫了實驗引擎:速度提升20倍

本文最初發佈於領英技術博客,經領英官方授權由InfoQ中文站翻譯並分享。

在領英,我們常說公司的血液中流淌着實驗的基因,因爲公司要發佈任何產品之前都必須經過實驗的檢驗。所謂“實驗”一般指的是“A/B測試”。領英依靠員工通過數據分析來做出決策。實驗是決策流程的數據驅動基礎,它可以幫助精確衡量每次變更和發佈所產生的影響,並評估產品的期望是否與現實情況相符。

領英的實驗平臺有着龐大的運作規模:

  • 它可以提供高達800,000QPS的網絡調用,
  • 它可以同時運行大約35,000個A/B實驗,
  • 它每天可以處理多達23萬億次實驗評估,
  • 實驗評估的平均延遲爲700ns,第99個百分位數則爲3μs,
  • 它被用在大約500個生產服務中。

這一平臺的核心是領英實驗引擎,簡稱“Lix引擎”。這一引擎需要處理數量巨大的評估和QPS,並在整個公司範圍內廣泛應用,因此它的庫必須具備很高的性能和資源使用效率,並遵循嚴格的測試、驗證和發佈流程。我們知道,對該引擎做出的每一點優化和改進,都會對整個公司的生產服務性能產生重大影響。最近我們就完成了一項重大改進工作,以滿足不斷增長的需求。

領英服務和Lix引擎

圖1顯示了領英服務與Lix引擎交互的機制。首先,Lix引擎會評估A/B測試請求並返回實驗(treatment)或對照(control)組。根據Lix引擎的結果,服務將對應的功能頁面返回給會員。

什麼是Lix引擎,它的作用是什麼?

從概念上講,Lix引擎是一種軟件,它可以理解用於實驗的領域特定語言(也就是Lix DSL),並能執行以下三種功能:

  1. 對測試總體進行隨機分割,
  2. 對總體進行細分,
  3. 爲參與實驗的給定實體分配實驗變體。

進行A/B測試的一個前提條件是將測試總體分爲隨機、獨立的樣本桶。爲Lix引擎提供了樣本桶的相對權重後,引擎就可以無縫執行分桶操作。

按1:4.5加權的總體隨機分割

實驗過程的另一個重要需求是向特定的總體子集(例如,所有應屆生)發佈功能。Lix引擎可以將總體分爲多個羣組(稱爲細分,segmentation),並針對每個細分獨立執行隨機分割:

總體細分

總體的細分和隨機分割都封裝在類似Lisp的實驗DSL包中。例如,上圖在DSL中表示爲:

(ab (all) [treatment 18.2 control 81.8]);

而圖2中的示例表示爲:

(ab (is-student) [treatment 50 control 50] (is-job-seeker) [treatment 25 control 75])。

Lix DSL擁有多個優點:

  1. 它很靈活:Lix DSL程序交付給服務時獨立於代碼和配置部署,這樣實驗的生命週期就可以獨立於代碼發佈的生命週期。由於Lix DSL交付生產的速度快如閃電,因此當更改或回滾的速度要求非常高時,它們還可用作功能標誌,或用於流量路由配置。
  2. 它是確定性的:給定相同的實驗和實驗DSL,評估完成後總會爲會員分配相同的實驗組,這意味着我們不必跟蹤之前的會員分配,或進行遠程調用來檢索此類信息。
  3. 它的用途受到限制:DSL只能執行一組基本操作,不能執行諸如循環或遞歸調用之類的動作。這樣可以防止語言濫用,並能簡化靜態分析工作。

兩年前的狀態

Lix引擎創建於2012年左右,最初是用Clojure編寫的。從那時起它一直運行在領英內部負責評估實驗。隨着時間的流逝,它開始受到一系列不同問題的困擾,這些問題是引擎的設計缺陷和所選實現語言的特性導致的。我們最近對系統進行改造時,這些都是我們要解決的挑戰。

弱類型,邊緣場景和寬鬆的語法

爲了支持實驗評估,Clojure Lix引擎在DSL中定義了以下數據類型,包括原語和集合:

上一代類型系統

看起來這是一個結構良好,定義明確的類型系統,但是它的實際表現不是很好,原因有二:

  • 引擎核心中沒有類型檢查。每個步驟中都要執行所有驗證,擴展能力很差。
  • 不支持多態,靜態或動態都不行。這樣就很難處理(= “string” “string”)或(= 1 2.0)之類的對比了——它們都必須經過代碼中的同一個入口點,並且需要開發人員來負責正確識別所有可能的輸入值組合,還得按正確順序處理它們。

內存、垃圾回收和執行速度方面的問題

多年來,我們在Clojure Lix引擎中發現了多個性能問題。

內存

影響效率的一個主要因素來自於內存管理。由於Clojure延遲評估的特性,引擎會在JVM的堆中創建大量臨時對象(例如延遲序列)。使用Clojure引擎時,這些臨時對象至少佔據了服務中30%的Java堆。

垃圾收集

與常規Java集合相比,Clojure的不可變數據結構佔用了太多的內存空間。大量對象和巨大的內存佔用導致了生產中過長的GC暫停(約0.5秒),因爲臨時對象可以在多個GC週期中倖存下來並移動到JVM的老對象(old generation)上。

速度

另一大性能問題是速度。性能下降並不是單一原因導致的。相反,它是以下幾個因素的共同結果:

  • 經常使用反射(reflection)API,
  • 動態對象和方法發現,
  • 弱類型,Clojure端的延遲評估和低效率的類型轉換,
  • Lix DSL端的異常處理和鎖定。

沒有Clojure開發人員=沒法開發

另一個問題是公司缺少熟練的Clojure開發人員。公司中只有很少的工程師能理解和編寫Clojure代碼(後者更爲關鍵),因此這拖累了我們的生產力。

重寫

目標

實驗平臺是領英公司最常用的庫之一,在維護它的過程中,我們意識到第一版引擎缺少了很多內容,並存在諸多缺陷;因此我們開始開發v2版本,希望達成以下目標。

新版引擎必須足夠快,同時具備:

  • 較低的內存佔用和垃圾足跡。
  • 較快的執行速度。

新版引擎必須易於開發和維護:

  • 引擎代碼必須容易理解。
  • 在語言中添加新的操作不能花費太長時間。

新版引擎必須足夠安全:

  • 類型安全是標配。
  • 引擎應該讓開發人員很難出錯。
  • 它必須具備廣泛的測試覆蓋。

選項

在開始重寫前,我們需要做出一些決策:

  • 語言選擇。我們用Java製作了一個用於概念驗證的語言解析器和評估器。結果令人驚訝:我們的代碼並沒有做大量優化工作,但性能卻達到了之前版本的2-3倍!鑑於Java是公司中使用最廣泛的語言,並且有着最豐富的技術棧,因此我們很容易就決定改用Java了。
  • 解釋vs字節碼生成。我們運行了許多基準測試,結果發現,雖然字節碼生成的性能表現最出色,但Lix DSL解釋的性能已經夠用了,並且能大大減少我們花費在實現上的時間。於是我們決定將DSL解析爲評估樹,然後執行它們,以此來解釋DSL。
  • 編譯時代碼生成vs Java反射API。我們決定要支持操作重載,以自動將執行(execution)路由到與參數兼容且簽名是“最佳”的方法上。根據我們的基準測試,合適的類型解析代碼速度大約是Java反射的3倍,前者是15ns,後者爲45ns。

實現

在v2版引擎的開發過程中,我們提出了許多有趣的想法並發現很多新事物。下面我們會分享其中一些最重要的內容。

規範

爲了讓新的實現具備類型安全的特性,我們必須指定語言中每個元素的行爲,其中包括它們的契約(例如輸入參數類型和操作的返回類型)。規範中還加入了元數據,這樣我們就可以在實驗UI中讀取並編輯這些元數據了。具體請參見此處的示例。

我們用規範文件來爲操作自動生成Java實現,其中包括用於所有已定義操作重載的抽象方法,還包括了參數解析代碼,後者會在後文討論。

評估樹

與DSL語法樹類似,我們引入了DSL評估樹,其中每個節點都可以基於一個輸入實體(例如會員/來賓)、上下文和從子樹返回的值來計算一個值。

這個樹以兩種類的形式來定義:這兩種類分別是AbstractEvalTreeAbstractEvalNodeEvalTree是一種高級實現,因爲我們沒有在每個節點的對象中存儲子節點,而是將所有信息都移動到了評估樹類中,並以三個數組的形式存儲了下來(示例見圖5):

  • AbstractEvalNode[]節點,其中包含樹的所有節點,並且根節點始終位於索引0處,
  • byte[]childCounts,其中第i個元素是上述數組中節點node[i]的子節點數,
  • short[]childListStartPositions,其中第i個元素定義數組節點中,節點node[i]的子節點列表的起始索引。每個節點的子節點都在數組節點中佔據連續的空間,因此要遍歷節點[i]的所有子節點,必須從節點[childListStartPositions[i]]遍歷到節點[childListStartPositions[i]+childCounts[i]-1]。

考慮以下DSL表達式的評估樹:

(and (= (string-property “osVersion”) “1.2.3”) (in (country-code) [“us” “gb”]))

評估樹

評估樹存儲在以下數據結構中。

評估樹的存儲

這裏我們考慮了一種簡單方法,以上述結構的節點形式來實現樹。

與簡單方法相比,我們現行的方法有許多優勢:

  • 較低的內存開銷。Java在對象的內存表示方面效率是很低的。現行方法下,在啓用了壓縮OOP的32位JVM或64位JVM中實現上述樹的開銷是60字節:
    • 每個對象12字節
    • 3個數組48字節(每個數組16字節)

而簡單方法下是208字節:

  • 每個對象12字節
  • 4數組列表需要196字節(每個數組列表48字節)。
  • 執行速度更快。令人驚訝的是,我們從簡單方法切換到現行方法後,性能提升到了2.5倍之多,原因在於:

CPU更喜歡順序內存訪問和較小的數據結構,因爲CPU高速緩存較小,而主內存訪問速度很慢

切換到三個普通數組後,我們還消除了Java ArrayList數據結構上虛擬調用的開銷。

我們的節點通常執行的是輕量級操作,因此樹的遍歷速度很關鍵。

類型解析和代碼生成

每個評估節點都有其特定的處理邏輯,但它們還需要能應對任何場景,這意味着:

  1. 編譯時節點不知道將從子節點返回什麼類型的值。
  2. 節點應該能夠在構建評估樹時或在運行時解決操作的重載。

如前所述,我們決定不使用Java反射API,而是讓代碼根據子節點返回的值類型來解析適當的重載方法。爲了避免重複編寫樣板代碼,我們決定以抽象方法的形式自動生成用於操作重載的存根,並根據DSL語言規範自動生成參數解析代碼。

使用自動生成的代碼可以顯著降低實現的複雜度,因爲開發人員只需要實現特定參數類型的處理邏輯即可。我們還能調整所有DSL操作實現的行爲,從而節省了很多時間。

遠程調用,短路

因爲我們這種語言完全在我們的掌控之下,所以我們還可以執行一些高級優化工作。下面就討論其中一個例子。

爲了執行細分,操作通常需要會員屬性數據,這會導致網絡調用並增加評估延遲。完整的本地評估最快的執行速度是50ns,而遠程調用的p99(第99個百分點)延遲爲4ms。例如,我們在圖1中討論的is-student操作需要獲取會員的教育經歷數據以做處理,但是某些操作不需要遠程調用,因此我們可以利用這一點並避免昂貴的查詢操作。在以下示例中,如果字符串屬性“osVersion”未返回“7.1.1”,我們將不執行遠程調用:

(ab (and (ge (connection-count) 30) (= (string-property “osVersion”) “7.1.1”)) [treatment 50])

從技術上講,這裏的優化是在本地執行期間禁用“and”和“or”操作的短路,並嘗試在其中找到至少一個可以完全在本地執行並返回“false”的子分支。

圖7顯示了使用和不使用遠程調用優化的評估過程。

啓用和沒有啓用遠程調用優化的評估

性能優化結果

做了這麼多工作和優化之後,我們實現了以下改進:

  • 順序評估快至20倍,併發評估快至14.7倍,
  • 內存佔用效率提升至10.2倍,
  • 臨時對象生成速率減小到1/6,並且沒有超過50ms的GC hiccup。

驗證

可以說,編程語言是很難驗證的,因爲你要測試的是具有無限可能狀態的高動態系統。因此,我們在新DSL的驗證和發佈過程中小心謹慎,步步爲營。

單元測試

首先我們編寫了大量的單元測試,讓引擎運行時代碼的行覆蓋率至少達到80-90%。

測試用例生成器

爲了聲明新代碼的功能與舊代碼完全相同,我們必須證明v1和v2版本的引擎生成的結果完全相等。我們發現,一種完美的方法是獲取所有生產DSL,並在其上分別運行v1和v2版的引擎,同時觸發評估樹的所有可能執行分支。於是我們想到了自動生成測試數據的簡單方法。瞭解程序的結構以及每個運算符的工作機制後,我們就可以解析樹並遞歸生成輸入數據,以觸發不同的程序分支。

例如,我們可以從圖4中獲取以下DSL表達式:

(and (= (string-property “osVersion”) “1.2.3”) (in (country-code) [“us” “gb”]))

可能的執行順序如下:

  1. (= (string-property “osVersion”) “1.2.3”)返回false,並且(in (country-code) [“us” “gb”])的返回值無關緊要。

應爲屬性“osVersion”分配一個隨機值以觸發分支。

  1. (= (string-property “osVersion”) “1.2.3”)返回true,但(in (country-code) [“us” “gb”]) returns返回false。

應爲屬性“osVersion”分配“1.2.3”。

“Country-code”會員屬性應設置爲“us”或“gb”以外的任何值。

  1. (= (string-property “osVersion”) “1.2.3”)和(in (country-code) [“us” “gb”]) 都返回true。

應爲屬性“osVersion”分配“1.2.3”。

“Country-code”會員屬性應設置爲“us”或“gb”。

自動生成的測試用例

爲每個解析樹分支構建測試套件並將這些測試遞歸組合後,我們就可以構建一套全面的測試,觸發所有DSL執行分支的99.9%,其中99%的分支返回多個不同的值。

Hadoop中的分佈式脫機驗證

獲得99.9%的置信度對我們來說還不夠,因此我們在生產Hadoop羣集中計算了4,000種運行時參數組合與2,000,000種會員屬性集的乘積,從而生成了8,000,000,000個測試用例。我們使用它們在數千個內核上進行了分佈式驗證來對比v1和v2版本的引擎,並達到了所有生產DSL分支的99.9998%覆蓋率。

爬坡

但是,完成實現並不是故事的終點。實際上,上線這個庫並將數百個領英服務遷移到v2版本的引擎上,是該項目最具挑戰性的部分之一。

我們爲這個庫制定了一項上線計劃,其目標如下:

  1. 遷移過程對實驗平臺用戶是透明的。
  2. 爬坡(ramping)過程可由團隊控制。
  3. 影響是可衡量的。

爲了以中心化的方式控制爬坡過程,我們發佈了帶有內部A/B測試的實驗客戶端庫版本,可以在v1和v2版的引擎之間切換。使用該庫的所有服務都升級到了這個版本,並且我們能夠通過A/B測試來控制爬坡並衡量其影響。

上線過程

我們用了37次迭代和8個月的時間來爬坡、測量、更新和迭代。在上線期間,我們發現將引擎代碼集成到目標服務中時存在一些問題,即便經過如此嚴格的測試,我們也無法在本地捕獲這些問題。我們還在爬坡過程中理解了遠程調用緩存的重要性,並且在生產中同時A/B測試了多達三種緩存邏輯“風味”(flavor)。

影響

鑑於我們的庫是領英基礎架構中使用最多的庫之一,因此我們對其進行的任何更改都會產生巨大的影響。這次重寫後:

  • 我們將公司所有API端點的性能平均提高了0.5%;
  • 一些關鍵服務的速度快至兩倍。
  • 我們在所有服務中釋放了總計超過4TB的已用內存。
  • 我們在成千上萬的機器上獲得了更好的垃圾收集持續性能。

致謝

我們要感謝T-REX團隊的所有成員,如果沒有他們在實驗平臺上的辛勤工作,這個項目將是不可能完成的。非常感謝管理團隊的Igor PerisicKapil SurlakerYa XuSuja ViswesanVish BalasubramanianShaochen Huang,以及T-REX校友Shao Xie的持續投入和指導。領英的許多團隊都參與了我們引擎的上線過程。我們感謝他們的合作。特別感謝實驗數據科學團隊和EPCSRE團隊的支持。

原文鏈接:
Making the LinkedIn experimentation engine 20x faster

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章