Files
raisfast/docs/performance-optimization.md
T

313 lines
8.9 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.
# 性能优化方案
> 基于 20 并发压力测试的瓶颈分析,覆盖中间件、数据库、CMS 引擎三个层面。
> 当前测试环境:macOS 单机,dev 编译,SQLite 本地文件。
## 当前基准性能
```
20 并发 × 5 秒,httpx Python 客户端
原生 GET /posts (列表) 1,046 RPS P50=18.5ms
CMS GET /cms/articles 531 RPS P50=38.9ms
原生 GET /posts/{slug} 1,047 RPS P50=18.0ms
CMS GET /cms/articles/{id} 1,043 RPS P50=18.3ms
原生 POST /posts 287 RPS P50=66.0ms
CMS POST /cms/articles 313 RPS P50=54.4ms
```
## 请求热路径
```
HTTP 请求到达
├─ CORS ~0μs
├─ TraceLayer (日志 span) ~1μs
├─ Request ID (UUID v7) ~1μs
├─ Metrics (计时器) ~1μs
├─ Locale (Header 解析) ~5μs
├─ ★ Rate Limit (全局 Mutex + O(N)) ~10-100μs ← 瓶颈 #1
├─ JWT 验证 (每次新建 DecodingKey) ~10-50μs ← 瓶颈 #2
├─ CMS 路由额外:
│ ├─ Registry.get() (std::sync::RwLock) ~1-5μs ← 瓶颈 #3
│ ├─ SQL 1: COUNT(*) ~500-2000μs
│ ├─ SQL 2: SELECT ... LIMIT ~1000-5000μs
│ └─ Relation 解析 (额外 N 次查询) ← 瓶颈 #4
└─ 原生路由额外:
├─ Cache RwLock 读 ~1μs (命中时)
├─ SQL 1: LEFT JOIN 主查询 ~1000-5000μs
├─ SQL 2: COUNT(*) ~500-2000μs
└─ SQL 3: 批量查 tags ~1000-3000μs
```
---
## 瓶颈 #1Rate Limit 全局 Mutex + O(N) retain
**位置**`src/middleware/rate_limit.rs:77-95`
**现状**
```rust
struct MemoryStore {
entries: tokio::sync::Mutex<HashMap<String, Entry>>,
}
async fn check(&self, key: &str, config: &RateLimitConfig) -> bool {
let mut entries = self.entries.lock().await; // 所有请求竞争一把锁
entries.retain(|_, entry| { ... }); // O(N) 全量遍历清理
let entry = entries.entry(key.to_string()) // 每次分配 String
.or_insert(Entry { count: 0, window_start: now });
// ...
}
```
**问题**
| 问题 | 影响 |
|------|------|
| 单一 `tokio::sync::Mutex` | 所有 API 请求串行化,高并发下排队 |
| `retain()` 每次全量遍历 | O(N),N = 活跃 IP 数,随流量线性增长 |
| `key.to_string()` 每次分配 | 即使 key 已存在也分配新 String |
| 锁持有期间做清理 | 阻塞所有其他请求 |
**方案**
1. 替换为 `DashMap<String, Entry>`(分片锁,16 个分片)
2. 去掉 `retain()`,改为惰性过期(检查时判断单条是否过期)
3.`entry()` API 避免 key 重复分配
4. 后台定时任务清理过期条目(已有 300 秒清理,保留)
**预估提升**RPS +100~200%
---
## 瓶颈 #2JWT DecodingKey 每次创建
**位置**`src/services/auth.rs:134-145`
**现状**
```rust
pub fn verify_token(token: &str, secret: &str) -> AppResult<Claims> {
jsonwebtoken::decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()), // 每次分配+拷贝
&Validation::default(),
)
// ...
}
```
**方案**
启动时创建 `DecodingKey` 存入 `AppState`
```rust
pub struct AppState {
pub jwt_decoding_key: DecodingKey, // 新增
// ...
}
```
**预估提升**-10~30μs/请求
---
## 瓶颈 #3ContentTypeRegistry 用 std::sync::RwLock
**位置**`src/content_type.rs:48`
**现状**
```rust
pub struct ContentTypeRegistry {
inner: std::sync::RwLock<HashMap<String, Arc<ContentTypeSchema>>>,
}
```
**问题**
- `std::sync::RwLock` 在 tokio 异步运行时中会阻塞线程
- 如果有写操作(注册新 Content Type),读端也会被阻塞
- 每次 CMS 请求都需要获取读锁
**方案**
替换为 `arc-swap::ArcSwap<HashMap<...>>`
```rust
pub struct ContentTypeRegistry {
inner: ArcSwap<HashMap<String, Arc<ContentTypeSchema>>>,
}
// 读:无锁,直接 load()
fn get(&self, name: &str) -> Option<Arc<ContentTypeSchema>> {
self.inner.load().get(name).cloned()
}
// 写:替换整个 map
fn register(&self, schema: ContentTypeSchema) {
let mut new_map = self.inner.load().as_ref().clone();
new_map.insert(schema.name.clone(), Arc::new(schema));
self.inner.store(Arc::new(new_map));
}
```
**预估提升**:消除尾部延迟,CMS 请求 -1~5μs
---
## 瓶颈 #4OneToMany/ManyToMany 的 N+1 查询
**位置**`src/content_type/resolver.rs:53-63`
**现状**
```rust
// OneToMany — 每个 item 单独查询
for item in items.iter_mut() {
resolve_one_to_many(pool, ct, field.name, rel, item).await?;
}
// ManyToMany — 同上
for item in items.iter_mut() {
resolve_many_to_many(pool, ct, field.name, rel, item).await?;
}
```
20 条记录 × 1 个 OneToMany 字段 = 20 次额外 SQL 查询。
**方案**
改为和 ManyToOne 一样的批量模式:
```
OneToMany: 收集所有 item 的 id → WHERE fk_col IN (id1, id2, ...) → 按 fk_col 分组 → 分发回各 item
ManyToMany: 收集所有 item 的 id → WHERE junction.source_id IN (...) JOIN target → 按 source_id 分组 → 分发
```
**预估提升**:N 次查询 → 1 次查询,CMS 列表 -50%
---
## 瓶颈 #5SQLite PRAGMA 未优化
**位置**`src/db/connection.rs:33-43`
**现状**
```rust
.after_connect(|conn, _meta| {
sqlx::query("PRAGMA journal_mode = WAL").execute(&mut *conn).await?;
sqlx::query("PRAGMA foreign_keys = ON").execute(&mut *conn).await?;
Ok(())
})
```
**缺少的 PRAGMA**
| PRAGMA | 当前值 | 建议值 | 说明 |
|--------|--------|--------|------|
| `busy_timeout` | 未设置(默认 0 | `5000` | 写冲突时等待 5 秒而非立即报错 |
| `synchronous` | `FULL`(默认) | `NORMAL` | WAL 模式下 NORMAL 足够安全,性能提升显著 |
| `cache_size` | `-2000`~2MB,默认) | `-64000`(64MB) | 增大页缓存,减少磁盘 IO |
| `temp_store` | `DEFAULT` | `MEMORY` | 临时表在内存中,排序/聚合更快 |
| `mmap_size` | `0`(默认) | `268435456`(256MB) | 内存映射 IO,减少系统调用 |
**预估提升**SQL 延迟 -30~50%
---
## 瓶颈 #6CMS 列表无缓存
**现状**
- 原生 `/posts``CachedPostRepository`(内存缓存,命中时 <0.5ms)
- CMS `/cms/articles``ContentRepository`**每次都查数据库**
**方案**
为 CMS 动态路由加缓存层:
```
缓存 key: "cms:{plural}:{tenant}:{page}:{page_size}:{status}:{filters_hash}"
缓存 TTL: 可配置,默认 30 秒
失效策略: create/update/delete 时清除该 content type 的所有缓存
```
**预估提升**:热数据从 38ms → <1ms,等效 RPS +10x
---
## 优化路线图
### 第一阶段:低风险高收益(1-2 天)
| # | 优化项 | 改动量 | 预估收益 |
|---|--------|--------|----------|
| 1 | SQLite PRAGMA 调优 | 5 行 | SQL -30~50% |
| 2 | JWT DecodingKey 缓存 | 3 行 | -10~30μs/请求 |
| 3 | CMS 列表内存缓存 | ~100 行 | 热数据 +10x |
### 第二阶段:中等改动(2-3 天)
| # | 优化项 | 改动量 | 预估收益 |
|---|--------|--------|----------|
| 4 | Rate Limit → DashMap | ~80 行 | RPS +100~200% |
| 5 | ContentTypeRegistry → ArcSwap | ~50 行 | 消除尾部延迟 |
| 6 | OneToMany/ManyToMany 批量化 | ~100 行 | CMS 列表 -50% |
### 第三阶段:进阶优化(可选)
| # | 优化项 | 说明 |
|---|--------|------|
| 7 | CMS 列表预 JOIN | 将 ManyToOne 改为 LEFT JOIN 合并到主查询,减少 1 次 SQL |
| 8 | 响应压缩 (gzip/brotli) | 中间件层压缩 JSON,减少传输时间 |
| 9 | 连接池调优 | 动态调整 max_connections 基于 CPU 核数 |
| 10 | release 编译优化 | LTO + codegen-units=1,预估 +20~30% |
---
## 优化后预估性能
```
当前 (20 并发, dev):
原生列表: 1,046 RPS P50=18.5ms
CMS 列表: 531 RPS P50=38.9ms
详情: 1,043 RPS P50=18.0ms
第一阶段后 (dev):
原生列表: 1,500-2,000 RPS P50=10-13ms
CMS 列表: 3,000-5,000 RPS P50=4-7ms (缓存命中)
详情: 1,500-2,000 RPS P50=10-13ms
全部优化后 (dev):
原生列表: 2,000-3,000 RPS P50=7-10ms
CMS 列表: 1,500-2,500 RPS P50=8-13ms (缓存未命中)
详情: 3,000-5,000 RPS P50=4-7ms
全部优化后 (release):
原生列表: 5,000-8,000 RPS P50=2-4ms
CMS 列表: 3,000-5,000 RPS P50=4-7ms
详情: 8,000-15,000 RPS P50=1-3ms
```
目标:release + 全部优化后,**达到 PocketBase 同一梯队(3,000-8,000 RPS**。
---
## 横向对比参考
| 系统 | 语言 | 列表 RPS | 详情 RPS |
|------|------|----------|----------|
| PocketBase | Go | 2,000-5,000 | 3,000-6,000 |
| raisfast (当前 dev) | Rust | 531-1,046 | 1,043 |
| raisfast (预估 release 全优化) | Rust | 3,000-8,000 | 8,000-15,000 |
| Strapi | Node.js | 100-300 | 200-400 |
| Ghost | Node.js | 200-500 | 300-600 |
| Directus | Node.js | 80-200 | 150-300 |