Mở đầu — vì sao bài này quan trọng
Là một BA (Business Analyst), bạn không phải là người chịu trách nhiệm vận hành database. Bạn không phải DBA, không phải kỹ sư backend. Nhưng có một sự thật phũ phàng mà ai đi làm phân tích đủ lâu đều gặp: query của bạn chạy chậm sẽ làm hỏng cả ngày làm việc của bạn và của người khác.
Hãy hình dung: bạn đang chuẩn bị báo cáo doanh thu quý cho cuộc họp lúc 9 giờ sáng. Bạn bấm "Run", rồi đợi. 2 phút. 5 phút. 10 phút. Cái vòng tròn quay quay vẫn quay. Tệ hơn, query của bạn đang chạy trên database production — và lúc này đội vận hành Shopee đang gọi điện báo rằng dashboard đơn hàng của họ bị treo, vì query nặng của ai đó (chính là bạn) đang khoá bảng orders lại.
Bài này không biến bạn thành chuyên gia tối ưu database. Mục tiêu khiêm tốn và thực tế hơn nhiều: giúp bạn viết query "đủ tốt" để không tự bắn vào chân mình, biết nhận ra khi nào một query có nguy cơ chậm, và biết cách diễn đạt lại logic để máy chủ làm việc nhẹ nhàng hơn. Đây là những "pattern" — những khuôn mẫu lặp đi lặp lại — mà bạn áp dụng gần như theo phản xạ.
Lưu ý: bài này tập trung vào cách bạn viết query (tư duy và thói quen). Việc đọc EXPLAIN plan để hiểu máy chủ thực thi ra sao đã có bài riêng (Bài 25), còn index hoạt động thế nào là Bài 24. Ở đây ta nói về những điều bạn kiểm soát được ngay trong câu lệnh SELECT của mình.
Khái niệm cốt lõi
1. Chỉ lấy đúng cột bạn cần — tránh SELECT *
Đây là pattern đầu tiên và quan trọng nhất.
-- KHÔNG nên
SELECT * FROM orders;-- TỐT
SELECT id, total, created_at FROM orders;
SELECT * nghĩa là "lấy hết mọi cột". Vấn đề là bảng orders thực tế có thể có 40 cột: địa chỉ giao hàng, ghi chú khách, dữ liệu JSON, metadata thanh toán... Bạn chỉ cần 3 cột nhưng máy chủ phải đọc, vận chuyển qua mạng, và đẩy về máy bạn cả 40 cột cho hàng triệu dòng. Đó là lãng phí băng thông và bộ nhớ khổng lồ.
Ngoài chuyện chậm, SELECT * còn nguy hiểm cho report: nếu ai đó thêm cột mới vào bảng, report của bạn đột nhiên có thêm cột lạ, có thể làm vỡ dashboard hoặc lộ dữ liệu nhạy cảm (ví dụ cột customer_phone). Hãy luôn liệt kê tên cột — vừa nhanh, vừa rõ ràng, vừa an toàn.
2. Lọc sớm và lọc đúng — đặt gánh nặng ở WHERE
Nguyên tắc vàng: giảm số dòng càng sớm càng tốt. Mỗi dòng bạn loại bỏ ở bước đầu là một dòng máy chủ không phải JOIN, không phải sort, không phải tính toán ở bước sau.
-- Lọc theo ngày trước khi làm bất cứ việc gì khác
SELECT customer_id, SUM(total) AS doanh_thu
FROM orders
WHERE created_at >= '2026-01-01'
AND created_at < '2026-04-01'
GROUP BY customer_id;
Một sai lầm tinh vi: đừng bọc cột trong hàm ở vế trái của điều kiện lọc. Khi bạn viết WHERE YEAR(created_at) = 2026, máy chủ buộc phải tính YEAR() cho từng dòng trước khi so sánh — và nó không thể dùng index trên created_at nữa. Hãy viết lại thành khoảng ngày:
-- CHẬM: vô hiệu hoá index
WHERE YEAR(created_at) = 2026-- NHANH: giữ được index
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
Tương tự, WHERE total * 1.1 > 100000 chậm hơn WHERE total > 90909. Hãy để cột đứng một mình ở vế trái.
3. JOIN gọn — chỉ nối bảng bạn thật sự dùng
Mỗi JOIN là một phép ghép tốn kém. Một pattern hay gặp ở BA là copy một query mẫu có sẵn 5 JOIN, rồi chỉ dùng dữ liệu từ 2 bảng. Ba JOIN thừa kia vẫn ngốn tài nguyên. Trước khi chạy, hãy tự hỏi: "Mình có thật sự cần bảng này không?"
Ngoài ra, hãy lọc trước khi JOIN khi có thể. Nếu bạn chỉ cần đơn hàng tháng 6, đừng JOIN toàn bộ lịch sử đơn hàng rồi mới lọc — hãy lọc tháng 6 trước (thường qua CTE hoặc subquery), rồi mới nối sang bảng khách hàng.
4. Tránh DISTINCT để "chữa cháy" cho JOIN sai
Khi BA thấy kết quả bị nhân đôi dòng, phản xạ phổ biến là thêm DISTINCT vào cho "sạch". Nhưng DISTINCT bắt máy chủ sort hoặc hash toàn bộ kết quả để loại trùng — rất tốn kém. Quan trọng hơn, nó che giấu nguyên nhân thật: thường là bạn đang JOIN với một bảng có quan hệ một-nhiều khiến dòng bị nhân lên. Hãy sửa logic JOIN (hoặc gom nhóm trước khi nối), đừng dán băng keo bằng DISTINCT.
5. EXISTS thay vì IN với subquery lớn, LIMIT khi khám phá
Khi kiểm tra "khách hàng nào đã từng mua hàng", EXISTS thường nhanh hơn IN (subquery) vì máy chủ chỉ cần tìm thấy một dòng khớp là dừng, thay vì xây cả danh sách. Và khi bạn đang dò dữ liệu, đừng bao giờ chạy query trả về 5 triệu dòng — thêm LIMIT 100 để xem mẫu trước.
Tình huống thực tế
Tình huống 1: Báo cáo doanh thu của Tiki và cái bẫy YEAR()
Linh, BA tại một sàn TMĐT mô phỏng kiểu Tiki, cần báo cáo doanh thu theo tháng cho năm 2026. Bảng orders có khoảng 18 triệu dòng. Query đầu tiên của cô:
SELECT MONTH(created_at) AS thang, SUM(total) AS doanh_thu
FROM orders
WHERE YEAR(created_at) = 2026
GROUP BY MONTH(created_at);
Query chạy mất 47 giây. Đội DBA ping vào nhóm: "Ai đang scan full bảng orders vậy?". Vấn đề: YEAR(created_at) = 2026 ép máy chủ tính hàm YEAR trên cả 18 triệu dòng, bỏ qua index sẵn có trên created_at. Linh viết lại:
SELECT MONTH(created_at) AS thang, SUM(total) AS doanh_thu
FROM orders
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01'
GROUP BY MONTH(created_at);
Lần này máy chủ dùng index để nhảy thẳng tới khoảng ngày 2026, chỉ đọc phần dữ liệu liên quan. Thời gian chạy: 1,2 giây. Cùng một kết quả, nhanh hơn gần 40 lần.
Bài học: Đừng bọc cột lọc trong hàm. Một thay đổi nhỏ về cách viết điều kiện ngày có thể thay đổi tốc độ gấp hàng chục lần.
Tình huống 2: Dashboard của Be bị nhân đôi và cái bẫy DISTINCT
Tuấn, BA tại một nền tảng gọi xe mô phỏng kiểu Be, dựng dashboard "số chuyến và tổng phí mỗi tài xế". Anh JOIN drivers với trips, rồi lại JOIN thêm trip_promotions (mỗi chuyến có thể có nhiều mã khuyến mãi). Kết quả: tổng số chuyến bị thổi phồng vì mỗi chuyến có 2-3 mã KM bị đếm lặp.
Phản xạ đầu tiên của Tuấn là thêm COUNT(DISTINCT trip_id). Nó cho con số đúng, nhưng query chạy 30 giây và làm dashboard load ì ạch vào giờ cao điểm. Mentor gợi ý: vấn đề không phải ở chỗ đếm, mà ở chỗ nối bảng trip_promotions quá sớm làm nhân dòng. Tuấn gom khuyến mãi lại trước khi nối:
WITH km_theo_chuyen AS (
SELECT trip_id, COUNT(*) AS so_ma_km
FROM trip_promotions
GROUP BY trip_id
)
SELECT d.driver_id,
COUNT(t.trip_id) AS so_chuyen,
SUM(t.fare) AS tong_phi
FROM drivers d
JOIN trips t ON t.driver_id = d.driver_id
LEFT JOIN km_theo_chuyen k ON k.trip_id = t.trip_id
GROUP BY d.driver_id;
Bằng cách gom trip_promotions về mức "một dòng một chuyến" trước khi JOIN, mỗi chuyến chỉ còn đúng một dòng. Không cần DISTINCT, số liệu chính xác, và query xuống còn 4 giây.
Bài học: DISTINCT thường là dấu hiệu của một JOIN sai cấu trúc. Hãy gom nhóm dữ liệu một-nhiều trước khi nối, thay vì che lỗi bằng DISTINCT.
Tình huống 3: MoMo và query "lấy hết rồi lọc sau"
Ở một ví fintech mô phỏng kiểu MoMo, Hà cần lấy danh sách giao dịch nghi ngờ gian lận trong 7 ngày gần nhất. Cô viết query "lấy hết về Excel rồi lọc tay":
SELECT * FROM transactions ORDER BY created_at DESC;
Bảng transactions có gần 200 triệu dòng và 35 cột. Query này cố tải toàn bộ về máy, ngốn hết RAM của máy chủ report và bị kill sau vài phút. Hà nghĩ "chắc tại dữ liệu nhiều quá". Thực ra vấn đề là cô bắt máy chủ làm việc thừa: lấy 200 triệu dòng, mọi cột, sort toàn bộ.
Cô viết lại với đúng 3 pattern: lọc sớm, chọn cột cần, và giới hạn:
SELECT txn_id, user_id, amount, status, created_at
FROM transactions
WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'
AND status = 'flagged'
ORDER BY created_at DESC
LIMIT 1000;
Query trả về trong dưới 1 giây, đúng tập dữ liệu cô cần, và không làm ai khác bị ảnh hưởng.
Bài học: Đừng dùng SQL như một cái ống hút để "hút hết" rồi xử lý ở Excel. Đẩy việc lọc về cho database — nó được thiết kế để làm việc đó hiệu quả hơn nhiều.
Hướng dẫn từng bước
Khi viết hoặc rà soát một query, hãy đi qua checklist này theo thứ tự:
- Liệt kê cột thay vì
SELECT *. Tự hỏi: report này thực sự cần những cột nào? Chỉ lấy đúng chừng đó.
- Đặt bộ lọc mạnh nhất lên đầu
WHERE. Điều kiện nào loại bỏ được nhiều dòng nhất (thường là khoảng thời gian)? Đảm bảo cột lọc đứng một mình, không bị bọc trong hàm.
- Rà soát từng JOIN. Với mỗi bảng được nối, hỏi: "Mình có dùng cột nào từ bảng này không?" Nếu không, xoá nó. Kiểm tra quan hệ một-nhiều có làm nhân dòng không.
- Nếu thấy dòng bị nhân đôi, đừng vội thêm
DISTINCT. Tìm bảng gây nhân dòng, gom nhóm nó trước (qua CTE) rồi mới JOIN.
- Thêm
LIMITkhi đang khám phá. Chạy thử vớiLIMIT 100để kiểm tra logic và xem mẫu trước khi chạy full.
- Chạy thử và quan sát thời gian. Nếu query mất hơn vài giây trên dữ liệu vừa, đó là tín hiệu để xem lại — hoặc đọc EXPLAIN plan (Bài 25) để hiểu vì sao.
- Cân nhắc môi trường. Nếu là query nặng và bạn không cần dữ liệu real-time, hỏi xem có bản replica/read-only hoặc bảng pre-aggregated nào không, thay vì đánh thẳng vào production.
Lỗi thường gặp & mẹo
- Dùng
SELECT *trong report chính thức. Vừa chậm, vừa dễ vỡ khi schema đổi, vừa có thể lộ cột nhạy cảm (PII). Luôn liệt kê cột.
- Bọc cột lọc trong hàm:
WHERE DATE(created_at) = '2026-06-27',WHERE UPPER(email) = .... Điều này vô hiệu hoá index. Viết lại thành khoảng giá trị, hoặc xử lý ở vế phải.
- Lọc ngày bằng chuỗi không rõ ràng.
WHERE created_at LIKE '2026-06%'ép so khớp chuỗi thay vì so sánh ngày. Dùng>=và<với mốc ngày rõ ràng.
ORtrên nhiều cột khác nhau.WHERE phone = ... OR email = ...thường khó dùng index. Đôi khi tách thành hai query rồiUNIONlại sẽ nhanh hơn nhiều.
%từ_khoá%(wildcard ở đầu).LIKE '%abc'không dùng được index vì máy chủ không biết bắt đầu từ đâu. Nếu phải tìm kiếm văn bản nhiều, hãy hỏi đội kỹ thuật về full-text search.
- Gọi hàm nặng trong
SELECTtrên hàng triệu dòng rồi mới lọc xuống còn vài dòng. Hãy lọc trước, tính sau.
- Mẹo lớn nhất: Khi nghi ngờ, hãy chạy
EXPLAINtrước query của bạn. Nếu thấy "full table scan" hoặc "Seq Scan" trên bảng lớn trong khi bạn nghĩ nó nên dùng index, đó là cờ đỏ. (Cách đọc chi tiết ở Bài 25.)
- Mẹo giao tiếp: Là BA, đôi khi giải pháp tốt nhất không phải tự tối ưu, mà là hỏi đúng câu. Ví dụ: "Bảng
orderscó index trêncreated_atkhông?" hay "Có view pre-aggregated nào cho doanh thu theo ngày không?". Hiểu các pattern này giúp bạn hỏi đội kỹ thuật đúng trọng tâm.
Bài tập thực hành
Giả sử bạn có ba bảng: orders(id, customer_id, total, status, created_at), customers(id, name, city), và order_items(id, order_id, product_id, quantity).
- Viết lại cho nhanh. Cho query sau, hãy chỉ ra hai vấn đề và viết lại:
SELECT * FROM orders WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 6;
- Sửa lỗi nhân dòng. Bạn muốn đếm số đơn và tổng số lượng sản phẩm mỗi khách hàng. Bạn JOIN
customers→orders→order_itemsvà thấy số đơn bị thổi phồng. Viết lại dùng CTE để gomorder_itemstrước khi nối, không dùngDISTINCT.
- Lọc sớm. Viết query lấy
id, total, created_atcủa các đơnstatus = 'completed'trong quý 1/2026, sắp xếp mới nhất trước, giới hạn 500 dòng. Đảm bảo điều kiện ngày không bọc cột trong hàm.
- Tư duy như BA. Bạn cần doanh thu theo ngày của cả năm để cập nhật dashboard mỗi giờ. Thay vì chạy lại query nặng trên
ordersmỗi lần, bạn nên hỏi đội kỹ thuật điều gì? (Gợi ý: liên quan đến bảng pre-aggregated — Bài 49.)
EXPLAIN để so sánh, và quan sát chênh lệch thời gian giữa bản gốc và bản tối ưu.Tóm tắt
Tối ưu query với vai trò BA không phải là chuyện ma thuật cao siêu — nó là một bộ thói quen đơn giản, lặp đi lặp lại:
- Chỉ lấy cột bạn cần, tránh
SELECT *. - Lọc sớm và lọc đúng — đặt bộ lọc mạnh lên
WHERE, và đừng bao giờ bọc cột lọc trong hàm vì nó giết chết index. - JOIN gọn — chỉ nối bảng bạn dùng, và gom nhóm dữ liệu một-nhiều trước khi nối thay vì che lỗi bằng
DISTINCT. - Dùng
LIMITkhi khám phá và đẩy việc lọc về database thay vì hút hết về Excel. - Khi nghi ngờ, chạy
EXPLAINvà đặt câu hỏi đúng cho đội kỹ thuật.