التخزين المؤقت لـ 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
- تُحدّث 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
}إذا طلبت عدة goroutines نفس النطاق في وقت واحد، يتم إجراء استعلام واحد فقط فعلي للخادم.
تدفق تحديث السجلات
عند وصول استجابة 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-- تجاوز الإعداد العام
إعدادات الخادم الفردي لها الأسبقية عندما تكون غير 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).