Кеширование DNS
Кеширование DNS в Xray реализовано для каждого сервера через структуру CacheController. Каждый кеширующий сервер имён (UDP, TCP, DoH, DoQ) имеет собственный независимый кеш. Уровень кеширования обрабатывает отслеживание TTL, обслуживание устаревших записей, дедупликацию запросов в полёте, уведомления 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 {
// 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-запросов:
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:
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 объединённого результата — это минимум из TTL записей A и AAAA, ограниченный dns.DefaultTTL (600 секунд).
Очистка кеша и сжатие карт
Файл: app/dns/cache_controller.go
Очистка выполняется каждые 300 секунд через task.Periodic:
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— переопределить глобальную настройку
Настройки для каждого сервера имеют приоритет, если они заданы.
Замечания по реализации
Поле
nameвCacheControllerформируется из типа сервера и адреса (например,"UDP://8.8.8.8:53","DOH//dns.google"). Оно отображается в сообщениях журнала.Когда
disableCacheимеет значение true,updateRecord()всё равно публикует в pubsub (чтобы запросы в полёте получили ответы), но не сохраняет запись в карте.Пороговые значения для сжатия:
minSizeForEmptyRebuild = 512— перестраивать пустые карты, только если пиковое значение было не менее 512shrinkAbsoluteThreshold = 10240— должно быть освобождено не менее 10240 записей от пикового значенияshrinkRatioThreshold = 0.65— должно быть освобождено не менее 65% от пиковых записей
singleflight.GroupвrequestGroupвозвращает кешированные результаты всем параллельным вызывающим, эффективно дедуплицируя как промахи кеша, так и обновления устаревших записей.Таймер очистки запускается лениво (только после вставки записи) и останавливается сам, когда карта пуста. Когда
serveStaleвключён безserveExpiredTTL, очистка вообще не запускается (записи живут вечно, пока не будут вытеснены сжатием карты).TTL в разобранных ответах использует минимальный TTL среди всех записей ответа, с нижней границей в 1 секунду (TTL=0 в DNS-ответах обрабатывается как TTL=1).