Các vấn đề về xử lý truy vấn hàng loạt và cách giải quyết (phần 1)

Các vấn đề về xử lý truy vấn hàng loạt và cách giải quyết (phần 1)Hầu như tất cả các sản phẩm phần mềm hiện đại đều bao gồm một số dịch vụ. Thông thường, thời gian đáp ứng dài của các kênh liên dịch vụ trở thành nguồn gốc của các vấn đề về hiệu suất. Giải pháp tiêu chuẩn cho loại vấn đề này là đóng gói nhiều yêu cầu dịch vụ xen kẽ vào một gói, được gọi là phân khối.

Nếu bạn sử dụng xử lý hàng loạt, bạn có thể không hài lòng với kết quả về hiệu suất hoặc độ rõ ràng của mã. Phương pháp này không dễ dàng với người gọi như bạn nghĩ. Đối với các mục đích khác nhau và trong các tình huống khác nhau, các giải pháp có thể khác nhau rất nhiều. Sử dụng các ví dụ cụ thể, tôi sẽ chỉ ra ưu và nhược điểm của một số phương pháp.

Dự án trình diễn

Để rõ ràng hơn, chúng ta hãy xem ví dụ về một trong những dịch vụ trong ứng dụng mà tôi hiện đang làm việc.

Giải thích về lựa chọn nền tảng cho các ví dụVấn đề hiệu suất kém khá chung chung và không liên quan đến bất kỳ ngôn ngữ hoặc nền tảng cụ thể nào. Bài viết này sẽ sử dụng các ví dụ về mã Spring + Kotlin để minh họa các vấn đề và giải pháp. Kotlin cũng dễ hiểu (hoặc không thể hiểu được) đối với các nhà phát triển Java và C#; ngoài ra, mã này nhỏ gọn và dễ hiểu hơn so với Java. Để giúp các nhà phát triển Java thuần túy dễ hiểu hơn, tôi sẽ tránh ma thuật đen của Kotlin và chỉ sử dụng ma thuật trắng (theo tinh thần của Lombok). Sẽ có một vài phương thức mở rộng, nhưng thực ra chúng đều quen thuộc với tất cả các lập trình viên Java là các phương thức tĩnh, nên đây sẽ là một chút đường không làm hỏng hương vị của món ăn.
Có dịch vụ phê duyệt tài liệu. Ai đó tạo một tài liệu và gửi nó để thảo luận, trong đó các chỉnh sửa được thực hiện và cuối cùng tài liệu đó được thống nhất. Bản thân dịch vụ phê duyệt không biết gì về tài liệu: nó chỉ là cuộc trò chuyện của những người phê duyệt với các chức năng bổ sung nhỏ mà chúng tôi sẽ không xem xét ở đây.

Vì vậy, có các phòng trò chuyện (tương ứng với các tài liệu) với một nhóm người tham gia được xác định trước trong mỗi phòng. Giống như trong các cuộc trò chuyện thông thường, tin nhắn chứa văn bản và tệp và có thể trả lời hoặc chuyển tiếp:

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
)

Liên kết tệp và người dùng là liên kết đến các tên miền khác. Ở đây chúng ta sống như thế này:

typealias FileReference Long
typealias UserReference Long

Dữ liệu người dùng được lưu trữ trong Keycloak và được nhận qua REST. Điều tương tự cũng xảy ra với các tệp: tệp và siêu thông tin về chúng nằm trong một dịch vụ lưu trữ tệp riêng biệt.

Tất cả các cuộc gọi đến các dịch vụ này đều yêu cầu nặng nề. Điều này có nghĩa là chi phí vận chuyển các yêu cầu này lớn hơn nhiều so với thời gian cần thiết để dịch vụ bên thứ ba xử lý chúng. Trên các băng ghế thử nghiệm của chúng tôi, thời gian gọi thông thường cho các dịch vụ như vậy là 100 mili giây, vì vậy chúng tôi sẽ sử dụng những con số này trong tương lai.

Chúng ta cần tạo một bộ điều khiển REST đơn giản để nhận N tin nhắn cuối cùng với tất cả thông tin cần thiết. Nghĩa là, chúng tôi tin rằng ở giao diện người dùng, mô hình tin nhắn gần như giống nhau và tất cả dữ liệu cần phải được gửi. Sự khác biệt giữa mô hình giao diện người dùng là tệp và người dùng cần được trình bày ở dạng được giải mã một chút để tạo liên kết cho chúng:

/** В таком виде отдаются ссылки на сущности для фронта */
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
)

Chúng ta cần triển khai những điều sau:

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

Giao diện người dùng Postfix có nghĩa là các mô hình DTO cho giao diện người dùng, tức là những gì chúng tôi phải phân phát thông qua REST.

Điều có thể đáng ngạc nhiên ở đây là chúng tôi không chuyển bất kỳ mã nhận dạng trò chuyện nào và ngay cả trong mô hình ChatMessage/ChatMessageUI cũng không có mã nhận dạng nào. Tôi đã cố tình làm điều này để không làm lộn xộn mã của các ví dụ (các cuộc trò chuyện bị cô lập, vì vậy chúng ta có thể cho rằng chúng ta chỉ có một cuộc trò chuyện).

Lạc đề triết họcCả lớp ChatMessageUI và phương thức ChatRestApi.getLast đều sử dụng kiểu dữ liệu Danh sách, trong khi trên thực tế nó là một Tập hợp có thứ tự. Trong JDK, điều này hoàn toàn tệ, vì vậy việc khai báo thứ tự các phần tử ở cấp giao diện (giữ nguyên thứ tự khi thêm và truy xuất) sẽ không hoạt động. Vì vậy, việc sử dụng Danh sách trong trường hợp cần một Bộ được sắp xếp đã trở thành thông lệ (cũng có LinkedHashSet, nhưng đây không phải là một giao diện).
Hạn chế quan trọng: Chúng ta sẽ giả định rằng không có chuỗi trả lời hoặc chuyển giao dài dòng nào. Tức là chúng tồn tại nhưng độ dài của chúng không vượt quá ba tin nhắn. Toàn bộ chuỗi tin nhắn phải được truyền đến giao diện người dùng.

Để nhận dữ liệu từ các dịch vụ bên ngoài, có các API sau:

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>
}

Có thể thấy, các dịch vụ bên ngoài ban đầu cung cấp khả năng xử lý hàng loạt và ở cả hai biến thể: thông qua Set (không bảo toàn thứ tự các phần tử, với các khóa duy nhất) và thông qua List (có thể trùng lặp - thứ tự được giữ nguyên).

Triển khai đơn giản

Triển khai ngây thơ

Việc triển khai đơn giản đầu tiên của bộ điều khiển REST của chúng tôi sẽ trông giống như thế này trong hầu hết các trường hợp:

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()
    )
}

Mọi thứ đều rất rõ ràng và đây là một điểm cộng lớn.

Chúng tôi sử dụng xử lý hàng loạt và nhận dữ liệu từ một dịch vụ bên ngoài theo đợt. Nhưng điều gì đang xảy ra với năng suất của chúng ta?

Đối với mỗi tin nhắn, một lệnh gọi tới UserRemoteApi sẽ được thực hiện để lấy dữ liệu về trường tác giả và một lệnh gọi tới FileRemoteApi để lấy tất cả các tệp đính kèm. Có vẻ như vậy đó. Giả sử các trường ForwardFrom và replyTo cho ChatMessage được lấy theo cách không yêu cầu các lệnh gọi không cần thiết. Nhưng việc biến chúng thành ChatMessageUI sẽ dẫn đến đệ quy, tức là số lượng cuộc gọi có thể tăng lên đáng kể. Như chúng tôi đã lưu ý trước đó, hãy giả sử rằng chúng tôi không có nhiều sự lồng ghép và chuỗi bị giới hạn ở ba thông báo.

Do đó, chúng tôi sẽ nhận được từ hai đến sáu cuộc gọi đến các dịch vụ bên ngoài cho mỗi tin nhắn và một cuộc gọi JPA cho toàn bộ gói tin nhắn. Tổng số cuộc gọi sẽ thay đổi từ 2*N+1 đến 6*N+1. Đơn vị thực này bằng bao nhiêu? Giả sử phải mất 20 tin nhắn để hiển thị một trang. Để có được chúng, bạn sẽ cần từ 4 giây đến 10 giây. Kinh khủng! Tôi muốn giữ nó trong vòng 500ms. Và vì họ mơ ước có thể cuộn liền mạch trên giao diện người dùng nên yêu cầu về hiệu suất cho điểm cuối này có thể tăng gấp đôi.

Ưu điểm:

  1. Mã này ngắn gọn và tự ghi lại (giấc mơ của nhóm hỗ trợ).
  2. Mã rất đơn giản nên hầu như không có cơ hội để tự bắn vào chân mình.
  3. Xử lý hàng loạt trông không giống một thứ gì đó xa lạ và được tích hợp một cách hữu cơ vào logic.
  4. Những thay đổi logic sẽ dễ thực hiện và mang tính cục bộ.

Trừ đi:

Hiệu suất khủng khiếp do các gói rất nhỏ.

Cách tiếp cận này có thể được nhìn thấy khá thường xuyên trong các dịch vụ đơn giản hoặc trong các nguyên mẫu. Nếu tốc độ thực hiện thay đổi là quan trọng thì việc làm phức tạp hệ thống là không đáng. Đồng thời, đối với dịch vụ rất đơn giản của chúng tôi, hiệu suất rất tệ, do đó phạm vi áp dụng phương pháp này rất hẹp.

Xử lý song song ngây thơ

Bạn có thể bắt đầu xử lý song song tất cả các tin nhắn - điều này sẽ cho phép bạn thoát khỏi sự gia tăng tuyến tính về thời gian tùy thuộc vào số lượng tin nhắn. Đây không phải là một đường dẫn đặc biệt tốt vì nó sẽ dẫn đến tải tối đa lớn cho dịch vụ bên ngoài.

Việc thực hiện xử lý song song rất đơn giản:

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

Bằng cách sử dụng xử lý tin nhắn song song, lý tưởng nhất là chúng tôi nhận được 300–700 mili giây, tốt hơn nhiều so với cách triển khai đơn giản nhưng vẫn chưa đủ nhanh.

Với cách tiếp cận này, các yêu cầu tới userRepository và fileRepository sẽ được thực thi đồng bộ, điều này không hiệu quả lắm. Để khắc phục điều này, bạn sẽ phải thay đổi logic cuộc gọi khá nhiều. Ví dụ: thông qua CompleteionStage (còn gọi là CompleteableFuture):

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()!!

Có thể thấy, mã ánh xạ đơn giản ban đầu đã trở nên khó hiểu hơn. Điều này là do chúng tôi phải tách các cuộc gọi đến các dịch vụ bên ngoài khỏi nơi sử dụng kết quả. Bản thân điều này không tệ. Nhưng việc kết hợp các cuộc gọi trông không đặc biệt thanh lịch và giống với một món “mì” phản ứng điển hình.

Nếu bạn sử dụng coroutine, mọi thứ sẽ trông ổn hơn:

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()
    )
  }

Trong đó:

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

Về mặt lý thuyết, bằng cách sử dụng quá trình xử lý song song như vậy, chúng tôi sẽ nhận được 200–400 ms, gần với mong đợi của chúng tôi.

Thật không may, sự song song tốt như vậy không xảy ra và cái giá phải trả khá tàn khốc: chỉ với một số ít người dùng làm việc cùng lúc, các dịch vụ sẽ gặp phải một loạt yêu cầu không được xử lý song song, vì vậy chúng tôi sẽ trở lại với 4s buồn bã của chúng tôi.

Kết quả của tôi khi sử dụng dịch vụ như vậy là 1300–1700 ms để xử lý 20 tin nhắn. Việc này nhanh hơn lần triển khai đầu tiên nhưng vẫn không giải quyết được vấn đề.

Các cách sử dụng thay thế của truy vấn song songĐiều gì sẽ xảy ra nếu dịch vụ của bên thứ ba không cung cấp khả năng xử lý hàng loạt? Ví dụ: bạn có thể che giấu việc thiếu triển khai xử lý hàng loạt bên trong các phương thức giao diện:

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())
}

Điều này có ý nghĩa nếu bạn hy vọng thấy được tính năng xử lý hàng loạt trong các phiên bản sau.
Ưu điểm:

  1. Dễ dàng thực hiện xử lý song song dựa trên tin nhắn.
  2. Khả năng mở rộng tốt.

Nhược điểm:

  1. Cần tách biệt việc thu thập dữ liệu khỏi quá trình xử lý dữ liệu khi xử lý song song các yêu cầu đến các dịch vụ khác nhau.
  2. Tăng tải cho các dịch vụ của bên thứ ba.

Có thể thấy rằng phạm vi áp dụng gần giống với cách tiếp cận ngây thơ. Sẽ rất hợp lý khi sử dụng phương thức yêu cầu song song nếu bạn muốn tăng hiệu suất dịch vụ của mình lên nhiều lần do sự bóc lột không thương tiếc của người khác. Trong ví dụ của chúng tôi, năng suất tăng 2,5 lần, nhưng điều này rõ ràng là chưa đủ.

bộ nhớ đệm

Bạn có thể thực hiện lưu vào bộ đệm theo tinh thần JPA cho các dịch vụ bên ngoài, nghĩa là lưu trữ các đối tượng đã nhận trong một phiên để không nhận lại chúng (kể cả trong quá trình xử lý hàng loạt). Bạn có thể tự tạo các bộ đệm như vậy, bạn có thể sử dụng Spring với @Cacheable của nó, ngoài ra, bạn luôn có thể sử dụng bộ đệm được tạo sẵn như EhCache theo cách thủ công.

Một vấn đề phổ biến là bộ đệm chỉ hữu ích nếu chúng có lượt truy cập. Trong trường hợp của chúng tôi, rất có thể có lượt truy cập vào trường tác giả (giả sử là 50%), nhưng sẽ không có lượt truy cập nào vào tệp. Cách tiếp cận này sẽ mang lại một số cải tiến nhưng sẽ không thay đổi hoàn toàn năng suất (và chúng ta cần một bước đột phá).

Bộ đệm xen kẽ (dài) yêu cầu logic vô hiệu hóa phức tạp. Nói chung, bạn càng bắt tay vào giải quyết các vấn đề về hiệu suất bằng cách sử dụng bộ đệm xen kẽ thì càng tốt.

Ưu điểm:

  1. Triển khai bộ nhớ đệm mà không cần thay đổi mã.
  2. Tăng năng suất lên nhiều lần (trong một số trường hợp).

Nhược điểm:

  1. Khả năng giảm hiệu suất nếu sử dụng không đúng cách.
  2. Chi phí bộ nhớ lớn, đặc biệt với bộ đệm dài.
  3. Việc vô hiệu hóa phức tạp, các lỗi sẽ dẫn đến các vấn đề khó tái tạo trong thời gian chạy.

Rất thường xuyên, bộ đệm chỉ được sử dụng để khắc phục nhanh chóng các vấn đề về thiết kế. Điều này không có nghĩa là chúng không nên được sử dụng. Tuy nhiên, bạn phải luôn xử lý chúng một cách thận trọng và trước tiên hãy đánh giá kết quả đạt được về hiệu suất, sau đó mới đưa ra quyết định.

Trong ví dụ của chúng tôi, bộ đệm sẽ giúp tăng hiệu suất khoảng 25%. Đồng thời, bộ nhớ đệm có khá nhiều nhược điểm nên tôi sẽ không sử dụng chúng ở đây.

Kết quả

Vì vậy, chúng tôi đã xem xét cách triển khai ban đầu của một dịch vụ sử dụng xử lý hàng loạt và một số cách đơn giản để tăng tốc dịch vụ đó.

Ưu điểm chính của tất cả các phương pháp này là sự đơn giản, từ đó mang lại nhiều hậu quả dễ chịu.

Vấn đề thường gặp với các phương pháp này là hiệu suất kém, chủ yếu liên quan đến kích thước của gói tin. Vì vậy, nếu những giải pháp này không phù hợp với bạn thì bạn nên xem xét những phương pháp triệt để hơn.

Có hai hướng chính mà bạn có thể tìm kiếm giải pháp:

  • làm việc không đồng bộ với dữ liệu (yêu cầu thay đổi mô hình nên không được thảo luận trong bài viết này);
  • mở rộng các lô trong khi vẫn duy trì xử lý đồng bộ.

Việc mở rộng các lô sẽ giảm đáng kể số lượng lệnh gọi bên ngoài và đồng thời giữ cho mã được đồng bộ. Phần tiếp theo của bài viết sẽ được dành cho chủ đề này.

Nguồn: www.habr.com

Thêm một lời nhận xét