0

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_json giữ nguyên cấu trúc text ban đầu, bao gồm cả hai thuộc tính name trùng lặp.

  • Cột binary_json tự động loại bỏ giá trị trùng lặp đầu tiên, chỉ giữ lại name: "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ểu TEXT.

  • 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_ops vs jsonb_path_ops: Khi tạo GIN Index trên JSONB, 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:

  1. 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 đồ.

  2. 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).

  3. 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).

  4. 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:

  1. Dữ liệu có quan hệ khóa ngoại (Foreign Keys): Bạn không thể đặt ràng buộc FOREIGN KEY trự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.

  2. 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 JSONB thự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.

  3. 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 JSON lư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 JSONB né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 JSONB trong 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ới jsonb_to_recordset / jsonb_array_elements.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.