Skip to content

PostgreSQL Advanced Roadmap ​

Lộ trình học PostgreSQL nâng cao, tối ưu performance và xử lý dữ liệu lớn


Tổng quan ​

PhaseChủ đề
1PostgreSQL Internals
2Indexing chuyên sâu
3Query Planner & Optimization
4Xử lý dữ liệu lớn
5Scalability & Operations

Phase 1 — PostgreSQL Internals ​

Nền tảng bắt buộc. Bỏ qua phần này thì các phần sau sẽ rất mơ hồ.

1.1 MVCC & Transactions ​

Multi-Version Concurrency Control — lý do PG có thể đọc/ghi đồng thời mà không block nhau.

Cần hiểu:

  • Snapshot isolation hoạt động như thế nào
  • xmin, xmax trên mỗi tuple là gì
  • Transaction ID (XID) và vấn đề wraparound
  • Các isolation levels: READ COMMITTED, REPEATABLE READ, SERIALIZABLE
  • pg_stat_activity, pg_stat_user_tables

Thực hành:

sql
-- Xem transaction id hiện tại
SELECT txid_current();

-- Xem snapshot hiện tại
SELECT txid_current_snapshot();

-- Kiểm tra tuples bị ảnh hưởng bởi MVCC
SELECT xmin, xmax, ctid, * FROM your_table LIMIT 10;

1.2 Locking & Concurrency ​

Cần hiểu:

  • Row-level locks: FOR UPDATE, FOR SHARE, FOR NO KEY UPDATE, FOR KEY SHARE
  • Table-level locks: ACCESS SHARE → ACCESS EXCLUSIVE (8 mức)
  • Advisory locks — app tự quản lý
  • Deadlock: nguyên nhân, cách phát hiện, cách phòng tránh
  • Lock queuing — tại sao 1 ALTER TABLE có thể block cả hệ thống

Thực hành:

sql
-- Xem các lock đang active
SELECT pid, locktype, relation::regclass, mode, granted
FROM pg_locks
WHERE relation IS NOT NULL;

-- Xem ai đang block ai
SELECT blocked.pid, blocked.query,
       blocking.pid AS blocking_pid,
       blocking.query AS blocking_query
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;

Phòng tránh deadlock: Luôn lock theo cùng thứ tự. Dùng SELECT FOR UPDATE sớm ngay đầu transaction.


1.3 WAL — Write-Ahead Log ​

WAL là lý do PostgreSQL không mất data khi server crash.

Flow cơ bản:

app UPDATE row
      ↓
ghi vào WAL trước  ← sequential write, rất nhanh
      ↓
ghi vào data page  ← random write, chậm hơn
      ↓
crash ở đây? → restart → replay lại WAL → data OK

Checkpoint là điểm PostgreSQL flush hết data pages xuống disk. WAL trước checkpoint có thể được xóa → giữ cho disk không đầy.

Khi nào BE dev cần quan tâm:

Triệu chứngNguyên nhân WALHướng xử lý
App chậm từng đợtCheckpoint đang flushTăng checkpoint_completion_target
Disk đầy bất thườngWAL tích lũy quá nhiềuTăng max_wal_size, kiểm tra long transaction
Setup replication / backupCần cấu hình WAL levelĐọc chi tiết WAL.md

Để full_page_writes, wal_compression ở mặc định — không cần quan tâm.


1.4 VACUUM & Autovacuum ​

VACUUM là garbage collector của PostgreSQL — dọn dead rows mà MVCC để lại sau mỗi UPDATE/DELETE.

Chạy tự động qua autovacuum, bình thường không cần can thiệp. Chỉ cần đọc thêm vacuum.md khi gặp bảng write-heavy bị chậm dần không rõ nguyên nhân (bloat) hoặc autovacuum có vấn đề.


1.5 Storage & Buffer Pool ​

  • shared_buffers và page 8KB → đã cover trong WAL.md
  • TOAST và OS page cache → xem chi tiết storage.md

Phase 2 — Indexing chuyên sâu ​

2.1 B-tree internals ​

Index mặc định của PG. Phù hợp cho equality và range queries.

Cần hiểu:

  • Cấu trúc B-tree, page split
  • Deduplication (PG 13+): index entries trùng được gộp
  • Index-only scan: khi nào query không cần đọc heap
  • Correlation: ảnh hưởng đến cost của index scan
  • Khi nào planner bỏ qua index (low selectivity, small table)
sql
-- Xem correlation của column (càng gần 1 hoặc -1 càng tốt)
SELECT tablename, attname, correlation
FROM pg_stats
WHERE tablename = 'your_table'
ORDER BY abs(correlation) DESC;

2.2 GIN / GiST / BRIN ​

IndexDùng choTrade-off
GINjsonb, array, full-text searchBuild chậm, lookup nhanh
GiSTGeo (PostGIS), range types, nearest neighborLossy — cần recheck
BRINTime-series, insert-ordered dataCực nhỏ, chỉ hiệu quả với sorted data
HashEquality onlyKhông WAL-safe trước PG10
sql
-- GIN cho jsonb
CREATE INDEX idx_payload_gin ON events USING GIN (payload);
-- Query: SELECT * FROM events WHERE payload @> '{"event_type": "click"}';

-- BRIN cho time-series
CREATE INDEX idx_recorded_brin ON metrics USING BRIN (recorded_at);

-- GiST cho range — [generic pattern, schema này không có range-type column]
CREATE INDEX idx_booking_range ON bookings USING GIST (during);  -- [generic pattern]

2.3 Partial & Expression index ​

sql
-- Partial index: chỉ index những row cần thiết
CREATE INDEX idx_active_users ON users (email)
WHERE is_active = true;

-- Expression index: index trên function
CREATE INDEX idx_email_lower ON users (lower(email));
-- Query phải dùng: WHERE lower(email) = 'user@example.com'

-- Covering index (INCLUDE): tránh heap lookup
CREATE INDEX idx_orders_covering ON orders (user_id)
INCLUDE (status, total_amount, created_at);

2.4 Index maintenance ​

sql
-- Xem unused indexes (tốn disk mà không được dùng)
SELECT schemaname, tablename, indexname,
       idx_scan, idx_tup_read, idx_tup_fetch,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

-- Rebuild index không lock
REINDEX INDEX CONCURRENTLY idx_name;

-- Xem index bloat
SELECT indexrelname,
       round(bloat_ratio::numeric, 2) AS bloat_pct,
       pg_size_pretty(real_size) AS real_size
FROM pgstattuple_approx('your_index');

2.5 pg_trgm — Similarity & LIKE optimization ​

Rất hay gặp trong thực tế: search theo tên, autocomplete, fuzzy match.

sql
CREATE EXTENSION pg_trgm;

-- GIN index cho LIKE/ILIKE và similarity search
CREATE INDEX idx_name_trgm ON products USING GIN (name gin_trgm_ops);

-- Giờ query này dùng index thay vì Seq Scan
SELECT * FROM products WHERE name ILIKE '%điện thoại%';

-- Similarity score
SELECT name, similarity(name, 'iphone') AS score
FROM products
WHERE name % 'iphone'          -- toán tử % = similarity > threshold (mặc định 0.3)
ORDER BY score DESC
LIMIT 10;

-- Điều chỉnh threshold
SET pg_trgm.similarity_threshold = 0.4;

-- GiST thay thế khi cần nearest-neighbor với ORDER BY ... <-> (distance operator)
CREATE INDEX idx_name_gist ON products USING GIST (name gist_trgm_ops);
SELECT name FROM products ORDER BY name <-> 'iphone' LIMIT 5;

Khi nào dùng GIN vs GiST với trgm:

GINGiST
Build timeChậm hơnNhanh hơn
LookupNhanh hơnChậm hơn
ORDER BY <->Không hỗ trợHỗ trợ
RecommendedLIKE/ILIKE, %Nearest-neighbor

Phase 3 — Query Planner & Optimization ​

Đây là tầng quan trọng nhất. Kỹ năng đọc EXPLAIN ANALYZE tốt giải quyết được 80% vấn đề performance.

3.1 EXPLAIN ANALYZE ​

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT ...;

Cách đọc output:

Seq Scan on orders  (cost=0.00..1842.00 rows=50000 width=64)
                     ^^^^^^^^^^^^^^^^^^^
                     cost=startup..total
                     rows=estimated rows

                    (actual time=0.123..45.678 rows=49821 loops=1)
                                                ^^^^^^^^^^^
                                                actual rows — nếu lệch nhiều với estimated → planner sai
                    Buffers: shared hit=1230 read=342
                                          ^^^  ^^^
                                          cache hit  disk read

Các node quan trọng:

NodeÝ nghĩa
Seq ScanĐọc toàn bộ bảng — thường là vấn đề
Index ScanDùng index + heap lookup
Index Only ScanDùng index, không cần heap (tốt nhất)
Bitmap Heap ScanKết hợp nhiều index conditions
Hash JoinJoin bằng hash table — tốt với large datasets
Nested LoopJoin lồng nhau — tốt khi outer nhỏ
Merge JoinJoin trên sorted data

3.2 Join strategies ​

Khi planner chọn Hash Join:

  • Một trong hai bảng vừa fit vào work_mem
  • Không có index hữu ích
  • Cardinality lớn

Khi planner chọn Nested Loop:

  • Outer set nhỏ
  • Inner có index
  • OLTP queries điển hình

Khi planner chọn Merge Join:

  • Cả hai bảng đã sorted theo join key
  • Thường thấy với window functions
sql
-- Force join strategy để test (đừng để trong production)
SET enable_hashjoin = off;
SET enable_nestloop = off;

3.3 Planner statistics ​

Planner dựa vào statistics để ước lượng rows. Nếu ước lượng sai → plan sai.

sql
-- Xem statistics của một column
SELECT * FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

-- Tăng statistics target cho column có nhiều giá trị distinct
ALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 500;
ANALYZE orders;

-- Extended statistics cho correlated columns
CREATE STATISTICS stat_region_product ON region, product_id FROM sales;
ANALYZE sales;

3.4 CTE & Subquery optimization ​

sql
-- PG 12+: CTE mặc định được inline (không còn là optimization fence)
-- Dùng MATERIALIZED để force materialize
WITH expensive_cte AS MATERIALIZED (
  SELECT * FROM large_table WHERE complex_condition
)
SELECT * FROM expensive_cte JOIN other_table USING (id);

-- LATERAL join: subquery tham chiếu outer row
SELECT u.id, last_orders.*
FROM users u
CROSS JOIN LATERAL (
  SELECT * FROM orders WHERE user_id = u.id
  ORDER BY created_at DESC LIMIT 3
) last_orders;

3.5 Parallel query ​

sql
-- Config cơ bản
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
parallel_setup_cost = 1000
parallel_tuple_cost = 0.1

-- Xem query có dùng parallel không
EXPLAIN SELECT count(*) FROM large_table;
-- Tìm "Gather" node trong plan

-- Force parallel cho session (test)
SET max_parallel_workers_per_gather = 8;
-- PG 15 trở xuống:
SET force_parallel_mode = on;
-- PG 16+: force_parallel_mode bị deprecated, dùng:
SET debug_parallel_query = on;

Phase 4 — Xử lý dữ liệu lớn ​

4.1 Table Partitioning ​

Khi nào nên dùng: Bảng > 50–100 triệu dòng, query chậm dù đã index tốt.

sql
-- Range partitioning theo thời gian
CREATE TABLE events (
  id bigint,
  created_at timestamptz NOT NULL,
  payload jsonb
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2024_01 PARTITION OF events
  FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

CREATE TABLE events_2024_02 PARTITION OF events
  FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

-- Index tự động apply cho tất cả partitions
CREATE INDEX ON events (created_at);

-- Xem partition pruning có hoạt động không
EXPLAIN SELECT * FROM events WHERE created_at >= '2024-01-15';
-- Phải thấy chỉ scan partition tương ứng, không phải tất cả

Detach partition để archive:

sql
-- Zero-downtime, không lock lâu
ALTER TABLE events DETACH PARTITION events_2023 CONCURRENTLY;

pg_partman — tự động hóa partition management:

pg_partman là extension standard cho production — tự động tạo partition mới, detach partition cũ, chạy qua background worker.

sql
CREATE EXTENSION pg_partman;

-- Tạo partitioned table và setup pg_partman quản lý
SELECT partman.create_parent(
  p_parent_table => 'public.events',
  p_control      => 'created_at',
  p_type         => 'range',
  p_interval     => 'monthly',
  p_premake      => 3           -- tạo trước 3 partition tương lai
);

-- Cập nhật config
UPDATE partman.part_config
SET retention            = '12 months',
    retention_keep_table = false,   -- drop partition cũ thay vì detach
    infinite_time_partitions = true
WHERE parent_table = 'public.events';

-- Chạy maintenance (thường đặt trong cron hoặc pg_partman background worker)
CALL partman.run_maintenance_proc();

4.2 Bulk Loading ​

sql
-- COPY nhanh hơn INSERT nhiều lần
COPY orders FROM '/path/to/orders.csv' WITH (FORMAT csv, HEADER true);

-- Tối ưu bulk insert:
-- 1. Drop indexes trước, rebuild sau
-- 2. Dùng UNLOGGED table (không ghi WAL)
-- 3. Tăng maintenance_work_mem
-- 4. Tắt autovacuum tạm thời

BEGIN;
SET maintenance_work_mem = '1GB';
ALTER TABLE staging DISABLE TRIGGER ALL;
COPY staging FROM '/data/file.csv' CSV;
ALTER TABLE staging ENABLE TRIGGER ALL;
COMMIT;

-- Rebuild indexes sau bulk load
REINDEX TABLE CONCURRENTLY staging;
ANALYZE staging;

4.3 Parallel Query cho analytics ​

sql
-- Parallel aggregation
SET max_parallel_workers_per_gather = 8;

SELECT region, sum(total_amount), avg(total_amount)
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY region;
-- Với plan tốt: Finalize GroupAggregate ← Gather ← Partial GroupAggregate

-- Parallel index scan (PG 14+)
SET enable_parallel_hash = on;

4.4 Time-series patterns ​

sql
-- BRIN index — cực nhỏ, hiệu quả với insert-ordered data
CREATE INDEX idx_ts_brin ON metrics USING BRIN (recorded_at)
WITH (pages_per_range = 128);

-- Partitioning theo thời gian + BRIN = combo mạnh nhất cho time-series

-- Continuous aggregation với materialized view
CREATE MATERIALIZED VIEW hourly_metrics AS
SELECT
  date_trunc('hour', recorded_at) AS hour,
  sensor_id,
  avg(value) AS avg_value,
  max(value) AS max_value
FROM metrics
GROUP BY 1, 2;

-- Refresh incremental
REFRESH MATERIALIZED VIEW CONCURRENTLY hourly_metrics;

TimescaleDB nếu cần: hypertable tự động chunk theo thời gian, continuous aggregates real-time.


4.5 Sharding với Citus ​

Dùng khi single-node PG không đủ — horizontal scaling.

sql
-- Tạo distributed table
SELECT create_distributed_table('orders', 'customer_id');

-- Colocation: đặt related tables cùng shard
SELECT create_distributed_table('order_items', 'customer_id',
  colocate_with => 'orders');

-- Query tự động route đến đúng shard
SELECT count(*) FROM orders WHERE customer_id = 12345;

Phase 5 — Scalability & Operations ​

5.1 Replication ​

sql
-- Streaming replication: monitor lag
SELECT client_addr,
       state,
       sent_lsn - write_lsn AS write_lag,
       write_lsn - flush_lsn AS flush_lag,
       flush_lsn - replay_lsn AS replay_lag
FROM pg_stat_replication;

-- Logical replication: chỉ replicate specific tables
CREATE PUBLICATION my_pub FOR TABLE orders, users;
-- Trên subscriber:
CREATE SUBSCRIPTION my_sub
  CONNECTION 'host=primary dbname=mydb'
  PUBLICATION my_pub;

Failover tự động: Dùng Patroni + etcd/Consul.


5.2 Connection Pooling — PgBouncer ​

PgBouncer là must-have khi có nhiều connections.

Modes:

  • session: 1 PG connection / 1 client session — ít lợi nhất
  • transaction: connection trả về pool sau mỗi transaction — recommended
  • statement: connection trả về sau mỗi statement — không hỗ trợ transactions

Transaction mode — incompatibilities cần biết:

Transaction mode trả connection về pool sau mỗi transaction, nên các tính năng cần connection persist sẽ không hoạt động đúng:

Tính năngVấn đề
PREPARE / EXECUTE (prepared statements)Statement chuẩn bị ở connection A, execute ở connection B → lỗi
LISTEN / NOTIFYLISTEN mất khi connection trả về pool
Advisory locks per-sessionLock gắn với connection, không phải session
SET config (e.g. SET search_path)Reset sau mỗi transaction
Temporary tablesDrop khi connection trả về pool

Workaround: Dùng DISCARD ALL hoặc cấu hình server_reset_query = DISCARD ALL trong pgbouncer.ini. Với prepared statements: dùng prepared_statements = off trong driver hoặc dùng protocol-level prepared statements (PgBouncer 1.21+ hỗ trợ).

Pool sizing formula:

pool_size = (num_cores * 2) + effective_spindle_count

pgbouncer.ini:

ini
[databases]
mydb = host=localhost port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
server_idle_timeout = 600

5.3 Config tuning — postgresql.conf ​

ini
# Memory
shared_buffers = 4GB                    # 25% RAM
effective_cache_size = 12GB             # 75% RAM (hint cho planner)
work_mem = 16MB                         # per sort/hash operation, PER CONNECTION — tổng RAM = work_mem × connections × sorts_per_query → OOM nếu set quá cao
maintenance_work_mem = 1GB              # VACUUM, CREATE INDEX

# WAL & Checkpoint
wal_buffers = 64MB
checkpoint_completion_target = 0.9
max_wal_size = 4GB
min_wal_size = 1GB

# Parallel
max_parallel_workers = 8
max_parallel_workers_per_gather = 4
max_parallel_maintenance_workers = 4

# Planner
random_page_cost = 1.1                  # SSD: set 1.1, HDD: giữ 4.0
effective_io_concurrency = 200          # SSD: 200, HDD: 2

# Logging
log_min_duration_statement = 1000       # Log queries chậm hơn 1s
log_autovacuum_min_duration = 500

5.4 Monitoring & Profiling ​

Extensions cần cài:

sql
CREATE EXTENSION pg_stat_statements;
CREATE EXTENSION auto_explain;

Queries hữu ích hàng ngày:

sql
-- Top 10 queries chậm nhất
SELECT query,
       calls,
       round(total_exec_time::numeric, 2) AS total_ms,
       round(mean_exec_time::numeric, 2) AS mean_ms,
       round(stddev_exec_time::numeric, 2) AS stddev_ms,
       rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

-- Cache hit ratio (nên > 99%)
SELECT
  sum(heap_blks_hit) / nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) AS ratio
FROM pg_statio_user_tables;

-- Bảng lớn nhất
SELECT tablename,
       pg_size_pretty(pg_total_relation_size(tablename::regclass)) AS total_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(tablename::regclass) DESC
LIMIT 20;

-- Long running queries
SELECT pid, now() - query_start AS duration, state, query
FROM pg_stat_activity
WHERE state != 'idle'
  AND query_start < now() - interval '5 minutes'
ORDER BY duration DESC;

Tools:

  • pgBadger: phân tích log file → HTML report
  • pganalyze: SaaS monitoring, giải thích plan tự động
  • Prometheus + postgres_exporter: metrics cho Grafana
  • pspg: pager đẹp hơn cho psql

5.5 Zero-downtime Migrations ​

sql
-- Thêm column — safe (PG 11+ với DEFAULT)
ALTER TABLE orders ADD COLUMN notes text DEFAULT '';

-- Thêm index — PHẢI dùng CONCURRENTLY
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
-- Không dùng CONCURRENTLY → lock cả bảng!

-- Thêm NOT NULL constraint — cẩn thận
-- Cách safe: CHECK constraint trước, sau đó validate
ALTER TABLE orders ADD CONSTRAINT orders_user_id_not_null
  CHECK (user_id IS NOT NULL) NOT VALID;
-- Sau đó:
ALTER TABLE orders VALIDATE CONSTRAINT orders_user_id_not_null;

-- Rename column — cần code deploy phối hợp
-- 1. Thêm column mới, sync data
-- 2. Deploy code đọc cả hai column
-- 3. Migrate data hoàn toàn
-- 4. Drop column cũ

-- Lock timeout — tránh queue dài
SET lock_timeout = '2s';
SET statement_timeout = '30s';

5.6 Zero-downtime Major Version Upgrade (Logical Replication) ​

pg_upgrade nhanh nhưng yêu cầu downtime. Với hệ thống production không thể dừng, dùng logical replication để upgrade không downtime:

[PG 14 - primary]  →  logical replication  →  [PG 17 - standby]
                                                      ↑
                                               khi đồng bộ xong → failover
sql
-- Trên PG 14 (source): tạo publication cho tất cả tables
CREATE PUBLICATION upgrade_pub FOR ALL TABLES;

-- Trên PG 17 (target): tạo schema giống hệt source (pg_dump --schema-only)
-- Sau đó subscribe:
CREATE SUBSCRIPTION upgrade_sub
  CONNECTION 'host=pg14-host port=5432 dbname=mydb user=replicator'
  PUBLICATION upgrade_pub;

-- Monitor replication lag
SELECT subname, received_lsn, latest_end_lsn,
       (latest_end_lsn - received_lsn) AS lag_bytes
FROM pg_stat_subscription;

-- Khi lag = 0: switch app connection sang PG 17, drop subscription
DROP SUBSCRIPTION upgrade_sub;

Lưu ý:

  • Sequences không replicate tự động → cần sync thủ công trước khi failover
  • DDL changes trong thời gian replication phải apply cả hai nơi
  • Logical replication không support: large objects, DDL, unlogged tables

Tài nguyên tham khảo ​

NguồnNội dung
use-the-index-luke.comIndexing từ gốc rễ, ngôn ngữ dễ hiểu
pganalyze.com/docsQuery optimization, planner
postgresqlco.nfGiải thích từng config parameter
pgpedia.infoCatalog tables, system views
PostgreSQL docs — src/backend/optimizer/Source code planner nếu muốn hiểu sâu nhất
Book: "PostgreSQL 14 Internals" — Egor RogovInternals toàn diện nhất
Book: "High Performance PostgreSQL"Practical performance tuning

Thứ tự học gợi ý ​

EXPLAIN ANALYZE (đọc được output)
    ↓
MVCC + Dead tuples + VACUUM
    ↓
Index types (B-tree → GIN/GiST → Partial/Expression)
    ↓
Locking & Deadlock
    ↓
Planner statistics + Join strategies
    ↓
CTE / Window functions optimization
    ↓
Config tuning (postgresql.conf)
    ↓
Partitioning (khi data đủ lớn)
    ↓
Replication + Connection pooling
    ↓
Monitoring + Zero-downtime migrations

Nguyên tắc vàng: Đo trước, optimize sau. Không bao giờ tối ưu thứ chưa đo được.


Tình huống thực tế → xem Labs

Personal notes by thanhlt