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:
- Read scaling. Khi dữ liệu đã tồn tại trên replica, các truy vấn chỉ đọc — reporting, analytics, search, dashboard — có thể chạy trên replica thay vì tranh chấp tài nguyên với write traffic trên primary. Điều này giảm áp lực CPU, memory, I/O khỏi node duy nhất phải nhận write.
- High availability (HA). Nếu primary chết, một replica đã có (gần như) toàn bộ dữ liệu có thể được promote thành primary mới chỉ trong vài giây đến vài phút, thay vì hàng giờ nếu phải restore từ backup. Đây chính là thứ biến một sự cố phần cứng thành một gián đoạn ngắn thay vì một outage.
- Disaster recovery (DR). Một replica đặt ở availability zone, region, hoặc data center khác sẽ sống sót qua các sự cố xóa sổ cả một site — hỏa hoạn, network partition cấp vùng, sự cố của cloud provider — những thứ mà redundancy trong cùng một site không thể bảo vệ.
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:
- Một replication user và quyền trên primary — một tài khoản có quyền
REPLICATIONmà replica dùng để xác thực. - Các entry trong
pg_hba.conftrên primary cho phép user đó kết nối từ địa chỉ của replica, dùng pseudo-databasereplication. wal_level = replica(hoặclogical, 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 địnhminimalbỏ qua thông tin mà replica cần.- 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. primary_conninfotrên replica (trước đây lưu trongrecovery.conf; từ PostgreSQL 12 nó là một GUC bình thường trongpostgresql.conf, kết hợp với filestandby.signalđể báo server khởi động ở chế độ standby) trỏ đến thông tin kết nối của primary.- 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 replication | Synchronous replication | |
|---|---|---|
| Độ trễ commit | Nhanh — primary không chờ replica | Chậm hơn — primary chờ ít nhất một replica xác nhận |
| Độ bền khi primary lỗi | Có 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 availability | Không — primary vẫn nhận write kể cả khi mọi replica đều down | Nế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ình | Read replica, replica DR liên vùng nơi vài ms/giây độ trễ là chấp nhận được | Cá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ình | Hành vi mặc định — không đặt synchronous_standby_names | synchronous_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.
- Một publication, tạo trên nguồn bằng
CREATE PUBLICATION, định nghĩa một tập các bảng (hoặc mọi bảng, hoặc bảng khớp một pattern) mà thay đổi của chúng sẽ được cung cấp cho subscriber. - Một subscription, tạo trên đích bằng
CREATE SUBSCRIPTION, kết nối đến một publication và bắt đầu replicate thay đổi của nó — trước tiên thực hiện đồng bộ dữ liệu ban đầu (thực chất là mộtCOPYcác dòng hiện có), sau đó áp dụng các thay đổi tiếp diễn khi chúng xảy ra.
-- 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:
- Replicate một phần. Chỉ những bảng cụ thể (thậm chí những cột/dòng cụ thể qua mệnh đề
WHEREvà danh sách cột trên các phiên bản PostgreSQL mới hơn) có thể được publish, thay vì cả cluster. - Replicate xuyên phiên bản. Vì hoạt động ở cấp dòng logic thay vì định dạng page vật lý, nguồn và đích có thể chạy các phiên bản major PostgreSQL khác nhau — đây là cơ chế tiêu chuẩn để thực hiện nâng cấp major version gần như không downtime: dựng một subscriber trên phiên bản mới, để nó bắt kịp, rồi chuyển traffic sang.
- Replicate vào một cấu trúc khác. Bảng đích chỉ cần tên cột khớp và kiểu tương thích — nó có thể có index khác, thêm cột (miễn là cho phép
NULLhoặc có giá trị mặc định), partition khác, hoặc trigger phụ, tất cả những điều này không thể có với physical replication vì đích phải là bản sao byte-identical. - Kiến trúc nhiều nguồn / fan-in và fan-out. Một database có thể subscribe publication từ nhiều nguồn (gộp dữ liệu từ các primary sharded hoặc multi-tenant), hoặc một publication có thể nuôi nhiều subscriber.
Đá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) replication | Logical replication | |
|---|---|---|
| Cái gì được replicate | Toàn bộ cluster — mọi database, bảng, index, byte-for-byte | Các bảng được chọn (hoặc tập con cột/dòng), chọn theo từng publication |
| Độ chi tiết | Cả cluster, không có tính chọn lọc | Cấ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ản | Không — replica phải chạy cùng major version với primary | Có — cơ chế phổ biến cho nâng cấp major version |
| Schema đích | Phả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 DDL | Tự độ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 failover | Có — có thể promote thành primary độc lập hoàn chỉnh | Thường không dùng làm mục tiêu failover một mình |
| Overhead | Thấp hơn — replay page vật lý | Cao hơn — decode WAL + apply logic |
| Trường hợp dùng điển hình | HA, DR, read replica, redundancy toàn cluster | Luồ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 agent Patroni, mỗi PostgreSQL node một agent, mỗi agent quản lý instance cục bộ của nó và định kỳ báo cáo với DCS.
- DCS (etcd/Consul/ZooKeeper), giữ leader lock và cấu hình/trạng thái cluster — chạy như một cluster quorum riêng của nó (thường 3 hoặc 5 node) để bản thân nó cũng không phải là single point of failure.
- Health check, nơi Patroni đánh giá replication lag, vị trí WAL, và sức khỏe instance để quyết định replica nào là ứng viên promotion tốt nhất nếu cần failover (nó không chọn tùy ý — nó ưu tiên replica bắt kịp nhất).
- Một REST API được mỗi agent Patroni expose, báo cáo node đó hiện có phải là leader hay không — đây là thứ mà connection routing (bên dưới) dùng để tìm primary hiện tại.
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.).
- repmgr — một công cụ cũ hơn, đơn giản hơn từ 2ndQuadrant/EDB, quản lý topology replication và có thể thực hiện automatic failover, nhưng trước đây dựa vào một cách tiếp cận witness-node/quorum đơn giản hơn thay vì một DCS đầy đủ như etcd, khiến đảm bảo chống split-brain của nó yếu hơn Patroni trong một số kịch bản partition. Vẫn được dùng, đặc biệt trong các thiết lập đơn giản hoặc legacy.
- pg_auto_failover — xây dựng bởi Citus Data/Microsoft, dùng một monitor node chuyên dụng (bản thân nó cũng chỉ là một database PostgreSQL) để theo dõi sức khỏe node và điều phối failover. Dễ suy luận hơn so với thiết lập dựa trên DCS, nhưng bản thân sự sẵn sàng của monitor node lại trở thành một điểm cần cân nhắc thiết kế.
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 vi | Giữ được tính năng cấp session? | Dùng điển hình |
|---|---|---|---|
| Session pooling | Mộ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 session | An 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 pooling | Mộ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ùng | Không — trạng thái session (prepared statement, biến SET, advisory lock giữ qua nhiều transaction) không tồn tại được | Lựa chọn tiêu chuẩn trong production — giảm mạnh số kết nối backend thật |
| Statement pooling | Kết nối backend được trả lại pool sau mỗi câu lệnh riêng lẻ, kể cả trong một transaction | Không — thậm chí không hỗ trợ đáng tin cậy transaction nhiều câu lệnh | Hiế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ả etcd và Consul đề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
- Chạy DCS (etcd/Consul) như một cluster quorum riêng gồm 3 hoặc 5 node, lý tưởng là trên phần cứng hoặc VM tách biệt với các node PostgreSQL, để một sự cố host PostgreSQL không thể kéo theo cả thứ chịu trách nhiệm phát hiện sự cố đó.
- Giữ ít nhất một replica ở một failure domain thực sự tách biệt (rack khác, AZ khác, region khác) so với primary — một replica cùng rack bảo vệ khỏi lỗi đĩa/phần mềm nhưng không bảo vệ khỏi kiểu lỗi (mất điện, mạng, thiên tai) thực sự gây ra những outage tồi tệ nhất.
- Nếu có thể tránh, đừng dựa vào một replica duy nhất cho cả HA và DR cùng lúc — một replica synchronous gần để failover nhanh và một replica asynchronous xa hơn cho disaster recovery phục vụ các mục đích khác nhau và không nên gộp lại thành một node đảm nhận cả hai vai trò.
Tinh chỉnh synchronous replication
- Dùng
synchronous_standby_namesvới dạng quorum (ANY n (...)) thay vì đặt tên một replica cố định duy nhất, để việc mất một replica synchronous được chỉ định không làm write trên primary đứng hoàn toàn. - Hiểu rằng
synchronous_commit = onđảm bảo transaction sống sót qua lỗi primary, không phải nó hiển thị ngay lập tức ở mọi nơi — dùngremote_applynếu replica phải được đảm bảo phản ánh một transaction vừa commit ngay khi client nhận xác nhận (ví dụ, yêu cầu nhất quán read-after-write-your-own-write). - Giám sát
pg_stat_replication.replay_lagvà cảnh báo trên đó — một replica synchronous chậm (không down, chỉ lag) sẽ làm chậm mọi commit trên primary, và đây là một dạng lỗi tinh vi hơn so với một replica down hẳn.
Vệ sinh vận hành Patroni
- Không bao giờ chỉnh tay
postgresql.confhay trạng thái standby/promotion một khi Patroni đang quản lý một node — Patroni sở hữu cấu hình và vòng đời của instance, và thay đổi thủ công sẽ bị ghi đè âm thầm hoặc xung đột với vòng lặp reconciliation của Patroni. - Kiểm thử failover thường xuyên trong môi trường non-production (hoặc trong các cửa sổ bảo trì đã lên kế hoạch trên production) — một thiết lập HA chưa từng thực sự failover là một thiết lập HA với các giả định chưa được kiểm chứng.
- Theo dõi replication lag trên mọi replica, không chỉ replica hiện đang được chỉ định synchronous — một replica lag nặng là một ứng viên failover tồi, và logic election của chính Patroni phụ thuộc vào thông tin lag chính xác, kịp thời.
PgBouncer và pooling
- Mặc định dùng transaction pooling trong production trừ khi có lý do cụ thể, đã hiểu rõ để không dùng; kiểm tra code ứng dụng và cấu hình ORM xem có phụ thuộc vào trạng thái cấp session không (prepared statement,
SETcấp session, advisory lock qua nhiều statement) trước khi bật nó. - Chạy PgBouncer gần ứng dụng (thường đặt cùng host hoặc cùng AZ) để tránh thêm độ trễ mạng liên vùng cho mỗi truy vấn, và chạy một PgBouncer/pool riêng cho mỗi workload logic ở nơi cần cách ly (để một ứng dụng ồn ào không làm cạn kiệt connection pool của ứng dụng khác).
- Định kích thước
pool_sizedựa trên nhu cầu concurrency thực tế vàmax_connectionscủa PostgreSQL, không phải đoán mò — pool quá lớn làm mất ý nghĩa của pooling, pool quá nhỏ gây queueing và timeout phía client.
Lưu ý về logical replication
- Nhớ rằng DDL không được replicate — các migration schema phải được áp dụng cho cả publisher và subscriber, theo thứ tự tương thích (thường là các thay đổi bổ sung trên subscriber trước).
- Đảm bảo các bảng được publish có
PRIMARY KEYhoặcREPLICA IDENTITY FULL/REPLICA IDENTITY USING INDEXrõ ràng, nếu không replicateUPDATE/DELETEsẽ thất bại hoặc hoạt động sai. - Không dùng logical replication như một cơ chế failover một mình — nó không được thiết kế để giữ giá trị sequence, DDL, hay toàn bộ trạng thái cluster như physical replication; hãy dùng nó cho đúng thế mạnh của nó (replicate một phần, migrate xuyên phiên bản, phân phối dữ liệu).
Tài liệu tham khảo
- PostgreSQL Documentation — High Availability, Load Balancing, and Replication
- PostgreSQL Documentation — Log-Shipping Standby Servers (Streaming Replication)
- PostgreSQL Documentation — Logical Replication
- PostgreSQL Documentation — pg_basebackup
- Patroni Documentation
- PgBouncer Documentation — Pooling Modes
- HAProxy Documentation
- etcd Documentation — Raft Consensus
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:
- Read scaling. Once data exists on a replica, read-only queries — reporting, analytics, search, dashboards — can run against the replica instead of competing with write traffic on the primary. This offloads CPU, memory, and I/O pressure from the one node that must accept writes.
- High availability (HA). If the primary dies, a replica that already has (nearly) all of the data can be promoted to take over as the new primary in seconds to minutes, instead of the hours a restore-from-backup would take. This is what turns a hardware failure into a brief blip instead of an outage.
- Disaster recovery (DR). A replica placed in a different availability zone, region, or data center survives failures that take out an entire site — a fire, a regional network partition, a cloud provider outage — that no amount of local redundancy protects against.
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:
- A replication user and permission on the primary — an account with the
REPLICATIONprivilege that replicas authenticate as. pg_hba.confentries on the primary allowing that user to connect from the replica’s address, using thereplicationpseudo-database.wal_level = replica(orlogical, which is a superset) on the primary, so WAL contains enough information to reconstruct data — the defaultminimallevel omits information replicas need.- 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. primary_conninfoon the replica (historically stored inrecovery.conf; since PostgreSQL 12 it’s a normal GUC inpostgresql.conf, combined with astandby.signalfile to tell the server to start in standby mode) pointing at the primary’s connection details.- Once started in standby mode, the replica connects to the primary as a
walsender/walreceiverpair, 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 replication | Synchronous replication | |
|---|---|---|
| Commit latency | Fast — primary doesn’t wait for replicas | Slower — primary waits for at least one replica to acknowledge |
| Durability on primary failure | Small window of possible data loss (recent commits not yet shipped) | No data loss for transactions that were acknowledged as committed |
| Availability impact | None — primary keeps accepting writes even if all replicas are down | If the only synchronous replica is down or slow, writes on the primary stall (unless synchronous_standby_names uses quorum with a lower threshold) |
| Typical use | Read replicas, cross-region DR replicas where a few ms/seconds of lag is acceptable | Financial/compliance workloads, or a nearby replica used specifically as a zero-data-loss failover target |
| Configuration | Default behavior — no synchronous_standby_names set | synchronous_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.
- A publication, created on the source with
CREATE PUBLICATION, defines a set of tables (or all tables, or tables matching a pattern) whose changes should be made available for subscribers. - A subscription, created on the destination with
CREATE SUBSCRIPTION, connects to a publication and begins replicating its changes — first performing an initial data sync (effectively aCOPYof existing rows), then applying ongoing changes as they occur.
-- 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:
- Partial replication. Only specific tables (or even specific columns/rows via
WHEREclauses and column lists on newer PostgreSQL versions) can be published, instead of the whole cluster. - Cross-version replication. Because it operates at the logical row level rather than the physical page format, the source and destination can run different major PostgreSQL versions — this is the standard mechanism for performing a near-zero-downtime major version upgrade: stand up a subscriber on the new version, let it catch up, then cut traffic over.
- Replicating into a different structure. The destination table just needs matching column names and compatible types — it can have different indexes, additional columns (as long as they allow
NULLor have a default), different partitioning, or extra triggers, none of which is possible with physical replication where the destination must be a byte-identical copy. - Multi-source / fan-in and fan-out topologies. A single database can subscribe to publications from several sources (consolidating data from sharded or multi-tenant primaries), or a single publication can feed many subscribers.
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) replication | Logical replication | |
|---|---|---|
| What’s replicated | Entire cluster — every database, table, index, byte-for-byte | Selected tables (or subset of columns/rows), chosen per publication |
| Granularity | Whole cluster, no selectivity | Table-level (and finer, with row/column filters) |
| Cross-version support | No — replica must run the same major version as the primary | Yes — common mechanism for major version upgrades |
| Destination schema | Must be identical (it’s a physical copy) | Can differ — extra indexes, columns, partitioning allowed |
| DDL replication | Automatic (it’s all just WAL) | Manual — DDL must be applied on both sides separately |
| Failover target | Yes — can be promoted to a full standalone primary | Not typically used as a failover target on its own |
| Overhead | Lower — physical page replay | Higher — WAL decoding + logical apply |
| Typical use case | HA, DR, read replicas, full cluster redundancy | ETL/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 agents, one per PostgreSQL node, each managing its local instance and periodically checking in with the DCS.
- The DCS (etcd/Consul/ZooKeeper), holding the leader lock and cluster configuration/state — running as its own quorum-based cluster (typically 3 or 5 nodes) so it isn’t a single point of failure either.
- Health checks, where Patroni evaluates replication lag, WAL position, and instance health to decide which replica is the best promotion candidate if a failover is needed (it doesn’t just pick arbitrarily — it prefers the replica that’s most caught up).
- A REST API exposed by each Patroni agent, reporting whether that node is currently the leader — this is what connection routing (below) uses to find the current primary.
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.).
- repmgr — an older, simpler tool from 2ndQuadrant/EDB that manages replication topology and can perform automatic failover, but historically relies on a simpler witness-node/quorum approach rather than a full DCS like etcd, making its split-brain guarantees weaker than Patroni’s in some partition scenarios. Still used, especially in simpler or legacy setups.
- pg_auto_failover — built by Citus Data/Microsoft, uses a dedicated monitor node (itself just a PostgreSQL database) to track node health and orchestrate failover. Simpler to reason about than a DCS-based setup, but the monitor node’s own availability becomes a design consideration.
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:
| Mode | Behavior | Session features preserved? | Typical use |
|---|---|---|---|
| Session pooling | A backend connection is assigned to a client for the entire session (until it disconnects) | Yes — full session semantics | Safest, but barely reduces connection count vs. direct connection |
| Transaction pooling | A backend connection is assigned only for the duration of one transaction, then returned to the pool for reuse by another client | No — session state (prepared statements, SET variables, advisory locks held across transactions) doesn’t survive | The standard production choice — large reduction in real backend connections |
| Statement pooling | A backend connection is returned to the pool after each individual statement, even within a transaction | No — cannot even reliably support multi-statement transactions | Rare; 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
- Run the DCS (etcd/Consul) as its own quorum cluster of 3 or 5 nodes, ideally on hardware or VMs separate from the PostgreSQL nodes themselves, so a PostgreSQL host failure can’t also take down the thing responsible for detecting it.
- Keep at least one replica in a genuinely separate failure domain (different rack, AZ, region) from the primary — a same-rack replica protects against disk/software failure but not against the failure mode (power, network, natural disaster) that actually causes the worst outages.
- Don’t rely on a single replica for both HA and DR simultaneously if avoidable — a nearby synchronous replica for fast failover and a farther asynchronous one for disaster recovery serve different purposes and shouldn’t be conflated into one node wearing both hats.
Synchronous replication tuning
- Use
synchronous_standby_nameswith a quorum form (ANY n (...)) rather than naming a single fixed replica, so the loss of one designated synchronous replica doesn’t stall writes on the primary entirely. - Understand that
synchronous_commit = onguarantees the transaction survives primary failure, not that it’s instantly visible everywhere — useremote_applyif replicas must be guaranteed to reflect a just-committed transaction the moment the client gets its acknowledgment (e.g., read-after-write-your-own-write consistency requirements). - Monitor
pg_stat_replication.replay_lagand alert on it — a synchronous replica that’s slow (not down, just lagging) will slow down every commit on the primary, and this is a subtler failure mode than an outright down replica.
Patroni operational hygiene
- Never manage
postgresql.confor the standby/promotion state by hand once Patroni is managing a node — Patroni owns the instance’s configuration and lifecycle, and manual changes get silently overwritten or fight with Patroni’s reconciliation loop. - Test failover regularly in a non-production environment (or during planned maintenance windows in production) — an HA setup that has never actually failed over is an HA setup with unknown, untested assumptions.
- Watch replication lag on every replica, not just the one currently designated synchronous — a badly lagging replica is a bad failover candidate, and Patroni’s own election logic depends on accurate, timely lag information.
PgBouncer and pooling
- Default to transaction pooling in production unless there’s a specific, understood reason not to; audit application code and ORM configuration for reliance on session-level state (prepared statements, session
SET, advisory locks across statements) before enabling it. - Run PgBouncer close to the application (often colocated on the same host or in the same AZ) to avoid adding cross-network latency to every query, and run a separate PgBouncer/pool per logical workload where isolation matters (so one noisy application doesn’t starve another’s connection pool).
- Size
pool_sizebased on actual concurrency needs and PostgreSQL’smax_connections, not guesswork — oversized pools defeat the purpose of pooling, undersized ones cause client-side queueing and timeouts.
Logical replication caveats
- Remember that DDL is not replicated — schema migrations must be applied to both publisher and subscriber, in a compatible order (usually additive changes on the subscriber first).
- Ensure published tables have a
PRIMARY KEYor explicitREPLICA IDENTITY FULL/REPLICA IDENTITY USING INDEX, orUPDATE/DELETEreplication will fail or behave incorrectly. - Don’t use logical replication as a failover mechanism on its own — it’s not designed to preserve sequence values, DDL, or full cluster state the way physical replication does; use it for its actual strengths (partial replication, cross-version migration, data distribution).
References
- PostgreSQL Documentation — High Availability, Load Balancing, and Replication
- PostgreSQL Documentation — Log-Shipping Standby Servers (Streaming Replication)
- PostgreSQL Documentation — Logical Replication
- PostgreSQL Documentation — pg_basebackup
- Patroni Documentation
- PgBouncer Documentation — Pooling Modes
- HAProxy Documentation
- etcd Documentation — Raft Consensus