perf: SQLite WAL + 复合索引 + GZip + scheduler jitter + 构建缓存
This commit is contained in:
+4
-2
@@ -1,4 +1,4 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1.4
|
||||||
# ---- Stage 1: Build frontend ----
|
# ---- Stage 1: Build frontend ----
|
||||||
FROM node:20-alpine AS frontend-build
|
FROM node:20-alpine AS frontend-build
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
@@ -42,7 +42,9 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
|
|
||||||
# Playwright Chromium:安装在镜像层中,业务代码变更不会触发重下
|
# Playwright Chromium:安装在镜像层中,业务代码变更不会触发重下
|
||||||
RUN playwright install chromium
|
# --mount=type=cache 利用 BuildKit 缓存避免每次构建重下 ~170 MB 浏览器
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/ms-playwright,sharing=locked \
|
||||||
|
playwright install chromium
|
||||||
|
|
||||||
# 源码层:业务代码变更不影响上面所有依赖层
|
# 源码层:业务代码变更不影响上面所有依赖层
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
|||||||
+40
-1
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import create_engine, inspect, text
|
from sqlalchemy import create_engine, event, inspect, text
|
||||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
@@ -8,6 +8,21 @@ engine = create_engine(
|
|||||||
settings.database_url,
|
settings.database_url,
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── SQLite 性能 PRAGMA(WAL + 缓存 + 超时) ──
|
||||||
|
@event.listens_for(engine, "connect", insert=True)
|
||||||
|
def _set_sqlite_pragma(dbapi_connection, connection_record):
|
||||||
|
if engine.dialect.name != "sqlite":
|
||||||
|
return
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
cursor.execute("PRAGMA synchronous=NORMAL;")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=5000;")
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON;")
|
||||||
|
cursor.execute("PRAGMA cache_size=-20000;") # 20 MB page cache
|
||||||
|
cursor.execute("PRAGMA mmap_size=67108864;") # 64 MB(小内存容器友好)
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
@@ -28,11 +43,35 @@ def init_db():
|
|||||||
# import models so SQLAlchemy registers them
|
# import models so SQLAlchemy registers them
|
||||||
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key # noqa: F401
|
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key # noqa: F401
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_ensure_indexes()
|
||||||
_migrate_custom_pages()
|
_migrate_custom_pages()
|
||||||
_migrate_upstreams()
|
_migrate_upstreams()
|
||||||
_migrate_upstream_generated_keys()
|
_migrate_upstream_generated_keys()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 已有数据库幂等索引迁移 ─────────────────────────────────
|
||||||
|
_NEW_INDEXES = [
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_snapshot_upstream_captured ON upstream_rate_snapshots(upstream_id, captured_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_notif_event_created ON notification_logs(event_type, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_notif_status_created ON notification_logs(status, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_sync_website_created ON website_sync_logs(website_id, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_sync_binding_created ON website_sync_logs(binding_id, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_upstream_enabled ON upstreams(enabled)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_key_upstream_name ON upstream_generated_keys(upstream_id, key_name)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_indexes() -> None:
|
||||||
|
"""创建 InitDB 时可能未包含的复合索引,幂等执行。"""
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for stmt in _NEW_INDEXES:
|
||||||
|
try:
|
||||||
|
conn.execute(text(stmt))
|
||||||
|
except Exception:
|
||||||
|
logger = __import__("logging").getLogger(__name__)
|
||||||
|
logger.warning("index creation failed (non-fatal): %s", stmt[:60])
|
||||||
|
|
||||||
|
|
||||||
def _migrate_custom_pages():
|
def _migrate_custom_pages():
|
||||||
"""Apply small SQLite-safe migrations for deployments without Alembic."""
|
"""Apply small SQLite-safe migrations for deployments without Alembic."""
|
||||||
inspector = inspect(engine)
|
inspector = inspect(engine)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ app.add_middleware(
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||||
|
|
||||||
# API routers
|
# API routers
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy import Integer, String, DateTime, Text, ForeignKey
|
from sqlalchemy import Index, Integer, String, DateTime, Text, ForeignKey, text
|
||||||
from sqlalchemy.orm import mapped_column, Mapped
|
from sqlalchemy.orm import mapped_column, Mapped
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
@@ -19,3 +19,8 @@ class NotificationLog(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
response_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
response_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_notif_event_created", "event_type", text("created_at DESC")),
|
||||||
|
Index("ix_notif_status_created", "status", text("created_at DESC")),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import Integer, Text, DateTime, ForeignKey
|
from sqlalchemy import Index, Integer, Text, DateTime, ForeignKey, text
|
||||||
from sqlalchemy.orm import mapped_column, Mapped
|
from sqlalchemy.orm import mapped_column, Mapped
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
@@ -11,3 +11,7 @@ class UpstreamRateSnapshot(Base):
|
|||||||
upstream_id: Mapped[int] = mapped_column(Integer, ForeignKey("upstreams.id", ondelete="CASCADE"), index=True)
|
upstream_id: Mapped[int] = mapped_column(Integer, ForeignKey("upstreams.id", ondelete="CASCADE"), index=True)
|
||||||
snapshot_json: Mapped[str] = mapped_column(Text, nullable=False)
|
snapshot_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
captured_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
captured_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_snapshot_upstream_captured", "upstream_id", text("captured_at DESC")),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy import Integer, String, Boolean, DateTime, Text, Float
|
from sqlalchemy import Index, Integer, String, Boolean, DateTime, Text, Float
|
||||||
from sqlalchemy.orm import mapped_column, Mapped
|
from sqlalchemy.orm import mapped_column, Mapped
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
@@ -36,3 +36,7 @@ class Upstream(Base):
|
|||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_upstream_enabled", "enabled"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -30,4 +30,5 @@ class UpstreamGeneratedKey(Base):
|
|||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("upstream_id", "group_id", "key_name", name="uq_upstream_group_key"),
|
UniqueConstraint("upstream_id", "group_id", "key_name", name="uq_upstream_group_key"),
|
||||||
|
Index("ix_key_upstream_name", "upstream_id", "key_name"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -66,3 +66,8 @@ class WebsiteSyncLog(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
message: Mapped[str] = mapped_column(Text, default="")
|
message: Mapped[str] = mapped_column(Text, default="")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_sync_website_created", "website_id", text("created_at DESC")),
|
||||||
|
Index("ix_sync_binding_created", "binding_id", text("created_at DESC")),
|
||||||
|
)
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ def refresh_upstream(upstream_id: int, interval_seconds: int = 0, enabled: bool
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
max_instances=1,
|
max_instances=1,
|
||||||
|
misfire_grace_time=60,
|
||||||
|
jitter=30,
|
||||||
)
|
)
|
||||||
logger.info("scheduler job %s set to %ds interval", job_id, interval_seconds)
|
logger.info("scheduler job %s set to %ds interval", job_id, interval_seconds)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ services:
|
|||||||
- DATABASE_URL=sqlite:////app/data/app.db
|
- DATABASE_URL=sqlite:////app/data/app.db
|
||||||
- TZ=${TZ:-Asia/Shanghai}
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3}
|
- UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3}
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
Reference in New Issue
Block a user