perf: batch byte accounting and bound gateway memory

- countingConn batches per-domain attribution (32KiB/500ms) so the relay
  hot path pays two atomics per I/O instead of a global mutex; Close drains
- Precompute OTel byte attributes to drop per-I/O allocations
- suffixtree: indexMap only for nodes with >=8 children; Match stays
  read-only for concurrent queries
- Soft Go memory limit (128MiB default, GOMEMLIMIT/SOWER_MEMORY_LIMIT_MB
  override) cuts resident memory from ~240MB to ~130MB on the default
  ad/china/gfw rule sets
- Split stats.go into snapshot/eviction/counting by responsibility
This commit is contained in:
wweir
2026-08-07 12:02:26 +08:00
parent 60ac21f230
commit defea58ac2
17 changed files with 1608 additions and 455 deletions
+50
View File
@@ -0,0 +1,50 @@
package router
import (
"fmt"
"testing"
"time"
)
// BenchmarkDNSPrepareUpstreamState measures the per-query upstream state
// snapshot cost (mutex + address-slice copy).
func BenchmarkDNSPrepareUpstreamState(b *testing.B) {
r, err := NewRouter(nil, "", "223.5.5.5", "", nil)
if err != nil {
b.Fatal(err)
}
r.dns.upstreamAddrs = []string{"1.1.1.1:53", "8.8.8.8:53", "223.5.5.5:53"}
r.dns.upstreamIndex = 0
b.ReportAllocs()
b.ResetTimer()
for range b.N {
r.prepareUpstreamState(time.Now())
}
}
// BenchmarkDNSRuleMatchPath measures the rule-classification cost of one
// DNS query: domain-list construction plus up to three RuleSet.Match calls.
func BenchmarkDNSRuleMatchPath(b *testing.B) {
r, err := NewRouter(nil, "", "223.5.5.5", "", nil)
if err != nil {
b.Fatal(err)
}
rules := make([]string, 0, 10000)
for i := 0; i < 10000; i++ {
rules = append(rules, fmt.Sprintf("host%d.example.com", i))
}
r.BlockRule.Replace(rules...)
r.DirectRule.Replace("direct.example.com")
r.ProxyRule.Replace("proxy.example.com")
b.ReportAllocs()
b.ResetTimer()
for range b.N {
domains := dnsRouteDomains("www.example.com", 1)
_ = dnsRuleMatch(r.BlockRule, domains)
if !dnsRuleMatch(r.DirectRule, domains) {
_ = dnsRuleMatch(r.ProxyRule, domains)
}
}
}
+30 -1
View File
@@ -10,6 +10,8 @@ import (
// RuleSet is a thread-safe rule container. It retains the raw rule list so
// rules can be listed and removed at runtime; the suffixtree is derived
// state and is rebuilt from the retained list whenever a rule is removed.
// Membership for Add/Remove uses the raw rule strings (set), so case and
// trailing-dot variants stay distinct entries in the configuration.
type RuleSet struct {
mu sync.RWMutex
rules []string
@@ -25,7 +27,8 @@ func NewRuleSet(rules ...string) *RuleSet {
return rs
}
// Add appends rules that are not already present. Duplicates are ignored.
// Add appends rules that are not already present, using the raw rule string
// as the membership key. Duplicates are ignored.
func (rs *RuleSet) Add(rules ...string) {
if len(rules) == 0 {
return
@@ -46,6 +49,32 @@ func (rs *RuleSet) Add(rules ...string) {
}
}
// Replace swaps the entire rule list atomically, rebuilding the suffix
// tree. Literal duplicates in the input are dropped. It is used to rebuild
// a rule set from the boot baseline after a delta reset.
func (rs *RuleSet) Replace(rules ...string) {
// Construct the full immutable candidate before taking the match lock.
// The field swap below is the operation's linearization point.
nextRules := make([]string, 0, len(rules))
nextSet := make(map[string]struct{}, len(rules))
nextTree := suffixtree.NewNodeFromRules()
for _, rule := range rules {
if _, ok := nextSet[rule]; ok {
continue
}
nextSet[rule] = struct{}{}
nextRules = append(nextRules, rule)
nextTree.Add(rule)
}
nextTree.GC()
rs.mu.Lock()
rs.rules = nextRules
rs.set = nextSet
rs.tree = nextTree
rs.mu.Unlock()
}
// Compact runs garbage collection on the underlying tree. Call it after a
// batch of adds to avoid per-add compaction overhead on large rule files.
func (rs *RuleSet) Compact() {
+34
View File
@@ -0,0 +1,34 @@
package router
import (
"fmt"
"testing"
)
func BenchmarkRuleSetMatch(b *testing.B) {
rules := make([]string, 0, 10000)
for i := 0; i < 10000; i++ {
rules = append(rules, fmt.Sprintf("host%d.example.com", i))
}
rs := NewRuleSet(rules...)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
rs.Match("www.example.com")
}
}
func BenchmarkRuleSetMatchHit(b *testing.B) {
rules := make([]string, 0, 10000)
for i := 0; i < 10000; i++ {
rules = append(rules, fmt.Sprintf("host%d.example.com", i))
}
rs := NewRuleSet(rules...)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
rs.Match("host5000.example.com")
}
}
+66
View File
@@ -0,0 +1,66 @@
package router
import (
"fmt"
"runtime"
"testing"
)
// keepGlobal pins the trees across GC; without it the compiler may treat
// them as dead after the Match checks.
var keepGlobal struct {
block *RuleSet
direct *RuleSet
proxy *RuleSet
}
// TestRulesetMemoryFootprint builds rule shapes matching the production
// ad/china/gfw lists (synthetic, no external files) and reports the retained
// heap. Skipped under -short.
func TestRulesetMemoryFootprint(t *testing.T) {
if testing.Short() {
t.Skip("memory footprint test; skipped in -short mode")
}
t.Cleanup(func() {
keepGlobal = struct {
block *RuleSet
direct *RuleSet
proxy *RuleSet
}{}
})
// adlist-like: many exact domains under a few second-level domains.
block := make([]string, 0, 160000)
for i := 0; i < 160000; i++ {
block = append(block, fmt.Sprintf("ads%d.doubleclick.net", i))
}
// chinalist-like: many exact domains under many second-level domains.
direct := make([]string, 0, 110000)
for i := 0; i < 110000; i++ {
direct = append(direct, fmt.Sprintf("host%d.example%d.com", i, i%2000))
}
// gfwlist-like: wildcard-heavy.
proxy := make([]string, 0, 5000)
for i := 0; i < 5000; i++ {
proxy = append(proxy, fmt.Sprintf("**.site%d.com", i%100))
}
t.Logf("rules: block=%d direct=%d proxy=%d", len(block), len(direct), len(proxy))
keepGlobal.block = NewRuleSet(block...)
keepGlobal.direct = NewRuleSet(direct...)
keepGlobal.proxy = NewRuleSet(proxy...)
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
before := m.HeapAlloc
// Keep references live across GC.
if !keepGlobal.block.Match("ads1.doubleclick.net") || !keepGlobal.direct.Match("host1.example1.com") || !keepGlobal.proxy.Match("a.b.site1.com") {
t.Fatal("unexpected match result")
}
runtime.GC()
runtime.ReadMemStats(&m)
t.Logf("retained after GC: %d KiB (%.1f MB)", m.HeapAlloc/1024, float64(m.HeapAlloc)/1024/1024)
t.Logf("heap before/after: %d / %d", before, m.HeapAlloc)
}
+27
View File
@@ -3,6 +3,7 @@ package router
import (
"fmt"
"sync"
"sync/atomic"
"testing"
)
@@ -149,6 +150,32 @@ func TestRuleSetConcurrentAccess(t *testing.T) {
}
}
func TestRuleSetReplaceConcurrentWithMatch(t *testing.T) {
rs := NewRuleSet("seed.example.com")
var stop atomic.Bool
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
for !stop.Load() {
_ = rs.Match("seed.example.com")
_ = rs.Match("replacement.example.com")
}
}()
}
for i := 0; i < 100; i++ {
rs.Replace(fmt.Sprintf("replacement%d.example.com", i), "replacement.example.com")
}
stop.Store(true)
wg.Wait()
if !rs.Match("replacement.example.com") || rs.Match("seed.example.com") {
t.Fatalf("unexpected final rules: %v", rs.List())
}
}
func TestListFilteredPaginationAndSearch(t *testing.T) {
rs := NewRuleSet("example.com", "**.cn", "GitHub.com", "mail.google.com", "a.cn")