Postgres connection pool, clean 503 under overload, time indexes, 4 workers
Postgres here allows 60 connections, but each of the 96 gunicorn threads held its own persistent one: at 100 concurrent users (local Postgres with the same limit) 21% of requests were 500 "remaining connection slots are reserved". Now each worker has a psycopg pool (8), so the same load gives 0 errors and 714 req/s (was 460). - A full pool answers 503 + Retry-After (DRF handler and middleware), not 500. - The AI tutor and the OTP SMS call return their connection before the network wait. - statement_timeout 30 s, idle-in-transaction 60 s; bad pool settings and the old DB_CONN_MAX_AGE stop startup instead of failing every request. - /api/health dbPool: per-worker pool stats and workers x pool + 6 vs max_connections. - Indexes on the columns global counters filter by time (weekly leaderboard, global SMS cap, global AI cap); the leaderboard recomputes once per expiry. - Worker count moves into gunicorn.conf.py (4 on a shared 8-core box). - security doc: Redis item closed (server uses our own Redis since 2026-10-08). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -282,6 +282,8 @@ MIDDLEWARE = [
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
# پاسخِ API هرگز در کشِ ArvanCloud/مرورگر نماند (config/middleware.py).
|
||||
'config.middleware.APINoStoreMiddleware',
|
||||
# poolِ دیتابیسِ پر → ۵۰۳ با Retry-After بهجای ۵۰۰ برای viewهای غیرِ DRF (config/db.py).
|
||||
'config.middleware.DatabaseBusyMiddleware',
|
||||
# ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛
|
||||
# عمداً آخرِ لیست قرار گرفته تا نزدیکترین میانافزار به خودِ view باشه
|
||||
# (مدتزمانِ دقیقتر، و request.user که توسطِ DRF authentication در
|
||||
@@ -322,6 +324,33 @@ if DB_NAME:
|
||||
_missing = [k for k in ('DB_USER', 'DB_PASSWORD') if not os.environ.get(k)]
|
||||
if _missing:
|
||||
raise RuntimeError('DB_NAME ست شده ولی %s خالی است.' % ', '.join(_missing))
|
||||
if 'DB_CONN_MAX_AGE' in os.environ:
|
||||
# pool جای اتصالِ دائمی را گرفته و جنگو این دو را با هم نمیپذیرد؛ مقدارِ قدیمی بیصدا
|
||||
# نادیده گرفته نمیشود تا کسی خیال نکند هنوز اثر دارد.
|
||||
raise RuntimeError('DB_CONN_MAX_AGE دیگر معنی ندارد (اتصالها از pool میآیند)؛ از .env برش دارید.')
|
||||
|
||||
# ── pool اتصالها ──
|
||||
# تا ۲۰۲۶-۱۰-۱۱ هر نخِ gunicorn (۳ worker × ۳۲ نخ = ۹۶) با CONN_MAX_AGE یک اتصالِ دائمیِ جدا
|
||||
# نگه میداشت، ولی Postgresِ ما max_connections=60 دارد (۵۷ تا برای غیرِ ابرکاربر). روی
|
||||
# Postgresِ محلی با همان سقف، ۱۵۰ درخواستِ همزمان روی /api/home یکسومِ درخواستها را ۵۰۰
|
||||
# کرد («remaining connection slots are reserved»). حالا هر worker یک pool دارد و نخها اتصال
|
||||
# را فقط تا پایانِ درخواست قرض میگیرند؛ زیرِ ازدحام نخها در صفِ pool منتظر میمانند و پس از
|
||||
# DB_POOL_TIMEOUT ثانیه ۵۰۳ با Retry-After میگیرند (config/db.py)،
|
||||
# نه ۵۰۰. بودجه: workers × DB_POOL_MAX_SIZE باید زیرِ max_connections بماند و جا برای
|
||||
# pg_receivewal، pg_dump و migrateِ دیپلو، و psqlِ دستی بگذارد؛ /api/health آن را میسنجد.
|
||||
# ۸ عدد: هر کوئری اینجا چند میلیثانیه است، پس ۸ اتصال صدها درخواست در ثانیه را برای یک
|
||||
# worker (که با GIL حداکثر یک هسته است) میکشد؛ در لودتستِ محلی از ۸ به ۱۶ گذرشی بیشتر نشد.
|
||||
DB_POOL_MIN_SIZE = int(os.environ.get('DB_POOL_MIN_SIZE', '2'))
|
||||
DB_POOL_MAX_SIZE = int(os.environ.get('DB_POOL_MAX_SIZE', '8'))
|
||||
DB_POOL_TIMEOUT = float(os.environ.get('DB_POOL_TIMEOUT', '10'))
|
||||
if not 1 <= DB_POOL_MIN_SIZE <= DB_POOL_MAX_SIZE:
|
||||
# psycopg_pool این را تازه سرِ اولین کوئری میفهمد و آنوقت همهی درخواستها ۵۰۰ میشوند.
|
||||
raise RuntimeError(f'DB_POOL_MIN_SIZE ({DB_POOL_MIN_SIZE}) باید بینِ ۱ و DB_POOL_MAX_SIZE ({DB_POOL_MAX_SIZE}) باشد.')
|
||||
# کوئریِ بیش از ۳۰ ثانیه در درخواستِ وب یعنی باگ یا قفلِ گیرکرده؛ بدونِ سقف اتصالِ pool و نخ را
|
||||
# تا timeoutِ gunicorn (۱۲۰ ثانیه) گرو میگرفت. تراکنشِ بازِ بیکار (کدی که atomic را رها نکرده)
|
||||
# بعد از ۶۰ ثانیه بسته میشود تا قفلهایش بقیه را نخواباند. برای یک فرمانِ مدیریتیِ طولانیِ دستی:
|
||||
# DB_STATEMENT_TIMEOUT_MS=0 python manage.py ...
|
||||
DB_STATEMENT_TIMEOUT_MS = int(os.environ.get('DB_STATEMENT_TIMEOUT_MS', '30000'))
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
@@ -330,7 +359,22 @@ if DB_NAME:
|
||||
'PASSWORD': os.environ['DB_PASSWORD'],
|
||||
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
|
||||
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
|
||||
'CONN_MAX_AGE': 0,
|
||||
# اتصالِ مرده (مثلاً بعد از ریاستارتِ Postgres) پیش از تحویل از pool کنار گذاشته میشود،
|
||||
# نه اینکه اولین درخواستِ بعدی رویش ۵۰۰ بگیرد. هزینه: یک رفتوبرگشتِ محلی برای هر قرض.
|
||||
'CONN_HEALTH_CHECKS': True,
|
||||
'OPTIONS': {
|
||||
'pool': {
|
||||
'min_size': DB_POOL_MIN_SIZE,
|
||||
'max_size': DB_POOL_MAX_SIZE,
|
||||
'timeout': DB_POOL_TIMEOUT,
|
||||
'max_idle': 300,
|
||||
'max_lifetime': 1800,
|
||||
},
|
||||
'connect_timeout': 5,
|
||||
'options': (f'-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} '
|
||||
'-c idle_in_transaction_session_timeout=60000'),
|
||||
},
|
||||
# قفلِ ردیف روی Postgres واقعی است (select_for_update)؛ transaction_mode
|
||||
# فقط برای SQLite بود. با تستهای همزمانی روی Postgres سنجیده میشود.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user