GraphQL 及元數據驅動架構在後端 BFF 中的實踐

GraphQL 是 Facebook 提出的一種數據查詢語言,核心特性是數據聚合和按需索取,目前被廣泛應用於前後端之間,解決客戶端靈活使用數據問題。本文介紹的是 GraphQL 的另一種實踐,我們將 GraphQL 下沉至後端 BFF(Backend For Frontend)層之下,結合元數據技術,實現數據和加工邏輯的按需查詢和執行。這樣不僅解決了後端 BFF 層靈活使用數據的問題,這些字段加工邏輯還可以直接複用,大幅度提升了研發的效率。

本文介紹的實踐方案已經在美團部分業務場景中落地,並取得不錯效果,希望這些經驗能夠對大家有幫助。

1 BFF 的由來

BFF 一詞來自 Sam Newman 的一篇博文《Pattern:Backends For Frontends》,指的是服務於前端的後端。BFF 是解決什麼問題的呢?據原文描述,隨着移動互聯網的興起,原適應於桌面 Web 的服務端功能希望同時提供給移動 App 使用,而在這個過程中存在這樣的問題:

因爲端的差異性存在,服務端的功能要針對端的差異進行適配和裁剪,而服務端的業務功能本身是相對單一的,這就產生了一個矛盾——服務端的單一業務功能和端的差異性訴求之間的矛盾。那麼這個問題怎麼解決呢?這也是文章的副標題所描述的 "Single-purpose Edge Services for UIs and external parties",引入 BFF,由 BFF 來針對多端差異做適配,這也是目前業界廣泛使用的一種模式。

圖 1 BFF 示意圖

在實際業務的實踐中,導致這種端差異性的原因有很多,有技術的原因,也有業務的原因。比如,用戶的客戶端是 Android 還是 iOS,是大屏還是小屏,是什麼版本。再比如,業務屬於哪個行業,產品形態是什麼,功能投放在什麼場景,面向的用戶羣體是誰等等。這些因素都會帶來面向端的功能邏輯的差異性。

在這個問題上,筆者所在團隊負責的商品展示業務有一定的發言權,同樣的商品業務,在 C 端的展示功能邏輯,深刻受到商品類型、所在行業、交易形態、投放場所、面向羣體等因素的影響。同時,面向消費者端的功能頻繁迭代的屬性,更是加劇並深化了這種矛盾,使其演化成了一種服務端單一穩定與端的差異靈活之間的矛盾,這也是商品展示(商品展示 BFF)業務系統存在的必然性原因。本文主要在美團到店商品展示場景的背景下,介紹面臨的一些問題及解決思路。

2 BFF 背景下的核心矛盾

BFF 這層的引入是解決服務端單一穩定與端的差異靈活訴求之間的矛盾,這個矛盾並不是不存在,而是轉移了。由原來後端和前端之間的矛盾轉移成了 BFF 和前端之間的矛盾。筆者所在團隊的主要工作,就是和這種矛盾作鬥爭。下面以具體的業務場景爲例,結合當前的業務特點,說明在 BFF 的生產模式下,我們所面臨的具體問題。下圖是兩個不同行業的團購貨架展示模塊,這兩個模塊我們認爲是兩個商品的展示場景,它們是兩套獨立定義的產品邏輯,並且會各自迭代。

圖 2 展示場景

在業務發展初期,這樣的場景不多。BFF 層系統 “煙囪式” 建設,功能快速開發上線滿足業務的訴求,在這樣的情況下,這種矛盾表現的不明顯。而隨着業務發展,行業的開拓,形成了許許多多這樣的商品展示功能,矛盾逐漸加劇,主要表現在以下兩個方面:

那麼這些問題是怎麼產生的呢?這要結合 “煙囪式” 系統建設的背景和商品展示場景所面臨的業務,以及系統特點來進行理解。

特點一:外部依賴多、場景間取數存在差異、用戶體驗要求高

圖例展示了兩個不同行業的團購貨架模塊,這樣一個看似不大的模塊,後端在 BFF 層要調用 20 個以上的下游服務才能把數據拿全,這是其一。在上面兩個不同的場景中,需要的數據源集合存在差異,而且這種差異普遍存在,這是其二,比如足療團購貨架需要的某個數據源,在麗人團購貨架上不需要,麗人團購貨架需要的某個數據源,足療團購貨架不需要。儘管依賴下游服務多,同時還要保證 C 端的用戶體驗,這是其三。

這幾個特點給技術帶來了不小的難題:1)聚合大小難控制,聚合功能是分場景建設?還是統一建設?如果分場景建設,必然存在不同場景重複編寫類似聚合邏輯的問題。如果統一建設,那麼一個大而全的數據聚合中必然會存在無效的調用。2)聚合邏輯的複雜性控制問題,在這麼多的數據源的情況下,不僅要考慮業務邏輯怎麼寫,還要考慮異步調用的編排,在代碼複雜度未能良好控制的情況下,後續聚合的變更修改將會是一個難題。

特點二:展示邏輯多、場景之間存在差異,共性個性邏輯耦合

我們可以明顯地識別某一類場景的邏輯是存在共性的,比如團單相關的展示場景。直觀可以看出基本上都是展示團單維度的信息,但這只是表象。實際上在模塊的生成過程中存在諸多的差異,比如以下兩種差異:

諸如此類的展示邏輯的差異性還有很多。類似的場景實際上在內部存在很多差異的邏輯,後端如何應對這種差異性是一個難題,下面是最常見的一種寫法,通過讀取具體的條件字段來做判斷實現邏輯路由,如下所示:

if(category == "麗人") {
  title = "[" + category + "]" + productTitle;
} else if (category == "足療") {
  title = productTitle;
}

這種方案在功能實現方面沒有問題,也能夠複用共同的邏輯。但是實際上在場景非常多的情況下,將會有非常多的差異性判斷邏輯疊加在一起,功能一直會被持續迭代的情況下,可以想象,系統將會變得越來越複雜,越來越難以修改和維護。

總結:在 BFF 這層,不同商品展示場景存在差異。在業務發展初期,系統通過獨立建設的方式支持業務快速試錯,在這種情況下,業務差異性帶來的問題不明顯。而隨着業務的不斷髮展,需要搭建及運營的場景越來越多,呈規模化趨勢。此時,業務對技術效率提出了更高的要求。在這種場景多、場景間存在差異的背景下,如何滿足場景拓展效率同時能夠控制系統的複雜性,就是我們業務場景中面臨的核心問題

3 BFF 應用模式分析

目前,業界針對此類的解決方案主要有兩種模式,一種是後端 BFF 模式;另一種是前端 BFF 模式。

3.1 後端 BFF 模式

後端 BFF 模式指的是 BFF 由後端同學負責,這種模式目前最廣泛的實踐是基於 GraphQL 搭建的後端 BFF 方案,具體是:後端將展示字段封裝成展示服務,通過 GraphQL 編排之後暴露給前端使用。如下圖所示:

圖 3 後端 BFF 模式

這種模式最大的特性和優勢是,當展示字段已經存在的情況下,後端不需要關心前端差異性需求,按需查詢的能力由 GraphQL 支持。這個特性可以很好地應對不同場景存在展示字段差異性這個問題,前端直接基於 GraphQL 按需查詢數據即可,後端不需要變更。同時,藉助 GraphQL 的編排和聚合查詢能力,後端可以將邏輯分解在不同的展示服務中,因此在一定程度上能夠化解 BFF 這層的複雜性。

但是基於這種模式,仍然存在幾個問題:展示服務顆粒度問題、數據圖劃分問題以及字段擴散問題,下圖是基於當前模式的具體案例:

圖 4 後端 BFF 模式(案例)

1)展示服務顆粒度設計問題

這種方案要求展示邏輯和取數邏輯封裝在一個模塊中,形成一個展示服務(Presentation Service),如上圖所示。而實際上展示邏輯和取數邏輯是多對多的關係,還是以前文提到的例子說明:

背景:有兩個展示服務,分別封裝了商品標題和商品標籤的查詢能力。

情景:此時 PM 提了一個需求,希望商品在某個場景的標題以 “[類型]+ 商品標題” 的形式展示,此時商品標題的拼接依賴類型數據,而此時類型數據商品標籤展示服務中已經調用了。

問題:商品標題展示服務自己調用類型數據還是將兩個展示服務合併到一起?

以上描述的問題的是展示服務顆粒度把控的問題,我們可以懷疑上述的示例是不是因爲展示服務的顆粒度過小?那麼反過來看一看,如果將兩個服務合併到一起,那麼勢必又會存在冗餘。這是展示服務設計的難點,核心原因在於,展示邏輯和取數邏輯本身是多對多的關係,結果卻被設計放在了一起

2)數據圖劃分問題

通過 GraphQL 將多個展示服務的數據聚合到一張圖(GraphQL Schema)中,形成一個數據視圖,需要數據的時候只要數據在圖中,就可以基於 Query 按需查詢。那麼問題來了,這個圖應該怎麼組織?是一張圖還是多張圖?圖過大的話,勢必帶來複雜的數據關係維護問題,圖過小則將會降低方案本身的價值。

3)展示服務內部複雜性 + 模型擴散問題

上文提到過一個商品標題的展示存在不同拼接邏輯的情況,在商品展示場景,這種邏輯特別普遍。比如同樣是價格,A 行業展示優惠後價格,B 行業展示優惠前價格;同樣是標籤位置,C 行業展示服務時長,而 D 行業展示商品特性等。

那麼問題來了,展示模型如何設計?以標題字段爲例,是在展示模型上放個title字段就可以,還是分別放個titletitleWithCategory?如果是前者那麼服務內部必然會存在if…else…這種邏輯,用於區分title的拼接方式,這同樣會導致展示服務內部的複雜性。如果是多個字段,那麼可以想象,展示服務的模型字段也將會不斷擴散。

總結:後端 BFF 模式能夠在一定程度上化解後端邏輯的複雜性,同時提供一個展示字段的複用機制。但是仍然存在未決問題,如展示服務的顆粒度設計問題,數據圖的劃分問題,以及展示服務內部的複雜性和字段擴散問題。目前這種模式實踐的代表有 Facebook、愛彼迎、eBay、愛奇藝、攜程、去哪兒等等。

3.2 前端 BFF 模式

前端 BFF 模式在 Sam Newman 的文章中的 "And Autonomy" 部分有特別的介紹,指的是 BFF 本身由前端團隊自己負責,如下示意圖所示:

圖 5 前端 BFF 模式

這種模式的理念是,本來能一個團隊交付的需求,沒必要拆成兩個團隊,兩個團隊本身帶來較大的溝通協作成本。本質上,也是一種將 “敵我矛盾” 轉化爲 “人民內部矛盾” 的思路。前端完全接手 BFF 的開發工作,實現數據查詢的自給自足,大大減少了前後端的協作成本。但是這種模式沒有提到我們關心的一些核心問題,如:複雜性如何應對、差異性如何應對、展示模型如何設計等等問題。除此之外,這種模式也存在一些前提條件及弊端,比如較爲完備的前端基礎設施;前端不僅僅需要關心渲染、還需要了解業務邏輯等。

總結:前端 BFF 模式通過前端自主查詢和使用數據,從而達到降低跨團隊協作的成本,提升 BFF 研發效率的效果。目前這種模式的實踐代表是阿里巴巴。

4 基於 GraphQL 及元數據的信息聚合架構設計

4.1 整體思路

通過對後端 BFF 和前端 BFF 兩種模式的分析,我們最終選擇後端 BFF 模式,前端 BFF 這個方案對目前的研發模式影響較大,不僅需要大量的前端資源,而且需要建設完善的前端基礎設施,方案實施成本比較高昂。

前文提到的後端 GraphQL BFF 模式代入我們的具體場景雖然存在一些問題,但是總體有非常大的參考價值,比如展示字段的複用思路、數據的按需查詢思路等等。在商品展示場景中,有 80% 的工作集中在數據的聚合和集成部分,並且這部分具有很強的複用價值,因此信息的查詢和聚合是我們面臨的主要矛盾。因此,我們的思路是:基於 GraphQL + 後端 BFF 方案改進,實現取數邏輯和展示邏輯的可沉澱、可組合、可複用,整體架構如下示意圖所示:

圖 6 基於 GraphQL BFF 的改進思路

從上圖可看出,與傳統 GraphQL BFF 方案最大的差別在於我們將 GraphQL 下放至數據聚合部分,由於數據來源於商品領域,領域是相對穩定的,因此數據圖規模可控且相對穩定。除此之外,整體架構的核心設計還包括以下三個方面:1)取數展示分離;2)查詢模型歸一;3)元數據驅動架構。

我們通過取數展示分離解決展示服務顆粒度問題,同時使得展示邏輯和取數邏輯可沉澱、可複用;通過查詢模型歸一化設計解決展示字段擴散的問題;通過元數據驅動架構實現能力的可視化,業務組件編排執行的自動化,這能夠讓業務開發同學聚焦於業務邏輯的本身。下面將針對這三個部分逐一展開介紹。

4.2 核心設計

4.2.1 取數展示分離

上文提到,在商品展示場景中,展示邏輯和取數邏輯是多對多的關係,而傳統的基於 GraphQL 的後端 BFF 實踐方案把它們封裝在一起,這是導致展示服務顆粒度難以設計的根本原因。思考一下取數邏輯和展示邏輯的關注點是什麼?取數邏輯關注怎麼查詢和聚合數據,而展示邏輯關注怎麼加工生成需要的展示字段,它們的關注點不一樣,放在一起也會增加展示服務的複雜性。因此,我們的思路是將取數邏輯和展示邏輯分離開來,單獨封裝成邏輯單元,分別叫取數單元和展示單元。在取數展示分離之後,GraphQL 也隨之下沉,用於實現數據的按需聚合,如下圖所示:

圖 7 取數展示分離 + 元數據描述

那麼取數和展示邏輯的封裝顆粒度是怎麼樣的呢?不能太小也不能太大,在顆粒度的設計上,我們有兩個核心考量:1)複用,展示邏輯和取數邏輯在商品展示場景中,都是可以被複用的資產,我們希望它們能沉澱下來,被單獨按需使用;2)簡單,保持簡單,這樣容易修改和維護。基於這兩點考慮,顆粒度的定義如下:

分開的好處是簡單且可被組合使用,那麼具體如何實現組合使用呢?我們的思路是通過元數據來描述它們之間的關係,基於元數據由統一的執行框架來關聯運行,具體設計下文會展開介紹。通過取數和展示的分離,元數據的關聯和運行時的組合調用,可以保持邏輯單元的簡單,同時又滿足複用訴求,這也很好地解決了傳統方案中存在的展示服務的顆粒度問題

4.2.2 查詢模型歸一

展示單元的加工結果通過什麼樣的接口透出呢?接下來,我們介紹一下查詢接口設計的問題。

1)查詢接口設計的難點

常見查詢接口的設計模式有以下兩種:

以上兩種模式在業界都有廣泛應用,且它們都有明確的優缺點。強類型模式對開發者友好,但是業務是不斷迭代的,與此同時,系統沉澱的展示單元會不斷豐富,在這樣的情況下,接口返回的 DTO 中的字段將會愈來愈多,每次新功能的支持,都要伴隨着接口查詢模型的修改,JAR 版本的升級。而 JAR 的升級涉及數據提供方和數據消費兩方,存在明顯效率問題。另外,可以想象,查詢模型的不斷迭代,最終將會包括成百上千個字段,難以維護。

而弱類型模式恰好可以彌補這一缺點,但是弱類型模式對於開發者來說非常不友好,接口查詢模型中有哪些查詢結果對於開發者來說在開發的過程中完全沒有感覺,但是程序員的天性就是喜歡通過代碼去理解邏輯,而非配置和文檔。其實,這兩種接口設計模式都存在着一個共性問題——缺少抽象,下面兩節,我們將介紹在接口返回的查詢模型設計方面的抽象思路及框架能力支持。

2)查詢模型歸一化設計

回到商品展示場景中,一個展示字段有多種不同的實現,如商品標題的兩種不同實現方式:1)商品標題;2)[類目]+ 商品標題。商品標題和這兩種展示邏輯的關係本質上是一種抽象 - 具體的關係。識別這個關鍵點,思路就明瞭了,我們的思路是對查詢模型做抽象。查詢模型上都是抽象的展示字段,一個展示字段對應多個展示單元,如下圖所示:

圖 8 查詢模型歸一化 + 元數據描述

在實現層面,同樣基於元數據描述展示字段和展示單元之間的關係,基於以上的設計思路,可以在一定程度上減緩模型的擴散,但是還不能避免擴展。比如除了價格、庫存、銷量等每個商品都有的標準屬性之外,不同的商品類型一般還會有這個商品特有的屬性。比如密室主題拼場商品纔有 “幾人拼” 這樣的描述屬性,這種字段本身抽象的意義不大,且放在商品查詢模型中作爲一個單獨的字段會導致模型擴張,針對這類問題,我們的解決思路是引入擴展屬性,擴展屬性專門承載這類非標準的字段。通過標準字段 + 擴展屬性的方式建立查詢模型,能夠較好地解決字段擴散的問題。

4.2.3 元數據驅動架構

到目前爲止,我們定義瞭如何分解業務邏輯單元以及如何設計查詢模型,並提到用元數據描述它們之間的關係。基於以上定義實現的業務邏輯及模型,都具備很強的複用價值,可以作爲業務資產沉澱下來。那麼,爲什麼用元數據描述業務功能及模型之間的關係呢?

我們引入元數據描述主要有兩個目的:1)代碼邏輯的自動編排,通過元數據描述業務邏輯之間的關聯關係,運行時可以自動基於元數據實現邏輯之間的關聯執行,從而可以消除大量的人工邏輯編排代碼;2)業務功能的可視化,元數據本身描述了業務邏輯所提供的功能,如下面兩個示例:

團單基礎售價字符串展示,例:30 元。 

團單市場價展示字段,例:100 元。

這些元數據上報到系統中,可以用於展示當前系統所提供的功能。通過元數據描述組件及組件之間關聯關係,通過框架解析元數據自動進行業務組件的調用執行,形成了如下的元數據架構:

圖 9 元數據驅動架構

整體架構由三個核心部分組成:

通過以上三個部分有機的組合在一起,形成了一個元數據驅動風格的架構。

5 針對 GraphQL 的優化實踐

5.1 使用簡化

1)GraphQL 直接使用問題

引入 GraphQL,會引入一些額外的複雜性,比如會涉及到 GraphQL 帶來的一些概念如:Schema、RuntimeWiring,下面是基於 GraphQL 原生 Java 框架的開發過程:

圖 10 原生 GraphQL 使用流程

這些概念對於未接觸過 GraphQL 的同學來說,增加了學習和理解的成本,而這些概念和業務領域通常沒有什麼關係。而我們僅僅希望使用 GraphQL 的按需查詢特性,卻被 GraphQL 本身拖累了,業務開發同學的關注點應該聚焦在業務邏輯本身才對,這個問題如何解決呢?

著名計算機科學家 David Wheeler 說了一句名言,"All problems in computer science can be solved by another level of indirection"。沒有加一層解決不了的問題,本質上是需要有人來對這事負責,因此我們在原生 GraphQL 之上增加了一層執行引擎層來解決這些問題,目標是屏蔽 GraphQL 的複雜性,讓開發人員只需要關注業務邏輯。

2)取數接口標準化

首先要簡化數據的接入,原生的DataFetcherDataLoader都是處在一個比較高的抽象層次,缺少業務語義,而在查詢場景,我們能夠歸納出,所有的查詢都屬於以下三種模式:

由此,我們對查詢接口進行了標準化,業務開發同學基於場景判斷是那種,按需選擇使用即可,取數接口標準化設計如下:

圖 11 查詢接口標準化

業務開發同學按需選擇所需要使用的取數器,通過泛型指定結果類型,1 查 1 和 1 查 N 比較簡單,N 查 N 我們對其定義爲批量查詢接口,用於滿足 "N+1" 的場景,其中batchSize字段用於指定分片大小,batchKey用於指定查詢 Key,業務開發只需要指定參數,其他的框架會自動處理。除此之外,我們還約束了返回結果必須是CompleteFuture,用於滿足聚合查詢的全鏈路異步化。

3)聚合編排自動化

取數接口標準化使得數據源的語義更清晰,開發過程按需選擇即可,簡化了業務的開發。但是此時業務開發同學寫好Fetcher之後,還需要去另一個地方去寫Schema,而且寫完Schema還要再寫SchemaFetcher的映射關係,業務開發更享受寫代碼的過程,不太願意寫完代碼還要去另外一個地方取配置,並且同時維護代碼和對應配置也提高了出錯的可能性,能否將這些冗雜的步驟移除掉?

SchemaRuntimeWiring本質上是想描述某些信息,如果這些信息換一種方式描述是不是也可以。我們的優化思路是,在業務開發過程中標記註解,通過註解標註的元數據描述這些信息,其他的事情交給框架來做。解決思路示意圖如下:

圖 12 註解元數據描述 Schema 和 RuntimeWiring

5.2 性能優化

5.2.1 GraphQL 性能問題

雖然 GraphQL 已經開源了,但是 Facebook 只開源了相關標準,並沒有給出解決方案。GraphQL-Java 框架是由社區貢獻的,基於開源的 GraphQL-Java 作爲按需查詢引擎的方案,我們發現了 GraphQL 應用方面的一些問題,這些問題有部分是由於使用姿勢不當所導致的,也有部分是 GraphQL 本身實現的問題,比如我們遇到的幾個典型的問題:

於是,我們對使用方式和框架做了一些優化與改造,以解決上面列舉的問題。本章着重介紹我們在 GraphQL-Java 方面的優化和改造思路。

5.2.2 GraphQL 編譯優化

1)GraphQL 語言原理概述

GraphQL 是一種查詢語言,目的是基於直觀和靈活的語法構建客戶端應用程序,用於描述其數據需求和交互。GraphQL 屬於一種領域特定語言(DSL),而我們所使用的 GraphQL-Java 客戶端在語言編譯層面是基於 ANTLR 4 實現的,ANTLR 4 是一種基於 Java 編寫的語言定義和識別工具,Antlr 是一種元語言(Meta-Language),它們的關係如下:

圖 13 GraphQL 語言基本原理示意圖

GraphQL 執行引擎所接受的SchemaQuery都是基於 GraphQL 定義的語言所表達的內容,GraphQL 執行引擎不能直接理解 GraphQL,在執行之前必須由 GraphQL 編譯器翻譯成 GraphQL 執行引擎可理解的文檔對象。而 GraphQL 編譯器是基於 Java 的,經驗表明在大流量場景實時解釋的情況下,這部分代碼將會成爲 CPU 熱點,而且還佔用響應延遲,SchemaQuery越複雜,性能損耗越明顯。

2)Schema 及 Query 編譯緩存

Schema表達的是數據視圖和取數模型同構,相對穩定,個數也不多,在我們的業務場景一個服務也就一個。因此,我們的做法是在啓動的時候就將基於Schema構造的 GraphQL 執行引擎構造好,作爲單例緩存下來。對於Query來說,每個場景的Query有些差異,因此Query的解析結果不能作爲單例,我們的做法是實現PreparsedDocumentProvider接口,基於Query作爲 Key 將Query編譯結果緩存下來。如下圖所示:

圖 14 Query 緩存實現示意圖

5.2.3 GraphQL 執行引擎優化

1)GraphQL 執行機制及問題

我們先一起了解一下 GraphQL-Java 執行引擎的運行機制是怎麼樣的。假設在執行策略上我們選取的是AsyncExecutionStrategy,來看看 GraphQL 執行引擎的執行過程:

圖 15 GraphQL 執行引擎執行過程

以上時序圖做了些簡化,去除了一些與重點無關的信息,AsyncExecutionStrategyexecute方法是對象執行策略的異步化模式實現,是查詢執行的起點,也是根節點查詢的入口,AsyncExecutionStrategy對對象的多個字段的查詢邏輯,採取的是循環 + 異步化的實現方式,我們從AsyncExecutionStrategyexecute方法觸發,理解 GraphQL 查詢過程如下:

  1. 調用當前字段所綁定的DataFetcherget方法,如果字段沒有綁定DataFetcher,則通過默認的PropertyDataFetcher查詢字段,PropertyDataFetcher的實現是基於反射從源對象中讀取查詢字段。

  2. 將從DataFetcher查詢得到結果包裝成CompletableFuture,如果結果本身是CompletableFuture,那麼不會包裝。

  3. 結果CompletableFuture完成之後,調用completeValue,基於結果類型分別處理。

以上是 GraphQL 的執行過程,這個過程有什麼問題呢?下面基於圖上的標記順序一起看看 GraphQL 在我們的業務場景中應用和實踐所遇到的問題,這些問題不代表在其他場景也是問題,僅供參考:

問題 1PropertyDataFetcherCPU 熱點問題,PropertyDataFetcher在整個查詢過程中屬於熱點代碼,而其本身的實現也有一些優化空間,在運行時PropertyDataFetcher的執行會成爲 CPU 熱點。(具體問題可參考 GitHub 上的 commit 和 Conversion:https://github.com/graphql-java/graphql-java/pull/1815

圖 16 PropertyDataFetcher 成爲 CPU 熱點

問題 2:列表的計算耗時問題,列表計算是循環的,對於查詢結果中存在大列表的場景,此時循環會造成整體查詢明顯的延遲。我們舉個具體的例子,假設查詢結果中存在一個列表大小是 1000,每個元素的處理是 0.01ms,那麼總體耗時就是 10ms,基於 GraphQL 的查機制,這個 10ms 會阻塞整個鏈路。

2)類型轉換優化

通過 GraphQL 查詢引擎拿到的 GraphQL 模型,和業務實現的DataFetcher返回的取數模型是同構,但是所有字段的類型都會被轉換成 GraphQL 內部類型。PropertyDataFetcher之所以會成爲 CPU 熱點,問題就在於這個模型轉換過程,業務定義的模型到 GraphQL 類型模型轉換過程示意圖如下圖所示:

圖 17 業務模型到 GraphQL 模型轉換示意圖

當查詢結果模型中的字段非常多的時候,比如上萬個,意味着每次查詢有上萬次的PropertyDataFetcher操作,實際就反映到了 CPU 熱點問題上,這個問題我們的解決思路是保持原有業務模型不變,將非PropertyDataFetcher查詢的結果反過來填充到業務模型上。如下示意圖所示:

圖 18 查詢結果模型反向填充示意圖

基於這個思路,我們通過 GraphQL 執行引擎拿到的結果就是業務Fetcher返回的對象模型,這樣不僅僅解決了因字段反射轉換帶來的 CPU 熱點問題,同時對於業務開發來說增加了友好性。因爲 GraphQL 模型類似 JSON 模型,這種模型是缺少業務類型的,業務開發直接使用起來非常麻煩。以上優化在一個場景上試點測試,結果顯示該場景的平均響應時間縮短 1.457ms,平均 99 線縮短 5.82ms,平均 CPU 利用率降低約 12%。

3)列表計算優化

當列表元素比較多的時候,默認的單線程遍歷列表元素計算的方式所帶來的延遲消耗非常明顯,對於響應時間比較敏感的場景這個延遲優化很有必要。針對這個問題我們的解決思路是充分利用 CPU 多核心計算的能力,將列表拆分成任務,通過多線程並行執行,實現機制如下:

圖 19 列表遍歷多核計算思路

5.2.4 GraphQL-DataLoader 調度優化

1)DataLoader 基本原理

先簡單介紹一下 DataLoader 的基本原理,DataLoader 有兩個方法,一個是load,一個是dispatch,在解決 N+1 問題的場景中,DataLoader 是這麼用的:

圖 20 DataLoader 基本原理

整體分爲 2 個階段,第一個階段調用load,調用 N 次,第二個階段調用dispatch,調用dispatch的時候會真正的執行數據查詢,從而達到批量查詢 + 分片的效果。

2)DataLoader 調度問題

GraphQL-Java 對 DataLoader 的集成支持的實現在FieldLevelTrackingApproach中,FieldLevelTrackingApproach的實現會存在怎樣的問題呢?下面基於一張圖表達原生 DataLoader 調度機制所產生的問題:

圖 21 GraphQL-Java 對 DataLoader 調度存在的問題

問題很明顯,基於FieldLevelTrackingApproach的實現,下一層級的DataLoaderdispatch是需要等到本層級的結果都回來之後才發出。基於這樣的實現,查詢總耗時的計算公式等於:TOTAL = MAX(Level  1 Latency)+ MAX(Level 2 Latency)+ MAX(Level 3 Latency)+ … ,總查詢耗時等於每層耗時最大的值加起來,而實際上如果鏈路編排由業務開發同學自己來寫的話,理論上的效果是總耗時等於所有鏈路最長的那個鏈路所耗的時間,這個纔是合理的。而FieldLevelTrackingApproach的實現所表現出來的結果是反常識的,至於爲什麼這麼實現,目前我們理解可能是設計者基於簡單和通用方面的考慮。

問題在於以上的實現在有些業務場景下是不能接受的,比如我們的列表場景的響應時間約束一共也就不到 100ms,其中幾十 ms 是因爲這個原因搭進去的。針對這個問題的解決思路,一種方式是對於響應時間要求特別高的場景獨立編排,不採用 GraphQL;另一種方式是在 GraphQL 層面解決這個問題,保持架構的統一性。接下來,介紹一下我們是如何擴展 GraphQL-Java 執行引擎來解決這個問題的。

3)DataLoader 調度優化

針對 DataLoader 調度的性能問題,我們的解決思路是在最後一次調用某個**DataLoaderload之後,立即調用dispatch方法發出查詢請求**,問題是我們怎麼知道哪一次的 load 是最後一次 load 呢?這個問題也是解決 DataLoader 調度問題的難點,以下舉個例子來解釋我們的解決思路:

圖 22 查詢對象結果示意圖

假設我們查詢到的模型結構如下:根節點是Query下的字段,字段名叫subjectssubject引用的是個列表,subject下有兩個元素,都是ModelA的對象實例,ModelA有兩個字段,fieldAfieldBsubjects[0]fieldA關聯是ModelB的一個實例,subjects[0]fieldB關聯多個ModelC實例。

爲了方便理解,我們定義一些概念,字段、字段實例、字段實例執行完、字段實例值大小等等:

除了以上定義之外,我們的業務場景還滿足以下條件:

基於以上信息,我們可以得出以下問題分析:

通過以上分析,我們可以得出,一個對象執行完的條件是其所在的字段實例以及其所在的字段所有的父親字段實例都執行完,且當前執行的對象實例是其所在字段實例的最後一個對象實例的時候。

基於這個判斷邏輯,我們的實現方案是在每次調用完DataFetcher的時候,判斷是否需要發起dispatch,如果是則發起。另外,以上時機和條件存在漏發dispatch的問題,有個特殊情況,噹噹前對象實例不是最後一個,但是剩下的對象大小都爲 0 的時候,那麼就永遠不會觸發當前對象關聯的DataLoaderload了,所以在對象大小爲 0 的時候,需要額外再判斷一次。

根據以上的邏輯分析,我們實現了DataLoader調用鏈路的最優化,達到理論最優的效果。

6 新架構對研發模式的影響

生產力決定生產關係,元數據驅動信息聚合架構是展示場景搭建的核心生產力,而業務開發模式和過程是生產關係,因此也會隨之改變。下面我們將會從開發模式和流程兩個角度來介紹新架構對研發帶來的影響。

6.1 聚焦業務的開發模式

新架構提供了一套基於業務抽象出的標準化代碼分解約束。以前開發同學對系統的理解很可能就是 “查一查服務,把數據粘在一起”,而現在,研發同學對於業務的理解及代碼分解思路將會是一致的。比如展示單元代表的是展示邏輯,取數單元代表的是取數邏輯。同時,很多冗雜且容易出錯的邏輯已經被框架屏蔽掉了,研發同學能夠有更多的精力聚焦於業務邏輯本身,比如:業務數據的理解和封裝,展示邏輯的理解和編寫,以及查詢模型的抽象和建設。如下示意圖所示:

圖 23 業務開發聚焦業務本身

6.2 研發流程升級

新架構不僅僅影響了研發的代碼編寫,同時也影響着研發流程的改進,基於元數據架構實現的可視化及配置化能力,現有研發流程和之前研發流程相比有了明顯的區別,如下圖所示:

圖 24 基於開發框架搭建展示場景前後研發流程對比

以前是 “一杆子捅到底” 的開發模式,每個展示場景的搭建需要經歷過從接口的溝通到 API 的開發整個過程,基於新架構之後,系統自動具備多層複用及可視化、配置化能力。

情況一:這是最好的情況,此時取數功能和展示功能都已經被沉澱下來,研發同學需要做的只是創建查詢方案,基於運營平臺按需選擇需要的展示單元,拿着查詢方案 ID 基於查詢接口就可以查到需要的展示信息了,可視化、配置化界面如下示意圖所示:

圖 25 可視化及文案按需選用

情況二:此時可能沒有展示功能,但是通過運營平臺查看到,數據源已經接入過,那麼也不難,只需要基於現有的數據源編寫一段加工邏輯即可,這段加工邏輯是非常爽的一段純邏輯的編寫,數據源列表如下示意圖所示:

圖 26 數據源列表可視化

情況三:最壞的情況是此時系統不能滿足當前的查詢能力,這種情況比較少見,因爲後端服務是比較穩定的,那麼也無需驚慌,只需要按照標準規範將數據源接入進來,然後編寫加工邏輯片段即可,之後這些能力是可以被持續複用的。

7 總結

商品展示場景的複雜性體現在:場景多、依賴多、邏輯多,以及不同場景之間存在差異。在這樣的背景下,如果是業務初期,怎麼快怎麼來,採用 “煙囪式” 個性化建設的方式不必有過多的質疑。但是隨着業務的不斷髮展,功能的不斷迭代,以及場景的規模化趨勢,“煙囪式”個性化建設的弊端會慢慢凸顯出來,包括代碼複雜度高、缺少能力沉澱等問題。

本文以基於對美團到店商品展示場景所面臨的核心矛盾分析,介紹了:

目前,筆者所在團隊負責的核心商品展示場景都已遷入新架構,基於新的研發模式,我們實現了 50% 以上的展示邏輯複用以及 1 倍以上的效率提升。希望本文對大家能夠有所幫助。

8 參考文獻

[1]https://samnewman.io/patterns/architectural/bff/

[2]https://www.thoughtworks.com/cn/radar/techniques/graphql-for-server-side-resource-aggregation

[3] 瞭解電商後臺系統,看這篇就夠了

[4] 框架定義 - 百度百科

[5] 高效研發 - 閒魚在數據聚合上的探索與實踐

[6] 《系統架構 - 複雜系統的產品設計與開發》

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mhM9tfWBlIuMVkZQ-6C0Tw