Bài hôm qua chọn kiểu; bài này về những chỗ hỏng khi hệ thống chạy thật — trên máy chủ ở múi giờ khác, trong container, và qua những đêm đổi giờ.
Bẫy một: lưu giờ địa phương là mất thông tin
Instant goc = Instant.parse("2026-07-01T02:30:00Z");
LocalDateTime luuNham = goc.atZone(vn).toLocalDateTime(); // lưu vào CSDL
Instant gốc : 2026-07-01T02:30:00Z
lưu LocalDateTime : 2026-07-01T09:30 <- không còn múi giờ
máy chủ ở NY đọc lại : 2026-07-01T13:30:00Z
-> lệch 11 giờ
Chuỗi 2026-07-01T09:30 không mang thông tin nào về nơi nó được sinh ra. Máy chủ đọc lại sẽ hiểu theo múi giờ của nó, và bạn có một sự kiện lệch 11 tiếng.
Điều làm lỗi này khó phát hiện: khi mọi thứ chạy trên một máy chủ, nó hoàn toàn đúng. Nó chỉ hỏng khi bạn thêm máy chủ thứ hai ở vùng khác, hoặc chuyển sang cloud, hoặc chạy trong container có TZ khác với máy phát triển.
Với PostgreSQL, khác biệt là timestamp và timestamptz. Cột timestamp không lưu múi giờ, và đó gần như luôn là lựa chọn sai cho thời điểm sự kiện.
Bẫy hai: equals không phải isEqual
9h Hà Nội : 2026-07-01T09:00+07:00[Asia/Ho_Chi_Minh]
22h New York: 2026-06-30T22:00-04:00[America/New_York]
isEqual (cùng thời điểm) : true
equals (so cả múi giờ) : false <- KHÁC nhau!
compareTo : 1
Cùng một khoảnh khắc trên trục thời gian, ba phép so cho ba câu trả lời khác nhau.
isEqual so thời điểm — đây gần như luôn là thứ bạn muốn.
equals so cả thời điểm lẫn múi giờ. Hai ZonedDateTime chỉ bằng nhau khi cùng vùng.
compareTo so thời điểm trước, nhưng khi bằng nhau thì nó so tiếp giờ địa phương và tên vùng để có thứ tự ổn định — nên nó trả về 1 ở đây, dù isEqual nói bằng nhau.
Hệ quả rất thực tế: ZonedDateTime là khoá HashMap hay phần tử TreeSet rất tệ. HashSet dùng equals nên giữ hai bản của cùng một thời điểm; TreeSet dùng compareTo nên cũng vậy — đúng cái bẫy compareTo không nhất quán với equals ở bài Comparable.
Cách tránh: so sánh và lưu trữ bằng Instant, chỉ đổi sang ZonedDateTime khi hiển thị.
Bẫy ba: lịch chạy hằng ngày trượt giờ
Giả sử bạn muốn một tác vụ chạy "mỗi ngày 8 giờ sáng giờ London", và bạn cài lịch bằng cách cộng 24 giờ:
ngày 1: 27/03 08:00
ngày 2: 28/03 08:00
ngày 3: 29/03 09:00 <- đổi giờ mùa hè
ngày 4: 30/03 09:00
Từ ngày thứ ba trở đi, tác vụ chạy lúc 9 giờ. Và nó không bao giờ tự quay lại 8 giờ — cho tới lần đổi giờ tiếp theo.
Cộng theo lịch thì đúng:
z.plusDays(i) // thay vì instant.plus(Duration.ofDays(i))
ngày 1: 27/03 08:00
ngày 2: 28/03 08:00
ngày 3: 29/03 08:00
ngày 4: 30/03 08:00
Đây chính là khác biệt Period và Duration của bài hôm qua, áp vào một bài toán thật.
Với ScheduledExecutorService, chỉ có scheduleAtFixedRate theo khoảng thời gian cố định — nên nó không dùng được cho "mỗi ngày lúc 8 giờ". Phải tính thời điểm chạy kế tiếp bằng ZonedDateTime rồi lên lịch một lần, và tính lại sau mỗi lần chạy. Quartz và Spring @Scheduled(cron=...) làm sẵn việc đó, nhưng chúng cần biết múi giờ — đừng quên tham số zone.
Một ngày không phải lúc nào cũng 24 giờ
29/03/2026 ở London dài: 23 giờ
25/10/2026 ở London dài: 25 giờ
Đây là giả định bị vi phạm nhiều nhất trong mã xử lý thời gian. Mọi phép tính kiểu soNgay * 24 * 60 * 60 * 1000 đều sai ở hai ngày mỗi năm.
Cách đúng là để java.time tính:
Duration.between(ngay.atStartOfDay(vung), ngay.plusDays(1).atStartOfDay(vung))
Và cũng đừng giả định "nửa đêm luôn tồn tại" — ở vài nơi như Brazil từng có năm mà đồng hồ nhảy từ 23:59 sang 01:00, nên atStartOfDay trả về 01:00. Nó xử lý đúng, miễn là bạn dùng nó thay vì tự ghép LocalTime.MIDNIGHT.
Đừng dùng offset cố định
ZoneId.of("GMT+1") mùa hè : 2026-07-01T12:00+01:00[GMT+01:00]
Europe/London mùa hè : 2026-07-01T12:00+01:00[Europe/London]
Europe/London mùa đông: 2026-01-01T12:00Z[Europe/London]
GMT+1 luôn là +1. Europe/London là +1 vào mùa hè và +0 vào mùa đông.
Nên ZoneId.of("GMT+7") cho Việt Nam tình cờ đúng vì Việt Nam không có giờ mùa hè — nhưng nó vẫn sai về nguyên tắc, và sẽ sai thật nếu quy tắc đổi. Luôn dùng tên vùng IANA: Asia/Ho_Chi_Minh, Europe/London, America/New_York.
ZoneOffset chỉ đúng chỗ khi bạn thật sự muốn một độ lệch cố định — ví dụ khi phân tích một chuỗi ISO-8601 đã mang sẵn offset.
Múi giờ của JVM
ZoneId.systemDefault() : Asia/Ho_Chi_Minh
biến môi trường TZ : Asia/Ho_Chi_Minh
user.timezone : Asia/Ho_Chi_Minh
JVM lấy múi giờ theo thứ tự: thuộc tính user.timezone, rồi biến môi trường TZ, rồi cấu hình hệ điều hành.
Trong container, mặc định gần như luôn là UTC — image cơ sở không có cấu hình vùng. Đây là nguồn của một lỗi rất hay gặp: mã chạy đúng trên máy lập trình viên (múi giờ địa phương) rồi lệch bảy tiếng trên máy chủ.
Ba cách xử lý, theo thứ tự tôi khuyên:
Đừng phụ thuộc vào mặc định. Mọi chỗ cần múi giờ thì khai tường minh. LocalDate.now() và Instant.now().atZone(ZoneId.systemDefault()) đều là chỗ ẩn phụ thuộc.
Đặt TZ trong container nếu ứng dụng có logic theo giờ địa phương — và nhớ cài tzdata trong image, vì nhiều image tối giản không có.
Đừng dùng TimeZone.setDefault() trong mã. Nó đổi trạng thái toàn cục của cả JVM và ảnh hưởng mọi luồng, kể cả thư viện.
Danh sách kiểm khi làm việc với thời gian
Lưu bằng Instant hay timestamptz, không bao giờ lưu giờ địa phương cho thời điểm sự kiện.
Ngoại lệ: ngày sinh, ngày lễ, giờ mở cửa — đó là khái niệm lịch, dùng LocalDate/LocalTime mới đúng. "Sinh nhật 15/03" không đổi khi bạn bay sang châu Âu.
Lưu cả múi giờ người dùng nếu cần hiển thị lại đúng ngữ cảnh của họ. Một cột Instant cộng một cột zone_id.
So sánh bằng isBefore, isAfter, isEqual trên Instant.
Kiểm tra TZ của môi trường chạy như một phần của danh sách triển khai.
Test với ít nhất hai múi giờ, và với một ngày đổi giờ mùa hè. Clock.fixed(instant, zone) làm việc này rất gọn.
Thử ba mươi giây
Chạy ứng dụng của bạn với TZ=America/New_York rồi so kết quả với lúc chạy bình thường.
Nếu có bất kỳ con số hay ngày tháng nào đổi, bạn vừa tìm ra một chỗ đang phụ thuộc vào múi giờ mặc định — và đó chính là chỗ sẽ hỏng khi lên máy chủ.
Ngày mai: biểu thức chính quy — nhóm, tham lam và không tham lam, và một regex chỉ mười ký tự đủ làm treo CPU.