PostgreSQL Bài 41: Làm chủ kiểu dữ liệu JSONB: Truy vấn, Cập nhật và Index chuyên sâu
Khi thiết kế hệ thống cho các nền tảng thương mại điện tử hoặc quản lý thuộc tính động (dynamic attributes), chúng ta thường đối mặt với bài toán: Mỗi ngành hàng lại có những thông số kỹ thuật khác nhau. Điện thoại thì có RAM, CPU, Camera; Quần áo thì có Size, Color, Material.
Nếu tạo cột cho từng thuộc tính trong bảng Relational, bảng sẽ có hàng trăm cột chứa toàn giá trị NULL. Giải pháp EAV (Entity-Attribute-Value) truyền thống lại gây ra các câu query JOIN phức tạp và cực chậm.
PostgreSQL giải quyết trọn vẹn bài toán này bằng kiểu dữ liệu JSONB – biến database quan hệ thành một Document Database (như MongoDB) thu nhỏ, với đầy đủ sức mạnh của ACID và Index.
1. JSON vs JSONB: Đừng bao giờ nhầm lẫn
PostgreSQL cung cấp 2 kiểu dữ liệu: JSON và JSONB. Quy tắc sống còn đầu tiên: Luôn luôn sử dụng JSONB.
| Tiêu chí | JSON | JSONB (Khuyên dùng) |
|---|---|---|
| Định dạng lưu trữ | Text (Văn bản thuần túy). Lưu nguyên vẹn dấu cách, phím enter. | Binary (Nhị phân). Đã được phân tích cú pháp (parsed). |
| Tốc độ ghi (INSERT) | Nhanh hơn một chút (do không cần parse). | Chậm hơn một chút (tốn CPU để phân tích và nén nhị phân). |
| Tốc độ đọc (SELECT) | Chậm (Phải parse lại thành object mỗi lần đọc). | Cực nhanh (Không cần parse lại). |
| Khả năng Index | KHÔNG hỗ trợ GIN Index. | Hỗ trợ GIN Index siêu tốc. |
| Xử lý key trùng lặp | Giữ nguyên các key trùng lặp. | Tự động loại bỏ key trùng lặp, giữ lại giá trị cuối cùng. Tự động sắp xếp key. |
2. Kỹ thuật Truy vấn JSONB (Data Extraction)
Giả sử chúng ta có bảng products lưu trữ thông tin sản phẩm:
SQL
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'::jsonb
);
INSERT INTO products (name, attributes) VALUES
('Laptop ThinkPad', '{"brand": "Lenovo", "specs": {"ram": "16GB", "cpu": "i7"}, "tags": ["work", "laptop"]}'),
('iPhone 15', '{"brand": "Apple", "specs": {"ram": "8GB", "storage": "256GB"}, "tags": ["phone", "apple"]}');
2.1. Bóc tách trường dữ liệu bằng -> và ->>
-
Toán tử
->: Trả về dữ liệu kiểu JSONB. (Dùng khi bạn muốn tiếp tục móc sâu vào object con). -
Toán tử
->>: Trả về dữ liệu kiểu TEXT. (Dùng để hiển thị hoặc so sánh với chuỗi).
SQL
-- Lấy ra brand dưới dạng chuỗi TEXT
SELECT name, attributes ->> 'brand' AS brand FROM products;
-- Móc sâu vào object con (specs -> ram)
SELECT name, attributes -> 'specs' ->> 'ram' AS ram FROM products;
-- Lấy phần tử mảng bằng index (tags[0])
SELECT name, attributes -> 'tags' ->> 0 AS first_tag FROM products;
2.2. Toán tử kiểm tra sự tồn tại (?, ?&, ?|)
Khi làm backend, bạn thường xuyên phải tìm các dòng có chứa một Key cụ thể:
SQL
-- Tìm sản phẩm mà attributes có chứa key 'brand'
SELECT * FROM products WHERE attributes ? 'brand';
-- Chứa TẤT CẢ các keys trong danh sách (AND)
SELECT * FROM products WHERE attributes ?& array['brand', 'specs'];
-- Chứa ÍT NHẤT MỘT key trong danh sách (OR)
SELECT * FROM products WHERE attributes ?| array['brand', 'color'];
2.3. Toán tử đối sánh tập hợp @> (Contains) - Quan trọng nhất!
Toán tử @> kiểm tra xem JSONB bên trái có bao hàm toàn bộ JSONB bên phải hay không. Đây là toán tử được sử dụng nhiều nhất để lọc dữ liệu.
SQL
-- Tìm các sản phẩm của Apple
SELECT * FROM products WHERE attributes @> '{"brand": "Apple"}';
-- Tìm sản phẩm có RAM 16GB
SELECT * FROM products WHERE attributes @> '{"specs": {"ram": "16GB"}}';
-- Tìm sản phẩm có chứa tag "laptop" trong mảng tags
SELECT * FROM products WHERE attributes @> '{"tags": ["laptop"]}';
3. Cập nhật và Xóa phần tử trong JSONB
Cập nhật JSONB trong PostgreSQL không đòi hỏi bạn phải lấy toàn bộ object ra backend, sửa đổi rồi ghi đè lại (Read-Modify-Write). Bạn có thể thao tác trực tiếp bằng SQL.
3.1. Hợp nhất / Thêm mới bằng toán tử ||
Toán tử || sẽ hợp nhất (merge) 2 object JSONB. Nếu key bị trùng, giá trị bên phải sẽ ghi đè bên trái.
SQL
-- Thêm/Cập nhật trường "color" và "warranty"
UPDATE products
SET attributes = attributes || '{"color": "Black", "warranty": "12 months"}'::jsonb
WHERE id = 1;
3.2. Chỉnh sửa chuyên sâu bằng jsonb_set()
Cú pháp: jsonb_set(target, path, new_value, [create_if_missing])
SQL
-- Đổi RAM từ 16GB thành 32GB
UPDATE products
SET attributes = jsonb_set(attributes, '{specs, ram}', '"32GB"'::jsonb)
WHERE id = 1;
(Lưu ý: Tham số thứ 3 bắt buộc phải là kiểu jsonb, nên chuỗi văn bản cần được bọc trong cặp nháy kép "32GB" rồi mới ép kiểu ::jsonb).
3.3. Xóa Key bằng toán tử - và #-
SQL
-- Xóa key "warranty" ở cấp ngoài cùng
UPDATE products SET attributes = attributes - 'warranty' WHERE id = 1;
-- Xóa phần tử mảng bằng giá trị
UPDATE products SET attributes = attributes - 'work' WHERE id = 1;
-- Xóa theo đường dẫn sâu (xóa specs -> cpu)
UPDATE products SET attributes = attributes #- '{specs, cpu}' WHERE id = 1;
4. Tối ưu hóa hiệu năng: Index chuyên sâu cho JSONB
Nếu bạn chạy lệnh WHERE attributes @> '{"brand": "Apple"}' trên bảng có 10 triệu dòng mà không có index, PostgreSQL sẽ phải parse và so sánh JSON trên toàn bộ bảng (Seq Scan), làm CPU server bốc cháy.
Để tối ưu, chúng ta sử dụng GIN Index (đã học ở Bài 23). Có 3 chiến lược lập chỉ mục cho JSONB tùy thuộc vào bài toán:
Chiến lược 1: GIN Index mặc định (jsonb_ops)
Hỗ trợ tất cả các toán tử: @>, ?, ?&, ?|.
SQL
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
-
Ưu điểm: Cực kỳ linh hoạt, query kiểu gì cũng ăn index.
-
Nhược điểm: Kích thước file index rất bự (phình to gấp 2-3 lần dữ liệu gốc) vì nó phải băm từng key và từng value ra để lưu. Tốc độ ghi (
INSERT/UPDATE) chậm.
Chiến lược 2: GIN Index thu gọn (jsonb_path_ops)
Nếu hệ thống của bạn chỉ sử dụng duy nhất toán tử @> (Contains) để tìm kiếm, hãy dùng jsonb_path_ops.
SQL
CREATE INDEX idx_products_attrs_path ON products USING GIN (attributes jsonb_path_ops);
-
Cơ chế: Nó gom chung Key và Value thành một mã băm duy nhất (thay vì tách rời).
-
Lợi ích: Kích thước index nhỏ hơn một nửa, tốc độ ghi nhanh hơn, tốc độ đọc với
@>cũng nhanh hơn. -
Hạn chế: Không hỗ trợ các toán tử dò key như
?,?&.
Chiến lược 3: B-Tree Expression Index (Tuyệt chiêu cho Key cố định)
Nếu bạn biết chắc chắn ứng dụng backend (Go/Laravel) luôn luôn lọc theo một field cố định, ví dụ brand, việc tạo GIN Index cho toàn bộ cục JSONB là một sự lãng phí tài nguyên khủng khiếp.
Thay vào đó, hãy bóc tách trường đó ra và tạo một B-Tree Index thông thường (Expression Index):
SQL
-- Chỉ tạo index cho riêng giá trị chuỗi của 'brand'
CREATE INDEX idx_products_brand ON products ((attributes ->> 'brand'));
-- Query kích hoạt B-Tree Index (Cực nhanh và nhẹ):
SELECT * FROM products WHERE (attributes ->> 'brand') = 'Apple';
5. Bẫy kinh điển: NULL SQL vs null JSONB
Đây là lỗi logic khiến rất nhiều lập trình viên đau đầu khi xử lý dữ liệu động. Trong JSONB, có sự khác biệt rất lớn giữa giá trị rỗng của SQL và giá trị null của JSON.
SQL
-- Dòng 1: Cột attributes không có giá trị gì (NULL của SQL)
INSERT INTO products (name, attributes) VALUES ('No Attributes', NULL);
-- Dòng 2: Cột attributes chứa một JSON hợp lệ, nhưng bên trong có key bị null
INSERT INTO products (name, attributes) VALUES ('Null Specs', '{"specs": null}'::jsonb);
Thử nghiệm lấy dữ liệu:
SQL
SELECT name, attributes -> 'specs' AS specs_jsonb, attributes ->> 'specs' AS specs_text FROM products;
| Dòng | Giá trị gốc | Kết quả -> (JSONB) |
Kết quả ->> (TEXT) |
|---|---|---|---|
| Dòng 1 | NULL SQL |
NULL |
NULL |
| Dòng 2 | JSON {"specs": null} |
null (Đây là 1 giá trị JSON hợp lệ) |
NULL (Bị ép kiểu thành SQL NULL) |
Nếu code backend kiểm tra bằng hàm IS NULL của SQL:
SQL
-- Sẽ tìm thấy Dòng 1 (vì SQL NULL) và Dòng 2 (vì ->> trả về SQL NULL)
SELECT * FROM products WHERE (attributes ->> 'specs') IS NULL;
-- CHỈ tìm thấy Dòng 1. Dòng 2 sẽ bị bỏ qua vì (attributes -> 'specs') trả về một object JSON 'null', không phải SQL NULL!
SELECT * FROM products WHERE (attributes -> 'specs') IS NULL;
6. Tổng kết & Best Practices
-
Đừng lạm dụng JSONB: Nếu một thuộc tính luôn luôn tồn tại ở mọi dòng (như
created_at,status,user_id), hãy tách nó ra làm cột Relational tiêu chuẩn. Chỉ dùng JSONB cho dữ liệu Schema-less, biến đổi khôn lường hoặc payload từ external APIs. -
Tránh mảng (Array) lồng nhau quá sâu: Việc cập nhật một phần tử mảng nằm sâu 3 tầng trong JSONB bằng SQL rất phức tạp và tốn chi phí CPU. Nếu mảng quá phức tạp, hãy cân nhắc tách thành một bảng quan hệ riêng (1-N).
-
Luôn dùng toán tử
@>kết hợp với GIN Index (jsonb_path_ops) cho các bài toán tìm kiếm tập hợp đa biến. -
Nếu chỉ lọc theo 1-2 trường JSON cố định, B-Tree Expression Index là lựa chọn vô địch về tối ưu tài nguyên.
Bài 42 xem tiếp: Full-Text Search (FTS) nâng cao trong PostgreSQL — chúng ta sẽ xây dựng một công cụ tìm kiếm chuẩn xác như Elasticsearch trực tiếp trong PostgreSQL. Phân tích văn bản thành các lexemes với
tsvector, truy vấn bằngtsquery, đánh dấu kết quả (highlighting) và tối ưu độ tin cậy xếp hạng (Ranking) với GIN Index.
All rights reserved