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 người ngồi ở bàn làm việc ghép các khối truy vấn để lọc một bảng lớn thành một bảng nhỏ, gọn gàng

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:

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

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:

  1. FROM và JOIN: dựng bảng làm việc.
  2. WHERE: loại bỏ dòng.
  3. GROUP BY: tạo nhóm.
  4. HAVING: loại bỏ nhóm.
  5. SELECT: tính các cột và bí danh.
  6. ORDER BY: sắp xếp (ở bước này đã dùng được bí danh từ SELECT).
  7. 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

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