Files
hms/docs/superpowers/specs/2026-04-26-performance-optimization-design.md
iven d1ab8074a3
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
docs: 多专家组头脑风暴产出 — 5 份设计规格
基于全景审计分析,产出 5 份跨领域设计规格:

1. 性能优化 — 后端批量INSERT/合并COUNT/告警预加载 + 前端N+1内联name
2. 安全纵深防御 — PostgreSQL RLS/行级数据范围/session_key Redis/审计哈希链
3. 事件驱动架构增强 — 6个业务域11个缺失事件补发 + Outbox LISTEN/NOTIFY
4. 前端工程化 — 14个大组件拆分 + 3个重复模式统一 + Bundle优化
5. 可观测性与运维 — 深度健康检查/Prometheus/OpenTelemetry/生产Docker
2026-04-27 07:46:36 +08:00

13 KiB
Raw Permalink Blame History

性能优化设计规格

日期: 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 多次独立 COUNTget_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 逐规则独立查询 DBcooldown + 条件评估) 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
// 方向:构建 ActiveModel Vec调用 insert_many
let models: Vec<device_readings::ActiveModel> = 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.rsbatch_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 + 应用层聚合

SELECT status, COUNT(*) AS cnt
FROM follow_up_task
WHERE tenant_id = $1 AND deleted_at IS NULL
GROUP BY status

应用层从 HashMap<status, count> 中提取各字段值。

同理适用于:

  • 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<rule_id>
  3. 条件评估single_threshold只需查最新一条 hourly 记录,可按 device_type 批量查出后在内存匹配
// 批量 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<Uuid> = recent_alerts.iter().map(|a| a.rule_id).collect();

影响范围: alert_engine.rsevaluate_rules() 和辅助函数。

3.4 B-4: get_health_summary 并行化

当前实现 (get_health_summary, 第 423-475 行):

  • 4 次 .await 串行执行
  • 总延迟 = sum(4 次查询延迟)

优化方案: 使用 tokio::join! 并行执行

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.rsget_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 常量

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.rscompute_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 层补充查询。

// 方向:在查询列表时 LEFT JOIN users 表获取 display_name
let rows: Vec<(appointment::Model, Option<String>)> = appointment::Entity::find()
    .filter(...)
    .find_also_related(user::Entity)  // 如果有关联定义
    .all(&state.db)
    .await?;

影响范围: 5 个 handler + 对应 service 函数 + DTO 结构体。

3.7 F-2: 移除 nameCache 依赖

当前问题: AppointmentList.tsxPointsOrderList.tsxuseEffect 依赖 nameCache,更新 nameCache 触发 fetchData 重建,形成循环。

优化方案:

  1. 后端内联 name 后F-1nameCache 机制可以完全移除
  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 配置

// 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:

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%