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
  • تُحدّث 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
}

إذا طلبت عدة goroutines نفس النطاق في وقت واحد، يتم إجراء استعلام واحد فقط فعلي للخادم.

تدفق تحديث السجلات

عند وصول استجابة 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 -- تجاوز الإعداد العام

إعدادات الخادم الفردي لها الأسبقية عندما تكون غير nil.

ملاحظات التنفيذ

  • حقل اسم CacheController مُشتق من نوع الخادم والعنوان (مثلاً، "UDP://8.8.8.8:53"، "DOH//dns.google"). يظهر هذا في رسائل السجل.

  • عندما يكون disableCache صحيحًا، لا تزال 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).

تحليل تقني لأغراض إعادة التنفيذ.