← PostgreSQL DBA← PostgreSQL DBA
PostgreSQL DBAPostgreSQL DBA19 Th7, 2026Jul 19, 202630 phút đọc25 min read

Replication & High AvailabilityReplication & High Availability

Thuộc bộ kiến thức PostgreSQL DBA Roadmap.

Tổng quan

Một instance PostgreSQL đơn lẻ luôn là một single point of failure. Đĩa có thể hỏng, host có thể crash, một data center có thể mất điện — và bất kỳ sự kiện nào trong số đó cũng kéo cả database, cùng mọi ứng dụng phụ thuộc vào nó, sập theo. Replication là kỹ thuật duy trì một hoặc nhiều bản sao của database trên các server riêng biệt, luôn được đồng bộ liên tục với primary, để toàn bộ hệ thống sống sót qua sự cố của bất kỳ node nào.

Replication mang lại giá trị vì ba lý do khác nhau, và việc xác định rõ một hệ thống thực sự đang giải quyết vấn đề nào rất quan trọng, vì kiến trúc phù hợp sẽ khác nhau tùy mục tiêu:

Ba động lực này bổ trợ lẫn nhau chứ không loại trừ nhau, và một hệ thống production trưởng thành thường đạt được cả ba từ cùng một kiến trúc vật lý: replica phục vụ read traffic hàng ngày, luôn sẵn sàng để promote nếu primary chết, và ít nhất một trong số chúng nằm đủ xa để sống sót qua thảm họa cấp site.

Bài viết này bao quát hai cơ chế replication của PostgreSQL (streaming/physical và logical), cách công cụ automated failover như Patroni biến một tập hợp replica độc lập thành một cluster tự chữa lành, và tầng connection routing (HAProxy, PgBouncer, Keepalived) khiến failover trở nên vô hình đối với ứng dụng. Backup và point-in-time recovery — kỷ luật bổ trợ về bảo vệ mất dữ liệu thay vì downtime — được trình bày ở Backup & Recovery.

Kiến thức nền tảng

WAL là nền tảng của replication

PostgreSQL không có một giao thức replication riêng biệt gắn thêm vào bên cạnh database — nó tái sử dụng chính cơ chế mà database đã dùng để đảm bảo an toàn khi crash: Write-Ahead Log (WAL). Như đã trình bày ở Storage Internals & VACUUM, mọi thay đổi trong database đều được ghi thành một bản ghi WAL trước khi các trang dữ liệu tương ứng được sửa trên đĩa. Đây là lý do PostgreSQL có thể phục hồi sạch sẽ sau crash — replay WAL kể từ checkpoint gần nhất là các tập tin dữ liệu trở lại trạng thái nhất quán.

Streaming replication đơn giản là mở rộng ý tưởng này qua mạng: thay vì chỉ ghi WAL vào đĩa local và replay nó khi crash recovery cục bộ, primary sẽ stream các bản ghi WAL của nó đến một hoặc nhiều replica ngay khi chúng được sinh ra, và mỗi replica liên tục replay WAL đó lên bản sao dữ liệu của chính nó — vĩnh viễn, không chỉ sau một crash. Theo một nghĩa rất thực, replica chính là một server bị kẹt trong một vòng lặp recovery gia tăng vô tận. Đây là lý do replication trong PostgreSQL không cần một cơ chế change-tracking riêng, không cần trigger, không cần bảng phụ nào cả: WAL vốn dĩ luôn là bản ghi đầy đủ, có thứ tự của mọi thay đổi vật lý trong cluster, và replication chỉ đơn giản là gửi bản ghi đó đi nơi khác rồi replay nó ở đó luôn.

Vì hoạt động trên WAL — thay đổi ở cấp page vật lý — kiểu replication này được gọi là physical replication, và một replica xây dựng theo cách này là bản sao byte-for-byte của data directory của primary (trừ độ trễ về thời gian). Nó replicate toàn bộ mọi thứ trong cluster: mọi database, mọi bảng, mọi index, mọi sequence. Không có cách nào để chỉ replicate một vài bảng với physical replication — độ chi tiết đó đòi hỏi logical replication, sẽ trình bày bên dưới.

Thiết lập streaming replication (mô tả khái niệm)

Một thiết lập streaming replication tối thiểu bao gồm vài thành phần:

  1. Một replication user và quyền trên primary — một tài khoản có quyền REPLICATION mà replica dùng để xác thực.
  2. Các entry trong pg_hba.conf trên primary cho phép user đó kết nối từ địa chỉ của replica, dùng pseudo-database replication.
  3. wal_level = replica (hoặc logical, là tập hợp con lớn hơn) trên primary, để WAL chứa đủ thông tin tái tạo dữ liệu — mức mặc định minimal bỏ qua thông tin mà replica cần.
  4. Một base backup của data directory primary, lấy bằng pg_basebackup, dùng để khởi tạo replica sao cho nó bắt đầu từ một snapshot nhất quán đã biết, thay vì một thư mục rỗng.
  5. primary_conninfo trên replica (trước đây lưu trong recovery.conf; từ PostgreSQL 12 nó là một GUC bình thường trong postgresql.conf, kết hợp với file standby.signal để báo server khởi động ở chế độ standby) trỏ đến thông tin kết nối của primary.
  6. Sau khi khởi động ở chế độ standby, replica kết nối đến primary như một cặp walsender/walreceiver, yêu cầu WAL bắt đầu từ vị trí ghi trong base backup, và bắt đầu liên tục stream và replay từ đó.
# Trên host của replica: clone data directory của primary và
# thiết lập nó thành streaming standby chỉ trong một bước
pg_basebackup -h primary.db.internal -U replicator -D /var/lib/postgresql/16/main \
  -Fp -Xs -P -R
# -R tự động ghi standby.signal và primary_conninfo (PG 12+)
-- Trên primary: kiểm tra các replica đang kết nối và độ trễ của từng cái
SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn,
       replay_lag
FROM pg_stat_replication;

Synchronous vs. asynchronous replication

Mặc định, streaming replication là asynchronous (bất đồng bộ): primary commit transaction cục bộ, xác nhận với client, rồi sau đó mới gửi WAL tương ứng tới replica bất cứ khi nào rảnh (thường là vài mili giây sau, nhưng không có gì đảm bảo). Nghĩa là nếu primary crash đúng vào thời điểm tồi tệ — sau khi commit nhưng trước khi WAL của commit đó tới được bất kỳ replica nào — transaction đó được ghi bền vững trên đĩa của primary nhưng chưa tới replica nào, và sẽ mất khỏi góc nhìn của replica nếu đĩa của primary không thể phục hồi và replica được promote.

Synchronous replication (đồng bộ) đóng khoảng hở đó lại: primary chờ một hoặc nhiều replica xác nhận đã nhận (và, tùy cấu hình, đã ghi hoặc flush) WAL của một transaction trước khi trả về “commit thành công” cho client. Cấu hình bằng synchronous_standby_names trên primary, đặt tên replica nào được tính là synchronous, và synchronous_commit kiểm soát mức độ đảm bảo mạnh yếu (on chờ flush xuống đĩa trên standby; remote_write chỉ chờ OS đã nhận dữ liệu, chưa fsync; remote_apply — mạnh nhất — chờ đến khi standby đã thực sự replay transaction, khiến nó hiển thị luôn cho các truy vấn đọc ở đó).

Asynchronous replicationSynchronous replication
Độ trễ commitNhanh — primary không chờ replicaChậm hơn — primary chờ ít nhất một replica xác nhận
Độ bền khi primary lỗiCó khoảng hở nhỏ có thể mất dữ liệu (các commit gần nhất chưa kịp gửi đi)Không mất dữ liệu với các transaction đã được xác nhận commit
Ảnh hưởng đến availabilityKhông — primary vẫn nhận write kể cả khi mọi replica đều downNếu replica synchronous duy nhất down hoặc chậm, write trên primary sẽ bị đứng (trừ khi synchronous_standby_names dùng quorum với ngưỡng thấp hơn)
Dùng điển hìnhRead replica, replica DR liên vùng nơi vài ms/giây độ trễ là chấp nhận đượcCác workload tài chính/tuân thủ quy định, hoặc một replica gần dùng riêng làm mục tiêu failover không mất dữ liệu
Cấu hìnhHành vi mặc định — không đặt synchronous_standby_namessynchronous_standby_names = 'FIRST 1 (replica1, replica2)' (hoặc ANY n (...) cho quorum commit)

Một pattern production phổ biến là chạy một replica synchronous trong cùng hoặc gần availability zone (đảm bảo độ bền mà không tốn nhiều độ trễ, vì round-trip mạng ngắn) và một hoặc nhiều replica asynchronous ở xa hơn cho DR và read scaling, nơi việc chờ round-trip liên vùng cho mỗi commit sẽ quá chậm để chấp nhận.

Khái niệm chính

Logical replication

Streaming replication replicate toàn bộ cluster như một bản sao vật lý chính xác. Logical replication tiếp cận theo cách khác: nó decode WAL trở lại thành một luồng các thay đổi ở mức logic — “insert dòng này vào bảng này,” “update dòng này thành các giá trị này,” “xóa dòng này” — và áp dụng các thay đổi đó ở đích như các thao tác SQL bình thường, dùng mô hình publish/subscribe.

-- Trên database nguồn (ví dụ "app_primary")
CREATE PUBLICATION orders_pub FOR TABLE orders, order_items;

-- Trên database đích (có thể là một phiên bản PostgreSQL major khác,
-- một server khác, thậm chí một schema đã đánh index/partition khác)
CREATE SUBSCRIPTION orders_sub
  CONNECTION 'host=source.db.internal dbname=app_primary user=replicator password=...'
  PUBLICATION orders_pub;

-- Kiểm tra trạng thái và độ trễ của subscription trên đích
SELECT subname, received_lsn, latest_end_lsn, last_msg_receipt_time
FROM pg_stat_subscription;

Mô hình này mở khóa những thứ mà physical replication về bản chất không thể làm được:

Đánh đổi: logical replication có overhead lớn hơn (decode WAL thành thay đổi logic rồi replay dưới dạng SQL tốn kém hơn replay page vật lý), không tự động replicate DDL (thay đổi schema trên nguồn nhìn chung phải được áp dụng thủ công lên subscriber, đúng thứ tự), không replicate sequence theo mặc định, và yêu cầu PRIMARY KEY hoặc REPLICA IDENTITY trên các bảng được publish để thao tác UPDATE/DELETE có thể khớp đúng dòng ở phía subscriber.

So sánh: streaming (physical) vs. logical replication

Streaming (physical) replicationLogical replication
Cái gì được replicateToàn bộ cluster — mọi database, bảng, index, byte-for-byteCác bảng được chọn (hoặc tập con cột/dòng), chọn theo từng publication
Độ chi tiếtCả cluster, không có tính chọn lọcCấp bảng (và chi tiết hơn, với bộ lọc dòng/cột)
Hỗ trợ xuyên phiên bảnKhông — replica phải chạy cùng major version với primaryCó — cơ chế phổ biến cho nâng cấp major version
Schema đíchPhải giống hệt (là bản sao vật lý)Có thể khác — cho phép thêm index, cột, partition
Replicate DDLTự động (tất cả chỉ là WAL)Thủ công — DDL phải áp dụng riêng ở cả hai bên
Mục tiêu failoverCó — có thể promote thành primary độc lập hoàn chỉnhThường không dùng làm mục tiêu failover một mình
OverheadThấp hơn — replay page vật lýCao hơn — decode WAL + apply logic
Trường hợp dùng điển hìnhHA, DR, read replica, redundancy toàn clusterLuồng ETL/analytics, phân phối dữ liệu một phần, nâng cấp major gần/không downtime, gộp nhiều nguồn

Failover: thủ công và tự động

Khi primary lỗi, phải có thứ gì đó thay thế làm primary mới — đây gọi là failover. Hình thức đơn giản nhất là thủ công: một operator nhận thấy primary đã down, chọn replica bắt kịp nhất, và chạy pg_ctl promote (hoặc pg_promote() từ SQL) trên đó. Promotion kết thúc chế độ recovery, replay hết WAL còn lại, và khiến replica bắt đầu nhận write như một primary bình thường. Failover thủ công vẫn hoạt động, nhưng chậm — phụ thuộc vào việc con người nhận ra sự cố, quyết định replica nào an toàn để promote, và cập nhật bất kỳ cấu hình ứng dụng hoặc DNS nào đang trỏ tới primary cũ — thường mất vài phút là ít, và thường lâu hơn nhiều nếu xảy ra lúc 3 giờ sáng.

Automated failover management loại bỏ con người khỏi đường găng: một phần mềm liên tục giám sát sức khỏe của primary, và khi xác nhận nó đã down, tự động chọn replica tốt nhất và promote nó, không cần can thiệp thủ công. Công cụ thống trị cho việc này trong hệ sinh thái PostgreSQL hiện nay là Patroni.

Kiến trúc Patroni

Patroni bản thân không phải là một database hay một proxy — nó là một agent process chạy song song với mỗi instance PostgreSQL trong cluster (primary và mọi replica), quản lý vòng đời của instance đó: khởi động, dừng, cấu hình nó thành primary hay replica, chạy health check định kỳ, và — quan trọng nhất — quyết định ai nên là primary tại bất kỳ thời điểm nào.

Quyết định đó không thể được thực hiện an toàn nếu mỗi node tự quyết định độc lập (“tôi không nghe được gì từ primary một lúc rồi, tôi sẽ tự promote mình”) vì một network partition có thể khiến một primary hoàn toàn khỏe mạnh trông như không thể truy cập được từ góc nhìn của một replica, trong khi primary vẫn đang phục vụ tốt các client khác. Nếu cả primary gốc lẫn một replica đã được promote đều tin rằng mình là primary và cả hai đều nhận write, đó là split-brain — hai bản sao dữ liệu phân kỳ, không có cách nào gộp lại, mất dữ liệu hoặc hỏng dữ liệu âm thầm. Ngăn chặn split-brain là bài toán khó nhất trong HA của database, và đó là lý do Patroni không cố giải quyết leader election bằng các heuristic tùy tiện.

Thay vào đó, Patroni giao việc leader election cho một Distributed Consensus Store (DCS) — thường là etcd hoặc Consul (ZooKeeper cũng được hỗ trợ). Đây là các hệ thống được xây dựng chuyên biệt xoay quanh các thuật toán đồng thuận phân tán (Raft, trong trường hợp của etcd) đảm bảo một câu trả lời duy nhất, được đồng thuận trên toàn cluster cho câu hỏi “ai đang giữ lock ngay bây giờ,” ngay cả khi có network partition — vì phần lớn (quorum) các node DCS phải đồng ý trước khi bất kỳ thay đổi trạng thái nào được chấp nhận. Agent Patroni trên primary hiện tại liên tục gia hạn một leader lock/lease trong DCS; nếu nó không gia hạn được (vì node down, hoặc bị phân tách khỏi phần lớn DCS), lease hết hạn, và các agent Patroni trên các replica còn sống sẽ đua nhau giành lấy nó — bất kỳ agent nào thành công sẽ trở thành leader mới và promote instance PostgreSQL của mình. Vì bản thân DCS chỉ chấp nhận một người chiến thắng duy nhất dưới quorum, việc hai node cùng giữ lease một lúc là bất khả thi về mặt cấu trúc — đó chính xác là đảm bảo cần thiết để loại trừ split-brain.

Ở mức tổng quan, các thành phần bao gồm:

Các giải pháp thay thế Patroni

Patroni không phải công cụ automated failover duy nhất, dù nó đã trở thành lựa chọn thống trị trong hệ sinh thái PostgreSQL, với mức độ áp dụng rộng nhất, phát triển tích cực nhất, và tích hợp sâu nhất với công cụ cloud-native (Kubernetes operator, Helm chart, v.v.).

Cả hai vẫn khả thi trong các tình huống cụ thể, nhưng các triển khai HA PostgreSQL mới hiện nay phần lớn mặc định chọn Patroni, và đó là khuyến nghị an toàn mặc định trừ khi có lý do cụ thể để chọn khác.

Connection routing và pooling

Automated failover giải quyết node nào nên là primary, nhưng client vẫn cần tìm ra node đó — và node đó thay đổi theo thời gian. Đây là nơi một tầng routing đặt trước cluster.

HAProxy thường được đặt trước một cluster Patroni như một TCP load balancer định tuyến kết nối đến bất kỳ node nào hiện đang là primary. Nó không cần hiểu gì về giao thức replication của PostgreSQL — nó chỉ đơn giản poll endpoint health-check REST API của Patroni trên mỗi node (ví dụ, GET /primary chỉ trả về HTTP 200 từ leader hiện tại, và mã khác từ replica), và định tuyến traffic chỉ đến node đang trả lời là primary. Khi failover xảy ra, endpoint REST của Patroni trên leader mới bắt đầu trả về 200, health check của HAProxy nhận ra điều đó trong khoảng interval đã cấu hình, và traffic chuyển sang primary mới tự động — thường trong vài giây. Các cổng listener riêng biệt thường được định nghĩa cho traffic “write” (chỉ định tuyến đến primary) và traffic “read” (định tuyến đến replica, thường round-robin, để read-scaling). Cơ chế reverse-proxy và health-check tổng quát của HAProxy được trình bày ở DevOps — Web Servers & Proxies; phần đặc thù cho PostgreSQL chỉ đơn giản là endpoint health-check nào nó poll và cách nó diễn giải response.

PgBouncer giải quyết một vấn đề trực giao: chi phí kết nối. Mô hình kết nối của PostgreSQL sinh ra một process OS đầy đủ cho mỗi kết nối client, và mỗi process mang overhead bộ nhớ đáng kể cùng một giới hạn thực (max_connections, thường vài trăm) — điều này khiến việc kết nối trực tiếp đến PostgreSQL từ, chẳng hạn, một ứng dụng web với hàng trăm worker process ngắn hạn mỗi cái giữ vài kết nối, trở nên tốn kém. PgBouncer đứng giữa client và PostgreSQL, chấp nhận nhiều kết nối client nhẹ và multiplex chúng xuống một pool nhỏ hơn nhiều các kết nối backend thật.

PgBouncer hỗ trợ ba chế độ pooling:

Chế độHành viGiữ được tính năng cấp session?Dùng điển hình
Session poolingMột kết nối backend được gán cho client suốt cả session (đến khi ngắt kết nối)Có — đầy đủ ngữ nghĩa sessionAn toàn nhất, nhưng gần như không giảm số kết nối so với kết nối trực tiếp
Transaction poolingMột kết nối backend chỉ được gán trong thời gian của một transaction, rồi trả lại pool cho client khác dùngKhông — trạng thái session (prepared statement, biến SET, advisory lock giữ qua nhiều transaction) không tồn tại đượcLựa chọn tiêu chuẩn trong production — giảm mạnh số kết nối backend thật
Statement poolingKết nối backend được trả lại pool sau mỗi câu lệnh riêng lẻ, kể cả trong một transactionKhông — thậm chí không hỗ trợ đáng tin cậy transaction nhiều câu lệnhHiếm gặp; chủ yếu cho các workload không trạng thái cụ thể (ví dụ một số công cụ analytics đứng sau PgBouncer)

Transaction pooling là lựa chọn phổ biến trong production vì nó nắm bắt gần như toàn bộ lợi ích giảm kết nối — hầu hết kết nối client dành phần lớn thời gian ở trạng thái idle giữa các transaction, chứ không phải giữa transaction — trong khi vẫn an toàn hơn statement pooling. Tuy nhiên chi phí của nó là có thật: bất cứ thứ gì dựa vào trạng thái cấp session đều bị hỏng hoặc hoạt động khó lường, đáng chú ý nhất là prepared statement phía server (PREPARE), SET cấp session (khác với SET LOCAL trong một transaction, vốn có phạm vi transaction và an toàn), advisory lock dự định giữ qua nhiều transaction, và LISTEN/NOTIFY. Code ứng dụng và ORM/driver cần lưu ý điều này — nhiều driver mặc định dùng prepared statement phía server và cần cấu hình rõ ràng để tránh dùng chúng khi ở chế độ transaction pooling (cache statement phía client, hoặc tắt hẳn prepared statement, là các cách khắc phục phổ biến).

Keepalived

Keepalived cung cấp một cơ chế HA đơn giản hơn nhiều dựa trên VRRP (Virtual Router Redundancy Protocol): một địa chỉ IP ảo được chia sẻ giữa một nhóm nhỏ các node, và các instance Keepalived trên mỗi node thương lượng xem ai đang “sở hữu” IP đó, chuyển nó sang node khác trong nhóm nếu chủ hiện tại ngừng phản hồi health check. Ứng dụng chỉ đơn giản kết nối tới IP ảo mà không cần biết node vật lý nào đang giữ nó.

So với Patroni + HAProxy, Keepalived đơn giản hơn nhiều để thiết lập và suy luận, nhưng nó là một công cụ thô hơn — nó không biết gì về trạng thái replication của PostgreSQL, vị trí WAL, hay replica nào bắt kịp nhất; nó chỉ chuyển một địa chỉ IP dựa trên health check do operator định nghĩa. Đôi khi nó được dùng trong các thiết lập HA nhỏ hơn (gắn IP ảo vào bất kỳ node nào một health check đơn giản coi là khỏe mạnh) hoặc thỉnh thoảng đi kèm Patroni + HAProxy như một cách cung cấp IP nổi cho chính tầng HAProxy, để tầng load balancer cũng không phải là single point of failure. Bản thân nó không phải là thứ thay thế cho một công cụ hiểu topology replication của PostgreSQL.

Distributed consensus store: etcd và Consul

Cả etcdConsul đều triển khai thuật toán đồng thuận Raft để duy trì một key-value store nhất quán mạnh, được replicate trên một số lẻ node (thường là 3 hoặc 5), chịu được lỗi của thiểu số node mà không mất tính nhất quán hay availability. Patroni dùng bất kỳ cái nào được cấu hình làm DCS của nó đơn thuần như một nơi để lưu trạng thái cluster và, quan trọng hơn, thực hiện leader election một cách nguyên tử qua một lock dựa trên TTL: leader hiện tại phải định kỳ gia hạn lease của mình, và nếu không làm được, lease hết hạn và trở nên sẵn sàng cho node khác giành lấy — với thuật toán đồng thuận đảm bảo chỉ một node có thể giữ nó tại một thời điểm, ngay cả khi có network partition. etcd có xu hướng là lựa chọn phổ biến hơn trong các môi trường Kubernetes-native (nó vốn đã là thứ chính Kubernetes dùng cho trạng thái cluster), trong khi Consul phổ biến trong các môi trường đã chuẩn hóa với công cụ HashiCorp. Các khái niệm tổng quát về quorum, redundancy, và tránh single point of failure — vốn là động lực để chạy chính DCS như một cluster 3 hoặc 5 node (thay vì một node đơn) — phản chiếu các nguyên lý HA rộng hơn được trình bày ở Network Engineer — Redundancy & High Availability.

Đi qua một thiết lập stateful đơn giản

Xem xét một topology production phổ biến: một primary, hai replica streaming, Patroni quản lý cả ba, một cluster etcd 3 node làm DCS, và HAProxy đứng trước, tất cả trong một data center (lý tưởng là, một trong các replica hoặc một node thứ tư đặt ở vùng khác cho DR).

Ứng dụng client không bao giờ kết nối trực tiếp đến hostname của PostgreSQL — nó kết nối đến địa chỉ ảo của HAProxy trên cổng “write”. HAProxy poll endpoint REST của Patroni trên mỗi node; chỉ primary hiện tại trả lời khỏe mạnh trên endpoint primary-check, nên HAProxy định tuyến kết nối tới đó. Nếu client đang đọc dữ liệu có thể chấp nhận độ trễ nhẹ, nó kết nối đến một cổng “read” riêng mà HAProxy load-balance qua các replica (cũng được điều khiển bởi trạng thái/vai trò do Patroni báo cáo).

Giờ giả sử host của primary mất điện. Agent Patroni trên node đó ngừng gia hạn leader lease của nó trong etcd. Trong khoảng TTL đã cấu hình (thường vài giây), lease hết hạn. Các agent Patroni trên hai replica đều nhận thấy lease đang trống và cố giành lấy nó; đồng thuận Raft của etcd đảm bảo chỉ một agent thành công. Agent đó kiểm tra replica của chính nó đã bắt kịp đủ chưa (hoặc, nếu có nhiều ứng viên khả thi, logic election của Patroni ưu tiên bất kỳ ai có replication lag thấp nhất / vị trí WAL tiên tiến nhất), sau đó promote instance PostgreSQL của mình thành primary — replay hết WAL còn lại và mở cho write. Endpoint REST của Patroni trên đó ngay lập tức bắt đầu báo cáo mình là leader. Lần poll health check tiếp theo của HAProxy (trong khoảng interval đã cấu hình, thường vài giây) nhận ra điều này và bắt đầu định tuyến các kết nối write mới đến node vừa được promote. Các kết nối hiện có tới primary đã chết bị ngắt và kết nối lại (logic retry ở tầng ứng dụng và connection pooling rất quan trọng ở đây); khi host của primary cũ khôi phục lại, Patroni nhận thấy một node khác giờ là leader và cấu hình lại node vừa hồi phục để tham gia lại như một replica của primary mới, thay vì tranh giành quyền lãnh đạo — đây chính xác là kịch bản split-brain mà toàn bộ thiết kế dựa trên DCS tồn tại để ngăn chặn. Toàn bộ chuỗi sự kiện này thường hoàn thành trong chưa đầy một phút, so với nhiều phút (hoặc lâu hơn) nếu failover thủ công do con người thực hiện.

Best Practices

Kích thước và topology

Tinh chỉnh synchronous replication

Vệ sinh vận hành Patroni

PgBouncer và pooling

Lưu ý về logical replication

Tài liệu tham khảo

Part of the PostgreSQL DBA Roadmap knowledge base.

Overview

A single PostgreSQL instance is a single point of failure. The disk can fail, the host can crash, a data center can lose power, and every one of those events takes the database — and every application that depends on it — down with it. Replication is the practice of maintaining one or more additional copies of the database on separate servers, kept continuously in sync with the primary, so that the system as a whole survives the failure of any one node.

Replication earns its keep for three distinct reasons, and it’s worth being precise about which one a given deployment is actually solving for, because the right architecture differs:

These three motivations are complementary, not mutually exclusive, and a mature production setup usually gets all three from the same physical topology: replicas serve read traffic day-to-day, stand ready to be promoted if the primary fails, and at least one of them lives far enough away to survive a site-level disaster.

This note covers PostgreSQL’s two replication mechanisms (streaming/physical and logical), how automated failover tooling like Patroni turns a set of independent replicas into a self-healing cluster, and the connection-routing layer (HAProxy, PgBouncer, Keepalived) that makes failover invisible to applications. Backup and point-in-time recovery — the complementary discipline of protecting against data loss rather than downtime — is covered in Backup & Recovery.

Fundamentals

WAL is the foundation of replication

PostgreSQL doesn’t have a separate replication protocol bolted onto the side of the database — it reuses the exact same mechanism the database already uses to guarantee crash safety: the Write-Ahead Log (WAL). As covered in Storage Internals & VACUUM, every change to the database is first recorded as a WAL record before the corresponding data pages are modified on disk. This is what lets PostgreSQL recover cleanly after a crash — replay the WAL since the last checkpoint and the data files are brought back to a consistent state.

Streaming replication simply extends this idea across a network: instead of only writing WAL to local disk and replaying it during local crash recovery, the primary streams its WAL records to one or more replicas as they’re generated, and each replica continuously replays that WAL against its own copy of the data files — permanently, not just after a crash. A replica is, in a very real sense, a server stuck in a permanent, incremental recovery loop. This is why replication in PostgreSQL requires no separate change-tracking mechanism, no triggers, no extra tables: WAL was always a complete, ordered record of every physical change to the cluster, and replication is just shipping that record somewhere else and replaying it there too.

Because it operates on WAL — physical page-level changes — this style of replication is called physical replication, and a replica built this way is a byte-for-byte copy of the primary’s data directory (modulo timing lag). It replicates everything in the cluster: every database, every table, every index, every sequence. There’s no way to replicate only some tables with physical replication — that granularity requires logical replication, covered below.

Setting up streaming replication (conceptual walkthrough)

A minimal streaming replication setup involves a handful of moving pieces:

  1. A replication user and permission on the primary — an account with the REPLICATION privilege that replicas authenticate as.
  2. pg_hba.conf entries on the primary allowing that user to connect from the replica’s address, using the replication pseudo-database.
  3. wal_level = replica (or logical, which is a superset) on the primary, so WAL contains enough information to reconstruct data — the default minimal level omits information replicas need.
  4. A base backup of the primary’s data directory, taken with pg_basebackup, used to initialize the replica so it starts from a known-consistent snapshot rather than an empty directory.
  5. primary_conninfo on the replica (historically stored in recovery.conf; since PostgreSQL 12 it’s a normal GUC in postgresql.conf, combined with a standby.signal file to tell the server to start in standby mode) pointing at the primary’s connection details.
  6. Once started in standby mode, the replica connects to the primary as a walsender/walreceiver pair, requests WAL starting from the position recorded in the base backup, and begins continuously streaming and replaying from there.
# On the replica host: clone the primary's data directory and
# set it up as a streaming standby in one step
pg_basebackup -h primary.db.internal -U replicator -D /var/lib/postgresql/16/main \
  -Fp -Xs -P -R
# -R writes standby.signal and primary_conninfo automatically (PG 12+)
-- On the primary: check connected replicas and how far behind each one is
SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn,
       replay_lag
FROM pg_stat_replication;

Synchronous vs. asynchronous replication

By default, streaming replication is asynchronous: the primary commits a transaction locally, acknowledges it to the client, and then ships the corresponding WAL to replicas whenever it gets around to it (typically milliseconds later, but with no guarantee). This means that if the primary crashes at exactly the wrong instant — after committing but before the WAL for that commit reaches any replica — that transaction is durably recorded on the primary’s disk but has not yet reached any replica, and is lost from the replica’s point of view if the primary’s disk is unrecoverable and the replica is promoted.

Synchronous replication closes that window: the primary waits for one or more replicas to confirm they’ve received (and, depending on configuration, written or flushed) the WAL for a transaction before returning “commit successful” to the client. This is configured with synchronous_standby_names on the primary, naming which replicas count as synchronous, and synchronous_commit controlling how strong the guarantee is (on waits for flush to disk on the standby; remote_write only waits for the OS to have the data, not fsynced; remote_apply — the strongest — waits until the standby has actually replayed the transaction, making it visible to read queries there too).

Asynchronous replicationSynchronous replication
Commit latencyFast — primary doesn’t wait for replicasSlower — primary waits for at least one replica to acknowledge
Durability on primary failureSmall window of possible data loss (recent commits not yet shipped)No data loss for transactions that were acknowledged as committed
Availability impactNone — primary keeps accepting writes even if all replicas are downIf the only synchronous replica is down or slow, writes on the primary stall (unless synchronous_standby_names uses quorum with a lower threshold)
Typical useRead replicas, cross-region DR replicas where a few ms/seconds of lag is acceptableFinancial/compliance workloads, or a nearby replica used specifically as a zero-data-loss failover target
ConfigurationDefault behavior — no synchronous_standby_names setsynchronous_standby_names = 'FIRST 1 (replica1, replica2)' (or ANY n (...) for quorum commit)

A common production pattern is to run one synchronous replica in the same or a nearby availability zone (durability without much latency penalty, since network round-trip is small) and one or more asynchronous replicas further away for DR and read scaling, where waiting for a cross-region round-trip on every commit would be too slow to accept.

Key Concepts

Logical replication

Streaming replication replicates the entire cluster as an exact physical copy. Logical replication takes a different approach: it decodes WAL back into a stream of logical changes — “insert this row into this table,” “update this row to have these values,” “delete this row” — and applies those changes on the destination as ordinary SQL-level operations, using a publish/subscribe model.

-- On the source database (e.g. "app_primary")
CREATE PUBLICATION orders_pub FOR TABLE orders, order_items;

-- On the destination database (can be a different major PostgreSQL version,
-- a different server, even a differently-indexed/partitioned schema)
CREATE SUBSCRIPTION orders_sub
  CONNECTION 'host=source.db.internal dbname=app_primary user=replicator password=...'
  PUBLICATION orders_pub;

-- Check subscription status and lag on the destination
SELECT subname, received_lsn, latest_end_lsn, last_msg_receipt_time
FROM pg_stat_subscription;

This model unlocks things physical replication fundamentally cannot do:

The trade-offs: logical replication has more overhead (decoding WAL into logical changes and replaying as SQL is more expensive than physically replaying pages), doesn’t replicate DDL automatically (schema changes on the source must generally be applied to subscribers manually, in the right order), doesn’t replicate sequences by default, and requires a PRIMARY KEY or REPLICA IDENTITY on published tables so that UPDATE/DELETE operations can be matched to the correct row on the subscriber side.

Comparison: streaming (physical) vs. logical replication

Streaming (physical) replicationLogical replication
What’s replicatedEntire cluster — every database, table, index, byte-for-byteSelected tables (or subset of columns/rows), chosen per publication
GranularityWhole cluster, no selectivityTable-level (and finer, with row/column filters)
Cross-version supportNo — replica must run the same major version as the primaryYes — common mechanism for major version upgrades
Destination schemaMust be identical (it’s a physical copy)Can differ — extra indexes, columns, partitioning allowed
DDL replicationAutomatic (it’s all just WAL)Manual — DDL must be applied on both sides separately
Failover targetYes — can be promoted to a full standalone primaryNot typically used as a failover target on its own
OverheadLower — physical page replayHigher — WAL decoding + logical apply
Typical use caseHA, DR, read replicas, full cluster redundancyETL/analytics feeds, partial data distribution, zero/near-zero-downtime major upgrades, multi-source consolidation

Failover: manual and automated

When a primary fails, something has to take over as the new primary — this is failover. The simplest form is manual: an operator notices the primary is down, picks the most caught-up replica, and runs pg_ctl promote (or pg_promote() from SQL) on it. Promotion ends recovery mode, replays any remaining WAL, and makes the replica start accepting writes as a normal primary. Manual failover works, but it’s slow — it depends on a human noticing the outage, deciding which replica is safe to promote, and updating whatever application configuration or DNS pointed at the old primary — typically minutes at best, often much longer at 3 a.m.

Automated failover management removes the human from the critical path: a piece of software continuously monitors the primary’s health, and when it’s confirmed down, automatically selects the best replica and promotes it, with no manual intervention. The dominant tool for this in the PostgreSQL ecosystem today is Patroni.

Patroni architecture

Patroni is not itself a database or a proxy — it’s an agent process that runs alongside each PostgreSQL instance in a cluster (primary and every replica) and manages that instance’s lifecycle: starting it, stopping it, configuring it as a primary or a replica, running periodic health checks, and — critically — deciding who should be the primary at any given moment.

That decision can’t be made safely by each node deciding independently (“I haven’t heard from the primary in a while, I’ll promote myself”) because a network partition can make a perfectly healthy primary look unreachable to a replica, while the primary is still happily serving other clients. If both the original primary and a promoted replica believe they’re the primary and both accept writes, that’s split-brain — two divergent copies of the data, no way to merge them, silent data loss or corruption. Preventing split-brain is the single hardest problem in database HA, and it’s why Patroni doesn’t try to solve leader election with ad hoc heuristics.

Instead, Patroni delegates leader election to a Distributed Consensus Store (DCS) — typically etcd or Consul (ZooKeeper is also supported). These are systems purpose-built around distributed consensus algorithms (Raft, in etcd’s case) that guarantee a single, cluster-wide agreed-upon answer to “who holds the lock right now,” even in the presence of network partitions — because a majority (quorum) of DCS nodes must agree before any state change is accepted. The Patroni agent on the current primary continuously renews a leader lock/lease in the DCS; if it fails to renew it (because the node is down, or partitioned away from the majority of the DCS), the lease expires, and the Patroni agents on the surviving replicas race to acquire it — whichever succeeds becomes the new leader and promotes its PostgreSQL instance. Because the DCS itself only accepts a single winner under quorum, it’s structurally impossible for two nodes to both hold the lease at once, which is exactly the guarantee needed to rule out split-brain.

At a high level, the pieces are:

Patroni alternatives

Patroni is not the only automated failover tool, though it has become the dominant choice in the PostgreSQL ecosystem, with the widest adoption, the most active development, and the deepest integration with cloud-native tooling (Kubernetes operators, Helm charts, etc.).

Both remain viable in specific situations, but new PostgreSQL HA deployments overwhelmingly default to Patroni today, and it’s the safe default recommendation absent a specific reason to choose otherwise.

Connection routing and pooling

Automated failover solves which node should be primary, but clients still need to find that node — and that node changes over time. This is where a routing layer sits in front of the cluster.

HAProxy is commonly placed in front of a Patroni cluster as a TCP load balancer that routes connections to whichever node is currently primary. It doesn’t need to understand PostgreSQL’s replication protocol at all — it simply polls each node’s Patroni REST API health-check endpoint (e.g., GET /primary returns HTTP 200 only from the current leader, and a different code from replicas), and routes traffic only to the node currently answering as primary. When a failover happens, Patroni’s REST endpoint on the new leader starts responding 200, HAProxy’s health check picks that up within its configured interval, and traffic shifts to the new primary automatically — usually within seconds. Separate listener ports are typically defined for “write” traffic (routed only to the primary) and “read” traffic (routed to replicas, often round-robin, for read-scaling). HAProxy’s general reverse-proxy and health-check mechanics are covered in DevOps — Web Servers & Proxies; the PostgreSQL-specific part is simply what health-check endpoint it polls and how it interprets the response.

PgBouncer solves an orthogonal problem: connection cost. PostgreSQL’s connection model spawns a full OS process per client connection, and each process carries meaningful memory overhead and a real limit (max_connections, typically in the hundreds) — this makes PostgreSQL expensive to connect to directly from, say, a web application with hundreds of short-lived worker processes each holding a handful of connections. PgBouncer sits between clients and PostgreSQL, accepting many lightweight client connections and multiplexing them down onto a much smaller pool of real backend connections.

PgBouncer supports three pooling modes:

ModeBehaviorSession features preserved?Typical use
Session poolingA backend connection is assigned to a client for the entire session (until it disconnects)Yes — full session semanticsSafest, but barely reduces connection count vs. direct connection
Transaction poolingA backend connection is assigned only for the duration of one transaction, then returned to the pool for reuse by another clientNo — session state (prepared statements, SET variables, advisory locks held across transactions) doesn’t surviveThe standard production choice — large reduction in real backend connections
Statement poolingA backend connection is returned to the pool after each individual statement, even within a transactionNo — cannot even reliably support multi-statement transactionsRare; mostly for specific stateless workloads (e.g., some PgBouncer-fronted analytics tools)

Transaction pooling is the common production choice because it captures nearly all of the connection-reduction benefit — most client connections spend the overwhelming majority of their time idle between transactions, not mid-transaction — while being safer than statement pooling. Its cost is real, though: anything relying on session-level state breaks or behaves unpredictably, most notably server-side prepared statements (PREPARE), session-level SET (as opposed to SET LOCAL inside a transaction, which is transaction-scoped and safe), advisory locks meant to be held across transactions, and LISTEN/NOTIFY. Application code and ORMs/drivers need to be aware of this — many drivers default to server-side prepared statements and need explicit configuration to avoid them under transaction pooling (Nginx-style client-side statement caching, or disabling prepared statements entirely, are common workarounds).

Keepalived

Keepalived offers a much simpler HA mechanism based on VRRP (Virtual Router Redundancy Protocol): a virtual IP address is shared among a small group of nodes, and Keepalived instances on each node negotiate which one currently “owns” that IP, moving it to another node within the group if the current owner stops responding to health checks. Applications simply connect to the virtual IP without knowing which physical node currently holds it.

Compared to Patroni + HAProxy, Keepalived is much simpler to set up and reason about, but it’s a blunter instrument — it doesn’t know anything about PostgreSQL replication state, WAL position, or which replica is most caught up; it just moves an IP address based on a health check the operator defines. It’s sometimes used in smaller HA setups (attaching the virtual IP to whichever node a simple health check considers healthy) or occasionally alongside Patroni + HAProxy as a way of providing a floating IP for the HAProxy layer itself, so the load balancer tier is also not a single point of failure. It is not, on its own, a substitute for something that understands PostgreSQL replication topology.

Distributed consensus stores: etcd and Consul

Both etcd and Consul implement the Raft consensus algorithm to maintain a strongly consistent, replicated key-value store across an odd number of nodes (3 or 5 being typical), tolerating the failure of a minority of nodes without losing consistency or availability. Patroni uses whichever one is configured as its DCS purely as a place to store cluster state and, crucially, to perform leader election atomically via a TTL-based lock: the current leader must periodically renew its lease, and if it fails to, the lease expires and becomes available for another node to acquire — with the consensus algorithm guaranteeing only one node can hold it at a time, even during a network partition. etcd tends to be the more common choice in Kubernetes-native environments (it’s already what Kubernetes itself uses for cluster state), while Consul is common in environments already standardized on HashiCorp tooling. The general concepts of quorum, redundancy, and avoiding single points of failure that motivate running the DCS itself as a 3- or 5-node cluster (rather than a single node) mirror the broader HA principles covered in Network Engineer — Redundancy & High Availability.

A simple stateful setup walkthrough

Consider a common production topology: one primary, two streaming replicas, Patroni managing all three, a 3-node etcd cluster as the DCS, and HAProxy in front, all within one data center (with, ideally, one of the replicas or a fourth node located in a separate region for DR).

A client application never connects directly to a PostgreSQL hostname — it connects to HAProxy’s virtual address on the “write” port. HAProxy polls each node’s Patroni REST endpoint; only the current primary answers healthy on the primary-check endpoint, so HAProxy routes the connection there. If the client is doing reads that can tolerate slight staleness, it connects to a separate “read” port that HAProxy load-balances across the replicas (again driven by their Patroni-reported health/role).

Now suppose the primary’s host loses power. Patroni’s agent on that node stops renewing its leader lease in etcd. Within the configured TTL (commonly a few seconds), the lease expires. The Patroni agents on the two replicas each notice the lease is free and attempt to acquire it; etcd’s Raft consensus ensures only one succeeds. That agent checks that its own replica is sufficiently caught up (or, if multiple candidates are viable, Patroni’s election logic prefers whichever has the least replication lag / most advanced WAL position), then promotes its PostgreSQL instance to primary — replaying any remaining WAL and opening for writes. Its Patroni REST endpoint immediately starts reporting itself as the leader. HAProxy’s next health-check poll (within its configured interval, typically a few seconds) notices this and starts routing new write connections to the newly promoted node. Existing connections to the dead primary are dropped and reconnect (application-level retry logic and connection pooling matter a lot here); once the old primary’s host comes back, Patroni notices a different node is now the leader and reconfigures the recovered node to rejoin as a replica of the new primary, rather than fighting over leadership — which is exactly the split-brain scenario the whole DCS-based design exists to prevent. End-to-end, this whole sequence typically completes in well under a minute, versus the many minutes (or longer) a manual, human-driven failover would take.

Best Practices

Sizing and topology

Synchronous replication tuning

Patroni operational hygiene

PgBouncer and pooling

Logical replication caveats

References