Data types — vì sao TIMESTAMPTZ thay TIMESTAMP cứu deploy multi-region
PG types: int variants, TEXT vs VARCHAR(n), TIMESTAMPTZ vs TIMESTAMP, NUMERIC vs FLOAT (money), UUID, BYTEA, ARRAY, RANGE. 4 type pitfall production.
Team TaskFlow vừa deploy lên hai region: server EU ở Frankfurt, server US ở Virginia. User EU nhìn thấy task due 2026-05-04 00:00, user US nhìn thấy cùng row đó với due 2026-05-03 17:00. Cùng một task — hai người thấy hai giá trị khác nhau. Bug ticket đầu tiên: "task chưa due hay đã due?" Mọi thứ chạy tốt trên local (một server, một timezone).
Root cause: column due_at TIMESTAMP không lưu timezone. Server Frankfurt store thời gian local server, không convert khi user US đọc. Fix một dòng DDL: đổi sang TIMESTAMPTZ. Đây là 1 trong 4 type pitfall phổ biến nhất production. Bài này map PG type catalog và giải thích tại sao mỗi type tồn tại — không phải syntax dump.
1. Analogy — Type là contract dữ liệu
Type là cam kết giữa schema và application: "column này chứa đúng loại dữ liệu này, không có ngoại lệ". Hình dung ổ cắm điện — ổ 2 chân và ổ 3 chân không cắm lẫn nhau được, không phải vì kỹ thuật không cho phép mà vì contract về an toàn. Type constraint trong database hoạt động tương tự: ngăn dữ liệu sai loại vào từ đầu thay vì để bug xảy ra runtime.
| Ổ cắm điện | SQL type |
|---|---|
| Ổ 2 chân / 3 chân | Loại type (TEXT, INT, TIMESTAMPTZ...) |
| Ổ không nhận phích không đúng chuẩn | Database reject INSERT sai type |
| Cắm sai loại điện → hỏng thiết bị | Store sai type → silent bug (float tiền, no-TZ timestamp) |
| Một ổ chỉ nhận một chuẩn | Một column chỉ nhận đúng một type |
| Adapter chuyển đổi chuẩn | Type cast trong SQL (::int, ::timestamptz) |
| Adapter kém chất lượng → mất điện | Cast không an toàn → runtime error hoặc data loss |
Chọn type đúng từ đầu = ít bug nhất, không cần validate runtime. Mismatch type không luôn gây error ngay — đôi khi là silent bug (float tiền, timestamp no-TZ) mà bạn chỉ phát hiện khi production chạy thật.
2. PG type catalog — tổng quan
PostgreSQL có hơn 50 built-in type. Bảng dưới nhóm các type production matter — bài này chỉ đi sâu vào những type hay gặp nhất. Type còn lại được nhắc khi context cần.
| Group | Types | Use case |
|---|---|---|
| Numeric | smallint/int/bigint, numeric(p,s), real/double precision | Counter, money, measurement |
| Character | text, varchar(n), char(n) | String — recommend text |
| Datetime | timestamp, timestamptz, date, time, interval | Time-related |
| Boolean | boolean | True/false (không 0/1 hack) |
| UUID | uuid | Distributed ID, random PK |
| JSON | json, jsonb | Semi-structured (Module 9 của khoá này deep dive) |
| Binary | bytea | File content, hash |
| Array | int[], text[] | Multi-value (anti-pattern thường — Module 4 bài 3 của khoá này normalize) |
| Range | int4range, tstzrange | Period, range queries |
| Specialized | inet, cidr, macaddr, point, line | Network, geometry |
3. Numeric — int variants và money type
3.1 Chọn int variant đúng
-- Int variants - chon dung de tranh overflow + tiet kiem storage
smallint -- 2 byte, range -32768 den 32767 (counter nho, flag)
int / int4 -- 4 byte, range khoang -2.1B den 2.1B (default)
bigint / int8 -- 8 byte, range khoang -9.2 quintillion (PK lon, counter global)
-- SERIAL / BIGSERIAL = shorthand cho int/bigint + sequence + default
id BIGSERIAL PRIMARY KEY -- tuong duong bigint + DEFAULT nextval(...)
Quy tắc thực chiến: PK dùng BIGSERIAL (tránh overflow khi vượt 2 tỉ row), counter nhỏ như view_count trong một bài viết dùng int là đủ, smallint chỉ hợp lý khi biết chắc range nhỏ và cần tối ưu storage (vd: bảng có hàng tỉ row).
3.2 Money — KHÔNG dùng float
-- Float KHONG exact - tranh cho money
real / float4 -- 4 byte float, KHONG exact
double precision / float8 -- 8 byte float, van KHONG exact
-- Minh hoa: float cong don
SELECT 0.1::real + 0.2::real;
-- Ket qua: 0.3 (may man round dung)
-- Nhung: SELECT 0.1::real + 0.2::real = 0.3 -> false (IEEE 754 comparison fail)
-- NUMERIC(p, s) - arbitrary precision, exact - dung cho tien
-- p = tong so chu so, s = so chu so sau dau thap phan
balance NUMERIC(15, 2) -- max 9999999999999.99 (15 digit, 2 decimal)
-- So sanh: float vs numeric
SELECT 0.1::float8 + 0.2::float8; -- 0.30000000000000004
SELECT 0.1::numeric + 0.2::numeric; -- 0.3 (exact)
-- Pattern alternative: store cents as bigint (tranh float hoan toan)
balance_cents BIGINT NOT NULL DEFAULT 0
-- Store $10.50 -> 1050 cents
-- App code: display = balance_cents / 100.0
-- Trao doi: app phai convert, nhung zero float bug
IEEE 754 float không thể biểu diễn chính xác nhiều phân số thập phân (0.1, 0.2, 0.3...). Tích lũy sai số qua nhiều phép tính — báo cáo tài chính cuối tháng sai vài cent. Dùng NUMERIC(p,s) hoặc store cents/integer. Không bao giờ dùng real hay double precision cho cột tiền.
4. Character — vì sao TEXT thắng VARCHAR(n)
-- 3 cach store string trong PG
text -- variable length, no limit, recommend
varchar(n) -- variable length voi cap n char
char(n) -- FIXED length, padded space (legacy, KHONG dung)
PostgreSQL implement text và varchar(n) bằng cùng cơ chế bên dưới — không có khác biệt performance. Đây khác MySQL, nơi varchar(n) có thể được optimize khác.
-- Demo: TEXT va VARCHAR(n) performance giong nhau trong PG
-- (MySQL co the khac - la ly do duy nhat phai note khi migrate)
-- Pitfall: varchar(n) cap qua som
title VARCHAR(50) -- spec ban dau OK
-- 6 thang sau marketing muon title 200 char
ALTER TABLE tasks ALTER COLUMN title TYPE VARCHAR(200);
-- PG 9.2+: khong rewrite neu tang len, nhung van phai hold ACCESS EXCLUSIVE lock
-- Tren bang lon: lock table trong thoi gian ALTER -> downtime
-- Better: TEXT + CHECK constraint khi can rang buoc business rule
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200)
-- ALTER CHECK de hon ALTER TYPE: chi thay doi constraint, khong hold lock lau
Khi nào nên dùng varchar(n): khi business rule thực sự rang buộc length và không bao giờ thay đổi (vd: mã quốc gia ISO char(2), mã SWIFT varchar(11)). Còn lại — dùng TEXT.
5. Datetime — TIMESTAMPTZ luôn thắng
Đây là type pitfall nghiêm trọng nhất vì lỗi chỉ lộ ra khi deploy multi-region hoặc khi server đổi timezone.
-- 2 type chinh:
timestamp -- KHONG store TZ, "wall clock time" thuan tuy
timestamp with time zone -- = timestamptz, normalize ve UTC, display per session TZ
-- Demo bug: TIMESTAMP no-TZ
SET TIME ZONE 'Asia/Ho_Chi_Minh'; -- UTC+7
INSERT INTO events(event_at) VALUES ('2026-05-04 14:00');
-- TIMESTAMP store: 2026-05-04 14:00 (con so tuong trung, khong co TZ context)
SET TIME ZONE 'America/New_York'; -- UTC-4 (EDT)
SELECT event_at FROM events;
-- TIMESTAMP tra ve: 2026-05-04 14:00 (KHONG convert, van giu nguyen con so)
-- -> User New York hieu la 14:00 EDT, sai hoan toan!
-- Fix: TIMESTAMPTZ
SET TIME ZONE 'Asia/Ho_Chi_Minh';
INSERT INTO events(event_at) VALUES ('2026-05-04 14:00');
-- TIMESTAMPTZ store: 2026-05-04 07:00 UTC (convert ve UTC khi insert)
SET TIME ZONE 'America/New_York';
SELECT event_at FROM events;
-- TIMESTAMPTZ tra ve: 2026-05-04 03:00 EDT (convert tu UTC sang session TZ)
-- -> Dung! Cung mot moment in time, khac nhau cach hien thi per timezone
TIMESTAMP (không TZ) store "con số đồng hồ treo tường" — không có ngữ cảnh timezone. Khi session timezone thay đổi (server chuyển DC, user ở timezone khác), cùng value bị interpret khác nhau. TIMESTAMPTZ luôn normalize về UTC khi store, display per session timezone khi đọc — cùng một moment in time, mọi user đều thấy đúng local time của họ.
-- Luu y: now() va current_timestamp tra ve TIMESTAMPTZ - an toan
SELECT now(); -- timestamptz, UTC
SELECT current_timestamp; -- timestamptz, UTC
-- TRANH: localtimestamp tra ve TIMESTAMP (no TZ) - anti-pattern
SELECT localtimestamp; -- timestamp, KHONG co TZ context
Các type datetime khác trong PG:
| Type | Lưu gì | Khi nào dùng |
|---|---|---|
date | Ngày (không giờ) | Birthday, anniversary, ngày kế toán |
time | Giờ (không ngày, không TZ) | Giờ mở cửa cố định (07:00 mỗi ngày) |
interval | Khoảng thời gian | due_at + INTERVAL '7 days', duration |
6. UUID và các type đặc biệt
-- UUID: 16 byte, format 8-4-4-4-12 hex
-- PG 13+: gen_random_uuid() built-in (UUID v4, random)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
);
INSERT INTO users DEFAULT VALUES;
-- id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
-- UUID v4 random: khong sequential -> B-tree index fragmentation
-- UUID v7 (timestamp-prefix): sequential -> tot hon cho index
-- Module 4 bai 5 cua khoa nay deep dive UUID v7 + ULID
-- ARRAY type
tags TEXT[] -- vd: ARRAY['urgent', 'bug', 'backend']
-- Anti-pattern thuong: violate 1NF
-- Kho query: WHERE 'urgent' = ANY(tags) phai scan mang
-- Module 4 bai 3 cua khoa nay: normalize tags ra bang rieng
-- RANGE type (PG-specific, rat huu ich)
CREATE TABLE bookings (
room_id INT,
period TSTZRANGE
);
INSERT INTO bookings VALUES (1, '[2026-05-04 14:00+07, 2026-05-04 16:00+07)');
-- '[' = inclusive, ')' = exclusive (nhu interval toan hoc)
-- Query overlap: tim booking conflict
SELECT * FROM bookings
WHERE room_id = 1
AND period && '[2026-05-04 15:00+07, 2026-05-04 17:00+07)'::tstzrange;
-- && = overlap operator, rat nhanh voi GiST index
7. Pitfall — 4 type bug thường gặp production
-- BUG 1: TIMESTAMP (no TZ) -> multi-region conflict
-- Da demo o Section 5 - chi ghi lai de nho
due_at TIMESTAMP -- KHONG dung
due_at TIMESTAMPTZ -- DUNG
-- BUG 2: float cho money -> accumulated rounding error
balance REAL NOT NULL DEFAULT 0
-- Test: update 10 lan + 0.1
UPDATE accounts SET balance = balance + 0.1 WHERE id = 1;
-- Lap 10 lan
SELECT balance FROM accounts WHERE id = 1;
-- Ket qua: 0.9999999... (khong phai 1.0)
-- Fix: NUMERIC(15,2) hoac balance_cents BIGINT
-- BUG 3: varchar(n) cap qua nho - phat hien muon
title VARCHAR(50) -- OK khi ra mat
-- 6 thang sau: user dang nhap title 120 char -> ERROR: value too long
-- ALTER TYPE tren bang lon -> lock, downtime
-- Fix: TEXT + CHECK, hoac VARCHAR(500) buffer lon tu dau
-- BUG 4: store numeric data as text
age TEXT -- sai! khong the SUM/AVG, sort theo string ('9' > '10')
phone TEXT -- OK - so dien thoai khong can so hoc
user_count TEXT -- sai! '100' < '99' khi sort string
8. Type cast và CHECK constraint
-- Explicit cast: an toan, ro rang
SELECT '123'::int; -- 123
SELECT '2026-05-04'::date; -- 2026-05-04
SELECT 'abc'::int; -- ERROR: invalid input syntax for type integer
-- Implicit cast: PG tu dong khi type compatible
INSERT INTO tasks(due_at) VALUES ('2026-05-04'); -- text -> timestamptz auto
INSERT INTO tasks(due_at) VALUES ('not-a-date'); -- ERROR: invalid input
-- CHECK constraint - rang buoc linh hoat hon type-level limit
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200)
-- Co the ALTER CHECK de dang:
ALTER TABLE tasks DROP CONSTRAINT tasks_title_check;
ALTER TABLE tasks ADD CONSTRAINT tasks_title_check
CHECK (length(title) BETWEEN 1 AND 500); -- tang len khong can lock lau
9. Applied — TaskFlow type rationale
Schema TaskFlow đã tour đầy đủ DDL ở Module 1 bài 5 (tour TaskFlow schema) của khoá này. Bài này giải thích "tại sao" cho từng type choice:
-- users.id BIGSERIAL PRIMARY KEY
-- BIGSERIAL = bigint + auto-increment sequence
-- Chon bigint thay int: tranh overflow khi user base > 2 ti (scale safety)
-- BIGSERIAL thay UUID: sequential -> B-tree index tot hon (M04.5 deep)
-- users.email TEXT UNIQUE NOT NULL
-- TEXT thay VARCHAR(n): email dai nhat thuc te ~254 char, TEXT khong can cap
-- UNIQUE constraint dam bao no duplicate, khong can rang buoc length
-- users.created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- TIMESTAMPTZ: multi-region safe, normalize UTC, display per session TZ
-- DEFAULT now(): server-side default, app khong can set
-- tasks.due_at TIMESTAMPTZ
-- Nullable: khong phai task nao cung co deadline (khac created_at)
-- TIMESTAMPTZ: dam bao user EU va user US thay cung moment in time
-- tasks.status TEXT CHECK (status IN ('todo', 'doing', 'done', 'archived'))
-- TEXT + CHECK thay vi CREATE TYPE ... AS ENUM:
-- Pro: de ALTER (them value chi can ADD CONSTRAINT moi)
-- Con: khong co type-safety o app layer, string compare (khong co enum ORM mapping)
-- Module 4 bai 2 cua khoa nay: ENUM type deep dive, khi nao chon ENUM vs TEXT+CHECK
-- tasks.title TEXT NOT NULL
-- TEXT: marketing co the doi spec length bat ky luc nao
-- Neu can rang buoc: them CHECK (length(title) <= 200) de alter sau
10. Deep Dive — PG data types
- PostgreSQL Documentation Ch.8 "Data Types" — official catalog đầy đủ mọi type + edge case. Đọc phần numeric, character, datetime trước; phần network/geometry khi context cần.
- PostgreSQL Documentation 8.5 "Date/Time Types" — TIMESTAMPTZ deep dive: input format, output format, timezone conversion,
AT TIME ZONEoperator. - Markus Winand — "Modern SQL" (modern-sql.com) — cross-vendor data type pitfall: khi nào PG khác MySQL/SQL Server về type behavior. Search "Modern SQL data types" nếu URL cụ thể thay đổi.
Ghi chú: PG docs Ch.8 cho overview catalog, Ch.8.5 cho datetime behavior chi tiết (đặc biệt TZ conversion và AT TIME ZONE). Modern SQL cho góc nhìn so sánh multi-vendor — hữu ích khi migrate hoặc viết query cần portable.
Liên kết khoá học khác
- Khoá Spring — bài 4.2 Entity Mapping — JPA mapping Java type sang SQL type (vd
LocalDateTimesangTIMESTAMPTZ).
11. Tóm tắt
- Numeric:
BIGSERIAL/BIGINTcho PK lớn (tránh overflow),NUMERIC(p,s)cho money (KHÔNGfloat/real/double precision). - Character:
TEXTthayVARCHAR(n)trong PG — cùng performance, dễ alter;VARCHAR(n)chỉ khi business rule rang buộc cố định. - Datetime:
TIMESTAMPTZluôn —TIMESTAMPlà bug magnet multi-region vì không normalize UTC. - UUID:
gen_random_uuid()built-in PG 13+; UUID v7 sequential tốt hơn v4 cho B-tree index — Module 4 bài 5 của khoá này deep dive. - Array column thường vi phạm 1NF và khó query — Module 4 bài 3 của khoá này normalize.
- CHECK constraint linh hoạt hơn type-level limit:
TEXT + CHECK (length(...) <= 200)dễ alter hơnVARCHAR(200). - 4 pitfall production: TIMESTAMP no-TZ, float cho money, varchar(n) cap sớm, store số dưới dạng TEXT.
- Forward: Module 4 bài 2 của khoá này (ENUM type vs TEXT+CHECK), Module 4 bài 5 (UUID v7 + ULID).
12. Tự kiểm tra
Q1Vì sao TEXT không chậm hơn VARCHAR(n) ở PG nhưng MySQL có thể khác? Điều này ảnh hưởng gì khi migrate từ MySQL sang PG?▸
PostgreSQL implement cả text và varchar(n) bằng cùng kiểu lưu trữ nội bộ (varlena — variable-length array). Không có code path khác biệt, không có index structure khác nhau. Engine chỉ thêm check độ dài khi varchar(n) được dùng, nhưng bản thân storage và retrieval giống hệt text.
MySQL lại có thể implement khác — một số engine version tối ưu varchar riêng với metadata inline, ảnh hưởng storage layout. Vì vậy benchmark MySQL varchar vs text có thể cho kết quả khác nhau tùy engine và query pattern.
Khi migrate MySQL sang PG: column varchar(n) từ MySQL có thể giữ nguyên trong PG mà không cần đổi sang text, nhưng đây là cơ hội tốt để review lại có cần rang buộc length không. Nếu không có business rule thực sự, đổi sang text để tránh alter-table pain sau này.
Q2Phân biệt khi nào nên dùng smallint, int, bigint. Cho ví dụ cụ thể với schema TaskFlow.▸
smallint (2 byte, max 32767): chỉ khi biết chắc value sẽ không vượt ngưỡng và cần tối ưu storage ở bảng cực lớn. Ví dụ: cột priority trong tasks với giá trị 1–5 — nhưng thực tế gain storage nhỏ, không đáng đánh đổi độ phức tạp.
int / int4 (4 byte, max ~2.1 tỉ): default cho hầu hết counter và ID nhỏ. Ví dụ: nếu TaskFlow chắc chắn không bao giờ vượt 2 tỉ comment, comments.id có thể dùng int. Nhưng trong thực tế, chọn bigint luôn an toàn hơn.
bigint / int8 (8 byte, max ~9.2 quintillion): dùng cho PK của bảng có thể grow lớn, ID global, counter không giới hạn. TaskFlow dùng BIGSERIAL cho users.id, tasks.id, projects.id — đúng vì không thể dự đoán scale. Sai lầm phổ biến: dùng int cho PK, sau 2 tỉ row phải migrate — đau hơn chọn đúng từ đầu.
Q3Float cho money: 0.1 + 0.2 = 0.30000000000000004. Hai cách handle — NUMERIC vs cents-as-bigint — tradeoff thực chiến?▸
0.1 + 0.2 = 0.30000000000000004. Hai cách handle — NUMERIC vs cents-as-bigint — tradeoff thực chiến?Root cause: IEEE 754 floating point không thể biểu diễn chính xác nhiều phân số thập phân trong binary — 0.1 trong binary là chuỗi vô hạn tuần hoàn, bị truncate khi store.
NUMERIC(p,s) — arbitrary precision decimal. Exact. Database handle mọi thứ. App code đọc ra number thập phân bình thường. Nhược: chậm hơn float vì không dùng được FPU instruction, query math trên NUMERIC tốn CPU hơn.
Cents-as-bigint — store $10.50 thành integer 1050. Dùng được integer arithmetic (cực nhanh, FPU-optimized). Không có float bug. Nhược: app code phải convert mọi lúc (display = cents / 100.0); logic tính thuế/làm tròn phức tạp hơn khi viết trong app thay vì dùng NUMERIC database arithmetic; dễ nhầm khi join với hệ thống khác không dùng cùng convention.
Thực chiến: fintech lớn (Stripe, Shopify) thường dùng cents-as-bigint cho core transaction, NUMERIC cho báo cáo aggregation. Product bình thường — NUMERIC đủ, đơn giản hơn nhiều.
Q4TIMESTAMP (no TZ) chạy tốt trên dev local. Production multi-region phát sinh bug. Cơ chế gì?▸
Dev local thường một server, một timezone. TIMESTAMP store "con số đồng hồ treo tường" — không có metadata timezone. Khi tất cả server và user cùng timezone, không có vấn đề vì mọi người interpret cùng một cách.
Production multi-region: server Frankfurt UTC+2, server Virginia UTC-4. User EU insert due_at = '2026-05-04 09:00' từ Frankfurt. TIMESTAMP store nguyên con số đó — không convert. Khi user US (Virginia, UTC-4) đọc, họ thấy 2026-05-04 09:00 và interpret là 09:00 EDT — lệch 6 tiếng so với user EU.
TIMESTAMPTZ fix: khi insert từ Frankfurt, PG convert 09:00 UTC+2 thành 07:00 UTC để store. Khi user US đọc với session timezone UTC-4, PG convert 07:00 UTC thành 03:00 EDT — đúng. Cùng một moment in time, cách hiển thị khác nhau per timezone, nhưng semantics nhất quán.
Q5varchar(50) cap title — 6 tháng sau marketing yêu cầu 500 char. ALTER TABLE TYPE rewrite vs migrate sang TEXT — tradeoff thực chiến?▸
ALTER TYPE trực tiếp trên bảng lớn: PG 9.2+ tối ưu cho trường hợp tăng varchar(n) limit — không rewrite rows, chỉ update catalog. Nhưng vẫn cần ACCESS EXCLUSIVE lock ngắn trong thời gian alter metadata. Trên bảng triệu row đang có traffic cao, lock dù ngắn cũng gây drop connection.
Zero-downtime pattern: (1) Thêm column mới title_v2 TEXT; (2) Backfill từ title; (3) Double-write trong app; (4) Swap column tên sau khi sync xong; (5) Drop column cũ. Phức tạp nhưng không lock production.
Migrate sang TEXT từ đầu: tránh toàn bộ vấn đề trên. Nếu cần rang buộc business, dùng CHECK (length(title) BETWEEN 1 AND 200) — alter CHECK nhanh hơn alter TYPE vì chỉ cần thay đổi constraint definition, không cần validate lại row (trừ khi thêm NOT VALID rồi validate riêng). Thực chiến: hầu hết đội dùng TEXT + CHECK và không bao giờ phải lo vấn đề này.
Bài tiếp theo: Constraints — NOT NULL/UNIQUE/CHECK/FK/EXCLUDE
Bài này có giúp bạn hiểu bản chất không?