Một bức ảnh chụp bằng điện thoại mang theo toạ độ GPS — mở ra là biết chụp ở đâu. Cắt phần dữ liệu đó đi, bạn còn lại một tấm ảnh đẹp nhưng câm: "chụp lúc 9 giờ" mà không ai biết 9 giờ ở đâu. Lưu một thời điểm mà không kèm múi giờ đúng là làm vậy — vứt mất phần metadata quan trọng nhất, rồi để máy chủ đọc lại tự đoán, thường là đoán sai. Gần như mọi cái bẫy múi giờ đều mọc ra từ chỗ này, và chúng chỉ lộ ra khi hệ thống rời khỏi máy của bạn.

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.

Nếu muốn soi nhanh xem mã của mình có ẩn phụ thuộc múi giờ nào không, chạy ứng dụng một lần với TZ=America/New_York rồi so kết quả với lúc chạy bình thường. Bất kỳ con số hay ngày tháng nào đổi chính là một chỗ đang bám vào múi giờ mặc định — và đó đúng là chỗ sẽ hỏng khi lên máy chủ.

Mẫu số chung

Cái bẫy "một con số thời gian trần thì nói dối" không phải đặc sản của Java — nó là hệ quả của việc thời gian vốn có hai khái niệm khác nhau mà ngôn ngữ nào cũng phải tách: một thời điểm tuyệt đối trên trục (Instant) và một cách đọc nó ở một nơi (giờ địa phương + vùng). Ngôn ngữ nào lẫn hai thứ này là sinh ra đúng những cái bẫy trên.

  • Python đặt tên rõ tới mức đáng học: datetime có hai loại — naive (không mang múi giờ) và aware (có tzinfo). Một datetime naive chính là tấm ảnh bị cắt mất GPS ở đầu bài, và cộng đồng Python gọi thẳng quy tắc vàng là "lưu trữ và tính toán bằng UTC, chỉ đổi sang giờ địa phương ở tầng hiển thị" — đúng một câu với lời khuyên "lưu bằng Instant" của ta.
  • JavaScript nổi tiếng là thù địch với múi giờ: Date chỉ là một mốc UTC nhưng mọi phương thức hiển thị lại âm thầm dùng múi giờ của trình duyệt, nên cùng một đoạn mã cho kết quả khác nhau trên máy khác nhau. Chuẩn Temporal mới sinh ra chính để vá chuyện này, với Temporal.Instant và ZonedDateTime tách bạch y như java.time.
  • Go chọn an toàn từ gốc: time.Time luôn mang theo một *Location, nên không có khái niệm "thời điểm trần không vùng" để mà lỡ tay. Rust (crate chrono) đi xa hơn, mã hoá ngay vào kiểu: DateTime<Utc> và DateTime<Local> là hai kiểu khác nhau, trộn lẫn là lỗi biên dịch.

Điểm chung đáng mang theo gói gọn trong một câu, đúng cho mọi ngôn ngữ: lưu và tính bằng thời điểm tuyệt đối (UTC/Instant), chỉ đổi sang giờ địa phương ở mép hiển thị, và không bao giờ tin vào múi giờ mặc định của môi trường. java.time của Java, Temporal của JS, time.Time của Go, chrono của Rust — tất cả hội tụ về đúng thiết kế đó, vì đó là thiết kế duy nhất sống sót qua một đêm đổi giờ.

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.