Skip to content

Кеширование DNS

Кеширование DNS в Xray реализовано для каждого сервера через структуру CacheController. Каждый кеширующий сервер имён (UDP, TCP, DoH, DoQ) имеет собственный независимый кеш. Уровень кеширования обрабатывает отслеживание TTL, обслуживание устаревших записей, дедупликацию запросов в полёте, уведомления 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
}

Типы record и IPRecord

Файл: 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 {
                    // CACHE HIT: fresh record
                    return ips, uint32(ttl), err
                }
                if cache.serveStale && (cache.serveExpiredTTL == 0 || cache.serveExpiredTTL < ttl) {
                    // CACHE OPTIMISTIC: stale but serveable
                    go pull(ctx, s, fqdn, option)  // background refresh
                    return ips, 1, err
                }
            }
        }
    }
    // CACHE MISS: fetch from upstream
    return fetch(ctx, s, fqdn, option)
}

Обслуживание устаревших записей

Когда включён serveStale:

  • Устаревшие записи возвращаются немедленно с TTL=1
  • Фоновая горутина (pull()) асинхронно обновляет запись
  • Поле serveExpiredTTL ограничивает, насколько далеко за пределы истечения запись может быть обслужена (0 = без ограничений)

serveExpiredTTL хранится как отрицательное значение int32 (например, -3600 означает «до 3600 секунд после истечения»). При сравнении TTL проверяется cache.serveExpiredTTL < ttl, где TTL уже отрицателен для устаревших записей.

Дедупликация запросов

Функция fetch() использует singleflight.Group для предотвращения дублирующих upstream-запросов:

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
}

Если несколько горутин одновременно запрашивают один и тот же домен, выполняется только один реальный upstream-запрос.

Поток обновления записей

Когда приходит 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 объединённого результата — это минимум из TTL записей A и AAAA, ограниченный dns.DefaultTTL (600 секунд).

Очистка кеша и сжатие карт

Файл: app/dns/cache_controller.go

Очистка выполняется каждые 300 секунд через task.Periodic:

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 — переопределить глобальную настройку

Настройки для каждого сервера имеют приоритет, если они заданы.

Замечания по реализации

  • Поле name в CacheController формируется из типа сервера и адреса (например, "UDP://8.8.8.8:53", "DOH//dns.google"). Оно отображается в сообщениях журнала.

  • Когда disableCache имеет значение true, updateRecord() всё равно публикует в pubsub (чтобы запросы в полёте получили ответы), но не сохраняет запись в карте.

  • Пороговые значения для сжатия:

    • minSizeForEmptyRebuild = 512 — перестраивать пустые карты, только если пиковое значение было не менее 512
    • shrinkAbsoluteThreshold = 10240 — должно быть освобождено не менее 10240 записей от пикового значения
    • shrinkRatioThreshold = 0.65 — должно быть освобождено не менее 65% от пиковых записей
  • singleflight.Group в requestGroup возвращает кешированные результаты всем параллельным вызывающим, эффективно дедуплицируя как промахи кеша, так и обновления устаревших записей.

  • Таймер очистки запускается лениво (только после вставки записи) и останавливается сам, когда карта пуста. Когда serveStale включён без serveExpiredTTL, очистка вообще не запускается (записи живут вечно, пока не будут вытеснены сжатием карты).

  • TTL в разобранных ответах использует минимальный TTL среди всех записей ответа, с нижней границей в 1 секунду (TTL=0 в DNS-ответах обрабатывается как TTL=1).

Технический анализ для целей повторной реализации.