PostgreSQL 2 — Hệ kiểu dữ liệu phong phú
Vì sao "kiểu đúng" lại quan trọng đến vậy?
Trong nhiều cơ sở dữ liệu, kiểu dữ liệu chỉ là chuyện thẩm mỹ: người ta nhét mọi thứ vào VARCHAR cho tiện, ép kiểu lúc cần. PostgreSQL đi theo triết lý ngược lại. Kiểu dữ liệu ở đây là một hợp đồng giữa bạn và cơ sở dữ liệu, và chọn đúng kiểu mang lại ba lợi ích cụ thể, có thể đo được:
- Đúng đắn (correctness). Nếu cột
so_tienlànumeric(18,2), bạn sẽ không bao giờ vô tình lưu"3.5 triệu"hay một số âm mập mờ vào đó — hệ sẽ từ chối. Kiểu là lớp phòng thủ đầu tiên, nằm sâu dưới cả tầng ứng dụng. - Ràng buộc (constraints). Kiểu quyết định phép toán nào hợp lệ. Bạn cộng
date + integerra mộtdatemới; bạn không thể cộng haidatevới nhau (vì "31/12 cộng 01/01" là vô nghĩa). Hệ hình học sẽ hiểu<->là khoảng cách; hệ mạng hiểu<<là "nằm trong subnet". Kiểu mang theo ngữ nghĩa, không chỉ là bit. - Hiệu năng (performance). Một
integerchiếm 4 byte, so sánh bằng một lệnh CPU. Cùng con số đó lưu dưới dạngtextchiếm nhiều byte hơn, so sánh theo từng ký tự, và không thể tận dụng B-tree tối ưu cho số. Kiểu đúng nghĩa là index nhỏ hơn, cache hiệu quả hơn, truy vấn nhanh hơn.
Xuyên suốt bài, bạn sẽ gặp cú pháp ép kiểu (cast). PostgreSQL có hai cách viết tương đương: cú pháp chuẩn SQL CAST(gia_tri AS kieu) và cú pháp tắt kiểu Postgres gia_tri::kieu. Cú pháp :: gọn và rất phổ biến trong cộng đồng Postgres.
▶ Chạy được trong SQL Builder
SELECT '2026-06-30'::date + 7 AS bay_ngay_sau, CAST('42' AS integer) * 2 AS ep_chuan;
Bài này nối tiếp Kiến trúc PostgreSQL. Sau khi hiểu MVCC và cách Postgres lưu dòng, ta bước vào thứ khiến Postgres thực sự khác biệt so với đa số RDBMS: kho kiểu dữ liệu.
Bản đồ tổng quan hệ kiểu
Số: đừng dùng kiểu float cho tiền
Số nguyên: smallint, integer, bigint
Ba kiểu nguyên chuẩn khác nhau ở dải giá trị và dung lượng:
| Kiểu | Byte | Dải giá trị |
|---|---|---|
smallint | 2 | khoảng −32.768 đến 32.767 |
integer (int) | 4 | khoảng ±2,1 tỷ |
bigint | 8 | khoảng ±9,2 × 10¹⁸ |
Quy tắc thực dụng: mặc định dùng integer. Nếu là khoá chính của bảng có thể vượt 2 tỷ dòng (log, sự kiện, giao dịch), dùng bigint ngay từ đầu — ALTER khoá chính từ int sang bigint trên bảng khổng lồ là một ca migration đau đớn.
numeric / decimal: chính xác tuyệt đối — dùng cho tiền
numeric(precision, scale) (bí danh decimal) lưu số thập phân chính xác, không sai số làm tròn nhị phân. precision là tổng số chữ số, scale là số chữ số sau dấu phẩy. Ví dụ numeric(18,2) chứa được tối đa 16 chữ số phần nguyên và 2 chữ số phần thập phân — quá đủ cho mọi loại tiền tệ.
Đây là lựa chọn bắt buộc cho cột tiền, số dư tài khoản, lãi suất, tỷ giá. Lý do nằm ở ví dụ kinh điển:
▶ Chạy được trong SQL Builder
SELECT (0.1::float8 + 0.2::float8) AS float_sai,
(0.1::numeric + 0.2::numeric) AS numeric_dung;
Kết quả cột float_sai cho ra 0.30000000000000004 — sai số cố hữu của dấu phẩy động IEEE 754. Với tiền, một sai số như vậy nhân qua hàng triệu giao dịch sẽ thành lệch sổ. numeric cho đúng 0.3.
real / double precision: dấu phẩy động — cảnh báo sai số
real (4 byte, ~6 chữ số ý nghĩa) và double precision (8 byte, ~15 chữ số) lưu số theo chuẩn IEEE 754. Chúng nhanh và tiết kiệm, phù hợp cho dữ liệu khoa học, đo lường cảm biến, toạ độ, thống kê — nơi sai số ở chữ số thứ 15 không quan trọng. Nhưng tuyệt đối không dùng cho tiền, và cẩn thận khi so sánh bằng (= 0.3 có thể không khớp). Hai giá trị đặc biệt 'NaN' và 'Infinity' cũng chỉ tồn tại ở kiểu float/numeric.
serial và identity: cột tự tăng
serial là "đường tắt" cũ: nó tự tạo một sequence và đặt DEFAULT nextval(...). Từ PostgreSQL 10 trở đi, khuyến nghị dùng identity column theo chuẩn SQL, sạch hơn và dễ quản lý quyền hơn:
-- (minh hoạ) cách khai báo khoá tự tăng hiện đại
CREATE TABLE accounts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
...
);
GENERATED ALWAYS chặn việc chèn tay vào cột id (an toàn hơn); nếu cần đôi lúc chèn tay thì dùng GENERATED BY DEFAULT.
Chuỗi: gần như luôn là text hoặc varchar
PostgreSQL có ba kiểu chuỗi, nhưng thực tế bạn chỉ cần nhớ một điều: dùng text hoặc varchar, quên char(n) đi.
| Kiểu | Đặc điểm |
|---|---|
text | Chuỗi độ dài tuỳ ý, không giới hạn. Đơn giản nhất. |
varchar(n) | Như text nhưng giới hạn tối đa n ký tự (báo lỗi nếu vượt). |
char(n) | Cố định n ký tự, đệm khoảng trắng cho đủ — gây phiền và không nhanh hơn. |
Điều quan trọng: trong PostgreSQL, text, varchar và char có cùng cơ chế lưu trữ bên dưới (varlena). varchar(n) không nhanh hơn text — nó chỉ thêm một phép kiểm tra độ dài. Vì vậy tài liệu chính thức khuyến nghị dùng text khi không cần giới hạn, và varchar(n) khi cần chặn độ dài (ví dụ mã sản phẩm tối đa 20 ký tự). char(n) gần như chỉ còn ý nghĩa lịch sử.
Collation (thứ tự sắp xếp)
COLLATE quyết định cách so sánh và sắp xếp chuỗi theo ngôn ngữ. Ví dụ tiếng Việt cần xử lý dấu và thứ tự bảng chữ cái riêng. Bạn có thể đặt collation cho cả database, một cột, hoặc ngay trong truy vấn:
-- (minh hoạ) sắp xếp theo quy tắc tiếng Việt
SELECT ho_ten FROM customers ORDER BY ho_ten COLLATE "vi-VN";
citext: chuỗi không phân biệt hoa/thường
Extension citext cung cấp kiểu chuỗi so sánh không phân biệt hoa thường. Rất tiện cho email hoặc username: [email protected] và [email protected] được coi là một mà không cần LOWER() khắp nơi. Cài bằng CREATE EXTENSION citext; rồi khai báo cột kiểu citext.
Thời gian: luôn ưu tiên timestamptz
Đây là phần dễ sai nhất và cũng gây thiệt hại thầm lặng nhất trong thực tế. PostgreSQL có nhóm kiểu thời gian sau:
| Kiểu | Ý nghĩa |
|---|---|
date | Chỉ ngày (không giờ). |
time | Chỉ giờ trong ngày. |
timestamp (without time zone) | Ngày + giờ, không gắn múi giờ. |
timestamptz (with time zone) | Ngày + giờ, có nhận thức múi giờ. |
interval | Một khoảng thời lượng (3 ngày 2 giờ...). |
Quy tắc vàng: gần như luôn dùng timestamptz. Cái tên gây hiểu lầm — Postgres không lưu kèm múi giờ vào giá trị. Thực chất khi bạn nhập một timestamptz, Postgres quy đổi giá trị về UTC để lưu; khi đọc ra, nó chuyển sang múi giờ của phiên làm việc (SET timezone). Nhờ vậy một thời điểm là tuyệt đối và không mập mờ, dù server, client và người dùng ở các múi giờ khác nhau.
Ngược lại timestamp (không tz) chỉ là "một mặt đồng hồ treo tường" — 2026-06-30 09:00 mà không ai biết là 9 giờ ở đâu. Dùng nó cho các mốc thời gian toàn cục là con đường ngắn nhất dẫn tới bug khi đổi múi giờ hoặc chuyển server.
▶ Chạy được trong SQL Builder
SELECT now() AS bay_gio,
now() + interval '90 days' AS het_han,
(timestamptz '2026-06-30 09:00+07') AT TIME ZONE 'UTC' AS gio_utc;
interval cho phép cộng/trừ thời gian rất tự nhiên: now() - interval '1 month', end_ts - start_ts (ra một interval). Chỉ dùng timestamp (không tz) hoặc date khi giá trị cố ý không gắn múi giờ — ví dụ "ngày sinh", "ngày lễ theo lịch địa phương".
Boolean
boolean nhận ba trạng thái: TRUE, FALSE, và NULL (chưa biết). PostgreSQL chấp nhận nhiều cách viết đầu vào: 't', 'true', 'yes', '1' cho đúng; 'f', 'false', 'no', '0' cho sai. Luôn nhớ logic ba trạng thái: WHERE is_active chỉ lấy dòng TRUE, còn dòng NULL bị loại — nếu muốn cả NULL phải viết rõ WHERE is_active IS NOT FALSE.
Array: mảng ngay trong một cột
Đây là một trong những tính năng "rất Postgres". Bất kỳ kiểu nào cũng có thể trở thành mảng bằng cách thêm []: integer[], text[], thậm chí mảng nhiều chiều.
▶ Chạy được trong SQL Builder
SELECT ARRAY[1,2,3] && ARRAY[3,4] AS co_giao_nhau, array_length(ARRAY[1,2,3], 1) AS do_dai;
Các thao tác mảng thường dùng:
- Toán tử chứa
@>: mảng bên trái có chứa tất cả phần tử bên phải không.
▶ Chạy được trong SQL Builder
SELECT '{1,2,3}'::int[] @> ARRAY[2] AS chua_phan_tu_2;
&&: hai mảng có phần tử chung (giao khác rỗng).ANY/ALL: so một giá trị với mọi phần tử.WHERE 5 = ANY(tags_ids)giống nhưIN, còn> ALL(...)yêu cầu lớn hơn tất cả.unnest: "trải" mảng thành nhiều dòng — cầu nối giữa thế giới mảng và thế giới quan hệ.
▶ Chạy được trong SQL Builder
SELECT unnest(ARRAY['a','b','c']) AS phan_tu;
array_agg: chiều ngược lại — gộp nhiều dòng thành một mảng, cực hữu ích trongGROUP BY.
Bạn còn có thể đánh index GIN trên cột mảng để tăng tốc các truy vấn @> / &&.
Khi nào nên dùng mảng? Khi tập giá trị nhỏ, thuộc về đúng một dòng, và bạn không cần truy vấn/nối phức tạp trên từng phần tử — ví dụ danh sách tag của một bài viết, danh sách quyền của một vai trò. Khi nào không nên? Khi các phần tử là thực thể có vòng đời riêng (cần khoá ngoại, cần join, cần đếm/thống kê theo phần tử) — lúc đó một bảng con chuẩn hoá luôn tốt hơn. Đừng biến mảng thành cái cớ để né việc thiết kế quan hệ.
Các kiểu chuyên biệt: UUID, ENUM, Range, Network...
UUID
uuid lưu định danh 128-bit, chuẩn cho khoá phân tán (không lộ số lượng, không đụng độ giữa nhiều node). PostgreSQL có sẵn hàm gen_random_uuid() (từ bản 13 đã tích hợp trong core, không cần extension):
▶ Chạy được trong SQL Builder
SELECT gen_random_uuid() AS ma_moi;
Lưu ý hiệu năng: UUID ngẫu nhiên (v4) làm khoá chính khiến index bị chèn rải rác, giảm locality. Với bảng cực lớn, nhiều đội cân nhắc bigint identity hoặc UUID có thứ tự thời gian.
ENUM: tập giá trị cố định, có thứ tự
ENUM là một kiểu tự định nghĩa với danh sách giá trị hữu hạn, có thứ tự theo đúng thứ tự khai báo:
-- (minh hoạ) tạo kiểu ENUM cho trạng thái đơn hàng
CREATE TYPE trang_thai AS ENUM ('cho_duyet', 'da_duyet', 'huy');
-- ORDER BY theo cột kiểu này sẽ theo thứ tự khai báo, không theo bảng chữ cái
ENUM gọn và nhanh hơn text, tự ràng buộc giá trị hợp lệ. Nhược điểm: thêm giá trị mới cần ALTER TYPE ... ADD VALUE và không thể xoá một giá trị dễ dàng. Nếu tập giá trị hay thay đổi, một bảng tra cứu (lookup table) với khoá ngoại linh hoạt hơn.
Range và Multirange: biểu diễn khoảng
Kiểu range biểu diễn một khoảng liên tục: int4range, numrange, tsrange, tstzrange, daterange. Đây là công cụ tuyệt vời cho các bài toán "khoảng thời gian": đặt phòng, hiệu lực hợp đồng, khung giá.
▶ Chạy được trong SQL Builder
SELECT int4range(1,10) @> 5 AS chua_5, int4range(1,10) && int4range(8,20) AS chong_lan;
Ký hiệu [1,10) nghĩa là bao gồm cận trái, loại cận phải — rất hợp với thời gian. Sức mạnh thật sự đến từ ràng buộc loại trừ (EXCLUDE): bạn có thể bắt cơ sở dữ liệu tự động chặn hai lượt đặt phòng chồng lấn nhau cho cùng một phòng, thứ mà UNIQUE thông thường không làm được.
Từ PostgreSQL 14 có thêm multirange (int4multirange, tstzmultirange...) — một tập nhiều khoảng rời nhau gộp thành một giá trị, tiện khi một tài nguyên có nhiều quãng bận rạc.
Network: inet, cidr, macaddr
Nhóm kiểu mạng lưu địa chỉ IP với đầy đủ ngữ nghĩa mạng, không chỉ là chuỗi. inet lưu một địa chỉ (kèm subnet tuỳ chọn); cidr lưu một khối mạng; macaddr lưu địa chỉ MAC. Toán tử << kiểm tra "nằm trong subnet":
▶ Chạy được trong SQL Builder
SELECT '192.168.1.10'::inet << '192.168.1.0/24'::cidr AS trong_subnet;
So với việc lưu IP dạng text rồi tự parse, kiểu inet/cidr cho phép so sánh, sắp xếp và kiểm tra subnet đúng đắn, đồng thời index hiệu quả.
Geometric và bytea
- Geometric:
point,line,lseg,box,path,polygon,circle— cùng toán tử hình học (khoảng cách<->, giao nhau&&). Dùng cho nhu cầu 2D cơ bản; với GIS đầy đủ hãy dùng extension PostGIS. - bytea: dữ liệu nhị phân thô (ảnh nhỏ, chữ ký số, khoá mã hoá). Khác
textở chỗ không diễn giải theo bảng mã. Lưu ý: đừng nhét file lớn vàobytea; với BLOB lớn nên lưu ở object storage và chỉ giữ đường dẫn trong DB.
Domain và Custom type: tự đúc kiểu riêng
DOMAIN — kiểu có sẵn kèm ràng buộc
CREATE DOMAIN tạo một kiểu mới dựa trên kiểu có sẵn, đính kèm ràng buộc CHECK và/hoặc NOT NULL. Ý tưởng: định nghĩa quy tắc một lần, tái dùng ở mọi cột.
-- (minh hoạ) domain email hợp lệ, không rỗng
CREATE DOMAIN email_hople AS text
NOT NULL
CHECK (VALUE ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$');
-- sau đó: CREATE TABLE users (email email_hople, ...);
Từ nay mọi cột email_hople tự có luật kiểm tra; không còn chuyện mỗi bảng viết lại một CHECK khác nhau. positive_int, phone_vn, percentage (0–100) là các domain rất đáng tạo.
Composite type — kiểu tổ hợp nhiều trường
CREATE TYPE ... AS (...) gộp nhiều trường thành một kiểu, dùng làm kiểu cột hoặc kiểu trả về của hàm:
-- (minh hoạ) kiểu địa chỉ tổ hợp
CREATE TYPE dia_chi AS (
duong text,
quan text,
thanh_pho text
);
Composite type mạnh khi làm kiểu trả về của hàm hoặc gói dữ liệu có cấu trúc cố định; tuy vậy để lưu trữ, cân nhắc kỹ giữa composite, các cột phẳng và jsonb.
JSON / JSONB: nhắc ngắn
PostgreSQL còn có json và jsonb cho dữ liệu bán cấu trúc — cùng toán tử truy cập, index GIN, và cả JSON Path. Đây là chủ đề đủ lớn để có bài riêng: xem PostgreSQL 3 — JSONB. Nguyên tắc chọn nhanh: dùng jsonb cho hầu hết trường hợp, và đừng dùng JSON như cái cớ để bỏ chuẩn hoá dữ liệu vốn có cấu trúc rõ ràng.
Use case thực tế: chọn kiểu cho ba cột
Hãy đặt mình vào một schema ngân hàng (accounts, transactions, customers) và quyết định kiểu cho ba cột điển hình:
1. Cột số tiền amount trong transactions.
Lựa chọn đúng: numeric(18,2) (hoặc scale 4 cho tỷ giá). Tuyệt đối không real/double vì sai số làm tròn nhị phân sẽ tích luỹ và lệch sổ. Kèm CHECK (amount <> 0) nếu nghiệp vụ yêu cầu. Loại tiền tệ (VND, USD) tách sang một cột riêng hoặc một ENUM/lookup, không nhét chung vào cột số.
2. Cột thời điểm created_at.
Lựa chọn đúng: timestamptz với DEFAULT now(). Điều này đảm bảo mọi giao dịch có một mốc thời gian tuyệt đối, đúng dù client ở Hà Nội hay server ở Singapore. Dùng timestamp (không tz) ở đây là cái bẫy phổ biến khiến báo cáo lệch giờ khi hệ thống mở rộng đa vùng.
3. Cột nhãn tags cho khách hàng (ví dụ: "VIP", "no_xau", "khuyen_mai").
Nếu tập nhãn nhỏ, thuộc về đúng khách hàng đó, ít quan hệ chéo: text[] + index GIN, lọc bằng tags @> ARRAY['VIP']. Nếu mỗi nhãn là một thực thể có thuộc tính riêng (mô tả, ngày gắn, người gắn) và cần thống kê/khoá ngoại: tách bảng customer_tags chuẩn hoá. Đây chính là ranh giới "khi nào nên/không nên dùng mảng".
Khi các bảng này lớn lên và truy vấn phức tạp lên (khoảng thời gian, phân tích), các kiểu range và mảng ở trên sẽ kết hợp với những kỹ thuật trong PostgreSQL 4 — SQL nâng cao.
Ghi nhớ
- Kiểu là hợp đồng, không phải trang trí: đúng đắn + ràng buộc + hiệu năng. Ép kiểu bằng
::typehoặcCAST(... AS ...). - Tiền →
numeric, không bao giờfloat. Số nguyên mặc địnhinteger, khoá lớn dùngbigint. - Chuỗi →
text/varchar, quênchar(n). Cần so sánh không phân biệt hoa/thường →citext. - Thời gian →
timestamptz(lưu UTC) trong hầu hết trường hợp; chỉ dùngtimestamp/datekhi cố ý bỏ múi giờ. - Mảng tốt cho tập giá trị nhỏ thuộc một dòng; thực thể có vòng đời riêng thì chuẩn hoá thành bảng.
- UUID cho định danh phân tán; ENUM cho tập cố định (cân nhắc lookup nếu hay đổi); range/multirange cho bài toán khoảng; inet/cidr cho địa chỉ mạng; bytea cho nhị phân nhỏ.
- DOMAIN để đóng gói ràng buộc dùng lại; composite type để gói cấu trúc; jsonb cho bán cấu trúc (xem bài riêng).
- Câu lệnh
CREATE TYPE/CREATE DOMAINở đây là minh hoạ; các khối gắn "▶ Chạy được trong SQL Builder" là câuSELECTtự chứa, chạy ngay không cần bảng.
Đọc tiếp: PostgreSQL 1 — Kiến trúc · PostgreSQL 3 — JSONB · PostgreSQL 4 — SQL nâng cao.
Bài viết liên quan
Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.
Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.
Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.
Lọc, sắp xếp, gộp nhóm và hàm tổng hợp — nền tảng mọi truy vấn phân tích.