PostgreSQL Bài 10: Quản lý dữ liệu bán cấu trúc với JSON vs JSONB
Một trong những lý do khiến nhiều kỹ sư quyết định ở lại với kiến trúc quan hệ thay vì chuyển dịch sang các cơ sở dữ liệu NoSQL như MongoDB chính là khả năng xử lý dữ liệu bán cấu trúc (Semi-structured data) của PostgreSQL.
PostgreSQL cung cấp hai kiểu dữ liệu chuyên biệt để lưu trữ tài liệu JSON: JSON và JSONB. Thoạt nhìn, chúng có vẻ làm cùng một nhiệm vụ, nhưng cơ chế lưu trữ vật lý, tốc độ thực thi và cách đánh chỉ mục (indexing) lại hoàn toàn trái ngược nhau.
1. Bản chất lưu trữ: JSON vs JSONB
Chữ cái B trong JSONB viết tắt của Binary (nhị phân). Điểm khác biệt cốt lõi nằm ở thời điểm engine tiến hành phân tích cú pháp (parse) dữ liệu:
Kiểu JSON (Text thuần):
Client gửi chuỗi JSON ──► Kiểm tra cú pháp (Validate) ──► Lưu nguyên văn chuỗi Text vào Đĩa
(Truy vấn: Parse lại từ đầu mỗi lần chạy query)
Kiểu JSONB (Phân tích nhị phân):
Client gửi chuỗi JSON ──► Parse cú pháp ──► Chuẩn hóa, bóc tách Keys/Values ──► Đóng gói Binary vào Đĩa
(Truy vấn: Đọc trực tiếp cấu trúc nhị phân đã phân tích sẵn)
1.1. Bảng so sánh chi tiết
| Tiêu chí | JSON (Text Storage) | JSONB (Binary Storage) |
|---|---|---|
| Cơ chế lưu trữ vật lý | Lưu nguyên bản chuỗi văn bản (tương tự như TEXT). |
Phân tích thành cây đối tượng nhị phân được tối ưu hóa. |
| Tốc độ ghi (INSERT/UPDATE) | Rất nhanh. Chỉ cần kiểm tra cú pháp hợp lệ rồi ghi thẳng xuống đĩa/TOAST. | Chậm hơn một chút. Tốn CPU để bóc tách cấu trúc JSON, loại bỏ khoảng trắng và sắp xếp keys. |
| Tốc độ đọc & Truy vấn khóa | Chậm. Mỗi lần bạn trích xuất một trường con, engine buộc phải đọc và parse lại toàn bộ chuỗi text từ đầu. | Cực nhanh. Đọc trực tiếp vị trí offset nhị phân của trường cần tìm mà không phải parse lại. |
| Khoảng trắng & Thụt dòng | Giữ nguyên vẹn mọi dấu cách, thụt đầu dòng (indentation). | Loại bỏ hoàn toàn khoảng trắng thừa để tiết kiệm dung lượng. |
| Thứ tự các Keys | Giữ nguyên thứ tự các key như lúc truyền vào. | Tự động sắp xếp lại keys (theo độ dài và bảng mã) để phục vụ tìm kiếm nhị phân. |
| Xử lý Duplicate Keys | Giữ nguyên tất cả các key trùng lặp. | Chỉ giữ lại key xuất hiện cuối cùng. |
| Hỗ trợ Indexing | Rất hạn chế. Không thể đánh index GIN tổng thể, chỉ có thể tạo Functional B-Tree Index trên từng trường cụ thể. | Hỗ trợ toàn diện. Đánh được GIN Index, BTREE, HASH. |
1.2. Thử nghiệm chứng minh sự khác biệt
Hãy chạy thử đoạn script sau trong psql:
SQL
SELECT
'{"name": "Laptop", "specs": {"ram": "16GB"}, "name": "MacBook"}'::JSON AS raw_json,
'{"name": "Laptop", "specs": {"ram": "16GB"}, "name": "MacBook"}'::JSONB AS binary_json;
Kết quả trả về:
Plaintext
-[ RECORD 1 ]+------------------------------------------------------------------
raw_json | {"name": "Laptop", "specs": {"ram": "16GB"}, "name": "MacBook"}
binary_json | {"name": "MacBook", "specs": {"ram": "16GB"}}
-
Cột
raw_jsongiữ nguyên cấu trúc text ban đầu, bao gồm cả hai thuộc tínhnametrùng lặp. -
Cột
binary_jsontự động loại bỏ giá trị trùng lặp đầu tiên, chỉ giữ lạiname: "MacBook"và chuẩn hóa lại cấu trúc nhị phân.
2. Các toán tử truy vấn cơ bản: Bóc tách dữ liệu từ JSON
PostgreSQL cung cấp một hệ thống toán tử mũi tên chuyên biệt để duyệt qua các cấp lồng nhau của tài liệu JSON:
| Toán tử | Mục đích | Kiểu dữ liệu trả về | Ví dụ |
|---|---|---|---|
-> |
Trích xuất trường con theo Key hoặc Index của mảng | JSON / JSONB | payload -> 'specs' |
->> |
Trích xuất trường con theo Key hoặc Index dưới dạng văn bản | TEXT | payload ->> 'name' |
#> |
Trích xuất trường con lồng sâu theo một đường dẫn (Path) | JSON / JSONB | payload #> '{specs, cpu, cores}' |
#>> |
Trích xuất trường con lồng sâu dưới dạng văn bản | TEXT | payload #>> '{specs, ram}' |
Quy tắc phân biệt
->và->>:
Nếu bạn muốn lấy dữ liệu ra để so sánh (
WHERE), tính toán hoặc hiển thị lên giao diện: Luôn kết thúc bằng->>để trả về kiểuTEXT.Nếu bạn muốn đi sâu tiếp vào một Object hoặc Array lồng phía trong: Dùng
->để giữ nguyên định dạng JSON/JSONB cho bước kế tiếp.
Ví dụ thực tế:
SQL
CREATE TABLE product_catalog (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku VARCHAR(50) NOT NULL UNIQUE,
attributes JSONB NOT NULL
);
INSERT INTO product_catalog (sku, attributes) VALUES
('DELL-XPS-15', '{
"brand": "Dell",
"price": 1800,
"in_stock": true,
"tags": ["laptop", "ultrabook", "developer"],
"specs": {
"cpu": "Intel Core i7",
"ram_gb": 32,
"storage": {"type": "SSD", "capacity_gb": 1000}
}
}');
-- 1. Lấy tên Brand và CPU dưới dạng Text
SELECT
sku,
attributes ->> 'brand' AS brand,
attributes -> 'specs' ->> 'cpu' AS cpu_name,
attributes #>> '{specs, storage, type}' AS ssd_type
FROM product_catalog;
-- 2. Lấy phần tử đầu tiên trong mảng tags
SELECT
sku,
attributes -> 'tags' ->> 0 AS first_tag
FROM product_catalog;
3. Các toán tử kiểm tra sự tồn tại (Containment Operators) trên JSONB
Một trong những sức mạnh lớn nhất của JSONB nằm ở khả năng kiểm tra quan hệ bao hàm (containment) thông qua các toán tử chuyên dụng:
3.1. Toán tử chứa @> (Contains)
Kiểm tra xem tài liệu JSONB bên trái có chứa toàn bộ cấu trúc và giá trị của JSONB bên phải hay không:
SQL
-- Tìm tất cả sản phẩm thuộc hãng Dell và còn hàng (in_stock = true)
SELECT sku
FROM product_catalog
WHERE attributes @> '{"brand": "Dell", "in_stock": true}';
-- Tìm sản phẩm có tag "developer" trong mảng tags
SELECT sku
FROM product_catalog
WHERE attributes @> '{"tags": ["developer"]}';
3.2. Toán tử kiểm tra Key: ?, ?|, ?&
-
?: Kiểm tra xem một Key cụ thể có tồn tại ở tầng ngoài cùng của document hay không. -
?|: Kiểm tra xem bất kỳ key nào trong danh sách mảng có tồn tại hay không (OR). -
?&: Kiểm tra xem tất cả các key trong danh sách mảng có cùng tồn tại hay không (AND).
SQL
-- Kiểm tra xem thuộc tính có trường 'warranty' hay không:
SELECT sku FROM product_catalog WHERE attributes ? 'warranty';
-- Kiểm tra xem có chứa ít nhất một trong hai trường 'discount' HOẶC 'sale_price':
SELECT sku FROM product_catalog WHERE attributes ?| array['discount', 'sale_price'];
4. Đánh chỉ mục (Index) trên dữ liệu JSONB
Nếu bạn lọc dữ liệu trên một bảng có hàng triệu bản ghi bằng WHERE attributes ->> 'brand' = 'Dell' mà không có index, PostgreSQL bắt buộc phải quét tuần tự toàn bộ bảng (Sequential Scan).
Để tối ưu, PostgreSQL hỗ trợ 2 chiến lược index chính cho JSONB:
Chiến lược 1: B-Tree Index trên một trường cụ thể (Functional Index)
Nếu bạn chỉ thường xuyên lọc hoặc sắp xếp theo một thuộc tính cố định bên trong JSON, tạo Expression-based B-Tree Index là phương án nhẹ và nhanh nhất:
SQL
-- Tạo B-Tree index trên trường brand:
CREATE INDEX idx_products_brand ON product_catalog ((attributes ->> 'brand'));
-- Câu truy vấn sẽ tận dụng được Index:
EXPLAIN ANALYZE
SELECT * FROM product_catalog WHERE attributes ->> 'brand' = 'Dell';
Chiến lược 2: GIN Index toàn diện (Generalized Inverted Index)
Nếu JSON của bạn có cấu trúc động và người dùng có thể tìm kiếm theo bất kỳ thuộc tính nào, hãy tạo GIN Index trên toàn bộ cột JSONB. GIN sẽ phân rã toàn bộ các cặp key/value thành các mục chỉ mục đảo ngược:
SQL
-- Tạo GIN Index tổng thể cho toàn bộ cột attributes:
CREATE INDEX idx_products_attributes_gin ON product_catalog USING GIN (attributes);
-- GIN Index sẽ tự động kích hoạt khi bạn sử dụng các toán tử: @>, ?, ?|, ?&
EXPLAIN ANALYZE
SELECT * FROM product_catalog
WHERE attributes @> '{"specs": {"ram_gb": 32}}';
So sánh
jsonb_opsvsjsonb_path_ops: Khi tạo GIN Index trênJSONB, PostgreSQL hỗ trợ 2 operator class:
jsonb_ops(Mặc định): Đánh index cho cả Key, Value và đường dẫn. Hỗ trợ tất cả các toán tử (@>,?,?|,?&). Kích thước index lớn hơn.
jsonb_path_ops: Chỉ băm (hash) toàn bộ đường dẫn từ gốc tới giá trị lá. Kích thước index nhỏ hơn đáng kể, tốc độ tìm kiếm toán tử@>nhanh hơn, nhưng không hỗ trợ các toán tử kiểm tra sự tồn tại của key đơn lẻ như?,?|,?&.SQL
-- Tạo index siêu nhẹ chuyên trị toán tử chứa (@>): CREATE INDEX idx_products_path_gin ON product_catalog USING GIN (attributes jsonb_path_ops);
5. Khi nào nên dùng JSONB, khi nào KHÔNG nên?
JSONB rất mạnh, nhưng lạm dụng nó để thay thế hoàn toàn bảng quan hệ phẳng là một sai lầm kiến trúc phổ biến dẫn đến suy thoái hiệu năng dài hạn.
5.1. Khi NÊN dùng JSONB:
-
Thuộc tính sản phẩm đa dạng (E-commerce Attributes): Ngành hàng thời trang có size/màu sắc; đồ điện tử có CPU/RAM/VGA; sách có số trang/tác giả. Việc tạo hàng trăm cột nullable cho từng loại sản phẩm sẽ làm phình lược đồ.
-
Lưu trữ Audit Logs / Snapshots: Lưu lại trạng thái của một đối tượng tại một thời điểm (ví dụ: Snapshot thông tin giỏ hàng và địa chỉ giao hàng tại lúc khách bấm thanh toán).
-
Payload Webhook và Tích hợp bên thứ ba: Nhận các gói tin JSON không cố định cấu trúc từ đối tác (Stripe, GitHub, ZaloPay).
-
Cấu hình tùy biến của người dùng (User Preferences): Giao diện tối/sáng, cài đặt thông báo, ngôn ngữ hiển thị.
5.2. Khi TUYỆT ĐỐI TRÁNH dùng JSONB:
-
Dữ liệu có quan hệ khóa ngoại (Foreign Keys): Bạn không thể đặt ràng buộc
FOREIGN KEYtrực tiếp lên một trường nằm sâu bên trong JSONB tham chiếu sang bảng khác. -
Các trường liên tục bị sửa đổi nhỏ giọt: Cập nhật một số nguyên đơn lẻ trong
JSONBthực chất là viết lại toàn bộ document JSONB đó (tạo tuple mới + kích hoạt MVCC), tốn tài nguyên I/O hơn nhiều so với sửa một cột số nguyên thông thường. -
Dữ liệu cần thống kê, tổng hợp (Aggregation) tần suất cao: Chạy các phép toán như
SUM(),AVG()trên các cột quan hệ nguyên bản luôn nhanh hơn gấp nhiều lần so với việc trích xuất và ép kiểu từ JSONB.
6. Tóm tắt & Bài tiếp theo
-
Kiểu
JSONlưu chuỗi text nguyên bản, ghi nhanh nhưng đọc chậm và thiếu khả năng đánh index tổng thể. -
Kiểu
JSONBnén nhị phân, chuẩn hóa loại bỏ trùng lặp, tối ưu vượt trội cho việc truy vấn trường sâu và hỗ trợ mạnh mẽ GIN Index. -
Phân biệt toán tử
->(trả về JSON object) và->>(trả về Text). -
Toán tử
@>kết hợp với GIN index là cặp bài trùng mạnh nhất để xử lý các truy vấn chứa trên tài liệu bán cấu trúc.
Bài 11 xem tiếp: Các hàm và toán tử chuyên biệt trên
JSONBtrong môi trường sản xuất — chúng ta sẽ học cách thao tác chỉnh sửa JSONB nâng cao mà không cần viết lại toàn bộ document:jsonb_set, toán tử xóa trừ-, toán tử xóa đường dẫn#-, và kỹ thuật làm phẳng dữ liệu JSON thành bảng quan hệ vớijsonb_to_recordset/jsonb_array_elements.
All Rights Reserved