PostgreSQL Bài 13: Enum Types và Custom Composite Types: Khi nào nên và không nên áp dụng
Khép lại Phần 2 về thiết kế lược đồ và mô hình dữ liệu, chúng ta đi đến một trong những tính năng thể hiện rõ nhất bản chất ORDBMS (Hệ quản trị CSDL hướng đối tượng - quan hệ) của PostgreSQL: khả năng cho phép người dùng tự định nghĩa các kiểu dữ liệu hoàn toàn mới bằng lệnh CREATE TYPE.
Hai dạng kiểu dữ liệu tùy biến phổ biến nhất là:
-
Enumerated Types (
ENUM): Tập hợp các nhãn tĩnh được định nghĩa trước. -
Composite Types: Kiểu dữ liệu phức hợp gồm nhiều trường con có cấu trúc (tương đương với một
structtrong Golang/C hoặc mộtclassdữ liệu).
Dù mang lại sự rõ ràng và tính bao đóng cho lược đồ, việc lạm dụng chúng trên Production có thể dẫn đến những "cơn ác mộng" khi bảo trì và chạy migration. Bài học này sẽ mổ xẻ cơ chế lưu trữ, lợi ích và những cạm bẫy cần tránh.
1. Kiểu liệt kê: ENUM Types
Kiểu ENUM đại diện cho một danh sách các giá trị tĩnh cố định (như trạng thái đơn hàng, vai trò hệ thống, giới tính).
1.1. Cú pháp khởi tạo và sử dụng
SQL
-- Tạo kiểu ENUM cho trạng thái đơn hàng
CREATE TYPE order_status AS ENUM (
'PENDING',
'PROCESSING',
'SHIPPED',
'DELIVERED',
'CANCELLED'
);
-- Sử dụng trong định nghĩa bảng
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
total_amount NUMERIC(12, 2) NOT NULL,
status order_status NOT NULL DEFAULT 'PENDING'
);
1.2. Cơ chế lưu trữ vật lý của ENUM
Khi bạn lưu chuỗi 'DELIVERED' vào một cột VARCHAR(20), PostgreSQL sẽ tốn 9 bytes (kèm 1 byte header).
Nhưng với ENUM:
-
Mỗi giá trị của ENUM được PostgreSQL lưu trữ nội bộ bằng một mã định danh số nguyên 4-byte (OID) trong bảng hệ thống
pg_enum. -
Trên các data pages của bảng chính, cột ENUM chỉ chiếm đúng 4 bytes.
-
Hiệu năng: Việc so sánh (
status = 'DELIVERED') hoặc sắp xếp B-Tree thực chất là so sánh các số nguyên 4 bytes thay vì so sánh chuỗi ký tự, giúp tiết kiệm bộ nhớshared_buffersvà CPU. -
Thứ tự sắp xếp: Các giá trị ENUM được sắp xếp theo thứ tự bạn khai báo trong lệnh
CREATE TYPE(ở ví dụ trên:'PENDING' < 'PROCESSING' < 'SHIPPED').
1.3. Cạm bẫy Migration của ENUM trong môi trường Production
Dù tiết kiệm dung lượng, ENUM là một trong những đối tượng gây đau đầu nhất cho các kỹ sư DevOps/DBA khi schema thay đổi:
Cạm bẫy 1: Thêm giá trị mới không được chạy trong Transaction Block (PG 11 trở về trước)
Dù các phiên bản hiện đại đã nới lỏng giới hạn, việc thêm giá trị vào ENUM bằng ALTER TYPE ... ADD VALUE có những ràng buộc khắt khe:
SQL
-- Thêm giá trị mới vào ENUM:
ALTER TYPE order_status ADD VALUE 'REFUNDED' AFTER 'CANCELLED';
Cảnh báo: Bạn không thể sử dụng giá trị mới thêm vào ngay trong cùng transaction tạo ra nó! Nếu migration tool của bạn (như Flyway, Liquibase, Laravel Migrations) bọc toàn bộ file migration trong một
BEGIN ... COMMIT, việc thêm value và cập nhật dữ liệu ngay sau đó sẽ khiến transaction thất bại:ERROR: unsafe use of new value "REFUNDED" of enum type order_status
Cạm bẫy 2: Không thể XÓA hoặc ĐỔI TÊN một giá trị ENUM một cách dễ dàng
PostgreSQL không hỗ trợ lệnh kiểu:
SQL
-- LỆNH NÀY HOÀN TOÀN KHÔNG TỒN TẠI TRONG POSTGRESQL:
ALTER TYPE order_status DROP VALUE 'PENDING';
Nếu muốn xóa một giá trị khỏi ENUM, bạn buộc phải:
-
Đổi tên ENUM cũ thành
order_status_old. -
Tạo ENUM mới
order_statuskhông chứa giá trị cần bỏ. -
Chạy
ALTER TABLE orders ALTER COLUMN status TYPE order_status USING status::text::order_status;(Lệnh này sẽ khóa toàn bộ bảngordersbằngAccessExclusiveLockđể viết lại toàn bộ cột trên đĩa!). -
Xóa ENUM cũ.
1.4. Bảng đối chiếu: ENUM vs VARCHAR + CHECK vs Lookup Table
| Tiêu chí | ENUM Type | VARCHAR + CHECK | Bảng tra cứu (Lookup Table) |
|---|---|---|---|
| Dung lượng lưu trữ | 4 bytes cố định | Chiều dài chuỗi thực tế + 1 byte | Thường là 2-4 bytes (Khóa ngoại SMALLINT/INT) |
| Bảo vệ toàn vẹn dữ liệu | Rất tốt (chặn giá trị lạ) | Rất tốt (chặn giá trị lạ) | Rất tốt (Foreign Key) |
| Dễ dàng thêm giá trị | Trung bình (ALTER TYPE) |
Dễ dàng (ALTER CHECK) |
Cực dễ (INSERT một dòng vào bảng phụ) |
| Xóa / Đổi tên giá trị | Rất khó & rủi ro | Dễ dàng | Cực dễ (Cập nhật bảng phụ) |
| Lưu thêm metadata phụ | Không thể | Không thể | Rất tốt (ví dụ: mô tả, mã màu, cờ kích hoạt) |
Quy tắc thực chiến:
Dùng ENUM: Khi danh sách trạng thái mang tính bất biến, đã chuẩn hóa theo quy chuẩn quốc tế hoặc khoa học và gần như không bao giờ thay đổi (ví dụ: các ngày trong tuần
MONDAY..SUNDAY, hệ máuA, B, AB, O, giới tính theo chuẩn sinh học).Dùng
VARCHAR+CHECK: Khi danh sách trạng thái thuộc nghiệp vụ phần mềm (đơn hàng, bài viết, thanh toán) có khả năng bổ sung hoặc đổi tên trong tương lai, nhưng quy mô chưa cần tới bảng riêng.Dùng Lookup Table (Foreign Key): Khi danh sách trạng thái cần cho phép admin quản trị cấu hình động qua dashboard, hoặc cần gắn thêm các metadata (mô tả, icon hiển thị).
2. Kiểu phức hợp: Composite Types
Một Composite Type đại diện cho một danh sách các tên trường và kiểu dữ liệu đi kèm.
Thực chất, khi bạn tạo bất kỳ một bảng nào trong PostgreSQL, hệ thống đều tự động ngầm định tạo ra một Composite Type trùng tên với bảng đó. Ngoài ra, bạn có thể chủ động tự tạo riêng.
2.1. Cú pháp khởi tạo và sử dụng
SQL
-- Tạo kiểu dữ liệu phức hợp cho địa chỉ
CREATE TYPE postal_address AS (
street TEXT,
ward TEXT,
district TEXT,
city TEXT,
postal_code VARCHAR(10)
);
-- Sử dụng làm kiểu dữ liệu của một cột
CREATE TABLE warehouses (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
location postal_address NOT NULL
);
-- Chèn dữ liệu:
INSERT INTO warehouses (name, location) VALUES
('Kho Miền Nam', ROW('123 Quốc lộ 1A', 'Linh Xuân', 'Thủ Đức', 'Hồ Chí Minh', '700000'));
2.2. Truy vấn và thao tác trên Composite Type
Để truy xuất một trường con bên trong Composite Type, bạn bắt buộc phải bọc tên cột trong dấu ngoặc đơn () để PostgreSQL phân biệt giữa cú pháp schema.table và column.field:
SQL
-- Lấy tên kho và thành phố:
SELECT
name,
(location).city,
(location).district
FROM warehouses
WHERE (location).city = 'Hồ Chí Minh';
3. Khi nào KHÔNG NÊN dùng Composite Type làm cột của bảng?
Dù Composite Type trông có vẻ rất hướng đối tượng, việc sử dụng nó làm kiểu dữ liệu cho một cột bảng trong hệ thống sản xuất thường mang lại nhiều phiền toái hơn là lợi ích:
-
Cú pháp truy vấn cồng kềnh: Việc phải viết
(column).fielddễ gây lỗi cú pháp và gây khó khăn cho hầu hết các ORM backend hiện nay (như Eloquent, Hibernate, Prisma, GORM) trong việc ánh xạ tự động vào entity. -
Khó khăn khi đánh Index: Muốn đánh index cho một trường con, bạn phải tạo Expression Index:
SQL
CREATE INDEX idx_warehouses_city ON warehouses (((location).city)); -
Sự trỗi dậy của
JSONB: Hầu hết các kịch bản trước đây dùng Composite Type ngày nay đều được thay thế hoàn hảo bằngJSONB.JSONBlinh hoạt hơn, dễ dàng mở rộng schema mà không cần migration DDL phức tạp, và có hệ sinh thái toán tử/index (GIN) mạnh mẽ hơn rất nhiều.
Vậy kịch bản TỐI ƯU NHẤT của Composite Type là gì?
Composite Type phát huy sức mạnh vượt trội nhất không phải ở tầng lưu trữ bảng, mà là ở tầng lập trình cơ sở dữ liệu (PL/pgSQL Functions):
Dùng Composite Type để định nghĩa kiểu dữ liệu trả về cho các User-Defined Functions (UDF) khi hàm cần trả về nhiều trường dữ liệu phức tạp:
SQL
-- Định nghĩa kiểu dữ liệu cho kết quả tính toán doanh thu
CREATE TYPE sales_summary_result AS (
total_orders BIGINT,
gross_revenue NUMERIC(15, 2),
net_profit NUMERIC(15, 2),
cancelled_count INT
);
-- Hàm trả về kiểu phức hợp này:
CREATE OR REPLACE FUNCTION calculate_monthly_summary(target_month DATE)
RETURNS sales_summary_result AS $$
DECLARE
result sales_summary_result;
BEGIN
-- Tính toán logic...
result.total_orders := 1500;
result.gross_revenue := 350000000.00;
result.net_profit := 85000000.00;
result.cancelled_count := 12;
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- Gọi hàm:
SELECT * FROM calculate_monthly_summary('2026-10-01');
4. Tóm tắt Phần 2 & Chuẩn bị bước sang Phần 3
Chúng ta đã chính thức hoàn thành toàn bộ Phần 2: Thiết kế lược đồ & Mô hình dữ liệu hiện đại (từ Bài 7 đến Bài 13). Bạn đã nắm trọn vẹn:
-
Các ràng buộc bảo vệ toàn vẹn dữ liệu và Foreign Key Indexing (Bài 7).
-
Kỹ thuật dùng UUID v7, INET/CIDR, MACADDR chuyên dụng (Bài 8).
-
Cơ chế nén và tách bảng của TOAST khi lưu chuỗi lớn (Bài 9).
-
Quản lý bán cấu trúc với JSONB, GIN Index và SQL/JSON Path (Bài 10 & 11).
-
Mảng nguyên bản và chống chồng chéo lịch trình bằng Exclusion Constraints + GiST (Bài 12).
-
Bản chất 4-byte của ENUM và vai trò đúng chỗ của Composite Types (Bài 13).
Bước sang Phần 3: Kỹ thuật truy vấn từ cơ bản đến nâng cao
Bài 14 xem tiếp: Cơ chế JOIN chuyên sâu: Phân tích Nested Loop, Hash Join và Merge Join — bước sang tầng tối ưu hóa truy vấn, chúng ta sẽ bóc tách cách Query Planner của PostgreSQL quyết định thuật toán kết hợp bảng: khi nào engine chọn quét vòng lặp lồng nhau (Nested Loop), khi nào dựng bảng băm trên RAM (Hash Join), và khi nào sắp xếp trước rồi gộp (Merge Join).
All rights reserved