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