Trong thế giới thiết kế cơ sở dữ liệu hiện đại, ranh giới giữa mô hình quan hệ chặt chẽ (Relational) và mô hình tài liệu phi cấu trúc (NoSQL Document) đang ngày càng mờ nhạt. Thay vì tách nhỏ mọi thuộc tính thành hàng chục bảng chuẩn hóa hoặc chuyển hẳn sang các DB không quan hệ, SQL hỗ trợ các cột lưu trữ dữ liệu dạng JSON kết hợp với cơ chế Generated Columns (Cột tự động sinh). Bài viết này sẽ đi sâu vào cách SQLite xử lý dữ liệu bán cấu trúc, cách trích xuất, làm phẳng các mảng phức tạp, và phương pháp tối ưu hóa hiệu năng bằng chỉ mục trên biểu thức (Index on Expression).

1. Lưu Trữ JSON Trong SQLite: Text thô vs Định Dạng Nhị Phân (JSONB)

Khác với các hệ quản trị lớn như PostgreSQL có kiểu dữ liệu jsonb lưu nhị phân chuyên dụng từ sớm, triết lý của SQLite là tối giản và tương thích ngược. Mặc định, SQLite lưu trữ dữ liệu JSON dưới dạng một chuỗi văn bản thô (kiểu TEXT).

Điều này có nghĩa là mỗi khi bạn chạy câu lệnh trích xuất thuộc tính bên trong JSON, SQLite buộc phải quét qua chuỗi văn bản và phân tích cú pháp (parse) lại từ đầu. Nếu bảng có $N$ dòng, CPU sẽ phải thực hiện $N$ lần thao tác parse chuỗi. Độ phức tạp tính toán trung bình cho việc phân tích cú pháp một chuỗi JSON độ dài $L$ là \(O(L)\), khiến các truy vấn quét toàn bảng trên dữ liệu JSON thô tốn rất nhiều CPU.

Để giải quyết bài toán hiệu năng này, kể từ phiên bản SQLite 3.45.0 (đầu năm 2024), định dạng JSONB đã được giới thiệu. JSONB chuyển đổi văn bản JSON thành một chuỗi nhị phân (kiểu BLOB) đã được phân tích sẵn cấu trúc cây.

Đặc tính JSON thô (TEXT) JSONB (Nhị phân BLOB)
Cách lưu trữ Chuỗi văn bản RFC-8259 nguyên bản. Mảng byte nhị phân đã sắp xếp sẵn vị trí key/value.
Tốc độ ghi (Insert/Update) Nhanh (chỉ ghi chuỗi thô, không cần parse). Chậm hơn một chút (phải parse và chuyển sang nhị phân khi viết).
Tốc độ đọc (Query/Extract) Chậm (phải parse JSON liên tục trên mỗi dòng). Nhanh hơn từ 2x đến 3x (chỉ cần nhảy thẳng tới offset của key).
Dung lượng đĩa Lớn hơn (chứa đầy đủ dấu nháy, khoảng trắng dư thừa). Nhỏ hơn khoảng 5-10% (loại bỏ cú pháp text thừa).

Trong thực tế, bạn không khai báo kiểu cột là JSONB vì SQLite dùng hệ kiểu động (Type Affinity). Bạn chỉ cần lưu kết quả của hàm jsonb() vào một cột kiểu BLOB:

CREATE TABLE product_specs (
  product_id INTEGER PRIMARY KEY,
  specs_raw TEXT,         -- Lưu JSON thô dạng văn bản
  specs_bin BLOB          -- Lưu JSONB nhị phân bằng hàm jsonb()
);

INSERT INTO product_specs (product_id, specs_raw, specs_bin)
VALUES (
  1, 
  '{"RAM": "16GB", "CPU": "M2", "weight_kg": 1.4}', 
  jsonb('{"RAM": "16GB", "CPU": "M2", "weight_kg": 1.4}')
);
💡 Hàm tự động chuyển đổi trong SQLite
Tất cả các hàm xử lý JSON của SQLite (như json_extract) đều chấp nhận cả đầu vào là TEXT lẫn nhị phân BLOB. Nếu đầu vào là nhị phân, nó tự động nhận biết cấu trúc đã tối ưu để bỏ qua bước phân tích cú pháp chuỗi, mang lại tốc độ vượt trội.

2. Truy Vấn Dữ Liệu Bán Cấu Trúc: Hàm Trích Xuất & Toán Tử -> / ->>

Hãy xem xét tình huống kinh doanh thực tế tại TechMart: Cửa hàng bán các dòng sản phẩm có thông số kỹ thuật (Specs) hoàn toàn khác nhau. Thay vì tạo hàng chục cột thưa chứa nhiều giá trị NULL (ví dụ: cột battery_life chỉ có cho Laptop, dpi chỉ có cho Mouse, frequency_response chỉ có cho Headphones), chúng ta gói tất cả các thuộc tính động này vào cột JSON specs.

Để trích xuất các thuộc tính bên trong JSON, SQLite cung cấp hàm json_extract(json, path). Đường dẫn path bắt đầu bằng ký tự $ đại diện cho gốc của đối tượng.

-- Truy vấn truyền thống bằng json_extract
SELECT product_name, 
       json_extract(specs, '$.RAM') AS ram,
       json_extract(specs, '$.weight_kg') AS weight
FROM techmart_products
WHERE json_extract(specs, '$.category_type') = 'Laptop';

Để cú pháp ngắn gọn và trực quan hơn (tương tự như PostgreSQL), kể từ phiên bản 3.38.0, SQLite hỗ trợ hai toán tử mũi tên:

  • Toán tử ->: Trích xuất một đối tượng con hoặc giá trị dưới dạng chuỗi JSON (giữ nguyên dấu nháy " bọc quanh chuỗi và định dạng JSON).
  • Toán tử ->>: Trích xuất một đối tượng dưới dạng giá trị thô của SQL (trả về kiểu text, integer, real thực tế, tự động gỡ bỏ dấu nháy kép bọc ngoài chuỗi).
-- Sử dụng các toán tử mũi tên hiện đại
SELECT product_name,
       specs -> '$.RAM' AS ram_json,     -- Trả về '"16GB"' (có dấu nháy)
       specs ->> '$.RAM' AS ram_raw,     -- Trả về '16GB' (chuỗi sạch)
       specs ->> '$.weight_kg' AS weight -- Trả về 1.4 dưới dạng số thực
FROM techmart_products
WHERE specs ->> '$.category_type' = 'Laptop';
🕳️ Cạm bẫy so sánh dữ liệu với toán tử mũi tên
Một trong những lỗi kinh điển khi mới làm việc với JSON trong SQL là sử dụng nhầm toán tử -> khi so sánh chuỗi. Nếu bạn viết:
WHERE specs -> '$.RAM' = '16GB'
Kết quả sẽ luôn là sai (empty), bởi vì vế trái trả về chuỗi JSON '"16GB"' (có chứa hai dấu nháy kép), trong khi vế phải là chuỗi SQL thông thường '16GB'. Hãy luôn sử dụng toán tử kép ->> khi muốn lọc hoặc so sánh giá trị văn bản thô.
⚠️ Ép kiểu tường minh cho các phép toán so sánh số học
Bản chất giá trị trả về của ->> vẫn có thể được hiểu là chuỗi văn bản nếu SQLite không thể tự động ép kiểu chính xác. Đối với các phép toán so sánh lớn hơn/nhỏ hơn trên kiểu số, để an toàn tuyệt đối tránh lỗi so sánh chuỗi (ví dụ: chuỗi '10' nhỏ hơn '2' theo thứ tự bảng chữ cái), bạn nên thực hiện ép kiểu rõ ràng:
SELECT product_name FROM techmart_products
WHERE CAST(specs ->> '$.weight_kg' AS REAL) > 1.5;

3. Làm Phẳng Dữ Liệu JSON Phức Tạp: Sử Dụng json_eachjson_tree

Trong nhiều trường hợp, một trường JSON không chỉ chứa các cặp key-value đơn giản mà chứa các mảng (Arrays) hoặc các cây phân cấp lồng nhau. Tình huống kinh doanh: Bảng api_logs ghi nhận các sự kiện từ client. Cột payload chứa mảng danh sách lỗi phát sinh trong một request:

{
  "request_id": 9901,
  "status": "failed",
  "errors": [
    {"code": "ERR_STOCK", "msg": "Wireless Mouse hết hàng"},
    {"code": "ERR_PRICE", "msg": "Sai lệch giá bán tối thiểu"}
  ]
}

Làm sao để lọc ra toàn bộ các bản ghi lỗi chi tiết của từng request thành từng hàng SQL độc lập? Chúng ta sử dụng hàm json_each(json, path) như một bảng ảo và kết hợp với phép nối JOIN (thực chất là một lateral join ngầm định).

SELECT log_id,
       payload ->> '$.request_id' AS request_id,
       value ->> '$.code' AS error_code,
       value ->> '$.msg' AS error_msg
FROM api_logs, json_each(api_logs.payload, '$.errors');

Trong câu lệnh trên, hàm json_each duyệt qua mảng nằm ở đường dẫn $.errors. Với mỗi phần tử trong mảng, nó trả về một hàng ảo có cột value chứa chuỗi JSON của phần tử đó. Nhờ đó, chúng ta có thể tiếp tục dùng toán tử ->> trên cột value để bóc tách thông tin lỗi chi tiết.

Sự khác biệt giữa json_eachjson_tree:

  • json_each: Chỉ duyệt qua các phần tử con trực tiếp (mức độ 1) của đường dẫn được chỉ định.
  • json_tree: Duyệt đệ quy theo chiều sâu qua toàn bộ các nhánh con, cháu, chắt của cây JSON. Nó trả về đầy đủ đường dẫn cụ thể (cột path) của từng phần tử xuất hiện trong cấu trúc.
-- Tìm kiếm tất cả các giá trị văn bản bất kể nó nằm ở cấp độ nào trong JSON payload
SELECT log_id, key, value, path
FROM api_logs, json_tree(api_logs.payload)
WHERE type = 'text' AND value LIKE '%hết hàng%';

4. Cập Nhật Dữ Liệu JSON: So Sánh json_set, json_insert, json_replacejson_patch

Không chỉ đọc, SQL còn cho phép chỉnh sửa nội dung bên trong tài liệu JSON mà không cần phải ghi đè toàn bộ cột bằng code ứng dụng. Tình huống kinh doanh: Lưu trữ cấu hình giao diện và tùy chọn thông báo của người dùng trong bảng user_profiles. SQLite cung cấp các hàm chuyên biệt sau:

Hàm Cơ chế hoạt động Nếu Key đã tồn tại Nếu Key chưa tồn tại
json_set() Thiết lập giá trị. Cập nhật giá trị mới. Tạo thêm cặp key-value mới.
json_insert() Chèn giá trị mới. Bỏ qua (không thay đổi giá trị cũ). Tạo thêm cặp key-value mới.
json_replace() Thay thế giá trị cũ. Cập nhật giá trị mới. Bỏ qua (không tạo mới).

Ví dụ minh họa sự khác biệt khi chạy cùng một cấu hình cập nhật:

-- Giả sử settings ban đầu là: '{"theme": "dark"}'

-- 1. json_set: Cập nhật theme cũ đồng thời thêm mới language
SELECT json_set('{"theme": "dark"}', '$.theme', 'light', '$.lang', 'vi');
-- Kết quả: '{"theme": "light", "lang": "vi"}'

-- 2. json_insert: theme giữ nguyên vì đã có, lang được thêm mới
SELECT json_insert('{"theme": "dark"}', '$.theme', 'light', '$.lang', 'vi');
-- Kết quả: '{"theme": "dark", "lang": "vi"}'

-- 3. json_replace: theme được cập nhật, lang bị bỏ qua vì chưa có
SELECT json_replace('{"theme": "dark"}', '$.theme', 'light', '$.lang', 'vi');
-- Kết quả: '{"theme": "light"}'

Ngoài việc cập nhật từng đường dẫn đơn lẻ, bạn có thể thực hiện trộn (merge) hai đối tượng JSON bằng hàm json_patch(json1, json2) (tuân thủ tiêu chuẩn RFC-7396). Nó sẽ ghi đè các trường của json1 bằng dữ liệu từ json2, và nếu một trường ở json2 có giá trị là null, trường đó sẽ bị xóa khỏi đối tượng kết quả.

-- Gộp cấu hình mặc định với cấu hình tùy biến của user
SELECT json_patch(
  '{"theme": "dark", "font_size": 14, "show_sidebar": true}',
  '{"theme": "light", "show_sidebar": null}' -- null nghĩa là xóa trường này
);
-- Kết quả: '{"theme": "light", "font_size": 14}'

5. Generated Columns: Cột Tự Động Tạo (Virtual vs Stored)

Một trong những phàn nàn lớn nhất khi làm việc với JSON trong cơ sở dữ liệu quan hệ là cú pháp trích xuất dữ liệu rườm rà. Mỗi khi cần lấy cân nặng sản phẩm để tính phí ship, chúng ta lại phải gõ specs ->> '$.weight_kg'.

Cơ chế Generated Columns ra đời để tạo ra các cột có giá trị được tính toán tự động dựa trên biểu thức từ các cột khác. Chúng ta có hai chế độ lưu trữ: VIRTUAL (Mặc định) và STORED.

Đặc tính Cột ảo (VIRTUAL) Cột lưu trữ (STORED)
Lưu trữ trên đĩa Không tốn bất kỳ byte nào. Chiếm dung lượng đĩa tương đương cột thường.
Thời điểm tính toán Tính toán lại biểu thức mỗi khi dòng đó được đọc (SELECT). Tính toán một lần duy nhất khi dòng được ghi (INSERT/UPDATE).
Tốc độ đọc Chậm hơn (phải tính toán biểu thức). Nhanh bằng cột thông thường.
Tốc độ ghi Nhanh (không mất chi phí tính toán khi viết). Chậm hơn (phải chạy biểu thức trước khi lưu đĩa).

Khai báo cột tự động trích xuất cấu hình từ JSON specs:

CREATE TABLE techmart_products (
  product_id INTEGER PRIMARY KEY,
  product_name TEXT NOT NULL,
  specs TEXT,
  -- Cột ảo: Không tốn dung lượng đĩa
  device_ram TEXT GENERATED ALWAYS AS (specs ->> '$.RAM') VIRTUAL,
  -- Cột lưu trữ: Tính sẵn khi ghi, đọc cực nhanh
  shipping_weight REAL GENERATED ALWAYS AS (CAST(specs ->> '$.weight_kg' AS REAL)) STORED
);

Với khai báo trên, lập trình viên có thể viết các câu truy vấn sạch đẹp như sau mà không cần quan tâm đến cú pháp JSON phức tạp phía sau:

SELECT product_name, device_ram, shipping_weight 
FROM techmart_products 
WHERE shipping_weight > 1.5;

6. Tối Ưu Hóa Hiệu Năng: Tạo Chỉ Mục Trên Biểu Thức (Index on Expression)

Mặc dù cột ảo (VIRTUAL Generated Column) không lưu trữ dữ liệu thật trên đĩa, SQLite vẫn cho phép chúng ta tạo chỉ mục (Index) lên trên nó.

Khi bạn tạo chỉ mục trên một cột ảo hoặc trực tiếp lên một biểu thức JSON:

CREATE INDEX idx_products_weight ON techmart_products(specs ->> '$.weight_kg');

SQLite sẽ trích xuất trước giá trị cân nặng từ JSON, xây dựng một cấu trúc cây chỉ mục B-Tree riêng và lưu trữ các khóa đó lên đĩa. Điều này mang lại một sự cải thiện hiệu năng khổng lồ:

  • Trước khi có index: Hệ thống bắt buộc phải quét tuần tự toàn bảng (Full Table Scan). Độ phức tạp thời gian là \(O(N)\) phép parse và so sánh JSON.
  • Sau khi có index: Hệ thống thực hiện tìm kiếm nhị phân trực tiếp trên B-Tree chỉ mục. Độ phức tạp giảm xuống mức tối ưu \(O(\log N)\).

Hãy cùng kiểm chứng sự tối ưu hóa này thông qua công cụ phân tích Query Plan dưới đây.

Đang tải SQLite-WASM...

📋 Sơ đồ bảng hiện có (Schema)

Kết quả truy vấn sẽ hiển thị ở đây...
🔬 Đào sâu: VDBE thực thi biểu thức chỉ mục thế nào?
Khi bạn thực hiện câu lệnh EXPLAIN trên SQLite cho một truy vấn lọc theo JSON có chỉ mục, bạn sẽ thấy máy ảo VDBE (Virtual Database Engine) không sử dụng chỉ thị Function để parse chuỗi JSON nữa. Thay vào đó, nó dùng op-code SeekGE hoặc SeekLT thẳng tới trang B-Tree của chỉ mục. Việc này biến SQLite thành một kho chứa tài liệu (Document Store) cực kỳ mạnh mẽ không thua kém gì MongoDB về mặt tốc độ truy vấn các thuộc tính tùy biến.

Kiểm tra kiến thức ôn tập

Trắc nghiệm ôn tập

Câu 1: Điểm khác biệt lớn nhất về hiệu năng của định dạng nhị phân JSONB so với văn bản JSON thô (TEXT) trong SQLite là gì?

Trắc nghiệm ôn tập

Câu 2: Tại sao câu lệnh SELECT product_name FROM techmart_products WHERE specs -> '$.RAM' = '16GB' không trả về dòng nào mặc dù trong database có specs chứa {"RAM": "16GB"}?

Trắc nghiệm ôn tập

Câu 3: Khi nào bạn nên chọn STORED thay vì VIRTUAL cho một cột Generated Column?

Trắc nghiệm ôn tập

Câu 4: Có thể tạo Index trên biểu thức JSON (Index on Expression) của một cột ảo (VIRTUAL Generated Column) được không?

Trắc nghiệm ôn tập

Câu 5: Khác biệt cốt lõi giữa hai hàm làm phẳng mảng JSON là json_eachjson_tree là gì?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 14: FTS5 Full-Text Search Bài 12: Trigger, View & Virtual Table Quay lại Lộ trình Series SQL