Skip to content

DNS 缓存

Xray 的 DNS 缓存通过 CacheController 结构体实现,每个服务器独立缓存。所有缓存命名服务器(UDP、TCP、DoH、DoQ)都拥有各自独立的缓存。缓存层负责 TTL 追踪、过期服务(serve-stale)、在途查询去重、pubsub 通知以及后台映射表压缩。

CacheController

文件: app/dns/cache_controller.go

go
type CacheController struct {
    name            string
    disableCache    bool
    serveStale      bool
    serveExpiredTTL int32  // negative value: max seconds past expiry to serve

    ips      map[string]*record
    dirtyips map[string]*record  // used during map compaction

    sync.RWMutex
    pub           *pubsub.Service
    cacheCleanup  *task.Periodic
    highWatermark int
    requestGroup  singleflight.Group
}

recordIPRecord 类型

文件: app/dns/dnscommon.go

go
type record struct {
    A    *IPRecord
    AAAA *IPRecord
}

type IPRecord struct {
    ReqID     uint16
    IP        []net.IP
    Expire    time.Time
    RCode     dnsmessage.RCode
    RawHeader *dnsmessage.Header
}

每个域名有一个 record,分别包含 A(IPv4)和 AAAA(IPv6)条目。Expire 字段存储绝对过期时间(当前时间 + 响应中的最小 TTL)。

TTL 计算

从缓存读取时,IPRecord.getIPs() 计算剩余 TTL:

go
func (r *IPRecord) getIPs() ([]net.IP, int32, error) {
    if r == nil {
        return nil, 0, errRecordNotFound
    }
    untilExpire := time.Until(r.Expire).Seconds()
    ttl := int32(math.Ceil(untilExpire))

    if r.RCode != dnsmessage.RCodeSuccess {
        return nil, ttl, dns.RCodeError(r.RCode)
    }
    if len(r.IP) == 0 {
        return nil, ttl, dns.ErrEmptyResponse
    }
    return r.IP, ttl, nil
}

正的 TTL 表示记录仍然有效。零或负的 TTL 表示记录已过期。

缓存查找流程

文件: app/dns/nameserver_cached.go

queryIP() 函数实现了缓存优先策略:

go
func queryIP(ctx context.Context, s CachedNameserver, domain string, option dns.IPOption) ([]net.IP, uint32, error) {
    fqdn := Fqdn(domain)
    cache := s.getCacheController()

    if !cache.disableCache {
        if rec := cache.findRecords(fqdn); rec != nil {
            ips, ttl, err := merge(option, rec.A, rec.AAAA)
            if !errors.Is(err, errRecordNotFound) {
                if ttl > 0 {
                    // 缓存命中:记录仍有效
                    return ips, uint32(ttl), err
                }
                if cache.serveStale && (cache.serveExpiredTTL == 0 || cache.serveExpiredTTL < ttl) {
                    // 乐观缓存:已过期但可服务
                    go pull(ctx, s, fqdn, option)  // background refresh
                    return ips, 1, err
                }
            }
        }
    }
    // 缓存未命中:从上游获取
    return fetch(ctx, s, fqdn, option)
}

过期服务(Serve-Stale)

当启用 serveStale 时:

  • 过期记录立即返回,TTL 设为 1
  • 后台 goroutine(pull())异步刷新记录
  • serveExpiredTTL 字段限制记录过期后可服务的最长时间(0 表示无限制)

serveExpiredTTL 存储为负的 int32(例如 -3600 表示"过期后最多 3600 秒")。在 TTL 比较时,它检查 cache.serveExpiredTTL < ttl,其中过期记录的 TTL 已为负值。

请求去重

fetch() 函数使用 singleflight.Group 防止重复的上游查询:

go
func fetch(ctx context.Context, s CachedNameserver, fqdn string, option dns.IPOption) ([]net.IP, uint32, error) {
    key := fqdn + "46"|"4"|"6"  // keyed by domain + IP version
    v, _, _ := s.getCacheController().requestGroup.Do(key, func() (any, error) {
        return doFetch(ctx, s, fqdn, option), nil
    })
    ret := v.(result)
    return ret.ips, ret.ttl, ret.error
}

如果多个 goroutine 同时请求同一域名,只会发送一次实际的上游查询。

记录更新流程

当 DNS 响应到达时,updateRecord() 处理缓存插入和 pubsub 通知:

go
func (c *CacheController) updateRecord(req *dnsRequest, rep *IPRecord) {
    rtt := time.Since(req.start)

    // 1. Publish to waiting subscribers
    switch req.reqType {
    case dnsmessage.TypeA:
        c.pub.Publish(req.domain+"4", rep)
    case dnsmessage.TypeAAAA:
        c.pub.Publish(req.domain+"6", rep)
    }

    if c.disableCache { return }

    // 2. Merge with existing record
    c.Lock()
    newRec := &record{}
    oldRec := c.ips[req.domain]

    switch req.reqType {
    case dnsmessage.TypeA:
        newRec.A = rep
        if oldRec != nil && oldRec.AAAA != nil {
            newRec.AAAA = oldRec.AAAA  // preserve existing AAAA
        }
    case dnsmessage.TypeAAAA:
        newRec.AAAA = rep
        if oldRec != nil && oldRec.A != nil {
            newRec.A = oldRec.A  // preserve existing A
        }
    }
    c.ips[req.domain] = newRec
    c.Unlock()

    // 3. Cross-publish: if A arrives, also notify AAAA subscribers with cached data
    if pubRecord != nil && pubRecord has valid IPs {
        c.pub.Publish(req.domain+pubSuffix, pubRecord)
    }

    // 4. Start cleanup timer
    if !c.serveStale || c.serveExpiredTTL != 0 {
        c.cacheCleanup.Start()
    }
}

交叉发布步骤对于合并的 A+AAAA 查询非常重要:当同时请求两种记录类型时,先到达的响应也会发布缓存的另一种类型,这样订阅者就不必等待。

PubSub 机制

缓存使用 pubsub.Service 进行异步通知。当 doFetch() 启动查询时:

go
func (c *CacheController) registerSubscribers(domain string, option dns.IPOption) (*pubsub.Subscriber, *pubsub.Subscriber) {
    if option.IPv4Enable {
        sub4 = c.pub.Subscribe(domain + "4")
    }
    if option.IPv6Enable {
        sub6 = c.pub.Subscribe(domain + "6")
    }
    return
}

doFetch() 函数随后等待这些订阅者:

go
func doFetch(ctx context.Context, s CachedNameserver, fqdn string, option dns.IPOption) result {
    sub4, sub6 := s.getCacheController().registerSubscribers(fqdn, option)
    defer closeSubscribers(sub4, sub6)

    noResponseErrCh := make(chan error, 2)
    s.sendQuery(ctx, noResponseErrCh, fqdn, option)

    // Wait for either: context cancel, transport error, or pubsub message
    rec4, err4 := onEvent(sub4)
    rec6, err6 := onEvent(sub6)

    ips, ttl, err := merge(option, rec4, rec6, errs...)
    return result{ips, rTTL, err}
}

合并 A 和 AAAA 记录

merge() 函数合并 IPv4 和 IPv6 结果:

go
func merge(option dns.IPOption, rec4 *IPRecord, rec6 *IPRecord, errs ...error) ([]net.IP, int32, error) {
    mergeReq := option.IPv4Enable && option.IPv6Enable

    // If only one type requested, return it directly
    // If both requested, combine IPs and use minimum TTL
    // If one has IPs and the other doesn't, return what we have
    // If neither has IPs, return combined errors
}

合并结果的 TTL 是 A 和 AAAA TTL 的最小值,上限为 dns.DefaultTTL(600 秒)。

缓存清理与映射表压缩

文件: app/dns/cache_controller.go

清理通过 task.Periodic 每 300 秒运行一次:

go
func (c *CacheController) CacheCleanup() error {
    expiredKeys, _ := c.collectExpiredKeys()
    c.writeAndShrink(expiredKeys)
    return nil
}

过期键收集(读锁)

go
func (c *CacheController) collectExpiredKeys() ([]string, error) {
    c.RLock()
    defer c.RUnlock()
    // Skip if migration in progress
    if c.dirtyips != nil { return nil, nil }
    // Collect domains where A or AAAA has expired
    // If serveStale with serveExpiredTTL, adjust "now" accordingly
}

写入与收缩(写锁)

收集过期键后,writeAndShrink() 执行实际删除并可选触发映射表压缩:

go
func (c *CacheController) writeAndShrink(expiredKeys []string) {
    c.Lock()
    defer c.Unlock()

    // Delete expired individual records (A or AAAA)
    // Delete the domain entry entirely if both are nil

    // Shrink decision:
    // If map is now empty and highWatermark >= 512: rebuild empty map
    // If reduction from peak > 10240 AND > 65% of peak: background migrate
}

后台迁移

当映射表显著缩小时,会创建新的较小映射表并分批(每批 4096 个)迁移条目:

go
func (c *CacheController) migrate() {
    batch := make([]migrationEntry, 0, 4096)
    for domain, rec := range c.dirtyips {
        batch = append(batch, migrationEntry{domain, rec})
        if len(batch) >= 4096 {
            c.flush(batch)
            runtime.Gosched()  // yield to other goroutines
        }
    }
    c.Lock()
    c.dirtyips = nil
    c.Unlock()
}

在迁移期间,findRecords() 同时检查 c.ips(新映射表)和 c.dirtyips(旧映射表):

go
func (c *CacheController) findRecords(domain string) *record {
    c.RLock()
    defer c.RUnlock()
    rec := c.ips[domain]
    if rec == nil && c.dirtyips != nil {
        rec = c.dirtyips[domain]
    }
    return rec
}

flush() 方法合并条目,优先使用 c.ips 中较新的数据而非 c.dirtyips 中的旧数据。

缓存配置

缓存行为在两个级别控制:

全局(DNSConfig):

  • disableCache -- 禁用所有服务器的缓存
  • serveStale -- 为所有服务器启用过期服务
  • serveExpiredTTL -- 过期后可服务的最大秒数

逐服务器(NameServerConfig):

  • disableCache -- 覆盖此服务器的全局设置
  • serveStale -- 覆盖全局设置
  • serveExpiredTTL -- 覆盖全局设置

逐服务器设置在非 nil 时优先生效。

实现要点

  • CacheController 的 name 字段由服务器类型和地址派生(例如 "UDP://8.8.8.8:53""DOH//dns.google")。它出现在日志消息中。

  • disableCache 为 true 时,updateRecord() 仍会向 pubsub 发布(使在途查询能获得响应),但不会将记录存储在映射表中。

  • 收缩阈值为:

    • minSizeForEmptyRebuild = 512 -- 仅在峰值至少为 512 时重建空映射表
    • shrinkAbsoluteThreshold = 10240 -- 必须从峰值释放至少 10240 个条目
    • shrinkRatioThreshold = 0.65 -- 必须释放峰值条目的至少 65%
  • requestGroup 中的 singleflight.Group 将缓存结果返回给所有并发调用者,有效去重缓存未命中和过期刷新。

  • 清理定时器惰性启动(仅在记录插入后),映射表为空时自行停止。当 serveStale 为 true 且无 serveExpiredTTL 时,不启动清理(记录永远存在,直到被映射表收缩淘汰)。

  • 解析响应中的 TTL 使用所有应答记录的最小 TTL,下限为 1 秒(DNS 响应中的 TTL=0 被视为 TTL=1)。

用于重新实现目的的技术分析。