PostgreSQL Bài 12: Kiểu mảng (Arrays) và Range Types (tsrange, daterange): Trường hợp sử dụng tối ưu
Trong mô hình cơ sở dữ liệu quan hệ truyền thống, quy tắc chuẩn hóa dạng 1 (1NF - First Normal Form) quy định: Mỗi thuộc tính chỉ được chứa một giá trị nguyên tố (atomic value). Điều này dẫn đến thói quen tạo vô số bảng phụ trung gian cho các mối quan hệ 1-N đơn giản (ví dụ: bảng post_tags, user_roles).
Tuy nhiên, PostgreSQL là một ORDBMS (Hệ quản trị CSDL quan hệ - hướng đối tượng). Engine hỗ trợ trực tiếp các kiểu dữ liệu tập hợp: Arrays (mảng một hoặc nhiều chiều) và Range Types (kiểu khoảng giá trị liên tục).
Bài học này sẽ hướng dẫn bạn cách ứng dụng hai kiểu dữ liệu này để đơn giản hóa kiến trúc bảng và giải quyết gọn gàng các bài toán kinh điển như chống chồng chéo lịch trình (Scheduling Overlap) mà không cần viết các câu lệnh kiểm tra phức tạp ở backend.
1. Kiểu mảng (Arrays) trong PostgreSQL
Mọi kiểu dữ liệu trong PostgreSQL (từ INT, TEXT cho đến các custom type) đều có một phiên bản kiểu mảng tương ứng, được định nghĩa bằng cách thêm cặp ngoặc vuông [] phía sau tên kiểu.
1.1. Cú pháp khai báo và thao tác cơ bản
SQL
CREATE TABLE articles (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}', -- Mảng các chuỗi
reading_times INT[] -- Mảng số nguyên
);
-- Chèn dữ liệu mảng (hỗ trợ cả cú pháp ARRAY[...] và chuỗi ký tự '{...}')
INSERT INTO articles (title, tags, reading_times) VALUES
('Làm chủ PostgreSQL Arrays', ARRAY['database', 'postgres', 'backend'], '{5, 10, 15}'),
('Kiến trúc Microservices', ARRAY['backend', 'architecture'], '{12, 20}');
Lưu ý chỉ số mảng (1-based Indexing): Không giống như hầu hết các ngôn ngữ lập trình (C, Java, Python) bắt đầu từ
0, chỉ số mảng trong SQL chuẩn của PostgreSQL bắt đầu từ1.
SQL
-- Lấy tag đầu tiên của bài viết
SELECT title, tags[1] AS first_tag FROM articles;
1.2. Các toán tử mảng cốt lõi
PostgreSQL cung cấp một hệ thống toán tử chuyên biệt cho mảng, tương tự như trong lý thuyết tập hợp:
| Toán tử | Tên gọi | Ý nghĩa | Ví dụ |
|---|---|---|---|
@> |
Contains | Mảng bên trái có chứa toàn bộ mảng bên phải? | tags @> ARRAY['postgres'] |
<@ |
Contained by | Mảng bên trái có nằm trọn trong mảng bên phải? | tags <@ ARRAY['backend', 'database', 'postgres', 'sql'] |
&& |
Overlap | Hai mảng có ít nhất một phần tử chung? | tags && ARRAY['postgres', 'frontend'] |
\|\| |
Concatenation | Nối hai mảng lại với nhau | ARRAY['db'] \|\| ARRAY['sql'] |
SQL
-- 1. Tìm các bài viết chứa tag 'postgres' VÀ 'backend':
SELECT title FROM articles WHERE tags @> ARRAY['postgres', 'backend'];
-- 2. Tìm các bài viết có ít nhất một trong hai tag 'frontend' HOẶC 'database':
SELECT title FROM articles WHERE tags && ARRAY['frontend', 'database'];
-- 3. Thêm một tag mới vào mảng mà không ghi đè:
UPDATE articles
SET tags = tags || 'performance'
WHERE id = 1;
1.3. Đánh Index trên mảng với GIN Index
Nếu bảng có hàng triệu bản ghi và bạn thường xuyên lọc dữ liệu bằng các toán tử @> hoặc &&, một B-Tree thông thường sẽ vô dụng vì B-Tree chỉ so sánh được sự bằng nhau (=) của cả mảng.
Giải pháp là sử dụng GIN (Generalized Inverted Index):
SQL
-- Tạo GIN Index trên cột mảng
CREATE INDEX idx_articles_tags_gin ON articles USING GIN (tags);
-- Truy vấn sử dụng toán tử @> hoặc && sẽ tận dụng Bitmap Index Scan:
EXPLAIN ANALYZE
SELECT * FROM articles WHERE tags && ARRAY['postgres'];
1.4. Khi nào nên dùng Mảng thay vì Bảng phụ (1-N)?
| Kịch bản | Khuyến nghị | Lý do |
|---|---|---|
| Tags, Badges, User Roles đơn giản | Dùng TEXT[] |
Tránh thao tác JOIN tốn kém, giảm số lượng bảng cần quản lý, dễ dàng lấy ra một lần cùng bản ghi chính. |
| Danh sách trạng thái hữu hạn | Dùng Array | Đơn giản hóa các truy vấn lọc bằng toán tử tập hợp &&. |
| Dữ liệu có thuộc tính phụ (ví dụ: tag có ngày tạo, người gán) | Dùng Bảng phụ (Normalized Table) | Mảng không thể lưu trữ các metadata đi kèm từng phần tử. |
| Tập phần tử quá lớn (> vài trăm phần tử/dòng) | Dùng Bảng phụ | Mảng quá dài sẽ làm phình hàng, kích hoạt TOAST và làm giảm hiệu năng cập nhật. |
2. Kiểu khoảng giá trị: Range Types
Trong thực tế, chúng ta liên tục gặp các dữ liệu đại diện cho một khoảng giá trị:
-
Đặt phòng khách sạn: từ
2026-10-10đến2026-10-15. -
Ca làm việc: từ
08:00:00đến17:00:00. -
Khung giá sản phẩm: từ
100.000đđến500.000đ.
Cách làm cũ (lưu 2 cột start_date và end_date) đòi hỏi câu truy vấn WHERE cực kỳ dài dòng để kiểm tra giao thoa:
SQL
-- Kiểm tra giao thoa giữa hai khoảng [A, B] và [C, D] kiểu truyền thống:
WHERE (A <= D) AND (B >= C) -- Dễ sai sót biên inclusive/exclusive
PostgreSQL giải quyết dứt điểm vấn đề này bằng Range Types.
2.1. Các kiểu Range tích hợp sẵn
-
daterange: Khoảng ngày tháng (DATE). -
tsrange: Khoảng thời gian không có timezone (TIMESTAMP). -
tstzrange: Khoảng thời gian có timezone (TIMESTAMPTZ- Khuyên dùng cho lịch trình/booking). -
int4range,int8range: Khoảng số nguyên. -
numrange: Khoảng số thực/thập phân chính xác.
2.2. Ký hiệu biên đóng/mở (Bounds)
Range tuân thủ toán học chuẩn:
-
[hoặc]: Biên đóng (inclusive - bao gồm giá trị tại mốc đó). -
(hoặc): Biên mở (exclusive - không bao gồm giá trị tại mốc đó). -
Khoảng vô tận (Unbounded): Bỏ trống một bên đầu mút.
SQL
SELECT
daterange('2026-10-01', '2026-10-10', '[)') AS standard_range, -- Bao gồm ngày 01, không gồm ngày 10
numrange(100, NULL, '[)') AS greater_than_or_equal_100; -- [100, +∞)
2.3. Các toán tử Range mạnh mẽ
| Toán tử | Ý nghĩa | Minh họa |
|---|---|---|
@> |
Chứa một điểm hoặc chứa một khoảng | daterange @> '2026-10-05'::DATE |
&& |
Có phần giao thoa / Chồng lấn (Overlap) | range1 && range2 |
<< |
Nằm hoàn toàn về bên trái (xảy ra trước) | range1 << range2 |
>> |
Nằm hoàn toàn về bên phải (xảy ra sau) | range1 >> range2 |
* |
Lấy phần giao giữa 2 khoảng | range1 * range2 |
+ |
Hợp nhất 2 khoảng liền kề | range1 + range2 |
3. Case Study thực chiến: Chống trùng lịch đặt phòng với Exclusion Constraints
Một bài toán kinh điển: Làm thế nào để đảm bảo Phòng A không bao giờ bị 2 khách hàng đặt trùng vào cùng một khoảng thời gian?
Nếu làm ở backend: Bạn chạy câu SELECT kiểm tra trùng -> nếu không trùng thì INSERT. Tuy nhiên, giữa bước SELECT và INSERT có thể xảy ra Race Condition nếu có 2 request đồng thời, dẫn đến tình trạng Overbooking.
PostgreSQL cung cấp giải pháp triệt để: Exclusion Constraint kết hợp GiST Index.
SQL
-- 1. Kích hoạt extension btree_gist để hỗ trợ so sánh kết hợp giữa kiểu thường (INT) và Range
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- 2. Tạo bảng đặt phòng
CREATE TABLE room_reservations (
reservation_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_number INT NOT NULL,
customer_name TEXT NOT NULL,
booking_period TSTZRANGE NOT NULL,
-- RÀNG BUỘC LOẠI TRỪ (EXCLUSION CONSTRAINT):
-- Không cho phép tồn tại 2 dòng có cùng room_number (=) mà thời gian booking lại giao nhau (&&)
CONSTRAINT exclude_overlapping_reservations
EXCLUDE USING GIST (
room_number WITH =,
booking_period WITH &&
)
);
Thử nghiệm thực tế:
SQL
-- Khách 1 đặt phòng 101 từ 14:00 ngày 10 đến 12:00 ngày 12 (Thành công)
INSERT INTO room_reservations (room_number, customer_name, booking_period) VALUES
(101, 'Nguyễn Văn A', tstzrange('2026-10-10 14:00:00+07', '2026-10-12 12:00:00+07', '[)'));
-- Khách 2 cố tình đặt phòng 101 từ 10:00 ngày 11 đến 12:00 ngày 13 (Bị trùng lịch!)
INSERT INTO room_reservations (room_number, customer_name, booking_period) VALUES
(101, 'Trần Thị B', tstzrange('2026-10-11 10:00:00+07', '2026-10-13 12:00:00+07', '[)'));
Ngay lập tức, PostgreSQL sẽ từ chối thao tác và ném lỗi:
Plaintext
ERROR: conflicting key value violates exclusion constraint "exclude_overlapping_reservations"
DETAIL: Key (room_number, booking_period)=(101, ["2026-10-11 03:00:00+00","2026-10-13 05:00:00+00"))
conflicts with existing key (room_number, booking_period)=(101, ["2026-10-10 07:00:00+00","2026-10-12 05:00:00+00")).
Database hoàn toàn tự bảo vệ tính toàn vẹn dữ liệu ở cấp độ phần cứng mà backend không cần phải sử dụng các lệnh khóa bàn tay (Distributed Locks / Redis Lock) tốn kém.
4. Tóm tắt & Bài tiếp theo
-
Kiểu Array thích hợp để lưu các danh sách ngắn gọn, có thể truy vấn bằng các toán tử tập hợp
@>,&&và được tăng tốc nhờ GIN Index. -
Chỉ số mảng trong PostgreSQL bắt đầu từ
1. -
Kiểu Range (
daterange,tstzrange) biểu diễn khoảng giá trị liên tục, hỗ trợ biên mở/đóng linh hoạt và loại bỏ các logic kiểm tra giao thoa thủ công. -
Sự kết hợp giữa Range Types + GiST Index + Exclusion Constraints là chuẩn mực tốt nhất để giải quyết triệt để bài toán Race Condition trong việc đặt lịch, chống trùng lặp khoảng thời gian.
Bài 13 xem tiếp: Enum Types và Custom Composite Types: Khi nào nên và không nên áp dụng — chúng ta sẽ tìm hiểu cách tự tạo kiểu dữ liệu mới trong PostgreSQL với
CREATE TYPE, so sánh hiệu năng giữaENUMvsVARCHAR + CHECK, và những cạm bẫy khó sửa đổi lược đồ (Migration lock) khi sử dụng Enum trong môi trường sản xuất.
All rights reserved