批次查詢處理的問題及其解決方案(第1部分)

批次查詢處理的問題及其解決方案(第1部分)幾乎所有現代軟體產品都包含多種服務。通常,服務間通道的較長反應時間會成為效能問題的根源。解決這類問題的標準解決方案是將多個服務間請求打包到一個套件中,稱為批次。

如果您使用批次處理,您可能對效能或程式碼清晰度方面的結果不滿意。對於呼叫者來說,此方法並不像您想像的那麼容易。對於不同的目的和不同的情況,解決方案可能會有很大差異。我將使用具體範例來展示幾種方法的優缺點。

示範工程

為了清楚起見,讓我們看一下我目前正在開發的應用程式中的一項服務的範例。

平台選擇舉例說明效能差的問題相當普遍,不涉及任何特定語言或平台。本文將使用 Spring + Kotlin 程式碼範例來示範問題和解決方案。對於 Java 和 C# 開發人員來說,Kotlin 同樣可以理解(或難以理解);此外,程式碼比 Java 更緊湊、更容易理解。為了讓純 Java 開發人員更容易理解,我將避免使用 Kotlin 的黑魔法,而只使用白魔法(本著 Lombok 的精神)。會有一些擴展方法,但它們實際上是所有 Java 程式設計師都熟悉的靜態方法,因此這將是一個小糖,不會破壞菜的味道。
有文件審批服務。有人建立文件並提交供討論,在此期間進行編輯,並最終就該文件達成一致。審批服務本身對文件一無所知:它只是審批者的聊天,具有一些我們在這裡不考慮的附加功能。

因此,存在聊天室(對應於文件),每個聊天室中都有一組預先定義的參與者。與常規聊天一樣,訊息包含文字和文件,可以回覆或轉發:

data class ChatMessage(
  // nullable так как появляется только после persist
  val id: Long? = null,
  /** Ссылка на автора */
  val author: UserReference,
  /** Сообщение */
  val message: String,
  /** Ссылки на аттачи */
  // из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
  val files: List<FileReference>? = null,
  /** Если является ответом, то здесь будет оригинал */
  val replyTo: ChatMessage? = null,
  /** Если является пересылкой, то здесь будет оригинал */
  val forwardFrom: ChatMessage? = null
)

文件和用戶連結是到其他網域的連結。我們在這裡生活是這樣的:

typealias FileReference Long
typealias UserReference Long

用戶資料儲存在 Keycloak 中並透過 REST 接收。文件也是如此:文件和它們的元資訊位於單獨的文件儲存服務中。

對這些服務的所有呼叫都是 繁重的要求。這意味著傳輸這些請求的開銷遠大於第三方服務處理它們所需的時間。在我們的測試平台上,此類服務的典型呼叫時間為 100 毫秒,因此我們將來將使用這些數字。

我們需要建立一個簡單的 REST 控制器來接收最後 N 個訊息以及所有必要的資訊。也就是說,我們認為前端的訊息模型幾乎是相同的,所有資料都需要發送。前端模型的差異在於文件和使用者需要以稍微解密的形式呈現才能使它們連結:

/** В таком виде отдаются ссылки на сущности для фронта */
data class ReferenceUI(
  /** Идентификатор для url */
  val ref: String,
  /** Видимое пользователю название ссылки */
  val name: String
)
data class ChatMessageUI(
  val id: Long,
  /** Ссылка на автора */
  val author: ReferenceUI,
  /** Сообщение */
  val message: String,
  /** Ссылки на аттачи */
  val files: List<ReferenceUI>,
  /** Если являтся ответом, то здесь будет оригинал */
  val replyTo: ChatMessageUI? = null,
  /** Если являтся пересылкой, то здесь будет оригинал */
  val forwardFrom: ChatMessageUI? = null
)

我們需要實施以下措施:

interface ChatRestApi {
  fun getLast(nInt): List<ChatMessageUI>
}

Postfix UI 意味著前端的 DTO 模型,也就是說,我們應該透過 REST 提供服務。

這裡可能令人驚訝的是,我們沒有傳遞任何聊天標識符,甚至在 ChatMessage/ChatMessageUI 模型中也沒有傳遞任何聊天標識符。我這樣做是故意的,以免使範例的程式碼變得混亂(聊天是隔離的,因此我們可以假設我們只有一個)。

哲學題外話ChatMessageUI 類別和 ChatRestApi.getLast 方法都使用 List 資料類型,但實際上它是一個有序 Set。在 JDK 中,這一切都很糟糕,因此在介面層級聲明元素的順序(在新增和檢索時保留順序)將不起作用。因此,在需要有序 Set 的情況下使用 List 已成為常見做法(也有 LinkedHashSet,但這不是介面)。
重要限制: 我們假設不存在長的回應或傳輸鏈。也就是說,它們存在,但它們的長度不超過三個訊息。整個訊息鏈必須傳送到前端。

若要從外部服務接收數據,有以下 API:

interface ChatMessageRepository {
  fun findLast(nInt): List<ChatMessage>
}
data class FileHeadRemote(
  val id: FileReference,
  val name: String
)
interface FileRemoteApi {
  fun getHeadById(idFileReference): FileHeadRemote
  fun getHeadsByIds(idSet<FileReference>): Set<FileHeadRemote>
  fun getHeadsByIds(idList<FileReference>): List<FileHeadRemote>
  fun getHeadsByChat(): List<FileHeadRemote>
}
data class UserRemote(
  val id: UserReference,
  val name: String
)
interface UserRemoteApi {
  fun getUserById(idUserReference): UserRemote
  fun getUsersByIds(idSet<UserReference>): Set<UserRemote>
  fun getUsersByIds(idList<UserReference>): List<UserRemote>
}

可以看出,外部服務最初提供批次處理,並且有兩種變體:透過 Set(不保留元素的順序,具有唯一鍵)和透過 List(可能存在重複項 - 保留順序)。

簡單的實現

簡單的實現

在大多數情況下,我們的 REST 控制器的第一個簡單實作看起來像這樣:

class ChatRestController(
  private val messageRepository: ChatMessageRepository,
  private val userRepository: UserRemoteApi,
  private val fileRepository: FileRemoteApi
) : ChatRestApi {
  override fun getLast(nInt) =
    messageRepository.findLast(n)
      .map it.toFrontModel() }
  
  private fun ChatMessage.toFrontModel(): ChatMessageUI =
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      author = userRepository.getUserById(author).toFrontReference(),
      message = message,
      files = files?.let files ->
        fileRepository.getHeadsByIds(files)
          .map it.toFrontReference() }
      } ?: listOf(),
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
}

一切都非常清楚,這是一個很大的優點。

我們使用批次並從外部服務批量接收資料。但我們的生產力發生了什麼變化呢?

對於每條訊息,將呼叫一次 UserRemoteApi 以取得作者欄位上的數據,並呼叫一次 FileRemoteApi 以取得所有附加檔案。好像就是這樣了。假設 ChatMessage 的forwardFrom 和replyTo 欄位的取得方式不需要不必要的呼叫。但將它們變成ChatMessageUI會導致遞歸,即呼叫計數器會大幅增加。正如我們之前提到的,我們假設沒有太多嵌套,並且鏈僅限於三個訊息。

因此,每個訊息我們將收到兩到六次對外部服務的調用,以及對整個訊息包的一次 JPA 調用。呼叫總數將從 2*N+1 到 6*N+1 不等。以實際單位計算是多少?假設渲染一個頁面需要 20 則訊息。要獲得它們,您需要 4 秒到 10 秒。糟糕的!我想將其保持在 500 毫秒之內。由於夢想是在前端創建無縫滾動,因此該端點的性能要求可以加倍。

優點:

  1. 程式碼簡潔且自記錄(支援團隊的夢想)。
  2. 程式碼很簡單,所以幾乎沒有搬石頭砸自己腳的機會。
  3. 批次看起來並不陌生,它是有機地融入邏輯的。
  4. 邏輯更改將很容易進行並且是本地的。

減:

由於數據包非常小,性能很差。

這種方法在簡單的服務或原型中很常見。如果更改的速度很重要,那麼幾乎不值得使系統複雜化。同時,對於我們非常簡單的服務來說,效能非常糟糕,因此這種方法的適用範圍非常狹窄。

簡單的平行處理

您可以開始並行處理所有訊息 - 這將使您擺脫根據訊息數量線性增加的時間。這不是一個特別好的路徑,因為它會導致外部服務出現很大的尖峰負載。

實作並行處理非常簡單:

override fun getLast(nInt) =
  messageRepository.findLast(n).parallelStream()
    .map it.toFrontModel() }
    .collect(toList())

使用平行訊息處理,理想情況下我們可以得到 300-700 毫秒,這比簡單的實作要好得多,但仍然不夠快。

使用此方法,對 userRepository 和 fileRepository 的請求將同步執行,效率不是很高。要解決這個問題,您必須對呼叫邏輯進行大量變更。例如,透過 CompletionStage(又稱 CompletableFuture):

private fun ChatMessage.toFrontModel(): ChatMessageUI =
  CompletableFuture.supplyAsync {
    userRepository.getUserById(author).toFrontReference()
  }.thenCombine(
    files?.let {
      CompletableFuture.supplyAsync {
        fileRepository.getHeadsByIds(files).map it.toFrontReference() }
      }
    } ?: CompletableFuture.completedFuture(listOf())
  ) authorfiles ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      author = author,
      message = message,
      files = files,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }.get()!!

可以看到,原本簡單的映射程式碼變得不太好理解了。這是因為我們必須將對外部服務的呼叫與使用結果的地方分開。這本身並不錯。但組合調用看起來並不特別優雅,類似於典型的反應式「麵條」。

如果你使用協程,一切都會看起來更體面:

private fun ChatMessage.toFrontModel(): ChatMessageUI =
  join(
    userRepository.getUserById(author).toFrontReference() },
    files?.let fileRepository.getHeadsByIds(files)
      .map it.toFrontReference() } } ?: listOf() }
  ).let (author, files) ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      author = author,
      message = message,
      files = files,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }

在哪裡:

fun <ABjoin(a: () -> Ab: () -> B) =
  runBlocking(IO{
    awaitAll(async a() }async b() })
  }.let {
    it[0as to it[1as B
  }

理論上,使用這種並行處理,我們將得到 200-400 毫秒,這已經接近我們的預期。

不幸的是,這樣好的並行化並沒有發生,而且付出的代價是相當殘酷的:只有少數用戶同時工作,服務將受到一系列無論如何都不會被並行處理的請求的打擊,所以我們又會回到我們悲傷的4s。

我使用此類服務時的結果是處理 1300 個訊息需要 1700–20 毫秒。這比第一個實現要快,但仍然沒有解決問題。

並行查詢的替代用途如果第三方服務不提供批次處理怎麼辦?例如,您可以隱藏介面方法內缺少批次實作的情況:

interface UserRemoteApi {
  fun getUserById(idUserReference): UserRemote
  fun getUsersByIds(idSet<UserReference>): Set<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toSet())
  fun getUsersByIds(idList<UserReference>): List<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toList())
}

如果您希望在未來版本中看到批次處理,這是有意義的。
優點:

  1. 輕鬆實現基於訊息的並行處理。
  2. 良好的可擴展性。

缺點:

  1. 並行處理不同服務的請求時,需要將資料取得與其處理分開。
  2. 增加了第三方服務的負載。

可以看出,適用範圍與樸素方法大致相同。如果由於其他人的無情剝削而想要將自己的服務效能提高數倍,那麼使用平行請求方法是有意義的。在我們的範例中,生產率提高了 2,5 倍,但這顯然還不夠。

緩存

您可以按照 JPA 的精神對外部服務進行緩存,即將接收到的物件儲存在會話中,以免再次接收它們(包括在批次期間)。你可以自己製作這樣的緩存,你可以使用Spring及其@Cacheable,而且你總是可以手動使用像EhCache這樣的現成緩存。

一個常見的問題是快取只有在命中時才有用。在我們的例子中,作者欄位的命中率非常高(比方說,50%),但檔案根本不會命中。這種方法將提供一些改進,但不會從根本上改變生產力(我們需要突破)。

會話間(長)快取需要複雜的失效邏輯。一般來說,越晚開始使用會話間快取解決效能問題越好。

優點:

  1. 無需更改程式碼即可實現快取。
  2. 生產力提高數倍(在某些情況下)。

缺點:

  1. 如果使用不當,可能會降低效能。
  2. 記憶體開銷較大,尤其是長快取時。
  3. 複雜的失效,其中的錯誤將導致運行時難以重現的問題。

通常,快取僅用於快速修補設計問題。這並不意味著不應使用它們。但是,您應該始終謹慎對待它們,並首先評估由此產生的性能增益,然後再做出決定。

在我們的範例中,快取將提供約 25% 的效能提升。同時,快取也有相當多的缺點,所以我不會在這裡使用它們。

結果

因此,我們研究了使用批次的服務的簡單實現,以及幾種加速它的簡單方法。

所有這些方法的主要優點是簡單,從而產生許多令人愉悅的結果。

這些方法的一個常見問題是效能差,主要與資料包的大小有關。因此,如果這些解決方案不適合您,那麼值得考慮更激進的方法。

您可以從兩個主要方向尋找解決方案:

  • 非同步處理資料(需要範式轉變,因此本文不討論);
  • 在保持同步處理的同時擴大批次。

批次的放大會大幅減少外部呼叫的次數,同時保持程式碼同步。本文的下一部分將專門討論這個主題。

來源: www.habr.com

添加評論