# 性能优化设计规格 > 日期: 2026-04-26 | 状态: draft | 主题: 后端数据库查询 + 前端 N+1 与渲染优化 ## 1. 背景 HMS 平台已完成核心业务功能开发(34 健康实体、22 前端页面),进入性能调优阶段。通过代码审查和运行时分析,发现以下瓶颈: - **后端**: 多处逐条 INSERT、串行 COUNT 查询、动态 SQL 拼接、串行 DB 调用 - **前端**: N+1 请求模式、nameCache 循环依赖导致重渲染、未拆分 vendor chunk ## 2. 问题分析 ### 2.1 后端瓶颈(5 项) | 编号 | 问题 | 文件位置 | 优先级 | 预估收益 | |------|------|----------|--------|----------| | B-1 | device_reading_service 逐条 INSERT(最多 500 次 DB 往返) | `crates/erp-health/src/service/device_reading_service.rs` `batch_insert_readings()` | P0 | 500 次往返 → 1 次,延迟降低 95%+ | | B-2 | stats_service 多次独立 COUNT(如 `get_follow_up_statistics` 4 次查询) | `crates/erp-health/src/service/stats_service.rs` `get_follow_up_statistics()` 等 | P1 | 4-7 次查询 → 1 次 GROUP BY,延迟降低 60-75% | | B-3 | alert_engine 逐规则独立查询 DB(cooldown + 条件评估) | `crates/erp-health/src/service/alert_engine.rs` `evaluate_rules()` | P1 | N 规则 × 2 查询 → 1+1 批量查询,延迟线性降低 | | B-4 | patient_service `get_health_summary` 4 次串行查询 | `crates/erp-health/src/service/patient_service.rs` `get_health_summary()` | P1 | 4 次串行 → 4 次并行,延迟降低 ~75% | | B-5 | `compute_avg_field` 动态 format! SQL 无法利用 prepared statement 缓存 | `crates/erp-health/src/service/stats_service.rs` `compute_avg_field()` | P2 | 利用 PG prepared statement 缓存,高频调用场景 CPU 降低 | ### 2.2 前端瓶颈(5 项) | 编号 | 问题 | 文件位置 | 优先级 | 预估收益 | |------|------|----------|--------|----------| | F-1 | N+1 请求: AppointmentList/ConsultationList/PointsOrderList 逐条请求 patient/doctor name | `apps/web/src/pages/health/AppointmentList.tsx` 等多个文件 | P0 | 页面加载从 O(N) 请求 → O(1),首屏时间降低 70-80% | | F-2 | nameCache useState 导致 fetchData 循环重建 | `AppointmentList.tsx` `PointsOrderList.tsx` 的 useEffect 依赖 | P0 | 消除无限循环风险,请求量减少 50%+ | | F-3 | PluginCRUDPage columns 未 memo 化 | `apps/web/src/pages/PluginCRUDPage.tsx` | P2 | 减少不必要 Table 重渲染 | | F-4 | PluginGraphPage requestAnimationFrame 持续重绘 | `apps/web/src/pages/PluginGraphPage.tsx` | P2 | CPU 占用降低,仅在数据变更时重绘 | | F-5 | @ant-design/charts / @xyflow/react / @wangeditor/editor 未拆独立 chunk | `apps/web/vite.config.ts` | P2 | 首屏 JS 体积降低 200-400KB (gzip) | ## 3. 解决方案 ### 3.1 B-1: device_reading_service 批量 INSERT **当前实现** (`batch_insert_readings`, 第 194-229 行): - for 循环逐条 `model.insert(db).await`,每条一次 DB 往返 - 500 条记录 = 500 次 INSERT **优化方案**: - 使用 SeaORM `Entity::insert_many()` 一次性插入 - 唯一约束冲突通过 `ON CONFLICT DO NOTHING` 处理(需要 raw SQL 或 sea_query 的 `on_conflict`) ```rust // 方向:构建 ActiveModel Vec,调用 insert_many let models: Vec = parsed_readings .iter() .map(|(r, measured_at)| device_readings::ActiveModel { ... }) .collect(); // insert_many + on_conflict_do_nothing let result = device_readings::Entity::insert_many(models) .on_conflict( sea_query::OnConflict::columns([ device_readings::Column::PatientId, device_readings::Column::DeviceType, device_readings::Column::MeasuredAt, ]) .do_nothing() .to_owned() ) .exec(&state.db) .await?; ``` **影响范围**: 仅修改 `device_reading_service.rs` 的 `batch_insert_readings()` 函数。 ### 3.2 B-2: stats_service 合并 COUNT 查询 **当前实现** (`get_follow_up_statistics`, 第 93-139 行): - `total_tasks`: 1 次 COUNT - `completed`: 1 次 COUNT(status='completed') - `pending`: 1 次 COUNT(status='pending') - `overdue`: 1 次 COUNT(status='overdue') - 共 4 次独立 DB 查询 **优化方案**: 合并为单次 `GROUP BY status` + 应用层聚合 ```sql SELECT status, COUNT(*) AS cnt FROM follow_up_task WHERE tenant_id = $1 AND deleted_at IS NULL GROUP BY status ``` 应用层从 HashMap 中提取各字段值。 同理适用于: - `get_patient_statistics` (4 次 → 2 次: patient 表 1 次 GROUP BY + points_transaction 1 次) - `get_consultation_statistics` (3 次 → 1 次 GROUP BY + 1 次平均响应时间) - `get_dialysis_statistics` (3 次 → 1 次 GROUP BY) - `get_lab_report_statistics` (4 次 → 1 次 GROUP BY) **影响范围**: `stats_service.rs` 中 6 个统计函数。 ### 3.3 B-3: alert_engine 预加载 + 批量评估 **当前实现** (`evaluate_rules`, 第 12-58 行): - 每条规则独立查询: `is_in_cooldown()` 1 次 + 条件评估 1-2 次 - N 条规则 = 2N+ 次 DB 查询 **优化方案**: 1. 一次性查询所有 active rules(已有) 2. 批量查询该患者最近 cooldown 期间所有 alerts,构建 HashSet 3. 条件评估(single_threshold)只需查最新一条 hourly 记录,可按 device_type 批量查出后在内存匹配 ```rust // 批量 cooldown 检查 let recent_alerts = alerts::Entity::find() .filter(alerts::Column::TenantId.eq(tenant_id)) .filter(alerts::Column::PatientId.eq(patient_id)) .filter(alerts::Column::CreatedAt.gt(cooldown_start)) .filter(alerts::Column::DeletedAt.is_null()) .all(&state.db) .await?; let cooldown_set: HashSet = recent_alerts.iter().map(|a| a.rule_id).collect(); ``` **影响范围**: `alert_engine.rs` 的 `evaluate_rules()` 和辅助函数。 ### 3.4 B-4: get_health_summary 并行化 **当前实现** (`get_health_summary`, 第 423-475 行): - 4 次 `.await` 串行执行 - 总延迟 = sum(4 次查询延迟) **优化方案**: 使用 `tokio::join!` 并行执行 ```rust let (latest_vitals, latest_lab, upcoming, pending_follow_ups) = tokio::join!( // 最新体征 vital_signs::Entity::find() .filter(...) .one(&state.db), // 最新化验 lab_report::Entity::find() .filter(...) .one(&state.db), // 待处理预约 appointment::Entity::find() .filter(...) .count(&state.db), // 待办随访 follow_up_task::Entity::find() .filter(...) .count(&state.db), ); ``` **影响范围**: 仅修改 `patient_service.rs` 的 `get_health_summary()`。 ### 3.5 B-5: compute_avg_field 参数化 **当前实现** (`compute_avg_field`, 第 423-464 行): - `format!("SELECT AVG({field})...")` 动态拼接 SQL - 每个不同 field 生成不同 SQL 文本,PostgreSQL 无法缓存 prepared statement **优化方案**: 对每个允许的 field 生成独立的静态 SQL 常量 ```rust macro_rules! avg_field_sql { ($field:literal) => { concat!( "SELECT AVG(", $field, ")::FLOAT8 AS avg_val FROM dialysis_record ", "WHERE tenant_id = $1 AND deleted_at IS NULL AND ", $field, " IS NOT NULL ", "AND created_at >= date_trunc('month', NOW())" ) }; } match field { "ultrafiltration_volume" => avg_field_sql!("ultrafiltration_volume"), "dialysis_duration" => avg_field_sql!("dialysis_duration"), // ... 其余字段 _ => return Err(...), } ``` **影响范围**: 仅修改 `stats_service.rs` 的 `compute_avg_field()`。 ### 3.6 F-1: 后端列表 API 内联 name 字段 **当前问题**: 前端拿到列表后,循环调用 `patientApi.get(id)` / `doctorApi.get(id)` 获取名字。 **优化方案**: 后端列表查询时 JOIN 或子查询返回 `patient_name` / `doctor_name` 字段。 **需修改的 handler/service**: | 模块 | 列表 API | 需内联字段 | |------|----------|-----------| | appointment | list_appointments | patient_name, doctor_name | | consultation_session | list_sessions | patient_name, doctor_name | | follow_up_task | list_tasks | patient_name | | points_order | list_orders | patient_name | | lab_report | list_reports | patient_name | **实现方式**: 使用 SeaORM 的 `select_only()` + `expr_as()` 添加 JOIN 字段,或直接在 DTO 层补充查询。 ```rust // 方向:在查询列表时 LEFT JOIN users 表获取 display_name let rows: Vec<(appointment::Model, Option)> = appointment::Entity::find() .filter(...) .find_also_related(user::Entity) // 如果有关联定义 .all(&state.db) .await?; ``` **影响范围**: 5 个 handler + 对应 service 函数 + DTO 结构体。 ### 3.7 F-2: 移除 nameCache 依赖 **当前问题**: `AppointmentList.tsx` 和 `PointsOrderList.tsx` 的 `useEffect` 依赖 `nameCache`,更新 nameCache 触发 fetchData 重建,形成循环。 **优化方案**: 1. 后端内联 name 后(F-1),nameCache 机制可以完全移除 2. 作为过渡方案,将 nameCache 查询拆到独立 useEffect,不作为列表数据获取的依赖 **影响范围**: `AppointmentList.tsx`, `PointsOrderList.tsx`, `ConsultationList.tsx`。 ### 3.8 F-5: vendor chunk 拆分 **当前问题**: @ant-design/charts (~200KB)、@xyflow/react (~150KB)、@wangeditor/editor (~300KB) 打入主 bundle。 **优化方案**: Vite `manualChunks` 配置 ```typescript // vite.config.ts build.rollupOptions.output.manualChunks manualChunks: { 'vendor-charts': ['@ant-design/charts'], 'vendor-flow': ['@xyflow/react'], 'vendor-editor': ['@wangeditor/editor'], } ``` **影响范围**: `apps/web/vite.config.ts`,配合 React.lazy 路由级加载。 ### 3.9 F-3/F-4: 渲染优化 **F-3 PluginCRUDPage columns useMemo**: ```typescript const columns = useMemo(() => [...], [schema]); ``` **F-4 PluginGraphPage 按需重绘**: - 替换持续 `requestAnimationFrame` 为数据变更时触发的单次重绘 - 使用 `ResizeObserver` 监听容器大小变化 ## 4. 实施步骤 ### Phase 1: P0 紧急优化(预估 2-3 天) | 步骤 | 任务 | 修改文件 | |------|------|----------| | 1.1 | B-1: `batch_insert_readings` 改用 `insert_many()` | `device_reading_service.rs` | | 1.2 | F-1: 后端列表 API 内联 patient_name/doctor_name | 5 个 handler + service + DTO | | 1.3 | F-2: 移除前端 nameCache 依赖 | `AppointmentList.tsx`, `PointsOrderList.tsx` | | 1.4 | 验证: cargo test + 前端页面加载对比 | - | ### Phase 2: P1 重要优化(预估 2-3 天) | 步骤 | 任务 | 修改文件 | |------|------|----------| | 2.1 | B-2: stats_service 合并 COUNT → GROUP BY | `stats_service.rs` | | 2.2 | B-3: alert_engine 预加载 + 批量评估 | `alert_engine.rs` | | 2.3 | B-4: `get_health_summary` 并行化 | `patient_service.rs` | | 2.4 | 验证: 基准测试对比(前后延迟测量) | - | ### Phase 3: P2 次要优化(预估 1-2 天) | 步骤 | 任务 | 修改文件 | |------|------|----------| | 3.1 | B-5: `compute_avg_field` 参数化 | `stats_service.rs` | | 3.2 | F-5: vendor chunk 拆分 | `vite.config.ts` + 路由 lazy | | 3.3 | F-3: PluginCRUDPage columns memo | `PluginCRUDPage.tsx` | | 3.4 | F-4: PluginGraphPage 按需重绘 | `PluginGraphPage.tsx` | ## 5. 风险与缓解 ### 5.1 B-1 批量 INSERT 冲突处理 **风险**: `insert_many()` + `ON CONFLICT DO NOTHING` 可能无法精确统计 inserted vs duplicates。 **缓解**: 先查询已存在的记录(按 patient_id + device_type + measured_at),过滤后插入净新增量。或使用 `exec_with_returning` 获取实际插入数。 ### 5.2 F-1 后端 JOIN 性能 **风险**: 列表 API JOIN users 表可能在大数据量下变慢。 **缓解**: users 表通常在千级别,JOIN 性能可接受。如遇瓶颈,可在列表查询中使用子查询 `(SELECT display_name FROM users WHERE id = ...)` 替代 JOIN。 ### 5.3 B-2 统计精度 **风险**: GROUP BY 聚合结果可能与多次 COUNT 不完全一致(如有 NULL status)。 **缓解**: 确保 `GROUP BY status` 包含对 NULL status 的处理,测试中对比优化前后结果一致性。 ### 5.4 前端 nameCache 移除过渡 **风险**: 后端未全部内联 name 前移除 nameCache 导致页面显示 UUID。 **缓解**: 采用渐进式 — 逐个 API 内联 name 并移除对应页面的 nameCache,每个 API 独立验证。 ## 6. 性能基准 ### 6.1 后端优化前后预估 | 接口 | 优化前 | 优化后 | 提升 | |------|--------|--------|------| | POST /device-readings/batch (500条) | ~2500ms (500×5ms) | ~50ms (1次INSERT) | 50x | | GET /stats/follow-up | ~40ms (4次查询) | ~10ms (1次GROUP BY) | 4x | | GET /stats/dashboard | ~200ms (串行4个stats) | ~80ms (并行+合并) | 2.5x | | GET /patient/{id}/health-summary | ~60ms (串行4次) | ~20ms (并行4次) | 3x | | GET /alert/evaluate | ~100ms (10规则×10ms) | ~30ms (批量) | 3x | ### 6.2 前端优化前后预估 | 页面 | 优化前 | 优化后 | 提升 | |------|--------|--------|------| | AppointmentList (20条) | ~3s (1+20+20请求) | ~300ms (1请求) | 10x | | ConsultationList (20条) | ~2.5s | ~300ms | 8x | | PointsOrderList (20条) | ~2s | ~300ms | 7x | | 首屏 JS 体积 | ~1.2MB gzip | ~0.8MB gzip | 33% |