Trong quá trình phát triển EzyPlatform mình đã phải đối mặt với một trong những vấn đề phức tạp đó là tổng hợp dữ liệu, ví dụ như màn hình hồ sơ của người dùng như dưới đây: Screenshot 2024-11-21 at 15.42.25.png

Nó bao gồm cả thông tin người dùng được lấy trong bàng ezy_users và các thông tin về media được lấy trong bảng ezy_medias ngoài ra còn cả danh sách các quyền của người dùng được lấy ở bảng ezy_user_roles nữa như vậy sẽ cần phải suy nghĩ cách giải quyết sao cho tổng quát nhất có thể để tái sử dụng cho các chức năng khác nữa.

Ba cách giải quyết vấn đề tổng hợp dữ liệu.

Đó chính là:

  1. Sử dụng câu lệnh join.
  2. Gọi tuần tự các hàm lấy dữ liệu và tổng hợp.
  3. Sử dụng đa luồng để gọi lấy dữ liệu sau đó tổng hợp lại.

Sử dụng câu lệnh join

Cách này là phổ biến nhất, ưu điểm của nó là dễ tiếp cận, ví dụ bạn cần tổng hợp dữ liệu cho màn hình hồ sơ, bạn có thể khai báo một lớp result thế này:

@Setter
@Getter
@EzyQueryResult
public class UserProfileResult {
    private long userId;
    private String username;
    private long avatarId;
    private long roleId;
   // các trường khác
    private String roleName;
}

Sau đó chúng ta sẽ cần viết câu truy vấn JPQL kiểu thế này để lấy được danh sách người dùng:

SELECT 
    u.id, u.uuid, u.username, u.displayName, u.email, u.phone, 
    avatar.url, banner.url, role.name, // các trường khác
FROM User u
LEFT JOIN Media avatar ON u.avatarImageId = avatar.id
LEFT JOIN Media banner ON u.coverImageId = banner.id
LEFT JOIN UserRole ur ON u.id = ur.userId
LEFT JOIN UserRoleName role ON ur.roleId = role.id

Tuy nhiên như bạn thấy việc viết một loạt các truy vấn join không phải là ý tưởng tốt, đã có tình trạng một lập trình viên viết câu truy vấn dài cả trang A4 thậm chí là vài trang A4 để tổng hợp được dữ liệu, về sau bạn đó nghỉ không ai có thể maintain được nữa.

Câu lệnh join phức tạp sẽ tốn nhiều tài nguyên của máy chủ cơ sở dữ liệu, làm cho khả năng phục vụ của nó kém đi và không đồng đều cho tất cả client.

Ngoài ra việc sử dụng câu lệnh join chỉ phục vụ cho 1 hoặc tối đa một vài nghiệp vụ vậy nên mã nguồn sẽ không tái sử dụng được.

Gọi tuần tự các hàm để lấy dữ liệu

Theo cách này chúng ta sẽ tách một câu lệnh truy vấn lớn thành các hàm nhỏ hơn và gọi tuần tự các hàm này để tổng hợp dữ liệu, ví dụ:

MediaNameModel userAvatarImage = mediaService.getMediaNameById(user.getAvatarImageId();
MediaNameModel userCoverImage =  mediaService.getMediaNameById(user.getCoverImageId();
List<UserRoleNameModel> userRoles", userRoleService.getUserRolesByUserId(user.getId());
viewFactory.newUsersViewBuilder(AdminViewName.PROFILE)
    .addVariable("userAvatarImage", userAvatarImage)
    .addVariable("userCoverImage", userCoverImage)
    .addVariable("userRoles", userRoles)
    .build()

Cách này khắc phục được những nhược điểm của câu lệnh join, nó đơn giản hơn để lập trình, có thể tái sử dụng lại các hàm cho nhiều nghiệp vụ, giảm tải một chút áp lực cho máy chủ cơ sở dữ liệu.

Tuy nhiên cái giá phải trả là tốc độ phục vụ sẽ chậm hơn, không tối ưu do cách này các hàm truy vấn phải chạy tuần tự làm luồng xử lý yêu cầu của máy chủ ứng dụng bị chiếm dụng quá lâu.

Sử dụng đa luồng để lấy và tổng hợp dữ liệu

Reactive.png

Trong cách 2, rõ ràng là các câu lệnh truy vấn là độc lập và chúng không nhất thiết phải chờ đợi nhau làm gì, vậy nên chúng ta hoàn toàn có thể sử dụng nhiều luồng đồng thời gọi các câu lệnh truy vấn và tổng hợp lại khi tất cả các luồng đều đã xong. Đây cũng chính là ý tưởng để EzyPlatform tạo ra lớp Reactive và bạn có thể sử dụng lớp này để tổng hợp dữ liệu, ví dụ như sau:

return Reactive.multiple()
    .register("userAvatarImage", () ->
        mediaService.getMediaNameById(user.getAvatarImageId())
    )
    .register("userCoverImage", () ->
        mediaService.getMediaNameById(user.getCoverImageId())
    )
    .register("userRoles", () ->
        userRoleService.getUserRolesByUserId(
            user.getId()
        )
    )
    .blockingGet(it ->
        viewFactory.newUsersViewBuilder(AdminViewName.PROFILE)
            .addVariables(it.valueMap())
            .build()
    );

Ở đây sẽ có các luồng ngẫu nhiên sẽ được lấy ra để gọi các câu lệnh truy vấn lấy ảnh đại diện, lấy ảnh cover, lấy các quyền cho người dùng, khi cả 3 truy vấn này đã xong thì nó sẽ tổng hợp dữ liệu và giải phóng luồng xử lý yêu cầu, nếu may mắn nó có thể nhanh gấp 3 lần so với cách gọi tuần tự.

Tuy nhiên theo các này cũng có những nhược điểm, nó sẽ nhồi một lượng lớn các truy vấn nhỏ, điều này làm gia tăng áp lực đáng kể cho máy chủ cơ sở dữ liệu. Các câu truy vấn nhỏ có thể được thực thi nhanh, tuy nhiên lại tốn nhiều lần gửi nhận qua mạng nên cũng bị chậm. Khi có quá nhiều câu lệnh truy vấn thì sẽ không có đủ luồng trong thread pool của Reactive và lúc đó thì sẽ là gọi tuần tự.

Ngoài ra Reactive cũng tương đối khó hiểu với các lập trình viên mới vào nghề do nó sinh ra các khái niệm mới như các hàm registerblockingGet hay vấn đề đói thread.

Nguyên lý hoạt động của Reactive

Giả sử bạn có 3 công việc lồng nhau thế này:

EzyPlatform Reactive.png

Reactive sẽ biến đổi chúng thành một stream (dòng) và cấp thread để thực thi chúng.

EzyPlatform Reactive flat.png

Mỗi khi một task trong thread được thực thi xong nó sẽ thông báo cho Reactive biết và Reactive sẽ phản ứng lại bằng cách giảm số kết quả đang chờ xuống một đơn vị, cho đến khi không còn kết quả nào chờ nữa thì nó sẽ kích hoạt hàm (phản ứng) blockingGet.

Vấn đề đói thread (thread starvation)

Quay trở lại với 3 công việc lồng vào nhau cần thực hiện nhưng chỉ có 2 luồng được cấp cho Reactive trong hình ở phần nguyên lý hoạt động. Khi cả 3 công việc bạn đều gọi hàm blockingGet sẽ gây ra tình trạng đói hay không đủ luồng để thực thi, chính vì vậy bạn hãy đảm bảo rằng bạn chỉ gọi hàm blockingGet ở giai đoạn tổng hợp dữ liệu cuối cùng thôi nhé.

Tổng kết lại

Reactive là một trong những lớp tương đối quan trọng của EzyPlatform để giúp hạn chế sử dụng câu lệnh join, tái sử dụng được tối đa mã nguồn đồng thời vẫn cố gắng đảm bảo được hiệu suất tốt nhất có thể. Cài đặt Reactive cũng là một trong những thách thức đối với đội ngũ của Young Monkeys khi phải áp dụng thuật toán để làm phẳng các task thành một stream để giải quyết vấn đề đói luồng. Khi sử dụng bạn cũng cần lưu ý chỉ gọi hàm blockingGet ở nơi tổng hợp dữ liệu cuối cùng thôi nhé.