Giao diện
Viết SQL khi phỏng vấn
Những dạng query hay bị yêu cầu viết tại chỗ, cùng các bẫy làm câu trả lời sai mà người viết không nhận ra. Mọi câu SQL trong trang đã được chạy thật trên MySQL 8.0.46 và PostgreSQL 17 với bộ dữ liệu mẫu bên dưới (ngày 2026-10-15). Kết quả in trong trang là kết quả thật; chỗ nào hai database cho kết quả khác nhau thì trang ghi rõ.
Cách làm một bài SQL khi phỏng vấn
- Hỏi lại trước khi viết. Hai người cùng lương thì tính thế nào? Cột có
NULLkhông? Không có kết quả thì trả vềNULLhay trả về rỗng? Người phỏng vấn thường cố ý để ngỏ các câu này để xem mình có hỏi không. - Viết từng bước bằng CTE (
WITH). Mỗi bước một tên rõ nghĩa, dễ đọc và dễ sửa hơn một câu lồng ba tầng subquery. - Tự thử với trường hợp biên: giá trị trùng,
NULL, nhóm chỉ có một dòng, bảng rỗng. - Nói thêm về hiệu năng khi được hỏi: câu này cần index nào, chạy trên bảng 100 triệu dòng thì sao (MySQL chương 4, chương 5).
Dữ liệu mẫu
sql
CREATE TABLE employees (
id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, dept VARCHAR(20) NOT NULL,
salary INT NULL, manager_id INT NULL
);
CREATE TABLE orders (
id INT PRIMARY KEY, customer_id INT NULL, amount INT NOT NULL,
status VARCHAR(20) NOT NULL, created_at DATE NOT NULL
);
CREATE TABLE customers (id INT PRIMARY KEY, email VARCHAR(50) NOT NULL);
CREATE TABLE logins (user_id INT NOT NULL, login_date DATE NOT NULL, PRIMARY KEY (user_id, login_date));| employees | ||||
|---|---|---|---|---|
| id | name | dept | salary | manager_id |
| 1 | An | eng | 3000 | NULL |
| 2 | Bình | eng | 2500 | 1 |
| 3 | Chi | eng | 2500 | 1 |
| 4 | Dũng | eng | 2000 | 2 |
| 5 | Hà | sales | 2200 | 1 |
| 6 | Khoa | sales | 1800 | 5 |
| 7 | Lan | ops | 1500 | 1 |
| 8 | Minh | ops | NULL | 7 |
- orders: 6 đơn từ 1/10 đến 4/10/2026. Ngày 2/10 chỉ có một đơn bị huỷ. Đơn số 5 có
customer_id = NULL(khách vãng lai). - customers: 5 khách, trong đó email
an@x.vnvàbinh@x.vnmỗi email xuất hiện hai lần. - logins: user 1 đăng nhập các ngày 1, 2, 3, 5, 6/10; user 2 đăng nhập ngày 2 và 4/10.
Bình và Chi cố ý có lương bằng nhau, Minh cố ý có lương NULL, để lộ ra các bẫy.
Thứ tự thực thi một câu SELECT
Database viết theo thứ tự SELECT … FROM … WHERE …, nhưng xử lý theo thứ tự khác:
FROM và JOIN → WHERE → GROUP BY → HAVING → SELECT (và window function) → DISTINCT → ORDER BY → LIMIT
Nhiều lỗi và câu hỏi giải thích được bằng thứ tự này:
Không dùng alias của
SELECTtrongWHERE, vì lúc chạyWHEREthì cột đó chưa được tính. Cả hai database đều báo lỗi:sqlSELECT name, salary * 12 AS yearly FROM employees WHERE yearly > 30000; -- MySQL: ERROR 1054: Unknown column 'yearly' in 'where clause' -- PostgreSQL: ERROR: column "yearly" does not existAlias dùng được trong
ORDER BYở cả hai database, vìORDER BYchạy sauSELECT.Alias trong
HAVINGthì khác nhau: MySQL cho phép (HAVING n >= 2), còn PostgreSQL báo lỗi. ViếtHAVING COUNT(*) >= 2thì chạy được ở cả hai.Không dùng window function trong
WHERE, vì window function được tính ở bướcSELECT. Muốn lọc theo kết quả của nó thì bọc trong CTE hoặc subquery, như ở mục Top N mỗi nhóm.
Nguồn: MySQL: Problems with Column Aliases, PostgreSQL: Window Function Calls.
JOIN và anti-join
Câu hỏi: khách hàng nào chưa có đơn nào? Đây gọi là anti-join. Có ba cách viết, nhưng chỉ hai cách đúng:
sql
-- 1. NOT EXISTS: đúng, nên dùng
SELECT c.id FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- → 4, 5
-- 2. LEFT JOIN rồi lọc dòng không ghép được: đúng
SELECT c.id FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
-- → 4, 5
-- 3. NOT IN: SAI khi subquery có NULL
SELECT id FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
-- → rỗng, trên cả MySQL và PostgreSQLCách 3 trả rỗng vì đơn số 5 có customer_id = NULL. Với khách số 4, điều kiện 4 NOT IN (1, 2, 3, NULL) tương đương 4 <> 1 AND 4 <> 2 AND 4 <> 3 AND 4 <> NULL. Vế cuối ra NULL (không biết), nên cả biểu thức ra NULL chứ không phải đúng, và dòng bị loại (PostgreSQL: Subquery Expressions). Lỗi này không báo gì, câu query chỉ âm thầm trả sai, nên rất nguy hiểm. Dùng NOT EXISTS, hoặc thêm WHERE customer_id IS NOT NULL vào subquery.
Với LEFT JOIN, phải lọc trên một cột không thể NULL của bảng bên phải, thường là khoá chính (o.id IS NULL). Nếu lọc trên cột có thể NULL thì không phân biệt được “không có đơn” với “đơn có cột đó NULL”.
GROUP BY
Cột không nằm trong nhóm
sql
SELECT dept, name, MAX(salary) FROM employees GROUP BY dept;
-- MySQL: ERROR 1055: ... nonaggregated column 'name' ... sql_mode=only_full_group_by
-- PostgreSQL: ERROR: column "employees.name" must appear in the GROUP BY clause ...Mỗi phòng có nhiều name, nên database không biết lấy tên nào. Câu này thường xuất hiện khi người viết muốn “lấy người lương cao nhất mỗi phòng”; cách đúng là window function ở mục Top N mỗi nhóm. MySQL bật ONLY_FULL_GROUP_BY mặc định nên báo lỗi; các bản MySQL cũ hoặc server đã tắt chế độ này thì không báo lỗi mà lấy đại một giá trị (MySQL: GROUP BY Handling).
Ngoại lệ: nếu đã GROUP BY theo khoá chính thì được chọn các cột khác của bảng đó, vì mỗi khoá chính chỉ ứng với một giá trị. Câu sau chạy được trên cả hai database:
sql
SELECT c.id, c.email, COUNT(o.id) AS orders
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;Để ý COUNT(o.id) chứ không phải COUNT(*): khách chưa có đơn vẫn có một dòng sau LEFT JOIN, nên COUNT(*) ra 1 còn COUNT(o.id) ra 0 (đúng).
Đếm có điều kiện
Đếm số đơn theo trạng thái mỗi ngày trên cùng một dòng, hay gặp khi làm báo cáo:
sql
SELECT created_at,
COUNT(*) AS total,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM orders GROUP BY created_at ORDER BY created_at;| created_at | total | paid | cancelled |
|---|---|---|---|
| 2026-10-01 | 2 | 2 | 0 |
| 2026-10-02 | 1 | 0 | 1 |
| 2026-10-03 | 2 | 2 | 0 |
| 2026-10-04 | 1 | 1 | 0 |
PostgreSQL viết gọn hơn được bằng COUNT(*) FILTER (WHERE status = 'paid').
Window function
GROUP BY gộp nhiều dòng thành một. Window function thì giữ nguyên từng dòng và thêm một cột tính trên một nhóm dòng liên quan (gọi là window). OVER (PARTITION BY dept ORDER BY salary DESC) nghĩa là: chia các dòng theo phòng, trong mỗi phòng sắp theo lương giảm dần, rồi tính.
Thử: window function trên bảng employees
Chạy trên:
SELECT dept, name, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS v
FROM employees;| dept | name | salary | v |
|---|---|---|---|
| eng | An | 3.000 | 1 |
| eng | Bình | 2.500 | 2 |
| eng | Chi | 2.500 | 3 |
| eng | Dũng | 2.000 | 4 |
| ops | Lan | 1.500 | 1 |
| ops | Minh | NULL | 2 |
| sales | Hà | 2.200 | 1 |
| sales | Khoa | 1.800 | 2 |
Bình và Chi cùng lương nhưng vẫn nhận hai số khác nhau. Thứ tự giữa hai người không xác định; muốn kết quả ổn định thì thêm cột phụ vào ORDER BY, ví dụ salary DESC, id.
Ô màu ở cột v đánh dấu dòng đồng hạng với dòng trên. Đổi giữa MySQL và PostgreSQL để thấy dòng lương NULL (Minh) đổi chỗ: khi sắp giảm dần, MySQL xếp NULL cuối còn PostgreSQL xếp NULL đầu.
Ba hàm xếp hạng
sql
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn,
RANK() OVER (ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY salary DESC) AS drnk
FROM employees WHERE dept = 'eng';| name | salary | rn | rnk | drnk |
|---|---|---|---|---|
| An | 3000 | 1 | 1 | 1 |
| Bình | 2500 | 2 | 2 | 2 |
| Chi | 2500 | 3 | 2 | 2 |
| Dũng | 2000 | 4 | 4 | 3 |
ROW_NUMBER: luôn 1, 2, 3, 4. Hai người cùng lương vẫn nhận hai số khác nhau, và ai nhận số nào là không xác định. Muốn ổn định thì thêm cột phụ:ORDER BY salary DESC, id.RANK: cùng lương thì cùng hạng, hạng sau nhảy cóc (2, 2, 4).DENSE_RANK: cùng lương thì cùng hạng, hạng sau không nhảy (2, 2, 3).
Tổng luỹ kế và bẫy khung RANGE
sql
SELECT created_at, amount,
SUM(amount) OVER (ORDER BY created_at, id) AS running_total,
SUM(amount) OVER (ORDER BY created_at) AS running_range
FROM orders WHERE status = 'paid';| created_at | amount | running_total | running_range |
|---|---|---|---|
| 2026-10-01 | 100 | 100 | 150 |
| 2026-10-01 | 50 | 150 | 150 |
| 2026-10-03 | 200 | 350 | 380 |
| 2026-10-03 | 30 | 380 | 380 |
| 2026-10-04 | 80 | 460 | 460 |
Cột running_range cho hai đơn cùng ngày cùng một tổng. Khi có ORDER BY mà không ghi khung, cả hai database mặc định dùng RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Với RANGE, “dòng hiện tại” bao gồm mọi dòng có cùng giá trị sắp xếp (gọi là peer). Vì vậy các đơn cùng ngày được cộng chung một lần (MySQL: Window Function Frame Specification, PostgreSQL: Window Function Calls). Muốn cộng từng dòng thì làm cho thứ tự sắp xếp là duy nhất (thêm id), hoặc ghi rõ khung ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
So với kỳ trước: LAG và bẫy ngày bị thiếu
sql
WITH daily AS (
SELECT created_at AS d, SUM(amount) AS revenue
FROM orders WHERE status = 'paid' GROUP BY created_at
)
SELECT d, revenue,
LAG(revenue) OVER (ORDER BY d) AS prev,
revenue - LAG(revenue) OVER (ORDER BY d) AS diff
FROM daily;| d | revenue | prev | diff |
|---|---|---|---|
| 2026-10-01 | 150 | NULL | NULL |
| 2026-10-03 | 230 | 150 | 80 |
| 2026-10-04 | 80 | 230 | -150 |
LAG lấy dòng trước, không phải ngày trước. Ngày 2/10 không có đơn đã thanh toán nên không có dòng nào, và ngày 3/10 bị so với ngày 1/10. Muốn so đúng ngày liền trước thì phải sinh đủ dãy ngày trước, như ở bài 6.
Bài hay gặp
1. Lương cao thứ hai
Có ba cách, và chúng khác nhau ở trường hợp biên:
sql
-- Cách 1: MAX của những lương nhỏ hơn MAX
SELECT MAX(salary) AS second FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- MySQL: 2500, PostgreSQL: 2500
-- Cách 2: DENSE_RANK, đổi số 2 thành N cho câu "cao thứ N"
SELECT DISTINCT salary FROM (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS r
FROM employees WHERE salary IS NOT NULL
) t WHERE r = 2;
-- MySQL: 2500, PostgreSQL: 2500
-- Cách 3: ORDER BY … LIMIT … OFFSET
SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1;
-- MySQL: 2500, PostgreSQL: 3000 ← khác nhau!Cách 3 cho hai kết quả khác nhau vì lương NULL của Minh:
- PostgreSQL coi
NULLlớn hơn mọi giá trị, nên khi sắp giảm dần thìNULLđứng đầu. Dãy thànhNULL, 3000, 2500…, và bỏ qua 1 dòng thì ra 3000. - MySQL xếp
NULLcuối khi sắp giảm dần (PostgreSQL: Sorting Rows, MySQL: Working with NULL Values).
Sửa bằng WHERE salary IS NOT NULL. Trên PostgreSQL còn có thể dùng ORDER BY salary DESC NULLS LAST; MySQL không có cú pháp NULLS LAST.
Khác biệt nữa: nếu không có lương thứ hai, cách 1 trả một dòng NULL, còn cách 2 và 3 trả rỗng. Nên hỏi người phỏng vấn muốn kết quả nào.
2. Top N mỗi nhóm
Hai người lương cao nhất mỗi phòng:
sql
WITH ranked AS (
SELECT dept, name, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC, id) AS rn
FROM employees WHERE salary IS NOT NULL
)
SELECT dept, name, salary FROM ranked WHERE rn <= 2 ORDER BY dept, rn;| dept | name | salary |
|---|---|---|
| eng | An | 3000 |
| eng | Bình | 2500 |
| ops | Lan | 1500 |
| sales | Hà | 2200 |
| sales | Khoa | 1800 |
Nếu đề nói “tính cả người đồng hạng” thì đổi ROW_NUMBER thành RANK hoặc DENSE_RANK. Khi đó phòng eng trả ba người (An, Bình, Chi). PostgreSQL còn có DISTINCT ON (dept) cho trường hợp chỉ lấy 1 người mỗi nhóm:
sql
-- chỉ PostgreSQL
SELECT DISTINCT ON (dept) dept, name, salary
FROM employees ORDER BY dept, salary DESC NULLS LAST;3. Tìm và xoá dòng trùng
sql
-- Tìm email bị trùng
SELECT email, COUNT(*) AS n FROM customers GROUP BY email HAVING COUNT(*) > 1;
-- an@x.vn 2, binh@x.vn 2
-- Xoá dòng trùng, giữ dòng có id nhỏ nhất của mỗi email
DELETE FROM customers WHERE id NOT IN (
SELECT keep_id FROM (SELECT MIN(id) AS keep_id FROM customers GROUP BY email) AS k
);
-- còn lại: 1 an@x.vn, 2 binh@x.vn, 4 chi@x.vnLớp SELECT keep_id FROM (…) AS k bọc ngoài là để chạy được trên MySQL. Viết thẳng WHERE id NOT IN (SELECT MIN(id) FROM customers GROUP BY email) thì PostgreSQL chạy được, nhưng MySQL báo ERROR 1093: You can't specify target table 'customers' for update in FROM clause. MySQL không cho vừa sửa một bảng vừa đọc chính bảng đó trong subquery, trừ khi phần đọc nằm trong một bảng dẫn xuất được tính ra trước (MySQL: Restrictions on Subqueries). Ở đây NOT IN an toàn vì MIN(id) không bao giờ NULL.
Xoá xong thì thêm UNIQUE (email) để lỗi không lặp lại (Chống trùng & race condition).
4. Gaps & islands: chuỗi ngày đăng nhập liên tiếp
Tìm các chuỗi ngày đăng nhập liên tiếp của mỗi user. Mẹo là lấy ngày trừ đi số thứ tự của nó. Trong một chuỗi liên tiếp, ngày tăng 1 và số thứ tự cũng tăng 1, nên hiệu không đổi. Hiệu đó dùng làm khoá để gom nhóm.
| user_id | login_date | rn | login_date − rn ngày |
|---|---|---|---|
| 1 | 10-01 | 1 | 09-30 |
| 1 | 10-02 | 2 | 09-30 |
| 1 | 10-03 | 3 | 09-30 |
| 1 | 10-05 | 4 | 10-01 |
| 1 | 10-06 | 5 | 10-01 |
sql
WITH numbered AS (
SELECT user_id, login_date,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn
FROM logins
)
SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS days
FROM numbered
GROUP BY user_id, DATE_SUB(login_date, INTERVAL rn DAY) -- MySQL
-- GROUP BY user_id, login_date - rn::int -- PostgreSQL
ORDER BY user_id, start_date;| user_id | start_date | end_date | days |
|---|---|---|---|
| 1 | 2026-10-01 | 2026-10-03 | 3 |
| 1 | 2026-10-05 | 2026-10-06 | 2 |
| 2 | 2026-10-02 | 2026-10-02 | 1 |
| 2 | 2026-10-04 | 2026-10-04 | 1 |
Cùng mẹo này giải được các bài như chuỗi ngày điểm danh dài nhất, khoảng thời gian máy chủ liên tục online, dãy số hoá đơn bị thiếu. Nếu một user đăng nhập nhiều lần trong một ngày thì phải lọc trùng ngày trước (ở đây khoá chính đã đảm bảo điều đó).
5. Dữ liệu dạng cây: CTE đệ quy
Tìm mọi nhân viên dưới quyền Bình, kể cả cấp dưới của cấp dưới:
sql
WITH RECURSIVE tree AS (
SELECT id, name, 0 AS depth FROM employees WHERE id = 2 -- điểm bắt đầu
UNION ALL
SELECT e.id, e.name, t.depth + 1
FROM employees e JOIN tree t ON e.manager_id = t.id -- đi xuống một cấp
)
SELECT id, name, depth FROM tree ORDER BY depth, id;
-- 2 Bình 0, 4 Dũng 1Phần đầu chạy một lần; phần sau UNION ALL chạy lặp lại trên các dòng vừa tìm được, cho tới khi không ra dòng mới. Nếu dữ liệu có vòng lặp (A quản lý B, B quản lý A) thì câu này chạy mãi, nên cần giới hạn depth (Quan hệ & dữ liệu dạng cây).
6. Doanh thu theo ngày kể cả ngày không có đơn
GROUP BY chỉ ra những ngày có dữ liệu. Muốn đủ mọi ngày thì sinh dãy ngày trước rồi LEFT JOIN:
sql
-- MySQL: CTE đệ quy
WITH RECURSIVE days AS (
SELECT DATE '2026-10-01' AS d
UNION ALL
SELECT d + INTERVAL 1 DAY FROM days WHERE d < DATE '2026-10-04'
)
SELECT days.d, COALESCE(SUM(o.amount), 0) AS revenue
FROM days LEFT JOIN orders o ON o.created_at = days.d AND o.status = 'paid'
GROUP BY days.d ORDER BY days.d;
-- PostgreSQL: generate_series
SELECT days.d::date AS d, COALESCE(SUM(o.amount), 0) AS revenue
FROM generate_series(DATE '2026-10-01', DATE '2026-10-04', INTERVAL '1 day') AS days(d)
LEFT JOIN orders o ON o.created_at = days.d::date AND o.status = 'paid'
GROUP BY days.d ORDER BY days.d;Cả hai ra: 150, 0, 230, 80. Hai chi tiết hay sai:
- Điều kiện
o.status = 'paid'phải nằm trongON, không được đặt ởWHERE. Nếu đặt ởWHERE, ngày 2/10 (chỉ có đơn huỷ) sẽ bị loại khỏi kết quả, vàLEFT JOINthànhINNER JOIN. COALESCEđổiNULLthành 0.
Bẫy với NULL
| Biểu thức | Kết quả | Giải thích |
|---|---|---|
salary = NULL | Không dòng nào khớp | So sánh gì với NULL cũng ra NULL. Phải dùng IS NULL |
COUNT(*) và COUNT(salary) | 8 và 7 | COUNT(cột) bỏ qua NULL |
AVG(salary) | 2214,29 (chia cho 7, không phải 8) | Hàm gộp bỏ qua NULL |
x NOT IN (subquery có NULL) | Không dòng nào khớp | Xem mục anti-join |
ORDER BY salary DESC | MySQL: NULL cuối; PostgreSQL: NULL đầu | Xem bài lương cao thứ hai |
NULL + 1 | NULL | Phép tính có NULL ra NULL. Dùng COALESCE(x, 0) khi cần |
CONCAT('a', NULL) | MySQL: NULL; PostgreSQL: 'a' | CONCAT của PostgreSQL bỏ qua NULL, còn 'a' || NULL thì ra NULL |
Số liệu trong bảng là kết quả chạy trên dữ liệu mẫu. Hành vi so sánh với NULL theo MySQL: Working with NULL Values.
Khác biệt MySQL và PostgreSQL hay gặp
| MySQL 8 | PostgreSQL | Kiểm chứng | |
|---|---|---|---|
7 / 2 | 3.5000 | 3 (chia nguyên khi hai vế là số nguyên) | Chạy thử. MySQL: / trả số thập phân, DIV là chia nguyên (tài liệu) |
'a' || 'b' | 0, vì || là phép OR (đã deprecated) | 'ab' | Chạy thử; MySQL: Logical Operators. Dùng CONCAT() để chạy được cả hai |
'an' = 'AN' | Đúng | Sai | Chạy thử |
'an' = 'Ân' | Đúng | Sai | Chạy thử. Collation mặc định utf8mb4_0900_ai_ci không phân biệt dấu (ai) và hoa thường (ci) (tài liệu) |
| Không phân biệt hoa thường | Mặc định | ILIKE, hoặc lower(x) = lower(y) | Chạy thử |
NULL khi sắp giảm dần | Đứng cuối | Đứng đầu; đổi được bằng NULLS LAST | Chạy thử và tài liệu |
Alias trong HAVING | Được | Lỗi | Chạy thử |
| Gộp chuỗi trong nhóm | GROUP_CONCAT(name ORDER BY name SEPARATOR ', ') | STRING_AGG(name, ', ' ORDER BY name) | Chạy thử |
| Một dòng mỗi nhóm | Window function | Thêm DISTINCT ON | Chạy thử |
| Sinh dãy ngày | CTE đệ quy | generate_series | Chạy thử |
| Cộng trừ ngày | DATE_SUB(d, INTERVAL n DAY), d + INTERVAL 1 DAY | d - n (với n là số nguyên), d + INTERVAL '1 day' | Chạy thử |
| Upsert | ON DUPLICATE KEY UPDATE | ON CONFLICT | Chống trùng & race condition |
Dòng 'an' = 'Ân' đáng nhớ khi làm ứng dụng tiếng Việt. Trên MySQL với collation mặc định, UNIQUE (username) coi “an” và “Ân” là trùng nhau, và WHERE name = 'binh' tìm ra “Bình”. Tiện cho ô tìm kiếm, nhưng sai nếu cần phân biệt. Khi đó đặt collation cho cột, ví dụ utf8mb4_0900_as_cs (phân biệt dấu và hoa thường).
Ghi nhớ
- Hỏi lại về giá trị trùng,
NULLvà kết quả rỗng trước khi viết; viết từng bước bằng CTE. - Thứ tự thực thi:
FROM→WHERE→GROUP BY→HAVING→SELECT→ORDER BY→LIMIT. Vì vậy alias và window function không dùng được trongWHERE. - Anti-join dùng
NOT EXISTS;NOT INtrả rỗng khi subquery cóNULL. ROW_NUMBERluôn khác nhau,RANKnhảy hạng,DENSE_RANKkhông nhảy hạng. Lọc theo window function thì bọc trong CTE.SUM() OVER (ORDER BY …)mặc định dùng khungRANGE, nên các dòng đồng hạng nhận cùng tổng.LAGso với dòng trước, không phải ngày trước.- MySQL và PostgreSQL khác nhau ở vị trí
NULLkhi sắp xếp, phép chia số nguyên,||, và so sánh chuỗi không phân biệt dấu của MySQL.
Nguồn tham khảo
Đã chạy thử ngày 2026-10-15 trên MySQL 8.0.46 (Docker image mysql:8.0, collation mặc định utf8mb4_0900_ai_ci) và PostgreSQL 17 (Docker image postgres:17-alpine), với bộ dữ liệu mẫu ở đầu trang. Mọi bảng kết quả và mọi chỗ ghi “MySQL: …, PostgreSQL: …” là kết quả thật. Mô phỏng window function cũng đã được đối chiếu từng ô với kết quả chạy thật.
Tài liệu chính thức đã đối chiếu (ngày 2026-10-15):
| Nguồn | Dùng cho phần |
|---|---|
| MySQL 8.4, Problems with Column Aliases | Không dùng alias trong WHERE; dùng được trong GROUP BY, ORDER BY, HAVING |
| MySQL 8.4, GROUP BY Handling | ONLY_FULL_GROUP_BY bật mặc định; nhóm theo khoá chính thì chọn được cột khác; ANY_VALUE() |
| MySQL 8.4, Window Function Frame Specification | Khung mặc định khi có ORDER BY gồm cả các dòng đồng hạng |
| MySQL 8.4, Working with NULL Values | So sánh với NULL ra NULL; NULL đứng đầu khi ASC, cuối khi DESC |
| MySQL 8.4, Restrictions on Subqueries | Không sửa bảng và đọc chính bảng đó trong subquery; ngoại lệ với bảng dẫn xuất được tính trước |
| MySQL 8.4, Unicode Character Sets | Ý nghĩa _ai, _ci trong utf8mb4_0900_ai_ci |
| MySQL 8.4, Logical Operators | || là OR, đã deprecated; PIPES_AS_CONCAT |
| MySQL 8.4, Arithmetic Operators | / trả số thập phân, DIV chia nguyên |
| PostgreSQL, Window Function Calls | Khung mặc định RANGE UNBOUNDED PRECEDING, các dòng đồng hạng; window function chỉ dùng trong SELECT và ORDER BY |
| PostgreSQL, Sorting Rows | NULL coi như lớn nhất: NULLS FIRST mặc định khi DESC; alias dùng được trong ORDER BY |
| PostgreSQL, Subquery Expressions | NOT IN ra NULL khi subquery có NULL và không có giá trị bằng |
Chưa đối chiếu với tài liệu, chỉ dựa vào chạy thử: PostgreSQL báo lỗi khi dùng alias trong HAVING; phép chia số nguyên của PostgreSQL; CONCAT của PostgreSQL bỏ qua NULL; DISTINCT ON, STRING_AGG, generate_series, FILTER; MySQL báo lỗi cú pháp với NULLS LAST (lỗi 1064); MySQL tìm 'binh' ra “Bình” với collation mặc định.
Code đã dùng để chạy thử
Script nằm trong thư mục lab/viet-sql/ của repo. Tự chạy lại bằng npm run lab:up rồi npm run lab -- viet-sql (cách chạy). Lần chạy lại ngày 2026-10-19 cho cùng kết quả với ngày 2026-10-15.
data.sql: dữ liệu mẫu
sql
-- Dữ liệu mẫu cho trang database/viet-sql. Chạy được trên cả MySQL và PostgreSQL.
DROP TABLE IF EXISTS employees;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;
DROP TABLE IF EXISTS logins;
CREATE TABLE employees (
id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, dept VARCHAR(20) NOT NULL,
salary INT NULL, manager_id INT NULL
);
INSERT INTO employees VALUES
(1,'An','eng',3000,NULL),(2,'Bình','eng',2500,1),(3,'Chi','eng',2500,1),(4,'Dũng','eng',2000,2),
(5,'Hà','sales',2200,1),(6,'Khoa','sales',1800,5),(7,'Lan','ops',1500,1),(8,'Minh','ops',NULL,7);
CREATE TABLE orders (
id INT PRIMARY KEY, customer_id INT NULL, amount INT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATE NOT NULL
);
INSERT INTO orders VALUES
(1,1,100,'paid','2026-10-01'),(2,2,50,'paid','2026-10-01'),(3,1,70,'cancelled','2026-10-02'),
(4,3,200,'paid','2026-10-03'),(5,NULL,30,'paid','2026-10-03'),(6,2,80,'paid','2026-10-04');
CREATE TABLE customers (id INT PRIMARY KEY, email VARCHAR(50) NOT NULL);
INSERT INTO customers VALUES (1,'an@x.vn'),(2,'binh@x.vn'),(3,'an@x.vn'),(4,'chi@x.vn'),(5,'binh@x.vn');
CREATE TABLE logins (user_id INT NOT NULL, login_date DATE NOT NULL, PRIMARY KEY (user_id, login_date));
INSERT INTO logins VALUES (1,'2026-10-01'),(1,'2026-10-02'),(1,'2026-10-03'),(1,'2026-10-05'),(1,'2026-10-06'),(2,'2026-10-02'),(2,'2026-10-04');common.sql: các câu chạy trên cả MySQL và PostgreSQL
sql
-- Các câu chạy trên cả MySQL và PostgreSQL. Lưu ý: client mysql không in gì khi kết quả rỗng,
-- còn psql in "(0 rows)"; đọc kết quả MySQL theo nhãn từng bước để khỏi nhầm bảng.
SELECT '--- Lương cao thứ hai, cách 1: MAX của lương nhỏ hơn MAX' AS step;
SELECT MAX(salary) AS second FROM employees WHERE salary < (SELECT MAX(salary) FROM employees);
SELECT '--- Cách 2: DENSE_RANK' AS step;
SELECT DISTINCT salary FROM (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS r
FROM employees WHERE salary IS NOT NULL
) t WHERE r = 2;
SELECT '--- Cách 3: ORDER BY DESC LIMIT 1 OFFSET 1 (khác nhau giữa hai database vì NULL)' AS step;
SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1;
SELECT DISTINCT salary FROM employees ORDER BY salary DESC;
SELECT '--- Không có lương thứ hai: cách 1 trả một dòng NULL' AS step;
SELECT MAX(salary) AS second FROM employees WHERE salary < (SELECT MAX(salary) FROM employees WHERE dept = 'none');
SELECT '--- Ba hàm xếp hạng trong phòng eng' AS step;
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn,
RANK() OVER (ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY salary DESC) AS drnk
FROM employees WHERE dept = 'eng' ORDER BY salary DESC, name;
SELECT '--- Top 2 mỗi phòng' AS step;
WITH ranked AS (
SELECT dept, name, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC, id) AS rn
FROM employees WHERE salary IS NOT NULL
)
SELECT dept, name, salary FROM ranked WHERE rn <= 2 ORDER BY dept, rn;
SELECT '--- Window function có PARTITION BY (dữ liệu của WindowFnSim)' AS step;
SELECT dept, id, salary,
RANK() OVER w AS rnk, DENSE_RANK() OVER w AS drnk,
SUM(salary) OVER w AS sum_range,
SUM(salary) OVER (PARTITION BY dept ORDER BY salary DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS sum_rows,
LAG(salary) OVER (PARTITION BY dept ORDER BY salary DESC, id) AS lag_s
FROM employees
WINDOW w AS (PARTITION BY dept ORDER BY salary DESC)
ORDER BY dept, rnk, id;
SELECT '--- Alias trong WHERE: lỗi ở cả hai database' AS step;
SELECT name, salary * 12 AS yearly FROM employees WHERE yearly > 30000;
SELECT '--- Alias trong HAVING: MySQL được, PostgreSQL lỗi' AS step;
SELECT dept, COUNT(*) AS n FROM employees GROUP BY dept HAVING n >= 2 ORDER BY n DESC, dept;
SELECT '--- Alias trong ORDER BY: được ở cả hai' AS step;
SELECT dept, COUNT(*) AS n FROM employees GROUP BY dept ORDER BY n DESC, dept;
SELECT '--- Bẫy NULL: COUNT, AVG, so sánh = NULL' AS step;
SELECT COUNT(*) AS all_rows, COUNT(salary) AS has_salary, AVG(salary) AS avg_salary FROM employees;
SELECT COUNT(*) AS eq_null FROM employees WHERE salary = NULL;
SELECT '--- Anti-join: NOT IN gặp NULL (trả rỗng)' AS step;
SELECT id FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
SELECT '--- Anti-join: NOT EXISTS' AS step;
SELECT c.id FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) ORDER BY c.id;
SELECT '--- Anti-join: LEFT JOIN … IS NULL' AS step;
SELECT c.id FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.id IS NULL ORDER BY c.id;
SELECT '--- NULL khi sắp xếp tăng dần' AS step;
SELECT name, salary FROM employees WHERE dept = 'ops' ORDER BY salary;
SELECT '--- GROUP BY cột không nhóm: lỗi ở cả hai' AS step;
SELECT dept, name, MAX(salary) FROM employees GROUP BY dept;
SELECT '--- GROUP BY theo khoá chính: chọn được cột khác; COUNT(*) khác COUNT(o.id)' AS step;
SELECT c.id, c.email, COUNT(*) AS cnt_star, COUNT(o.id) AS cnt_col
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id GROUP BY c.id ORDER BY c.id;
SELECT '--- Đếm có điều kiện' AS step;
SELECT created_at,
COUNT(*) AS total,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM orders GROUP BY created_at ORDER BY created_at;
SELECT '--- Tổng luỹ kế: RANGE mặc định so với thứ tự duy nhất' AS step;
SELECT created_at, amount,
SUM(amount) OVER (ORDER BY created_at, id) AS running_total,
SUM(amount) OVER (ORDER BY created_at) AS running_range
FROM orders WHERE status = 'paid' ORDER BY created_at, id;
SELECT '--- LAG so với dòng trước, không phải ngày trước' AS step;
WITH daily AS (
SELECT created_at AS d, SUM(amount) AS revenue FROM orders WHERE status = 'paid' GROUP BY created_at
)
SELECT d, revenue, LAG(revenue) OVER (ORDER BY d) AS prev, revenue - LAG(revenue) OVER (ORDER BY d) AS diff,
AVG(revenue) OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS avg2
FROM daily ORDER BY d;
SELECT '--- Điều kiện status đặt ở WHERE thay vì ON làm mất ngày 2/10' AS step;
SELECT days.d, SUM(o.amount) AS revenue
FROM (SELECT DATE '2026-10-01' AS d UNION ALL SELECT DATE '2026-10-02' UNION ALL SELECT DATE '2026-10-03') days
LEFT JOIN orders o ON o.created_at = days.d WHERE o.status = 'paid' GROUP BY days.d ORDER BY days.d;
SELECT '--- CTE đệ quy: nhân viên dưới quyền Bình' AS step;
WITH RECURSIVE tree AS (
SELECT id, name, manager_id, 0 AS depth FROM employees WHERE id = 2
UNION ALL
SELECT e.id, e.name, e.manager_id, t.depth + 1 FROM employees e JOIN tree t ON e.manager_id = t.id
)
SELECT id, name, depth FROM tree ORDER BY depth, id;
SELECT '--- Email trùng' AS step;
SELECT email, COUNT(*) AS n FROM customers GROUP BY email HAVING COUNT(*) > 1 ORDER BY email;
SELECT '--- Xoá trùng viết thẳng: MySQL lỗi 1093, PostgreSQL chạy được (rollback ngay)' AS step;
BEGIN;
DELETE FROM customers WHERE id NOT IN (SELECT MIN(id) FROM customers GROUP BY email);
ROLLBACK;
SELECT '--- Xoá trùng qua bảng dẫn xuất: chạy được cả hai (rollback ngay)' AS step;
BEGIN;
DELETE FROM customers WHERE id NOT IN (SELECT keep_id FROM (SELECT MIN(id) AS keep_id FROM customers GROUP BY email) AS k);
SELECT id, email FROM customers ORDER BY id;
ROLLBACK;
SELECT '--- 7 / 2 và CONCAT với NULL' AS step;
SELECT 7 / 2 AS division, CONCAT('a', NULL) AS concat_null, NULL + 1 AS plus_null;mysql.sql: các câu chỉ chạy trên MySQL
sql
-- Các câu chỉ chạy trên MySQL.
SELECT '--- Gaps & islands (DATE_SUB)' AS step;
WITH numbered AS (
SELECT user_id, login_date, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn FROM logins
)
SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS days
FROM numbered GROUP BY user_id, DATE_SUB(login_date, INTERVAL rn DAY) ORDER BY user_id, start_date;
SELECT '--- || là OR, không phải nối chuỗi' AS step;
SELECT 'a' || 'b' AS pipes;
SELECT '--- Collation mặc định: không phân biệt hoa thường và dấu' AS step;
SELECT @@collation_connection AS conn_collation, @@collation_database AS db_collation;
SELECT 'an' = 'AN' AS ci, 'an' = 'Ân' AS ai, 'an' = 'Ân' COLLATE utf8mb4_0900_as_cs AS as_cs;
SELECT name FROM employees WHERE name = 'binh';
SELECT '--- GROUP_CONCAT' AS step;
SELECT dept, GROUP_CONCAT(name ORDER BY name SEPARATOR ', ') AS names FROM employees GROUP BY dept ORDER BY dept;
SELECT '--- MySQL không có NULLS LAST (lỗi 1064)' AS step;
SELECT name FROM employees ORDER BY salary DESC NULLS LAST;
SELECT '--- Dãy ngày bằng CTE đệ quy' AS step;
WITH RECURSIVE days AS (
SELECT DATE '2026-10-01' AS d
UNION ALL
SELECT d + INTERVAL 1 DAY FROM days WHERE d < DATE '2026-10-04'
)
SELECT days.d, COALESCE(SUM(o.amount), 0) AS revenue
FROM days LEFT JOIN orders o ON o.created_at = days.d AND o.status = 'paid'
GROUP BY days.d ORDER BY days.d;pg.sql: các câu chỉ chạy trên PostgreSQL
sql
-- Các câu chỉ chạy trên PostgreSQL.
\echo '--- Gaps & islands (date - integer)'
WITH numbered AS (
SELECT user_id, login_date, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn FROM logins
)
SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS days
FROM numbered GROUP BY user_id, login_date - rn::int ORDER BY user_id, start_date;
\echo '--- || nối chuỗi; CONCAT bỏ qua NULL còn || thì không'
SELECT 'a' || 'b' AS pipes, concat('a', NULL) AS concat_fn, 'a' || NULL AS pipes_null;
\echo '--- So sánh chuỗi phân biệt hoa thường và dấu; ILIKE'
SELECT 'an' = 'AN' AS ci, 'an' = 'Ân' AS ai;
SELECT name FROM employees WHERE name = 'binh';
SELECT name FROM employees WHERE name ILIKE 'bình';
\echo '--- STRING_AGG'
SELECT dept, STRING_AGG(name, ', ' ORDER BY name) AS names FROM employees GROUP BY dept ORDER BY dept;
\echo '--- DISTINCT ON và NULLS LAST'
SELECT DISTINCT ON (dept) dept, name, salary FROM employees ORDER BY dept, salary DESC NULLS LAST;
\echo '--- FILTER'
SELECT created_at, COUNT(*) FILTER (WHERE status = 'paid') AS paid FROM orders GROUP BY created_at ORDER BY created_at;
\echo '--- Dãy ngày bằng generate_series'
SELECT days.d::date AS d, COALESCE(SUM(o.amount), 0) AS revenue
FROM generate_series(DATE '2026-10-01', DATE '2026-10-04', INTERVAL '1 day') AS days(d)
LEFT JOIN orders o ON o.created_at = days.d::date AND o.status = 'paid'
GROUP BY days.d ORDER BY days.d;