# PERF-260619-01 查询效率优化方案 > **状态**:方案设计完成,待评审 > **日期**:2026-06-19 > **范围**:DevFlow 前后端数据查询链路 --- ## 一、探索分析 ### 1.1 问题全景 当前 DevFlow 的数据查询链路存在 **6 个核心问题**,分布在后端 SQL 层、前端 store 层、前端视图层三个层面。 ### 1.2 问题清单 | # | 层 | 问题 | 严重度 | 代码位置 | |---|---|---|---|---| | P1 | 后端 SQL | `list_tasks` 全量查询后内存 `retain` 过滤 project_id | 中 | `commands/task.rs:32-37` | | P2 | 前端视图 | `ProjectDetail.vue` 全量 `loadTasks()` 后 computed 过滤 | 中 | `ProjectDetail.vue onMounted` | | P3 | 前端视图 | `TaskDetail.vue` 独立 `projectApi.list()` 拉全量项目仅解析单个项目名 | 低 | `TaskDetail.vue load()` | | P4 | 前端视图 | `Dashboard.vue` 每次进入全量加载 projects+tasks+ideas | 低 | `Dashboard.vue loadAll()` | | P5 | 前端 store | 无跨视图缓存/去重,路由切换重复 IPC | 中 | `stores/project.ts` 全局 | | P6 | 后端 SQL | 无字段投影,列表场景返回完整 Record(含 description 大字段) | 低 | CRUD 层全局 | ### 1.3 问题详解 #### P1:`list_tasks` 内存过滤(中) **现状**: ```rust // commands/task.rs:32-37 pub async fn list_tasks(state, project_id: Option) -> Result, String> { let mut tasks = state.tasks.list_active().await.map_err(err_str)?; // SELECT * FROM tasks WHERE deleted_at IS NULL if let Some(pid) = project_id { tasks.retain(|t| t.project_id == pid); // 内存过滤 } Ok(tasks) } ``` **影响**:每次按项目筛选任务时,从 SQLite 拉取**全部未删除任务**(含 14 个字段含 description),然后在 Rust 端 `retain` 过滤。当前 6 个项目 ~15 个任务时影响极小,但随任务增长线性退化。 **注释中的判断**(task.rs:28):*"任务量小,无需 SQL 下推,避免新增专用查询方法"* — 当前成立,但需提前规划。 #### P2:`ProjectDetail.vue` 全量 loadTasks(中) **现状**: ```typescript // ProjectDetail.vue onMounted await store.loadProjects() await store.loadTasks() // 无参 → 全量拉取所有项目的任务 // 然后 computed 前端过滤 const projectTasks = computed(() => store.tasks.filter(t => t.project_id === projectId.value) ) ``` **影响**:进入项目详情页时拉取**所有项目的全部任务**,仅为展示当前项目的任务。这与 `Tasks.vue` 的按项目筛选加载模式(B-260615-29 契约)不一致。 **根因**:`ProjectDetail.vue` 使用全局 `store.tasks` 单例 + computed 过滤,而非按 project_id 精确拉取。设计意图是"不覆盖全局 tasks 单例"(避免破坏 Tasks 视图的筛选状态),但代价是全量拉取。 #### P3:`TaskDetail.vue` 独立拉全量项目列表(低) **现状**: ```typescript // TaskDetail.vue load() const [t, ps] = await Promise.all([ taskApi.get(taskId.value), // 拉单个任务 ✓ projectApi.list(), // 拉全部项目 ✗ 仅为解析 project_id → name ]) ``` **影响**:TaskDetail 绕过 store 直接调 `projectApi.list()`,拉取全部项目(含 description 大字段),仅为从 `projects.find(p => p.id === task.project_id)` 解析一个项目名。 **根因**:TaskDetail 设计为"绕 store 独立入口"(有本地 `df-data-changed` 监听补偿),不依赖全局 store 的 projects 数组。 #### P4:`Dashboard.vue` 全量三实体加载(低) **现状**: ```typescript // Dashboard.vue loadAll() await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()]) ``` **影响**:每次进入 Dashboard 全量拉取三个实体。Dashboard 只需要: - 项目数 + 活跃任务数(统计) - 项目列表(名称 + 状态 + updated_at) - 灵感列表前 4 条(标题 + score + status) 但拉取了完整 Record(含 description / ai_analysis / scores 等大字段)。 **缓解因素**:store 是全局单例,如果用户先访问过其他页面(Tasks/Projects),数据已在 store 中。但 Dashboard 的 `loadAll` 无条件覆盖刷新。 #### P5:无跨视图缓存/去重(中) **现状**: - `Tasks.vue onMounted` → `loadProjects() + loadTasks()` - `ProjectDetail.vue onMounted` → `loadProjects() + loadTasks()` - `Dashboard.vue onMounted` → `loadProjects() + loadTasks() + loadIdeas()` - `TaskDetail.vue load()` → `projectApi.list()`(绕 store) **影响**:路由切换时(如 Tasks → ProjectDetail → Tasks),每次 onMounted 都重新发起 IPC 全量拉取。store 虽然是单例(数据在内存),但 `loadXxx` 方法无条件覆盖刷新,没有"数据未过期则跳过"的判断。 **根因**:store 的 load 方法无 dirty/timestamp 标记,无法判断"已有数据是否新鲜"。 #### P6:无字段投影(低) **现状**:所有查询返回完整 Record。 - `ProjectRecord`:9 字段(含 description 可能很长) - `TaskRecord`:14 字段(含 description + output_json 可能很长) - `IdeaRecord`:13 字段(含 ai_analysis + scores + description) 列表场景(Tasks/Dashboard/Projects)只需要 id/name/status/priority 等少量字段,详情页才需要全字段。 **影响**:IPC 传输 + JSON 序列化开销。当前数据量小,影响不明显。 ### 1.4 关键约束 | # | 约束 | 来源 | 影响 | |---|---|---|---| | C1 | `store.tasks` 必须反映 Tasks 视图当前筛选(activeProject),不能被无参 `loadTasks()` 覆盖为全量 | B-260615-29 契约 | P2 修复不能简单改为 `loadTasks(projectId)`,因为会覆盖全局 tasks 单例 | | C2 | 后端 AI 工具执行后 emit `df-data-changed`,前端按 entity 刷新对应列表 | AR-11 事件机制 | 优化时需保留联动;缓存策略需响应此事件失效 | | C3 | TaskDetail 绕 store 直接调 API,有本地 `df-data-changed` 监听补偿 | 设计决策 | P3 修复需考虑是否回归 store 或保持独立 | | C4 | 任务 status 只能走 `advance_task_atomic` 状态机 | 状态机收口 | 不影响查询优化,但 update_task 白名单已移除 status | | C5 | SQLite 本地存储,当前数据量小(~6 项目 ~15 任务) | 实际数据 | 短期性能影响极小,优化面向中长期数据增长 | | C6 | CRUD 层(`impl_repo!` 宏)无 SELECT 列裁剪、无 LIMIT/OFFSET 支持 | 架构现状 | P6 需扩展宏或新增专用查询方法 | --- ## 二、方案设计 ### 2.1 设计原则 1. **渐进式**:分阶段实施,每阶段独立可交付、可验证 2. **低风险**:优先做不改 API 契约的内部优化,外部行为不变 3. **数据量驱动**:当前数据量小,不做过度设计;预留扩展点但不提前实现 4. **不破坏 AR-11**:所有缓存/优化方案必须保留 `df-data-changed` 事件联动 ### 2.2 分阶段实施计划 --- ### Phase 1:SQL 下推 + 精确查询(低风险,高 ROI) **目标**:消除后端全量查询 + 内存过滤,前端视图精确拉取所需数据。 **改动点**: #### 1.1 TaskRepo 新增 `list_active_by_project`(后端) ```rust // crates/df-storage/src/crud/task_repo.rs impl TaskRepo { /// 列出指定项目的未删除任务 pub async fn list_active_by_project(&self, project_id: &str) -> Result> { let conn = self.conn.clone(); let project_id = project_id.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let mut stmt = guard .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC") .map_err(storage_err)?; let rows = stmt.query_map(params![project_id], |row| task_from_row(row)).map_err(storage_err)?; // ...collect }).await.map_err(storage_err)? } } ``` ⚠️ **依赖注意(F-260619-01 idea_id)**:上方 SQL 硬编码 14 列,与 `list_active()` 完全重复。若 F-260619-01(TaskRecord 新增 `idea_id` 字段)先于本方案落地,`task_from_row` 会调 `row.get("idea_id")`,但 SQL 列表无此列 → rusqlite 报错。两案并行时须同步加列,或改用 `SELECT * FROM tasks WHERE ...`(rusqlite 允许结果集含 from_row 未消费的多余列)。 #### 1.2 `list_tasks` 命令层路由(后端) ```rust // commands/task.rs pub async fn list_tasks(state, project_id: Option) -> Result, String> { match project_id { Some(pid) => state.tasks.list_active_by_project(&pid).await.map_err(err_str), None => state.tasks.list_active().await.map_err(err_str), } } ``` **外部行为不变**:API 签名 + 返回类型一致,前端零改动。 #### 1.3 `ProjectDetail.vue` 按项目精确拉取(前端) **问题**:不能直接 `store.loadTasks(projectId)`,因为会覆盖全局 tasks 单例(违反 C1)。 **方案**:ProjectDetail 不用全局 `store.tasks`,改为本地 ref + 精确拉取: ```typescript // ProjectDetail.vue const localTasks = ref([]) onMounted(async () => { await store.loadProjects() localTasks.value = await taskApi.list(projectId.value) // 精确拉取 // ... }) ``` **收益**:从全量拉取 → 仅拉取当前项目任务。 ⚠️ **必须配套(评审升级)**:ProjectDetail 当前**没有** `df-data-changed` 监听——它靠全局 `store.tasks`(App.vue 的 `startDataChangedListener` 刷新 store → computed 自动响应)间接获得 AR-11 联动。改为本地 ref 后这条链路**断裂**。必须在 onMounted 中新增本地 `df-data-changed` 监听: ```typescript // ProjectDetail.vue onMounted 新增(必须,非可选) let _unlistenDataChanged: (() => void) | null = null onMounted(async () => { await store.loadProjects() localTasks.value = await taskApi.list(projectId.value) // AR-11 本地监听:entity=task 时按当前 projectId 精确刷新 _unlistenDataChanged = await listen('df-data-changed', (event) => { if (event.payload.entity === 'task') { taskApi.list(projectId.value).then(ts => { localTasks.value = ts }) } }) }) onUnmounted(() => { _unlistenDataChanged?.() // ...existing unlisten cleanup }) ``` > **注意**:ProjectDetail 现有的 `store.startEventListener()` 是**工作流节点事件**监听(`workflowStore.startEventListener`),不是数据变更监听,两者不可混淆。 #### 1.4 `TaskDetail.vue` 改用 store 或 get_project(前端) **方案 A**:改用 `store.projects` 复用全局缓存,不再独立 `projectApi.list()`: ```typescript // TaskDetail.vue const store = useProjectStore() const projectName = computed(() => store.projects.find(p => p.id === task.value?.project_id)?.name ?? task.value?.project_id ?? '—' ) // load() 只拉单个任务,不再拉全量项目 async function load() { task.value = await taskApi.get(taskId.value) } ``` ⚠️ **前置条件(评审纠错)**:原方案声称"App.vue 根 onMounted 已有 projects 预加载",经核验**不成立**——App.vue onMounted 只做 appSettings/migrate/theme/locale/Agentic 轮次恢复/AR-11 listener 启动,**未调用 `store.loadProjects()`**。当前 projects 加载分散在各视图 onMounted 中(Tasks/ProjectDetail/Dashboard 各自调)。 采用方案 A 必须二选一补齐前置: - **A-1(推荐)**:App.vue onMounted 加 `await store.loadProjects()`(一行改动,全局受益,所有视图共享缓存)。 - **A-2(保守)**:TaskDetail load() 内加 `if (store.projects.length === 0) await store.loadProjects()` lazy 补偿,不依赖全局预加载。 **方案 B**:两步查询单个项目,替代全量 `projectApi.list()`: ```typescript async function load() { task.value = await taskApi.get(taskId.value) // 拿到 task 后按 project_id 查单个项目 if (task.value?.project_id) { const p = await projectApi.get(task.value.project_id) projectName.value = p?.name ?? task.value.project_id } } ``` 方案 B 无先后依赖问题(串行两步,先 task 后 project),IPC 开销为查 1 条 vs 查全量。**不依赖全局 store 状态**,保持 TaskDetail"绕 store 独立入口"的设计一致性。 **推荐**: - 若同时实施 Phase 2 缓存 → **方案 A-1**(App.vue 预加载 + store 缓存,全链路最优) - 若只实施 Phase 1 → **方案 B**(零全局依赖,改动最小,不引入 store 耦合) --- ### Phase 2:前端缓存 + 去重(中等风险,中 ROI) **目标**:避免路由切换时的重复 IPC,引入脏标记/时间戳缓存。 **改动点**: #### 2.1 store 加载方法增加新鲜度判断 ⚠️ **评审补充 — tasks 缓存筛选维度冲突**:`loadTasks(projectId?)` 带筛选参数。若 Tasks 视图选了项目 A(`store.tasks` 只含项目 A),30s 内切到 Dashboard 调 `loadTasks()` 无参全量——缓存命中跳过,但 `store.tasks` 仍是项目 A 子集,Dashboard 的 `stats.activeTasks` 统计错误。 **解决方案**:tasks 缓存须记录筛选维度,筛选变化时强制刷新: ```typescript // stores/project/state.ts 新增 export const state = reactive({ // ...existing... _projectsLoadedAt: 0, _tasksLoadedAt: 0, _ideasLoadedAt: 0, _tasksFilter: undefined as string | undefined, // 当前 tasks 缓存对应的筛选(undefined=全量) }) // stores/project/projects.ts const STALE_MS = 30_000 // 30s 内不重复拉取 async function loadProjects(force = false) { if (!force && Date.now() - state._projectsLoadedAt < STALE_MS && state.projects.length > 0) { return // 数据新鲜,跳过 } state.loading = true state.projects = await projectApi.list() state._projectsLoadedAt = Date.now() } // stores/project/tasks.ts — 筛选维度 + TTL 双判断 async function loadTasks(projectId?: string, force = false) { const filter = projectId // undefined=全量, string=按项目 // 筛选变化 → 强制刷新(防 Dashboard 拿到 Tasks 视图的子集) const filterChanged = state._tasksFilter !== filter if (!force && !filterChanged && Date.now() - state._tasksLoadedAt < STALE_MS) { return // 数据新鲜 + 筛选一致,跳过 } state.loading = true state.tasks = await taskApi.list(filter) state._tasksLoadedAt = Date.now() state._tasksFilter = filter } ``` #### 2.2 AR-11 事件联动失效缓存 ⚠️ **评审补充 — AR-11 条件刷新与缓存交互**:当前 AR-11 listener 的 task 分支有 `_activeTaskProject !== undefined` 守卫(CR-260615-26)。若 Tasks 视图未挂载(undefined),entity=task 时不触发 load,缓存也不失效。用户之后进 Tasks 时 onMounted 调 `loadTasks`,若缓存未过期则命中跳过——拿到过期数据。 **解决方案**:AR-11 失效缓存与条件刷新分离——缓存时间戳无条件清零(下次 load 必拉新),load 调用仍守卫: ```typescript // stores/project.ts startDataChangedListener if (entity === 'project') { state._projectsLoadedAt = 0 // 无条件失效缓存 void projectsStore.loadProjects(true) // force 刷新 } else if (entity === 'task') { state._tasksLoadedAt = 0 // 无条件失效缓存(下次任意视图 loadTasks 必拉新) if (_activeTaskProject !== undefined) { void tasksStore.loadTasks(_activeTaskProject === 'all' ? undefined : _activeTaskProject) } } ``` #### 2.3 Dashboard loadAll 利用缓存 ⚠️ **评审补充 — Dashboard stats 对 tasks 全量的依赖**:Dashboard 的 `stats.activeTasks`(`project.ts:105`)从 `state.tasks` 算 `filter(t => t.status === 'in_progress').length`。若 `state.tasks` 是某项目子集(Tasks 视图留下的),统计偏小。Dashboard loadAll 须确保 tasks 全量: ```typescript // Dashboard.vue async function loadAll() { await Promise.all([ store.loadProjects(), // 命中缓存则跳过 store.loadTasks(), // 无参=全量;筛选维度变化时强制刷新(2.1 逻辑保证) store.loadIdeas(), ]) } ``` > Dashboard 调 `loadTasks()` 无参(filter=undefined),若 Tasks 视图留下的 filter 是某项目 id,`filterChanged=true` → 强制全量刷新,stats 正确。 **风险**: - `df-data-changed` 必须可靠失效缓存(当前 AR-11 已覆盖 create/update/delete/restore/purge/bind_directory) - 用户在外部修改 SQLite(如直接操作数据库)后缓存不新鲜 — 桌面应用场景概率极低 --- ### Phase 3:字段投影(低优先级,远期) **目标**:列表场景只返回需要的字段,减少 IPC 传输 + 序列化开销。 **现状评估**:当前数据量极小(6 项目 15 任务),description 字段平均 ~500 字节,全量传输 < 10KB。**Phase 3 在数据量达到百级之前不需要实施。** **预留方案**(不实施,仅记录): ```rust // 方向:impl_repo! 宏扩展 columns 参数,或新增 list_summary 方法 pub async fn list_active_summary(&self) -> Result> { // SELECT id, name, status, created_at, updated_at FROM projects WHERE ... } ``` 前端新增 `ProjectSummary` / `TaskSummary` 类型,列表视图用 Summary,详情页用完整 Record。 **成本**:需新增 DTO 类型 + 前端类型同步 + 视图适配,复杂度较高。 --- ### 2.3 优先级矩阵 | Phase | 问题覆盖 | 风险 | ROI | 建议时机 | |-------|---------|------|-----|---------| | Phase 1 | P1 + P2 + P3 | 低(不改 API 契约) | 高 | **立即实施** | | Phase 2 | P4 + P5 | 中(缓存一致性) | 中 | Phase 1 验证后 | | Phase 3 | P6 | 低(纯新增) | 低(当前数据量) | 数据量达百级时 | ### 2.4 涉及文件 | Phase | 文件 | 改动类型 | |-------|------|---------| | 1.1 | `crates/df-storage/src/crud/task_repo.rs` | 新增 `list_active_by_project` 方法 | | 1.2 | `src-tauri/src/commands/task.rs` | `list_tasks` 路由分支 | | 1.3 | `src/views/ProjectDetail.vue` | 改用本地 ref + 精确拉取 + **新增 df-data-changed 本地监听** | | 1.4 | `src/views/TaskDetail.vue` | 方案 B:改用 `projectApi.get(id)` 单查;或方案 A-1/A-2 复用 store.projects | | 1.4-A1 | `src/App.vue` | (方案 A-1)onMounted 新增 `store.loadProjects()` 预加载 | | 2.1 | `src/stores/project/state.ts` | 新增时间戳 + `_tasksFilter` 字段 | | 2.2 | `src/stores/project/projects.ts` | loadProjects 加新鲜度判断 | | 2.3 | `src/stores/project/tasks.ts` | loadTasks 加筛选维度 + TTL 双判断 | | 2.4 | `src/stores/project.ts` | AR-11 listener 无条件失效缓存 + 条件刷新分离 | | 2.5 | `src/views/Dashboard.vue` | loadAll 利用缓存(筛选维度保证全量 tasks) | ### 2.5 验收标准 #### Phase 1 1. `list_tasks(Some(pid))` 走 SQL WHERE 下推,不再内存过滤 2. `ProjectDetail.vue` 不再全量 loadTasks,改为按 projectId 精确拉取 3. `ProjectDetail.vue` 新增本地 `df-data-changed` 监听,entity=task 时刷新 localTasks 4. `TaskDetail.vue` 不再独立 `projectApi.list()`(改用方案 B 单查或方案 A 复用 store) 5. B-260615-29 契约不破坏(store.tasks 仍反映 Tasks 视图筛选) 6. AR-11 事件联动正常(create/update/delete 后 ProjectDetail + Tasks 列表均刷新) 7. 若选方案 A-1:App.vue onMounted 成功预加载 projects,TaskDetail projectName 正确解析 8. 若 F-260619-01 已先行:`list_active_by_project` SQL 含 idea_id 列(或用 SELECT *) 9. `cargo check --workspace` EXIT 0 + `vue-tsc` EXIT 0 #### Phase 2 10. 30s 内路由切换不重复 IPC(store 缓存命中) 11. `df-data-changed` 事件正确失效缓存并刷新 12. Dashboard 统计数据准确(Tasks 视图筛选某项目后切 Dashboard,stats 不偏小) 13. Tasks 视图未挂载时 AR-11 entity=task 事件仍清零缓存(下次进 Tasks 拿新数据) ### 2.6 风险评估 | 风险 | 概率 | 影响 | 缓解措施 | |------|------|------|---------| | ProjectDetail 改本地 ref 后 AR-11 不刷新任务 | **高** | 中 | **必须配套**:onMounted 新增本地 `df-data-changed` 监听,entity=task 时重新 `taskApi.list(projectId)`(详见 1.3) | | TaskDetail 改用 store.projects 时 projects 未加载 | 中 | 低 | App.vue onMounted **新增** `store.loadProjects()`(A-1),或 TaskDetail 内 lazy 补偿(A-2),或改用方案 B 完全规避 | | Phase 2 缓存筛选维度冲突致 Dashboard stats 错误 | 中 | 中 | tasks 缓存记录 `_tasksFilter`,筛选变化时 `filterChanged=true` 强制刷新(详见 2.1) | | Phase 2 AR-11 守卫 + 缓存致 Tasks 拿过期数据 | 中 | 低 | AR-11 无条件清零 `_tasksLoadedAt`(下次 load 必拉新),load 调用仍守卫(详见 2.2) | | Phase 2 缓存导致用户看到过期数据 | 低 | 低 | 30s TTL 短 + AR-11 强制失效 + 手动刷新按钮兜底 | | list_active_by_project SQL 列与 idea_id 不同步 | 中 | 中 | 两案并行时同步加列,或改用 `SELECT *`(详见 1.1) | | list_active_by_project SQL 注入 | 极低 | 高 | 使用参数化查询 `params![project_id]`,不走 `query` 宏的 field 拼接 | --- ## 三、决策记录 ### D1:Phase 1 先行,Phase 2 视需 **理由**:Phase 1 是纯内部优化(SQL 下推 + 精确拉取),不改 API 契约,风险极低,ROI 明确。Phase 2 引入缓存层有一致性风险,待 Phase 1 验证后再评估必要性。 ### D2:不实施 Phase 3(字段投影) **理由**:当前数据量极小(< 10KB 全量传输),字段投影需新增 DTO 类型 + 前端类型同步 + 视图适配,成本远高于收益。当任务数达到 ~200+ 或 description 平均长度 > 5KB 时再评估。 ### D3:ProjectDetail 用本地 ref 而非改 store 契约 **理由**:store.tasks 全局单例 + B-260615-29 筛选契约是硬约束(C1)。改 store 支持"多任务列表"会引入复杂度(哪个视图的数据?切换时怎么办?)。ProjectDetail 用本地 ref + 精确拉取更简单清晰,且 AR-11 可通过本地监听补偿。