PostgreSQL Advanced — Архітектура, Docker та Production патерни¶
Продовження після
DJANGO_ORM_DEEP.md. Тут: внутрішня механіка PostgreSQL, чому він такий швидкий, як підключити через Docker, специфічні PostgreSQL поля та production-оптимізація.
🔬 SQL інспекція в ноутбуку:
docs/reference/legacy_source/orm_laboratory_reference.md
Концепція Розділ ноутбука queryset.explain(analyze=True)— query plan## 11. 🔬 SQL Inspectionconnection.queries— логування SQL## 11. 🔬 SQL InspectionRaw SQL через cursor.execute()## 14. 🧪 Raw SQL🧠 Ментальна модель: PostgreSQL — не просто "місце для збереження". Це складна клієнт-серверна система з багатопроцесною архітектурою, власним менеджером пам'яті (Shared Buffers), механізмом ізоляції (MVCC) та журналом відмовостійкості (WAL). Розуміння цього = розуміння чому деякі запити швидкі, а деякі ні.
📚 Чому це важливо для Django-розробника: Коли ти пишеш
Note.objects.filter(user=user)— Django відправляє SQL в PostgreSQL, PostgreSQL вирішує де шукати дані (диск чи пам'ять, індекс чи sequential scan), виконує та повертає результат. Розуміючи цей процес — ти знаєш де bottleneck і як його усунути.🌐 Практична цінність: Кожен Django девелопер рано чи пізно стикається з "чому моя сторінка завантажується 5 секунд?" Відповідь завжди в PostgreSQL: немає індексу, N+1 запити, sequential scan на великій таблиці.
EXPLAIN ANALYZE+ знання архітектури = діагноз за 5 хвилин.❌ Типова помилка: Думати що PostgreSQL = "просто сховище". Насправді PostgreSQL — потужний сервер з планувальником запитів, кешем, транзакційним менеджером. Правильна конфігурація може дати 10-100x приріст продуктивності без зміни коду.
1. PostgreSQL vs SQLite — чому змінюємо на production¶
🧠 Ментальна модель: SQLite — це "файл-база". Весь доступ через один файловий дескриптор. В один момент тільки ОДИН процес може писати. PostgreSQL — окремий сервер-процес. Сотні клієнтів одночасно → кожен отримує свій backend процес → MVCC забезпечує ізоляцію.
📚 Реальний сценарій: Gunicorn з 4 воркерами → 4 паралельних Django запити → 4 одночасних INSERT. В SQLite: перший пише, три чекають. В PostgreSQL: всі 4 пишуть незалежно без блокування (через MVCC).
❌ Типова помилка: Деплоїти production з SQLite. Django навіть документує: "SQLite is not suitable for production". Для реального трафіку (50+ concurrent users) — SQLite = bottleneck.
SQLite (Serverless):
Django Process A ─────────────────┐
Django Process B ─────────────────┤ → [db.sqlite3 file] ← ОДИН файл
Django Process C ─────────────────┘ (write lock на весь файл)
Проблема: Process A пише → B і C ЧЕКАЮТЬ
PostgreSQL (Client-Server):
Django + psycopg2 ──TCP──► [postmaster :5432]
│ ─ fork() ─► [backend process 1] ─► [Shared Buffers]
│ ─ fork() ─► [backend process 2] ─► [Shared Buffers]
│ ─ fork() ─► [backend process 3] ─► [Shared Buffers]
Всі три backend processes паралельно, кожен зі своєю транзакцією (MVCC).
| Характеристика | SQLite | PostgreSQL |
|---|---|---|
| Архітектура | Serverless, один файл | Client-server, окремий daemon |
| Concurrent writes | Database-level lock | MVCC: тисячі паралельних |
| Розмір БД | До ~1 TB практично | Необмежено (навіть петабайти) |
| JSON/Arrays | Базово | Повна підтримка + GIN індекси |
| Full-text search | Обмежено | GIN + tsvector, trigram |
| Транзакції DDL | Обмежено | Повна підтримка |
| Підключення | Direct file | TCP socket (або Unix socket) |
| Для production | ❌ Ніколи | ✅ Завжди |
2. PostgreSQL Process Model — як сервер обробляє запити¶
🧠 Ментальна модель: Коли Django підключається до PostgreSQL — postmaster daemon fork()-ує новий процес спеціально для цього підключення. Цей backend process обслуговує ONE connection поки вона активна. Shared Buffers — це спільна пам'ять між усіма backend процесами: кеш даних.
📚 Чому fork() а не threads: PostgreSQL використовує процеси (не threads) для ізоляції. Якщо backend process падає — він не вбиває весь сервер. Shared Buffers — спільна пам'ять (не копії): всі процеси бачать один і той самий кеш.
🌐 CONN_MAX_AGE в Django: Кожен новий Python процес → нове підключення до PostgreSQL → fork() на боці PostgreSQL. Це дорого (10-100ms).
CONN_MAX_AGE=60→ тримати підключення між HTTP запитами → не vitko fork() щоразу → 5-10x менше overhead на DB connections.❌ Типова помилка:
CONN_MAX_AGE=None(persistent) без PgBouncer. При 100 Gunicorn воркерах = 100 постійних підключень до PostgreSQL. PostgreSQL тримає 100 backend процесів. Приmax_connections=100→ 101-й клієнт отримує "too many connections" помилку.
┌─────────────────────────────────────────────────────────┐
│ PostgreSQL Server │
│ │
Django ────► │ Postmaster (порт 5432) │
psycopg2 │ │ │
│ ├─ fork() ──► Backend Process 1 (твій запит) │
│ │ │ ← Query Parser │
│ │ │ ← Query Planner & Optimizer │
│ │ │ ← Execution Engine │
│ │ │ │
│ │ └──► Shared Buffers (8GB RAM кеш) │
│ │ │ │
│ │ WAL Writer ──► WAL Log (диск) │
│ │ Checkpointer ──► Data Files (диск) │
│ │ │
│ ├─ fork() ──► Backend Process 2 (інший клієнт) │
│ └─ fork() ──► Backend Process 3 (ще один) │
└─────────────────────────────────────────────────────────┘
3. MVCC — Multi-Version Concurrency Control¶
🧠 Ментальна модель: Уяви що кожен рядок у таблиці має "версії". Коли ти починаєш транзакцію — ти бачиш snapshot стану БД на момент початку. Інші транзакції можуть паралельно змінювати ті самі рядки — ти їх не бачиш поки вони не закоммітили, і вони не бачать твоїх змін. Це як Google Docs: кілька людей редагують одночасно без конфліктів.
📚 Як PostgreSQL це реалізує: Кожен рядок зберігає
xmin(яка транзакція створила) таxmax(яка транзакція видалила або замінила). При SELECT PostgreSQL бачить тільки рядки деxmin <= current_transactionтаxmaxабо NULL (не видалений) абоxmax > current_transaction(видалений пізніше ніж твоя транзакція почалась).🌐 Що це дає Django:
READ COMMITTED(за замовчуванням) — кожен запит в транзакції бачить свіжі закомічені дані. Жодного "dirty read" (читання незакомічених змін). Паралельні транзакції не блокують одна одну при читанні.❌ Типова помилка: Думати що PostgreSQL "блокує таблицю" при кожному SELECT. Ні. Читачі не блокують письменників, письменники не блокують читачів (MVCC). Блокування виникає тільки при
SELECT FOR UPDATEабо при конкурентних записах в один і той самий рядок.
MVCC — туплів версіонування:
таблиця notes_note (внутрішнє представлення):
┌─────┬───────────┬──────────┬───────────┬──────────────┐
│ id │ title │ xmin │ xmax │ Кому видимий │
├─────┼───────────┼──────────┼───────────┼──────────────┤
│ 42 │ "Django" │ txid=100 │ NULL │ всім ≥ 100 │ ← активна версія
│ 42 │ "Django!" │ txid=200 │ NULL │ всім ≥ 200 │ ← нова версія (UPDATE)
│ 42 │ "Django" │ txid=100 │ txid=200 │ транзакціям │ ← стара (xmax=200: видалена)
│ │ │ │ │ < 200 │
└─────┴───────────┴──────────┴───────────┴──────────────┘
Транзакція txid=150 (почалась між 100 і 200):
→ бачить рядок з xmin=100, xmax=NULL (стара версія)
→ НЕ бачить рядок з xmin=200 (ще не існував коли 150 почалась)
Транзакція txid=250 (почалась після 200):
→ бачить рядок з xmin=200 (нова версія)
→ стара версія (xmax=200) для неї "видалена"
VACUUM прибирає "мертві" тупліі (xmax != NULL і недоступні жодній активній транзакції)
4. WAL — Write-Ahead Logging¶
🧠 Ментальна модель: WAL — це "чорний ящик літака" для бази даних. Перш ніж зробити будь-яку зміну в даних — PostgreSQL записує намір у WAL журнал. Якщо сервер впаде — при рестарті PostgreSQL читає WAL і відновлює всі підтверджені транзакції. Ніяких втрат даних після COMMIT.
📚 Чому "Write-Ahead": "Write-Ahead" = "запиши журнал ПЕРЕД тим як змінити дані". Якщо питання: що важливіше зберегти — журнал або дані? Журнал! Журнал → можна відновити дані. Дані без журналу → не знаємо до якого стану відновлювати.
🌐 WAL і продуктивність: PostgreSQL пише зміни спочатку в Shared Buffers (RAM) → потім WAL на диск. Checkpointer синхронізує dirty pages з диску.
synchronous_commit=on(за замовчуванням) → COMMIT чекає запис WAL на диск.synchronous_commit=off→ швидше але ризик втрати останніх транзакцій при збої. Для некритичних даних (логи, аналітика) —offможе бути прийнятним.❌ Типова помилка: Запускати PostgreSQL з
fsync=off"для прискорення розробки". Це ок для локальної розробки, але на production = гарантована корупція даних при збої. Ніколи на production.
Шлях даних від COMMIT до диску:
1. Django: session.commit()
│
2. SQL: COMMIT
│
3. PostgreSQL WAL Writer:
┌─ WAL Buffer (в RAM) ─────────────────────────────────┐
│ LSN 0/1234ABC: BEGIN txid=200 │
│ LSN 0/1234DEF: INSERT notes_note (id=42, title=...) │
│ LSN 0/1235000: COMMIT txid=200 │
└──────────────────────────────────────────────────────┘
│ fsync() — flush до диску
▼
WAL файл: pg_wal/000000010000000000000001
(Тепер навіть після збою — транзакцію можна відновити!)
│
4. COMMIT повертається клієнту (Django отримує OK)
│
5. Пізніше: Checkpointer записує dirty pages з Shared Buffers у Data Files
(асинхронно, не затримує клієнта)
5. Підключення Django до PostgreSQL через Docker¶
🧠 Ментальна модель: Docker для PostgreSQL — це "віртуальний сервер" що запускається за 5 секунд. Контейнер PostgreSQL = окремий network namespace → Django підключається через TCP на
localhost:5432як ніби це справжній сервер.📚 Чому Docker а не встановлювати PostgreSQL: Версіонування (легко перемкнутись на postgres:15 або postgres:16), ізоляція (не засмічує систему), однакове середовище у всієї команди, легко очистити (
docker-compose down -v).🌐 docker-compose.yml для development:
volumes: postgres_dataзберігає дані між рестартами контейнера. Без volume — перезапуск = нова порожня БД.healthcheck— Django не підключається поки PostgreSQL не готовий приймати з'єднання.❌ Типова помилка: Ставити порожні паролі або
POSTGRES_HOST_AUTH_METHOD=trustв docker-compose. Для локальної розробки — ок. Але якщо порт 5432 відкритий назовні (наприклад, port forwarding в хмарі) — хтось може підключитись без пароля.
Повний docker-compose.yml для розробки:¶
# docker-compose.yml
version: '3.9'
services:
# ── PostgreSQL ────────────────────────────────────────────────────────────
db:
image: postgres:16-alpine # alpine = мінімальний образ (~25MB)
restart: unless-stopped
environment:
POSTGRES_DB: notes_db # назва бази даних
POSTGRES_USER: notes_user # ім'я користувача
POSTGRES_PASSWORD: notes_pass # пароль (у .env файлі на production!)
volumes:
- postgres_data:/var/lib/postgresql/data # дані зберігаються між рестартами
ports:
- "5432:5432" # host:container
healthcheck:
# Django (app) не запуститься поки PostgreSQL не готовий
test: ["CMD-SHELL", "pg_isready -U notes_user -d notes_db"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
# ── Django App (опційно) ──────────────────────────────────────────────────
app:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://notes_user:notes_pass@db:5432/notes_db
depends_on:
db:
condition: service_healthy # чекати поки db healthcheck = healthy
command: >
sh -c "python manage.py migrate &&
python manage.py runserver 0.0.0.0:8000"
volumes:
postgres_data: # named volume — дані зберігаються поза контейнером
# Команди для роботи:
# Запустити тільки PostgreSQL (для локальної розробки)
docker-compose up -d db
# Запустити весь стек
docker-compose up -d
# Перевірити що PostgreSQL запущений
docker-compose ps
docker-compose logs db
# Підключитись до psql консолі
docker-compose exec db psql -U notes_user -d notes_db
# Показати всі таблиці
docker-compose exec db psql -U notes_user -d notes_db -c "\dt notes_app*"
# Зупинити та зберегти дані
docker-compose stop
# Зупинити та ВИДАЛИТИ дані (повне очищення!)
docker-compose down -v
Django settings.py для PostgreSQL:¶
# settings.py
from decouple import config # pip install python-decouple
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': config('DB_NAME', default='notes_db'),
'USER': config('DB_USER', default='notes_user'),
'PASSWORD': config('DB_PASSWORD', default='notes_pass'),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='5432', cast=int),
# CONN_MAX_AGE: тримати з'єднання між HTTP запитами
# None = persistent (не закривати ніколи)
# 0 = завжди нове (за замовчуванням, неефективно)
# 60 = тримати 60 секунд (рекомендовано для Gunicorn)
'CONN_MAX_AGE': config('DB_CONN_MAX_AGE', default=60, cast=int),
# OPTIONS для production:
'OPTIONS': {
# 'connect_timeout': 5, # таймаут з'єднання
# 'options': '-c search_path=myschema', # PostgreSQL search_path
},
}
}
# .env файл (НІКОЛИ не комітити в git!)
DB_NAME=notes_db
DB_USER=notes_user
DB_PASSWORD=notes_pass
DB_HOST=localhost
DB_PORT=5432
DB_CONN_MAX_AGE=60
6. psycopg2 та PgBouncer¶
🧠 Ментальна модель: psycopg2 — це "телефонна лінія" між Python і PostgreSQL. PgBouncer — це "АТС": замість того щоб кожен Django воркер тримав пряму лінію до PostgreSQL, вони підключаються через ATC яка переключає між обмеженою кількістю реальних ліній.
📚 Проблема без PgBouncer: Gunicorn з 10 воркерами + 5 threads = 50 паралельних підключень. PostgreSQL fork()-ує 50 backend процесів. При 100 воркерах → 100 backend процесів →
max_connections=100→ 101-й отримає помилку. PgBouncer → 1000 Django підключень → 20 реальних з'єднань з PostgreSQL.🌐 Режими PgBouncer: Session pooling (1 з'єднання до PostgreSQL на Django session), Transaction pooling (1 реальне з'єднання з PostgreSQL на транзакцію — найефективніше), Statement pooling (найефективніше але не підтримує multi-statement transactions).
❌ Типова помилка: Transaction pooling +
CONN_MAX_AGEу Django. При Transaction pooling — кожна транзакція може йти через різне фізичне з'єднання. Persistent з'єднань з PostgreSQL тоді немає сенсу. З PgBouncer:CONN_MAX_AGE=0.
Без PgBouncer:
Django w1 ──────────────────────────── TCP ──► PostgreSQL :5432
Django w2 ──────────────────────────── TCP ──► PostgreSQL :5432
Django w3 ──────────────────────────── TCP ──► PostgreSQL :5432
... (100 воркерів = 100 postgres backend processes)
З PgBouncer:
Django w1 ──┐
Django w2 ──┤── PgBouncer :6432 ──[ pool: 20 conns ]──► PostgreSQL :5432
Django w3 ──┘ (100 Django з'єднань → 20 реальних)
# settings.py з PgBouncer:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'notes_db',
'USER': 'notes_user',
'PASSWORD': 'notes_pass',
'HOST': 'pgbouncer', # хост PgBouncer, не PostgreSQL!
'PORT': '6432', # порт PgBouncer
'CONN_MAX_AGE': 0, # з PgBouncer persistent connections не потрібні
}
}
7. PostgreSQL-специфічні поля Django¶
🧠 Ментальна модель: PostgreSQL підтримує типи даних яких немає в SQLite або MySQL: масиви, JSONB, UUID, hstore, range types. Django надає поля для роботи з ними. Ці поля — тільки для PostgreSQL backend!
📚 JSONField vs окрема таблиця: JSONField зберігає довільну структуру в одному стовпці. Коли використовувати: конфігурація, metadata, дані що часто змінюють структуру. Коли НЕ використовувати: дані по яких потрібні складні JOIN, часті оновлення окремих полів, строга схема.
🌐 GIN індекс для JSON і масивів: Звичайний B-Tree індекс не може індексувати JSON або масив (багато значень в одному стовпці). GIN (Generalized Inverted Index) — індексує кожен елемент масиву або кожен ключ JSON.
GinIndex(fields=['metadata'])→ швидкий пошук по JSON ключах.❌ Типова помилка: Використовувати JSONField як "запасний" для непродуманої схеми. JSONField без GIN індексу + фільтрація
filter(metadata__key=val)→ sequential scan (повне сканування таблиці). Завжди додавай GIN індекс якщо фільтруєш по JSON.
JSONField — структуровані дані в стовпці:¶
from django.db import models
class Note(models.Model):
# JSONField зберігає Python dict/list в PostgreSQL JSONB стовпці
# JSONB = бінарний JSON → швидше ніж TEXT JSON
metadata = models.JSONField(
default=dict,
blank=True,
help_text='Довільні метадані: word_count, reading_time, language тощо'
)
# Заповнення:
note.metadata = {
'word_count': 450,
'reading_time_minutes': 3,
'language': 'uk',
'ai_tags': ['python', 'orm', 'database'],
'last_spell_check': '2024-01-15'
}
note.save()
# Фільтрація по JSON полях:
Note.objects.filter(metadata__language='uk')
# WHERE metadata->>'language' = 'uk'
Note.objects.filter(metadata__word_count__gte=100)
# WHERE (metadata->>'word_count')::int >= 100
Note.objects.filter(metadata__ai_tags__contains=['python'])
# WHERE metadata->'ai_tags' @> '["python"]'
# Анотація з JSON полем:
from django.db.models import F
Note.objects.annotate(
word_count=F('metadata__word_count')
).order_by('-word_count')
ArrayField — масиви у PostgreSQL:¶
from django.contrib.postgres.fields import ArrayField
class Note(models.Model):
# Масив рядків — альтернатива M:N для простих тегів
quick_labels = ArrayField(
base_field=models.CharField(max_length=50),
blank=True,
default=list,
help_text='Швидкі мітки (no FK, no junction table)'
)
# Масив IntegerField
related_note_ids = ArrayField(
base_field=models.IntegerField(),
blank=True,
default=list,
)
# SQL: quick_labels TEXT[] DEFAULT '{}'
# Фільтрація:
Note.objects.filter(quick_labels__contains=['python'])
# WHERE quick_labels @> ARRAY['python'] (масив містить 'python')
Note.objects.filter(quick_labels__overlap=['python', 'django'])
# WHERE quick_labels && ARRAY['python', 'django'] (є хоча б один збіг)
Note.objects.filter(quick_labels__len=3)
# WHERE array_length(quick_labels, 1) = 3 (масив з 3 елементів)
UniqueConstraint та CheckConstraint:¶
class Note(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
slug = models.SlugField(max_length=200)
views_count = models.PositiveIntegerField(default=0)
priority = models.PositiveSmallIntegerField(default=1)
class Meta:
constraints = [
# СКЛАДЕНИЙ UNIQUE: slug унікальний в межах одного user
# (alice може мати /notes/python/, bob теж може, але alice не може двічі)
models.UniqueConstraint(
fields=['user', 'slug'],
name='unique_note_slug_per_user'
),
# CHECK: views_count >= 0 (не може бути від'ємним)
# Перевіряється навіть при прямому SQL INSERT (не тільки через Django)
models.CheckConstraint(
check=models.Q(views_count__gte=0),
name='note_views_count_non_negative'
),
# CHECK: priority від 1 до 4
models.CheckConstraint(
check=models.Q(priority__gte=1) & models.Q(priority__lte=4),
name='note_priority_valid_range'
),
]
-- Що генерується в SQL:
ALTER TABLE notes_note
ADD CONSTRAINT unique_note_slug_per_user UNIQUE (user_id, slug),
ADD CONSTRAINT note_views_count_non_negative CHECK (views_count >= 0),
ADD CONSTRAINT note_priority_valid_range CHECK (priority >= 1 AND priority <= 4);
8. EXPLAIN ANALYZE — діагностика запитів¶
🧠 Ментальна модель:
EXPLAIN ANALYZE— це MRI-скан для твого SQL запиту. Він показує: який план обрав Planner, скільки часу зайняв кожен крок, скільки рядків було оброблено. З цим — ти бачиш де bottleneck: немає індексу, занадто широкий JOIN, неоптимальне сортування.📚 EXPLAIN vs EXPLAIN ANALYZE:
EXPLAIN— показує план без виконання (швидко).EXPLAIN ANALYZE— виконує запит і показує реальні метрики (повільно, але точно). На production завжди тестуй на копії,EXPLAIN ANALYZEреально виконує запит!🌐 Як читати план:
Seq Scan= читає всю таблицю O(N).Index Scan= використовує B-Tree O(log N).Index Only Scan= покриваючий індекс (не читає heap).Hash Join= ефективний JOIN для великих таблиць.Nested Loop Join= ефективний для малих таблиць з індексом.❌ Типова помилка: Ігнорувати
Seq Scanна великих таблицях.Seq Scan on notes_note (cost=0.00..5000.00 rows=100000)= читання мільйона рядків. Потрібен індекс на поле в WHERE clause.
# Django: qs.explain(analyze=True) — відправляє EXPLAIN ANALYZE до PostgreSQL
from notes_app.models import Note
qs = Note.objects.filter(user_id=5, is_archived=False).order_by('-updated_at')
print(qs.explain(analyze=True))
-- Типовий вивід EXPLAIN ANALYZE:
-- ХОРОШИЙ запит (є індекс):
Index Scan using note_user_updated_idx on notes_note (cost=0.29..8.31 rows=1 width=100)
(actual time=0.023..0.025 rows=1 loops=1)
Index Cond: ((user_id = 5) AND (is_archived = false))
Planning Time: 0.150 ms
Execution Time: 0.045 ms ← мілісекунди!
-- ПОГАНИЙ запит (Seq Scan без індексу):
Seq Scan on notes_note (cost=0.00..25000.00 rows=500000 width=100)
(actual time=0.050..1500.000 rows=150 loops=1)
Filter: ((user_id = 5) AND (is_archived = false))
Rows Removed by Filter: 499850 ← 500K рядків проскановано щоб знайти 150!
Planning Time: 0.250 ms
Execution Time: 1500.000 ms ← 1.5 секунди!
-- Читаємо план:
-- cost=0.00..8.31 → кошт: estimated start..total cost (в PostgreSQL units)
-- rows=1 → estimated кількість рядків
-- actual time=0.023..0.025 → реальний час: first row..last row (мс)
-- rows=1 loops=1 → реально повернуто рядків, кількість ітерацій
-- Операції:
-- Seq Scan → ❌ повне сканування таблиці O(N)
-- Index Scan → ✅ B-Tree індекс O(log N)
-- Index Only Scan → ✅✅ покриваючий індекс (без heap читання)
-- Bitmap Index Scan → ✅ для великої кількості рядків по індексу
-- Hash Join → JOIN через hash table (ефективно для великих таблиць)
-- Nested Loop → JOIN через цикл (ефективно коли є індекс на внутрішній таблиці)
-- Sort → ORDER BY без індексу (може бути повільно)
9. CONN_MAX_AGE та Connection Pooling¶
🧠 Ментальна модель: Підключення до PostgreSQL — дорога операція: TCP handshake, аутентифікація, fork() backend процесу — 5-50ms.
CONN_MAX_AGEкаже Django: "тримай підключення між HTTP запитами замість того щоб відкривати/закривати кожен раз".📚 Як CONN_MAX_AGE працює з Gunicorn: Кожен Gunicorn воркер = окремий процес = окреме підключення. При
CONN_MAX_AGE=60— воркер тримає підключення 60 секунд після останнього запиту. При наступному HTTP запиті до цього воркера — підключення вже готове.🌐 Правило для production: Gunicorn +
CONN_MAX_AGE=60+ без PgBouncer: кількість підключень = кількість воркерів * потоків. 10 воркерів * 2 threads = 20 підключень max. Якщоmax_connections=100— безпечно. Якщо воркерів більше — або зменшуйCONN_MAX_AGEабо додавай PgBouncer.❌ Типова помилка:
CONN_MAX_AGE=None(infinite). Підключення ніколи не закривається. При деплої нової версії старі підключення залишаються. З часом PostgreSQL накопичує "зависшие" підключення. КращеCONN_MAX_AGE=60або300.
З'єднання без CONN_MAX_AGE (CONN_MAX_AGE=0):
HTTP Request 1 → [open connection] → [SQL] → [close connection] +50ms overhead
HTTP Request 2 → [open connection] → [SQL] → [close connection] +50ms overhead
HTTP Request 3 → [open connection] → [SQL] → [close connection] +50ms overhead
З'єднання з CONN_MAX_AGE=60:
HTTP Request 1 → [open connection] → [SQL] → [keep open] +50ms (перший раз)
HTTP Request 2 → [reuse connection] → [SQL] → [keep open] +0ms!
HTTP Request 3 → [reuse connection] → [SQL] → [keep open] +0ms!
... (через 60 секунд без запитів → закривається)
10. Production стек¶
Internet
│
▼
[nginx] (порт 443, SSL termination, static files)
│
▼
[Gunicorn] (4-8 воркерів, Unix socket)
│
│ Django WSGI app
│ CONN_MAX_AGE=60
▼
[PgBouncer] (порт 6432, transaction pooling)
│
│ pool_size=20
▼
[PostgreSQL 16] (порт 5432)
│
├── Shared Buffers: 25% RAM
├── WAL: pg_wal/
└── Data Files: /var/lib/postgresql/data/
# settings/production.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': config('DB_NAME'),
'USER': config('DB_USER'),
'PASSWORD': config('DB_PASSWORD'),
'HOST': config('DB_HOST', default='pgbouncer'), # pgbouncer хост
'PORT': config('DB_PORT', default='6432'), # pgbouncer порт
'CONN_MAX_AGE': 0, # з PgBouncer не потрібен persistent conn
'OPTIONS': {
'connect_timeout': 10,
'options': '-c lock_timeout=3s', # тайм-аут для lock-ів
},
}
}
# Логування повільних запитів у розробці
LOGGING = {
'version': 1,
'handlers': {
'console': {'class': 'logging.StreamHandler'},
},
'loggers': {
'django.db.backends': {
'handlers': ['console'],
'level': 'DEBUG', # показує всі SQL запити
},
},
}
Prediction Exercises¶
Exercise 1: З'єднання¶
# settings.py:
DATABASES = {'default': {'CONN_MAX_AGE': 0, ...}}
# Gunicorn: 4 воркери, кожен обробляє 100 запитів на хвилину
Питання: Скільки підключень до PostgreSQL відкривається за хвилину?
Відповідь: 400 підключень (100 req/хвилину × 4 воркери). Кожен запит = open + close. З
CONN_MAX_AGE=60→ 4 підключення (по одному на воркер, тримаються 60 секунд).
Exercise 2: MVCC¶
Транзакція T1 починається о 10:00:00
Транзакція T2 вставляє новий рядок і COMMIT о 10:00:01
T1 виконує SELECT о 10:00:02
Питання: Чи бачить T1 новий рядок T2?
Відповідь (залежить від isolation level): -
READ COMMITTED(за замовчуванням в PostgreSQL) → ТАК бачить. Кожен запит в T1 бачить свіжі закомічені дані. -REPEATABLE READабоSERIALIZABLE→ НЕ бачить. Snapshot T1 зафіксований на момент START TRANSACTION.
Питання для самоперевірки¶
- Чому PostgreSQL не блокує таблицю при кожному SELECT (MVCC)?
- Що таке WAL і навіщо він потрібен?
- Що таке CONN_MAX_AGE і які значення рекомендовані?
- Коли використовувати JSONField а коли окрему таблицю?
- Чому потрібен GIN індекс для JSONField?
- Що показує
Seq Scanв EXPLAIN ANALYZE і коли це проблема? - Навіщо PgBouncer і коли він потрібен?
- Яка різниця між
EXPLAINтаEXPLAIN ANALYZE? - Що означає
xminіxmaxу внутрішньому представленні PostgreSQL? - Чому
POSTGRES_HOST_AUTH_METHOD=trustнебезпечно на production?