基于全景审计分析,产出 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
13 KiB
性能优化设计规格
日期: 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)
// 方向:构建 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 次 COUNTcompleted: 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 查询
优化方案:
- 一次性查询所有 active rules(已有)
- 批量查询该患者最近 cooldown 期间所有 alerts,构建 HashSet<rule_id>
- 条件评估(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.rs 的 evaluate_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.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 常量
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 层补充查询。
// 方向:在查询列表时 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 重建,形成循环。
优化方案:
- 后端内联 name 后(F-1),nameCache 机制可以完全移除
- 作为过渡方案,将 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% |