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. Settime_zone = '+00:00'cho connection. - Postgres:
TIMESTAMPTZthay 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.