← Blog

3 bug production tôi đã ship vì sai múi giờ — và 4 quy tắc tôi rút ra

Server UTC, app local time, database không biết phải lưu gì. Đây là 3 lần tôi đốt thời gian thật cho bug múi giờ và quy tắc tôi sống theo từ đó.

Múi giờ là lý do số 1 khiến code “chạy ngon trên máy bạn” mà bể trên production. Tôi đã ship đủ cả 3 bug múi giờ kinh điển trong 8 năm code — đây là 3 câu chuyện đó, và 4 quy tắc tôi rút ra để không bao giờ dính lại.

Bug #1: Lưu local time vào database, daylight saving đến

Đây là bug tôi ship năm 2019, project quản lý phòng học cho một trung tâm tiếng Anh ở Hà Nội.

Schema lúc đó:

CREATE TABLE class_schedule (
  id INT PRIMARY KEY,
  start_at DATETIME,   -- không có time zone
  duration_minutes INT
);

Insert: 2019-03-25 19:00:00 (7h tối, ca tối).

Trên máy tôi (TZ = Asia/Ho_Chi_Minh), Laravel Carbon::parse('2019-03-25 19:00:00') cho ra đúng 7h tối. Trên server staging cũng VN-tz. Test xong, deploy.

3 tuần sau, khách email: “Mấy lớp ở chi nhánh Singapore của em báo sai giờ 1 tiếng”.

Hóa ra: chi nhánh đó ở Singapore, server thuê AWS Singapore, server-tz mặc định UTC. Khi lớp được tạo từ web Sing, timestamp lưu vào DB là UTC nhưng không có metadata nào nói nó là UTC — chỉ là 2019-03-25 12:00:00. Khi web VN load, code parse lại với tz Việt Nam → ra 12h trưa thay vì 7h tối.

Còn DST? Đây là Asia, không có DST. Nhưng project sau của tôi với US client thì có. Cùng pattern, hậu quả tương tự — nhân đôi mỗi tháng 3 và tháng 11.

Bài học: DATETIME không có time zone là một con dao chờ cứa bạn. Luôn dùng TIMESTAMP (MySQL) hoặc TIMESTAMP WITH TIME ZONE (Postgres) — chúng auto-convert về UTC khi lưu, convert lại khi đọc, dựa trên time_zone của connection.

Bug #2: Cron job server UTC, khách kỳ vọng VN time

Năm 2021, một SaaS B2B cho dealer ô tô. Một feature là “báo cáo doanh số sáng” — gửi email mỗi sáng 8h cho từng dealer.

Tôi viết:

0 8 * * * php /var/www/app/artisan reports:morning

Test local: ngon. Deploy.

Sáng hôm sau: 0 email. Đến 3h chiều thì email mới đến hết.

Server AWS Singapore, default UTC. 0 8 * * * = 8h UTC = 15h VN. Đáng lẽ phải là 0 1 * * * (1h UTC = 8h VN).

Nhưng vấn đề không dừng ở đó. Sau khi fix, một dealer ở Đà Nẵng nói “OK rồi”. 1 tháng sau dealer ở Cần Thơ phàn nàn. Lúc đó tôi mới nhận ra Việt Nam là một múi giờ duy nhất nên may, nhưng nếu là client US chia 4 múi giờ Pacific/Mountain/Central/Eastern thì sao?

Bài học: cron job có business logic phụ thuộc múi giờ thì không nên hardcode trong crontab. Để cron chạy */5 * * * * (mỗi 5 phút check), code application tự đọc DB xem dealer nào đến giờ → gửi. Logic múi giờ nằm ở chỗ có context (DB lưu tz của dealer), không phải ở /etc/crontab xa lạ.

Bug #3: new Date('2024-03-10') interpret khác nhau giữa browser

Năm 2023, làm dashboard analytics. User pick range ngày từ date picker. Frontend gửi lên API:

GET /api/sales?from=2024-03-10&to=2024-03-15

Server (Node, UTC) parse: new Date('2024-03-10')2024-03-10T00:00:00.000Z (UTC midnight). Query DB từ 2024-03-10 00:00 UTC đến 2024-03-15 23:59 UTC.

User VN báo: “Báo cáo từ 10/3 mà tôi thấy có order đêm 9/3 cũng tính vào”.

Đúng. 10/3 UTC midnight = 10/3 7h sáng VN = đêm 9/3 (theo VN) cũng nằm trong khoảng đó.

Sửa: convert input thành “đầu ngày VN” trước khi query:

// Sai
const from = new Date(req.query.from);

// Đúng
const from = new Date(`${req.query.from}T00:00:00+07:00`);

Bug nhỏ hơn nhưng cùng họ: new Date('03/10/2024') — Chrome interpret là Mar 10 (M/D/Y, US format), Safari có thời điểm interpret là Oct 3 (D/M/Y, EU format). Inconsistent giữa browser. Đừng bao giờ parse date từ string không có timezone marker.

4 quy tắc tôi sống theo từ đó

Sau 3 lần đốt thời gian, tôi rút ra 4 quy tắc cứng. Project nào tôi join cũng push team áp dụng:

Quy tắc 1: Database lưu UTC. Luôn luôn.

Cụ thể:

  • MySQL: TIMESTAMP (auto UTC) thay vì DATETIME. Set time_zone = '+00:00' cho connection.
  • Postgres: TIMESTAMPTZ thay vì TIMESTAMP. Khi insert, Postgres convert input về UTC dựa trên session tz.
  • MongoDB: BSON Date là UTC milliseconds tự nhiên — không phải lo.

Server time zone cũng nên là UTC. Không debate.

Quy tắc 2: Convert ở display layer, không ở data layer

Code business logic làm việc với UTC. Chỉ ở edge — khi render UI hoặc nhận input từ user — mới convert sang local time. Mỗi user có một preferred timezone (lưu trong profile, không đoán từ browser).

// Bad — convert giữa logic
const order = await Order.find(id);
order.createdAt = order.createdAt.toLocaleString('vi-VN'); // ⚠️ giờ là string
processOrder(order); // logic kế tiếp ăn string thay vì Date

// Good — convert ngay khi response
res.json({
  ...order,
  createdAtDisplay: formatInTz(order.createdAt, user.timezone),
});

Quy tắc 3: Mọi datetime ra ngoài API phải ISO 8601 với offset

2024-03-10T00:00:00+07:00     ✅
2024-03-10T00:00:00Z          ✅ (Z = UTC)
2024-03-10 00:00:00           ❌ (không tz, ai parse cũng đoán bừa)
03/10/2024                    ❌ (thậm chí không biết là tháng mấy)

Frontend nhận vào, parse bằng new Date(isoString) — tất cả browser hành xử như nhau khi có offset.

Quy tắc 4: Test với 3 múi giờ giả lập

Trước khi deploy bất cứ feature nào liên quan thời gian:

# Test ở UTC
TZ=UTC npm test

# Test ở VN
TZ=Asia/Ho_Chi_Minh npm test

# Test ở US East (có DST!)
TZ=America/New_York npm test

Nếu test pass cả 3 → bug múi giờ có khả năng đã được catch. Pass có 1 → bạn chỉ test trên máy mình thôi.

CI cũng chạy 3 tz này song song cho integration test:

# .github/workflows/test.yml
strategy:
  matrix:
    tz: [UTC, Asia/Ho_Chi_Minh, America/New_York]
env:
  TZ: ${{ matrix.tz }}

Library tôi tin

JavaScript: luxon (kế thừa moment.js, immutable, full tz support qua IANA). Hoặc native Intl.DateTimeFormat cho format đơn giản.

PHP: Carbon — Laravel ship sẵn. Carbon::now('Asia/Ho_Chi_Minh') đáng tin trong 99% case.

Python: zoneinfo (built-in từ 3.9+) thay vì pytz cũ.

Database: dùng DateTime kiểu có tz (TIMESTAMP/TIMESTAMPTZ), không tự reinvent.

Lời cuối

Múi giờ là một trong những phần code mà bug không xuất hiện trên máy bạn. Bạn ở VN, dev local VN, server staging cũng VN — mọi thứ “OK”. Đến khi production deploy lên AWS Singapore (UTC), hoặc khách đầu tiên ở US, bug mới hiện ra — và lúc đó trễ.

Nếu bạn nhớ được 1 thứ duy nhất từ bài này: DB lưu UTC, convert ở display, ISO 8601 với offset trên API. Ba câu đó đã đủ chống được 80% bug múi giờ.

Còn 20% còn lại? Đó là Daylight Saving Time. Nhưng đó là chuyện cho một bài blog đầy nước mắt khác.