DNS 缓存
Xray 的 DNS 缓存通过 CacheController 结构体实现,每个服务器独立缓存。所有缓存命名服务器(UDP、TCP、DoH、DoQ)都拥有各自独立的缓存。缓存层负责 TTL 追踪、过期服务(serve-stale)、在途查询去重、pubsub 通知以及后台映射表压缩。
CacheController
文件: app/dns/cache_controller.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
}record 和 IPRecord 类型
文件: app/dns/dnscommon.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:
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() 函数实现了缓存优先策略:
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 防止重复的上游查询:
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 通知:
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() 启动查询时:
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() 函数随后等待这些订阅者:
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 结果:
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 秒运行一次:
func (c *CacheController) CacheCleanup() error {
expiredKeys, _ := c.collectExpiredKeys()
c.writeAndShrink(expiredKeys)
return nil
}过期键收集(读锁)
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() 执行实际删除并可选触发映射表压缩:
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 个)迁移条目:
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(旧映射表):
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)。