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

321 lines
13 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.
# 性能优化设计规格
> 日期: 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 逐规则独立查询 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`
```rust
// 方向:构建 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.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<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 批量查出后在内存匹配
```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<Uuid> = 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<String>)> = 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-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` 配置
```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% |