Kiểu dữ liệu dễ dùng sai: vì sao đơn 23:30 rơi sang hôm sau và tổng tiền lệch 1 đồng Cơ bản
Thời gian đọc ước tính: 8 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một shop bán hàng ở Việt Nam và Nhật dùng chung database. Đơn 1 trị giá 450.000 đồng được tạo lúc 23:30 ngày 22/10 giờ Việt Nam. Báo cáo doanh thu do nhóm ở Tokyo chạy lại ghi đơn này vào ngày 23/10.
Cùng tuần, đối soát thuế lệch 1 đồng: ceil(450000 * 1.1) tính bằng float8 ra 495.001 thay vì 495.000.
Bài Số, ngày, chuỗi liệt kê lỗi phía code Node. Bài này đi vào cơ chế PostgreSQL 17: giá trị nào thật sự được lưu, và cái gì quyết định cách nó hiện ra?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Thời điểm sự kiện: tạo đơn, thanh toán | timestamptz |
Báo cáo theo ngày ghi rõ múi giờ bằng AT TIME ZONE |
| Giờ “trên đồng hồ”: giờ mở cửa 08:00 | timestamp hoặc time |
Không so sánh thời điểm giữa các nước |
| Tiền VND, JPY (không có đơn vị lẻ) | bigint |
Thuế, tỷ giá tính bằng numeric rồi làm tròn có chủ đích |
| Tiền có phần lẻ, tỷ giá | numeric(p, s) |
Chấp nhận tính chậm hơn số nguyên |
| Đo lường, toạ độ | float8 |
Không so sánh =, không dùng cho tiền |
| Chuỗi | text (+ CHECK nếu cần) |
Tránh char(n) |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”| Kiểu | Lưu gì | Cách hiện ra |
|---|---|---|
timestamp |
Ngày giờ “trần”, không múi giờ | Lưu sao in vậy |
timestamptz |
Thời điểm tuyệt đối, nội bộ là UTC | Theo TimeZone của session |
numeric(p, s) |
Số thập phân chính xác (p chữ số, s sau dấu phẩy) |
Nguyên văn |
float8 / real |
Số dấu phẩy động nhị phân 8 / 4 byte | Dạng ngắn nhất đủ chính xác |
text, varchar(n), char(n) |
Chuỗi varlena | char(n) pad space tới n |
timestamptzkhông lưu múi giờ: múi giờ đầu vào chỉ dùng để quy về UTC rồi bị bỏ.timestamplặng lẽ bỏ offset đầu vào:TIMESTAMP '2026-10-22 23:30:00+07'ra2026-10-22 23:30:00.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”timestamptz: lưu UTC, hiển thị theo session
Phần tiêu đề “timestamptz: lưu UTC, hiển thị theo session”Câu hỏi: đơn 1 được lưu một lần, vì sao hai người đọc thấy hai ngày khác nhau?
23:30+07:00App gửi Date của đơn 1 kèm offset: 2026-10-22T23:30+07:00.
| Bước | Session thấy (giá trị lưu luôn là 16:30 UTC) |
|---|---|
3. TimeZone=UTC |
2026-10-22 16:30:00+00 |
4. TimeZone=Asia/Tokyo |
2026-10-23 01:30:00+09, ::date = 23 |
5. AT TIME ZONE 'Asia/Ho_Chi_Minh' |
2026-10-22 23:30:00 ở mọi session |
created_at::date cắt ngày theo TimeZone của session chạy báo cáo, không theo nơi đơn được tạo.
AT TIME ZONE đổi chiều theo kiểu đầu vào
Phần tiêu đề “AT TIME ZONE đổi chiều theo kiểu đầu vào”Đang tải diagram…
timestamptz AT TIME ZONE z→timestamp: “lúc đó đồng hồ ởzchỉ mấy giờ?”.timestamp AT TIME ZONE z→timestamptz: “giờ này là giờ ởz, đổi ra thời điểm tuyệt đối”.
Kết quả chạy thật (session TimeZone=UTC):
SELECT TIMESTAMP '2026-10-22 23:30' AT TIME ZONE 'Asia/Ho_Chi_Minh' AS a, TIMESTAMPTZ '2026-10-22 23:30+07' AT TIME ZONE 'Asia/Ho_Chi_Minh' AS b;-- a | b-- ------------------------+----------------------- 2026-10-22 16:30:00+00 | 2026-10-22 23:30:00Báo cáo doanh thu theo ngày Việt Nam, chạy từ session Tokyo:
SET TimeZone = 'Asia/Tokyo';SELECT created_at::date AS ngay, sum(total_vnd) FROM orders GROUP BY 1;-- ngay | sum-- 2026-10-23 | 570000 -- đơn 1 (22/10 giờ VN) bị gộp vào 23/10
SELECT date_trunc('day', created_at AT TIME ZONE 'Asia/Ho_Chi_Minh') AS ngay_vn, sum(total_vnd) FROM orders GROUP BY 1 ORDER BY 1;-- ngay_vn | sum-- 2026-10-22 00:00:00 | 450000-- 2026-10-23 00:00:00 | 120000Lọc một ngày VN thì so khoảng trên chính cột để index B-tree dùng được: created_at >= TIMESTAMP '2026-10-22 00:00' AT TIME ZONE 'Asia/Ho_Chi_Minh' và < mốc ngày 23 tương tự.
numeric và float8
Phần tiêu đề “numeric và float8”float8 lưu số theo cơ số 2, nên 0.1 chỉ là giá trị gần đúng. Tài liệu xếp float vào loại inexact, khuyên dùng numeric cho tiền, và ghi phép tính numeric “very slow” so với số nguyên và float.
SELECT 0.1::float8 + 0.2::float8 AS f8, 0.1::numeric + 0.2 AS num, ceil(450000 * 1.1::float8) AS f8_ceil, ceil(450000 * 1.1::numeric) AS num_ceil;-- f8 | num | f8_ceil | num_ceil-- 0.30000000000000004 | 0.3 | 495001 | 495000
SELECT round(2.5::float8) AS f8_r, round(2.5::numeric) AS num_r;-- f8_r | num_r-- 2 | 3450000 * 1.1bằng float ra495000.00000000006;ceilđẩy lên 1 đồng.- Làm tròn khác luật:
numericlàm tròn nửa ra xa số 0, cònfloat8(trên đa số máy) làm tròn về số chẵn gần nhất. realchỉ chính xác khoảng 6 chữ số.
text, varchar(n), char(n)
Phần tiêu đề “text, varchar(n), char(n)”Tài liệu nói không có khác biệt hiệu năng giữa ba kiểu, ngoài chỗ lưu thêm của char(n) và vài chu kỳ CPU kiểm độ dài; char(n) thường chậm nhất.
CREATE TABLE t (c char(5), v varchar(5), x text CHECK (char_length(x) <= 5));INSERT INTO t VALUES ('ab', 'ab', 'ab');SELECT '[' || c || ']' AS c_concat, octet_length(c), length(c), c = 'ab ' AS eq FROM t;-- c_concat | octet_length | length | eq-- [ab] | 5 | 2 | tINSERT INTO t (v) VALUES ('abcdef'); -- ERROR: value too long for type character varying(5)INSERT INTO t (x) VALUES ('abcdef'); -- ERROR: ... violates check constraint "t_x_check"SELECT 'abcdef'::varchar(5); -- abcde (cast tường minh cắt im lặng)char(n) lưu cả space pad (5 byte) nhưng bỏ space cuối khi so sánh và khi đổi sang text. CHECK giới hạn như varchar(n); đổi giới hạn chỉ cần thay constraint.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”now()=transaction_timestamp(): thời điểm bắt đầu transaction. Demo: saupg_sleep(1.2)trong cùng transaction,now()giữ13:15:13.171729,clock_timestamp()đã sang13:15:14.379239. Transaction dài ghinow()sẽ sớm hơn lúc commit.TimeZoneđặt được theo server, role, database hoặc session. Container demo mặc địnhUTC.- Chuỗi tối đa khoảng 1 GB; ≤ 126 byte tốn 1 byte header, chuỗi dài được nén hoặc ra TOAST (xem storage layout).
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo examples/src/data-type-pitfalls/demo.ts (node-postgres 8.23, PostgreSQL 17.11). Hàng 1 do SQL ghi 23:30; hàng 2 do Node gửi new Date('2026-10-22T16:30:00Z'):
TZ=Asia/Ho_Chi_Minh npx --prefix examples ts-node examples/src/data-type-pitfalls/demo.tsTZ=Asia/Tokyo npx --prefix examples ts-node examples/src/data-type-pitfalls/demo.tsprocess TZ=Asia/Ho_Chi_Minh, session TimeZone=UTCtimestamp -> 2026-10-22T16:30:00.000Ztimestamptz -> 2026-10-22T16:30:00.000Znumeric -> string "450000"bigint -> string "450000"count(*) -> string "1"Date -> timestamp lưu : 2026-10-22 23:30:00Date -> timestamptz lưu : 2026-10-22 16:30:00+00
process TZ=Asia/Tokyo, session TimeZone=UTCtimestamp -> 2026-10-22T14:30:00.000Ztimestamptz -> 2026-10-22T16:30:00.000Z...Date -> timestamp lưu : 2026-10-23 01:30:00timestamptz→Dateđúng thời điểm ở mọiTZ;timestampđược hiểu theoTZcủa process.numeric,bigint,count(*)về string theo mặc định.- Ngoại lệ dễ sót:
numeric[]thành mảng float,12345678901234567.89ra12345678901234568.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
timestamptzcho một thời điểm duy nhất, so sánh đúng giữa mọi pod và session. -
numericcho kết quả chính xác với cộng, trừ, nhân và luật làm tròn rõ ràng. -
bigintđơn vị nhỏ nhất: nhanh, gọn 8 byte, cộng chính xác.
<
slot="cons">
>
-
timestamptzkhông nhớ múi giờ gốc; cần giờ local của khách thì lưu thêm cột tên múi giờ. -
numerictính chậm hơn số nguyên và float; driver trả string, app cần thư viện decimal. -
bigintbuộc mọi phép chia (thuế, tỷ giá) phải chốt cách làm tròn.
Nhược điểm
Checklist production
Phần tiêu đề “Checklist production”- Mọi cột “sự kiện xảy ra lúc nào” là
timestamptz; rà soát cộttimestamphiện có. - Báo cáo theo ngày ghi rõ múi giờ nghiệp vụ bằng
AT TIME ZONEhoặcdate_trunc(..., 'Asia/Ho_Chi_Minh'), không dựa vàoTimeZonecủa session. - Đặt
TZ=UTCcho mọi process app để hành vi giống nhau giữa môi trường. - Tiền:
bigintđơn vị nhỏ nhất (VND, JPY) hoặcnumeric(p, s), kèm cột tiền tệ; không có cột tiền kiểureal/float8. - Chốt cách parse
int8,numeric,numeric[]trong driver và áp dụng toàn app. - Dùng
text+CHECK (char_length(...) <= n)hoặcvarchar(n); không tạo cộtchar(n)mới.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Lỗi hay gặp nhất là chuyển đổi hai lần:
Áp AT TIME ZONE hai lần hoặc sai kiểu
Hay gặp
SET TimeZone = 'Asia/Tokyo';SELECT created_at::timestamp AT TIME ZONE 'Asia/Ho_Chi_Minh' FROM orders;-- 2026-10-23 03:30:00+09 (đúng là 01:30+09): ::timestamp lấy giờ Tokyo rồi coi là giờ VNNên viết
SELECT created_at AT TIME ZONE 'Asia/Ho_Chi_Minh' AS gio_vn FROM orders;-- 2026-10-22 23:30:00 ở mọi sessionVì sao
Kiểm tra kiểu bằng pg_typeof(...) trước khi thêm AT TIME ZONE.
Sai lầm thứ hai đến từ cách viết giới hạn độ dài:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
timestamptz lưu múi giờ gốc |
Không biết đơn tạo ở VN hay Nhật | Lưu thêm cột múi giờ nếu cần |
created_at::date là ngày theo nơi bán |
Báo cáo đổi theo session | AT TIME ZONE múi giờ nghiệp vụ |
real đủ cho giá tiền |
Sai từ chữ số thứ 7 | bigint hoặc numeric |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Hai session
TimeZonekhác nhau đọc cùng hàngtimestamptz. Giá trị lưu có khác không? Vì sao::datelại khác? x AT TIME ZONE 'Asia/Tokyo'trả kiểu gì khixlàtimestamp, và khixlàtimestamptz?- Vì sao
ceil(450000 * 1.1::float8)ra 495.001 cònnumericra 495.000?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- PostgreSQL 17 — Time Zones:
timestamptzlưu UTC, hiển thị theoTimeZone. - AT TIME ZONE: hai chiều chuyển đổi.
- Current Date/Time:
now()vàclock_timestamp(). - Numeric Types: độ chính xác, tốc độ, luật làm tròn.
- Character Types: hiệu năng, pad space, cắt khi cast.
- node-postgres — Data Types: parse
TIMESTAMPtheoprocess.env.TZ.