SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 12 phút đọc
Câu lệnh SQL cơ bản: SELECT, WHERE, ORDER BY, GROUP BY và JOIN cho người mới

Một truy vấn SQL cơ bản luôn có cùng một hình dạng: SELECT cho biết bạn muốn xem những cột nào, FROM cho biết dữ liệu lấy từ bảng nào, WHERE lọc các dòng, GROUP BY gộp dòng thành nhóm để tính tổng, ORDER BY sắp xếp kết quả và JOIN nối hai bảng qua một cột chung. Chỉ với sáu mảnh ghép này, bạn đã giải quyết được phần lớn công việc hằng ngày của một data analyst. Bài hướng dẫn này đi qua từng mảnh một, với những truy vấn chạy được thật: tất cả đều chạy trên cơ sở dữ liệu của một hãng hàng không mà khóa SQL trên Nomto sử dụng, và mọi kết quả bạn thấy bên dưới là đầu ra thật.
Cơ sở dữ liệu dùng trong các ví dụ
Chúng ta sẽ làm việc với năm bảng của một hãng hàng không (giá trị trong bảng bằng tiếng Anh, tiền tính bằng đô la Mỹ, giờ là giờ gốc của hãng: Moscow, UTC+3):
| Bảng | Một dòng là | Các cột sẽ dùng |
|---|---|---|
airports |
một sân bay (104) | airport_code, airport_name, city, timezone |
aircrafts |
một mẫu máy bay (9) | aircraft_code, model, range (tầm bay, km) |
flights |
một chuyến bay khởi hành trong một ngày (33.121) | flight_no, scheduled_departure, departure_airport, arrival_airport, status, aircraft_code, actual_departure |
bookings |
một đơn đặt chỗ | book_ref, book_date, total_amount |
ticket_flights |
một chặng bay trên một vé | flight_id, fare_conditions (hạng ghế), amount (giá) |
Trước khi viết bất kỳ truy vấn nào, bạn nên biết một dòng trong mỗi bảng đại diện cho cái gì. Gần như mọi lỗi của người mới học SQL đều bắt nguồn từ việc đếm những dòng không phải là thứ mình nghĩ.
SELECT và FROM: chọn cột
Truy vấn đơn giản nhất lấy vài cột từ một bảng:
SELECT model, range
FROM aircrafts;
Vài quy tắc nên nắm ngay từ đầu:
SELECT *trả về mọi cột. Nó tiện để xem qua dữ liệu, nhưng trong truy vấn thật hãy ghi rõ tên các cột bạn cần: kết quả dễ đọc hơn và không bị hỏng khi ai đó thêm cột vào bảng.- Trong
SELECTbạn có thể tính toán. DùngASđể đặt tên cho cột được tính. - Dấu chấm phẩy ở cuối dùng để ngăn cách các truy vấn. Trong Sân tập SQL của Nomto mỗi lần chỉ chạy một truy vấn, nên bạn có thể bỏ nó đi.
Tầm bay tính bằng kilômét. Nếu muốn có thêm cột tính bằng dặm:
SELECT model,
range,
ROUND(range / 1.609) AS range_miles
FROM aircrafts
ORDER BY range DESC
LIMIT 5;
| model | range | range_miles |
|---|---|---|
| Boeing 777-300 | 11100 | 6899 |
| Boeing 767-300 | 7900 | 4910 |
| Airbus A319-100 | 6700 | 4164 |
| Airbus A320-200 | 5700 | 3543 |
| Airbus A321-200 | 5600 | 3480 |
Nếu không có ROUND, cột này sẽ ra một dãy số thập phân dài (6898.69484151647). ORDER BY và LIMIT được giải thích ở phần dưới.
WHERE: lọc dòng
WHERE chỉ cho đi qua những dòng thỏa mãn một điều kiện. Các sân bay ở Moscow:
SELECT airport_code, airport_name, city
FROM airports
WHERE city = 'Moscow'
ORDER BY airport_code;
| airport_code | airport_name | city |
|---|---|---|
| DME | Domodedovo | Moscow |
| SVO | Sheremetyevo | Moscow |
| VKO | Vnukovo | Moscow |
Hãy để ý hai chi tiết: chuỗi văn bản nằm trong dấu nháy đơn (trong PostgreSQL, dấu nháy kép dành cho tên cột), và phép so sánh phân biệt chữ hoa chữ thường: 'moscow' không trả về dòng nào.
Kết hợp điều kiện: AND, OR, IN, BETWEEN
Các chuyến khởi hành từ Sheremetyevo ngày 1 tháng 8:
SELECT flight_no, scheduled_departure, arrival_airport, status
FROM flights
WHERE departure_airport = 'SVO'
AND scheduled_departure >= '2026-08-01'
AND scheduled_departure < '2026-08-02'
ORDER BY scheduled_departure, flight_no
LIMIT 5;
| flight_no | scheduled_departure | arrival_airport | status |
|---|---|---|---|
| PG0120 | 2026-08-01 09:00:00 | MJZ | Arrived |
| PG0591 | 2026-08-01 09:00:00 | TOF | Arrived |
| PG0241 | 2026-08-01 09:40:00 | CSY | Arrived |
| PG0677 | 2026-08-01 09:40:00 | MCX | Arrived |
| PG0041 | 2026-08-01 09:50:00 | NFG | Arrived |
Hôm đó có 50 chuyến bay cất cánh từ SVO. Bộ lọc ngày dùng mẫu “lớn hơn hoặc bằng đầu ngày và nhỏ hơn đầu ngày hôm sau”. Cách này an toàn hơn BETWEEN '2026-08-01' AND '2026-08-01', vốn với cột ngày giờ chỉ bắt được đúng thời điểm nửa đêm.
IN thay cho nhiều OR trên cùng một cột. Các chuyến bay đã cất cánh (đang bay hoặc đã hạ cánh):
SELECT COUNT(*)
FROM flights
WHERE status IN ('Departed', 'Arrived');
Kết quả là 16.765. Khi trộn AND với OR, hãy dùng dấu ngoặc: AND được tính trước, nên a OR b AND c nghĩa là a OR (b AND c).
LIKE: tìm theo mẫu
% thay cho một số ký tự bất kỳ, còn _ thay cho đúng một ký tự. Các thành phố bắt đầu bằng “Novo”:
SELECT airport_code, airport_name, city
FROM airports
WHERE city LIKE 'Novo%'
ORDER BY city;
| airport_code | airport_name | city |
|---|---|---|
| NOZ | Spichenkovo | Novokuznetsk |
| OVB | Tolmachevo | Novosibirsk |
Trong PostgreSQL, ILIKE làm điều tương tự nhưng không phân biệt chữ hoa chữ thường.
IS NULL: giá trị rỗng
NULL nghĩa là “không có giá trị”. Một chuyến bay chưa cất cánh có actual_departure rỗng. Đây là cái bẫy nổi tiếng nhất của SQL:
SELECT COUNT(*) FROM flights WHERE actual_departure = NULL; -- 0
SELECT COUNT(*) FROM flights WHERE actual_departure IS NULL; -- 16348
Truy vấn đầu trả về 0 dù có hàng nghìn chuyến chưa cất cánh: mọi phép so sánh với NULL đều cho kết quả “không xác định”, và WHERE chỉ cho đi qua những gì đúng. Với giá trị rỗng, hãy luôn dùng IS NULL hoặc IS NOT NULL.
ORDER BY và LIMIT: sắp xếp và lấy những dòng đầu
Khi không có ORDER BY, cơ sở dữ liệu trả về các dòng theo thứ tự nào tiện cho nó, và thứ tự đó có thể thay đổi giữa các lần chạy. Năm đơn đặt chỗ đắt nhất:
SELECT book_ref, book_date, total_amount
FROM bookings
ORDER BY total_amount DESC
LIMIT 5;
| book_ref | book_date | total_amount |
|---|---|---|
| 3B54BB | 2026-07-01 17:08:00 | 12045.00 |
| 3AC131 | 2026-07-27 01:06:00 | 10871.00 |
| 65A6EA | 2026-06-29 06:28:00 | 10656.00 |
| D7E9AA | 2026-08-04 05:29:00 | 10628.00 |
| EF479E | 2026-07-29 15:58:00 | 10351.00 |
ASC(mặc định) sắp xếp từ nhỏ đến lớn;DESCtừ lớn đến nhỏ.- Bạn có thể sắp xếp theo nhiều cột:
ORDER BY scheduled_departure, flight_nophân định theo số hiệu chuyến bay khi hai chuyến cất cánh cùng giờ, như trong ví dụ SVO ở trên. LIMITmà không cóORDER BYcho bạn “vài dòng bất kỳ”, chứ không phải “những dòng đầu tiên”. Nếu bạn quan tâm dòng nào được trả về, hãy sắp xếp.
Hàm tổng hợp: COUNT, SUM, AVG, MIN, MAX
Hàm tổng hợp biến nhiều dòng thành một con số. Có một khác biệt bạn nên hiểu sớm:
SELECT COUNT(*) AS total,
COUNT(actual_departure) AS departed,
COUNT(*) - COUNT(actual_departure) AS not_yet
FROM flights;
| total | departed | not_yet |
|---|---|---|
| 33121 | 16773 | 16348 |
COUNT(*) đếm dòng; COUNT(cột) chỉ đếm các giá trị không rỗng. COUNT(DISTINCT cột) đếm các giá trị khác nhau: SELECT COUNT(DISTINCT timezone) FROM airports trả về 17 múi giờ.
GROUP BY: một giá trị tổng cho mỗi nhóm
GROUP BY chia bảng thành các nhóm và tính giá trị tổng hợp trong từng nhóm. Số chuyến bay theo từng trạng thái:
SELECT status, COUNT(*) AS flights
FROM flights
GROUP BY status
ORDER BY flights DESC;
| status | flights |
|---|---|
| Arrived | 16707 |
| Scheduled | 15383 |
| On Time | 518 |
| Cancelled | 414 |
| Departed | 58 |
| Delayed | 41 |
Nhiều hàm tổng hợp cùng lúc, theo hạng ghế:
SELECT fare_conditions,
COUNT(*) AS segments,
ROUND(AVG(amount), 2) AS avg_price,
MIN(amount) AS min_price,
MAX(amount) AS max_price
FROM ticket_flights
GROUP BY fare_conditions
ORDER BY avg_price DESC;
| fare_conditions | segments | avg_price | min_price | max_price |
|---|---|---|---|---|
| Business | 107642 | 511.43 | 91.00 | 2033.00 |
| Comfort | 17291 | 327.41 | 199.00 | 476.00 |
| Economy | 920793 | 159.6 | 30.00 | 745.00 |
Quy tắc vàng: mọi cột trong SELECT phải nằm trong GROUP BY hoặc nằm bên trong một hàm tổng hợp. Nếu bạn viết SELECT status, flight_no, COUNT(*) ... GROUP BY status, PostgreSQL sẽ báo lỗi, vì trong một nhóm có hàng trăm số hiệu chuyến bay và nó không biết nên hiển thị số nào.
HAVING: lọc nhóm
WHERE lọc dòng trước khi nhóm; HAVING lọc nhóm sau khi nhóm. Các thành phố có nhiều hơn một sân bay:
SELECT city, COUNT(*) AS airports
FROM airports
GROUP BY city
HAVING COUNT(*) > 1
ORDER BY airports DESC;
| city | airports |
|---|---|
| Moscow | 3 |
| Ulyanovsk | 2 |
Bạn không thể viết WHERE COUNT(*) > 1: lúc WHERE được tính, các nhóm còn chưa tồn tại. Phần này được giải thích kỹ hơn trong bài GROUP BY và HAVING trong SQL.
JOIN: nối các bảng
Trong flights, máy bay chỉ xuất hiện dưới dạng mã (321, SU9). Tên mẫu máy bay nằm trong aircrafts. JOIN nối hai bảng qua cột chung:
SELECT a.model, COUNT(*) AS flights
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY a.model
ORDER BY flights DESC;
| model | flights |
|---|---|
| Cessna 208 Caravan | 9273 |
| Bombardier CRJ-200 | 9048 |
| Sukhoi Superjet-100 | 8504 |
| Airbus A321-200 | 1952 |
| Boeing 737-300 | 1274 |
| Airbus A319-100 | 1239 |
| Boeing 767-300 | 1221 |
| Boeing 777-300 | 610 |
f và a là bí danh (alias): tên ngắn để khỏi lặp lại tên đầy đủ của bảng. ON cho biết những dòng nào đi cùng nhau.
INNER JOIN và LEFT JOIN
Bảng trên có 8 mẫu máy bay, nhưng aircrafts có 9. JOIN (tức INNER JOIN) chỉ giữ những dòng có dòng khớp ở cả hai bảng, nên mẫu máy bay không có chuyến bay nào biến mất. LEFT JOIN giữ mọi dòng của bảng bên trái và điền NULL vào chỗ không tìm thấy dòng khớp. Nhờ vậy bạn tìm được “những dòng không có cặp”:
SELECT a.aircraft_code, a.model
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
WHERE f.flight_id IS NULL;
| aircraft_code | model |
|---|---|
| 320 | Airbus A320-200 |
Nếu đếm sau LEFT JOIN, hãy dùng COUNT(f.flight_id) thay vì COUNT(*): như vậy A320 ra 0 chứ không phải 1.
Một lưu ý cho khi bạn học lên: nếu nối một bảng với bảng khác có nhiều dòng cho mỗi dòng của nó (một đơn đặt chỗ có nhiều vé), số dòng sẽ bị nhân lên và các tổng bị thổi phồng. Hiện tượng này được giải thích bằng số liệu cụ thể trong bài JOIN trong SQL.
Ghép tất cả lại: một truy vấn hoàn chỉnh
Các mảnh ghép kết hợp theo một thứ tự cố định: SELECT … FROM … JOIN … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT. Các thành phố có ít nhất 300 chuyến đã hạ cánh và độ trễ cất cánh trung bình, tính bằng phút:
SELECT ap.city,
COUNT(*) AS flights,
ROUND(AVG(EXTRACT(EPOCH FROM f.actual_departure - f.scheduled_departure) / 60), 1) AS avg_delay_min
FROM flights f
JOIN airports ap ON ap.airport_code = f.departure_airport
WHERE f.status = 'Arrived'
GROUP BY ap.city
HAVING COUNT(*) >= 300
ORDER BY avg_delay_min DESC;
| city | flights | avg_delay_min |
|---|---|---|
| Yekaterinburg | 348 | 14.7 |
| Bryansk | 306 | 13.2 |
| Krasnoyarsk | 358 | 13.1 |
| St. Petersburg | 956 | 12.6 |
| Moscow | 3990 | 12.4 |
| Novosibirsk | 536 | 11.8 |
| Perm | 311 | 10.5 |
| Rostov-on-Don | 310 | 9.2 |
Hiệu của hai mốc thời gian là một khoảng thời gian (interval); EXTRACT(EPOCH FROM …) đổi nó ra giây và ta chia cho 60.
Thứ tự thực thi một truy vấn
Chúng ta viết SELECT đầu tiên, nhưng cơ sở dữ liệu xử lý nó gần như cuối cùng:
FROMvàJOIN: dựng bảng làm việc.WHERE: loại bỏ dòng.GROUP BY: tạo nhóm.HAVING: loại bỏ nhóm.SELECT: tính các cột và bí danh.ORDER BY: sắp xếp (ở bước này đã dùng được bí danh từ SELECT).LIMIT: cắt bớt.
Thứ tự này giải thích vì sao bí danh trong SELECT dùng được trong ORDER BY nhưng không dùng được trong WHERE, và vì sao điều kiện trên hàm tổng hợp phải đặt trong HAVING.
Những lỗi thường gặp của người mới
= NULLthay vìIS NULL. Trả về 0 dòng mà không hề báo lỗi.- Dấu nháy kép cho chuỗi văn bản.
WHERE city = "Moscow"trong PostgreSQL sẽ đi tìm một cột tên là Moscow. - Cột không được nhóm trong SELECT. Mọi thứ không nằm trong
GROUP BYphải nằm trong một hàm tổng hợp. LIMITkhông cóORDER BY. Kết quả không phải là “top”, mà là ngẫu nhiên.COUNT(*)sauLEFT JOIN. Những dòng không có cặp bị đếm là 1.- Dùng
BETWEENcho ngày trên cột có giờ. Hãy dùng>=đầu ngày và<ngày hôm sau.
Cách luyện tập
Đọc truy vấn có ích, nhưng bạn chỉ thật sự học được khi tự viết và tự mắc lỗi. Mọi ví dụ trong bài này đều chạy và sửa được trong Sân tập SQL của Nomto, trên cùng một cơ sở dữ liệu: đổi thành phố, trạng thái hay thứ tự sắp xếp rồi xem chuyện gì xảy ra. Học phần đầu tiên của khóa học bắt đầu đúng từ SELECT và bí danh, và mỗi bài học kết thúc bằng ba bài tập được kiểm tra tự động. Khi đã nắm chắc phần cơ bản, hãy chuyển sang bài tập SQL có lời giải và hàm cửa sổ trong SQL.
Câu hỏi thường gặp
Mất bao lâu để học các câu lệnh SQL cơ bản?
SELECT, WHERE, ORDER BY, GROUP BY và JOIN có thể học trong hai đến ba tuần nếu bạn luyện mỗi ngày một giờ. Phần tốn thời gian hơn là đọc hiểu dữ liệu: biết một dòng là gì và khi nào JOIN làm số dòng bị nhân lên.
WHERE và HAVING khác nhau thế nào?
WHERE lọc dòng trước khi nhóm và không dùng được hàm tổng hợp. HAVING lọc các nhóm đã tạo xong và được dùng với COUNT, SUM, AVG và các hàm tương tự.
SQL trong bài này có dùng được cho MySQL hay SQL Server không?
Gần như toàn bộ là có. SELECT, WHERE, GROUP BY, HAVING, JOIN và ORDER BY giống nhau ở mọi hệ quản trị. Khác biệt nằm ở chi tiết: LIMIT trong SQL Server là TOP, ILIKE không có trong MySQL lẫn SQL Server, và các hàm xử lý ngày tháng có tên khác nhau.
Có cần cài cơ sở dữ liệu để luyện tập không?
Không. Trong Sân tập SQL của Nomto, bạn viết truy vấn ngay trên trình duyệt và chạy nó trên cơ sở dữ liệu của hãng hàng không, không cần cài đặt gì.
Ngôn ngữ khác: English, Português (Brasil), 简体中文, Español