docs: 说明类型描述符缓存的生命周期与 reflect.StructOf 的注意事项

补充两个评审/接手时大概率会问到的问题:

1. 缓存什么时候清除 —— 永不清除, 正常情况下也不需要。key 是 reflect.Type,
   程序里声明的类型是编译期确定的有限集合, 缓存大小 warmup 之后收敛。
   附实测占用(1000 个类型约 0.6 MB), 并说明只增不删是有意设计:
   描述符指针永久有效, 递归引用不需要生命周期管理。

2. 唯一的例外是调用方用 reflect.StructOf 在运行时动态造类型(典型是 ORM 按
   查询字段拼扫描目标 struct)。实测 12 字段的动态类型每个约 3315 字节,
   其中 reflux 占 59%、Go runtime 自己的类型元数据占 41% ——
   后者同样永不回收, 所以这不是 reflux 特有的问题。

同时给出两条实践建议: 优先在上游收敛字段组合并缓存类型(reflect.StructOf
即使命中 runtime 缓存也要 2μs / 30 次分配); 以及注意字段顺序 ——
字段集合相同但顺序不同会产生不同的类型, 拼字段时遍历 map 会因为迭代顺序
随机而每次造出新类型, 那才是真正会导致无界增长的 bug。
This commit is contained in:
2026-08-28 20:05:13 +08:00
parent 05522809b9
commit d3216cb3b4
+56
View File
@@ -1251,6 +1251,62 @@ type Reflux interface {
`Get` 剩下的 2 次分配是 API 形状决定的下限: 一次是返回的 `R` 包装对象(24 字节), `Get` 剩下的 2 次分配是 API 形状决定的下限: 一次是返回的 `R` 包装对象(24 字节),
一次是可变参数切片(通过接口调用时逃逸分析穿不透)。想完全避免就用 `Get[T]` 一次是可变参数切片(通过接口调用时逃逸分析穿不透)。想完全避免就用 `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 值传递**: 1. **指针 vs 值传递**: