Hôm nọ trong lúc làm Job đồng bộ 5.2M triệu dữ liệu khách hàng, mình có gặp 1 issue nhỏ liên quan đến việc sử dụng Mapstruct để mapping dữ liệu qua lại, qua đó có cái nhìn rõ ràng hơn về cách Mapstruct mapping các trường dữ liệu dạng LocalDateTime.

Issue là gì

Tổng quát hoá vấn đề như này, mình có hai bảng dữ liệu:

  • Bảng 1 là bảng nguồn, lưu trữ thông tin khách hàng (lượng dữ liệu lớn) và có trường verify_account_at ám chỉ là thời điểm khách hàng xác thực, được lưu dạng Timestamp trong Oracle
  • Bảng 2 là bảng lưu trữ thông tin khách hàng đồng ý điều kiện điều khoản, có trường agreed_at ám chỉ là thời điểm khách hàng xác nhận đồng ý điều kiện điều khoản, trường này sẽ lấy giá trị từ Bảng 1 để lưu vào với từng khách hàng (kiểu dữ liệu cũng là Timestampt trong Oracle)

Nghe chừng thì đơn giản lắm luôn, chỉ lấy ra và lưu lại thôi thì vấn đề là gì?

Dữ liệu bảng 1 (bảng nguồn)
Dữ liệu bảng 2 (bảng lưu thông tin điều kiện, điều khoản)

Sau khi nhìn sơ qua về dữ liệu, thấy chênh lệch 7 tiếng giữa hai trường verify_account_atagreed_at thì mình phán đoán ngay là có sự chênh lệch của múi giờ, giữa giờ UTC+7 (ví dụ: giờ Việt Nam – ICT) và UTC (Giờ Phối hợp Quốc tế).

Nhưng quan trọng là việc lệch ở đây sẽ xuất phát ở bước nào?, chúng ta sẽ cần phải tìm hiểu chi tiết, có thể là từ bước lấy ra dữ liệu trong database nguồn ở bảng 1, hoặc bước ghi nhận dữ liệu vào database đích ở bảng 2, hoặc trong quá trình bắn event qua lại giữa hai Process gây ra chênh lệch,…

Tìm nguyên nhân gây lỗi

Trước tiên phân tích Step 1, chúng ta lấy ra cột verify_account_at ở bảng 1, cột này có kiểu dữ liệu là TIMESTAMP (không có thông tin múi giờ) trong Oracle, nó đang lưu giá trị là giờ địa phương (local time), ví dụ: 2025-06-25 16:38:33.728000.

Step 1 này, sau khi lấy ra được đối tượng khách hàng cụ thể, có trường verify_account_at kiểu LocalDateTime (kiểu dữ liệu này chỉ biểu diễn ngày và giờ, ví dụ “16:38:33 ngày 25-06-2025”, mà không biết đó là 16:38 ở Hà Nội, London hay New York,…)

Trước khi phân tích Step 2 ở việc lưu dữ liệu, thì ở bước giữa Step 1 và Step 2 có 1 đoạn mapping dữ liệu bằng Mapstruct khi bắn event qua hai process khác nhau, các bạn xem hình ảnh bên dưới để hiểu thêm flow nhé.

Process ở bước 1 sẽ scan toàn bộ tài khoản customer, trong từng thread sẽ xử lý một vài nghiệp vụ logic, mapping lại data customer bằng Mapstruct, sau đó bắn event vào queue ở bước 2, Process ở bước 2 sẽ lấy ra và xử lý thêm ít logic, rồi lưu vào database.

Giờ chúng ta sẽ tiến hành debug một chút xem lỗi nằm ở bước nào:

Tới đoạn này chúng ta sẽ Evaluate một chút xem giá trị lấy ra từ hàm getVerifyAccountAt đang trả dữ liệu như nào nhé.

Tới đoạn này chúng ta đã nhìn ra vấn đề rồi, đoạn code .toInstant()ở trong Mapstruct gây ra sai khác thời gian, nhưng tại sao nó lại gây ra điều này?

if ( iCustomerViewDto.getVerifyAccountAt() != null ) {
            customerDto.setVerifyAccountAt( LocalDateTime.ofInstant( iCustomerViewDto.getVerifyAccountAt().toInstant(), ZoneId.of( "UTC" ) ) );
}
Timezone trong project

Phương thức iCustomerViewDto.getVerifyAccountAt() trả về một đối tượng Timestamp, ở đây có giá trị là 2025-06-25 16:38:33.728.

Khi gọi .toInstant(), Java hiểu rằng thời điểm này đang ở múi giờ mặc định của JVM (ở đây là UTC+7) thì kết quả sẽ được chuyển đổi sang múi giờ UTC.

Cụ thể, thời điểm: 2025-06-25 16:38:33.728 ở UTC+7 → tương đương với 2025-06-25 09:38:33.728 ở UTC.

Kết quả của .toInstant()Instant mang giá trị 2025-06-25T09:38:33.728Z

Tiếp tục, khi dùng LocalDateTime.ofInstant(..., ZoneId.of("UTC")), Java tạo ra một đối tượng LocalDateTime từ thời điểm 09:38:33.728 UTC, nên kết quả cuối cùng thu được sẽ là:
2025-06-25T09:38:33.728

Event sau khi mapping

Phía trên là event sau khi trải qua quá trình quét và mapping dữ liệu.

dữ liệu trước khi chèn vào database

Và đây là dữ liệu agreements trước khi chèn vào database bảng 2 đang ghi nhận thời gian UTC.

Cách xử lý

Phương án xử lý tốt nhất hiện tại đó là dạy MapStruct cách chuyển đổi đúng, bằng việt viết thêm một hàm default trong mapper

  1. Mở file CustomerMapper.java
  2. Thêm một default method để chuyển từ Timestamp sang LocalDateTime, logic đúng đơn giản là gọi .toLocalDateTime()
    @Mapping(source = "verifyAccountAt", target = "verifyAccountAt", qualifiedByName = "mappingTime")
    CustomerDto mapCustomerToDto(ICustomerViewDto iCustomerViewDto);


    List<CustomerDto> from(List<ICustomerViewDto> views);

    @Named("mappingTime")
    default LocalDateTime mappingTime(Timestamp timestamp) {
        if (timestamp == null) {
            return null;
        }
        return timestamp.toLocalDateTime();
    }
File CustomerMapperImpl mà Mapstruct đã generate lại

Oke giờ chúng ta sẽ debug lại và xem kết quả cuối cùng

evaluate giá trị

timestamp.toLocalDateTime() chỉ đơn thuần chuyển đổi các thành phần ngày giờ trực tiếp mà không quan tâm múi giờ.

Bảng nguồn 1 chứa dữ liệu verify_account_at
Bảng đích lưu thông tin agreed_at

Dữ liệu đã hoàn toàn chính xác ở bảng đích, giá trị trường agreed_at đã lưu bằng với verify_account_at ở bảng nguồn, vấn đề đã được giải quyết, Perfect Money

Xem thêm một số bài viết nổi bật bên dưới: