Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

JSON trong SQL: query semi-structured data

SQL and Data Analysis cho BA Bài 52/60

Mở đầu — vì sao bài này quan trọng

Cách đây mười năm, gần như mọi dữ liệu trong database đều "vuông vắn": mỗi cột một kiểu dữ liệu cố định, mỗi dòng một bản ghi gọn gàng. Nhưng hệ thống hiện đại không còn như vậy. Khi bạn mở một bảng orders của một sàn thương mại điện tử kiểu Tiki hay Shopee, bạn sẽ thấy bên cạnh các cột quen thuộc như order_id, customer_id, total_amount là một cột tên metadata, attributes, payload hay event_data — và nội dung của nó trông giống một đoạn văn bản loằng ngoằng kiểu {"device": "iOS", "coupon": "FREESHIP", "items": [...]}.

Đó chính là JSON — một định dạng dữ liệu "bán cấu trúc" (semi-structured). Nó được dùng cực kỳ phổ biến vì nó linh hoạt: đội kỹ thuật không cần xin phép DBA thêm cột mới mỗi khi sản phẩm phát sinh thuộc tính mới. Họ chỉ việc nhét thêm key vào JSON. Tiện cho dev, nhưng lại là cơn ác mộng nho nhỏ cho BA — vì nếu bạn không biết cách "bóc" dữ liệu ra khỏi cột JSON, bạn sẽ đứng nhìn một mỏ vàng thông tin mà không lấy được gì.

Trong thực tế làm việc, rất nhiều thông tin có giá trị phân tích nằm ẩn bên trong các cột JSON: kênh marketing nào đưa khách tới (utm_source), khách dùng mã giảm giá nào, thiết bị gì, A/B test variant nào, danh sách sản phẩm trong giỏ hàng... Một BA biết query JSON sẽ tự mình trả lời được câu hỏi "mã FREESHIP đóng góp bao nhiêu doanh thu tháng này" mà không cần đi nhờ data engineer. Bài này sẽ trang bị cho bạn đúng kỹ năng đó.

Khái niệm cốt lõi

JSON trong database trông như thế nào

JSON (JavaScript Object Notation) lưu dữ liệu dưới dạng cặp key: value. Có hai cấu trúc cơ bản bạn phải nắm:

  • Object — bọc trong {}, gồm các cặp key-value, ví dụ {"device": "iOS", "app_version": "5.2.1"}.
  • Array — bọc trong [], là danh sách các phần tử, ví dụ ["FREESHIP", "GIAM10K"].
Hai cấu trúc này có thể lồng vào nhau nhiều tầng. Một metadata thực tế có thể trông như:

{
  "channel": "facebook_ads",
  "device": { "os": "Android", "model": "Samsung A54" },
  "coupons": ["FREESHIP", "TIKI50"],
  "is_first_order": true
}

Việc "bóc" dữ liệu nghĩa là chỉ vào đúng đường dẫn (path) tới giá trị bạn cần: device.os cho hệ điều hành, coupons[0] cho mã giảm giá đầu tiên.

Hai "trường phái" cú pháp: MySQL và PostgreSQL

Đây là điểm dễ gây nhầm lẫn nhất, nên bạn cần ghi nhớ rõ ngay từ đầu: MySQL và PostgreSQL query JSON theo cú pháp hoàn toàn khác nhau. Biết bạn đang làm việc trên hệ nào là bước số một.

MySQL dùng hàm và toán tử path:

  • JSON_EXTRACT(metadata, '$.channel') — lấy giá trị tại path $.channel. Dấu $ đại diện gốc của JSON.
  • Viết tắt bằng toán tử -> : metadata->'$.channel' (tương đương JSON_EXTRACT).
  • metadata->>'$.channel' (hai dấu lớn hơn) — lấy giá trị và "lột" dấu nháy kép, trả về text thuần. Đây là cái BA dùng nhiều nhất.
SELECT
  order_id,
  metadata->>'$.channel'        AS channel,
  metadata->>'$.device.os'      AS os
FROM orders;

PostgreSQL dùng toán tử ->->> nhưng theo từng cấp:

  • metadata -> 'device' — trả về một JSON object con (vẫn là kiểu JSON).
  • metadata ->> 'channel' — trả về giá trị dạng text.
  • Để đi sâu nhiều tầng dùng #>> với mảng path: metadata #>> '{device,os}'.
SELECT
  order_id,
  metadata ->> 'channel'        AS channel,
  metadata #>> '{device,os}'    AS os
FROM orders;

Quy tắc mẹo để nhớ: một mũi tên (->) trả về JSON, hai mũi tên (->>) trả về text. Đúng cho cả hai hệ. Khi bạn muốn so sánh, lọc, hay đưa vào báo cáo thì gần như luôn dùng ->> để có text/số sạch.

Khác biệt kiểu cột: JSON vs JSONB

Trong PostgreSQL có hai kiểu: JSON lưu nguyên văn (giữ thứ tự key, khoảng trắng), còn JSONB lưu dạng nhị phân đã được phân tích sẵn — query nhanh hơn nhiều và có thể đánh index. Thực tế gần như mọi hệ thống hiện đại dùng JSONB. Với MySQL chỉ có một kiểu JSON duy nhất (bản chất đã được tối ưu sẵn).

Trích phần tử trong mảng và "bung" mảng ra nhiều dòng

Khi giá trị là một array (ví dụ danh sách sản phẩm trong đơn), bạn thường cần "bung" mỗi phần tử thành một dòng riêng để phân tích — gọi là unnest/flatten.

  • PostgreSQL: jsonb_array_elements(payload -> 'items') biến mảng thành nhiều dòng.
  • MySQL: JSON_TABLE(...) làm việc tương tự, biến JSON array thành một bảng ảo để JOIN.
Đây là kỹ thuật mạnh nhất khi làm việc với JSON, vì nó đưa dữ liệu lồng nhau về dạng bảng phẳng quen thuộc mà bạn đã thành thạo từ các bài trước.

Tình huống thực tế

Ví dụ 1 — Sàn TMĐT "ChợViệt": mã giảm giá nào hiệu quả?

Bối cảnh: Bạn là BA tại ChợViệt, một sàn TMĐT giả định. Đội marketing chạy nhiều mã giảm giá và muốn biết mã nào tạo ra doanh thu cao nhất trong tháng 6/2026. Vấn đề: mã giảm giá không được lưu thành cột riêng, mà nằm trong cột metadata của bảng orders, dạng {"coupon": "FREESHIP", "channel": "tiktok"}.

Trên PostgreSQL, bạn viết:

SELECT
  metadata ->> 'coupon'        AS coupon_code,
  COUNT(*)                      AS so_don,
  SUM(total_amount)             AS doanh_thu
FROM orders
WHERE created_at >= '2026-06-01'
  AND created_at <  '2026-07-01'
  AND metadata ->> 'coupon' IS NOT NULL
GROUP BY metadata ->> 'coupon'
ORDER BY doanh_thu DESC;

Kết quả: mã FREESHIP mang về 1.240 đơn và 860 triệu doanh thu, trong khi GIAM50K chỉ 180 đơn và 95 triệu. Diễn giải: dù GIAM50K giảm sâu hơn về tuyệt đối, FREESHIP lại kéo được lượng đơn gấp 7 lần — vì tâm lý "miễn phí ship" hấp dẫn người Việt hơn con số giảm. Bài học: dữ liệu quyết định trú trong JSON, và chỉ cần một dòng metadata ->> 'coupon' là bạn tự trả lời được câu hỏi triệu đô mà không cần đợi đội data.

Ví dụ 2 — Fintech "ViPay": bóc danh sách giao dịch lồng trong payload

Bối cảnh: Tại ViPay (ví điện tử giả định kiểu MoMo/ZaloPay), mỗi sự kiện thanh toán được log vào bảng events với cột payload kiểu JSONB. Một bản ghi nạp tiền trông như:

{
  "event": "topup",
  "channel": "vietcombank",
  "items": [
    {"sku": "TOPUP", "amount": 200000},
    {"sku": "FEE",   "amount": 0}
  ]
}

Sếp muốn biết tổng giá trị nạp tiền theo từng ngân hàng liên kết. Phần amount nằm sâu trong mảng items, nên bạn phải bung mảng:

SELECT
  e.payload ->> 'channel'                     AS ngan_hang,
  SUM((item ->> 'amount')::numeric)           AS tong_nap
FROM events e,
     jsonb_array_elements(e.payload -> 'items') AS item
WHERE e.payload ->> 'event' = 'topup'
  AND item ->> 'sku' = 'TOPUP'
GROUP BY e.payload ->> 'channel'
ORDER BY tong_nap DESC;

Chú ý chỗ (item ->> 'amount')::numeric->> trả về text nên phải ép kiểu sang số trước khi SUM. Kết quả cho thấy Vietcombank chiếm 42% tổng giá trị nạp, theo sau là Techcombank 23%. Bài học: với dữ liệu dạng mảng lồng, bạn cần jsonb_array_elements (PostgreSQL) hoặc JSON_TABLE (MySQL) để bung ra, và đừng quên ép kiểu khi muốn tính toán số học.

Ví dụ 3 — Be/Grab: lọc theo thuộc tính sâu trong JSON

Bối cảnh: Một nền tảng gọi xe lưu thông tin chuyến đi trong bảng trips, cột trip_info (MySQL). Mỗi chuyến có {"service": "bike", "promo": {"applied": true, "code": "NEWUSER"}, "rating": 5}. Đội vận hành muốn đếm số chuyến xe máy có dùng mã NEWUSER và bị chấm dưới 3 sao — dấu hiệu trải nghiệm tệ với khách mới.

SELECT COUNT(*) AS chuyen_te
FROM trips
WHERE trip_info->>'$.service' = 'bike'
  AND trip_info->>'$.promo.code' = 'NEWUSER'
  AND CAST(trip_info->>'$.rating' AS UNSIGNED) < 3;

Kết quả: 320 chuyến trong tháng — một con số đủ lớn để đội vận hành phải báo động về chất lượng tài xế phục vụ khách mới. Bài học: bạn có thể WHERE trực tiếp trên path JSON nhiều tầng ($.promo.code), và khi so sánh số (rating) thì phải CAST vì JSON trả về dạng text.

Hướng dẫn từng bước

Khi gặp một cột JSON lạ, hãy theo quy trình sau để không bị rối:

  • Xác định hệ quản trị (MySQL hay PostgreSQL). Đây quyết định toàn bộ cú pháp. Nếu không chắc, hỏi đội kỹ thuật hoặc nhìn các query cũ trong team.
  • Xem trước cấu trúc JSON. Đừng đoán mò key. Chạy SELECT metadata FROM orders LIMIT 5; để nhìn vài bản ghi thật. Quan sát: key nào là object, key nào là array, có bao nhiêu tầng lồng.
  • Trích thử một key đơn giản trước. Lấy một giá trị phẳng để chắc cú pháp đúng: SELECT metadata ->> 'channel' FROM orders LIMIT 5;. Khi cột này ra đúng giá trị, bạn đã đi đúng đường.
  • Đi sâu vào tầng lồng. Với object con dùng #>> '{device,os}' (Postgres) hay ->>'$.device.os' (MySQL). Với mảng, dùng jsonb_array_elements / JSON_TABLE để bung ra dòng.
  • Ép kiểu khi cần tính toán. Nhớ rằng ->> luôn trả text. Muốn SUM, AVG, so sánh <, > thì ép: (... ->> 'amount')::numeric (Postgres) hoặc CAST(... AS DECIMAL) (MySQL).
  • Lọc giá trị thiếu. Cột JSON rất hay khuyết key, nên thêm điều kiện IS NOT NULL để tránh kết quả bị méo.
  • Gói thành CTE cho dễ đọc. Khi query phình to, đưa phần bóc JSON vào một CTE (bạn đã học ở Bài 16) rồi GROUP BY/JOIN ở tầng ngoài. Code sẽ sạch và dễ bảo trì hơn nhiều.

Lỗi thường gặp & mẹo

Lẫn lộn ->->>. Đây là lỗi số một. Dùng -> (một mũi tên) rồi đem đi GROUP BY hay so sánh, bạn sẽ thấy giá trị kèm dấu nháy kép "facebook" thay vì facebook, hoặc gặp lỗi kiểu dữ liệu. Quy tắc vàng: cần text/số để báo cáo thì luôn dùng ->>.

Quên ép kiểu khi tính toán. SUM(payload ->> 'amount') trên Postgres sẽ báo lỗi vì đang cộng text. Luôn ép ::numeric hoặc CAST(... AS DECIMAL) trước khi làm phép số học. Triệu chứng kinh điển: tổng tiền ra sai bét hoặc query đỏ lừ.

Áp cú pháp MySQL vào PostgreSQL và ngược lại. JSON_EXTRACT(col, '$.x') chỉ chạy trên MySQL; col #>> '{x}' chỉ chạy trên Postgres. Nếu copy query từ Stack Overflow mà thấy lỗi cú pháp khó hiểu, hãy kiểm tra xem có đang nhầm hệ không.

Sai key vì phân biệt hoa-thường. Key trong JSON phân biệt chữ hoa chữ thường: Channel khác channel. Nếu query trả toàn NULL dù dữ liệu có vẻ tồn tại, gần như chắc chắn bạn gõ sai key. Quay lại bước xem trước cấu trúc.

Mẹo hiệu năng cho BA. Query trên cột JSON thường chậm hơn cột thường vì database phải phân tích JSON cho từng dòng. Nếu một thuộc tính được lọc/nhóm thường xuyên (ví dụ channel), hãy đề xuất đội kỹ thuật tạo index trên biểu thức JSON đó, hoặc tách hẳn ra thành cột riêng. Bạn không cần tự làm, nhưng biết để "hỏi đúng" (đúng tinh thần Bài 24).

Mẹo kiểm tra tồn tại key. Trên Postgres, toán tử ? kiểm tra một object có chứa key nào đó: metadata ? 'coupon'. Hữu ích khi muốn lọc các bản ghi có gắn key cụ thể.

Bài tập thực hành

Giả sử bạn có bảng orders(order_id, customer_id, total_amount, created_at, metadata) với metadata dạng JSON/JSONB chứa ví dụ {"channel": "tiktok", "device": {"os": "iOS"}, "coupons": ["FREESHIP"], "is_first_order": true}. Hãy viết query cho các yêu cầu sau (làm cả phiên bản MySQL và PostgreSQL nếu có thể):

  • Trích cột channeldevice.os cho 10 đơn gần nhất.
  • Đếm số đơn theo từng channel trong tháng 6/2026, sắp xếp giảm dần.
  • Tính tổng total_amount của các đơn là đơn đầu tiên (is_first_order = true).
  • (Nâng cao) Bung mảng coupons ra từng dòng và đếm mỗi mã giảm giá xuất hiện bao nhiêu lần — gợi ý dùng jsonb_array_elements_text (Postgres) hoặc JSON_TABLE (MySQL).
  • (Tư duy BA) Lọc các đơn dùng iOS nhưng metadata không hề có key coupons — viết điều kiện kiểm tra sự vắng mặt của key.
Hãy tự kiểm: với mọi cột mang ra báo cáo, bạn đã dùng ->> chưa? Với mọi phép tính số, bạn đã ép kiểu chưa? Với mọi key, bạn đã lọc NULL chưa?

Tóm tắt

JSON là cách hệ thống hiện đại lưu dữ liệu linh hoạt, và rất nhiều thông tin có giá trị phân tích — kênh marketing, mã giảm giá, thiết bị, A/B variant, danh sách sản phẩm — đang nằm ẩn trong các cột metadata, payload, attributes. Một BA biết query JSON sẽ tự chủ động khai thác mỏ vàng này thay vì phụ thuộc đội kỹ thuật.

Ba điểm cần khắc cốt: (1) MySQL dùng JSON_EXTRACT/->>'$.path' còn PostgreSQL dùng ->>/#>>/jsonb_array_elements — luôn biết mình đang ở hệ nào; (2) một mũi tên -> trả JSON, hai mũi tên ->> trả text, và hãy luôn dùng ->> rồi ép kiểu khi cần tính toán; (3) với dữ liệu dạng mảng lồng, hãy bung ra thành bảng phẳng bằng jsonb_array_elements hoặc JSON_TABLE để đưa về dạng quen thuộc. Nắm vững những điều này, không một cột JSON nào còn có thể giấu dữ liệu khỏi bạn.