From b7b958ed588a235851d334d5ce6c7705c6d0c1bf Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 28 Jun 2026 09:36:31 +0800 Subject: [PATCH] =?UTF-8?q?perf(pricebot):=20=E9=80=8F=E4=BC=A0=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E5=85=B1=E4=BA=AB=20httpx=20=E5=8D=95=E4=BE=8B,?= =?UTF-8?q?=E5=85=8D=E6=AF=8F=E8=AF=B7=E6=B1=82=E9=87=8D=E5=BB=BA=20SSL=20?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=20+=20=E7=BB=95=E8=BF=87=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E4=BB=A3=E7=90=86=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coupon/compare 透传原先每请求 async with httpx.AsyncClient(...) 新建: 每次重建 SSL 上下文(加载 certifi CA)实测 ~1s+,而 pricebot 是纯 HTTP 透传根本用不到 TLS;且 trust_env 默认 True 会把 http://localhost:8000 经进程代理(Clash)再绕几秒。 抽 app/core/pricebot_client.py 共享单例:trust_env=False 直连,lifespan 启动预热(SSL 一次性成本付在启动)、关停 aclose;timeout 下放到 .post() 保留 coupon 30s / compare 60s 差异。keep-alive 复用 TCP,每帧降到个位数 ms。 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/87 Co-authored-by: guke Co-committed-by: guke --- app/api/v1/compare.py | 9 +++++---- app/api/v1/coupon.py | 9 +++++---- app/core/pricebot_client.py | 35 +++++++++++++++++++++++++++++++++++ app/main.py | 3 +++ 4 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 app/core/pricebot_client.py diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index c05e7ca..29362c1 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -26,6 +26,7 @@ import httpx from fastapi import APIRouter, HTTPException, Request, status from app.core.config import settings +from app.core.pricebot_client import get_pricebot_client from app.core.pricebot_router import pick_pricebot logger = logging.getLogger("shagua.compare") @@ -65,10 +66,10 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]: ) try: - async with httpx.AsyncClient(timeout=timeout) as client: - resp = await client.post( - url, content=raw, headers={"Content-Type": "application/json"} - ) + client = get_pricebot_client() + resp = await client.post( + url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout + ) except httpx.RequestError as e: logger.error("[pricebot] request failed: %s", e) raise HTTPException( diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 07c19af..1734c79 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -20,6 +20,7 @@ from fastapi.concurrency import run_in_threadpool from app.api.deps import CurrentUser, DbSession from app.core.config import settings +from app.core.pricebot_client import get_pricebot_client from app.core.pricebot_router import pick_pricebot from app.db.session import SessionLocal from app.repositories import coupon_state as coupon_repo @@ -141,10 +142,10 @@ async def coupon_step( ) try: - async with httpx.AsyncClient(timeout=timeout) as client: - resp = await client.post( - url, content=raw, headers={"Content-Type": "application/json"} - ) + client = get_pricebot_client() + resp = await client.post( + url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout + ) except httpx.RequestError as e: logger.error("[pricebot] request failed: %s", e) raise HTTPException( diff --git a/app/core/pricebot_client.py b/app/core/pricebot_client.py new file mode 100644 index 0000000..fd81da1 --- /dev/null +++ b/app/core/pricebot_client.py @@ -0,0 +1,35 @@ +"""透传到 pricebot 的共享 httpx.AsyncClient 单例。 + +为什么不能每请求新建(coupon.py / compare.py 老写法 async with httpx.AsyncClient(...)): + ① 每次构造都重建一套 SSL 上下文(httpx.create_ssl_context 加载 certifi CA),实测 + ~1s+/次;而 pricebot 是纯 http 透传,根本用不到 TLS → 纯浪费,且每帧重交一次。 + ② trust_env 默认 True 会读进程 HTTP_PROXY,把 http://localhost:8000 这条本地透传整个 + 塞进本机代理(如 Clash 7897),恒定再多几秒。 +单例:启动只建一次(SSL/连接池一次性),keep-alive 复用 TCP,每帧降到个位数 ms。 +trust_env=False:对齐 integrations/meituan.py 的既有约定,不被进程代理误导,直连 pricebot。 +""" +from __future__ import annotations + +import httpx + +_client: httpx.AsyncClient | None = None + + +def get_pricebot_client() -> httpx.AsyncClient: + """取透传单例。lifespan 启动会预热;未预热(如测试态)懒建兜底。 + + 超时不在此固化(coupon 30s / compare 60s 不同),由调用点 client.post(timeout=...) 传。 + 懒建无 await,asyncio 单线程下不会有并发竞态。 + """ + global _client + if _client is None: + _client = httpx.AsyncClient(trust_env=False) + return _client + + +async def aclose_pricebot_client() -> None: + """lifespan 关停时调,优雅关连接池。""" + global _client + if _client is not None: + await _client.aclose() + _client = None diff --git a/app/main.py b/app/main.py index dd7ebbd..7b5e376 100644 --- a/app/main.py +++ b/app/main.py @@ -49,6 +49,7 @@ from app.core.daily_exchange_worker import ( stop_daily_exchange_worker, ) from app.core.logging import setup_logging +from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client from app.core.withdraw_reconcile_worker import ( start_withdraw_reconcile_worker, stop_withdraw_reconcile_worker, @@ -68,6 +69,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings.APP_DEBUG, settings.DATABASE_URL.split("://", 1)[0], ) + get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热 reconcile_task = start_withdraw_reconcile_worker() heartbeat_task = start_heartbeat_monitor() daily_exchange_task = start_daily_exchange_worker() @@ -77,6 +79,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: await stop_heartbeat_monitor(heartbeat_task) await stop_withdraw_reconcile_worker(reconcile_task) await stop_daily_exchange_worker(daily_exchange_task) + await aclose_pricebot_client() logger.info("shutting down")