Menu
ESC

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

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

Đang tải...

Bài 29 — SQL Cơ bản và Advanced cho PM

Technical Product Manager Bài 29/60

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

Có một sự thật mà ít ai nói thẳng với bạn khi mới vào nghề Product Manager: phần lớn các cuộc tranh luận về sản phẩm không kết thúc bằng người hùng biện hay nhất, mà bằng người có dữ liệu thuyết phục nhất. Và trong 90% trường hợp, dữ liệu đó nằm trong một cơ sở dữ liệu quan hệ, chỉ chờ một câu SQL để lôi ra.

Là một Technical PM, bạn không cần viết code production, không cần tối ưu query cho hệ thống chịu tải triệu request. Nhưng bạn cần đủ giỏi SQL để: tự trả lời câu hỏi "tuần trước có bao nhiêu người dùng mới quay lại?" mà không phải chờ data analyst hai ngày; đọc hiểu và phản biện một báo cáo phân tích; và quan trọng nhất là viết được acceptance criteria có thể kiểm chứng bằng dữ liệu thật.

Bài học này giả định bạn đã biết những thứ cơ bản — SELECT, WHERE, GROUP BY, JOIN. Nếu bạn còn lăn tăn về những lệnh đó, hãy ôn lại trước. Ở đây chúng ta sẽ đi sâu vào nhóm công cụ tạo ra khác biệt thật sự giữa một PM "biết SQL" và một PM "dùng SQL để ra quyết định": window functions, CTE, và tư duy phân tích dữ liệu theo nhóm.

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

Ôn nhanh nền tảng — và một bẫy hay gặp

Bạn đã biết GROUP BY gộp nhiều dòng thành một dòng tổng hợp. Vấn đề là: sau khi gộp, bạn mất chi tiết từng dòng. Nếu muốn vừa giữ chi tiết từng đơn hàng, vừa biết "đơn này là đơn thứ mấy của khách" — GROUP BY bó tay. Đây chính là lý do window functions ra đời.

Một lưu ý nhỏ về thứ tự thực thi mà nhiều người hiểu sai: SQL chạy FROMWHEREGROUP BYHAVINGSELECTORDER BY. Vì WHERE chạy trước GROUP BY, bạn không thể dùng WHERE để lọc theo kết quả tổng hợp — phải dùng HAVING. Nhớ điều này sẽ giúp bạn debug query nhanh hơn rất nhiều.

Window functions — vũ khí mạnh nhất của PM

Window function cho phép bạn tính toán trên một "cửa sổ" các dòng liên quan đến dòng hiện tại, mà không gộp chúng lại. Cú pháp chung:

HÀM() OVER (PARTITION BY cột_nhóm ORDER BY cột_sắp_xếp)
  • PARTITION BY chia dữ liệu thành các nhóm (giống GROUP BY nhưng không làm mất dòng).
  • ORDER BY trong OVER() quyết định thứ tự bên trong mỗi nhóm.
ROW_NUMBER, RANK, DENSE_RANK — ba hàm đánh số thứ tự, khác nhau ở cách xử lý đồng hạng:

SELECT
  user_id,
  order_date,
  amount,
  ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date) AS lan_mua_thu,
  RANK()       OVER (PARTITION BY user_id ORDER BY amount DESC) AS hang_theo_gia_tri
FROM orders;
  • ROW_NUMBER luôn cho số duy nhất 1, 2, 3, 4... ngay cả khi giá trị bằng nhau.
  • RANK nếu có hai dòng đồng hạng 1 thì dòng tiếp theo là hạng 3 (nhảy cóc).
  • DENSE_RANK đồng hạng 1 thì tiếp theo vẫn là hạng 2 (không nhảy cóc).
Ứng dụng kinh điển: lấy đơn hàng đầu tiên của mỗi khách (để tính ngày acquisition), hay giao dịch lớn nhất của mỗi tài khoản. Bạn dùng window function đánh số rồi lọc WHERE lan_mua_thu = 1 ở lớp ngoài.

LAG và LEAD — nhìn về dòng trước/sau. Cực kỳ hữu ích để tính tăng trưởng và khoảng cách thời gian:

SELECT
  thang,
  doanh_thu,
  LAG(doanh_thu) OVER (ORDER BY thang) AS doanh_thu_thang_truoc,
  doanh_thu - LAG(doanh_thu) OVER (ORDER BY thang) AS tang_truong_tuyet_doi
FROM doanh_thu_thang;

LAG lấy giá trị dòng trước, LEAD lấy dòng sau. Đây là cách bạn tính month-over-month growth chỉ trong một query, thay vì self-join rối rắm.

SUM/AVG dạng running total — cộng dồn theo thời gian:

SELECT
  ngay,
  so_user_moi,
  SUM(so_user_moi) OVER (ORDER BY ngay) AS tong_user_cong_don
FROM dang_ky_theo_ngay;

CTE — viết query phức tạp mà vẫn đọc được

CTE (Common Table Expression), khai báo bằng WITH, cho phép bạn đặt tên cho một query con và tái sử dụng. Thay vì lồng subquery ba bốn tầng không ai đọc nổi, bạn chia thành các bước có tên rõ ràng:

WITH don_dau_tien AS (
  SELECT user_id, MIN(order_date) AS ngay_mua_dau
  FROM orders
  GROUP BY user_id
),
khach_thang_1 AS (
  SELECT user_id FROM don_dau_tien
  WHERE ngay_mua_dau >= '2026-01-01' AND ngay_mua_dau < '2026-02-01'
)
SELECT COUNT(*) FROM khach_thang_1;

Với PM, CTE quan trọng không phải vì hiệu năng, mà vì khả năng đọc. Một query phân tích retention dùng CTE đọc gần như văn xuôi, người sau bạn sửa lại được, và bạn tự debug được sau ba tháng quên mất.

Tình huống thực tế

Tình huống 1 — Tiki và bài toán "khách mua lần đầu"

Giả sử bạn là PM mảng tăng trưởng tại một sàn TMĐT kiểu Tiki. Marketing tự hào báo cáo: "Tháng 5 có 120.000 đơn hàng từ chiến dịch flash sale, tăng 40%!". Nhưng câu hỏi thật sự CEO quan tâm là: trong số đó có bao nhiêu là khách mới lần đầu, bao nhiêu là khách cũ vốn dĩ vẫn mua dù không có flash sale?

Nếu chỉ COUNT(*) đơn hàng, bạn không tách được. Giải pháp là window function:

WITH don_co_thu_tu AS (
  SELECT
    order_id, user_id, order_date,
    ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date) AS lan_mua
  FROM orders
)
SELECT
  COUNT(*) FILTER (WHERE lan_mua = 1) AS khach_moi,
  COUNT(*) FILTER (WHERE lan_mua > 1) AS khach_quay_lai
FROM don_co_thu_tu
WHERE order_date >= '2026-05-01' AND order_date < '2026-06-01';

Kết quả hé lộ rằng chỉ 18.000 trong 120.000 đơn là khách mới, còn lại là khách cũ "ăn" mã giảm giá. Bài học: flash sale không kéo khách mới như tưởng — nó đang trợ giá cho khách vốn đã trung thành. PM dùng đúng một query đã thay đổi cả hướng đầu tư ngân sách marketing.

Tình huống 2 — Grab và phát hiện gian lận qua khoảng cách thời gian

Bạn là PM mảng an toàn tại một nền tảng gọi xe kiểu Grab. Đội vận hành nghi ngờ một số tài xế tạo cuốc ảo: nhận cuốc rồi hủy, rồi nhận lại ngay để cày thưởng. Để xác định, bạn cần đo khoảng thời gian giữa hai sự kiện liên tiếp của cùng một tài xế — bài toán điển hình cho LAG:

WITH su_kien AS (
  SELECT
    driver_id, event_type, event_time,
    LAG(event_time) OVER (PARTITION BY driver_id ORDER BY event_time) AS su_kien_truoc
  FROM driver_events
)
SELECT driver_id, event_time,
  EXTRACT(EPOCH FROM (event_time - su_kien_truoc)) AS giay_cach_nhau
FROM su_kien
WHERE EXTRACT(EPOCH FROM (event_time - su_kien_truoc)) < 5;

Query lọc ra những tài xế có hai sự kiện cách nhau dưới 5 giây — bất thường với thao tác của con người. Kết quả: 23 tài xế có hành vi này lặp lại hàng trăm lần/ngày. Bài học: PM không cần data science phức tạp để phát hiện anomaly đầu tiên — một window function LAG đã đủ để khoanh vùng nghi vấn và chuyển cho đội điều tra.

Tình huống 3 — VNPay và báo cáo cohort retention

Bạn là PM tại một ví điện tử kiểu VNPay, muốn biết: trong số người dùng đăng ký tháng 1, bao nhiêu phần trăm còn giao dịch ở tháng thứ 2, 3? Đây là cohort retention — và CTE giúp query này gọn gàng:

WITH thang_dang_ky AS (
  SELECT user_id, DATE_TRUNC('month', MIN(transaction_date)) AS cohort
  FROM transactions GROUP BY user_id
),
hoat_dong AS (
  SELECT t.user_id, c.cohort,
    DATE_TRUNC('month', t.transaction_date) AS thang_hoat_dong
  FROM transactions t
  JOIN thang_dang_ky c ON t.user_id = c.user_id
)
SELECT cohort,
  thang_hoat_dong,
  COUNT(DISTINCT user_id) AS so_user_active
FROM hoat_dong
GROUP BY cohort, thang_hoat_dong
ORDER BY cohort, thang_hoat_dong;

Đổ kết quả ra bảng cohort, bạn thấy nhóm tháng 1 chỉ giữ được 22% ở tháng 3. Bài học: một con số "tổng người dùng tăng" che giấu một thực tế rò rỉ nghiêm trọng. CTE giúp bạn tự dựng phân tích retention mà không cần làm phiền đội data — và quan trọng hơn, bạn hiểu sâu logic nên không bị "lùa" bởi báo cáo đẹp.

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

Đây là quy trình tôi khuyên bạn áp dụng mỗi khi cần lôi dữ liệu ra để ra quyết định:

  • Viết câu hỏi bằng tiếng Việt trước. Ví dụ: "Tỷ lệ khách mua lần 2 trong vòng 30 ngày kể từ lần mua đầu là bao nhiêu?". Câu hỏi mơ hồ luôn dẫn đến query sai.
  • Xác định bảng và cột cần dùng. Mở schema (hoặc hỏi engineer). Tìm bảng orders, cột user_id, order_date, amount. Đừng đoán tên cột.
  • Bắt đầu nhỏ — luôn LIMIT 10. Chạy SELECT * FROM orders LIMIT 10 để xem dữ liệu thật trông thế nào trước khi viết logic phức tạp. Bạn sẽ phát hiện sớm những thứ như amount lưu bằng VND hay nghìn đồng, ngày lưu dạng timestamp hay date.
  • Dựng từng tầng bằng CTE. Đừng cố viết một query khổng lồ. Viết tầng 1, chạy kiểm tra, rồi mới thêm tầng 2.
  • Đối chiếu con số với một nguồn khác. Nếu query nói "tháng 5 có 50.000 user", so với dashboard có sẵn. Lệch nhiều nghĩa là logic của bạn sai ở đâu đó (thường là JOIN nhân đôi dòng hoặc quên lọc trùng).
  • Viết comment giải thích trong query. Bạn của ba tháng sau sẽ cảm ơn bạn.

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

  • JOIN làm nhân đôi dòng. Lỗi phổ biến nhất: JOIN bảng orders với bảng order_items rồi COUNT() ra số đơn — nhưng thực ra mỗi đơn nhiều item nên số bị thổi phồng. Mẹo: sau mỗi JOIN, kiểm tra COUNT() trước và sau có thay đổi bất thường không.
  • Nhầm COUNT() với COUNT(DISTINCT ...). "Bao nhiêu người dùng giao dịch" phải là COUNT(DISTINCT user_id), không phải COUNT() (đếm số giao dịch). Nhầm hai cái này là nguồn gốc của vô số báo cáo sai.
  • NULL âm thầm phá hoại. NULL = NULL không trả về TRUE. Khi JOIN hoặc so sánh trên cột có thể NULL, dùng IS NULL / COALESCE. Và nhớ: AVG() bỏ qua NULL, nhưng COUNT(cot) cũng bỏ qua NULL — đôi khi đó không phải điều bạn muốn.
  • Quên ranh giới thời gian theo timezone. Database thường lưu UTC. Khi bạn lọc order_date >= '2026-05-01', "ngày 1 tháng 5" theo giờ Việt Nam (UTC+7) thực ra bắt đầu từ 17:00 ngày 30/4 UTC. Lệch múi giờ làm số liệu cuối tháng sai lệch. Luôn xác nhận timezone với engineer.
  • Mẹo FILTER thay cho CASE WHEN. Trong PostgreSQL, COUNT(*) FILTER (WHERE dieu_kien) gọn và dễ đọc hơn SUM(CASE WHEN dieu_kien THEN 1 ELSE 0 END). Nhưng nhớ: cú pháp này không có ở MySQL — nơi đó bạn vẫn phải dùng CASE WHEN.
  • Đừng chạy query nặng lên database production giờ cao điểm. Một query thiếu điều kiện lọc trên bảng tỷ dòng có thể làm chậm cả hệ thống. Hỏi engineer xem có replica/warehouse riêng cho phân tích không (chủ đề scaling sẽ học kỹ ở Bài 50).

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

Giả sử bạn có bảng orders(order_id, user_id, order_date, amount) của một shop online. Hãy thử viết query cho các yêu cầu sau:

  • Cơ bản: Đếm số khách hàng duy nhất đã mua trong quý 1/2026.
  • Window function: Với mỗi khách, tìm đơn hàng có giá trị cao nhất của họ (trả về user_id, order_id, amount). Gợi ý: ROW_NUMBER() với ORDER BY amount DESC rồi lọc = 1.
  • LAG: Tính số ngày giữa lần mua đầu tiên và lần mua thứ hai của mỗi khách. Khách nào quay lại nhanh nhất?
  • CTE + tổng hợp: Tính doanh thu mỗi tháng, kèm cột tăng trưởng so với tháng trước (dùng LAG). Tháng nào tăng trưởng âm?
  • Thử thách: Tính tỷ lệ khách mua lần đầu trong tháng 1 mà có mua lại trong vòng 60 ngày. Đây là bài kết hợp MIN(), self-logic qua CTE và điều kiện thời gian — chính là dạng câu hỏi retention bạn sẽ gặp ở công việc thật.
Hãy viết tay từng query, chạy thử trên dữ liệu mẫu, và quan trọng nhất: tự đối chiếu kết quả xem có hợp lý không. Khả năng tự nghi ngờ con số mình tạo ra là kỹ năng quý nhất của một Technical PM.

Tóm tắt

SQL với PM không phải để khoe kỹ thuật, mà là để tự chủ trong việc ra quyết định bằng dữ liệu. Bạn đã có nền SELECT/WHERE/GROUP BY/JOIN; bài này bổ sung ba nhóm công cụ tạo khác biệt: window functions (ROW_NUMBER, RANK, LAG/LEAD, running total) để phân tích theo nhóm mà không mất chi tiết; CTE để viết query phức tạp vẫn đọc được; và tư duy kiểm chứng số liệu để không bị lừa bởi báo cáo đẹp.

Ba tình huống — tách khách mới khỏi khách cũ trong flash sale, phát hiện gian lận qua khoảng cách thời gian, và dựng cohort retention — cho thấy cùng một bộ công cụ giải được những bài toán sản phẩm rất khác nhau. Hãy nhớ các lỗi kinh điển: JOIN nhân đôi dòng, nhầm COUNT với COUNT DISTINCT, NULL âm thầm, và timezone lệch.

Mục tiêu cuối cùng không phải là viết được query phức tạp nhất, mà là đặt đúng câu hỏi, lấy đúng con số, và tự tin bảo vệ quyết định của mình trong phòng họp. Đó mới là SQL của một Technical PM thực thụ.