Commit f7446793 authored by wangteng's avatar wangteng

性能优化(一)基础层:事件循环解冻 + 规则编译期 API + 接口小优化

- db.py 四端点、ai.py explain/gen_regex 从 async def 改 def:内部是同步阻塞的
  DB 建连(最长 10s)与 LLM 调用(最长 30s×2),挂事件循环上会冻结整个后端;
  def 走 FastAPI 线程池。explain 按 /ai/rule 同款补 run_in_executor
- rule_runner 新增 compile_rule() 编译期 API(regex→编译好的 Pattern;
  number/date/string→沙箱 exec 一次取出 check 闭包,skip_null/date 归一化烘焙
  进闭包);run_rule 收敛为薄封装(单值测试端点继续用);_exec_user_function
  拆为 _compile_user_function + _sandbox_namespace
- tasks.py list_tasks 批量计数(逐任务 3~4 条 SQL → 3 条 group_by 聚合)
- app.py 加 GZipMiddleware(minimum_size=1024)

检测引擎接入预编译(queries.py)与流式扫描在下一笔。
Co-Authored-By: default avatarClaude Fable 5 <noreply@anthropic.com>
parent 09a35038
...@@ -18,6 +18,7 @@ except Exception: ...@@ -18,6 +18,7 @@ except Exception:
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from web.backend._logging import setup_logging, get_logger from web.backend._logging import setup_logging, get_logger
...@@ -49,6 +50,9 @@ app.add_middleware( ...@@ -49,6 +50,9 @@ app.add_middleware(
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# 2026-09-24:gzip 压缩大 JSON 响应(starlette 自带,无新依赖)。
# 查询结果 / 任务列表这类响应行数多、文本重复度高,压缩比通常 5~10 倍。
app.add_middleware(GZipMiddleware, minimum_size=1024)
from web.backend.routers.ai import router as ai_router # noqa: E402 from web.backend.routers.ai import router as ai_router # noqa: E402
......
This diff is collapsed.
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
POST /api/ai/regex body: {desc} → {ok, regex, note} (rule_type 默认为 regex) POST /api/ai/regex body: {desc} → {ok, regex, note} (rule_type 默认为 regex)
POST /api/regex/test body: {regex, value} → {ok, match, error} (rule_type 默认为 regex) POST /api/regex/test body: {regex, value} → {ok, match, error} (rule_type 默认为 regex)
「测试」端点统一走 Python(regex 走 re.compile、number/date 走 rule_runner._exec_user_function), 「测试」端点统一走 Python(regex 走 re.compile、number/date 走 rule_runner.run_rule → compile_rule),
保证和检测引擎语义一致。 保证和检测引擎语义一致。
""" """
...@@ -172,10 +172,12 @@ class GenRegexResponse(BaseModel): ...@@ -172,10 +172,12 @@ class GenRegexResponse(BaseModel):
@router.post("/ai/regex", response_model=GenRegexResponse, summary="[兼容] AI 生成 Python 正则") @router.post("/ai/regex", response_model=GenRegexResponse, summary="[兼容] AI 生成 Python 正则")
async def ai_gen_regex(req: GenRegexRequest): def ai_gen_regex(req: GenRegexRequest):
"""老端点,转发到 /ai/rule(rule_type='regex')。 """老端点,转发到 /ai/rule(rule_type='regex')。
保留原因:兼容前端历史调用,避免已有客户端集成失效。 保留原因:兼容前端历史调用,避免已有客户端集成失效。
2026-09-24:async def → def —— gen_rule 是同步 LLM 调用(最长 30s×2),
挂事件循环上会冻结整个后端。
""" """
logger.info(f"POST /api/ai/regex (legacy) desc={req.desc!r}") logger.info(f"POST /api/ai/regex (legacy) desc={req.desc!r}")
code, note = gen_rule(req.desc, "regex") code, note = gen_rule(req.desc, "regex")
...@@ -254,15 +256,21 @@ async def ai_explain_issue(req: ExplainIssueRequest): ...@@ -254,15 +256,21 @@ async def ai_explain_issue(req: ExplainIssueRequest):
f"rule_type={req.rule_type!r} rule_id={req.rule_id} rule_name={req.rule_name!r}" f"rule_type={req.rule_type!r} rule_id={req.rule_id} rule_name={req.rule_name!r}"
) )
started = time.monotonic() started = time.monotonic()
result = _explain_issue( # 2026-09-24:_explain_issue 是同步 LLM 调用(可能 1~3 次 × 30s timeout),
field_key=req.field_key, # 直接调用会把整个后端挂住 —— 放线程池执行后再返回(与 /ai/rule 同款做法)。
field_comment=req.field_comment, loop = asyncio.get_running_loop()
value=req.value, result = await loop.run_in_executor(
rule_type=req.rule_type, None,
rule_name=req.rule_name, lambda: _explain_issue(
rule_desc=req.rule_desc, field_key=req.field_key,
code=req.code, field_comment=req.field_comment,
regex=req.regex, value=req.value,
rule_type=req.rule_type,
rule_name=req.rule_name,
rule_desc=req.rule_desc,
code=req.code,
regex=req.regex,
),
) )
elapsed_ms = (time.monotonic() - started) * 1000 elapsed_ms = (time.monotonic() - started) * 1000
logger.info( logger.info(
......
...@@ -95,8 +95,11 @@ def _log_failure_hint(msg: str) -> None: ...@@ -95,8 +95,11 @@ def _log_failure_hint(msg: str) -> None:
# ── 端点 ───────────────────────────────────────────────── # ── 端点 ─────────────────────────────────────────────────
# 2026-09-24:四个端点从 async def 改为 def —— 内部是同步阻塞的 DB 建连/查询
# (connect_timeout 最长 10s),async 挂在事件循环上会冻结整个后端;
# def 端点 FastAPI 自动放进 starlette 线程池,互不阻塞。
@router.post("/test", response_model=TestConnectionResponse, summary="测试数据库连接") @router.post("/test", response_model=TestConnectionResponse, summary="测试数据库连接")
async def connect_test(req: TestConnectionRequest): def connect_test(req: TestConnectionRequest):
"""复用 web.backend.core.db_adapter,验证 db_type/host/port/user/password/database """复用 web.backend.core.db_adapter,验证 db_type/host/port/user/password/database
返回 TestConnectionResponse(ok, message, db_type, database_name) 返回 TestConnectionResponse(ok, message, db_type, database_name)
...@@ -136,7 +139,7 @@ async def connect_test(req: TestConnectionRequest): ...@@ -136,7 +139,7 @@ async def connect_test(req: TestConnectionRequest):
@router.post("/schemas", summary="列出可用的 schema / database / user") @router.post("/schemas", summary="列出可用的 schema / database / user")
async def list_schemas(req: TestConnectionRequest): def list_schemas(req: TestConnectionRequest):
"""根据 db_type 列出可连接的 schema 列表 """根据 db_type 列出可连接的 schema 列表
- MySQL = SHOW DATABASES - MySQL = SHOW DATABASES
...@@ -189,7 +192,7 @@ async def list_schemas(req: TestConnectionRequest): ...@@ -189,7 +192,7 @@ async def list_schemas(req: TestConnectionRequest):
@router.post("/tables", summary="列出指定 schema 下的所有表") @router.post("/tables", summary="列出指定 schema 下的所有表")
async def list_tables(req: TestConnectionRequest): def list_tables(req: TestConnectionRequest):
"""复用 db_adapter.list_tables(),走 info_schema/list_tables.<dialect>.sql 模板""" """复用 db_adapter.list_tables(),走 info_schema/list_tables.<dialect>.sql 模板"""
cfg = _build_db_config(req) cfg = _build_db_config(req)
logger.info("─" * 60) logger.info("─" * 60)
...@@ -220,7 +223,7 @@ class ListColumnsRequest(TestConnectionRequest): ...@@ -220,7 +223,7 @@ class ListColumnsRequest(TestConnectionRequest):
@router.post("/columns", summary="列出指定表的全部字段(数据字典)") @router.post("/columns", summary="列出指定表的全部字段(数据字典)")
async def list_columns(req: ListColumnsRequest): def list_columns(req: ListColumnsRequest):
"""复用 db_adapter.list_columns(),走 info_schema/list_columns.<dialect>.sql 模板 """复用 db_adapter.list_columns(),走 info_schema/list_columns.<dialect>.sql 模板
接收连接信息 + table_name,返回该表的所有列元数据,用于任务弹窗 接收连接信息 + table_name,返回该表的所有列元数据,用于任务弹窗
......
...@@ -323,23 +323,34 @@ def _copy_fields(db: Session, src_task: Task, dst_task: Task) -> None: ...@@ -323,23 +323,34 @@ def _copy_fields(db: Session, src_task: Task, dst_task: Task) -> None:
)) ))
def _row_to_out(db: Session, t: Task, *, include_field_list: bool = False) -> TaskOut: def _row_to_out(
db: Session,
t: Task,
*,
include_field_list: bool = False,
counts: Optional[dict] = None,
) -> TaskOut:
"""t.to_dict() 给基础字段;counts / field_list 都复用当前 session 直接 query。 """t.to_dict() 给基础字段;counts / field_list 都复用当前 session 直接 query。
include_field_list=True 时再查 field+rule 全量(详情用); include_field_list=True 时再查 field+rule 全量(详情用);
列表页不查(避免 N+1)。 列表页不查(避免 N+1)。
counts:2026-09-24 批量计数通道 —— list_tasks 传 {task_id: (fields, rules)}
预聚合结果进来,跳过下面的逐任务计数查询(原来每个任务 3~4 条 SQL)。
""" """
d = t.to_dict() d = t.to_dict()
conn = _parse_conn_json(t.conn_json) conn = _parse_conn_json(t.conn_json)
fields_count = ( if counts is not None and t.id in counts:
db.query(Field).filter(Field.task_id == t.id).count() if t.id else 0 fields_count, rules_count = counts[t.id]
) else:
task_field_ids = [f.id for f in db.query(Field).filter(Field.task_id == t.id).all()] fields_count = (
rules_count = ( db.query(Field).filter(Field.task_id == t.id).count() if t.id else 0
db.query(FieldRule).filter(FieldRule.field_id.in_(task_field_ids)).count() )
+ db.query(Rule).join(Field, Rule.field_id == Field.id).filter(Field.task_id == t.id).count() task_field_ids = [f.id for f in db.query(Field).filter(Field.task_id == t.id).all()]
) if task_field_ids else 0 rules_count = (
db.query(FieldRule).filter(FieldRule.field_id.in_(task_field_ids)).count()
+ db.query(Rule).join(Field, Rule.field_id == Field.id).filter(Field.task_id == t.id).count()
) if task_field_ids else 0
field_list: list[FieldOut] = [] field_list: list[FieldOut] = []
if include_field_list: if include_field_list:
...@@ -426,7 +437,38 @@ def list_tasks( ...@@ -426,7 +437,38 @@ def list_tasks(
like = f"%{keyword}%" like = f"%{keyword}%"
q = q.filter((Task.name.like(like)) | (Task.source_table.like(like))) q = q.filter((Task.name.like(like)) | (Task.source_table.like(like)))
rows = q.order_by(Task.id.desc()).all() rows = q.order_by(Task.id.desc()).all()
return [_row_to_out(db, t) for t in rows]
# 2026-09-24:批量计数 —— 原来逐任务 3~4 条 SQL(fields 数 + FieldRule 数 +
# 老 Rule 数),现在 3 条 group_by 聚合一次算完(任务多时列表接口明显提速)。
task_ids = [t.id for t in rows]
counts: dict = {}
if task_ids:
from sqlalchemy import func
f_cnt = dict(
db.query(Field.task_id, func.count(Field.id))
.filter(Field.task_id.in_(task_ids))
.group_by(Field.task_id)
.all()
)
fr_cnt = dict(
db.query(Field.task_id, func.count(FieldRule.id))
.join(Field, FieldRule.field_id == Field.id)
.filter(Field.task_id.in_(task_ids))
.group_by(Field.task_id)
.all()
)
r_cnt = dict(
db.query(Field.task_id, func.count(Rule.id))
.join(Field, Rule.field_id == Field.id)
.filter(Field.task_id.in_(task_ids))
.group_by(Field.task_id)
.all()
)
counts = {
tid: (f_cnt.get(tid, 0), fr_cnt.get(tid, 0) + r_cnt.get(tid, 0))
for tid in task_ids
}
return [_row_to_out(db, t, counts=counts) for t in rows]
@router.post("", response_model=TaskOut, summary="新建任务") @router.post("", response_model=TaskOut, summary="新建任务")
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment