SQL & Database — Thực chiến PostgreSQL/SELECT — projection + alias, vì sao SELECT * chậm gấp 17 lần
~16 phútTruy vấn cơ bản lượt xem

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ừ khoSELECT
Lấy cả thùng (mọi món)SELECT *
Chỉ lấy 2 món cầnSELECT id, title
Tốn xe tải lớn hơnNetwork bandwidth — nhiều byte hơn trên dây
Tốn thời gian bốc dỡ tại kho phân phốiSerialization — database convert mọi cell sang binary protocol
Khó kiểm hàng khi danh sách thay đổiApp fragile khi schema thêm column mới
Nhận đồ trộn lẫn — phải phân loại sauDeserialize tất cả rồi app mới biết cần gì
Chuẩn bị danh sách trước khi vào khoExplicit column list = predictable cost
💡 Cách nhớ

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 *SELECT id, title đọc cùng lượng disk I/O để tìm row (cùng index lookup trên id).
  • 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;
Pitfall — alias trong WHERE không hoạt động

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:

Pitfall — SELECT * rò rỉ dữ liệu nhạy cảm

SELECT * FROM users trả về cả password_hash, reset_token, payment_info — dù API endpoint chỉ cần idemail. 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:

ContextSELECT * có ổn không?Lý do
psql exploration (LIMIT 5)ỔnAd-hoc, không phải app code
Migration script chuyển full tableỔnCần tất cả column để copy
Backup scriptỔnExport toàn bộ dữ liệu
View definitionThường ổnView là alias, không phải app code
Production app code (REST API, service)Không ổnSchema fragile + performance + security
ORM findMany() không có selectKhông ổnTươ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

📚 Deep Dive — SELECT execution

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 * vs SELECT id, title chê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, SQLAlchemy query(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

Tự kiểm tra
Q1
Vì 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ề.

Q2
Phâ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 tasks sang 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 tasks trả 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_data vào users: 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.
Q3
Bạ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;
Q4
Prisma `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ảng usersbio TEXT hay avatar_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ế.

Q5
Schema 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_at bị expose ra API — có thể không mong muốn (sensitive timing data).
  • Nếu last_login_at là 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?