Bỏ qua để đến nội dung
Knowledge Hub

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

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?

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)
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
  • timestamptz không lưu múi giờ: múi giờ đầu vào chỉ dùng để quy về UTC rồi bị bỏ.
  • timestamp lặng lẽ bỏ offset đầu vào: TIMESTAMP '2026-10-22 23:30:00+07' ra 2026-10-22 23:30:00.

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?

Đơn 1 lưu một giá trị UTC; session Tokyo đọc ra ngày 23, AT TIME ZONE đưa về ngày 22
Đơn 1 lưu một giá trị UTC; session Tokyo đọc ra ngày 23, AT TIME ZONE đưa về ngày 22Bước 1/5: App gửi Date của đơn 1 kèm offset: 2026-10-22T23:30+07:00.App (Node)TZ=Asia/Ho_Chi_MinhPostgreSQLlưu UTCSession UTCTimeZone=UTCSession TokyoTimeZone=Asia/Tokyo23:30+07:00
App (Node) → PostgreSQL23:30+07:00
Bước 1/5

App 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ồ ở z chỉ 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:00

Bá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 | 120000

Lọ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ự.

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 | 3
  • 450000 * 1.1 bằng float ra 495000.00000000006; ceil đẩy lên 1 đồng.
  • Làm tròn khác luật: numeric làm tròn nửa ra xa số 0, còn float8 (trên đa số máy) làm tròn về số chẵn gần nhất.
  • real chỉ chính xác khoảng 6 chữ số.

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 | t
INSERT 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.

  • now() = transaction_timestamp(): thời điểm bắt đầu transaction. Demo: sau pg_sleep(1.2) trong cùng transaction, now() giữ 13:15:13.171729, clock_timestamp() đã sang 13:15:14.379239. Transaction dài ghi now() sẽ sớm hơn lúc commit.
  • TimeZone đặt được theo server, role, database hoặc session. Container demo mặc định UTC.
  • 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).

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'):

Terminal window
TZ=Asia/Ho_Chi_Minh npx --prefix examples ts-node examples/src/data-type-pitfalls/demo.ts
TZ=Asia/Tokyo npx --prefix examples ts-node examples/src/data-type-pitfalls/demo.ts
process TZ=Asia/Ho_Chi_Minh, session TimeZone=UTC
timestamp -> 2026-10-22T16:30:00.000Z
timestamptz -> 2026-10-22T16:30:00.000Z
numeric -> string "450000"
bigint -> string "450000"
count(*) -> string "1"
Date -> timestamp lưu : 2026-10-22 23:30:00
Date -> timestamptz lưu : 2026-10-22 16:30:00+00
process TZ=Asia/Tokyo, session TimeZone=UTC
timestamp -> 2026-10-22T14:30:00.000Z
timestamptz -> 2026-10-22T16:30:00.000Z
...
Date -> timestamp lưu : 2026-10-23 01:30:00
  1. timestamptz → Date đúng thời điểm ở mọi TZ; timestamp được hiểu theo TZ của process.
  2. numeric, bigint, count(*) về string theo mặc định.
  3. Ngoại lệ dễ sót: numeric[] thành mảng float, 12345678901234567.89 ra 12345678901234568.

Ưu điểm

  • timestamptz cho một thời điểm duy nhất, so sánh đúng giữa mọi pod và session.

  • numeric cho 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">
  • timestamptz khô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ờ.

  • numeric tính chậm hơn số nguyên và float; driver trả string, app cần thư viện decimal.

  • bigint buộc mọi phép chia (thuế, tỷ giá) phải chốt cách làm tròn.

Nhược điểm

  1. Mọi cột “sự kiện xảy ra lúc nào” là timestamptz; rà soát cột timestamp hiện có.
  2. Báo cáo theo ngày ghi rõ múi giờ nghiệp vụ bằng AT TIME ZONE hoặc date_trunc(..., 'Asia/Ho_Chi_Minh'), không dựa vào TimeZone của session.
  3. Đặt TZ=UTC cho mọi process app để hành vi giống nhau giữa môi trường.
  4. Tiền: bigint đơn vị nhỏ nhất (VND, JPY) hoặc numeric(p, s), kèm cột tiền tệ; không có cột tiền kiểu real/float8.
  5. Chốt cách parse int8, numeric, numeric[] trong driver và áp dụng toàn app.
  6. Dùng text + CHECK (char_length(...) <= n) hoặc varchar(n); không tạo cột char(n) mới.

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ờ VN

Nê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 session

Vì 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
  1. Hai session TimeZone khác nhau đọc cùng hàng timestamptz. Giá trị lưu có khác không? Vì sao ::date lại khác?
  2. x AT TIME ZONE 'Asia/Tokyo' trả kiểu gì khi x là timestamp, và khi x là timestamptz?
  3. Vì sao ceil(450000 * 1.1::float8) ra 495.001 còn numeric ra 495.000?