Files
DevFlow/docs/02-架构设计/专项设计/查询效率优化方案-2026-06-19.md
绝尘 998a2f243d 文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并:
- 意图识别层论证(8维度+10业界佐证)
- 多主题上下文管理愿景+并存论证+补充论证(多轮agentic)
- 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化)
- 前端架构技术债清单归档
2026-06-19 15:04:04 +08:00

22 KiB
Raw Blame History

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 问题详解

P1list_tasks 内存过滤(中)

现状

// commands/task.rs:32-37
pub async fn list_tasks(state, project_id: Option<String>) -> Result<Vec<TaskRecord>, 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 下推,避免新增专用查询方法" — 当前成立,但需提前规划。

P2ProjectDetail.vue 全量 loadTasks

现状

// 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 视图的筛选状态),但代价是全量拉取。

P3TaskDetail.vue 独立拉全量项目列表(低)

现状

// 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 数组。

P4Dashboard.vue 全量三实体加载(低)

现状

// 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 onMountedloadProjects() + loadTasks()
  • ProjectDetail.vue onMountedloadProjects() + loadTasks()
  • Dashboard.vue onMountedloadProjects() + loadTasks() + loadIdeas()
  • TaskDetail.vue load()projectApi.list()(绕 store

影响:路由切换时(如 Tasks → ProjectDetail → Tasks每次 onMounted 都重新发起 IPC 全量拉取。store 虽然是单例(数据在内存),但 loadXxx 方法无条件覆盖刷新,没有"数据未过期则跳过"的判断。

根因store 的 load 方法无 dirty/timestamp 标记,无法判断"已有数据是否新鲜"。

P6无字段投影

现状:所有查询返回完整 Record。

  • ProjectRecord9 字段(含 description 可能很长)
  • TaskRecord14 字段(含 description + output_json 可能很长)
  • IdeaRecord13 字段(含 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 1SQL 下推 + 精确查询(低风险,高 ROI

目标:消除后端全量查询 + 内存过滤,前端视图精确拉取所需数据。

改动点

1.1 TaskRepo 新增 list_active_by_project(后端)

// crates/df-storage/src/crud/task_repo.rs
impl TaskRepo {
    /// 列出指定项目的未删除任务
    pub async fn list_active_by_project(&self, project_id: &str) -> Result<Vec<TaskRecord>> {
        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-01TaskRecord 新增 idea_id 字段)先于本方案落地,task_from_row 会调 row.get("idea_id"),但 SQL 列表无此列 → rusqlite 报错。两案并行时须同步加列,或改用 SELECT * FROM tasks WHERE ...rusqlite 允许结果集含 from_row 未消费的多余列)。

1.2 list_tasks 命令层路由(后端)

// commands/task.rs
pub async fn list_tasks(state, project_id: Option<String>) -> Result<Vec<TaskRecord>, 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 + 精确拉取:

// ProjectDetail.vue
const localTasks = ref<TaskRecord[]>([])

onMounted(async () => {
  await store.loadProjects()
  localTasks.value = await taskApi.list(projectId.value)  // 精确拉取
  // ...
})

收益:从全量拉取 → 仅拉取当前项目任务。

⚠️ 必须配套(评审升级)ProjectDetail 当前没有 df-data-changed 监听——它靠全局 store.tasksApp.vue 的 startDataChangedListener 刷新 store → computed 自动响应)间接获得 AR-11 联动。改为本地 ref 后这条链路断裂。必须在 onMounted 中新增本地 df-data-changed 监听:

// 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<DfDataChangedPayload>('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()

// 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()

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 后 projectIPC 开销为查 1 条 vs 查全量。不依赖全局 store 状态,保持 TaskDetail"绕 store 独立入口"的设计一致性。

推荐

  • 若同时实施 Phase 2 缓存 → 方案 A-1App.vue 预加载 + store 缓存,全链路最优)
  • 若只实施 Phase 1 → 方案 B(零全局依赖,改动最小,不引入 store 耦合)

Phase 2前端缓存 + 去重(中等风险,中 ROI

目标:避免路由切换时的重复 IPC引入脏标记/时间戳缓存。

改动点

2.1 store 加载方法增加新鲜度判断

⚠️ 评审补充 — tasks 缓存筛选维度冲突loadTasks(projectId?) 带筛选参数。若 Tasks 视图选了项目 Astore.tasks 只含项目 A30s 内切到 Dashboard 调 loadTasks() 无参全量——缓存命中跳过,但 store.tasks 仍是项目 A 子集Dashboard 的 stats.activeTasks 统计错误。

解决方案tasks 缓存须记录筛选维度,筛选变化时强制刷新:

// 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 视图未挂载undefinedentity=task 时不触发 load缓存也不失效。用户之后进 Tasks 时 onMounted 调 loadTasks,若缓存未过期则命中跳过——拿到过期数据。

解决方案AR-11 失效缓存与条件刷新分离——缓存时间戳无条件清零(下次 load 必拉新load 调用仍守卫:

// 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.activeTasksproject.ts:105)从 state.tasksfilter(t => t.status === 'in_progress').length。若 state.tasks 是某项目子集Tasks 视图留下的统计偏小。Dashboard loadAll 须确保 tasks 全量:

// Dashboard.vue
async function loadAll() {
  await Promise.all([
    store.loadProjects(),  // 命中缓存则跳过
    store.loadTasks(),     // 无参=全量;筛选维度变化时强制刷新(2.1 逻辑保证)
    store.loadIdeas(),
  ])
}

Dashboard 调 loadTasks() 无参filter=undefined若 Tasks 视图留下的 filter 是某项目 idfilterChanged=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 在数据量达到百级之前不需要实施。

预留方案(不实施,仅记录):

// 方向impl_repo! 宏扩展 columns 参数,或新增 list_summary 方法
pub async fn list_active_summary(&self) -> Result<Vec<ProjectSummary>> {
    // 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-1onMounted 新增 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-1App.vue onMounted 成功预加载 projectsTaskDetail 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

  1. 30s 内路由切换不重复 IPCstore 缓存命中)
  2. df-data-changed 事件正确失效缓存并刷新
  3. Dashboard 统计数据准确Tasks 视图筛选某项目后切 Dashboardstats 不偏小)
  4. 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 拼接

三、决策记录

D1Phase 1 先行Phase 2 视需

理由Phase 1 是纯内部优化SQL 下推 + 精确拉取),不改 API 契约风险极低ROI 明确。Phase 2 引入缓存层有一致性风险,待 Phase 1 验证后再评估必要性。

D2不实施 Phase 3字段投影

理由:当前数据量极小(< 10KB 全量传输),字段投影需新增 DTO 类型 + 前端类型同步 + 视图适配,成本远高于收益。当任务数达到 ~200+ 或 description 平均长度 > 5KB 时再评估。

D3ProjectDetail 用本地 ref 而非改 store 契约

理由store.tasks 全局单例 + B-260615-29 筛选契约是硬约束C1。改 store 支持"多任务列表"会引入复杂度哪个视图的数据切换时怎么办。ProjectDetail 用本地 ref + 精确拉取更简单清晰,且 AR-11 可通过本地监听补偿。