squash合并: - 意图识别层论证(8维度+10业界佐证) - 多主题上下文管理愿景+并存论证+补充论证(多轮agentic) - 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化) - 前端架构技术债清单归档
22 KiB
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 内存过滤(中)
现状:
// 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 下推,避免新增专用查询方法" — 当前成立,但需提前规划。
P2:ProjectDetail.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 视图的筛选状态),但代价是全量拉取。
P3:TaskDetail.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 数组。
P4:Dashboard.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 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 设计原则
- 渐进式:分阶段实施,每阶段独立可交付、可验证
- 低风险:优先做不改 API 契约的内部优化,外部行为不变
- 数据量驱动:当前数据量小,不做过度设计;预留扩展点但不提前实现
- 不破坏 AR-11:所有缓存/优化方案必须保留
df-data-changed事件联动
2.2 分阶段实施计划
Phase 1:SQL 下推 + 精确查询(低风险,高 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-01(TaskRecord 新增 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.tasks(App.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 后 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 缓存须记录筛选维度,筛选变化时强制刷新:
// 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 调用仍守卫:
// 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 全量:
// 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 在数据量达到百级之前不需要实施。
预留方案(不实施,仅记录):
// 方向: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-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
list_tasks(Some(pid))走 SQL WHERE 下推,不再内存过滤ProjectDetail.vue不再全量 loadTasks,改为按 projectId 精确拉取ProjectDetail.vue新增本地df-data-changed监听,entity=task 时刷新 localTasksTaskDetail.vue不再独立projectApi.list()(改用方案 B 单查或方案 A 复用 store)- B-260615-29 契约不破坏(store.tasks 仍反映 Tasks 视图筛选)
- AR-11 事件联动正常(create/update/delete 后 ProjectDetail + Tasks 列表均刷新)
- 若选方案 A-1:App.vue onMounted 成功预加载 projects,TaskDetail projectName 正确解析
- 若 F-260619-01 已先行:
list_active_by_projectSQL 含 idea_id 列(或用 SELECT *) cargo check --workspaceEXIT 0 +vue-tscEXIT 0
Phase 2
- 30s 内路由切换不重复 IPC(store 缓存命中)
df-data-changed事件正确失效缓存并刷新- Dashboard 统计数据准确(Tasks 视图筛选某项目后切 Dashboard,stats 不偏小)
- 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 可通过本地监听补偿。