From 4c0608f84c7b56dc2f26977bc9666a8c4db7b201 Mon Sep 17 00:00:00 2001 From: nange Date: Wed, 19 Aug 2026 20:25:27 +0800 Subject: [PATCH] feat: jitter dns cache ttl to avoid expiry bursts --- client/dns/cache.go | 18 +++++++++++++++++- client/dns/cache_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/client/dns/cache.go b/client/dns/cache.go index 506f547c..0b3365da 100644 --- a/client/dns/cache.go +++ b/client/dns/cache.go @@ -2,6 +2,7 @@ package dns import ( "fmt" + "math/rand/v2" "strings" "github.com/coocood/freecache" @@ -59,6 +60,9 @@ func (c *Cache) Get(name, qtype string, isDirect bool) *dns.Msg { // Set stores a DNS message in the appropriate cache using DNS TTL. // Only A and AAAA records are cached. If isDirect is true, the direct cache is used. +// The effective cache lifetime is the base TTL plus a random jitter in +// [0, baseTTL) so that entries with the same base TTL do not expire at the +// same moment, avoiding bursts of concurrent DNS queries. func (c *Cache) Set(msg *dns.Msg, isDirect bool) error { if msg == nil || len(msg.Question) == 0 { return nil @@ -70,7 +74,7 @@ func (c *Cache) Set(msg *dns.Msg, isDirect bool) error { return err } key := []byte(q.Name + dns.TypeToString[q.Qtype]) - ttl := dnsCacheTTL(msg, c.serverDomain) + ttl := jitterTTL(dnsCacheTTL(msg, c.serverDomain)) if isDirect { return c.direct.Set(key, v, ttl) } @@ -109,6 +113,18 @@ func dnsCacheTTL(msg *dns.Msg, serverDomain string) int { return int(ttl) } +// jitterTTL returns the effective cache lifetime in seconds for a base TTL: +// the base TTL plus a random jitter in [0, ttl). Entries sharing the same +// base TTL therefore expire at scattered moments instead of all at once, +// avoiding a burst of concurrent DNS queries. A TTL of 0 (never expire, +// e.g. the proxy server's own domain) is returned unchanged. +func jitterTTL(ttl int) int { + if ttl <= 0 { + return ttl + } + return ttl + rand.IntN(ttl) +} + // PrePopulate resolves the domain via the given DNS server and stores the // A and AAAA results in both the direct and proxied caches. This is used // to pre-seed the cache with the proxy server's IP before TUN routes are diff --git a/client/dns/cache_test.go b/client/dns/cache_test.go index 8bfdd294..31281725 100644 --- a/client/dns/cache_test.go +++ b/client/dns/cache_test.go @@ -252,3 +252,31 @@ func TestCache_ServerDomain_NeverExpires(t *testing.T) { t.Fatal("regular domain entry expired before minCacheTTL") } } + +func TestJitterTTL_NeverExpire(t *testing.T) { + // TTL 0(服务器域名永不过期)必须原样返回,不受抖动影响 + if ttl := jitterTTL(0); ttl != 0 { + t.Errorf("jitterTTL(0) expected 0, got %d", ttl) + } + // 负数属于防御性边界,不应 panic 也不应被改动 + if ttl := jitterTTL(-1); ttl != -1 { + t.Errorf("jitterTTL(-1) expected -1, got %d", ttl) + } +} + +func TestJitterTTL_Range(t *testing.T) { + const base = 1800 + seen := make(map[int]bool) + for i := 0; i < 1000; i++ { + ttl := jitterTTL(base) + // 结果必须在 [base, 2*base) 区间内 + if ttl < base || ttl >= 2*base { + t.Fatalf("jitterTTL(%d) = %d, want in [%d, %d)", base, ttl, base, 2*base) + } + seen[ttl] = true + } + // 1000 次采样下结果应分散,而非固定同一个值 + if len(seen) < 2 { + t.Errorf("jitterTTL(%d) always returned %d, expected random jitter", base, base) + } +}