Commit aeb29b90 authored by wangteng's avatar wangteng

性能优化(二)流式扫描:OFFSET 深分页 → 后端单连接流式扫描 + /pull 轮询

旧方案每页独立连接跑 LIMIT/OFFSET,页越深扫得越多(50万行=1000次建连+
1000次全表扫描排序)。改为 /start 起 daemon 线程单连接 iter_rows 流式扫
全表一遍(保留 ORDER BY 首列,全表只排序一次),命中行进 session 缓冲区,
前端由 3-worker 分页队列改为轮询 /pull 增量拉取(满额立即续拉)。

后端:
- queries.py:_scan_worker 扫描线程(cancelled 无锁读+关连接双保险、每
  1000 行发布进度、命中超 10 万条截断只计数);/pull 游标切片;/cancel
  摘 session+request_cancel;删 /page;_load_task_compiled 批量化(N+1→
  固定 4 条查询)+ 接入 compile_rule 预编译(regex 每行 re.compile、code
  类每行 compile+exec 的量级浪费消除);/start 的 COUNT 与字段注释合并
  单连接;并发扫描软上限 MAX_ACTIVE_SCANS=4
- session_manager.py:QuerySession 扩展流式扫描状态(scanned/bad_rows/
  scan_done/scan_error/truncated,统一持锁读写),request_cancel() 幂等
  取消(置标志+关连接),TTL 清理锁外停掉过期扫描线程
- db_adapter.py:DBConnection.close() 抽为 public 幂等(跨线程取消用);
  iter_rows finally 的 cur.close 包保护(取消关连接后 generator 提前退出)

前端:
- queries.js:fetchPage → pullQuery(after 游标)
- DataQualityView.vue:pollLoop 轮询器(404 分流本端取消/会话过期、网络
  异常 2s 退避×3、done 定稿 toast);进度条改「已扫描 X/Y 条(百分比)」;
  快照/续跑按 __row_index 去重适配;无规则任务提示修正(原误报"无数据")
- client.js:错误对象挂 HTTP status

实测(37151 行任务):扫描 12.5s→0.9s;扫描期间 /api/health 均值
114ms→2.2ms;code 类规则求值 175x;命中结果与旧版逐字节一致。
Co-Authored-By: default avatarClaude Fable 5 <noreply@anthropic.com>
parent f7446793
......@@ -612,9 +612,17 @@ class DBConnection:
return self
def __exit__(self, exc_type, exc, tb):
if self._conn is not None:
self.close()
def close(self) -> None:
"""关闭连接(幂等)。2026-09-24:从 __exit__ 抽成 public —— 流式扫描的
取消路径要跨线程关连接(/cancel 或 TTL 清理在别的线程调用),与 worker
自己的 with 退出双重关闭是常态,必须幂等。
"""
conn, self._conn = self._conn, None
if conn is not None:
try:
self._conn.close()
conn.close()
logger.debug("[DB] 连接已关闭")
except Exception as e:
logger.warning(f"[DB] 关闭连接异常: {e}")
......@@ -682,7 +690,13 @@ class DBConnection:
finally:
elapsed_ms = (time.monotonic() - started) * 1000
logger.debug(f"[SQL] iter_rows {n} rows ({elapsed_ms:.1f}ms): {sql[:120]}...")
# 2026-09-24:连接可能已被取消线程关闭(流式扫描的 /cancel 路径),
# generator 提前退出走到这里时 cur.close() 会对已关连接抛异常、
# 掩盖原退出路径 —— 包一层按原计划收尾。
try:
cur.close()
except Exception:
pass
def fetchone(self, sql: str, params: tuple | None = None) -> Optional[dict]:
cur = self._cursor()
......
"""查询 session 管理器(web 自包含版)
为每次 ``POST /api/queries/start`` 在内存里建一个 ``QuerySession``,记录任务配置 +
DB 连接 + 分页参数的快照。前端按页号去 ``POST /api/queries/page`` 拉一页结果,可并发
拉多页,并主动 ``POST /api/queries/cancel`` 终止。
编译好的规则 + 流式扫描状态。后端起 daemon 线程单连接流式扫全表(2026-09-24 起,
替代逐页 LIMIT/OFFSET 深分页),前端经 ``POST /api/queries/pull`` 增量拉取命中行 +
进度,并主动 ``POST /api/queries/cancel`` 终止。
生命周期:
创建 ``/start`` 跑完 COUNT 后建 session 入 ``_REGISTRY``,UUID 作 token 返回
取消 ``/cancel`` 从 ``_REGISTRY`` pop 掉,后续 ``/page`` 立刻 409 短路
TTL 每次 ``/start`` 入口懒清理 ``>30min`` 的 session(无后台线程,简单即正义)
创建 ``/start`` 跑完 COUNT 后建 session 入 ``_REGISTRY``(先 put 后起扫描线程),
UUID 作 token 返回
拉取 ``/pull`` 按 after 游标从 bad_rows 缓冲区增量切片返回(不摘 session)
取消 ``/cancel`` 从 ``_REGISTRY`` pop 掉 + request_cancel()(置标志 + 关扫描
连接),后续 ``/pull`` 立刻 404 短路
TTL 每次 ``/start`` 入口懒清理 ``>30min`` 的 session(无后台线程,简单即正义);
过期 session 同样 request_cancel(),停掉可能还在扫的线程
隔离粒度:**per-client** 而非 **per-user**。
当前无 auth 模块 → 任何人拿到 ``session_id`` 都能拉/取消对应 session;
......@@ -22,10 +27,13 @@ from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Optional
from typing import Any, Callable, Optional
from web.backend.core.db_adapter import DBConfig
# 编译期产出的规则校验闭包:check(value) -> bool(rule_runner.compile_rule 的返回类型)
RuleCheckFn = Callable[[Any], bool]
# ── 模块级存储 ────────────────────────────────────────────
# 改 per-user 时记得把 get/pop 都接受 user_id 校验。
......@@ -43,53 +51,87 @@ class CompiledFieldRules:
字段大小写已归一为小写(与列名对齐)。
"""
field_key: str
# 每条规则:(rule_type, regex_or_None, code_or_None, desc, skip_null, name, rule_id)
# 2026-08-21:追加 skip_null(值为空时是否跳过)
# 2026-08-24:追加 name(规则名称)→ 给 _evaluate_row 写到 issues 里,
# 前端「数据明细」按字段+规则名展示「违反哪条规则」
# 2026-08-24:再追加 rule_id(Rule ORM 主键)→ 前端「点 ! 问 LLM」时用来
# 在 col.ruleList[] 里精确定位原规则(含 code/regex/rule_type)
rules: list[tuple[str, Optional[str], Optional[str], str, bool, str, int]] = field(default_factory=list)
# 每条规则:(rule_type, check_fn_or_None, desc, name, rule_id)
# 2026-09-24:预编译改造 —— check_fn 是 rule_runner.compile_rule() 产出的
# check(value) -> bool 闭包(regex 存编译好的 Pattern,number/date/string 存
# exec 一次后取出的用户 check 函数;skip_null 短路已烘焙进去)。
# 之前存 regex/code 字符串、每行重复 compile+exec(含重建沙箱命名空间),
# 全表扫描量级差距巨大。
# None = 编译失败的坏规则:_evaluate_row 对每行判不合规并注明「规则编译失败」。
# desc:规则说明(issues 展示用);name:规则名称(2026-08-24 加,前端展示
# 「违反哪条规则」);rule_id:Rule ORM 主键(2026-08-24 加,前端「点 ! 问
# LLM」时在 col.ruleList[] 里精确定位原规则)。
rules: list[tuple[str, Optional["RuleCheckFn"], str, str, int]] = field(default_factory=list)
@dataclass
class QuerySession:
"""一次查询的所有上下文。
注意:
``cancelled`` 由 ``/cancel`` 置 True 并从 ``_REGISTRY`` 摘掉;
但 ``/page`` 在并发飞过来时仍可能拿到 in-flight 副本,所以 ``/page`` 入口要
二次检查 ``_REGISTRY`` 里是否还在(不在就 404),以及 session 上的 cancelled。
2026-09-24 流式扫描改造:session 不再被 /page 逐页引用,而是绑定一个后台扫描
线程(daemon)单连接流式扫全表,结果缓存在 bad_rows 缓冲区,前端经 /pull
增量拉取。生命周期:
/start 建 session 入 _REGISTRY + 起扫描线程(先 put 后 start)
/pull 按 after 游标切缓冲区增量返回 + 进度计数快照
/cancel request_cancel():置 cancelled + 关扫描连接(worker 批次间退出)
TTL 30min 懒清理;过期 session 同样 request_cancel()(停掉泄漏线程)
加锁纪律(重要):
- bad_rows / bad_rows_total / scanned / truncated / scan_done / scan_error /
scan_conn 一律持 self.lock 写;/pull 持锁切片(ms 级,与 worker 抢锁无竞争)
- cancelled 锁内置位;扫描 worker 循环里**无锁读**(GIL 下 bool 读原子,
只为尽快退出,读到旧值最多多扫一批)
"""
session_id: str
task_id: int
db_config: DBConfig
db_type: str
page_size: int
total_rows: int
total_pages: int
select_sql_base: str # SELECT cols FROM table ORDER BY first_field
compiled: list[CompiledFieldRules] = field(default_factory=list)
col_names: list[str] = field(default_factory=list)
field_list_out: list[dict] = field(default_factory=list) # 给前端表头用
cancelled: bool = False
pages_scanned: int = 0
bad_rows_total: int = 0
created_at: float = field(default_factory=time.time)
lock: threading.Lock = field(default_factory=threading.Lock)
def mark_page_done(self, bad_delta: int) -> None:
# ── 流式扫描状态(2026-09-24,除 scan_thread 外全部经 self.lock 读写)──
scanned: int = 0 # 已扫行数(worker 每 SCAN_PUBLISH_EVERY 行发布一次)
bad_rows: list[dict] = field(default_factory=list) # 命中行缓冲区(只追加;超上限停止收集)
bad_rows_total: int = 0 # 命中总数(含被截断丢弃的部分)
truncated: bool = False # 缓冲区超上限后置 True(前端提示「仅展示前 N 条」)
scan_done: bool = False # worker 终止标志(成功/异常/取消都置)
scan_error: Optional[str] = None # 非取消原因的异常描述
scan_conn: Optional[object] = None # worker 打开的 DBConnection 引用(取消用;避免循环 import 用 object 注解)
scan_thread: Optional[threading.Thread] = None
def request_cancel(self) -> None:
"""置取消标志 + best-effort 关闭扫描连接(幂等;/cancel 与 TTL 清理共用)。
关闭连接是为了打断阻塞中的 DB 读取(MySQL SSDictCursor 会被 close 唤醒);
连不上也无妨 —— worker 在下一个批次边界看到 cancelled 退出。
"""
with self.lock:
self.pages_scanned += 1
self.bad_rows_total += bad_delta
self.cancelled = True
conn = self.scan_conn
if conn is not None:
try:
conn.close()
except Exception:
pass
# ── CRUD ──────────────────────────────────────────────────
def put(session: QuerySession) -> None:
"""插入 session,入口处顺便清一遍过期 session"""
"""插入 session,入口处顺便清一遍过期 session(含停掉其 in-flight 扫描线程)"""
with _REGISTRY_LOCK:
_purge_expired_locked()
expired = _purge_expired_locked()
_REGISTRY[session.session_id] = session
# 锁外取消:request_cancel 内部会抢 session.lock + 关 DB 连接,
# 放在 registry 锁内一旦与 /pull(持 session.lock)交叠容易拉长临界区
for s in expired:
s.request_cancel()
def get(session_id: str) -> Optional[QuerySession]:
......@@ -109,14 +151,25 @@ def size() -> int:
return len(_REGISTRY)
def active_scan_count() -> int:
"""还在扫描中的 session 数(/start 的并发软上限用,防连点/泄漏)。
判定 = 未 done 且未取消;对 scan_done 的无锁读在「worker 正要置 done」的
窗口里可能多算一个,作为软上限的误差可接受。
"""
with _REGISTRY_LOCK:
return sum(1 for s in _REGISTRY.values() if not s.scan_done and not s.cancelled)
# ── 内部 ──────────────────────────────────────────────────
def _purge_expired_locked() -> None:
"""把超过 TTL 的 session 删掉。调用方必须已持有 _REGISTRY_LOCK。
def _purge_expired_locked() -> list[QuerySession]:
"""把超过 TTL 的 session 摘掉,返回被摘的 session 列表(调用方锁外 request_cancel)。
Lazy cleanup —— 没有后台线程;只在 /start 入口触发一次。
单进程 uvicorn 这个开销可忽略(session 数个位数)。
"""
cutoff = time.time() - _TTL_SECONDS
expired = [sid for sid, s in _REGISTRY.items() if s.created_at < cutoff]
for sid in expired:
_REGISTRY.pop(sid, None)
\ No newline at end of file
expired = [s for sid, s in _REGISTRY.items() if s.created_at < cutoff]
for s in expired:
_REGISTRY.pop(s.session_id, None)
return expired
\ No newline at end of file
This diff is collapsed.
......@@ -51,7 +51,10 @@ async function request(path, { method = 'GET', body, params } = {}, { signal } =
const body = await res.json()
msg = errorDetailText(body.detail || body.message) || msg
} catch (_) { /* 非 JSON */ }
throw new Error(msg)
// 2026-09-24:挂上 HTTP status —— 轮询器等调用方要按 404(会话过期)分流
const err = new Error(msg)
err.status = res.status
throw err
}
if (res.status === 204) return null
return res.json()
......
/**
* 数据查询 / 校验 API(分页版,2026-08-21 重构)
* 数据查询 / 校验 API(流式扫描版,2026-09-24 重构)
*
* 老端点 POST /api/queries/run(一次性同步返回)已删。
* 旧分页三段式(start + 逐页 fetchPage + cancel)已删 —— 每页独立
* LIMIT/OFFSET 深分页在大表上退化严重(页越深扫得越多)。
* 新三段式:
* - startQuery(taskId, { pageSize, signal }) 创建 session + 返 total_rows/total_pages/field_list
* - fetchPage(sessionId, pageNo, { signal }) 拉一页(可并发,多页同时拉)
* - cancelQuery(sessionId, { signal }) 主动取消,best-effort
* - startQuery(taskId, { signal }) 创建 session:COUNT + 编译规则 + 起后台扫描线程
* - pullQuery(sessionId, after, { signal }) 增量拉取命中行 + 进度(前端轮询,满额立即续拉)
* - cancelQuery(sessionId, { signal }) 主动取消:摘 session + 关扫描连接
*
* 三段都支持 AbortSignal:传 signal 后前端 abort 会让 fetch 直接 reject
* (网络中断 + 节省带宽),同时 server 端 /cancel 摘掉 session 让后续 /page 立刻 404。
* (网络中断 + 节省带宽),同时 server 端 /cancel 摘掉 session 让后续 /pull 立刻 404。
*/
import { http } from './client'
/**
* 创建查询 session(跑 COUNT + 编译规则)
* 创建查询 session(COUNT + 编译规则 + 起扫描线程)
*
* @param {number} taskId
* @param {{ pageSize?: number, signal?: AbortSignal }} [opts]
* @param {{ signal?: AbortSignal }} [opts]
* @returns {Promise<{
* ok: boolean,
* message?: string,
* session_id: string,
* task_id: number,
* total_rows: number,
* total_pages: number,
* page_size: number,
* total_rows: number, // 进度分母
* total_pages: number, // 兼容保留,流式版恒 0
* page_size: number, // 兼容保留,流式版恒 0
* field_list: Array<{
* id: number, field_key: string, show_default: boolean, ord: number,
* rules: number, rule_list: Array<{ id, desc, rule_type, regex?, code? }>,
......@@ -33,37 +34,37 @@ import { http } from './client'
* }>,
* }>}
*/
export function startQuery(taskId, { pageSize = 500, signal } = {}) {
return http.post(
'/queries/start',
{ task_id: taskId, page_size: pageSize },
{ signal },
)
export function startQuery(taskId, { signal } = {}) {
return http.post('/queries/start', { task_id: taskId }, { signal })
}
/**
* 拉一页(单页独立连接,跑规则,只返不合规行)
* 增量拉取扫描结果(前端轮询;返回的 next_after 作为下次的 after 游标)
*
* 满额返回(bad_rows.length === 5000)时应立即再拉(清缓冲);不足额等下一轮询周期。
* done=true 表示扫描线程已终止,这是最后一次有效数据。
*
* @param {string} sessionId /start 返回的 session token
* @param {number} pageNo 1-based
* @param {number} after 已消费的 bad_rows 数(游标,首次 0)
* @param {{ signal?: AbortSignal }} [opts]
* @returns {Promise<{
* ok: boolean,
* session_id: string,
* page_no: number,
* page_size: number,
* row_start: number,
* bad_rows: Array<{ __row_index: number, errorCells: string[], issues: Array, ...业务列 }>,
* scanned_delta: number,
* bad_delta: number,
* cancelled: boolean, // session 已被 /cancel 时 true
* done: boolean, // 本页是最后一页
* next_after: number, // after + len(bad_rows)
* scanned: number, // 已扫描行数(进度分子)
* total_rows: number, // COUNT 结果(进度分母)
* bad_total: number, // 命中总数(含截断丢弃部分)
* done: boolean, // 扫描线程已终止
* error?: string, // 非取消原因的扫描异常
* cancelled: boolean, // 已被 /cancel(前端据此静默退出)
* truncated: boolean, // 命中超缓冲区上限(提示「仅展示前 N 条」)
* }>}
*/
export function fetchPage(sessionId, pageNo, { signal } = {}) {
export function pullQuery(sessionId, after, { signal } = {}) {
return http.post(
'/queries/page',
{ session_id: sessionId, page_no: pageNo },
'/queries/pull',
{ session_id: sessionId, after },
{ signal },
)
}
......@@ -76,7 +77,7 @@ export function fetchPage(sessionId, pageNo, { signal } = {}) {
* @returns {Promise<{
* ok: boolean,
* cancelled: boolean,
* pages_scanned: number,
* scanned: number,
* bad_rows_so_far: number,
* }>}
*/
......
This diff is collapsed.
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