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à timestamptimestamptz. 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 PeriodDuration 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()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.