Đánh giá mức sẵn sàng làm Business Analyst
Chấm từng chiều kỹ năng và cho ra hồ sơ — biết mình đứng ở đâu trước.
Làm bài này
Product Management
Tám bậc từ đọc một bảng tới nối bảng, cộng hai câu lệnh ghi dữ liệu bạn cần đọc hiểu — tự trả lời câu hỏi dữ liệu thay vì xếp hàng chờ data team.
Đăng nhập để đánh dấu kỹ năng và theo dõi tiến độ.
Dữ liệu của sản phẩm nằm trong database, và database lưu nó thành các bảng — mỗi bảng giống một sheet Excel rất lớn: có cột, có dòng, mỗi dòng là một sự vật (một người dùng, một đơn hàng).
SQL là ngôn ngữ để hỏi những bảng đó một câu hỏi. Bạn đưa vào một câu lệnh, nhận về một bảng kết quả. Điểm khiến SQL dễ hơn người ta tưởng: bạn chỉ mô tả thứ mình muốn, không mô tả cách máy đi tìm. Câu lệnh đọc gần như tiếng Anh — SELECT ... FROM ... nghĩa đen là "lấy ... từ ...".
Vì sao BA/PO nên biết: phần lớn câu hỏi hằng ngày ("tuần này có bao nhiêu đơn bị huỷ?", "khách nào đặt nhiều nhất?") chỉ cần vài dòng SQL. Tự hỏi được nghĩa là bạn kiểm chứng giả định trong hôm nay thay vì chờ hàng đợi của data team. Quan trọng không kém: bạn đọc hiểu được câu lệnh mà lập trình viên sắp chạy trên dữ liệu thật.
Trang này đi theo tám bậc. Mỗi bậc chỉ thêm đúng một ý so với bậc trước, nên không có chỗ nào phải nhảy cóc.
Mọi câu lệnh bên dưới chạy trên cùng một cửa hàng trực tuyến giả định, để bạn không phải làm quen lại với dữ liệu mới ở mỗi bậc.
| Bảng | Mỗi dòng là gì | Cột chính | Quy mô |
|---|---|---|---|
users | Một người đã đăng ký tài khoản. | id, email, full_name, created_at | 12.480 dòng |
orders | Một đơn hàng. | id, user_id, total_amount, status, created_at | 38.650 dòng |
order_items | Một sản phẩm nằm trong một đơn hàng. | id, order_id, product_name, quantity, unit_price | nhiều dòng cho mỗi đơn |
Hai cột đáng nhớ nhất là sợi dây nối các bảng: orders.user_id ghi đơn này của ai, order_items.order_id ghi món này thuộc đơn nào. Bậc 6 sẽ dùng tới chúng.
orders.status chỉ nhận một trong bốn giá trị — pending, paid, shipped, cancelled — và một đơn ở đúng một trạng thái tại một thời điểm.
Tám câu lệnh, theo đúng thứ tự nên học. Sau mỗi câu là mô tả kết quả trả về bằng tiếng Việt, để bạn biết mình đang mong đợi cái gì trước khi bấm chạy.
Bậc 1 — Xem toàn bộ một bảng.
-- 1. Xem toàn bộ một bảng
SELECT * FROM orders;
Trả về: toàn bộ 38.650 dòng của bảng orders, kèm tất cả các cột. Dấu * nghĩa là "mọi cột".
Đọc câu lệnh: SELECT là "lấy", FROM là "từ bảng nào". Chỉ có vậy.
Lưu ý thật: trên bảng vài triệu dòng, câu này làm nặng database. Khi thăm dò một bảng lạ, hãy thêm LIMIT 20 ở cuối để chỉ lấy 20 dòng xem thử.
Bậc 2 — Chỉ lấy cột mình cần.
-- 2. Chỉ lấy cột mình cần
SELECT id, user_id, total_amount, status FROM orders;
Trả về: vẫn đủ 38.650 dòng, nhưng mỗi dòng chỉ còn 4 cột thay vì toàn bộ.
Điểm người mới hay nhầm: chọn cột không lọc bớt dòng. Số dòng không đổi. Muốn ít dòng hơn thì phải sang bậc 3.
Bậc 3 — Lọc dòng.
-- 3. Lọc dòng
SELECT id, total_amount FROM orders WHERE status = 'paid';
Trả về: 21.400 dòng — chỉ những đơn đang ở trạng thái paid, trong tổng số 38.650 đơn.
Đọc câu lệnh: WHERE là điều kiện lọc dòng. Ngoài = còn có >, <, BETWEEN a AND b, IN ('paid','shipped'), LIKE '%gmail%'. Nối nhiều điều kiện bằng AND / OR.
Ghi nhớ: đây là từ khoá quan trọng nhất trong SQL. Bậc 7 và bậc 8 sẽ cho thấy vì sao quên nó lại thành sự cố.
Bậc 4 — Sắp xếp rồi giới hạn.
-- 4. Sắp xếp và giới hạn
SELECT id, total_amount FROM orders WHERE status = 'paid' ORDER BY total_amount DESC LIMIT 10;
Trả về: đúng 10 dòng — 10 đơn đã thanh toán có giá trị lớn nhất, đơn đứng đầu 18.900.000 đ.
Đọc câu lệnh: ORDER BY chọn cột để sắp xếp, DESC là giảm dần (ASC là tăng dần), LIMIT 10 cắt lấy 10 dòng đầu sau khi đã sắp xếp. Thứ tự đó quan trọng: đổi chỗ hai bước này là ra 10 đơn bất kỳ, không phải 10 đơn lớn nhất.
Bậc 5 — Đếm và nhóm.
-- 5. Đếm và nhóm
SELECT status, COUNT(*) AS so_don FROM orders GROUP BY status;
Trả về: đúng 4 dòng, mỗi trạng thái một dòng:
| status | so_don |
|---|---|
| paid | 21.400 |
| shipped | 11.050 |
| pending | 3.120 |
| cancelled | 3.080 |
Cộng bốn con số lại được đúng 38.650 — mỗi đơn được đếm một lần và chỉ một lần.
Đọc câu lệnh: GROUP BY status gom mọi dòng có cùng trạng thái thành một nhóm; COUNT(*) đếm số dòng trong từng nhóm; AS so_don đặt tên cho cột kết quả.
Điểm chuyển quan trọng: đây là bậc đầu tiên mà kết quả không còn là một phần của bảng gốc, mà là một bảng tóm tắt hoàn toàn mới. Gần như mọi con số trên dashboard đều sinh ra theo kiểu này.
Bậc 6 — Nối hai bảng.
-- 6. Nối hai bảng
SELECT u.email, COUNT(o.id) AS so_don
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.email;
Trả về: 12.480 dòng — mỗi người dùng một dòng, kèm số đơn của người đó. 3.860 người có so_don = 0 vì chưa từng đặt đơn nào; 8.620 người còn lại chia nhau 38.650 đơn, trung bình khoảng 4,5 đơn mỗi người.
Đọc câu lệnh: ON o.user_id = u.id chính là chỗ khai báo sợi dây nối hai bảng. u và o là tên gọi tắt để câu lệnh ngắn lại.
Vì sao là COUNT(o.id) chứ không phải COUNT(*): với người chưa có đơn nào, phép nối vẫn tạo ra một dòng nhưng mọi cột của orders đều rỗng. COUNT(o.id) chỉ đếm những giá trị không rỗng nên trả về 0 — đúng như mong đợi. COUNT(*) đếm số dòng, nên nó sẽ trả về 1 cho người chưa mua gì. Một ký tự khác nhau, 3.860 con số sai.
Chữ LEFT quyết định con số bạn báo cáo: LEFT JOIN giữ lại mọi dòng của bảng bên trái (users) kể cả khi không có đơn nào khớp. Đổi thành INNER JOIN thì 3.860 người chưa đặt đơn biến mất khỏi kết quả — và "trung bình số đơn mỗi khách" của bạn nhảy từ 3,1 lên 4,5 mà không ai biết vì sao.
(38.650 chia cho 12.480 người là 3,1; chia cho 8.620 người từng đặt đơn là 4,5. Cùng một dữ liệu, hai con số, khác nhau ở đúng một chữ.)
Ở cấp độ này có hai loại "khó" rất khác nhau, và người mới thường chỉ được dạy loại thứ nhất.
Khó vì cú pháp — một câu hỏi cần nhiều tầng. Câu dưới đây trả lời: trong nhóm người đăng ký tuần đầu tháng 4, bao nhiêu người còn hoạt động đúng 7 ngày sau (D7 retention). Nó dùng thêm bảng events — mỗi dòng là một lần người dùng hoạt động — ngoài ba bảng ở trên.
-- D7 retention của user signup tuần trước WITH cohort AS ( SELECT id, DATE(created_at) AS signup_date FROM users WHERE created_at BETWEEN '2026-04-01' AND '2026-04-07' ), active AS ( SELECT DISTINCT user_id, DATE(event_date) AS active_date FROM events ) SELECT COUNT(DISTINCT c.id) AS signups, COUNT(DISTINCT CASE WHEN a.active_date = c.signup_date + INTERVAL 7 DAY THEN c.id END) AS d7_retained FROM cohort c LEFT JOIN active a ON a.user_id = c.id;
CTE (WITH ... AS) mua cho bạn điều gì: nó đặt tên cho một kết quả trung gian. Ở đây cohort là "nhóm người đăng ký tuần đầu tháng 4" và active là "những ngày từng có hoạt động của mỗi người". Có tên rồi thì câu lệnh chính đọc như một câu tiếng Việt: nối cohort với active, đếm xem bao nhiêu người có hoạt động vào đúng ngày thứ 7.
Không có CTE, hai khối đó phải lồng vào giữa câu lệnh chính trong ngoặc đơn, và bạn phải đọc từ trong ra ngoài. CTE không làm câu lệnh chạy nhanh hơn — nó làm câu lệnh đọc được, nên sửa được và review được. Với một câu lệnh sẽ đi vào báo cáo cho ban lãnh đạo, đó là khác biệt đáng giá.
Hai từ khoá mới trong câu này: DISTINCT loại bỏ các dòng trùng nhau (một người hoạt động 5 lần trong ngày chỉ tính là một), và DATE(created_at) cắt bỏ phần giờ phút để chỉ giữ lại ngày.
Phần COUNT(DISTINCT CASE WHEN ... THEN c.id END) đọc theo tầng: CASE WHEN trả về c.id nếu người đó có hoạt động đúng ngày thứ 7 và trả về rỗng nếu không; COUNT(DISTINCT ...) đếm số id khác nhau, bỏ qua các giá trị rỗng. Nói gọn: "đếm số người thoả điều kiện".
Khó vì hậu quả — bậc 7 và bậc 8. Hai câu lệnh dưới đây thay đổi dữ liệu thật. Là BA/PO bạn gần như luôn chỉ có quyền đọc, nên lý do học chúng không phải để tự chạy, mà là: đọc hiểu một migration script trước khi nó được duyệt, hiểu điều lập trình viên sắp chạy khi họ nói "để anh sửa tay dữ liệu này", và nhận ra một câu lệnh thiếu điều kiện trước khi nó chạy.
Bậc 7 — Sửa dữ liệu (UPDATE).
-- 7. Sửa dữ liệu — thường bạn KHÔNG có quyền này
UPDATE orders SET status = 'cancelled' WHERE id = 12345;
Kết quả: đúng 1 dòng bị sửa. Nếu đơn 12345 đang ở trạng thái paid thì bảng đếm ở bậc 5 đổi thành paid 21.399 và cancelled 3.081 — tổng vẫn là 38.650 vì không có dòng nào mất đi.
Đọc câu lệnh: SET nói cột nào nhận giá trị mới, phần điều kiện phía sau nói dòng nào được đụng tới. Bỏ phần điều kiện đó đi thì cả 38.650 đơn — kể cả đơn đang giao — đều thành cancelled.
Bậc 8 — Xoá dữ liệu (DELETE).
-- 8. Xoá dữ liệu — nguy hiểm nhất
DELETE FROM orders WHERE id = 12345;
-- Thiếu WHERE là xoá sạch bảng. Đây là sự cố production kinh điển.
Kết quả: 1 dòng biến mất, bảng còn 38.649 đơn. Không có nút hoàn tác: khôi phục nghĩa là lấy bản sao lưu gần nhất, và mọi thay đổi sau thời điểm sao lưu đó cũng mất theo.
Vì sao thiếu điều kiện lại là sự cố: DELETE FROM orders; hoàn toàn hợp lệ về cú pháp. Database không hỏi lại. Nó xoá cả 38.650 dòng và báo "thành công".
Việc của bạn khi review: mỗi câu lệnh ghi dữ liệu trong một migration script phải có điều kiện giới hạn, và tác giả phải trả lời được "câu này chạm vào bao nhiêu dòng?" bằng một con số cụ thể — kiểm tra trước bằng cách đổi DELETE FROM thành SELECT COUNT(*) FROM với đúng điều kiện đó.
Bốn câu hỏi đủ để review phần lớn script sửa dữ liệu, kể cả khi bạn không viết được câu lệnh đó:
Mọi quyết định product đòi hỏi data. PM phải tự query được ~70% câu hỏi cơ bản (DAU, conversion funnel, cohort retention) thay vì đợi data team queue 3 ngày.
SELECT … FROM … WHERE — filter rows.GROUP BY + aggregate (COUNT, SUM, AVG, MAX, MIN).JOIN — INNER, LEFT, RIGHT, FULL.HAVING — filter sau aggregate.ORDER BY, LIMIT.CASE WHEN cho conditional logic.ROW_NUMBER(), LAG(), RANK() cho cohort, retention.WITH clause) để viết query phức tạp readable.SELECT * trên bảng triệu rows → DB engineer ghét bạn.COUNT(DISTINCT) và COUNT(*).Chấm từng chiều kỹ năng và cho ra hồ sơ — biết mình đứng ở đâu trước.
Làm bài nàyHọc kỹ năng này ở đâu?
Có 2 khóa dạy đúng phần này, miễn phí và bằng tiếng Việt.
Thảo luận & tài liệu thêm 0
Chia sẻ kinh nghiệm, đặt câu hỏi, hoặc đính kèm tài liệu/YouTube giúp người khác học kỹ năng này.