diff --git a/README.md b/README.md index 0e35daa..a1d8479 100644 --- a/README.md +++ b/README.md @@ -1251,6 +1251,62 @@ type Reflux interface { `Get` 剩下的 2 次分配是 API 形状决定的下限: 一次是返回的 `R` 包装对象(24 字节), 一次是可变参数切片(通过接口调用时逃逸分析穿不透)。想完全避免就用 `Get[T]`。 +### 类型描述符缓存:什么时候清除 + +**永不清除,正常情况下也不需要清除。** + +缓存的 key 是 `reflect.Type`。程序里**声明**的类型是编译期确定的有限集合, +runtime 为它们创建的 `reflect.Type` 本身就是永久对象,所以缓存大小收敛于 +"程序实际用到的类型数",warmup 之后不再增长。 + +实测占用(描述符 + 字段切片 + 字段名索引): + +| 类型数 | 缓存占用 | +|---|---| +| 100 | 约 0.06 MB | +| 1,000 | 约 0.6 MB | +| 10,000 | 约 6 MB | + +一个中等项目撑死几百个类型,占用在百 KB 级别。 + +设计上有意做成**只增不删**:描述符指针一旦发布就永久有效, +`Elem`/`Type` 这些递归引用不需要任何生命周期管理,热路径上也不必做 +引用计数或有效性校验。 + +#### 唯一的例外:`reflect.StructOf` 动态造类型 + +如果调用方用 `reflect.StructOf` / `reflect.MapOf` 等在**运行时动态生成 +形状各异的类型**,再把它们交给 `reflux.New`,缓存就会持续增长。 +典型场景是 ORM 按查询字段动态拼 struct 作为扫描目标。 + +实测(12 字段的动态 struct,1000 个): + +| | 每类型占用 | 占比 | +|---|---:|---:| +| Go runtime 的类型元数据 | 1369 B | 41% | +| reflux 的类型描述符 | **1945 B** | **59%** | +| 合计 | 3315 B | | + +两点需要说明: + +1. **这不是 reflux 特有的问题**。`reflect.StructOf` 创建的类型 + **Go runtime 自己也永不回收**,泄漏在更底层就已经发生了; + reflux 是在此基础上多加了约 59%。 +2. **类型数量通常仍然有界**。`reflect.StructOf` 对**相同字段集合**(名称、 + 类型、tag、**顺序**都相同)返回同一个 `reflect.Type`,所以重复调用不会造新类型。 + 只有当字段组合本身是无界的(比如允许客户端任意指定 `?fields=a,b,c`), + 才会真正持续增长。 + +如果你的场景确实会动态生成大量不同的 struct 类型,建议: + +- **优先在上游收敛**:把字段组合固定成有限的几种,或者在构造类型的那一层 + 加缓存(既省掉 `reflect.StructOf` 的开销,也自然限住了类型数量)。 + 顺带一提,`reflect.StructOf` 即使命中 runtime 自己的类型缓存也要 + **2μs 上下、30 次左右的分配**,重复调用本身就值得避免。 +- **注意字段顺序**:字段集合相同但顺序不同会产生**不同**的类型。 + 如果拼字段时遍历了 map(Go 的 map 迭代顺序随机),同一个逻辑查询每次都会 + 造出新类型 —— 这是真正会导致无界增长的 bug,务必用切片保证顺序稳定。 + ## 注意事项 1. **指针 vs 值传递**: