fix(http): 支持默认HTTP客户端原子重建以恢复HTTP/2流容量

feat(db): 为外键列补充缺失的数据库索引并添加迁移脚本

- HTTPClientPool._reset_default_client 原子替换共享客户端,旧客户端在宽限期后异步关闭
- reset_upstream_client 对无代理路由不再直接跳过,改为调用 _reset_default_client
- 为 Usage, WalletTransaction, PaymentCallback, RefundRequest, VideoTask, RequestCandidate 等表的外键列添加 index=True
This commit is contained in:
fawney19
2026-03-08 01:27:50 +08:00
parent 90663793a2
commit d0eca509d4
3 changed files with 184 additions and 25 deletions

View File

@@ -0,0 +1,80 @@
"""add missing foreign key indexes for cascade delete performance
Revision ID: 2d932114930d
Revises: 8e71f2a4c9b0
Create Date: 2026-03-07 16:28:48.633531+00:00
"""
from alembic import op
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '2d932114930d'
down_revision = '8e71f2a4c9b0'
branch_labels = None
depends_on = None
def _index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
def _create_index_if_not_exists(index_name: str, table_name: str, columns: list[str]) -> None:
if not _index_exists(table_name, index_name):
op.create_index(op.f(index_name), table_name, columns, unique=False)
def _drop_index_if_exists(index_name: str, table_name: str) -> None:
if _index_exists(table_name, index_name):
op.drop_index(op.f(index_name), table_name=table_name)
# (index_name, table_name, columns)
_INDEXES = [
# api_keys.user_id (CASCADE -> users.id)
('ix_api_keys_user_id', 'api_keys', ['user_id']),
# usage: wallet_id, provider_endpoint_id, provider_api_key_id (SET NULL)
('ix_usage_wallet_id', 'usage', ['wallet_id']),
('ix_usage_provider_endpoint_id', 'usage', ['provider_endpoint_id']),
('ix_usage_provider_api_key_id', 'usage', ['provider_api_key_id']),
# wallet_transactions.operator_id (SET NULL -> users.id)
('ix_wallet_transactions_operator_id', 'wallet_transactions', ['operator_id']),
# payment_callbacks.payment_order_id (SET NULL -> payment_orders.id)
('ix_payment_callbacks_payment_order_id', 'payment_callbacks', ['payment_order_id']),
# refund_requests: payment_order_id, requested_by, approved_by, processed_by (SET NULL)
('ix_refund_requests_payment_order_id', 'refund_requests', ['payment_order_id']),
('ix_refund_requests_requested_by', 'refund_requests', ['requested_by']),
('ix_refund_requests_approved_by', 'refund_requests', ['approved_by']),
('ix_refund_requests_processed_by', 'refund_requests', ['processed_by']),
# proxy_nodes.registered_by (SET NULL -> users.id)
('ix_proxy_nodes_registered_by', 'proxy_nodes', ['registered_by']),
# video_tasks: api_key_id, provider_id, endpoint_id, key_id, remixed_from_task_id
('ix_video_tasks_api_key_id', 'video_tasks', ['api_key_id']),
('ix_video_tasks_provider_id', 'video_tasks', ['provider_id']),
('ix_video_tasks_endpoint_id', 'video_tasks', ['endpoint_id']),
('ix_video_tasks_key_id', 'video_tasks', ['key_id']),
('ix_video_tasks_remixed_from_task_id', 'video_tasks', ['remixed_from_task_id']),
# user_preferences.default_provider_id (-> providers.id)
('ix_user_preferences_default_provider_id', 'user_preferences', ['default_provider_id']),
# announcements.author_id (SET NULL -> users.id)
('ix_announcements_author_id', 'announcements', ['author_id']),
# announcement_reads.announcement_id (-> announcements.id)
('ix_announcement_reads_announcement_id', 'announcement_reads', ['announcement_id']),
# request_candidates: user_id, api_key_id, endpoint_id, key_id (CASCADE)
('ix_request_candidates_user_id', 'request_candidates', ['user_id']),
('ix_request_candidates_api_key_id', 'request_candidates', ['api_key_id']),
('ix_request_candidates_endpoint_id', 'request_candidates', ['endpoint_id']),
('ix_request_candidates_key_id', 'request_candidates', ['key_id']),
]
def upgrade() -> None:
for index_name, table_name, columns in _INDEXES:
_create_index_if_not_exists(index_name, table_name, columns)
def downgrade() -> None:
for index_name, table_name, _columns in reversed(_INDEXES):
_drop_index_if_exists(index_name, table_name)

View File

@@ -54,6 +54,8 @@ class HTTPClientPool:
_max_proxy_clients: int = 50 _max_proxy_clients: int = 50
# Tunnel 客户端缓存:{node_id: client} # Tunnel 客户端缓存:{node_id: client}
_tunnel_clients: dict[str, httpx.AsyncClient] = {} _tunnel_clients: dict[str, httpx.AsyncClient] = {}
# 后台清理任务引用集合(防止被 GC 回收)
_background_tasks: set[asyncio.Task[None]] = set()
def __new__(cls) -> "HTTPClientPool": def __new__(cls) -> "HTTPClientPool":
if cls._instance is None: if cls._instance is None:
@@ -457,6 +459,51 @@ class HTTPClientPool:
client_config.update(kwargs) client_config.update(kwargs)
return httpx.AsyncClient(**client_config) # type: ignore[arg-type] return httpx.AsyncClient(**client_config) # type: ignore[arg-type]
@classmethod
async def _reset_default_client(cls) -> bool:
"""Atomically replace the shared default client with a fresh instance.
The old client is kept open briefly so that in-flight requests can
finish on their existing HTTP/2 streams; it is closed asynchronously
after a short grace period.
"""
async with _default_client_lock:
old_client = cls._default_client
if old_client is None:
return False
# Create a new client before discarding the old one
cls._default_client = httpx.AsyncClient(
http2=config.enable_http2,
verify=get_ssl_context(),
timeout=httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
),
limits=httpx.Limits(
max_connections=config.http_max_connections,
max_keepalive_connections=config.http_keepalive_connections,
keepalive_expiry=config.http_keepalive_expiry,
),
follow_redirects=True,
)
# Close old client after a grace period so in-flight requests can drain
async def _close_old() -> None:
await asyncio.sleep(5)
try:
await old_client.aclose()
except Exception as exc:
logger.warning("关闭旧默认客户端失败: {}", exc)
task = asyncio.create_task(_close_old())
cls._background_tasks.add(task)
task.add_done_callback(cls._background_tasks.discard)
logger.warning("默认HTTP客户端已重建(HTTP/2 流容量恢复)")
return True
@classmethod @classmethod
async def reset_upstream_client( async def reset_upstream_client(
cls, cls,
@@ -467,8 +514,9 @@ class HTTPClientPool:
"""Reset cached upstream client for the given proxy/tunnel route. """Reset cached upstream client for the given proxy/tunnel route.
Returns True when a cached client was closed and removed. Returns True when a cached client was closed and removed.
For the shared no-proxy default client this is a no-op to avoid For the shared no-proxy default client, atomically replaces it with a
disrupting unrelated in-flight requests. new instance so that subsequent requests get a fresh HTTP/2 connection
while in-flight requests on the old client can finish naturally.
""" """
if delegate_cfg and delegate_cfg.get("tunnel"): if delegate_cfg and delegate_cfg.get("tunnel"):
node_id = str(delegate_cfg.get("node_id") or "") node_id = str(delegate_cfg.get("node_id") or "")
@@ -490,7 +538,7 @@ class HTTPClientPool:
base_cache_key = compute_proxy_cache_key(proxy_config) base_cache_key = compute_proxy_cache_key(proxy_config)
if base_cache_key == "__no_proxy__": if base_cache_key == "__no_proxy__":
return False return await cls._reset_default_client()
cache_key_prefixes = [base_cache_key] cache_key_prefixes = [base_cache_key]
tls_profile_key = str(tls_profile or "").strip().lower() tls_profile_key = str(tls_profile or "").strip().lower()

View File

@@ -188,7 +188,9 @@ class ApiKey(Base):
) )
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
key_hash = Column(String(64), unique=True, index=True, nullable=False) # API密钥的SHA256哈希 key_hash = Column(String(64), unique=True, index=True, nullable=False) # API密钥的SHA256哈希
key_encrypted = Column(Text, nullable=True) # 加密后的完整密钥,用于查看 key_encrypted = Column(Text, nullable=True) # 加密后的完整密钥,用于查看
name = Column(String(100), nullable=True) # 密钥名称,便于用户管理 name = Column(String(100), nullable=True) # 密钥名称,便于用户管理
@@ -319,7 +321,9 @@ class Usage(Base):
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True) api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True)
wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="SET NULL"), nullable=True) wallet_id = Column(
String(36), ForeignKey("wallets.id", ondelete="SET NULL"), nullable=True, index=True
)
# 请求信息 # 请求信息
request_id = Column(String(100), unique=True, index=True, nullable=False) request_id = Column(String(100), unique=True, index=True, nullable=False)
@@ -332,10 +336,16 @@ class Usage(Base):
# Provider 侧追踪信息(记录最终成功的 Provider/Endpoint/Key # Provider 侧追踪信息(记录最终成功的 Provider/Endpoint/Key
provider_id = Column(String(36), ForeignKey("providers.id", ondelete="SET NULL"), nullable=True) provider_id = Column(String(36), ForeignKey("providers.id", ondelete="SET NULL"), nullable=True)
provider_endpoint_id = Column( provider_endpoint_id = Column(
String(36), ForeignKey("provider_endpoints.id", ondelete="SET NULL"), nullable=True String(36),
ForeignKey("provider_endpoints.id", ondelete="SET NULL"),
nullable=True,
index=True,
) )
provider_api_key_id = Column( provider_api_key_id = Column(
String(36), ForeignKey("provider_api_keys.id", ondelete="SET NULL"), nullable=True String(36),
ForeignKey("provider_api_keys.id", ondelete="SET NULL"),
nullable=True,
index=True,
) )
# Token统计 # Token统计
@@ -582,7 +592,9 @@ class WalletTransaction(Base):
link_type = Column(String(30), nullable=True) link_type = Column(String(30), nullable=True)
link_id = Column(String(100), nullable=True) link_id = Column(String(100), nullable=True)
operator_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) operator_id = Column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
created_at = Column( created_at = Column(
@@ -648,7 +660,7 @@ class PaymentCallback(Base):
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
payment_order_id = Column( payment_order_id = Column(
String(36), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True String(36), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True, index=True
) )
payment_method = Column(String(30), nullable=False) payment_method = Column(String(30), nullable=False)
@@ -686,7 +698,7 @@ class RefundRequest(Base):
wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="RESTRICT"), nullable=False) wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="RESTRICT"), nullable=False)
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
payment_order_id = Column( payment_order_id = Column(
String(36), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True String(36), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True, index=True
) )
source_type = Column(String(30), nullable=False) # payment_order/manual_recharge/card_recharge source_type = Column(String(30), nullable=False) # payment_order/manual_recharge/card_recharge
@@ -696,9 +708,15 @@ class RefundRequest(Base):
status = Column(String(30), nullable=False, default="pending_approval") status = Column(String(30), nullable=False, default="pending_approval")
reason = Column(Text, nullable=True) reason = Column(Text, nullable=True)
requested_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) requested_by = Column(
approved_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
processed_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) )
approved_by = Column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
processed_by = Column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
gateway_refund_id = Column(String(128), nullable=True) gateway_refund_id = Column(String(128), nullable=True)
payout_method = Column(String(50), nullable=True) payout_method = Column(String(50), nullable=True)
@@ -1125,6 +1143,7 @@ class ProxyNode(Base):
String(36), String(36),
ForeignKey("users.id", ondelete="SET NULL"), ForeignKey("users.id", ondelete="SET NULL"),
nullable=True, nullable=True,
index=True,
comment="注册该节点的管理员用户 ID可空", comment="注册该节点的管理员用户 ID可空",
) )
last_heartbeat_at = Column(DateTime(timezone=True), nullable=True) last_heartbeat_at = Column(DateTime(timezone=True), nullable=True)
@@ -1914,10 +1933,10 @@ class VideoTask(Base):
# 关联 # 关联
user_id = Column(String(36), ForeignKey("users.id"), nullable=False) user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
api_key_id = Column(String(36), ForeignKey("api_keys.id")) api_key_id = Column(String(36), ForeignKey("api_keys.id"), index=True)
provider_id = Column(String(36), ForeignKey("providers.id")) provider_id = Column(String(36), ForeignKey("providers.id"), index=True)
endpoint_id = Column(String(36), ForeignKey("provider_endpoints.id")) endpoint_id = Column(String(36), ForeignKey("provider_endpoints.id"), index=True)
key_id = Column(String(36), ForeignKey("provider_api_keys.id")) key_id = Column(String(36), ForeignKey("provider_api_keys.id"), index=True)
# 格式转换追踪 # 格式转换追踪
client_api_format = Column(String(50), nullable=False) client_api_format = Column(String(50), nullable=False)
@@ -1967,7 +1986,7 @@ class VideoTask(Base):
# Remix 支持 # Remix 支持
remixed_from_task_id = Column( remixed_from_task_id = Column(
String(36), ForeignKey("video_tasks.id", ondelete="SET NULL"), nullable=True String(36), ForeignKey("video_tasks.id", ondelete="SET NULL"), nullable=True, index=True
) )
# 使用追踪(候选 key、请求头等 # 使用追踪(候选 key、请求头等
@@ -2021,7 +2040,7 @@ class UserPreference(Base):
bio = Column(Text, nullable=True) # 个人简介 bio = Column(Text, nullable=True) # 个人简介
# 偏好设置 # 偏好设置
default_provider_id = Column(String(36), ForeignKey("providers.id"), nullable=True) default_provider_id = Column(String(36), ForeignKey("providers.id"), nullable=True, index=True)
theme = Column(String(20), default="light") # light/dark/auto theme = Column(String(20), default="light") # light/dark/auto
language = Column(String(10), default="zh-CN") language = Column(String(10), default="zh-CN")
timezone = Column(String(50), default="Asia/Shanghai") timezone = Column(String(50), default="Asia/Shanghai")
@@ -2059,7 +2078,9 @@ class Announcement(Base):
priority = Column(Integer, default=0) # 优先级,数字越大越重要 priority = Column(Integer, default=0) # 优先级,数字越大越重要
# 发布信息 # 发布信息
author_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) author_id = Column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
is_active = Column(Boolean, default=True, index=True) is_active = Column(Boolean, default=True, index=True)
is_pinned = Column(Boolean, default=False) # 置顶 is_pinned = Column(Boolean, default=False) # 置顶
@@ -2095,7 +2116,7 @@ class AnnouncementRead(Base):
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
announcement_id = Column(String(36), ForeignKey("announcements.id"), nullable=False) announcement_id = Column(String(36), ForeignKey("announcements.id"), nullable=False, index=True)
read_at = Column( read_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
) )
@@ -2373,18 +2394,28 @@ class RequestCandidate(Base):
# 关联字段 # 关联字段
request_id = Column(String(100), nullable=False, index=True) request_id = Column(String(100), nullable=False, index=True)
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True) user_id = Column(
api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=True) String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
api_key_id = Column(
String(36), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=True, index=True
)
# 候选信息 # 候选信息
candidate_index = Column(Integer, nullable=False) # 候选序号从0开始 candidate_index = Column(Integer, nullable=False) # 候选序号从0开始
retry_index = Column(Integer, nullable=False, default=0) # 重试序号从0开始 retry_index = Column(Integer, nullable=False, default=0) # 重试序号从0开始
provider_id = Column(String(36), ForeignKey("providers.id", ondelete="CASCADE"), nullable=True) provider_id = Column(String(36), ForeignKey("providers.id", ondelete="CASCADE"), nullable=True)
endpoint_id = Column( endpoint_id = Column(
String(36), ForeignKey("provider_endpoints.id", ondelete="CASCADE"), nullable=True String(36),
ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
nullable=True,
index=True,
) )
key_id = Column( key_id = Column(
String(36), ForeignKey("provider_api_keys.id", ondelete="CASCADE"), nullable=True String(36),
ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
nullable=True,
index=True,
) )
# 状态信息 # 状态信息