Skip to content

PostgreSQL Labs — Tình huống thực tế ​

Mỗi lab là một sự cố hoặc tình huống thường gặp trong production. Đọc triệu chứng → tự chẩn đoán → xem hướng giải quyết.


Lab 1 — Query chậm không rõ nguyên nhân ​

Tình huống:

Endpoint /api/orders?user_id=12345 trước đây response < 50ms. Sau khi data tăng lên 10 triệu records, response time lên 4–8 giây. Code không thay đổi, index orders(user_id) đã có từ trước.

Bước 1 — Chạy EXPLAIN ANALYZE:

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.status, o.total_amount, o.created_at
FROM orders o
WHERE o.user_id = 12345
ORDER BY o.created_at DESC
LIMIT 20;

Bước 2 — Đọc output, tìm dấu hiệu:

Index Scan using idx_orders_user_id on orders
  (cost=0.56..18432.12 rows=9821 width=48)
  (actual time=0.043..3812.445 rows=9821 loops=1)
  Index Cond: (user_id = 12345)
  Buffers: shared hit=42 read=9779

Dấu hiệu:

  • read=9779 — gần như toàn bộ từ disk, không phải cache
  • rows=9821 — planner ước đúng nhưng quá nhiều rows để sort sau đó

Bước 3 — Xác định vấn đề:

Index (user_id) tìm được 9821 rows cho user này. Sau đó PG phải đọc heap để lấy created_at và sort. Vấn đề là thiếu covering index.

Fix:

sql
-- Tạo composite index: filter trên user_id, sort trên created_at, INCLUDE các column cần select
CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders (user_id, created_at DESC)
INCLUDE (status, total_amount);

-- Sau đó chạy lại EXPLAIN — phải thấy Index Only Scan
-- Buffers: shared hit tăng, read giảm về ~20

Kiểm tra hiểu biết:

  • Tại sao ORDER BY created_at DESC trong index lại quan trọng ở đây?
  • INCLUDE khác gì với thêm column vào key của index?
  • Khi nào thì Index Only Scan không hoạt động dù có covering index?

Lab 2 — Lock contention làm nghẽn toàn hệ thống ​

Tình huống:

DBA chạy lệnh bảo trì lúc thấp điểm:

sql
ALTER TABLE users ADD COLUMN last_seen_at timestamptz;

Lệnh này block trong 30 giây. Sau đó mọi request đến /api/users/* đều bị timeout hàng loạt trong 2 phút, dù ALTER TABLE đã xong.

Giải thích cơ chế:

ALTER TABLE yêu cầu ACCESS EXCLUSIVE lock
    ↓
Đang có long transaction T1 giữ ACCESS SHARE lock (đọc users)
    ↓
ALTER TABLE phải đợi T1 xong
    ↓
Trong lúc đó, tất cả request mới (đọc/ghi users) queue sau ALTER TABLE
    ↓
T1 xong → ALTER TABLE chạy (nhanh) → nhả lock
    ↓
Hàng queue tràn vào cùng lúc → spike

Chẩn đoán khi đang xảy ra:

sql
-- Xem ai đang chờ và ai đang block
SELECT
  blocked.pid,
  blocked.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking.query AS blocking_query,
  now() - blocked.query_start AS waiting_duration
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0
ORDER BY waiting_duration DESC;

Fix cho lần sau:

sql
-- 1. Luôn set lock_timeout khi chạy DDL
SET lock_timeout = '3s';
ALTER TABLE users ADD COLUMN last_seen_at timestamptz;
-- Nếu không lấy được lock trong 3s → lệnh fail thay vì block vô hạn

-- 2. Với index: PHẢI dùng CONCURRENTLY
CREATE INDEX CONCURRENTLY idx_users_last_seen ON users (last_seen_at);

-- 3. Với NOT NULL constraint: dùng NOT VALID + VALIDATE tách biệt
ALTER TABLE users
  ADD CONSTRAINT users_last_seen_not_null
  CHECK (last_seen_at IS NOT NULL) NOT VALID;
-- Chạy giờ thấp điểm:
ALTER TABLE users VALIDATE CONSTRAINT users_last_seen_not_null;

Kiểm tra hiểu biết:

  • Tại sao CREATE INDEX CONCURRENTLY không gây vấn đề này?
  • NOT VALID constraint có được enforce cho INSERT/UPDATE mới không?
  • Nếu không thể tránh ACCESS EXCLUSIVE, quy trình deploy an toàn là gì?

Lab 3 — Table bloat làm query chậm dần ​

Tình huống:

Bảng sessions có ~5 triệu rows, nhưng query SELECT count(*) FROM sessions WHERE expires_at < now() mất 8 giây, trong khi pg_relation_size('sessions') trả về 12GB — quá lớn so với data thực tế.

Chẩn đoán:

sql
-- Kiểm tra dead rows
SELECT
  n_live_tup,
  n_dead_tup,
  round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pct,
  last_autovacuum,
  last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'sessions';

Nếu dead_pct > 20% và last_autovacuum là null hoặc rất cũ → autovacuum bị miss.

sql
-- Xem autovacuum có đang chạy không
SELECT pid, query, now() - query_start AS duration
FROM pg_stat_activity
WHERE query LIKE 'autovacuum%';

-- Xem config autovacuum của table này
SELECT reloptions FROM pg_class WHERE relname = 'sessions';

Nguyên nhân thường gặp:

Nguyên nhânDấu hiệuFix
Table write quá nhanh, autovacuum không kịpn_dead_tup tăng liên tụcGiảm autovacuum_vacuum_scale_factor
Autovacuum bị throttleautovacuum_vacuum_cost_delay caoTăng autovacuum_vacuum_cost_limit
Long transaction giữ xminage(relfrozenxid) lớnTìm và kill long transaction

Fix:

sql
-- Force vacuum ngay lập tức (manual)
VACUUM ANALYZE sessions;

-- Nếu muốn reclaim disk space thực sự (full lock, dùng cẩn thận)
VACUUM FULL sessions;  -- hoặc dùng pg_repack để không lock

-- Tune autovacuum cho table write-heavy
ALTER TABLE sessions SET (
  autovacuum_vacuum_scale_factor = 0.01,   -- vacuum khi 1% rows là dead (thay vì 20%)
  autovacuum_vacuum_cost_limit = 800        -- cho autovacuum chạy nhanh hơn
);

Kiểm tra hiểu biết:

  • Tại sao VACUUM thường không trả disk space về OS?
  • pg_repack khác VACUUM FULL ở điểm gì quan trọng nhất?
  • Long transaction giữ xmin gây ra vấn đề gì ngoài bloat?

Lab 4 — Planner chọn plan sai ​

Tình huống:

Query sau chạy 200ms trên staging (100k rows) nhưng mất 45 giây trên production (50M rows):

sql
SELECT u.email, count(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at >= '2024-01-01'
  AND u.country = 'VN'
GROUP BY u.id, u.email
HAVING count(o.id) > 10;

Chẩn đoán:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.email, count(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at >= '2024-01-01'
  AND u.country = 'VN'
GROUP BY u.id, u.email
HAVING count(o.id) > 10;

Tìm trong output:

  • Estimated rows vs actual rows — lệch nhiều không?
  • Join strategy là gì? Hash Join hay Nested Loop?

Ví dụ output đáng ngờ:

Hash Join  (cost=1234..89234 rows=150 width=32)
           (actual rows=48291 loops=1)   ← actual gấp 300 lần estimated!

Planner nghĩ chỉ 150 rows match điều kiện country = 'VN' nên chọn Nested Loop — thực tế 48k rows → catastrophic.

Nguyên nhân: Statistics lỗi thời hoặc không đủ chi tiết cho column country.

Fix:

sql
-- Chạy ANALYZE để refresh statistics
ANALYZE users;

-- Nếu vẫn sai: tăng statistics target cho column low-cardinality nhưng skewed
ALTER TABLE users ALTER COLUMN country SET STATISTICS 500;
ANALYZE users;

-- Kiểm tra histogram sau khi analyze
SELECT most_common_vals, most_common_freqs, histogram_bounds
FROM pg_stats
WHERE tablename = 'users' AND attname = 'country';

-- Nếu country và created_at correlated: tạo extended statistics
CREATE STATISTICS stat_users_country_created ON country, created_at FROM users;
ANALYZE users;

Kiểm tra hiểu biết:

  • pg_stats.most_common_vals lưu tối đa bao nhiêu giá trị theo mặc định?
  • Extended statistics (CREATE STATISTICS) giúp gì mà single-column statistics không làm được?
  • Khi nào nên dùng SET enable_hashjoin = off và khi nào không nên?

Lab 5 — Replication lag đột ngột tăng ​

Tình huống:

Grafana alert: replication lag trên replica vượt 30 giây và đang tăng. Application đang đọc từ replica cho reporting queries.

Chẩn đoán ngay lập tức:

sql
-- Trên primary: xem replication status
SELECT
  client_addr,
  state,
  sent_lsn,
  write_lsn,
  flush_lsn,
  replay_lsn,
  pg_wal_lsn_diff(sent_lsn, replay_lsn) AS total_lag_bytes,
  write_lag,
  flush_lag,
  replay_lag
FROM pg_stat_replication;

-- Trên replica: xem recovery status
SELECT
  now() - pg_last_xact_replay_timestamp() AS replication_delay,
  pg_is_in_recovery(),
  pg_last_wal_receive_lsn(),
  pg_last_wal_replay_lsn();

Phân tích theo loại lag:

Lag tăng ở đâuNguyên nhânHướng xử lý
write_lag caoNetwork giữa primary và replicaKiểm tra network, xem xét synchronous_commit = off
flush_lag caoDisk trên replica chậmUpgrade disk replica hoặc dùng async replication
replay_lag caoReplica đang bị query nặngQuery conflict với recovery — xem max_standby_streaming_delay

Xử lý query conflict trên replica:

sql
-- Xem query đang block recovery trên replica
SELECT pid, now() - query_start AS duration, state, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;

-- Option 1: Tăng tolerance — replica chờ query xong rồi mới apply WAL
-- postgresql.conf:
-- max_standby_streaming_delay = '30s'

-- Option 2: Cho phép replica cancel conflicting queries ngay
-- hot_standby_feedback = on  ← primary sẽ không cleanup rows replica đang đọc
-- max_standby_streaming_delay = '10s'

-- Option 3: Dùng connection khác cho long-running reports
-- Kết nối trực tiếp primary cho queries > 30s, replica chỉ cho short reads

Kiểm tra hiểu biết:

  • hot_standby_feedback = on giải quyết conflict nhưng có tác dụng phụ gì trên primary?
  • Tại sao replica lag tăng đột ngột trong khi trước đó ổn định?
  • Khi nào nên cân nhắc dùng synchronous_commit = remote_apply?

Personal notes by thanhlt