fix(memory): 面板调优引入的两处运行期写入面收口(-race 修前红 → 修后绿)

一、审查档(preinsert.go / preinsert_gate.go):面板调优会在**运行期**改 cfg 的
两个字段(DisableIngestGate / TempTTL),而读这两个字段的两处不持锁
(gateEnabled 在同步入库闸上、deferDeadline 在第五态 defer 上)。
- 读侧改走新的 tuningSnapshot()(r.mu 由 Mutex 换成 RWMutex,读锁下取整份快照);
- **只**收这两个字段的读点:其余读点读的是构造期冻结的字段,不与写方交手,
  故刻意不给入库热路径加无谓的锁;注释写明「将来面板再接管别的字段,那些读点
  必须与它一起收」;
- 锁序:写方持锁期间只做字段赋值、不取任何别的锁(normalize/回落都在取锁之前
  跑完)⇒ 不引入新锁序。

二、整理档(tidy_service_tuning.go):修掉我自己的那条真竞争 —— 原顺序是
「Stop → Start → 写 cfg」,而新起的循环**立刻**读 s.cfg 重算下一拍,与写入并排
(那一拍还可能按旧配置算出来)。改成「Stop → 写 → Start」。
顺带去掉 Stop 的超时分支:超时意味着 wg 的 Wait 仍在飞,紧接着 Start 的 Add 会
踩上 M3 在 tidy_service.go 明文写下的 WaitGroup 复用纪律(-race 下 panic);`Stop`
期间也不持 runMu,避免与「心跳正要开跑一轮」互相等死。

证据(全部逐条跑过再还原,还原用 diff 逐字校验为 0 差异):
- 审查档:撤回读侧修复 ⇒ 2 处 DATA RACE(写 preinsert_tuning.go 的两个字段 × 读
  gateEnabled / deferDeadline);恢复 ⇒ 绿。
- 整理档:把顺序改回「先起后写」⇒ DATA RACE(写 applyTidyConfig × 读 nextDelay);
  恢复 ⇒ 绿。
- 新增两条只在 -race 下有意义的回归哨兵(tidy_service_tuning_test.go):审查档真
  并发读写;整理档用**真心跳**当读者(不伪造生产不存在的调用方,那个入口在包里
  只有心跳 goroutine 会调)。

热路径成本:新增的同步是「一次无竞争 RLock + 整份小结构体快照」,隔离实测约
4ns/次(5e6 次共 22.9ms);入库闸那条测量用例的批均值在 2.62ms → 2.71~2.81ms
之间波动,落在那条用例自身的运行间噪声里(同一份代码连跑两次,纯写入基线就从
39µs 飘到 60µs)。
This commit is contained in:
AskaEth committed 2026-10-13 22:11:41 +08:00
1 parent e9e7b7dcb2
commit a87fbeb494
5 files changed
+193 -70

No files matched your search

+11 -5
View File
@@ -81,11 +81,17 @@ func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int {
// 缺位/坏值由 `ApplyReviewTuning`(WARN + 不覆盖)与 `normalizeReviewConfig`(代码
// 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。
//
// 🔴 并发边界(如实登记):写 `r.cfg` 的两处在 `r.mu` 下,但 `preinsert.go` /
// `preinsert_gate.go` 读 `r.cfg` 的几处**没有**取 `r.mu`(本切片 scope 不含那两个
// 文件,故未一并收口)。这两个字段分别是一个 bool 与一个 int64,在 64 位平台上单字长
// 写入不会撕裂,但严格意义上这仍是「写方持锁、读方不持锁」的窄窗口。把 cfg 读改成
// 访问器(或换成原子快照)是后续应做的一次收口,已在回报里登记。
// 🔴 并发口径(本方法引入的运行期写入面,已连同读侧一起收口):`cfg` 在这条特性
// 之前是**构造期冻结**的,读侧无锁本来安全;这里开了「运行期改两个字段」这条路,
// 于是读这两个字段的地方也必须同步。写侧持 `r.mu` 的**写锁**;读侧两处
// (`gateEnabled` / `deferDeadline`)走 `r.tuningSnapshot()` 取读锁快照。
// 其余读点读的是构造期冻结的字段(`Enabled` / `QueueSize` / `Timeout` / `Threshold` /
// T1 四件套…),与写方不交手,因此**刻意没有**一并加锁(不给入库热路径加无谓的锁);
// 将来面板再接管别的字段,那些读点必须与它一起收。
//
// 锁序:本方法持锁期间**只做字段赋值**,不去取任何别的锁(`normalizeReviewConfig` /
// `ApplyReviewTuning` 都在取锁**之前**跑完)⇒ 不引入新的锁序,也就不可能与既有路径
// 形成环。
func (r *PreInsertReviewer) ApplyPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
next := normalizeReviewConfig(ApplyReviewTuning(ReviewConfig{}, panel, r.log))
r.mu.Lock()