Giao diện
Thời gian & múi giờ
Lỗi thời gian là loại lỗi khó phát hiện nhất: chạy đúng trên máy dev, sai ở server đặt múi giờ khác, sai với khách ở nước khác, hay chỉ sai vào đúng đêm đổi giờ mùa hè. Trang này giải thích cách phân biệt các loại "thời gian", nên lưu thế nào, kiểu dữ liệu nào làm gì trong MySQL và PostgreSQL, những cái bẫy ở driver của Node.js, và cách gom số liệu theo ngày cho đúng.
Ba loại thời gian khác nhau
| Loại | Ví dụ | Nên lưu |
|---|---|---|
| Thời điểm (instant): một khoảnh khắc trên dòng thời gian, như nhau với mọi người ở mọi nơi | Đơn hàng được tạo, user đăng nhập, giao dịch chuyển tiền | Thời điểm ở UTC |
| Ngày theo lịch, không gắn với múi giờ | Sinh nhật, ngày hiệu lực hợp đồng, ngày nghỉ lễ | Kiểu DATE, không có giờ, không có múi giờ |
| Giờ địa phương trong tương lai, gắn với một nơi | Lịch hẹn 9 giờ sáng 15/3 năm sau ở Hà Nội, giờ mở bán vé | Giờ địa phương + tên múi giờ (Asia/Ho_Chi_Minh) |
Nhầm loại này với loại kia là nguồn gốc của phần lớn lỗi. Lưu sinh nhật thành một thời điểm UTC (nửa đêm 15/3 giờ Việt Nam là 17:00 ngày 14/3 UTC) thì người dùng ở múi giờ khác thấy sinh nhật lùi một ngày.
Quy tắc chung: lưu thời điểm ở UTC, đổi sang giờ địa phương khi hiển thị, theo múi giờ của người xem. Server và database cũng nên đặt múi giờ UTC, để không có giá trị nào bị đổi ngầm theo múi giờ của máy.
Kiểu dữ liệu trong MySQL và PostgreSQL
| Có quy đổi theo múi giờ của phiên | Ghi chú | |
|---|---|---|
MySQL DATETIME | Không: lưu và trả về đúng những gì được ghi | Ghi vào thế nào đọc ra thế ấy, nên app phải tự đảm bảo luôn ghi UTC |
MySQL TIMESTAMP | Có: đổi từ múi giờ của phiên sang UTC khi lưu, đổi ngược lại khi đọc | Chỉ tới 2038-01-19 03:14:07 UTC (MySQL chương 2) |
PostgreSQL timestamptz | Có: lưu một thời điểm, hiển thị theo TimeZone của phiên | Nên dùng cho mọi thời điểm |
PostgreSQL timestamp | Không | Như DATETIME; dễ gây lỗi khi dùng cho thời điểm |
Chạy thử: ghi 10:00 khi múi giờ của phiên là +07:00, rồi đọc lại khi phiên đặt +00:00. DATETIME và timestamp vẫn trả 10:00, còn TIMESTAMP và timestamptz trả 03:00, tức đúng cùng một thời điểm nhìn từ UTC. Ghi 2040-01-01 vào cột TIMESTAMP của MySQL thì báo lỗi.
Với PostgreSQL, timestamptz gần như luôn là lựa chọn đúng cho thời điểm. Với MySQL, DATETIME được dùng nhiều vì không bị giới hạn năm 2038, với điều kiện cả app và database thống nhất luôn ghi UTC.
Bẫy ở driver Node.js
Kiểu dữ liệu đúng vẫn chưa đủ, vì driver chuyển Date của JavaScript thành chuỗi gửi cho database, và cách chuyển phụ thuộc vào cấu hình:
- mysql2: tuỳ chọn
timezonemặc định là'local', tức chuyểnDatesang giờ của process Node. Trên lab, cùng mộtnew Date('2025-06-01T03:00:00Z')được lưu thành03:00khi process chạy vớiTZ=UTC, nhưng thành10:00khi process chạy vớiTZ=Asia/Ho_Chi_Minh, ở cả cộtDATETIMElẫnTIMESTAMP. Một server deploy với múi giờ khác máy dev là đủ làm dữ liệu lệch 7 tiếng mà không có lỗi nào. - pg: cột
timestamptzlưu đúng thời điểm dù process ở múi giờ nào. Cộttimestamp(không có múi giờ) thì lệch y như trên.
Cách phòng:
- Đặt
TZ=UTCcho process Node trên mọi môi trường, kể cả máy dev và CI. - Với mysql2, đặt
timezone: 'Z'để driver luôn đổiDatesang UTC, không phụ thuộc máy. Trên lab, với tuỳ chọn này, process chạy giờ Việt Nam cũng lưu đúng03:00. - Với PostgreSQL, dùng
timestamptzcho thời điểm. - Viết một test ghi rồi đọc lại một
Datevà so sánh, chạy vớiTZkhác UTC, để bắt lỗi cấu hình sớm.
Lịch hẹn trong tương lai
Luật múi giờ thay đổi thường xuyên hơn nhiều người nghĩ: các nước bỏ hay áp dụng giờ mùa hè, đổi múi giờ, có khi chỉ báo trước vài tuần. Nếu lúc khách đặt hẹn "9 giờ sáng ngày 15/3 năm sau", hệ thống đổi ngay sang UTC theo luật hiện tại rồi chỉ lưu UTC, thì khi luật đổi, thời điểm UTC đó không còn ứng với 9 giờ sáng nữa:
| Cách lưu | Lưu trong database | Thời điểm UTC dùng để nhắc hẹn | Khách thấy |
|---|---|---|---|
| A. Chỉ lưu thời điểm UTC (đổi lúc đặt, theo +07:00) | 02:00Z | 02:00Z | 09:00 |
| B. Lưu giờ địa phương + tên múi giờ | 09:00, vung_gia_dinh | 02:00Z (tính theo luật +07:00) | 09:00 |
Khi luật không đổi, hai cách cho cùng một kết quả. Bật thay đổi luật để thấy khác biệt. Ngoài đời, các nước vẫn thường đổi luật giờ: bỏ hay áp dụng giờ mùa hè, đổi múi giờ, đôi khi chỉ báo trước vài tuần.
Vùng và độ lệch là giả định; dữ liệu múi giờ thật (tz database) được cập nhật vài lần mỗi năm.
Vì vậy với lịch hẹn trong tương lai, lưu giờ địa phương và tên múi giờ (2026-03-15 09:00, Asia/Ho_Chi_Minh), chứ không lưu độ lệch (+07:00), vì độ lệch là thứ có thể thay đổi. Chỉ đổi sang UTC khi cần, ví dụ khi lên lịch gửi nhắc nhở, bằng dữ liệu múi giờ mới nhất. Sự kiện đã xảy ra rồi thì vẫn lưu UTC như bình thường, vì quá khứ không đổi.
Việt Nam không có giờ mùa hè nên ít gặp chuyện này, nhưng hệ thống phục vụ khách ở nhiều nước thì gặp. Ở những nước có giờ mùa hè còn có hai trường hợp đặc biệt: có giờ không tồn tại (lúc chỉnh đồng hồ nhảy lên) và có giờ xuất hiện hai lần (lúc chỉnh lùi lại), nên phải quyết định trước sẽ xử lý thế nào.
Gom theo ngày: ngày của ai?
Báo cáo "doanh thu theo ngày" phải nói rõ ngày theo múi giờ nào. Đơn hàng lúc 2025-01-01 18:00 UTC là 01:00 sáng ngày 2/1 ở Việt Nam. Gom theo ngày UTC thì nó thuộc ngày 1/1, gom theo giờ Việt Nam thì thuộc ngày 2/1. Trên lab, cùng 5 đơn hàng, gom theo ngày UTC ra 2 ngày (600 và 900), còn gom theo giờ Việt Nam ra 3 ngày (300, 700 và 500).
sql
-- MySQL: created_at lưu UTC
SELECT DATE(CONVERT_TZ(created_at, '+00:00', '+07:00')) AS ngay, SUM(total)
FROM orders GROUP BY ngay;
-- PostgreSQL: created_at là timestamptz
SELECT (created_at AT TIME ZONE 'Asia/Ho_Chi_Minh')::date AS ngay, SUM(total)
FROM orders GROUP BY ngay;Hai điểm cần chú ý:
CONVERT_TZcủa MySQL với tên múi giờ ('Asia/Ho_Chi_Minh') cần bảng múi giờ đã được nạp vào database, như tài liệu MySQL lưu ý. Image Docker chính thức đã nạp sẵn, nhưng cài đặt khác có thể chưa, và khi đó hàm trả về NULL. Dùng độ lệch ('+07:00') thì không cần bảng, nhưng chỉ đúng với nơi không có giờ mùa hè.- Điều kiện lọc theo khoảng ngày cũng phải đổi cùng múi giờ, và nên viết sao cho vẫn dùng được index trên
created_at: đổi biên của khoảng sang UTC (created_at >= '2025-01-01 17:00' AND created_at < '2025-01-02 17:00'cho ngày 2/1 giờ Việt Nam), thay vì bọc hàm quanh cột.
Bảng tổng hợp theo ngày (Bảng tổng hợp) cũng phải chọn múi giờ ngay từ đầu, vì đã gom theo ngày UTC thì không tách lại theo ngày giờ Việt Nam được nữa.
Đồng hồ của server
Thời gian lấy từ đồng hồ máy có thể lệch giữa các máy hàng chục ms, và có lúc nhảy lùi khi đồng bộ NTP. Đừng dùng thời gian của máy để xếp thứ tự các sự kiện đến từ nhiều máy, hay để quyết định lệnh ghi nào "mới hơn" (Hệ phân tán › Thời gian). Trong một database, thứ tự đáng tin là thứ tự commit, hoặc một cột số tăng dần do chính database sinh ra.
Ghi nhớ
- Phân biệt thời điểm (lưu UTC), ngày theo lịch (lưu
DATE) và giờ địa phương trong tương lai (lưu giờ địa phương + tên múi giờ). - MySQL
TIMESTAMPvà PostgreSQLtimestamptzquy đổi theo múi giờ của phiên;DATETIMEvàtimestampthì không.TIMESTAMPchỉ tới năm 2038. - mysql2 mặc định đổi
Datetheo múi giờ của process Node: đặtTZ=UTCcho process vàtimezone: 'Z'cho driver; với PostgreSQL dùngtimestamptz. - Lịch hẹn tương lai lưu tên múi giờ, không lưu độ lệch, vì luật múi giờ có thể đổi.
- Báo cáo theo ngày phải nói rõ múi giờ; đổi biên của khoảng ngày sang UTC để vẫn dùng được index.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-23):
| Nguồn | Dùng cho phần |
|---|---|
| MySQL 8.0 Reference Manual, The DATE, DATETIME, and TIMESTAMP Types | TIMESTAMP đổi từ múi giờ hiện tại sang UTC khi lưu và đổi ngược khi đọc; phạm vi tới 2038-01-19 03:14:07 UTC |
| MySQL 8.0 Reference Manual, Date and Time Functions | Dùng tên múi giờ trong CONVERT_TZ cần bảng múi giờ đã được cài đặt |
Kiểm bằng lab: hành vi của bốn kiểu dữ liệu khi đổi múi giờ của phiên; giá trị mysql2 (mặc định và timezone: 'Z') và pg ghi khi process có TZ khác nhau; bảng múi giờ có sẵn trong image mysql:8.0 (1.795 tên); kết quả gom theo ngày. Từ kiến thức chung, chưa đối chiếu lần này: tên giá trị mặc định 'local' của tuỳ chọn timezone trong tài liệu mysql2; CONVERT_TZ trả NULL khi chưa nạp bảng; tz database và các lần đổi luật múi giờ.
Số đo chi tiết
Chạy bằng script lab/mui-gio/tz.mjs ngày 2026-10-23 trên MySQL 8.0 và PostgreSQL 17 trong Docker (server đặt UTC).
Kết quả chạy thử
| Phép thử | Kết quả |
|---|---|
MySQL, ghi 10:00 lúc time_zone = '+07:00', đọc lúc '+00:00' | DATETIME 10:00, TIMESTAMP 03:00 |
MySQL, ghi 2040-01-01 vào TIMESTAMP | Lỗi Incorrect datetime value |
PostgreSQL, ghi 10:00 lúc TimeZone = Asia/Ho_Chi_Minh, đọc lúc UTC | timestamp 10:00, timestamptz 03:00+00 |
mysql2, new Date('2025-06-01T03:00:00Z'), process TZ=UTC | DATETIME 03:00, TIMESTAMP 03:00 |
mysql2, cùng giá trị, process TZ=Asia/Ho_Chi_Minh | DATETIME 10:00, TIMESTAMP 10:00 (lệch 7 giờ) |
mysql2 với timezone: 'Z', process TZ=Asia/Ho_Chi_Minh | DATETIME 03:00, TIMESTAMP 03:00 (đúng) |
pg, cùng giá trị, process TZ=Asia/Ho_Chi_Minh | timestamp 10:00, timestamptz 03:00+00 (đúng) |
CONVERT_TZ('2025-01-01 20:00', 'UTC', 'Asia/Ho_Chi_Minh') | 2025-01-02 03:00:00 (image đã nạp 1.795 tên múi giờ) |
| 5 đơn hàng gom theo ngày UTC | 1/1 = 600, 2/1 = 900 |
| Cùng 5 đơn gom theo ngày giờ Việt Nam (MySQL và PostgreSQL) | 1/1 = 300, 2/1 = 700, 3/1 = 500 |
Code đã dùng để chạy thử
Chạy bằng npm run lab -- mui-gio sau npm run lab:up, vài giây. Xem Lab.
lab/mui-gio/tz.mjs
js
// Trang database/thoi-gian: thời điểm, múi giờ và driver.
// 1. MySQL TIMESTAMP và DATETIME khi đổi time_zone của session; PostgreSQL timestamptz và timestamp khi đổi TimeZone.
// 2. Cùng một new Date() ghi qua mysql2 và pg, chạy trong process có TZ=UTC và TZ=Asia/Ho_Chi_Minh: database nhận giá trị nào.
// 3. CONVERT_TZ với tên múi giờ khi MySQL chưa nạp bảng múi giờ.
// 4. Doanh thu theo ngày: gom theo ngày UTC và theo ngày giờ Việt Nam.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'
const fmtDate = (v) => (v instanceof Date ? v.toISOString() + ' (Date)' : String(v))
// Phần 2 chạy trong process con với TZ khác nhau: node mui-gio/tz.mjs write <nhãn>
if (process.argv[2] === 'write') {
const label = process.argv[3]
const at = new Date('2025-06-01T03:00:00Z') // 10:00 giờ Việt Nam
// timezone mặc định của mysql2 là 'local'; lượt thứ ba đặt 'Z' để driver luôn đổi Date sang UTC
const my = await mysql.createConnection({ ...MYSQL, database: 'tz', ...(process.argv[4] === 'Z' ? { timezone: 'Z' } : {}) })
await my.query('INSERT INTO events_my (label, at_dt, at_ts) VALUES (?, ?, ?)', [label, at, at])
await my.end()
const p = new pg.Client({ ...PG, database: 'tz' })
await p.connect()
await p.query('INSERT INTO events_pg (label, at_plain, at_tz) VALUES ($1, $2, $3)', [label, at, at])
await p.end()
process.exit(0)
}
ensureDb('mysql', 'tz')
ensureDb('pg', 'tz')
const my = await mysql.createConnection({ ...MYSQL, database: 'tz', dateStrings: true })
const p = new pg.Client({ ...PG, database: 'tz' })
await p.connect()
// Đọc kiểu thời gian dưới dạng chuỗi, để thấy đúng giá trị database trả về.
for (const oid of [1082, 1114, 1184]) pg.types.setTypeParser(oid, (s) => s)
console.log('\n=== 1. MySQL: TIMESTAMP và DATETIME ===')
await my.query('DROP TABLE IF EXISTS t')
await my.query('CREATE TABLE t (dt DATETIME, ts TIMESTAMP)')
await my.query("SET time_zone = '+07:00'")
await my.query("INSERT INTO t VALUES ('2025-06-01 10:00:00', '2025-06-01 10:00:00')")
for (const tz of ['+07:00', '+00:00']) {
await my.query(`SET time_zone = '${tz}'`)
const [[r]] = await my.query('SELECT dt, ts FROM t')
console.log(`Ghi lúc time_zone = +07:00, đọc lúc time_zone = ${tz}: DATETIME ${r.dt}, TIMESTAMP ${r.ts}`)
}
try { await my.query("INSERT INTO t VALUES ('2040-01-01', '2040-01-01')"); console.log('TIMESTAMP 2040-01-01: ghi được') }
catch (e) { console.log(`TIMESTAMP 2040-01-01: LỖI ${e.message}`) }
console.log('\n=== 1. PostgreSQL: timestamptz và timestamp ===')
await p.query('DROP TABLE IF EXISTS t')
await p.query('CREATE TABLE t (plain timestamp, tz timestamptz)')
await p.query("SET TIME ZONE 'Asia/Ho_Chi_Minh'")
await p.query("INSERT INTO t VALUES ('2025-06-01 10:00:00', '2025-06-01 10:00:00')")
for (const tz of ['Asia/Ho_Chi_Minh', 'UTC']) {
await p.query(`SET TIME ZONE '${tz}'`)
const { rows: [r] } = await p.query('SELECT plain, tz FROM t')
console.log(`Ghi lúc TimeZone = Asia/Ho_Chi_Minh, đọc lúc TimeZone = ${tz}: timestamp ${r.plain}, timestamptz ${r.tz}`)
}
console.log('\n=== 2. Cùng new Date("2025-06-01T03:00:00Z") ghi từ process Node có TZ khác nhau (mysql2 timezone mặc định, pg mặc định) ===')
await my.query("SET time_zone = '+00:00'")
await my.query('DROP TABLE IF EXISTS events_my')
await my.query('CREATE TABLE events_my (label VARCHAR(60), at_dt DATETIME, at_ts TIMESTAMP)')
await p.query('DROP TABLE IF EXISTS events_pg')
await p.query('CREATE TABLE events_pg (label VARCHAR(60), at_plain timestamp, at_tz timestamptz)')
const self = fileURLToPath(import.meta.url)
for (const [tz, opt] of [['UTC', ''], ['Asia/Ho_Chi_Minh', ''], ['Asia/Ho_Chi_Minh', 'Z']]) {
const r = spawnSync(process.execPath, [self, 'write', `TZ=${tz}${opt ? ', timezone Z' : ''}`, opt], { env: { ...process.env, TZ: tz }, stdio: 'inherit' })
if (r.status) process.exit(r.status)
}
const [rowsMy] = await my.query('SELECT label, at_dt, at_ts FROM events_my ORDER BY label')
for (const r of rowsMy) console.log(`mysql2, process ${r.label.padEnd(20)}: DATETIME lưu ${r.at_dt}, TIMESTAMP (đọc ở UTC) ${r.at_ts}`)
await p.query("SET TIME ZONE 'UTC'")
const { rows: rowsPg } = await p.query('SELECT label, at_plain, at_tz FROM events_pg ORDER BY label')
for (const r of rowsPg) console.log(`pg, process ${r.label.padEnd(20)}: timestamp lưu ${r.at_plain}, timestamptz (đọc ở UTC) ${r.at_tz}`)
console.log('\n=== 3. CONVERT_TZ ===')
const [[c]] = await my.query("SELECT CONVERT_TZ('2025-01-01 20:00:00', 'UTC', 'Asia/Ho_Chi_Minh') AS by_name, CONVERT_TZ('2025-01-01 20:00:00', '+00:00', '+07:00') AS by_offset, (SELECT COUNT(*) FROM mysql.time_zone_name) AS zones")
console.log(`Theo tên múi giờ: ${c.by_name}; theo độ lệch: ${c.by_offset}; số múi giờ đã nạp trong mysql.time_zone_name: ${c.zones}`)
console.log('\n=== 4. Doanh thu theo ngày: ngày UTC so với ngày giờ Việt Nam ===')
await my.query('DROP TABLE IF EXISTS orders')
await my.query('CREATE TABLE orders (id INT PRIMARY KEY, total INT NOT NULL, created_at DATETIME NOT NULL)') // lưu UTC
await my.query(`INSERT INTO orders VALUES (1, 100, '2025-01-01 02:00:00'), (2, 200, '2025-01-01 16:30:00'), (3, 300, '2025-01-01 18:00:00'),
(4, 400, '2025-01-02 10:00:00'), (5, 500, '2025-01-02 23:30:00')`)
const [byUtc] = await my.query('SELECT DATE(created_at) AS d, SUM(total) AS revenue FROM orders GROUP BY d ORDER BY d')
const [byVn] = await my.query("SELECT DATE(CONVERT_TZ(created_at, '+00:00', '+07:00')) AS d, SUM(total) AS revenue FROM orders GROUP BY d ORDER BY d")
console.log('Theo ngày UTC: ' + byUtc.map((r) => `${r.d} = ${r.revenue}`).join(', '))
console.log('Theo ngày giờ Việt Nam: ' + byVn.map((r) => `${r.d} = ${r.revenue}`).join(', '))
const { rows: pgVn } = await p.query(`SELECT (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Ho_Chi_Minh')::date AS d, SUM(total) AS revenue
FROM (VALUES (100, timestamp '2025-01-01 02:00'), (200, timestamp '2025-01-01 16:30'), (300, timestamp '2025-01-01 18:00'), (400, timestamp '2025-01-02 10:00'), (500, timestamp '2025-01-02 23:30')) AS o(total, created_at)
GROUP BY d ORDER BY d`)
console.log('PostgreSQL, AT TIME ZONE \'Asia/Ho_Chi_Minh\': ' + pgVn.map((r) => `${r.d.slice(0, 10)} = ${r.revenue}`).join(', '))
await my.end()
await p.end()