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:
1 parent
e9e7b7dcb2
commit
a87fbeb494
5 files changed
+193
-70
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -211,3 +212,99 @@ func TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer(t *testing.T) {
|
||||
t.Fatal("经 Store 的转调没有落到审查服务上")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreInsertReviewer_PanelTuningRaceWithGateReads 是**并发回归哨兵**(切片
|
||||
// MEMPANEL / M4 的快修)。
|
||||
//
|
||||
// 背景:面板调优(`ApplyPanelTuning`)会在**运行期**改 `cfg` 的两个字段
|
||||
// (`DisableIngestGate` / `TempTTL`)。在这条特性之前 `cfg` 是构造期冻结的,读侧
|
||||
// 无锁本来安全;有了运行期写入之后,凡是读这两个字段的地方都必须与写方同步 ——
|
||||
// 否则就是「写方持锁、读方不持锁」的数据竞争。读侧两处分别是:
|
||||
// - `gateEnabled()`(同步入库闸,记忆**写入路径**上调用);
|
||||
// - `deferDeadline()`(第五态 `defer` 落 `expires_at`,写入路径与异步审查都会调)。
|
||||
//
|
||||
// 它**只在 `-race` 下有意义**:不带 `-race` 时它只是一次并发冒烟(两边都跑完即通过),
|
||||
// 有没有竞争交给 race detector 判。跑法:
|
||||
//
|
||||
// go test -race -count=1 -run TestPreInsertReviewer_PanelTuningRaceWithGateReads ./internal/runtime/memory/
|
||||
func TestPreInsertReviewer_PanelTuningRaceWithGateReads(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||||
}
|
||||
const rounds = 200
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
// 写方:模拟面板保存(每次改两个面板键)。
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
panel := runtimeconfig.MemoryTidyTuning{
|
||||
IngestGate: tuningBool(i%2 == 0),
|
||||
TempTtlSec: tuningInt(3600 + i),
|
||||
}
|
||||
if err := reviewer.ApplyPanelTuning(panel); err != nil {
|
||||
t.Errorf("ApplyPanelTuning: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// 读方一:同步入库闸的开关判定(写入路径)。
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
_ = reviewer.gateEnabled()
|
||||
}
|
||||
}()
|
||||
// 读方二:第五态的到期时刻。
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
_ = reviewer.deferDeadline(time.Now())
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestTidyService_PanelTuningRaceWithLiveHeartbeat 是整理档的**并发回归哨兵**。
|
||||
//
|
||||
// 背景(实测踩出来的,不是推演):`s.cfg` 的**调度读**(`nextDelay` / `dueNow`)
|
||||
// 不持锁,因此「面板调优写 cfg」必须落在一个**没有任何 goroutine 读它**的窗口里。
|
||||
// 曾经的顺序 `Stop → Start → 写` 在这里会被 -race 抓住:新起的循环立刻读 `s.cfg`
|
||||
// 重算下一拍,与写入并排(而且那一拍还可能按**旧**配置算出来)。正确顺序是
|
||||
// `Stop → 写 → Start`。
|
||||
//
|
||||
// 读者刻意**不**另起 goroutine 调 `nextDelay()`:那个入口在包里只有心跳 goroutine
|
||||
// 会调(生产代码不在别处调它),另起一个就是伪造一个生产不存在的调用方。这里直接让
|
||||
// **真心跳**当读者(连跑多轮,每轮都要停一次、起一次),与生产形态一致。
|
||||
//
|
||||
// 同样**只在 `-race` 下有意义**(不带 -race 时它是一次并发冒烟 + 值生效断言)。跑法:
|
||||
//
|
||||
// go test -race -count=1 -run TestTidyService_PanelTuningRaceWithLiveHeartbeat ./internal/runtime/memory/
|
||||
func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) {
|
||||
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
|
||||
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
|
||||
svc.Start(nil)
|
||||
t.Cleanup(func() { _ = svc.Stop(nil) })
|
||||
|
||||
const rounds = 25
|
||||
for i := 0; i < rounds; i++ {
|
||||
panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)}
|
||||
if err := svc.ApplyPanelTuning(base, panel); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
// 每轮重排之后心跳都必须还活着(`Stop` 把 running 置为 false,忘了重启就会静默停摆),
|
||||
// 且最后一轮的周期真的生效在运行期。
|
||||
svc.mu.Lock()
|
||||
running := svc.running
|
||||
svc.mu.Unlock()
|
||||
if !running {
|
||||
t.Fatal("连着保存多次之后心跳必须仍在跑")
|
||||
}
|
||||
if want := 3600 + rounds - 1; svc.cfg.IntervalSec != want {
|
||||
t.Fatalf("最后一轮的 intervalSec = %d, want %d", svc.cfg.IntervalSec, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user