SELECT — projection + alias, vì sao SELECT * chậm gấp 17 lần
Projection ở executor layer + network/serialization overhead. Cú pháp column list, expression, alias. Vì sao Prisma select() và Spring @Query đều chọn explicit column.
Cùng query trên bảng tasks 100k row của TaskFlow — SELECT * FROM tasks WHERE id=42 mất 200ms. SELECT id, title FROM tasks WHERE id=42 mất 12ms. Cùng một row được trả về, khác ở chỗ query chọn bao nhiêu column — chênh nhau 17 lần. Nếu disk I/O không phải nguyên nhân (cả hai query đều tìm đúng một row bằng primary key index), thì vì sao?
Bài này giải thích cơ chế projection diễn ra ở executor layer, cost thực sự nằm ở network và serialization, cú pháp đầy đủ của SELECT (column list, expression, alias), và vì sao mọi ORM — Prisma, Spring Data JPA, SQLAlchemy — đều đặt explicit column là best practice.
1. Analogy — Lấy hàng từ kho
Hình dung kho hàng với mỗi sản phẩm được đóng trong thùng lớn gồm 50 ngăn. Bạn chỉ cần lấy 2 món: nhãn sản phẩm và giá. Có hai cách:
Cách A: Lấy cả thùng ra xe tải, chở về kho phân phối, mở ra, lấy 2 món cần, bỏ 48 món còn lại.
Cách B: Mở thùng tại chỗ, lấy đúng 2 món, để lại 48 món trong kho.
Cách A tốn xe tải to hơn, tốn thời gian bốc dỡ, tốn không gian tạm chứa — dù kết quả cuối cùng giống nhau.
| Lấy hàng từ kho | SELECT |
|---|---|
| Lấy cả thùng (mọi món) | SELECT * |
| Chỉ lấy 2 món cần | SELECT id, title |
| Tốn xe tải lớn hơn | Network bandwidth — nhiều byte hơn trên dây |
| Tốn thời gian bốc dỡ tại kho phân phối | Serialization — database convert mọi cell sang binary protocol |
| Khó kiểm hàng khi danh sách thay đổi | App fragile khi schema thêm column mới |
| Nhận đồ trộn lẫn — phải phân loại sau | Deserialize tất cả rồi app mới biết cần gì |
| Chuẩn bị danh sách trước khi vào kho | Explicit column list = predictable cost |
SELECT * = lấy cả thùng. SELECT id, title = lấy đúng 2 món. Cost không nằm ở việc tìm thùng (disk I/O) — cost nằm ở chở thùng về (network) và phân loại lại (serialization).
2. Cơ chế projection — diễn ra ở đâu
PostgreSQL executor xử lý query theo pipeline. Với SELECT id, title FROM tasks WHERE id=42:
flowchart LR A["Heap scan / Index scan\n(đọc row từ disk)"] --> B["WHERE filter\n(id = 42)"] B --> C["Project\n(chỉ giữ id, title)"] C --> D["Serialize → wire\n(binary protocol gửi về client)"]
Projection diễn ra ở bước 3 — sau khi scan và filter xong. Vì vậy:
SELECT *vàSELECT id, titleđọc cùng lượng disk I/O để tìm row (cùng index lookup trênid).- Sự khác biệt nằm ở bước 3 và 4: project giữ bao nhiêu column → serialize bao nhiêu byte → gửi bao nhiêu qua network.
Với bảng tasks có 12 column (bao gồm description TEXT có thể chứa vài KB), SELECT * có thể trả về row nặng hơn SELECT id, title hàng chục lần. Multiply với 100k row trong một batch query — chênh lệch rõ ràng.
Xem số liệu thực tế qua EXPLAIN ANALYZE:
-- Xem "width" -- so byte moi row duoc tra ve
EXPLAIN ANALYZE SELECT * FROM tasks WHERE id = 42;
-- Output: ... (width=312) ...
EXPLAIN ANALYZE SELECT id, title FROM tasks WHERE id = 42;
-- Output: ... (width=28) ...
Cột width trong output EXPLAIN là số byte trung bình mỗi row. Bài Module 7 của khoá này sẽ đi sâu vào đọc EXPLAIN plan.
3. Cú pháp đầy đủ
-- 1. Column list explicit (best practice cho app code)
SELECT id, title, status FROM tasks;
-- 2. Toan bo column -- dung cho exploration, khong dung trong app code
SELECT * FROM tasks;
-- 3. Expression trong SELECT -- tinh toan gia tri tu column
SELECT
id,
UPPER(title) AS title_upper,
EXTRACT(EPOCH FROM (now() - created_at))::int AS age_seconds,
(status = 'done')::int AS is_done_int
FROM tasks;
-- 4. Alias -- doi ten column trong output
SELECT
id AS task_id,
title AS "Tieu de" -- quote khi alias co space hoac trung keyword
FROM tasks;
-- 5. SELECT khong tu table -- constant query, ham utility
SELECT 1 + 1 AS sum, now() AS current_time, version() AS pg_version;
Column trong SELECT list có thể là:
- Tên column trực tiếp:
id,title - Expression:
UPPER(title),salary * 12,(status = 'done')::int - Constant:
1,'hello',true - Function call:
now(),version(),COALESCE(assignee_id, 0)
4. Alias — quy tắc và pitfall
Alias với AS:
SELECT
id AS task_id, -- doi ten column trong output
title AS task_title,
status AS current_status
FROM tasks;
AS là optional — SELECT id task_id cũng hoạt động — nhưng nên viết explicit để code dễ đọc.
Quote alias khi cần:
SELECT
title AS "Tieu de cong viec", -- co space -> phai quote
status AS "order" -- 'order' la keyword -> nen quote
FROM tasks;
Alias không được quote: PostgreSQL tự lowercase (TaskId thành taskid). Alias được quote: preserve case chính xác.
Alias không dùng được trong WHERE cùng query:
-- SAI: alias annual_salary chua ton tai khi WHERE duoc tinh
SELECT salary * 12 AS annual_salary
FROM employees
WHERE annual_salary > 100000;
-- ERROR: column "annual_salary" does not exist
Lý do: thứ tự xử lý của SQL engine là FROM → WHERE → SELECT → ORDER BY. Alias trong SELECT chỉ tồn tại sau khi SELECT clause được evaluate — tức là sau khi WHERE đã chạy xong. Hai cách fix:
-- Fix 1: lap lai expression trong WHERE
SELECT salary * 12 AS annual_salary
FROM employees
WHERE salary * 12 > 100000;
-- Fix 2: wrap subquery (hoac CTE -- Module 8 cua khoa nay)
SELECT *
FROM (
SELECT *, salary * 12 AS annual_salary FROM employees
) sub
WHERE annual_salary > 100000;
WHERE annual_salary > 100000 sau SELECT salary * 12 AS annual_salary luôn báo lỗi "column does not exist". SQL engine chạy WHERE trước SELECT — alias chưa tồn tại tại thời điểm WHERE được evaluate. Fix: lặp lại expression trong WHERE, hoặc dùng subquery/CTE.
5. Vì sao SELECT * có hại trong production
Bốn lý do thực tế:
1. Schema fragile:
-- Schema hom nay: users(id, email, name, created_at)
SELECT * FROM users; -- tra ve 4 column
-- Sau khi DBA them column: users(id, email, name, created_at, last_login_at, is_active, ...)
SELECT * FROM users; -- dot nhien tra ve 7 column
-- App code parse theo thu tu column truoc -> parse sai hoac broken
Explicit column list không bị ảnh hưởng khi schema thêm column — chỉ trả về đúng những gì đã khai báo.
2. N+1 amplification:
Với lazy load ORM + SELECT *, mỗi entity fetch 50 column thay vì 5 column cần dùng. Multiply với 1000 entity trong một request — memory và network sử dụng gấp 10 lần không cần thiết.
3. Covering index miss:
Khi tất cả column cần thiết đã có trong index (covering index), PostgreSQL có thể trả về kết quả từ index mà không cần đọc heap. SELECT * phá vỡ optimization này vì index không bao giờ chứa mọi column — database phải hit heap mỗi row. Module 5 của khoá này sẽ phân tích covering index chi tiết.
4. Sensitive data leak:
SELECT * FROM users trả về cả password_hash, reset_token, payment_info — dù API endpoint chỉ cần id và email. Nếu code serialize toàn bộ query result thành JSON response, sensitive column bị expose mà không ai để ý. Explicit column list là lớp bảo vệ đầu tiên, đơn giản nhất.
6. Khi nào SELECT * vẫn ổn
SELECT * không phải lúc nào cũng sai — context quyết định:
| Context | SELECT * có ổn không? | Lý do |
|---|---|---|
psql exploration (LIMIT 5) | Ổn | Ad-hoc, không phải app code |
| Migration script chuyển full table | Ổn | Cần tất cả column để copy |
| Backup script | Ổn | Export toàn bộ dữ liệu |
| View definition | Thường ổn | View là alias, không phải app code |
| Production app code (REST API, service) | Không ổn | Schema fragile + performance + security |
| ORM findMany() không có select | Không ổn | Tương đương SELECT * |
Pattern ngắn gọn: production app code = explicit column. CLI/migration/exploration = SELECT * ổn.
7. Applied — ORM convention
Lý do explicit column là best practice lan rộng sang mọi ORM:
Prisma:
// KHONG tot -- tuong duong SELECT *
const tasks = await prisma.task.findMany();
// Tot -- explicit column, type-safe, generated types match
const tasks = await prisma.task.findMany({
select: {
id: true,
title: true,
status: true,
},
});
// tasks co kieu: { id: bigint; title: string; status: string }[]
Prisma select generate type narrowed — TypeScript biết chính xác những field nào có trong kết quả. Thêm select: { passwordHash: true } thì type tự xuất hiện — không thêm thì không có trong type, không thể truy cập nhầm.
Spring Data JPA:
// JPQL explicit column -- DTO projection
@Query("SELECT u.id, u.email FROM User u WHERE u.isActive = true")
List<Object[]> findActiveUserProjection();
// Hoac dung DTO projection interface (Spring Data 2.x+)
public interface UserSummary {
Long getId();
String getEmail();
}
List<UserSummary> findByIsActive(boolean isActive);
node-postgres (stack trong project TaskFlow):
// Explicit column -- an toan va ro rang ve intent
const { rows } = await pool.query(
'SELECT id, title, status FROM tasks WHERE assignee_id = $1',
[userId]
);
SQLAlchemy:
# Explicit column projection
results = session.query(User.id, User.email).filter(User.is_active == True).all()
# Khong: session.query(User).all() -- equivalent SELECT *
Cross-link: khoá Spring Data JPA của OLHub — Module 4 phân tích DTO projection pattern và interface-based projection theo cùng tư duy explicit column này.
8. Deep Dive — SELECT execution
- PostgreSQL Documentation Ch.7.2 "Table Expressions" — cú pháp SELECT đầy đủ và projection rules theo chuẩn PG. Đọc 7.2 cho SELECT list, 7.3 cho SELECT output expressions.
- Use The Index, Luke — "Index-Only Scan" — giải thích tại sao
SELECT *phá vỡ covering index optimization và index-only scan. Đọc khi học Module 5 của khoá này để thấy connection rõ hơn. - PostgreSQL Documentation — Frontend/Backend Protocol — chi tiết format binary wire protocol, cách mỗi column được serialize. Chỉ cần đọc khi muốn hiểu sâu tại sao số byte per row ảnh hưởng đến latency — không bắt buộc cho phần lớn ứng dụng.
Ghi chú: Ch.7.2 cho cú pháp chuẩn; Use The Index Luke cho perf myth-busting khi học index; wire protocol chỉ khi muốn deep optimization.
9. Tóm tắt
- Projection chạy ở executor layer — sau scan và WHERE filter, trước serialize/network.
- Cost chính nằm ở network và serialization, không phải disk I/O — lý do
SELECT *vsSELECT id, titlechênh nhiều dù cùng row. - Cú pháp SELECT: column list, expression (UPPER, EXTRACT, cast), alias với
AS(quote khi có space hoặc reserved word). - Alias không dùng được trong WHERE cùng query — alias chưa tồn tại khi WHERE được evaluate. Fix: lặp lại expression hoặc dùng subquery/CTE.
SELECT *có 4 vấn đề production: schema fragile, N+1 amplification, covering index miss, sensitive data leak.SELECT *ổn trong: psql exploration, migration script, backup, view definition — không phải app code.- Mọi ORM đều reflect tư duy này: Prisma
select:{}, Spring DTO projection, SQLAlchemyquery(Model.col). - Forward links: Module 5 (covering index) + Module 7 (EXPLAIN ANALYZE) + Module 8 (subquery/CTE) của khoá này.
10. Tự kiểm tra
Q1Vì sao SELECT * chậm hơn SELECT id, title dù cả hai đều tìm đúng một row bằng index lookup — disk I/O không tăng nhiều?▸
Projection diễn ra sau scan và WHERE filter — cả hai query đọc cùng lượng disk để tìm row. Sự chênh lệch nằm ở hai bước tiếp theo: serialize (database phải convert mọi column sang binary wire protocol) và network (gửi nhiều byte hơn về client). Với bảng có description TEXT vài KB mỗi row, SELECT * có thể gửi hàng chục lần nhiều byte hơn SELECT id, title. Nhân với nhiều row trong batch query, chênh lệch tích lũy thành latency rõ ràng. EXPLAIN ANALYZE hiển thị số này qua cột width — số byte trung bình mỗi row được trả về.
Q2Phân biệt 4 trường hợp SELECT * ổn và ít nhất 4 trường hợp không ổn. Cho ví dụ cụ thể với TaskFlow.▸
Ổn:
- Ad-hoc exploration trong psql:
SELECT * FROM tasks LIMIT 5để xem cấu trúc data — không phải app code. - Migration script: copy toàn bộ bảng
taskssang schema mới — cần tất cả column. - Backup script: dump full table.
- View definition:
CREATE VIEW active_tasks AS SELECT * FROM tasks WHERE status != 'archived'— view là alias, không phải endpoint trực tiếp.
Không ổn:
- REST API endpoint
GET /tasks:SELECT * FROM taskstrả về cảupdated_at, metadata nội bộ — không cần cho client. - ORM
findMany()không cóselect: load 50 column thay vì 5 — N+1 amplification. - App code sau khi DBA thêm column
salary_datavàousers:SELECT *đột nhiên trả về sensitive column. - Dashboard query chạy thường xuyên: không thể dùng covering index — phải hit heap mỗi row.
Q3Bạn viết `SELECT salary * 12 AS annual_salary FROM employees WHERE annual_salary > 100000` — error. Vì sao? Hai cách fix?▸
SQL engine xử lý theo thứ tự: FROM → WHERE → SELECT → ORDER BY. Alias annual_salary được định nghĩa trong SELECT clause — nhưng WHERE chạy trước SELECT. Tại thời điểm WHERE được evaluate, alias chưa tồn tại nên PostgreSQL báo "column annual_salary does not exist".
Fix 1 — lặp lại expression trong WHERE:
SELECT salary * 12 AS annual_salary
FROM employees
WHERE salary * 12 > 100000;Fix 2 — wrap subquery (hoặc CTE — Module 8 của khoá này):
SELECT * FROM (
SELECT *, salary * 12 AS annual_salary
FROM employees
) sub
WHERE annual_salary > 100000;Q4Prisma `select: { id: true, email: true }` thay vì `findMany()` tiết kiệm gì cụ thể? Đo bằng cách nào trong dự án thực?▸
Ba thứ cụ thể được tiết kiệm:
- Network bytes: chỉ
id+emailđược gửi qua wire thay vì tất cả column. Với bảnguserscóbio TEXThayavatar_url, mỗi row nhỏ hơn đáng kể. - Deserialization time: driver chỉ parse và allocate object cho 2 field — không phải toàn bộ record.
- Memory: result set nhỏ hơn trong heap của Node.js process — ít áp lực GC hơn khi fetch nhiều row.
Đo trong project thực: dùng Prisma với log: ['query'] và xem query được generate — so sánh SELECT "id", "email" vs SELECT "id", "email", "name", "bio", .... Dùng EXPLAIN ANALYZE trong psql để xem width (bytes per row). Đo latency end-to-end với console.time hoặc tracing tool (OpenTelemetry) ở route handler để thấy difference trong thực tế.
Q5Schema thay đổi: thêm column `last_login_at` vào bảng `users`. App code cũ `SELECT * FROM users` và `SELECT id, email FROM users` — cái nào bị ảnh hưởng và bị ảnh hưởng thế nào?▸
SELECT * FROM users bị ảnh hưởng theo nhiều cách:
- Query đột nhiên trả về thêm column
last_login_at— nếu app parse result theo vị trí column (index-based) thay vì tên, toàn bộ mapping bị lệch. - Nếu app serialize toàn bộ result thành JSON response,
last_login_atbị expose ra API — có thể không mong muốn (sensitive timing data). - Nếu
last_login_atlà nullable và app code expect specific shape, TypeScript/runtime type check có thể fail.
SELECT id, email FROM users không bị ảnh hưởng: query chỉ fetch đúng 2 column đã khai báo. Column mới được thêm vào schema hoàn toàn transparent với query này. App code tiếp tục nhận { id, email } như trước — không có gì thay đổi. Đây là lý do explicit column list là lựa chọn defensive mặc định cho app code production.
Bài tiếp theo: WHERE + NULL three-valued logic
Bài này có giúp bạn hiểu bản chất không?