基于Go自研DNS调度系统Ax调度:从原理到代码实战
我正在负责一个对外提供解析服务的项目域名量不大但客户分布在全国各地运营商也杂。早先用的是传统DNS解析方式A记录直接配置结果经常收到客户反馈北方用户访问慢、某个运营商全网超时、半夜现网流量莫名其妙全部打到同一台机器上。排查半天发现根子上就一个问题——解析没有调度能力IP写死了用户去哪全凭运气。后来我把这套解析服务整体重写了一遍核心思路就是做一个可编程的调度层我给它取名Ax调度。简单说Ax调度负责在收到解析请求的那一刻根据用户来源区域、运营商、节点健康状态、实时负载动态决定把域名解析到哪个节点IP。它不是一个独立的DNS软件而是DNS解析前面的一层大脑。这篇文章就把我实现Ax调度的完整过程写出来包括为什么这么设计、Go代码里的核心逻辑、调度策略怎么选以及上线后踩过的几个坑。适合正在自建DNS解析、CDN调度、或者做边缘接入层的同学参考不是科普文都是能直接落到代码和配置里的东西。1. 为什么需要Ax调度从域名解析的混乱说起1.1 传统解析的三个痛点先不带任何概念直接看问题。早期我们一个域名在现网配置了三个A记录对应华北、华东、华南三台入口机器。用户请求进来系统按DNS轮询的逻辑把流量散到三个IP上。结果是什么华南用户访问时被分到华北节点跨网绕路延迟从10ms飙到50ms华东一台机器故障了但DNS里还留着它的记录按照轮询策略依然有一批用户会被指向故障IP直到人工把记录摘掉。更麻烦的是运营商调度完全没法做——电信用户和联通用户访问路径差异巨大但传统DNS根本不知道谁是电信谁是联通。这个就是最核心的痛点DNS环节缺少感知既感知不到用户是谁也感知不到节点是不是活着更感知不到当前网络状态。传统DNS给你返回的是一个静态答案它对答案的质量漠不关心。1.2 Ax调度到底调度什么很多人一听调度就以为只是换IP其实Ax调度做的事情可以拆成三块第一块是空间调度。根据用户来源IP归属地和运营商把它导向最近或者最合适的节点。注意最合适不等于最近比如某个近节点带宽已经打满那宁可让它去一个稍远但空闲的节点。第二块是健康调度。每个节点需要实时上报自己的存活状态和业务状态调度层根据这些状态把故障节点从候选列表里摘除并且这个过程是自动的不用等人工去改DNS记录。第三块是流量调度。控制每个节点承接的流量比例。比如新上线一个节点我想先把5%的流量切过去观察两天这就是灰度某个节点运营商侧线路抖动我要快速把它的流量切走这就是容灾。这三块合在一起才是完整的Ax调度。单纯做一个DNS服务器不难难的是让DNS返回的每一条记录都是当前最该返回的那一条。1.3 我为什么没有直接用开源方案做之前我也考虑过直接用现成的调度系统或者商业CDN的调度能力但最终决定自己实现理由有几点。商业CDN的调度对我们是黑盒客户问为什么解析到这个节点我们拿不出来依据客服完全没法解释。开源方案里有的重在新节点调度有的重在线路切分但是没有一个能完全贴合我们的场景——我们既要支持DNS侧A记录返回又要支持HTTP侧303跳转还要能按钮式手动切流量。还有一个实际原因我们的节点数量不大一共十几个边缘入口调度逻辑可以做得非常明确不需要复杂的机器学习一套规则引擎加一套健康检查就够了。在这种规模下自己写的系统比套一个重型框架反而更容易维护、更容易排错。注意如果你们的节点规模到了几百上千我建议还是认真评估成熟的流量调度产品。自研调度适合的是规则清晰、规模可控、定制需求多的场景规模大了之后调度本身的容错就变成了一个巨大的工程问题。2. 第一版Ax调度的关键设计分层和数据结构先想清楚代码还没写之前我花了两天时间把整个系统拆成了四个模块。这个阶段不能急想不清楚结构后面改起来全是泪。2.1 调度器、决策引擎、下发通道、探测模块的边界我最终把系统拆成了四层调度器负责统筹全局状态决定当前应该怎么调度是整个系统的入口。决策引擎纯粹的规则计算模块输入是用户信息节点状态输出是候选节点列表及其权重。下发通道把决策结果应用到实际入口上。对于DNS场景就是动态生成解析记录返回给用户对于HTTP场景就是返回一个302跳转地址对于四层代理场景就是更新负载均衡后端列表。探测模块负责采集每个节点的健康状态、负载、时延等数据持续喂给决策引擎。这个拆分最重要的价值在于决策引擎是无状态的。它不关心自己是被DNS触发、被HTTP触发还是被手动按钮触发只要输入同样的状态数据输出就是一致的。这样整个系统的可测试性大大增强我可以在测试环境里直接喂一组伪造的节点状态验证调度决策是否符合预期。2.2 数据模型区域-运营商-节点三级映射调度系统最核心的数据结构不是某个配置文件而是一张映射表。我的设计是三级的第一级是用户IP到区域运营商的映射。这一步依赖IP归属库。我用的方案是定期从外部更新一份网段归属数据加载到内存里同时做了一层本地缓存避免每个请求都去查数据库。第二级是区域运营商到节点集合的映射。比如华北-电信这个组合对应的是bj-01、tj-01这两个节点华东-联通对应sh-01、hz-01。这一层是静态的是人工规划好的容量拓扑。第三级是每个节点的实时状态映射。节点当前带宽使用率、存活状态、当前承载的会话数这些是动态的每几秒刷新一次。调度决策就是在这三级映射上叠加权重计算。抽象成Go结构体的话核心就这几个type RegionInfo struct { RegionID string json:region_id // 例如 cn-north ISP string json:isp // 例如 telecom / unicom / mobile NodeIDs []string json:node_ids // 可接的节点ID列表按优先级排序 } type NodeStatus struct { NodeID string json:node_id RegionID string json:region_id ISP string json:isp Alive bool json:alive BandwidthMB int json:bandwidth_mb // 当前带宽占用MB/s ConnCount int json:conn_count // 当前会话数 LastUpdate int64 json:last_update // 最近一次上报时间 } type RouteTable struct { Version int json:version Generated int64 json:generated Mappings map[string][]*NodeWeight json:mappings }怎么样判断三层映射是不是合理一个简单标准任何一次调度决策都能用用户属于某个区域运营商 - 候选节点列表 - 按当前状态加权排序这个链路解释清楚。如果哪个环节绕不回来说明设计里面混入了不该有的东西。2.3 为什么缓存和TTL必须单独设计这一块是我最开始忽略、后来吃过大亏的地方。DNS解析天生自带缓存机制本地递归DNS会把解析结果缓存一段时间TTL决定。如果你的调度系统每秒钟都在变但用户侧递归DNS缓存着老结果那调度做得再精细也是白搭。所以Ax调度里的TTL不是随便填一个300而是分了三个场景场景TTL建议值原因正常稳态调度300秒平衡缓存生效和调度生效速度节点故障切换60秒需要快速把流量从故障节点摘走灰度切换120秒让新节点逐步承接流量避免瞬断再说缓存。调度结果不能每次都去全量计算特别是峰值的时候每秒解析量很大。我在调度层做了一个两层缓存第一层是内存里的路由表每次触发调度事件后全量生成一份快照所有解析请求直接读快照第二层是单条解析结果的短期缓存热点域名几秒内直接返回内存对象不加锁、不查规则引擎。这个设计的附带好处是故障时可以做调度回滚——保留上一份路由表快照发现问题就秒级切回老版本。3. Go实现Ax调度核心逻辑代码怎么落地设计想清楚之后写代码反而很快。我选Go是有明确原因的部署单文件、并发模型契合高并发解析场景、内存管理足够可控。下面把几个核心模块的核心逻辑讲清楚。3.1 调度器主循环调度器是一个常驻的goroutine工作模式是事件驱动加定时补偿。所谓事件驱动就是当节点健康状态变化、负载超过阈值、或运营人员手动触发切换时立刻执行一次调度计算定时补偿则是兜底比如每30秒全量算一次防止事件漏掉。func (s *Scheduler) Run(ctx context.Context) { ticker : time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case -ctx.Done(): return case evt : -s.eventChan: s.handleEvent(evt) case -ticker.C: s.recomputeAll() } } } func (s *Scheduler) handleEvent(evt SchedulerEvent) { switch evt.Type { case EventNodeDown: s.markNodeDown(evt.NodeID) s.triggerRecompute(evt.NodeID) case EventManualSwitch: s.applyManualRule(evt.Rule) case EventLoadReport: s.updateNodeLoad(evt.NodeStatus) } } func (s *Scheduler) triggerRecompute(nodeID string) { s.routeTable s.evaluator.BuildRouteTable(s.nodeRegistry.Snapshot()) s.routeTable.Version s.distributor.PushRouteTable(s.routeTable) }这里有个小细节routeTable的更新不能直接覆盖而是用不可变快照的方式。Go里面这就意味着旧的路由表对象只要没人引用GC会自动回收但DNS查询goroutine在读取的时候拿到的一定是一份完整的、一致的快照不会读到一半被改写。3.2 决策引擎权重计算决策引擎是最核心的部分它做的事情很简单给定用户区域和运营商从候选节点里算出每个节点的权重然后返回排序结果。func (e *Evaluator) Evaluate(req ResolveRequest) []*NodeWeight { candidates : e.router.GetCandidates(req.RegionID, req.ISP) if len(candidates) 0 { // 兜底全量节点避免出现无解 candidates e.router.GetAllNodes() } results : make([]*NodeWeight, 0, len(candidates)) for _, node : range candidates { status : e.registry.Get(node.NodeID) if status nil || !status.Alive { continue } score : e.baseScore(node, req) if status.BandwidthMB node.MaxBandwidthMB { score - 30 // 超过带宽上限惩罚 } if status.ConnCount node.MaxConn { score - 20 // 超过连接数上限进一步惩罚 } // 时延表现不好的节点降权 score - int(status.LatencyMS / 10) results append(results, NodeWeight{ NodeID: node.NodeID, Weight: score, }) } sort.Slice(results, func(i, j int) bool { return results[i].Weight results[j].Weight }) return results }这个权重计算看起来简单但有个关键点每一个惩罚项都不能直接一票否决除非节点完全不健康。原本我打算负载超过阈值就直接不返回结果实测下来发现一个问题如果所有节点都超限大促的时候那这个域名就解析不出结果了等于全网故障。所以我全部改成降权模式保留兜底能力。选择权重最高的节点作为解析结果也意味着调度要尽量降低抖动。后面第五节我会专门讲抖动问题的处理。3.3 DNS侧接入改造CoreDNS插件成本最低调度计算出来了怎么让用户的DNS请求拿到结果商业做法是自己实现一套权威DNS服务器但那是大工程。我的做法是在CoreDNS上做了一层插件把Ax调度当成一个特殊的后端。CoreDNS的插件机制允许你在ServeDNS的时刻介入。我的插件逻辑是收到解析请求后先从解析上下文提取用户来源IPEDNS Client Subnet如果没有就用UDP源地址然后把来源IP传给调度器拿到候选节点最后把节点IP拼成DNS响应返回。func (p *AxPlugin) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { qname : r.Question[0].Name if p.routeTable nil { return plugin.NextOrFailure(p.Name(), p.next, ctx, w, r) } clientIP : getClientIP(w, r) region, isp : p.geo.Lookup(clientIP) nodes : p.evaluator.Evaluate(ResolveRequest{ Domain: qname, RegionID: region, ISP: isp, ClientIP: clientIP, }) if len(nodes) 0 { return dns.RcodeServerFailure, nil } rr : dns.A{ Hdr: dns.RR_Header{ Name: qname, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60, }, A: net.ParseIP(nodes[0].IP).To4(), } m : new(dns.Msg) m.SetReply(r) m.Answer []dns.RR{rr} w.WriteMsg(m) return dns.RcodeSuccess, nil }TTL这里我用了60秒因为调度系统本身生效要快不能因为TTL导致用户侧缓存太长而无法感知调度变化。如果你的场景对解析缓存敏感度不高可以适当提高到300。3.4 HTTP侧接入302跳转是老方案但依然好用DNS解析之外我们还做了一种HTTP接入方式终端用户请求一个固定入口域名服务端根据来源IP计算调度结果直接返回302 Location到对应节点。代码上就是给调度层封装一个HTTP Handlerfunc (h *HttpSwitchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { clientIP : parseRemoteIP(r) region, isp : h.geo.Lookup(clientIP) nodes : h.evaluator.Evaluate(ResolveRequest{...}) if len(nodes) 0 { http.Error(w, no available node, http.StatusServiceUnavailable) return } // 灰度规则如果目标节点是灰度节点动态增加一个独立域名作为跳转目标 target : buildTargetURL(nodes[0], r) http.Redirect(w, r, target, http.StatusFound) }HTTP方式的好处是可以附带更多上下文比如浏览器UA、用户自定义参数调度规则可以更细。坏处是比DNS方式多一跳但胜在灵活。两种接入方式共用同一套调度器和决策引擎这是当时设计定下来的原则。4. 调度策略选型从贪心到归一化的演进4.1 五种策略的对比Ax调度里我先后实现了五种策略区域优先、权重随机、最少连接、一致性哈希、时延感知。我不建议一上来就追求复杂先把基础策略做扎实后面再慢慢叠加。策略核心指标优点缺点适用场景区域优先用户区域运营商就近实现简单延迟低不考虑节点负载节点资源充足、流量平稳权重随机静态权重当前负载流量分配平滑可能把请求打到较远节点多节点容量不一最少连接当前活跃连接数负载均衡效果好需要精确的连接统计长连接场景一致性哈希用户标识哈希会话保持稳定节点变化时迁移大需要会话保持的场景时延感知最近探测时延直接优化用户体验对探测频率要求高线路质量波动明显时我的线上策略是区域优先兜底时延加权修正。原理简单说先用区域运营商过滤出候选节点集合保证不跨大区、不跨主干运营商然后在这个集合内用节点健康度、当前负载、时延三个维度做归一化加权排序。这里有个关键认知调度不求最优解求的是满意解。如果你执着于把每个请求都调到全网最低延迟那么系统会频繁变化反而导致路由抖动、缓存失效、连接断开。我的经验是候选节点排名前两位的差距如果不超过15%那就保持当前结果不变不要盲目切换。4.2 时延探测怎么做得准时延感知的前提是探测数据可靠。我这里单独讲一下因为很容易踩坑。最初我用的是边缘节点主动向中心探测节点发ICMP但很快发现一个典型问题不同地区、不同运营商之间的探测结果差异极大而且ICMP的探测路径和TCP业务路径不完全一致经常出现ping值很低但建连很慢的情况。后来改成双重探测机制探测节点周期性地对每个边缘节点发起TCP connect探测记录建连时延和TLS握手时延。调度层结合历史探测结果做滑动窗口平均不采用单次值。type LatencyWindow struct { samples []float64 size int } func (w *LatencyWindow) Add(v float64) { w.samples append(w.samples, v) if len(w.samples) w.size { w.samples w.samples[1:] } } func (w *LatencyWindow) Avg() float64 { if len(w.samples) 0 { return 0 } sum : 0.0 for _, v : range w.samples { sum v } return sum / float64(len(w.samples)) }窗口大小我设置在5到10次之间。窗口太小噪音多窗口太大反应慢。如果你对实时性要求更高可以在滑动平均基础上再乘一个实时衰减系数让最新的探测数据占比大一些。4.3 我实测的一组调度效果数据上线Ax调度后我拿一个客户域名的解析情况做了对比。客户是华南某电商小程序域名主要流量来源是广东、广西、福建。传统解析三个节点A记录轮询解析结果分布几乎是33%/33%/34%无论用户在哪。使用Ax调度后按区域归属统计区域主要命中节点解析占比平均TCP建连时延变化广东深圳sz-0161%从18ms降至6ms广东广州sz-01 / gz-0158% / 33%从21ms降至8ms广西南宁gz-0172%从35ms降至17ms福建福州fz-0168%从29ms降至14ms跨区域兜底按负载最空闲节点5%以下未明显劣化可以看到大部分流量被收敛到就近节点跨区域兜底的流量控制在5%以下用来消化节点故障或突增流量。整体用户体验的感知是打开变快了因为减少了跨地域的RTT。5. 上线三个月踩过的坑健康检查、地址库与调度抖动第三部分全是复盘也是我觉得最有价值的内容。这些都是运行中出现真实问题后才总结出来的写下来给后来的人避坑。5.1 健康检查不能只探活要探可用性上线初期健康检查逻辑很简单每10秒探测一次节点TCP能连上就是存活。结果有一次节点因为业务层死锁TCP端口还能连上但HTTP请求全部超时。我们的调度系统还认为节点健康照常给它分配流量客户侧立即出现大量5xx。教训健康检查必须分层。L3层Ping通不代表可用。L4层TCP端口可连不代表业务正常。L7层HTTP探测返回200且响应延迟在阈值内才算可用。我后来给Ax调度写了一个L7可用性判据探测请求要带特定的路径比如/healthz需要返回200且完整响应时间小于2秒。连续三次失败才标记节点不可用避免偶发超时导致误摘除。摘除后的节点只有当连续探测成功5次后才恢复防止抖动。5.2 IP归属库的冷数据和热数据问题IP归属库的更新频率永远是调度的命门。有一次某运营商的IP段大范围调整但我们的归属库还是老的结果华东几百个C段IP被解析到华南节点客服被客户投诉淹没。处理办法是我在调度层加了一层归属库可信度机制如果某个IP段在近期实际流量中的行为数据和归属库不一致比如大量请求来自一个本该归属北方的IP段但它的实际TCP建连延迟极低那么就把这个IP段标记为疑似归属变更自动降级为就近兜底调度而不是按库调度。这个思路本质上就是给静态归属数据加一层动态校验。具体实现上可以在每次解析决策后记录一条用户IP段与最终延迟的信息定期离线分析并生成覆盖规则优先级高于原始归属库。5.3 调度结果抖动权重调整必须带滞回区间这是所有调度系统都要面对的问题调度结果频繁变化。现象是用户第一次解析到A节点刷新一下又解析到B节点过一会又回到A节点。造成这个现象的原因是权重计算里负载和时延的实时变化太灵敏微小的负载波动就让排序反转。我的解决办法是引入滞回区间func (e *Evaluator) shouldSwitch(current *NodeWeight, best *NodeWeight) bool { if best nil { return false } // 当前节点依然健康且权重差距不超过阈值保持稳定 if current.Weight20 best.Weight { return false } return true }阈值20是经验值你可以根据节点间的容量差异调整。原则是新结果比当前结果显著更优时才切换相近就保持原状。这会让短期的负载均衡效果差一点但换来的是用户侧稳定性和缓存命中率大幅提升。5.4 新节点上线灰度手动规则先于自动规则Ax调度里我设计了一个规则优先级机制手动规则 灰度规则 自动规则。新节点上线时我会在调度系统里配置一条灰度规则比如华东电信区域5%流量切到新节点系统会自动在候选集合里把新节点权重调整到对应比例不会被自动均衡逻辑吞掉。这个设计在代码上就是一个规则表加载顺序固定type Rule struct { Priority int // 0手动, 1灰度, 2自动 RegionID string ISP string NodeID string Weight int } func (e *Evaluator) ApplyRules(baseResults []*NodeWeight, rules []Rule) []*NodeWeight { for _, rule : range rules { if rule.NodeID { continue } for _, res : range baseResults { if res.NodeID rule.NodeID { res.Weight rule.Weight } } } return baseResults }灰度规则里有个额外细节新节点进入权重表后要让它先承接少量健康检查流量确认无误后再按比例上调。不然一个大版本切换直接在现网炸掉灰度就没意义了。我个人最想强调的一点Ax调度这个系统真正难的不是代码而是当结论足够重要的调度决策出现时系统能不能给出理由。6. 给正在自研调度的你一些最后建议调度系统上线到现在稳定运行了大半年。如果让我重新做一遍有几个决策我会提前做而不是踩完了才补。第一接口设计从第一天就要考虑可观测性。我当时没在调度器内部埋足够的日志结果分析线上问题时只能从DNS query日志反推效率低得让人抓狂。现在每个调度决策都会产出一条结构化日志包含用户IP、归属区域、运营商、候选节点列表、权重排序结果、最终命中节点、命中原因线上排障直接从日志搜就行。第二调度系统一定要支持手动兜底。哪怕自动调度再聪明也总有它判断不了的情况。Ax调度里我保留了一个运维开关可以针对某个域名、某个区域、某个运营商强制指定节点优先级最高。这不是倒退这是工程上的保险丝。第三别急着上复杂策略。先把区域就近和健康摘除做好网络质量和用户体验就能提升一大截。时延感知这类动态策略是在基础稳定之后一点点加的加一个验证一个不要一次全上出了问题你连是哪个策略导致的都分不清。最后分享一个小技巧我给每个节点的探测请求都带了独立的tokentoken既能标识来源又能在节点侧形成访问控制白名单。这样即使调度层的探测地址被扫描到攻击者也伪造不了健康状态上报。这种细节决定了你的调度系统在真实攻击或者异常流量场景下能不能扛住。