基于全景审计分析,产出 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
321 lines
13 KiB
Markdown
321 lines
13 KiB
Markdown
# 性能优化设计规格
|
||
|
||
> 日期: 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<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-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% |
|