- Tác giả

- Name
- Nguyễn Đức Xinh
- Ngày xuất bản
- Ngày xuất bản
DateTime Trong MySQL: Tránh Bug Timezone Khi Report
Một trong những nơi bug timezone xuất hiện nhiều nhất không phải ở tầng Backend hay Frontend, mà ở ngay câu SQL query dữ liệu theo ngày. Một report tưởng chừng đơn giản như "lấy dữ liệu hôm nay" hay "lấy dữ liệu hôm qua" lại là nguồn gốc của rất nhiều bug âm thầm: không throw exception, không crash, chỉ lặng lẽ trả về sai tập dữ liệu — thiếu vài giờ đầu ngày, thừa vài giờ cuối ngày, hoặc lệch hẳn sang ngày kế bên.
Bài viết này tập trung vào một chủ đề hẹp nhưng cực kỳ thực tế: query và report DateTime trực tiếp trên MySQL — cách các hàm NOW(), CURDATE(), UTC_TIMESTAMP() thực sự hoạt động, vì sao "hôm nay" của Database chưa chắc là "hôm nay" của Business, và vì sao một câu WHERE DATE(created_at) = '...' tưởng vô hại lại có thể làm chậm report trên bảng hàng triệu record.
Audit Trước Khi Bắt Đầu: Cột Đang Là Kiểu Gì, Timezone Đang Set Gì
Trước khi viết bất kỳ report nào, nên dành 5 phút audit lại hiện trạng của database — rất nhiều bug timezone thực ra đã "nằm sẵn" trong schema hoặc trong cấu hình session từ trước, chỉ chưa bị phát hiện.
1. Kiểm tra kiểu dữ liệu của các audit field trên toàn bộ table:
SELECT
TABLE_NAME,
COLUMN_NAME,
DATA_TYPE,
COLUMN_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND COLUMN_NAME IN ('created_at', 'updated_at', 'deleted_at')
ORDER BY
TABLE_NAME,
FIELD(COLUMN_NAME, 'created_at', 'updated_at', 'deleted_at');
Kết quả cho biết ngay những cột nào đang là TIMESTAMP (dễ bị auto-convert khi đổi session timezone) so với DATETIME (lưu literal cố định), và cột nào đang dùng default DB-side (COLUMN_DEFAULT = CURRENT_TIMESTAMP) — loại default sinh giá trị trực tiếp từ MySQL, không đi qua application code, nên phụ thuộc hoàn toàn vào timezone của DB tại thời điểm insert.
2. Kiểm tra timezone hiện tại của session và của server:
-- Timezone của session hiện tại (kết nối đang dùng)
SELECT @@session.time_zone;
-- Timezone global của toàn server MySQL
SELECT @@global.time_zone;
-- Giá trị "bây giờ" mà DB đang hiểu, theo đúng timezone ở trên
SELECT NOW();
Ba giá trị này nên được đối chiếu lại với timezone mà business thực sự mong đợi. @@session.time_zone và @@global.time_zone không nhất thiết giống nhau — một connection pool hoặc tool báo cáo có thể override session timezone khác với global default của server.
3. Nếu timezone chưa đúng, set lại ở đúng cấp cần thiết:
-- Set timezone cho session hiện tại
SET time_zone = '+09:00';
-- Hoặc set global (cần quyền SUPER, hoặc cấu hình ở RDS Parameter Group)
SET GLOBAL time_zone = 'Asia/Tokyo';
Lưu ý: SET GLOBAL chỉ ảnh hưởng đến các session kết nối mới sau khi lệnh được chạy, không ảnh hưởng ngược lại các session đang mở sẵn. Với RDS/managed MySQL, thường phải cấu hình qua Parameter Group thay vì chạy SET GLOBAL trực tiếp, vì user quản trị của managed service thường không có quyền SUPER.
Trước Khi Query: Luôn Kiểm Tra Timezone Của Session
Trước khi tin vào bất kỳ kết quả nào từ NOW() hay CURDATE(), cần xác nhận session đang query đang hiểu "bây giờ" là mấy giờ:
SELECT NOW();
SELECT CURRENT_DATE();
SELECT CURRENT_TIME();
SELECT UTC_TIMESTAMP();
SELECT CONVERT_TZ(NOW(), @@session.time_zone, '+09:00');
Vài lưu ý quan trọng về các hàm này:
NOW()/CURRENT_TIMESTAMP: trả về ngày-giờ hiện tại theo@@session.time_zone— đây là điểm mấu chốt. Cùng một thời điểm thực tế, hai session cótime_zonekhác nhau sẽ cho ra hai giá trịNOW()khác nhau.CURDATE()/CURRENT_DATE: lấy phần ngày từNOW(), nên cũng phụ thuộc session timezone. Đây chính là hàm gây bug nhiều nhất trong report "hôm nay/hôm qua".UTC_TIMESTAMP()/UTC_DATE(): không phụ thuộc session timezone — luôn trả về giờ UTC thực, dùng để đối chiếu khi nghi ngờ session đang lệch.CONVERT_TZ(dt, from_tz, to_tz): chuyển một giá trị datetime từ timezone này sang timezone khác. Có một cạm bẫy hay bị bỏ sót: nếu dùng tên timezone dạng IANA ('Asia/Tokyo') thay vì offset ('+09:00'), MySQL cần bảngmysql.time_zone_nameđã được nạp dữ liệu (mysql_tzinfo_to_sql) — nếu chưa nạp,CONVERT_TZsẽ âm thầm trả vềNULLthay vì báo lỗi, khiến toàn bộ điều kiệnWHEREliên quan biến mất một cách khó hiểu.
-- An toàn trên mọi server, không cần load timezone tables
SELECT CONVERT_TZ(NOW(), '+00:00', '+09:00');
-- Chỉ hoạt động nếu server đã nạp timezone name tables (mysql_tzinfo_to_sql)
SELECT CONVERT_TZ(NOW(), 'UTC', 'Asia/Tokyo');
Phân Tích Sâu: NOW() Và CURRENT_DATE Thực Sự Trả Về Gì?
Giả sử một hệ thống có cấu hình:
- Application timezone =
Asia/Tokyo - Database session timezone =
Asia/Tokyo - User đang thao tác từ Việt Nam (UTC+7)
Tại cùng một thời điểm thực tế:
Việt Nam: 18/09/2026 15:00
Nhật: 18/09/2026 17:00
UTC: 18/09/2026 08:00
Kết quả các hàm MySQL trả về:
SELECT NOW(); -- 2026-09-18 17:00:00
SELECT CURRENT_DATE; -- 2026-09-18
SELECT UTC_TIMESTAMP(); -- 2026-09-18 08:00:00
Đây không phải là bug — MySQL đang làm đúng việc của nó. Vấn đề chỉ nảy sinh khi Developer đang ngồi ở Việt Nam kỳ vọng NOW() trả về giờ Việt Nam (15:00), trong khi session đang connect với time_zone = Asia/Tokyo. Nếu session timezone đổi khác đi (ví dụ đổi sang UTC sau một lần migrate hạ tầng hay đổi region RDS), toàn bộ 3 giá trị trên sẽ đổi theo — dù không ai UPDATE bất kỳ dòng dữ liệu nào. Đây chính là lý do tuyệt đối không nên "đoán" timezone của session, mà phải luôn SELECT @@session.time_zone; trước khi diễn giải kết quả của NOW()/CURDATE().
Query "Hôm Nay" Theo Đúng Session Timezone
Cách phổ biến và an toàn nhất để lấy dữ liệu "hôm nay" là dùng range thay vì so sánh trực tiếp:
-- Today (theo session timezone)
WHERE created_at >= CURDATE()
AND created_at < CURDATE() + INTERVAL 1 DAY;
Cách này hoạt động đúng miễn là session timezone khớp với business timezone mà report cần. Nếu không khớp — ví dụ business yêu cầu "hôm nay theo giờ Nhật" nhưng session DB đang là UTC — thì CURDATE() sẽ trả về ngày UTC, lệch so với ngày JST thật.
Query Theo Business Timezone Khi Session Không Cùng Timezone
Đây là tình huống thực tế rất hay gặp: DB session đang là UTC (theo convention chung của hệ thống), nhưng report cần "Japan Business Day" — tức là boundary phải tính theo giờ Nhật, không phải giờ UTC.
-- Japan Business Day (khi session đang UTC)
WHERE created_at >= CONVERT_TZ('2026-09-18 00:00:00', '+09:00', 'UTC')
AND created_at < CONVERT_TZ('2026-09-19 00:00:00', '+09:00', 'UTC');
Logic ở đây: xác định boundary theo business timezone trước (00:00 JST đến 24:00 JST của ngày cần lấy), sau đó mới convert cả hai mốc sang UTC để so sánh với cột created_at (đang lưu UTC). Thứ tự này quan trọng — không nên convert created_at sang JST rồi so sánh với ngày literal, vì áp dụng hàm lên cột index sẽ làm mất khả năng dùng index (chi tiết ở phần dưới).
Nguyên tắc chung có thể áp dụng cho mọi loại range tương tự — Last 7 days, This month, Group by day — đều nên xác định rõ business timezone trước khi viết boundary, thay vì mặc định dùng session timezone hiện tại của DB.
-- This month theo Japan Business Timezone, session đang UTC
WHERE created_at >= CONVERT_TZ('2026-09-01 00:00:00', '+09:00', 'UTC')
AND created_at < CONVERT_TZ('2026-10-01 00:00:00', '+09:00', 'UTC');
Vì Sao Không Nên Dùng DATE(created_at) Trong WHERE?
Đây là lỗi rất phổ biến, thường không bị phát hiện cho đến khi bảng dữ liệu đủ lớn để cảm nhận được độ chậm:
-- Không tốt — mất khả năng dùng index
WHERE DATE(created_at) = '2026-09-18'
-- Tốt — SARGable, tận dụng được index trên created_at
WHERE created_at >= '2026-09-18 00:00:00'
AND created_at < '2026-09-19 00:00:00'
WHERE DATE(created_at) = '...' |
WHERE created_at >= ... AND created_at < ... |
|
|---|---|---|
| Áp dụng function lên cột | Có — DATE() bọc quanh cột index |
Không |
Có tận dụng được index trên created_at? |
Thường không (MySQL phải tính DATE() cho từng row để so sánh) |
Có, nếu created_at đã được index |
| Hành vi trên bảng nhỏ | Không nhận ra khác biệt | Không nhận ra khác biệt |
| Hành vi trên bảng lớn (hàng triệu record) | Full table scan, report chậm dần theo thời gian | Dùng range scan trên index, ổn định về hiệu năng |
| Thuật ngữ | Non-SARGable | SARGable (Search ARGument-ABLE) |
Lý do kỹ thuật: khi một hàm được áp dụng lên cột trong điều kiện WHERE (DATE(created_at), MONTH(created_at), YEAR(created_at)...), MySQL không thể dùng B-Tree index để tìm nhanh vị trí bắt đầu — nó buộc phải tính giá trị hàm cho từng row rồi mới so sánh, tương đương full table scan. Ngược lại, so sánh trực tiếp cột với một literal (created_at >= 'x' AND created_at < 'y') cho phép optimizer nhảy thẳng đến vị trí trong index và quét theo range — nhanh hơn rất nhiều lần trên bảng lớn.
Có thể xác minh khác biệt này bằng EXPLAIN:
EXPLAIN SELECT * FROM orders WHERE DATE(created_at) = '2026-09-18';
-- type: ALL (full table scan), key: NULL
EXPLAIN SELECT * FROM orders
WHERE created_at >= '2026-09-18 00:00:00' AND created_at < '2026-09-19 00:00:00';
-- type: range, key: idx_created_at
Nguyên tắc này áp dụng cho mọi biến thể tương tự — tránh MONTH(), YEAR(), WEEK() bọc quanh cột trong WHERE, thay vào đó luôn quy về dạng range [start, end).
Checklist Query/Report DateTime An Toàn
- [ ] Đã kiểm tra
@@session.time_zonecủa connection đang dùng để chạy report, chưa vội tin vàoNOW()/CURDATE(). - [ ] Xác định rõ report cần "hôm nay theo timezone nào" — theo session DB, theo business timezone, hay theo timezone của người xem report.
- [ ] Nếu business timezone khác session timezone, convert boundary (không convert cột) bằng
CONVERT_TZ()trước khi so sánh. - [ ] Dùng offset (
'+09:00') thay vì tên IANA ('Asia/Tokyo') trongCONVERT_TZnếu không chắc server đã nạp timezone tables. - [ ] Không bọc hàm (
DATE(),MONTH(),YEAR()) quanh cột có index trongWHERE— luôn viết dưới dạng range[start, end). - [ ] Với report chạy định kỳ trên bảng lớn, kiểm tra
EXPLAINđể xác nhận query đang dùng index đúng như kỳ vọng. - [ ] Ghi rõ trong tài liệu report: boundary được tính theo timezone nào, để người đọc số liệu sau này không tự suy đoán sai.
Kết Luận
Query DateTime trong MySQL an toàn không nằm ở việc nhớ tên hàm, mà nằm ở việc luôn trả lời được hai câu hỏi trước khi viết bất kỳ điều kiện WHERE nào liên quan đến ngày giờ: "Session này đang hiểu bây giờ là mấy giờ?" và "Report này cần 'hôm nay' theo timezone nào?". Khi hai câu hỏi đó được trả lời rõ ràng, việc chọn giữa CURDATE() trực tiếp hay CONVERT_TZ() để tính boundary trở nên hiển nhiên. Song song đó, luôn viết điều kiện lọc theo ngày dưới dạng range thay vì bọc hàm quanh cột — một thói quen nhỏ nhưng quyết định report của bạn còn chạy nhanh hay không khi bảng dữ liệu lớn dần theo thời gian.
