Skip to content
DevRecall
← Roadmap
13

SQL & PostgreSQL

drafted

Indexes, EXPLAIN, transactions & isolation, MVCC/VACUUM, locking, pooling, replication, zero-downtime migrations — kèm so sánh với SQL Server.

0/68 rated · mastery 0%

Bài học

13 bài · ~277 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~23 phút· 5 câuverify

    Postgres lưu dữ liệu thế nào: page, heap, tuple, WAL

    Từ file trên disk tới 8 KB page, heap tuple, TOAST và WAL — nền tảng để hiểu index, MVCC và ACID; và mental model khác SQL Server ở đâu.

  2. 02~19 phút· 3 câuverify

    Một câu SQL được thực thi ra sao

    Logical query order (FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT), join algorithms, aggregation, window functions và CTE.

  3. 03~24 phút· 7 câuverify

    B-tree index: composite, covering, partial, expression

    Index giúp gì và tốn gì; cách chọn thứ tự cột, Index Only Scan, partial/expression index và query sargable.

  4. 04~17 phút· 4 câuverify

    GIN, GiST, BRIN và JSONB

    Khi B-tree không đủ: inverted index cho jsonb/array/full-text, GiST cho range/geo, BRIN cho data tăng dần; JSON vs JSONB và cách index.

  5. 05~24 phút· 7 câuverify

    Query planner & đọc EXPLAIN

    Planner ước lượng cost bằng statistics; đọc EXPLAIN (ANALYZE, BUFFERS), vì sao bỏ qua index, estimate sai, generic vs custom plan.

  6. 06~20 phút· 6 câuverify

    Transaction & isolation levels

    ACID trong thực tế, bốn isolation level, anomaly của từng level trong Postgres (lost update, write skew) và khác SQL Server/RCSI.

  7. 07~20 phút· 6 câuverify

    MVCC, VACUUM, HOT và xid wraparound

    Mỗi UPDATE tạo tuple version mới: snapshot, dead tuple, autovacuum, bloat, HOT update và vì sao wraparound có thể dừng database.

  8. 08~20 phút· 8 câuverify

    Locking & concurrency control

    Row lock (FOR UPDATE/SHARE…), table lock level, deadlock, advisory lock, SKIP LOCKED queue, optimistic locking và upsert an toàn.

  9. 09~19 phút· 5 câuverify

    Connections & PgBouncer

    Process-per-connection, vì sao cần pooler, session/transaction/statement mode, prepared statements và sizing pool khi scale pods.

  10. 10~20 phút· 6 câuverify

    Replication, partitioning & scaling

    Streaming vs logical replication, replica lag và read-your-writes, hot standby conflict, declarative partitioning và cách chọn hướng scale.

  11. 11~16 phút· 8 câuverify

    Zero-downtime schema migrations

    Lock queue, lock_timeout, ADD COLUMN instant vs rewrite, CREATE INDEX CONCURRENTLY, NOT VALID + VALIDATE, backfill theo batch và expand–contract.

  12. 12~29 phút· 5 câuverify

    Data modeling: keys, types, time zones, multi-tenant schema

    Chọn primary key (identity, UUIDv4/v7), timestamp vs timestamptz, normalization, soft delete và schema/index cho bảng multi-tenant.

  13. 13~27 phút· 6 câuverify

    Truy cập dữ liệu từ app: N+1, pagination, bulk load

    N+1 và cách phát hiện, OFFSET vs keyset pagination, COPY cho bulk load và các pattern Node/TypeScript an toàn.

Tóm tắt & cheat sheet của track

TL;DR

  • PostgreSQL là MVCC thuần: mỗi UPDATE tạo tuple version mới, bản cũ thành dead tuple chờ VACUUM dọn. Reader không block writer, writer không block reader — khác SQL Server mặc định (lock-based READ COMMITTED, trừ khi bật RCSI).
  • Postgres không có clustered index: mọi table là heap, mọi index (kể cả PK) là secondary index trỏ tới ctid. CLUSTER chỉ sắp xếp lại một lần, không duy trì.
  • Senior interview xoáy vào: đọc EXPLAIN (ANALYZE, BUFFERS), vì sao planner bỏ qua index, anomaly ở từng isolation level, bloat / long transaction / xid wraparound, lock queue khi migrate, PgBouncer transaction mode, replica lag.
  • Zero-downtime migration = expand → migrate (backfill theo batch) → contract, cộng lock_timeout, CREATE INDEX CONCURRENTLY, constraint NOT VALID + VALIDATE.

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn
B-tree indexCây cân bằng, sắp xếp theo key; hỗ trợ =, <, >, BETWEEN, ORDER BY, prefix LIKE 'abc%' (cần C collation hoặc text_pattern_ops)CREATE INDEX ON orders (tenant_id, created_at DESC)
Composite indexThứ tự cột quan trọng: equality trước, range/sort sau(tenant_id, status, created_at) phục vụ WHERE tenant_id=? AND status=? ORDER BY created_at
Covering indexINCLUDE cột payload để có Index Only Scan (PG 11+)CREATE INDEX ON orders (customer_id) INCLUDE (total)
Partial indexIndex chỉ một tập con rowCREATE INDEX ON jobs (run_at) WHERE status = 'pending'
Expression indexIndex trên biểu thức; query phải dùng đúng biểu thứcCREATE INDEX ON users (lower(email))
GIN / GiST / BRINGIN: inverted (jsonb, array, full-text) · GiST: range/geo/nearest · BRIN: block range tóm tắt cho data tăng dầnCREATE INDEX ON events USING brin (created_at)
MVCCMỗi row version có xmin/xmax; transaction nhìn theo snapshotSELECT xmin, xmax, ctid FROM t
VACUUMDọn dead tuple, cập nhật visibility map, freeze xid cũVACUUM (VERBOSE, ANALYZE) orders
HOT updateUpdate không đụng cột indexed + còn chỗ trên page → không cần sửa indexfillfactor = 90 cho table update nhiều
Row lockLưu trong tuple header, không lock escalation như SQL ServerSELECT ... FOR UPDATE SKIP LOCKED
Advisory lockLock theo số do app định nghĩaSELECT pg_try_advisory_xact_lock(hashtext('job:daily'))
PgBouncerPooler nhẹ; mode session / transaction / statementTransaction mode phá SET, session advisory lock, LISTEN
Streaming replicationReplica apply WAL; async mặc định → replica lagpg_stat_replication.replay_lag
Declarative partitioningChia table theo range/list/hash; partition pruningPARTITION BY RANGE (created_at)
Keyset paginationPhân trang bằng WHERE (k) > (last_k) thay vì OFFSETWHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 50

Cơ chế hoạt động

Vòng đời một UPDATE dưới MVCC

SQL Server khác: update thường in-place trên page của clustered index; chỉ khi bật RCSI/SNAPSHOT thì bản cũ được copy vào version store (tempdb, hoặc Persistent Version Store khi bật ADR — SQL Server 2019+).

Zero-downtime column change (expand / contract)

Mỗi DDL chạy với SET lock_timeout = '3s' + retry: nếu ALTER TABLE phải chờ ACCESS EXCLUSIVE sau một transaction dài, mọi query mới sẽ xếp hàng sau nó → outage dù DDL "chỉ mất 1ms".

Đường đi của query qua pool & replica

Trade-offs

Chủ đềLựa chọn ALựa chọn BKhi nào chọn gì
Concurrency controlPostgres MVCC (heap + VACUUM)SQL Server lock-based RC (mặc định on-prem) / RCSIMVCC: đọc không block; trả giá bằng bloat & vacuum. Lock-based: không bloat nhưng reader/writer block nhau, dễ deadlock
PaginationOFFSETKeysetOFFSET cho admin nhỏ, cần nhảy trang; keyset cho feed/API lớn
PKbigint identityUUID (v4 / v7)bigint nhỏ & nhanh; UUID khi sinh ở client / merge nhiều nguồn; v4 random làm B-tree phân mảnh, v7 time-ordered tốt hơn
IsolationRead Committed + lock tường minhSerializable (SSI) + retryRC cho đa số OLTP; SSI khi invariant phức tạp (write skew), chấp nhận retry
Pooler modeSessionTransactionTransaction scale tốt hơn nhiều nhưng mất session state
ReplicationPhysical streamingLogicalPhysical: HA/read replica cả cluster; Logical: chọn table, khác major version, CDC
Chia dữ liệuPartitioning (1 node)Sharding (nhiều node, vd Citus)Partition cho retention/pruning; shard khi 1 node không đủ write/storage
JSONCột quan hệjsonbjsonb cho attribute thưa/động; field hay filter/join nên là cột thật

Pitfalls

  • EXPLAIN ANALYZE thực thi thật câu lệnh — với UPDATE/DELETE hãy bọc trong BEGIN; ... ROLLBACK;.
  • Hàm/cast trên cột (WHERE date(created_at) = ..., so sánh varchar với tham số sai kiểu) làm index vô dụng.
  • Transaction mở lâu (kể cả idle in transaction) giữ xmin → VACUUM không dọn được gì → bloat toàn DB.
  • CREATE INDEX thường lock ghi cả table; dùng CONCURRENTLY — nhưng không chạy được trong transaction block (nhiều migration tool mặc định bọc transaction!).
  • Thêm cột DEFAULT now()/gen_random_uuid() (volatile) vẫn rewrite table; default hằng số thì instant từ PG 11.
  • Đọc từ replica ngay sau khi ghi → dữ liệu cũ.
  • timestamp without time zone cho sự kiện thực tế → bug khi đổi DST / đa múi giờ; dùng timestamptz.
  • Tin rằng NOLOCK-style dirty read tồn tại trong Postgres: READ UNCOMMITTED ở Postgres hoạt động như READ COMMITTED.

Cheat sheet

  • Chẩn đoán query: EXPLAIN (ANALYZE, BUFFERS) → so estimated vs actual rows, tìm node tốn thời gian nhất, shared read (disk) vs shared hit (cache).
  • Top query: pg_stat_statements (total_exec_time, calls, mean). Lock: pg_locks + pg_blocking_pids(pid). Activity: pg_stat_activity (state, xact_start, backend_xmin).
  • Bloat/vacuum: pg_stat_user_tables (n_dead_tup, last_autovacuum). Wraparound: age(datfrozenxid) trong pg_database.
  • Isolation Postgres: RU≈RC (mặc định) · RR = snapshot isolation (không phantom, có write skew) · Serializable = SSI (lỗi 40001 → retry).
  • Lock hàng: FOR UPDATE > FOR NO KEY UPDATE > FOR SHARE > FOR KEY SHARE; queue: FOR UPDATE SKIP LOCKED.
  • Migration an toàn: lock_timeout, ADD COLUMN nullable, CREATE INDEX CONCURRENTLY, ADD CONSTRAINT ... NOT VALID → VALIDATE CONSTRAINT, backfill batch, SET NOT NULL sau khi có CHECK hợp lệ (PG 12+ bỏ qua full scan).
  • Timeouts nên có: statement_timeout, lock_timeout, idle_in_transaction_session_timeout, (PG 17+) transaction_timeout.
  • SQL Server ↔ Postgres: TOP ↔ LIMIT · IDENTITY ↔ GENERATED ... AS IDENTITY · filtered index ↔ partial index · datetimeoffset ↔ timestamptz (Postgres không lưu offset, chỉ lưu instant UTC) · MERGE ↔ INSERT ... ON CONFLICT (Postgres có MERGE từ PG 15) · WITH (NOLOCK) ↔ không có.

Personal notes (Notion)

Ghi chú cá nhân — có thể sai hoặc lỗi thời, dùng để tham khảo.

Interview Questions

Showing 68 / 68

BEGIN;
EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders SET status = 'archived' WHERE created_at < now() - interval '2 years';
ROLLBACK;
SELECT *
FROM (
  SELECT o.*, row_number() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
  FROM orders o
  WHERE tenant_id = $1
) t
WHERE rn = 1;
-- Postgres-only alternative:
SELECT DISTINCT ON (customer_id) * FROM orders
WHERE tenant_id = $1 ORDER BY customer_id, created_at DESC;
CREATE INDEX orders_customer_cover ON orders (tenant_id, customer_id) INCLUDE (total, status);
EXPLAIN (ANALYZE) SELECT total, status FROM orders WHERE tenant_id = 7 AND customer_id = 42;
-- Index Only Scan using orders_customer_cover ... Heap Fetches: 0
CREATE UNIQUE INDEX users_email_lower ON users (tenant_id, lower(email));
SELECT * FROM users WHERE tenant_id = $1 AND lower(email) = lower($2); -- dùng index
SELECT * FROM users WHERE tenant_id = $1 AND email ILIKE $2;          -- không dùng
-- Chống 2 ca làm việc trùng giờ cho cùng nhân viên (cần extension btree_gist)
ALTER TABLE shifts ADD CONSTRAINT no_overlap
  EXCLUDE USING gist (employee_id WITH =, tstzrange(starts_at, ends_at) WITH &&);
Nested Loop  (cost=0.85..1250.40 rows=120 width=64) (actual time=0.05..3.20 rows=118 loops=1)
  Buffers: shared hit=480
  ->  Index Scan using customers_tenant_email on customers c  (rows=1) (actual rows=1 loops=1)
        Index Cond: ((tenant_id = 7) AND (email = 'a@b.com'))
  ->  Bitmap Heap Scan on orders o  (rows=120) (actual rows=118 loops=1)
        Recheck Cond: (customer_id = c.id)
        ->  Bitmap Index Scan on orders_customer_id  (actual rows=118 loops=1)
              Index Cond: (customer_id = c.id)
const r = await db.query(
  `UPDATE addresses SET line1 = $1, version = version + 1
   WHERE id = $2 AND tenant_id = $3 AND version = $4`,
  [line1, id, tenantId, expectedVersion],
);
if (r.rowCount === 0) throw new ConflictError('Address was modified by someone else');
WITH next AS (
  SELECT id FROM jobs
  WHERE status = 'pending' AND run_at <= now()
  ORDER BY run_at
  FOR UPDATE SKIP LOCKED
  LIMIT 10
)
UPDATE jobs j SET status = 'running', locked_at = now(), attempts = attempts + 1
FROM next WHERE j.id = next.id
RETURNING j.*;
CREATE TABLE events (
  tenant_id bigint NOT NULL, id bigint GENERATED ALWAYS AS IDENTITY,
  created_at timestamptz NOT NULL, payload jsonb,
  PRIMARY KEY (created_at, id)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_09 PARTITION OF events
  FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE INDEX products_attrs_gin ON products USING gin (attrs jsonb_path_ops);
SELECT id FROM products WHERE tenant_id = $1 AND attrs @> '{"brand":"acme","size":"M"}';
SELECT (date '2026-10-05' + time '09:00') AT TIME ZONE 'Australia/Sydney' AS starts_at_utc;
-- Ngày DST bắt đầu ở Sydney (2026-10-04) làm 02:00-03:00 không tồn tại.
-- index: CREATE INDEX orders_created_at ON orders (created_at);
SELECT id, total FROM orders
WHERE date(created_at) = current_date
  AND tenant_id::text = $1          -- $1 bound as text by the driver
ORDER BY created_at DESC;
Nested Loop  (cost=1.13..16.20 rows=1 width=40) (actual time=0.09..40211.55 rows=480112 loops=1)
  ->  Index Scan using products_tenant_category on products p  (rows=1) (actual rows=480112 loops=1)
        Index Cond: ((tenant_id = 12) AND (category_id = 900))
  ->  Index Scan using inventory_pkey on inventory i  (rows=1) (actual rows=1 loops=480112)
-- migration 2026_09_01_add_gift_note.sql
ALTER TABLE orders ADD COLUMN gift_note text;

-- at the same time, pg_stat_activity shows:
-- pid 811  state: idle in transaction  xact_start: 00:12 ago  query: SELECT * FROM orders WHERE ...
async function reserve(tx: Tx, items: { productId: number; qty: number }[]) {
  for (const it of items) {                 // cart order as the user added items
    await tx.query(
      `UPDATE inventory SET reserved = reserved + $1
       WHERE product_id = $2 AND available - reserved >= $1`,
      [it.qty, it.productId],
    );
  }
}
// Request A: items [101, 202]   Request B: items [202, 101]
SELECT id, name, price, updated_at FROM products
WHERE tenant_id = $1
ORDER BY updated_at DESC
LIMIT 50 OFFSET $2;   -- $2 = page * 50
// per request
await client.query(`SET search_path TO tenant_${tenantId}, public`);
await client.query({ name: 's_12', text: 'SELECT * FROM orders WHERE id = $1', values: [id] });
const orders = await orderRepo.find({ where: { tenantId }, take: 50 });
for (const o of orders) {
  o.customer = await customerRepo.findOneBy({ id: o.customerId });
  o.items = await itemRepo.findBy({ orderId: o.id });
}
SET lock_timeout = '3s';
ALTER TABLE orders ADD CONSTRAINT orders_currency_nn CHECK (currency IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_currency_nn;
ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT orders_currency_nn;
-- lặp từ app/script với :last_id tăng dần
UPDATE orders SET currency_code = legacy_currency
WHERE id > :last_id AND id <= :last_id + 5000 AND currency_code IS NULL;
-- T1 và T2 chạy song song ở REPEATABLE READ
SELECT count(*) FROM on_call WHERE shift_id = 9 AND active; -- cả hai thấy 2
UPDATE on_call SET active = false WHERE shift_id = 9 AND doctor_id = :me;
COMMIT; -- cả hai thành công → 0 active