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ì?


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_at
và agreed_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" ) ) );
}

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()
là 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

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

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
- Mở file CustomerMapper.java
- 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();
}

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




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ờ.


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:
- Throw back: chuyến đi Thái Lan đáng nhớ của tôi
- Lỗi trên môi trường Staging mà không ai chịu sửa đến cùng
- Filter Spring và pha xử lý bug nhớ đời
- Những thói quen tốt trong ngành phát triển phần mềm
- Viết code với những thói quen tốt này sẽ giúp bạn giỏi hơn
- Kĩ năng quản lý căng thẳng cho Developer
- Làm việc trong môi trường Agile là như thế nào
- Là kĩ sư phần mềm hãy cố gắng giữ gìn sức khỏe bản thân
- Bạn không giỏi lắng nghe như bạn nghĩ đâu
- Rest API: Cách Ngăn Chặn Duplicate Request Hiệu Quả
- Có CodeRabbit làm tôi review code cũng nhàn hơn