Vì sao Django cần PgBouncer cho PostgreSQL? Sao Postgres hoặc Django không hỗ trợ luôn nhỉ?
PgBouncer không phải feature bị thiếu. Nó là một process phải tồn tại, vì cái ranh giới nó đứng thì không Django lẫn Postgres nào sở hữu được.
Ai dùng Django với PostgreSQL chắc cũng gặp lỗi này khi lượng người dùng tăng lên. App đang chạy ngon, scale thêm vài pod, rồi Postgres từ chối connection thứ 101.
Phản xạ đầu tiên là gõ max_connections = 500 rồi restart. Phản xạ thứ hai, sau khi Google năm phút, là cài PgBouncer. Mọi người đều bảo cài PgBouncer. Từ 2015 tới giờ, mọi thread StackOverflow đều bảo cài PgBouncer.
Nhưng có một câu hỏi hơi ngu ngốc mà mình khựng lại: nếu ai cũng cần, sao không bên nào tự làm luôn đi? Postgres viết 30 năm rồi. Django cũng gần 20 năm. Hai project trưởng thành bậc nhất trong ngành, mà lại để một cái vấn đề phổ biến đến vậy cho một binary 200KB của bên thứ ba xử lý?
Câu trả lời nằm trong mailing list pgsql-hackers.
Mở một connection tới Postgres thật ra tốn những gì?
Không phải tốn ở TCP. Tốn ở chỗ mỗi connection là một process.
Postgres không dùng thread-per-connection như MySQL. Client connect vào, postmaster fork() ra một backend process riêng cho client đó [1]. Process này mang theo relcache, catcache, plan cache, prepared statement của riêng nó, và một slot trong ProcArray cùng lock table nằm ở shared memory.

Vài MB RAM mỗi connection thì bạn có thể mua thêm RAM. Vấn đề nằm ở chỗ ít ai nhìn: mỗi transaction cần một snapshot, và lấy snapshot nghĩa là phải đụng vào ProcArray, cái mảng lớn dần theo số connection. Postgres 14 đã tối ưu đáng kể phần này, nhưng bản chất không đổi. Lock manager contention và cache-line bouncing giữa các core vẫn tăng theo tổng số backend, kể cả khi phần lớn chúng đang ngồi không.
Đó là lý do đường cong throughput theo số connection có một cái đỉnh, thường đâu đó quanh 2 tới 4 lần số core, rồi đi xuống. 500 connection idle không hề miễn phí. Chúng đang làm chậm 20 connection thật sự đang chạy query.
Postgres muốn ít connection, sống lâu.
Vậy sao Django lại đẻ ra nhiều connection thế?
Vì Django (WSGI, sync) là share-nothing tuyệt đối. django.db.connections là thread-local. Mỗi worker giữ connection của riêng nó và không ai mượn được của ai [2].
Làm phép nhân với một setup hết sức bình thường:
gunicorn: 8 workers x 4 threads = 32 conn / pod
kubernetes: 6 pods = 192 conn
celery: 4 worker x 8 concurrency = 32 conn
------------------------------------------------
tổng: 224 connection, phần lớn đang ngồi chơi
224 con số này va thẳng vào max_connections = 100. Và nâng nó lên 300 chính là tự bắn vào chân mình ở phần trên.

Django 5.1 có native pool rồi mà, sao vẫn cần PgBouncer?
Django 5.1 có pool, qua psycopg_pool, bật bằng "OPTIONS": {"pool": True} [3]. Nó hoạt động, và nó cắt được 50 tới 70 mili giây latency mỗi lần mở connection nếu DB của bạn nằm xa [4]. Con số đó không nhỏ.
Nhưng đọc kỹ tài liệu thì nó là pool per-process [5].
Và đó là toàn bộ vấn đề.
Một cái pool sống bên trong process Django chỉ có thể tái sử dụng connection cho chính process đó. Nó không nhìn thấy 7 gunicorn worker anh em bên cạnh. Nó càng không nhìn thấy 5 pod đang chạy ở node khác. Nó cắt được chi phí handshake, nhưng nó không hề làm giảm tổng số connection mà Postgres nhìn thấy. Thậm chí ngược lại: pool có min_size, nên mỗi process giờ giữ sẵn N connection thay vì 1.
Muốn N process app dùng chung M connection database, cái pool bắt buộc phải là một process thứ ba, đứng ngoài tất cả bọn chúng.

Django không thể đứng vào vị trí đó. Không phải vì lười. Vì theo định nghĩa, Django sống bên trong cái process cần được gom lại. Bạn không thể tự nhấc mình lên bằng dây giày của chính mình.
PgBouncer không phải một feature mà Django còn thiếu. Nó là một vị trí trong topology mà Django, về mặt cấu trúc, không thể chiếm.
Ok, thế Postgres thì sao? Nó đứng đúng chỗ mà?
Đúng. Và đã có người thử.
Konstantin Knizhnik ở Postgres Professional submit patch built-in connection pooler lên pgsql-hackers từ 2019 [6]. Tranh luận kéo dài, Tomas Vondra và nhiều committer khác vào review [7]. Và nó không bao giờ được commit vào core.
Lý do, khi đọc kỹ, hé lộ đúng bản chất vấn đề: một backend Postgres không hề stateless.
Nó mang theo cả một đống thứ thuộc về session, không thuộc về transaction:
- GUC đã
SET:search_path,timezone,statement_timeout - prepared statements
- temp tables
- session-level advisory locks
- kênh đang
LISTEN - cursor
WITH HOLD,currval()của sequence
Muốn ghép nhiều client lên chung một backend, bạn phải swap sạch đống state này mỗi lần đổi client. Chính lời tác giả patch: một khi client động vào bất kỳ thứ nào ở trên, backend đó bị coi là tainted. Nó dính chết vào session đó, không schedule lại cho ai khác được nữa.

Bản patch làm được đầy đủ session semantics thì phải sờ vào gần như mọi subsystem. Nó chỉ được ship trong Postgres Pro Enterprise, bản thương mại. Tác giả tự thừa nhận là quá nhiều thay đổi để đưa vào mainstream.
Postgres không từ chối built-in pooler vì lười, mà vì session state của backend không tách rời được nếu không refactor toàn bộ core
https://www.postgresql.org/message-id/[email protected] 2019 — Knizhnik, pgsql-hackers: để giữ patch ít xâm lấn, built-in pooler cố tình không save/restore session context. Backend nào bị client đụng vào GUC, temp table, prepared statement hay advisory lock thì trở thành "tainted" và bị khoá vĩnh viễn vào session đó. Bản hỗ trợ đầy đủ session semantics chỉ tồn tại trong PgPro Enterprise, vì "có rất ít cơ hội commit nó vào mainstream".
Nhưng PgBouncer thì làm được. Nó ảo thuật kiểu gì?
Nó không ảo thuật. Nó gian lận.
pool_mode = transaction chạy được chính xác vì nó giả vờ session state không tồn tại. Nó trả connection về pool ngay tại ranh giới COMMIT, và bạn trả giá bằng chính những thứ Postgres không dám vứt đi:

| Thứ bị hỏng | Bạn phải làm gì trong Django |
|---|---|
Server-side cursors (.iterator()) |
DISABLE_SERVER_SIDE_CURSORS = True |
| Prepared statements | cần PgBouncer >= 1.21 và bật max_prepared_statements [8] |
LISTEN / NOTIFY |
vứt đi, chuyển sang Redis |
| Session advisory lock | đổi sang pg_advisory_xact_lock |
SET ngoài transaction |
leak sang request của người khác |
Cái bảng này chính là cái giá của việc pool ở tầng proxy thay vì tầng engine.
Và đó cũng là lý do Postgres từ chối commit. Nếu built-in pooler vào core, cái bảng trên sẽ áp lên toàn bộ user base, kể cả người chưa từng nghe tới connection pooling. Đẩy nó ra ngoài thành một tool riêng nghĩa là chỉ ai chọn trả giá mới phải trả.
Đây chỉ là cái cớ. Postgres đơn giản là bảo thủ, còn Django thì chậm chân
Nếu vậy thì đã phải có ai đó fix trong engine rồi. Nhìn xem cả ngành làm gì: AWS dựng RDS Proxy. Supabase viết Supavisor. Yandex viết Odyssey. Azure thì nhét thẳng PgBouncer vào managed service. PgCat, pgpool-II, cùng một hình dạng.
Hàng chục team, hàng trăm triệu đô, và không một ai chọn đường sửa trong core. Tất cả đều hội tụ về một cái proxy đứng ngoài. Khi mọi người độc lập đi tới cùng một đáp án xấu xí như vậy, thường không phải vì họ đều lười. Mà vì đáp án đẹp không tồn tại.
PgBouncer route được sang nhiều database không? Sharding thì sao?
Không. PgBouncer không parse SQL. Nó không nhìn vào query, nên không thể chia read/write, không route theo shard key, không scatter-gather.
Thứ duy nhất giống routing mà nó có là static alias trong pgbouncer.ini:
[databases]
primary = host=10.0.0.1 dbname=app
replica = host=10.0.0.2 dbname=app
Client connect vào tên nào thì đi tới host đó. Quyết định nằm ở chỗ client gõ tên database, không phải ở nội dung query.
Nên logic chia database nằm ở Django, qua DATABASE_ROUTERS [9]:
class ReadWriteRouter:
def db_for_read(self, model, **hints):
return 'replica'
def db_for_write(self, model, **hints):
return 'default'
def allow_migrate(self, db, app_label, **hints):
return db == 'default'
Replica nhận data qua streaming replication của Postgres, không qua Django. Migration chỉ chạy trên primary rồi replicate xuống, nên allow_migrate phải chặn.
Và đây là chỗ sẽ cắn bạn: replication lag. User bấm Save, ghi vào primary, redirect, đọc từ replica, thấy data cũ, tưởng hỏng, bấm Save lần nữa. Django không có khái niệm sticky-read-after-write. Read nào nhạy cảm thì phải ép về primary bằng .using('default').
Còn sharding thật, với cross-shard join, thì cả PgBouncer lẫn Django đều không làm được. Đó là địa hạt của Citus (extension, sharding ở tầng engine) hoặc PgCat (proxy Rust có parse query).
Một điều đáng chú ý: mỗi alias trong DATABASES giữ connection riêng cho mỗi thread. Thêm một replica là nhân đôi số connection. Vẫn đúng cái phép nhân ở đầu bài, chỉ là ở tầng khác.
Vậy mình có nên cài PgBouncer không?
Trước hết, đếm xem app của bạn đang mở bao nhiêu connection:
\[\text{total connections} = (\text{pods} \times \text{workers} \times \text{threads}) + \text{celery concurrency}\]
Nếu con số đó dưới khoảng 100 và bạn deploy tĩnh, bỏ qua PgBouncer đi. CONN_MAX_AGE = 60 cộng CONN_HEALTH_CHECKS = True là đủ. Thêm "pool": True nếu database nằm xa và bạn muốn ăn nốt 50ms kia.
Nếu con số đó vài trăm, hoặc bạn autoscale, hoặc bạn chạy serverless (Lambda, Cloud Run scale-to-zero, nơi mỗi instance mới là một cú fork() mới bên Postgres), thì PgBouncer transaction mode, và chấp nhận cái bảng ở trên.
Một cái bẫy: nếu đã có PgBouncer thì đừng bật thêm native pool của Django. Bạn sẽ có pool chồng lên pool, mỗi app process giữ sẵn min_size connection tới PgBouncer, và toàn bộ tính toán sizing của bạn thành vô nghĩa. Chọn một thôi.
Điều làm mình thích câu hỏi này không phải câu trả lời kỹ thuật. Mà là cái nó lộ ra về cách phần mềm thật sự tiến hoá.
Mình đã mặc định rằng khi một nhu cầu phổ biến đến mức ai cũng cần, sớm muộn nó sẽ được nuốt vào core. Đó là niềm tin ngầm của mình về mọi thứ: đủ phổ biến thì sẽ được built-in.
Nhưng PgBouncer đứng đó, hai mươi năm, ngoài cả hai. Không phải vì nó chưa đủ chín. Mà vì nó đang đứng ở đúng một chỗ mà không bên nào với tay tới được: Django ở bên trong cái nó cần gom, Postgres thì không thể vứt bỏ session state của chính mình mà vẫn còn là Postgres.
Có những mảnh phần mềm không tồn tại vì ai đó chưa kịp làm. Chúng tồn tại vì có một cái khe hở kiến trúc mà không kiến trúc nào tự lấp được cho mình. Và mình bắt đầu nghĩ, sidecar, service mesh, message queue, có khi kha khá thứ trong stack của mình cũng đang ở đúng loại khe hở đó mà mình chưa nhìn ra.
Nếu bạn nghĩ mình đang nhầm ở đâu đó, mình muốn nghe.
Bình
Citations
- PostgreSQL Documentation: Architectural Fundamentals. PostgreSQL Global Development Group. Postgres fork mot backend process rieng cho moi client connection
- Concurrency and Database Connections in Django. Heroku Dev Center. Cong thuc dem connection: dynos x workers x threads
- Django 5.1 release notes. Django Software Foundation. 2024-08. Them ho tro connection pool cho PostgreSQL qua psycopg_pool, OPTIONS pool=True
- Saurabh Kumar. Cut Django Database Latency by 50-70ms With Native Connection Pooling. 2025-06. Do latency handshake bi cat khi bat native pool
- Django docs: Databases - Connection pool. Django Software Foundation. Pool la per-process. Doc khuyen dung external pooler khi chay ASGI tren 5.1-5.2
- Konstantin Knizhnik. Built-in connection pooler. pgsql-hackers. 2019. Patch built-in pooler. Backend bi coi la tainted khi client dung temp table, prepared statement, GUC, advisory lock. Ban full session semantics chi ship trong Postgres Pro Enterprise vi qua nhieu thay doi de vao core
- Tomas Vondra. Re: Built-in connection pooler. pgsql-hackers. 2019-07-30. Thao luan ve gioi han cua patch, session state va do phuc tap
- PgBouncer 1.21.0 release. PgBouncer. 2023-10. Ho tro prepared statement trong transaction pooling mode qua max_prepared_statements
- Django docs: Multiple databases - Automatic database routing. Django Software Foundation. DATABASE_ROUTERS voi db_for_read, db_for_write, allow_relation, allow_migrate