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

454 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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
**现状**
```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 1SQL 下推 + 精确查询(低风险,高 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<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` 命令层路由(后端)
```rust
// 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 + 精确拉取:
```typescript
// 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` 监听:
```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<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()`
```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 后 projectIPC 开销为查 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` 只含项目 A30s 内切到 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 视图未挂载undefinedentity=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<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
10. 30s 内路由切换不重复 IPCstore 缓存命中)
11. `df-data-changed` 事件正确失效缓存并刷新
12. Dashboard 统计数据准确Tasks 视图筛选某项目后切 Dashboardstats 不偏小)
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 拼接 |
---
## 三、决策记录
### 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 可通过本地监听补偿。