SELECT, WHERE và ORDER BY tạo nên phần lớn công việc truy xuất dữ liệu hằng ngày. Bài viết không dừng ở cú pháp mà xây một màn hình tra cứu đơn hàng cho nhóm chăm sóc khách hàng, nơi điều kiện ngày, NULL, tìm kiếm chuỗi và phân trang phải được xử lý chính xác.
Mục lục
- Bài toán thực tế
- Sản phẩm hoàn thành
- Kiến thức cần dùng
- Chuẩn bị môi trường
- Dữ liệu thực hành
- Xây dựng từng bước
- Chương trình hoàn chỉnh
- Kiểm tra kết quả
- Lỗi thường gặp
- Đưa vào thực tế
- Bài tập thực hành
- Project mở rộng
- Tổng kết
- Tài liệu tham khảo
1. Bài toán thực tế

Tổng đài nhận câu hỏi như “đơn hoàn tất tại TP.HCM trong tuần này”, “đơn có giá trị trên 5 triệu” hoặc “đơn chưa có mã vận chuyển”. Nhân viên đang tải toàn bộ dữ liệu ra Excel rồi lọc thủ công, khiến cùng một yêu cầu cho ra kết quả khác nhau.
Màn hình tra cứu cần phản hồi nhanh, điều kiện rõ và thứ tự ổn định. Một bản ghi nằm đúng 00:00 ngày đầu tháng hoặc có shipped_at bằng NULL phải được xử lý có chủ đích.
2. Sản phẩm hoàn thành
Sản phẩm là bộ truy vấn tìm kiếm orders theo trạng thái, thời gian, khu vực, giá trị và từ khóa khách hàng. Bộ truy vấn hỗ trợ phân trang và có total count để giao diện biết tổng số kết quả.
Dữ liệu đầu ra giữ grain một dòng mỗi đơn. Các cột hiển thị gồm mã đơn, thời điểm, khách hàng, khu vực, trạng thái, tổng tiền và trạng thái vận chuyển.
- Chọn đúng cột bằng SELECT và đặt alias dễ hiểu.
- Kết hợp AND, OR và dấu ngoặc trong WHERE.
- Lọc ngày theo khoảng đóng ở đầu, mở ở cuối.
- Xử lý NULL bằng IS NULL và COALESCE.
- Sắp xếp ổn định và phân trang bằng LIMIT, OFFSET hoặc keyset.
3. Kiến thức cần dùng
SELECT quyết định cột đầu ra; FROM xác định nguồn; WHERE lọc dòng; ORDER BY sắp xếp; LIMIT giới hạn kết quả. Về mặt logic, FROM và WHERE được xử lý trước SELECT, vì vậy alias tạo trong SELECT thường không dùng trực tiếp ở WHERE cùng cấp.
SQL sử dụng logic ba giá trị: TRUE, FALSE và UNKNOWN. So sánh một giá trị với NULL cho UNKNOWN, nên shipped_at = NULL không trả bản ghi. Cách đúng là IS NULL hoặc IS NOT NULL.
AND có độ ưu tiên cao hơn OR. Khi yêu cầu nghiệp vụ có nhiều nhóm điều kiện, dấu ngoặc là tài liệu trực tiếp cho người đọc và tránh thay đổi ý nghĩa ngoài ý muốn.
4. Chuẩn bị môi trường

Dùng PostgreSQL và bảng orders có khoảng 100 dòng mẫu. Với dữ liệu nhỏ, ưu tiên tính đúng; phần cuối bài mới thêm index và EXPLAIN để quan sát hiệu năng.
Mọi mốc thời gian lưu bằng timestamptz. Báo cáo theo ngày Việt Nam phải xác định múi giờ thay vì cắt ngày theo UTC một cách mặc định.
SET TIME ZONE 'Asia/Ho_Chi_Minh';
SHOW TIME ZONE;
SELECT COUNT(*) AS order_count,
MIN(order_date) AS first_order,
MAX(order_date) AS latest_order
FROM orders;5. Dữ liệu thực hành
orders có một dòng mỗi đơn với khóa order_id. total_amount là tổng tiền đã được hệ thống giao dịch chốt; status thuộc pending, completed, cancelled hoặc refunded. shipped_at có thể NULL vì đơn chưa giao.
customer_name được dùng để minh họa tìm kiếm chuỗi. Trong hệ thống chuẩn hóa, tên khách hàng nên nằm ở bảng customers và được JOIN khi hiển thị.
CREATE TABLE orders (
order_id bigint PRIMARY KEY,
order_date timestamptz NOT NULL,
customer_name varchar(200) NOT NULL,
region varchar(50) NOT NULL,
status varchar(30) NOT NULL,
total_amount numeric(14,2) NOT NULL,
shipped_at timestamptz
);6. Xây dựng từng bước
Bước 1: Chọn cột và đặt tên
Danh sách cột tường minh giúp API không bất ngờ khi schema thay đổi. Alias tiếng Anh ngắn gọn phù hợp cho lớp dữ liệu; nhãn tiếng Việt nên xử lý ở giao diện.
SELECT
order_id,
order_date,
customer_name,
status,
total_amount AS order_value
FROM orders
LIMIT 20;Kết quả mong đợi: 20 đơn với đúng năm cột phục vụ tra cứu.
Bước 2: Lọc theo nhiều điều kiện
Các điều kiện cùng phải đúng được nối bằng AND. IN ngắn gọn hơn chuỗi OR khi so sánh cùng một cột.
SELECT order_id, order_date, region, status, total_amount
FROM orders
WHERE status IN ('completed', 'refunded')
AND region IN ('HCM', 'Hanoi')
AND total_amount >= 5000000;Kết quả mong đợi: Đơn giá trị cao thuộc hai khu vực và đã hoàn tất hoặc hoàn tiền.
Bước 3: Viết điều kiện thời gian không chồng lấn
Dùng mốc đầu tháng tiếp theo thay cho 23:59:59.999. Cách đóng ở đầu và mở ở cuối hoạt động với mọi độ chính xác timestamp.
SELECT order_id, order_date, total_amount
FROM orders
WHERE order_date >= TIMESTAMPTZ '2026-09-01 00:00:00+07'
AND order_date < TIMESTAMPTZ '2026-10-01 00:00:00+07'
ORDER BY order_date;Kết quả mong đợi: Toàn bộ đơn trong tháng 9 theo giờ Việt Nam, không lặp với tháng 10.
Bước 4: Xử lý NULL và tìm kiếm tên
ILIKE tìm chuỗi không phân biệt hoa thường trong PostgreSQL. Ký tự phần trăm đại diện cho chuỗi bất kỳ. Truy vấn tìm đơn chưa giao của khách có tên chứa Nguyen.
SELECT order_id, customer_name, shipped_at
FROM orders
WHERE shipped_at IS NULL
AND customer_name ILIKE '%nguyen%';Kết quả mong đợi: Các đơn chưa có thời điểm giao và tên khách khớp từ khóa.
Bước 5: Sắp xếp và phân trang ổn định
Nếu chỉ ORDER BY total_amount, các đơn bằng tiền có thứ tự không xác định. Thêm order_id làm tie-breaker để các lần chạy ổn định.
SELECT order_id, order_date, customer_name, total_amount
FROM orders
WHERE status = 'completed'
ORDER BY total_amount DESC, order_id DESC
LIMIT 25 OFFSET 0;Kết quả mong đợi: Trang đầu gồm 25 đơn hoàn tất có giá trị cao nhất.
Bước 6: Keyset pagination cho dữ liệu lớn
OFFSET lớn buộc database bỏ qua nhiều dòng. Keyset dùng giá trị cuối trang trước, phù hợp khi cuộn liên tục.
SELECT order_id, order_date, total_amount
FROM orders
WHERE status = 'completed'
AND (total_amount, order_id) < (5000000, 1050)
ORDER BY total_amount DESC, order_id DESC
LIMIT 25;Kết quả mong đợi: Trang kế tiếp sau đơn 1050 có giá trị 5 triệu.
Thiết kế bộ lọc như sản phẩm tìm kiếm
Một màn hình tốt phải phân biệt người dùng chưa chọn bộ lọc với người dùng chủ động chọn giá trị trống. Ví dụ status NULL trong tham số có thể mang nghĩa “tất cả trạng thái”, nhưng shipped_at IS NULL mang nghĩa “chưa giao”. Hai loại NULL không được đánh đồng trong logic API.
Giới hạn page_size ở ứng dụng và database để tránh một yêu cầu tải hàng triệu dòng. Trả lại applied_filters giúp người dùng biết hệ thống đã hiểu những điều kiện nào, đặc biệt khi giao diện có nhiều lựa chọn tùy chọn.
Kiểm tra ranh giới thời gian bằng dữ liệu cố ý
Tạo ba đơn ở 2026-09-01 00:00:00+07, 2026-09-30 23:59:59.999999+07 và 2026-10-01 00:00:00+07. Truy vấn tháng 9 phải lấy đúng hai đơn đầu. Đây là kiểm thử nhỏ nhưng phát hiện lỗi phổ biến hơn việc nhìn hàng trăm kết quả ngẫu nhiên.
Nếu dữ liệu lưu UTC, timestamptz giúp so sánh thời điểm, nhưng việc chuyển sang “ngày kinh doanh” vẫn cần timezone. Một đơn 17:30 UTC ngày 30/9 là 00:30 ngày 1/10 tại Việt Nam; báo cáo theo ngày phải thống nhất điều này.
Giải thích vì sao truy vấn không trả kết quả
Khi kết quả rỗng, đừng xóa ngẫu nhiên điều kiện. Bắt đầu với FROM, chạy COUNT, rồi thêm từng predicate và ghi lại số dòng còn lại. Điều kiện làm số dòng về 0 là điểm cần kiểm tra với dữ liệu hoặc nghiệp vụ.
EXPLAIN có thể cho biết index và row estimate, nhưng không thay việc kiểm tra giá trị thật. Nhiều lỗi tra cứu đến từ khoảng trắng, trạng thái viết khác chuẩn hoặc tham số ngày sai timezone chứ không phải database chậm.
Bộ dữ liệu kiểm thử cho mọi kiểu điều kiện
Tạo các đơn có total_amount bằng đúng 5 triệu, ngay dưới 5 triệu, status viết đúng miền, shipped_at NULL và tên khách chứa ký tự có dấu. Dữ liệu biên buộc người học quyết định dùng lớn hơn hay lớn hơn hoặc bằng thay vì chọn tùy ý.
Thêm một khách tên “Nguyễn An” và một khách “Nguyen An” để thấy ILIKE không tự bỏ dấu. Nếu sản phẩm cần tìm kiếm không dấu, phải chuẩn hóa thêm hoặc dùng extension phù hợp, không nên hứa hẹn từ một toán tử không hỗ trợ.
INSERT INTO orders(order_id,order_date,customer_name,region,status,total_amount,shipped_at) VALUES
(2001,'2026-09-01 00:00+07','Nguyễn An','HCM','completed',5000000,NULL),
(2002,'2026-09-30 23:59:59+07','Nguyen An','Hanoi','completed',4999999,'2026-10-01 08:00+07'),
(2003,'2026-10-01 00:00+07','Lê Bình','HCM','pending',8000000,NULL);Các mẫu lọc thường gặp trong công việc
Ngoài điều kiện bằng và khoảng, analyst thường phải tìm top N, loại trừ status, phát hiện dữ liệu trống và tạo nhóm giá trị. Mỗi mẫu nên có một câu hỏi kinh doanh đi kèm để tránh học cú pháp rời rạc.
NOT IN có thể gây bất ngờ nếu danh sách con chứa NULL. Với truy vấn chống nối, NOT EXISTS thường diễn đạt ý định an toàn hơn. Ở bài cơ bản, chỉ cần ghi nhớ không dùng NOT IN với subquery khi chưa kiểm soát NULL.
SELECT order_id,total_amount,
CASE WHEN total_amount<1000000 THEN 'Dưới 1 triệu'
WHEN total_amount<5000000 THEN 'Từ 1 đến dưới 5 triệu'
ELSE 'Từ 5 triệu' END value_band
FROM orders
WHERE status<>'cancelled'
ORDER BY total_amount DESC,order_id;Từ OFFSET đến cursor thực tế
OFFSET dễ hiểu và phù hợp trang đầu, nhưng dữ liệu thay đổi giữa hai lần đọc có thể làm bản ghi dịch trang. Keyset dùng cặp order_date, order_id cuối cùng của trang trước làm cursor, giữ thứ tự nhất quán hơn.
Cursor gửi ra ngoài cần được encode và xác thực ở ứng dụng. SQL chỉ nhận hai giá trị đã giải mã qua bind parameter. Không ghép trực tiếp cursor hoặc sort column tùy ý vào câu lệnh.
SELECT order_id,order_date,customer_name,total_amount
FROM orders
WHERE (order_date,order_id)<(:last_order_date,:last_order_id)
ORDER BY order_date DESC,order_id DESC
LIMIT 25;Đọc EXPLAIN cho truy vấn lọc
EXPLAIN ANALYZE cho biết số dòng dự kiến và thực tế. Nếu planner dự kiến 10 dòng nhưng đọc 100.000 dòng, statistics hoặc tính chọn lọc có thể chưa phù hợp. Trên dữ liệu nhỏ, sequential scan là bình thường.
Tạo index sau khi đã có truy vấn mục tiêu. Index (status, order_date DESC, order_id DESC) phù hợp màn hình thường chọn một status và sắp ngày. Nếu người dùng hiếm khi lọc status, thứ tự khác có thể hợp lý hơn.
CREATE INDEX idx_orders_status_date
ON orders(status,order_date DESC,order_id DESC);
EXPLAIN (ANALYZE,BUFFERS)
SELECT order_id,order_date,total_amount
FROM orders WHERE status='completed'
ORDER BY order_date DESC,order_id DESC LIMIT 25;7. Chương trình hoàn chỉnh

Bộ lọc hoàn chỉnh nhận start_time, end_time, status, region và min_amount từ ứng dụng dưới dạng bind parameter. Không nối chuỗi người dùng trực tiếp vào SQL.
Ứng dụng chạy thêm câu COUNT với cùng điều kiện để hiển thị tổng số kết quả. Khi dùng keyset, giao diện có thể không cần tổng trang, đổi lại hiệu năng ổn định hơn.
SELECT
order_id, order_date, customer_name, region,
status, total_amount, shipped_at
FROM orders
WHERE order_date >= :start_time
AND order_date < :end_time
AND (:status IS NULL OR status = :status)
AND (:region IS NULL OR region = :region)
AND (:min_amount IS NULL OR total_amount >= :min_amount)
ORDER BY order_date DESC, order_id DESC
LIMIT :page_size;8. Kiểm tra kết quả
Kiểm tra riêng từng điều kiện trước khi kết hợp. Với khoảng ngày, tạo bản ghi đúng tại start_time, ngay trước end_time và đúng end_time; chỉ hai bản ghi đầu thuộc khoảng.
So sánh số dòng của truy vấn chi tiết với COUNT dùng cùng WHERE. Thử status không tồn tại, từ khóa rỗng và page_size bằng 1 để quan sát hành vi biên.
SELECT
COUNT(*) AS total_rows,
COUNT(*) FILTER (WHERE shipped_at IS NULL) AS not_shipped,
COUNT(*) FILTER (WHERE total_amount < 0) AS negative_amount
FROM orders;
SELECT status, COUNT(*)
FROM orders
GROUP BY status
ORDER BY status;9. Lỗi thường gặp

- Dùng = NULL: Không bản ghi nào khớp; phải dùng IS NULL.
- Thiếu ngoặc với OR: Điều kiện sau OR có thể bỏ qua các bộ lọc còn lại.
- BETWEEN cho timestamp: Hai khoảng liên tiếp có thể cùng lấy mốc biên.
- ORDER BY không có tie-breaker: Phân trang có thể lặp hoặc bỏ dòng.
- Nối từ khóa vào SQL: Tạo nguy cơ SQL injection; dùng bind parameter.
10. Đưa vào thực tế
Index nên phản ánh bộ lọc và thứ tự thường dùng, chẳng hạn (status, order_date DESC, order_id DESC). Không tạo index riêng cho mọi trường nếu người dùng có thể kết hợp tùy ý; đo execution plan của các truy vấn phổ biến.
Tìm kiếm ILIKE '%keyword%' không tận dụng B-tree thông thường. Với nhu cầu lớn, cân nhắc pg_trgm và GIN index hoặc hệ thống tìm kiếm chuyên dụng. Chuẩn hóa dấu và tên phải thống nhất với trải nghiệm người dùng.
11. Bài tập thực hành

- Tìm đơn cancelled trong bảy ngày gần nhất.
- Lọc đơn completed tại HCM hoặc đơn bất kỳ trên 20 triệu, dùng ngoặc đúng.
- Hiển thị trạng thái giao hàng bằng COALESCE.
- Viết trang thứ hai bằng OFFSET rồi chuyển sang keyset.
- Dùng EXPLAIN ANALYZE trước và sau khi tạo index.
12. Project mở rộng
Xây API tìm kiếm đơn hàng nhận bộ lọc tùy chọn và trả JSON gồm items, next_cursor và applied_filters. Ghi log thời gian chạy nhưng không ghi dữ liệu cá nhân nhạy cảm vào log.
13. Tổng kết
SELECT, WHERE và ORDER BY nhìn đơn giản nhưng các lỗi về NULL, ranh giới thời gian, ưu tiên toán tử và thứ tự không ổn định có thể làm sản phẩm sai ngay từ lớp tra cứu.
Người học nên dùng chính bộ dữ liệu này cho bài GROUP BY tiếp theo, nơi các dòng đơn hàng được tổng hợp thành báo cáo doanh thu theo ngày và khu vực.
14. Tài liệu tham khảo
TechData.AI - Leading the Future.
Tham khảo các khoá học theo link: https://techdata.ai/techdata-ai-course/
Hoàng Minh.
