關鍵要點
- 在處理請求過程中以及完成響應后,這些操作可以被視為同一緩存條目的兩種不同狀態,這樣一來,在發生緩存未命中時就可以避免重復計算。
- 針對每個鍵采用單例路由機制、使用共享的內存狀態,并通過序列化方式執行相關操作,這樣就能讓某個單一主體安全地協調那些正在處理中的請求以及已被緩存的結果。
- 這種設計模式有助于減少緩存未命中時出現的一系列問題,通過避免使用分布式鎖或輪詢機制來簡化系統設計,并且能夠在水平擴展的情況下保持系統的正確性。
- 這種方法適用于那些具有類似“Actor”模型特性的運行環境,比如Cloudflare Durable Objects、Akka或Orleans;然而,如果僅使用無狀態函數以及最終一致性的鍵值存儲系統,就很難重現這種設計模式帶來的效果。
- 對于那些被頻繁訪問的鍵,其相關請求會在某個單一主體的控制下被序列化處理;在實際應用中,還需要考慮超時、重試、數據淘汰、錯誤處理以及是否需要持久化保存已完成的響應等問題。
引言
在優化分布式系統時,緩存無疑是工程師們首先會采用的工具之一。我們會將已經完成的處理結果(比如數據庫查詢的結果或HTTP響應內容)緩存在內存中,從而避免重復進行耗時的計算操作。然而,傳統的緩存機制并沒有解決另一個常常被忽視的低效問題:重復的、正在處理中的請求。
當多個客戶端幾乎同時請求同一個資源時,緩存未命中就會導致多個相同的計算任務并行執行。在單進程的JavaScript應用程序中,通常會通過將那些正在處理中的Promise對象存儲在內存中來避免這種情況,這樣后續的調用者就可以等待相同的結果。在其他編程語言和運行環境中,雖然實現方式可能有所不同,但根本的原理是相同的:利用共享內存和單一的執行上下文。
但在分布式、無服務器或邊緣計算環境中,這種假設就不再成立了。每個實例都有自己的內存空間,因此任何形式的重復請求消除機制都僅限于單個進程的生命周期和作用范圍內。
工程師們通常會采用另一種機制來輔助緩存系統:使用鎖、標記或協調記錄來跟蹤正在處理中的任務。但這些方法往往難以理解,且常常會退化為輪詢或粗粒度的同步操作。
本文提出了一種不同的解決方案:將已完成的響應與正在處理中的請求視為同一個緩存條目的兩種不同狀態。通過利用Cloudflare Workers和Durable Objects,我們可以為每個緩存鍵指定一個唯一的、權威的“所有者”。這個“所有者”可以安全地存儲那些正在進行中的任務的中間結果,允許其他請求等待這些結果的完成,而當任務最終完成時,再將該條目轉換為已緩存的響應。
這種方案并沒有引入額外的協調層,而是將緩存機制與正在處理中的請求去重功能統一在同一個框架之下。雖然它依賴于某些并非所有環境都支持的運行時特性,但對于那些支持按鍵執行單例實例的環境來說,這種方法確實是一種簡潔而實用的技術方案。
問題的本質
從高層次來看,問題并不在于緩存本身,而在于在某個緩存條目真正生成之前所發生的一系列過程。
以一個耗時較多的操作為例:比如數據庫查詢、外部API調用或需要大量CPU資源的計算。在分布式邊緣環境中,多個客戶端可能會在非常短的時間內請求同一個資源。如果緩存中還沒有這個鍵對應的值,那么每個請求都會獨立地觸發同樣的處理流程。
由于邊緣環境中的運行時系統通常是水平擴展的,因此這些請求往往會被不同的執行上下文所處理。每個上下文都會認為自己是第一個發起請求的客戶端,從而再次執行相同的操作。結果就是會出現大量的重復計算,而緩存本來應該是用來避免這種情況發生的,但由于緩存只有在第一個請求完成之后才會生效,因此它無法起到預期的作用。
為了解決這個問題,許多系統會引入額外的機制來跟蹤正在處理中的任務。其中一個緩存用于存儲已完成的響應結果,而另一個結構(有時是內存中的映射,有時是分布式存儲系統)則用來標記那些尚未完成但仍在處理的請求。這種分離會導致系統的復雜性顯著增加,因為現在需要同時協調兩個不同的系統來管理請求的生命周期,并且還需要仔細處理競態條件、故障和超時等問題。
進程內內存去重機制可以在一定程度上緩解這個問題,但其作用范圍僅限于同一個運行時實例之內。在無服務器環境和邊緣環境中,運行時實例通常是短壽命的,并且被設計成相互隔離的。因此,即使兩個請求在邏輯上是相同的,它們也無法共享那些正在處理中的狀態。當流量增加或分布范圍變得更廣時,這種優化機制的效果就會迅速減弱。
這種機制在單體服務或長期運行的服務中表現良好,但在那些需要進行大規模水平擴展的環境中卻會遇到問題。
當緩存結果尚未生成而第一次計算已經完成時,傳統的內存緩存策略就無法提供幫助,而實時去重機制在分布式運行環境中既必不可少,又極其難以實現。
為什么持久對象適合解決這類問題
上一節中提到的這些困難,實際上正是現代無服務器架構與邊緣計算平臺設計方式的直接后果。隔離的執行環境、短生命周期的進程以及水平擴展能力,這些本應被視為優勢的特性,反而成了實現實時去重機制的障礙。因此,任何解決方案都必須適應這些限制條件,而不是試圖繞過它們。
Cloudflare的持久對象技術為解決這一問題提供了關鍵的支持。
首先,持久對象實例具有鍵級單例特性。對于同一個對象標識符,無論請求來自何處,所有請求都會被路由到同一個邏輯實例上。這一設計徹底消除了狀態歸屬的不確定性:對于某個特定的緩存鍵來說,其狀態信息只能存在于一個地方。
其次,持久對象允許在不同請求之間保持內存狀態的同步性。與傳統的工作進程不同,持久對象能夠將狀態信息在多次請求之間保留下來。因此,它可以在無需外部協調的情況下,記錄正在進行的計算過程或其他操作。
第三,對持久對象的請求會按順序處理。這種序列化的執行模型使得在檢查或更新狀態信息時,完全不需要使用顯式的鎖定機制。判斷某個計算任務是否已經在運行中、如果尚未運行則創建它,以及添加額外的等待邏輯,所有這些操作都可以在同一個執行環境中確定性地完成。
綜上所述,持久對象的這些特性使它們能夠成為負責管理正在處理中的緩存狀態以及已完成緩存結果的權威機制。調用者只需將請求轉發給負責對應鍵值的對象,然后等待結果即可,而無需再去詢問“這個請求是否已經在其他地方被啟動了”。
重要的是,這種能力是無法通過最終一致性的鍵值存儲系統來模擬的。雖然鍵值存儲系統非常適合用于保存已完成的計算結果,但它們無法體現執行過程的本質,也無法讓多個調用者在不進行輪詢或外部通知的情況下,共同等待同一個內存操作的結果。相比之下,持久對象將實時處理過程中的狀態管理作為其核心功能之一。
當然,這并不意味著持久對象適用于所有場景。本文描述的設計模式依賴于它們的單例特性和內存狀態同步機制,因此只適用于那些具備類似功能的運行環境。但只要這些特性存在,持久對象就能為統一緩存管理和實時去重機制提供簡潔而有效的解決方案,而無需增加額外的協調層。
該模式的適用范圍并不限于Cloudflare
雖然本文中的示例使用了Cloudflare Workers和Durable Objects,但這種底層設計模式并非Cloudflare所獨有。關鍵不在于平臺本身,而在于上述那些運行時保障機制。
至少,運行時環境必須提供以下功能:
- 針對每個鍵的單一實例執行機制:所有針對同一鍵的請求都會被路由到同一個邏輯實例上。
- 對于該實例而言,不同請求之間能夠共享內存中的狀態數據。
- 請求處理過程需要支持序列化操作,或者具備其他等效的保障機制,從而無需使用顯式的鎖定機制。
Cloudflare的Durable Objects明確滿足了這些要求,因此它們成為了這一模式的理想示例。在其他環境中也可以找到類似的設計思路,只不過它們的實現方式可能有所不同,名稱也可能不同:
- 基于Actor的模式,比如那些使用Akka或Orleans構建的系統,通過Actor的身份識別機制和消息序列化功能,也能提供類似的功能保障。在這種系統中,一個Actor可以自然地同時負責處理針對某個鍵的正在執行中的計算任務以及已緩存的結果。
- 有狀態的無服務器平臺以及“持久性執行”模型也在逐漸興起,不過它們的API設計和提供的保障機制各不相同。但這些技術共同體現了這樣一個理念:并非所有的無服務器計算過程都必須是無狀態的,而適當地使用有限的狀態數據反而可以幫助解決某些協調問題。
相比之下,那些僅提供無狀態功能并結合最終一致性的鍵值存儲系統的平臺,則無法干凈利落地實現這種設計模式。在沒有單一的權威管理機制以及共享的內存執行環境的情況下,處理請求時的去重操作不可避免地會轉化為輪詢或分布式鎖定機制。
因此,這里所描述的模式應該被理解為一種依賴于具體運行時環境的技術。它并不是傳統緩存技術的通用替代方案,而是一種只有在特定的運行時模型支持下才能發揮作用的解決方案。
最簡單的實現方式
一旦確定了所需的運行時保障機制,實際的實現代碼就會變得非常簡潔。我們的目標并不是構建一個通用的緩存系統,而是要展示如何通過一種抽象層來同時處理請求過程中的去重操作和響應緩存功能。
下面的示例展示了一個專門用于管理某個鍵的緩存數據的Durable Object。所有針對該鍵的請求都會被路由到同一個對象實例上:
export class CacheObject {
private inflight?: Promise〈Response〉;
private cached?: Response;
async fetch(request: Request): Promise〈Response〉 {
// 如果緩存中已經存在相應的結果,就直接返回它
if (this.cached) {
return this.cached.clone();
}
// 如果還沒有開始計算操作,那就啟動計算過程
if (!this.inflight) {
this.inflight = thiscompute().then((response) => {
// 將計算結果存儲到緩存中
this.cached = response.clone();
// 清除正在進行的計算任務
this.inflight = undefined;
return response;
});
}
// 等待計算完成,然后返回結果
return (await this.inflight).clone();
}
private async compute(): Promise〈Response〉 {
// 這里可以放置那些耗時較長的操作,比如數據庫查詢或外部API調用
const data = await fetch("https://example.com/expensive").then(r => r.text());
return new Response(data, { status: 200 });
}
}
這個對象維護兩種狀態:
inflight:表示正在進行的計算。cached:用于存儲已完成的響應結果。
當有請求到達時,該對象會首先檢查是否存在緩存的響應。如果沒有,則會判斷是否已有計算任務在運行中。如果有的話,調用者只需等待相同的響應結果即可;如果沒有,對象就會啟動相應的計算任務,并將最終的結果存儲在內存中。
由于持久化對象是按順序處理請求的,因此不需要使用顯式的鎖機制或原子操作。用于檢查及創建計算任務的邏輯可以在同一個執行環境中確定性地運行完畢。
從調用者的角度來看,這種機制表現得就像一個普通的緩存系統。不同之處在于,即使緩存最初為空,多個并發調用者也不會導致重復的計算工作。一旦計算完成,所有等待的調用者都會得到相同的結果,后續的請求也會直接從緩存的響應中獲取結果。
這個示例故意省略了持久化、過期處理以及錯誤處理這些內容。這些功能可以在后期再添加——例如,可以通過將已完成的響應存儲在鍵值存儲系統中來確保數據的持久性——但這并不會改變這種模式的核心思想。關鍵在于,處于“進行中”狀態的計算任務永遠不會離開內存,這樣就保持了該模式的簡潔性和正確性。
為什么這種方法有用
這種模式的主要優點在于它將兩個相關的功能整合成了一個統一的抽象概念。它沒有把“去重處理”和“響應緩存”看作是獨立的問題,而是將它們視為同一個緩存條目的不同狀態。
這樣做有幾個實際的好處:
- 首先,它避免了在僅靠緩存無法解決問題時出現的重復計算。由于允許多個并發調用者等待同一項正在進行的計算任務,系統就可以避免在緩存未命中時出現大量冗余請求的情況——而這正是傳統緩存最不奏效的場景。
- 其次,這種設計簡化了系統的結構。不需要額外的協調層、分布式鎖,也不需要將“進行中”的狀態信息與緩存數據分開存儲。所有與請求處理、執行以及結果重用相關的邏輯都集中在一個地方,由同一個運行時實體負責管理。
- 第三,這種模式與JavaScript應用程序的編寫習慣非常契合。等待一個共享的響應結果是一種常見且被廣泛理解的設計模式,而持久化對象使得這種模式可以在多進程環境中得到應用,而不會改變開發者的思維方式。調用者可以像使用本地緩存一樣來使用這個系統,盡管實際的計算過程是分布式的。
- 第四,這種模式能夠水平擴展而不影響其正確性。無論流量如何增加,或者請求分布在不同的地理位置,每個請求仍然會路由到同一個負責處理該請求的實體那里。隨著更多邊緣節點的加入,系統的性能也不會下降——這與那些針對單進程進行的優化方案截然不同。
- 最后,這種模式具有很強的可擴展性。可以隨時添加過期策略、已完成響應的持久化存儲功能、監控指標以及重試機制,而這些改動都不會影響到核心的控制流程。本質上看,這個模式依然遵循“一個請求只由一個實體處理,最終結果只會被緩存一次”這一原則。
這些特性使得這種模式非常適合那些重復性工作成本較高、請求并發性難以預測的工作負載,例如邊緣API、數據聚合端點或復雜的上下游集成系統。
權衡與局限性
盡管這種模式設計精巧,但它并非適用于所有場景。其實用性在很大程度上取決于底層運行時的執行模型,同時也會帶來一些需要仔細考慮的權衡因素。
- 最顯著的局限性在于對運行時的依賴性。在進行去重處理時,需要有一個擁有共享內存狀態的權威節點來管理請求。如果沒有針對每個鍵進行單例化處理,這種模式就無法順利實現。嘗試使用最終一致性的鍵值存儲系統來復制這一機制,必然會導致輪詢、分布式鎖或其他協調機制的出現,從而破壞其原有的簡潔性。
- 該模式的實現本身也可能相當復雜。雖然最簡單的示例代碼量較少,但實際應用版本必須考慮錯誤處理、重試機制、超時設置、數據淘汰策略以及內存限制等問題。必須確保失敗的計算操作不會使系統長時間處于“進行中”狀態,同時也要保證緩存的響應能夠被正確地清除。
另一個需要重點考慮的因素是適用性。在許多架構良好的系統中,重復的請求本來就很少出現。冪等的上游API、自然的請求分散機制或粗粒度的緩存策略可能會使去重操作變得沒有必要。在這種情況下引入這種模式反而會增加系統的復雜性,而并不會帶來實質性的好處。
還存在擴展性方面的權衡。如果將所有針對同一鍵的請求都路由到同一個處理節點,就會形成天然的序列化點。對于那些某個特定鍵的處理請求量極大的工作負載來說,這可能會成為性能瓶頸。在這種情況下,采用分片策略或其他緩存方案可能更為合適。
最后需要強調的是,這種模式并不能替代傳統的緩存機制,它只是對傳統緩存方案的補充。已完成處理的響應仍然需要被保存在鍵值存儲系統中或HTTP緩存中,這樣才能確保系統在進程重啟或意外關閉后仍能正常訪問這些數據。然而,持久化處理應該僅適用于已經完成的結果;如果將正在處理中的請求狀態也保存到外部存儲中,就會失去這種機制原本帶來的優勢。
基于以上原因,這種模式應該被視為有針對性的優化措施,而非默認的架構選擇。只有當運行時環境支持它,并且相關工作負載確實需要這種機制時,將響應緩存與請求去重功能結合起來使用,才能顯著減少重復性工作。而在不符合這些條件的情況下,采用更簡單的設計方案通常會更加合適。
結論
本文介紹了一種在分布式JavaScript運行環境中統一響應緩存與請求去重功能的機制:通過利用針對每個鍵的單例化處理機制和共享內存狀態,就可以將正在進行的計算過程及其最終結果視為同一個緩存條目的不同狀態,從而消除重復性工作,而無需引入輪詢或外部協調機制。
需要強調的是,這種模式主要只是一種設計方案,并非經過實際測試驗證過的成熟方案。雖然其中所涉及的基本概念(持久化對象、承諾機制以及序列化執行模型)已經被廣泛理解,但這里描述的這些組件的組合在真實生產環境中尚未得到大規模的驗證。關于其運行行為、可觀測性以及長期性能等方面的問題仍然存在,需要進一步研究才能得出結論。
不過,這種模式的價值在于它清晰地揭示了緩存與執行之間的關系。它表明,在分布式系統中,數據去重所帶來的復雜性并非系統本身的固有特性,而是與我們通常使用的執行模型有關的。當運行時為每個鍵分配一個唯一的、具有權威性的處理者時,這個問題就會變得簡單許多。
隨著無服務器架構和邊緣計算平臺的不斷發展,帶有狀態的管理模型正在變得越來越普遍。像這樣的模式表明,重新審視一些長期存在的假設(比如緩存與協調機制之間的嚴格分離)可能會幫助我們設計出更簡潔、更具表現力的系統方案。無論這種具體的方法最終會被廣泛應用,還是僅僅成為一種小范圍的優化手段,它都為未來的運行時框架和應用架構指明了重要的發展方向。
