perf: 优化批量删除策略,缩小事务粒度并增强容错

- 批量删除分批大小从 500 降至 50,减少单事务锁持有时间
- 单批失败时跳过并继续,不再中断整个删除任务
- 进度上报改为按批次计数(每 5 批或末批),替代时间限频
- 关联表清理简化为直接 DELETE WHERE IN,移除逐行分批删除
This commit is contained in:
fawney19
2026-03-09 02:35:07 +08:00
parent 84cf07b7a2
commit fd32597015
2 changed files with 25 additions and 47 deletions

View File

@@ -8,7 +8,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import time
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
@@ -27,8 +26,8 @@ STATUS_FAILED = "failed"
# 任务完成后保留时长(秒),也用作 Redis key 的 TTL # 任务完成后保留时长(秒),也用作 Redis key 的 TTL
_TASK_RETAIN_SECONDS = 600 _TASK_RETAIN_SECONDS = 600
# 删除 ProviderAPIKey 时的分批大小 # 批量删除时每个独立事务处理的 key 数量
_DELETE_BATCH_SIZE = 500 _CLEANUP_BATCH_SIZE = 50
# Redis key 前缀 # Redis key 前缀
_REDIS_KEY_PREFIX = "batch_delete_task" _REDIS_KEY_PREFIX = "batch_delete_task"
@@ -175,7 +174,8 @@ def _sync_delete(
) -> int: ) -> int:
"""在线程中执行的同步删除逻辑,避免阻塞事件循环。 """在线程中执行的同步删除逻辑,避免阻塞事件循环。
每批 cleanup + DELETE + commit 作为独立事务,减少锁持有时间。 按小批次_CLEANUP_BATCH_SIZE清理关联表并删除 key
每个批次独立事务,单批失败跳过并继续,确保进度条持续推进。
""" """
from src.database import create_session from src.database import create_session
from src.models.database import ProviderAPIKey from src.models.database import ProviderAPIKey
@@ -184,8 +184,11 @@ def _sync_delete(
db = create_session() db = create_session()
try: try:
affected = 0 affected = 0
for i in range(0, len(key_ids), _DELETE_BATCH_SIZE): total_batches = (len(key_ids) + _CLEANUP_BATCH_SIZE - 1) // _CLEANUP_BATCH_SIZE
batch = key_ids[i : i + _DELETE_BATCH_SIZE] batch_idx = 0
for i in range(0, len(key_ids), _CLEANUP_BATCH_SIZE):
batch = key_ids[i : i + _CLEANUP_BATCH_SIZE]
batch_idx += 1
try: try:
cleanup_key_references(db, batch) cleanup_key_references(db, batch)
result = db.execute( result = db.execute(
@@ -197,14 +200,19 @@ def _sync_delete(
rowcount = getattr(result, "rowcount", 0) or 0 rowcount = getattr(result, "rowcount", 0) or 0
affected += int(rowcount) affected += int(rowcount)
db.commit() db.commit()
except Exception: except Exception as exc:
logger.warning(
"[BATCH_DELETE] batch failed (keys {}-{}): {}",
i,
i + len(batch),
exc,
)
try: try:
db.rollback() db.rollback()
except Exception: except Exception:
pass pass
raise # 每 5 个批次或最后一个批次上报进度,避免过于频繁写 Redis
# 通过回调通知进度(在事件循环线程中更新 Redis if progress_callback is not None and (batch_idx % 5 == 0 or batch_idx == total_batches):
if progress_callback is not None:
progress_callback(affected) progress_callback(affected)
return affected return affected
finally: finally:
@@ -222,16 +230,10 @@ async def _run_batch_delete(
r = await get_redis_client(require_redis=False) r = await get_redis_client(require_redis=False)
await _update_task_field(task_id, r=r, status=STATUS_RUNNING) await _update_task_field(task_id, r=r, status=STATUS_RUNNING)
# 用于从工作线程安全地触发 Redis 进度更新(按时间限频,最多每 2 秒写一次) # 从工作线程安全地触发 Redis 进度更新
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
last_report_time = [0.0]
_PROGRESS_INTERVAL = 2.0
def on_progress(current: int) -> None: def on_progress(current: int) -> None:
now = time.monotonic()
if now - last_report_time[0] < _PROGRESS_INTERVAL:
return
last_report_time[0] = now
try: try:
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
_update_task_field(task_id, r=r, deleted=current), loop _update_task_field(task_id, r=r, deleted=current), loop

View File

@@ -4,10 +4,7 @@ Provider Key 写操作后的副作用处理。
from __future__ import annotations from __future__ import annotations
from typing import Any
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.base.models_service import invalidate_models_list_cache from src.api.base.models_service import invalidate_models_list_cache
@@ -18,43 +15,22 @@ from src.services.cache.provider_cache import ProviderCacheService
_SQLITE_BATCH_SIZE = 900 _SQLITE_BATCH_SIZE = 900
_DEFAULT_BATCH_SIZE = 2000 _DEFAULT_BATCH_SIZE = 2000
# RequestCandidate 等大表每轮 DELETE 的行数上限,
# 避免单条 DELETE 语句扫描过多行导致超时。
_ROW_DELETE_BATCH = 5000
def cleanup_key_references(db: Session, key_ids: list[str]) -> None: def cleanup_key_references(db: Session, key_ids: list[str]) -> None:
"""在删除 ProviderAPIKey 前,先清理关联表记录,避免 CASCADE 级联删除超时。""" """在删除 ProviderAPIKey 前,先清理关联表记录,避免 CASCADE 级联删除超时。
PostgreSQL 下直接使用 DELETE WHERE key_id IN (...),利用 key_id 索引高效删除。
SQLite 下按 key_id 批次拆分,避免超出变量数限制。
"""
if not key_ids: if not key_ids:
return return
batch_size = _resolve_batch_size(db) batch_size = _resolve_batch_size(db)
for batch in _iter_batches(key_ids, batch_size): for batch in _iter_batches(key_ids, batch_size):
# RequestCandidate 数据量通常最大,分批按行数删除 db.execute(sa_delete(RequestCandidate).where(RequestCandidate.key_id.in_(batch)))
_delete_in_row_batches(db, RequestCandidate, RequestCandidate.key_id, batch)
db.execute(sa_delete(GeminiFileMapping).where(GeminiFileMapping.key_id.in_(batch))) db.execute(sa_delete(GeminiFileMapping).where(GeminiFileMapping.key_id.in_(batch)))
db.execute(sa_delete(VideoTask).where(VideoTask.key_id.in_(batch))) db.execute(sa_delete(VideoTask).where(VideoTask.key_id.in_(batch)))
def _delete_in_row_batches(db: Session, model: Any, fk_col: Any, key_id_batch: list[str]) -> int:
"""分批删除大表记录,每次最多删除 _ROW_DELETE_BATCH 行,避免单条语句超时。"""
total = 0
while True:
row_ids = [
r[0]
for r in db.execute(
select(model.id).where(fk_col.in_(key_id_batch)).limit(_ROW_DELETE_BATCH)
)
]
if not row_ids:
break
result = db.execute(sa_delete(model).where(model.id.in_(row_ids)))
deleted = getattr(result, "rowcount", 0) or 0
total += deleted
if len(row_ids) < _ROW_DELETE_BATCH:
break
return total
def _resolve_batch_size(db: Session) -> int: def _resolve_batch_size(db: Session) -> int:
try: try:
bind = db.get_bind() bind = db.get_bind()