JSON sang SQL: câu lệnh INSERT, thiết kế bảng và bẫy ánh xạ kiểu
Kết luận: Việc biến JSON thành SQL INSERT che giấu việc tách bảng lồng nhau, NULL so với thiếu trường, escaping và khác biệt dialect. Câu lệnh chạy được và bảng so sánh cho từng bẫy.

Kết luận: hãy quyết định một dòng đại diện cho thực thể nghiệp vụ nào trước khi thiết kế bảng, rồi viết VALUES nhiều dòng tường minh trong một transaction; chốt cách đặt tên trường và ánh xạ kiểu trước khi nhập thay vì vá chúng trong SQL.
JSON mô tả hình dạng; SQL còn mô tả ràng buộc. Khi bạn chuyển cái trước sang cái sau, mọi ràng buộc bạn không ghi ra sẽ quay lại sau này dưới dạng dữ liệu bẩn.
1. Quyết định trước: một bảng hay một bản ghi mỗi dòng JSON
Hãy nhìn lớp ngoài cùng của dữ liệu trước khi thiết kế bất kỳ bảng nào. Quyết định sai ở chỗ này thì mọi câu INSERT sau đó đều phải viết lại.
| Cấu trúc JSON | Thiết kế bảng đề xuất | Rủi ro chính |
|---|---|---|
Mảng object phẳng [{...},{...}] |
Một bảng, nhiều dòng | Khóa không đồng nhất giữa các bản ghi khiến phần lớn cột hợp nhất bị NULL |
Object bọc mảng {"data":[...],"total":100} |
Một bảng, chỉ lấy data |
Ghi total vào mọi dòng làm lạc ý nghĩa của trường |
Object lồng {"user":{"name":"x"}} |
Bảng cha cộng các cột đã làm phẳng, hoặc cha cộng bảng liên quan | Mở rộng không kiểm soát, tên cột trùng lặp |
Trường mảng {"tags":["a","b"]} |
Bảng con, hoặc cột JSON/TEXT | Quan hệ một-nhiều bị ép vào một ô và không còn truy vấn được theo tag |
| Khóa không đồng nhất | Cột hợp nhất, hoặc tách bảng theo loại | "Thiếu khóa" và "giá trị là null" sụp thành cùng một NULL |
Chỉ có một tiêu chí: một dòng đại diện cho thực thể nghiệp vụ nào? Nếu một dòng là đơn hàng, các dòng chi tiết phải nằm ở bảng riêng. Nếu một dòng là người dùng, tag thuộc bảng con hoặc cột JSON, không phải chuỗi nối bằng dấu phẩy.
2. Từ mảng đến INSERT: mẫu chạy được nhỏ nhất
Dạng an toàn nhất luôn liệt kê tên cột tường minh và không bao giờ dựa vào thứ tự cột:
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
);
INSERT INTO users (id, name, email) VALUES
(1, 'Ada', 'ada@example.com'),
(2, 'Linus', NULL);
Ba chi tiết quan trọng:
- Viết mọi tên cột. Khi schema sau này thêm cột, không có gì bị lệch sang trường sai.
VALUESnhiều dòng hoạt động trong SQLite, PostgreSQL và MySQL, và tiết kiệm rất nhiều vòng gọi so với việc lặp INSERT từng dòng.NULLlà từ khóa; đừng đặt nó trong ngoặc. Viết'NULL'sẽ lưu một chuỗi bốn ký tự.
Nếu khóa JSON không khớp tên cột (userId so với user_id), hãy thêm một lớp ánh xạ. Đừng đoán bằng COALESCE bên trong SQL. Sửa tên trường ở lớp dữ liệu rẻ hơn nhiều so với vá trong truy vấn.
3. Object lồng nhau: bảng cha cộng bảng con
Đơn hàng là trường hợp một-nhiều kinh điển:
INSERT INTO orders (id, customer_name, total_cents) VALUES
(1001, 'Ada', 12900);
INSERT INTO order_items (order_id, sku, quantity) VALUES
(1001, 'A-1', 2),
(1001, 'B-7', 1);
Điều này tương ứng với JSON {"id":1001,"customer":"Ada","total":129.00,"items":[{"sku":"A-1","quantity":2},{"sku":"B-7","quantity":1}]}.
Các điểm chính:
- Độ dài mảng
itemsquyết định số dòng con; bảng cha nhận đúng một dòng. - Lưu tiền dưới dạng số nguyên cent (
total_cents), không bao giờ dùngFLOAT. - Cho bảng con một khóa ngoại
order_idvà commit cả hai lệnh insert trong một transaction, nếu không cha có thể tồn tại trong khi các mục của nó bị thiếu. - Nếu dữ liệu chỉ đọc và không ai lọc theo từng dòng hàng, một cột JSON là chấp nhận được (PostgreSQL
jsonb, MySQLJSON, hoặc SQLiteTEXTvới các hàm json1), nhưng hãy chấp nhận cái giá: không đánh chỉ mục được và truy vấn khó.
Hãy làm phẳng các object lồng cố định như user.address thành cột (address_city, address_zip). Chỉ tách thành bảng khi lồng nhau có thể tiếp tục lớn lên; tách một hình dạng cố định chỉ thêm JOIN.
4. NULL so với trường thiếu: SQL không có "khóa vắng mặt"
JSON cho phép một khóa vắng mặt hoàn toàn. Cột SQL thì cố định. Sau khi nhập, cả hai trường hợp trông giống hệt — NULL — dù chúng có thể mang nghĩa khác nhau:
| Tình huống JSON | Cơ sở dữ liệu hiển thị | Cách xử lý đề xuất |
|---|---|---|
| Khóa vắng mặt | NULL |
Thêm giá trị mặc định ở định nghĩa bảng, hoặc cột đánh dấu _present |
Khóa tồn tại với null |
NULL |
Nghĩa là người dùng đã xóa giá trị một cách tường minh |
| Khóa tồn tại với chuỗi rỗng | '' |
Phân biệt được với NULL, nhưng dễ nhầm trong truy vấn |
| Trường số bị thiếu | NULL |
Đừng bao giờ để 0 giả làm "thiếu" |
Hai lỗi phổ biến:
- Dùng chuỗi rỗng thay vì NULL. Sau này
WHERE email IS NULLsẽ bỏ sót những dòng đó, và chỉ số "người dùng không có email" của bạn âm thầm đếm thiếu. - Kiểm tra bằng
col = NULL. Trong logic ba giá trị của SQL,NULL = NULLcho raNULL, không phảiTRUE. Bạn phải viếtIS NULL:
SELECT COUNT(*) FROM users WHERE email IS NULL;
Nếu cần giữ việc một khóa có tồn tại hay không, hãy thêm cột đánh dấu boolean thay vì suy ra từ NULL. Hợp đồng API cũng nên nêu điều này; xem Thiết kế JSON API và đặt tên trường.
5. Quoting và escaping: đừng bao giờ nối chuỗi SQL
Chuỗi JSON có thể chứa dấu nháy đơn, nháy kép, dấu gạch chéo ngược, xuống dòng, và cả \u0000. Với chuỗi tự dựng, một lần escape sót sẽ làm lệch phần còn lại của câu lệnh, và trong trường hợp tệ nhất trở thành SQL injection.
Trong SQL chuẩn, dấu nháy đơn được escape bằng cách nhân đôi:
INSERT INTO notes (id, content) VALUES (1, 'It''s a ''quoted'' value');
Mã production nên dùng binding tham số và để driver xử lý escaping cùng kiểu:
// PostgreSQL uses $1/$2, MySQL uses ?; this example uses node-postgres
await client.query('INSERT INTO users (id, name) VALUES ($1, $2)', [row.id, row.name]);
Chỉ tự escape khi bạn buộc phải xuất tệp .sql, và khi đó tối thiểu:
- thay
'bằng''; - nếu giữ xuống dòng, xác nhận cơ sở dữ liệu đích chấp nhận literal chuỗi nhiều dòng;
- loại bỏ
\u0000, vì hầu hết cơ sở dữ liệu từ chối NUL trong chuỗi; - MySQL mặc định coi dấu gạch chéo ngược là ký tự escape, nên
\phải thành\\, hoặc chuyển sang chế độ chuẩn.
Dấu nháy kép từ JSON không cần escape bên trong chuỗi SQL đặt trong nháy đơn. Thêm một lớp escaping nữa chính là cách các dấu gạch chéo lạc lọt vào dữ liệu của bạn.
6. Ánh xạ kiểu: sáu giá trị JSON đáp ở đâu trong SQL
JSON chỉ có string, number, boolean, null, object và array. SQL có hệ thống kiểu chi tiết hơn nhiều. Sai sót thường xảy ra ở độ chính xác và ngữ nghĩa chứ không phải cú pháp:
| Giá trị JSON | Kiểu SQL đề xuất | Chỗ vỡ |
|---|---|---|
| Số nguyên | INTEGER / BIGINT |
Trên 2^53, phía JavaScript đã mất độ chính xác, nên giá trị được lưu cũng sai |
| Thập phân, tiền | NUMERIC / DECIMAL |
FLOAT là dấu phẩy động nhị phân; 0.1 + 0.2 không chính xác |
| Chuỗi | TEXT / VARCHAR(n) |
Giới hạn độ dài cắt bớt; collation thay đổi cách so sánh |
| Boolean | BOOLEAN / TINYINT(1) |
SQLite không có kiểu boolean và lưu 0/1 |
| null | NULL |
Không phân biệt được với trường thiếu |
| Chuỗi ngày | DATE / TIMESTAMP / TIMESTAMPTZ |
Mất múi giờ, cắt mili giây, định dạng không hợp lệ |
| Chuỗi enum | TEXT với CHECK, hoặc bảng enum |
Khác biệt hoa thường và giá trị bẩn không có ràng buộc |
| Object / array | Bảng con, hoặc JSON / JSONB |
Lưu dưới dạng chuỗi thì không đánh chỉ mục hay truy vấn được |
Boolean ẩn lỗi tinh vi nhất: JSON false trở thành 0 trong MySQL TINYINT(1), khứ hồi trông ổn, và không ai nhận ra. Nhưng khoảnh khắc một đoạn mã chạy if (value) trên chuỗi "false", nó được đánh giá là truthy. Script chuyển đổi nên gọi Boolean(value) một cách tường minh.
PostgreSQL JSONB phân tích và khử trùng khóa, giữ lần xuất hiện cuối, phù hợp cho truy vấn; JSON giữ nguyên văn bản gốc và thứ tự khóa. SQLite JSON chỉ là TEXT cộng một bộ hàm, không có ràng buộc kiểu thật. Trước khi chọn, hãy hỏi xem bảng này có cần lọc trên các trường bên trong JSON không.
7. Insert hàng loạt và transaction: theo lô, commit, chạy lại được
Khi khối lượng lớn lên, ba điều quyết định một lần nhập có còn kiểm soát được không:
- Theo lô. Lặp N câu
INSERTmột dòng tốn N vòng mạng. Gộp 500 đến 1000 dòng vào mộtVALUESnhiều dòng, hoặc dùng bulk loader của chính cơ sở dữ liệu (PostgreSQLCOPY, MySQLLOAD DATA, insert nhiều dòng trong một transaction SQLite). - Dùng transaction. Tất cả hoặc không gì cả, để một bảng nạp một nửa không bao giờ sống sót sau lỗi:
BEGIN;
INSERT INTO users (id, name) VALUES (1, 'Ada'), (2, 'Linus');
INSERT INTO order_items (order_id, sku, quantity) VALUES (1, 'A-1', 2);
COMMIT;
- Làm cho nó idempotent. Script nhập bị chạy lại liên tục. Dùng upsert thay vì xóa-rồi-chèn:
| Cơ sở dữ liệu | Dạng idempotent |
|---|---|
| PostgreSQL / SQLite | INSERT ... ON CONFLICT (id) DO UPDATE SET ... |
| MySQL | INSERT ... ON DUPLICATE KEY UPDATE ... |
ON CONFLICT cần một ràng buộc duy nhất hoặc khóa chính, nếu không nó không bao giờ kích hoạt. Khi không có khóa tự nhiên, hãy dựng chỉ mục duy nhất trên khóa nghiệp vụ như email. Khi hoàn toàn không có khóa, hãy thêm id ổn định phía JSON — đừng bao giờ dựa vào id tự tăng để idempotent, vì mỗi lần chạy lại sẽ chèn bản trùng.
8. Khác biệt dialect nhìn nhanh
| Khả năng | SQLite | PostgreSQL | MySQL |
|---|---|---|---|
VALUES nhiều dòng |
Có | Có | Có |
| Upsert | ON CONFLICT |
ON CONFLICT |
ON DUPLICATE KEY UPDATE |
| Khóa tự tăng | INTEGER PRIMARY KEY |
GENERATED ... AS IDENTITY |
AUTO_INCREMENT |
| Kiểu boolean | Không có, lưu 0/1 | boolean |
TINYINT(1) |
| Nhập hàng loạt | Insert nhiều dòng trong một transaction | COPY |
LOAD DATA |
| Hỗ trợ JSON | TEXT cộng các hàm json1 |
json / jsonb |
JSON |
| Quote định danh | Dấu nháy kép | Dấu nháy kép | Dấu backtick (mặc định) |
Để một tệp SQL chạy được trên cả ba, tối thiểu hãy tránh ON CONFLICT, dấu backtick và SERIAL. Với dự án đa cơ sở dữ liệu, hãy sinh schema và câu upsert riêng; đừng kỳ vọng một script phù hợp mọi nơi.
9. Ba điểm kiểm tra sau khi nhập
Trước khi xóa tệp nguồn, hãy xác nhận ba điều:
- Số dòng.
SELECT COUNT(*) FROM users;phải bằng độ dài mảng JSON. Thiếu một dòng thường có nghĩa bản ghi cuối bị cắt. - Cột có tỷ lệ NULL cao nhất. Điều đó chỉ ra tên khóa không đồng nhất hoặc lồng nhau chưa được mở rộng. Quay lại xem nguồn có dùng cả
userIdvàuser_idkhông. - Tiền và định danh dài. Lấy giá trị lớn nhất và nhỏ nhất, xác nhận cả chuyển đổi dấu phẩy động lẫn số nguyên đều không cắt chúng.
Nếu cần hiểu hình dạng dạng bảng trước, hãy dùng cách làm phẳng trong Chuyển đổi JSON, CSV và dữ liệu dạng bảng. Nếu đối tượng là đồng nghiệp chỉ muốn đọc dữ liệu, bốn cách chuyển JSON sang Excel ít công hơn là dựng một cơ sở dữ liệu.
Vì sao số mất độ chính xác sau khi nhập JSON vào SQL?
Số nguyên trên 2^53 đã mất độ chính xác trong JSON.parse của JavaScript, và ghi vào BIGINT không thể phục hồi. Những id như vậy nên là chuỗi trong JSON và được chuyển sang BIGINT hoặc TEXT khi nhập.
Nên dùng gì làm khóa chính khi JSON không có id?
Để cơ sở dữ liệu sinh ra một khóa, hoặc tạo một UUID ổn định trước khi nhập. Đừng bao giờ dùng chỉ số mảng làm id: chạy lại sẽ sắp xếp lại dữ liệu và trỏ sai dòng vào sai bản ghi. Một chỉ mục duy nhất trên khóa nghiệp vụ (email, mã đơn hàng) cũng cho phép upsert.
Phải làm gì khi INSERT hàng loạt gặp xung đột khóa duy nhất?
Thay INSERT thường bằng upsert tương ứng với dialect của bạn, và xác nhận cột xung đột có ràng buộc duy nhất. Xóa trước rồi chèn sau cũng được, nhưng phải nằm trong một transaction và bạn chấp nhận một khoảng thời gian ngắn không có dòng nào.
Mảng lồng nhau có luôn cần bảng con không?
Không. Với mục đích hiển thị chỉ đọc mà không truy vấn theo mục con, một cột JSON đơn giản hơn. Ngay khi bạn cần lọc hoặc tổng hợp theo tag hay SKU, bảng con có chỉ mục là lựa chọn bền vững. Yếu tố quyết định là cách bạn truy vấn, không phải cách dữ liệu có hình dạng.