Skip to content

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 ​

  1. 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ó NULL không? Không có kết quả thì trả về NULL hay 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.
  2. 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.
  3. 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.
  4. 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
idnamedeptsalarymanager_id
1Aneng3000NULL
2Bìnheng25001
3Chieng25001
4Dũngeng20002
5Hàsales22001
6Khoasales18005
7Lanops15001
8MinhopsNULL7
  • 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.vn và binh@x.vn mỗ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 SELECT trong WHERE, vì lúc chạy WHERE thì cột đó chưa được tính. Cả hai database đều báo lỗi:

    sql
    SELECT 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 exist
  • Alias dùng được trong ORDER BY ở cả hai database, vì ORDER BY chạy sau SELECT.

  • Alias trong HAVING thì khác nhau: MySQL cho phép (HAVING n >= 2), còn PostgreSQL báo lỗi. Viết HAVING COUNT(*) >= 2 thì chạy được ở cả hai.

  • Không dùng window function trong WHERE, vì window function được tính ở bước SELECT. 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à PostgreSQL

Cá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_attotalpaidcancelled
2026-10-01220
2026-10-02101
2026-10-03220
2026-10-04110

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;
deptnamesalaryv
engAn3.0001
engBình2.5002
engChi2.5003
engDũng2.0004
opsLan1.5001
opsMinhNULL2
salesHà2.2001
salesKhoa1.8002

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';
namesalaryrnrnkdrnk
An3000111
Bình2500222
Chi2500322
Dũng2000443
  • 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_atamountrunning_totalrunning_range
2026-10-01100100150
2026-10-0150150150
2026-10-03200350380
2026-10-0330380380
2026-10-0480460460

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;
drevenueprevdiff
2026-10-01150NULLNULL
2026-10-0323015080
2026-10-0480230-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 NULL lớn hơn mọi giá trị, nên khi sắp giảm dần thì NULL đứng đầu. Dãy thành NULL, 3000, 2500…, và bỏ qua 1 dòng thì ra 3000.
  • MySQL xếp NULL cuố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;
deptnamesalary
engAn3000
engBình2500
opsLan1500
salesHà2200
salesKhoa1800

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.vn

Lớ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_idlogin_daternlogin_date − rn ngày
110-01109-30
110-02209-30
110-03309-30
110-05410-01
110-06510-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_idstart_dateend_datedays
12026-10-012026-10-033
12026-10-052026-10-062
22026-10-022026-10-021
22026-10-042026-10-041

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 1

Phầ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 trong ON, 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 JOIN thành INNER JOIN.
  • COALESCE đổi NULL thành 0.

Bẫy với NULL ​

Biểu thứcKết quảGiải thích
salary = NULLKhông dòng nào khớpSo sánh gì với NULL cũng ra NULL. Phải dùng IS NULL
COUNT(*) và COUNT(salary)8 và 7COUNT(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ớpXem mục anti-join
ORDER BY salary DESCMySQL: NULL cuối; PostgreSQL: NULL đầuXem bài lương cao thứ hai
NULL + 1NULLPhé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 8PostgreSQLKiểm chứng
7 / 23.50003 (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'ĐúngSaiChạy thử
'an' = 'Ân'ĐúngSaiChạ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ườngMặc địnhILIKE, 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 LASTChạy thử và tài liệu
Alias trong HAVINGĐượcLỗiChạy thử
Gộp chuỗi trong nhómGROUP_CONCAT(name ORDER BY name SEPARATOR ', ')STRING_AGG(name, ', ' ORDER BY name)Chạy thử
Một dòng mỗi nhómWindow functionThêm DISTINCT ONChạy thử
Sinh dãy ngàyCTE đệ quygenerate_seriesChạy thử
Cộng trừ ngàyDATE_SUB(d, INTERVAL n DAY), d + INTERVAL 1 DAYd - n (với n là số nguyên), d + INTERVAL '1 day'Chạy thử
UpsertON DUPLICATE KEY UPDATEON CONFLICTChố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ớ

  1. Hỏi lại về giá trị trùng, NULL và kết quả rỗng trước khi viết; viết từng bước bằng CTE.
  2. 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 trong WHERE.
  3. Anti-join dùng NOT EXISTS; NOT IN trả rỗng khi subquery có NULL.
  4. ROW_NUMBER luôn khác nhau, RANK nhảy hạng, DENSE_RANK không nhảy hạng. Lọc theo window function thì bọc trong CTE.
  5. SUM() OVER (ORDER BY …) mặc định dùng khung RANGE, nên các dòng đồng hạng nhận cùng tổng. LAG so với dòng trước, không phải ngày trước.
  6. MySQL và PostgreSQL khác nhau ở vị trí NULL khi 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ồnDùng cho phần
MySQL 8.4, Problems with Column AliasesKhông dùng alias trong WHERE; dùng được trong GROUP BY, ORDER BY, HAVING
MySQL 8.4, GROUP BY HandlingONLY_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 SpecificationKhung mặc định khi có ORDER BY gồm cả các dòng đồng hạng
MySQL 8.4, Working with NULL ValuesSo sánh với NULL ra NULL; NULL đứng đầu khi ASC, cuối khi DESC
MySQL 8.4, Restrictions on SubqueriesKhô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 CallsKhung 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 RowsNULL coi như lớn nhất: NULLS FIRST mặc định khi DESC; alias dùng được trong ORDER BY
PostgreSQL, Subquery ExpressionsNOT 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;