Ruby Async::DNS解析器:自定义hosts文件与并行查询实现

来源:MongoDB教程作者:松松建站头衔:草根站长
导读:本期聚焦于松松建站创作的《Ruby Async::DNS解析器:自定义hosts文件与并行查询实现》,敬请观看详情。如何让Ruby项目拥有一个既支持本地hosts覆盖、又能并行查询多个上游DNS的解析器?本文基于Async::DNS给出完整实现方案。文章先分析标准库Resolv在并发场景下的不足,再讲解自定义hosts文件的加载与匹配策略,包括通配符域名和热更新处理,随后利用async事件循环同时向多个DNS服务器发起请求并取最快结果,附性能对比分析。最后覆盖超时控制、异常降级和TTL缓存设计,所有代码均可直接运行,适合需要在Ruby中构建可控域名解析能力的开发者参考。

在搭建内网测试环境或做服务治理时,经常需要让程序优先使用自定义的hosts映射,而未被覆盖的域名则走正常DNS解析。Ruby标准库的Resolv虽然可用,但它是阻塞式设计,在高并发场景下性能吃力,而且要实现hosts覆盖逻辑得自己 hack 一番。Async::DNS基于async事件循环构建,天然支持非阻塞IO,配合async生态的并发原语,可以轻松实现自定义hosts文件优先匹配、多个上游DNS并行查询取最快结果等高级特性。本文将从零开始,完整实现一个生产可用的自定义DNS解析器。

为什么选择Async::DNS而不是标准库Resolv

Ruby内置的Resolv库在日常简单场景下够用,但它存在几个明显短板。第一,它是同步阻塞的,每次调用Resolv.getaddress都会阻塞当前线程直到响应返回或超时,如果要在并发环境下使用,只能靠多线程硬撑,线程切换开销和内存占用都会随并发量上升。第二,Resolv的可定制性有限,想插入一层本地hosts覆盖逻辑、或控制向哪些DNS服务器发起请求、如何处理超时重试,都需要继承并重写不少内部方法,代码侵入性强。第三,标准库对IPv6、DNSSEC等现代特性的支持不够完整。

Async::DNS则完全不同。它构建在async事件循环之上,所有网络IO均为非阻塞,单个线程内可以同时挂起成千上万个DNS查询,资源开销极低。它的Resolver类原生支持配置多个上游服务器、自定义超时时间和重试策略。更关键的是,解析流程是开放可扩展的,我们可以在查询链最前面插入自定义hosts查找逻辑,命中则直接返回,未命中再交给上游,这正是本文实现的基础。此外,async生态提供了Async::BarrierAsync::Condition等并发原语,让多上游并行查询的实现变得非常自然。

实现自定义hosts文件的加载与匹配

自定义hosts解析的核心思路是:在查询发往上游DNS之前,先在本地hosts记录中查找,命中就直接返回并附带一个较短的TTL,未命中则走上游解析。一个实用的hosts存储还应支持通配符域名,例如*.internal.ipipp.com可以匹配所有子域名,这在配置整个内网测试域时非常方便。

下面是hosts文件的加载与匹配实现,包含精确匹配、通配符匹配和线程安全的热更新支持:

require 'set'

class HostsStore
  def initialize(path)
    @path = path
    @exact = {}      # 精确匹配表: hostname => ip
    @wildcards = []  # 通配符规则: [正则, ip]
    @lock = Mutex.new
    @mtime = nil
    reload
  end

  # 解析hosts文件,每行格式: IP 域名1 域名2 ...
  def reload
    return unless File.exist?(@path)
    mtime = File.mtime(@path)
    return if @mtime == mtime  # 文件未变化,跳过重载

    exact = {}
    wildcards = []
    File.readlines(@path, chomp: true).each do |line|
      line = line.strip
      next if line.empty? || line.start_with?('#')
      parts = line.split(/\s+/)
      ip = parts[0]
      parts[1..].each do |host|
        if host.include?('*')
          # 将 *.example.ipipp.com 转为正则 ^[^.]+\.example\.ipipp\.com$
          pattern = host.downcase.gsub(/\./, '\\.')
          pattern = pattern.gsub('*', '[^.]+')
          wildcards << [/\\A#{pattern}\\z/, ip]
        else
          exact[host.downcase] = ip
        end
      end
    end
    @lock.synchronize do
      @exact = exact
      @wildcards = wildcards
      @mtime = mtime
    end
  end

  # 查询域名,返回ip或nil
  def lookup(hostname)
    reload  # 惰性检测文件变化,hosts文件很小,开销可忽略
    name = hostname.downcase.sub(/\.\z/, '')  # 去掉末尾的点
    @lock.synchronize do
      return @exact[name] if @exact.key?(name)
      @wildcards.each do |regex, ip|
        return ip if regex.match?(name)
      end
    end
    nil
  end
end

这段代码有几个细节值得注意。域名统一做了小写转换并去掉末尾的点,因为DNS规范允许域名以点结尾,而hosts文件中通常不带。通配符转正则时,只允许*匹配单段内容(即不含点),这样*.internal.ipipp.com不会错误匹配a.b.internal.ipipp.com,行为与常见DNS通配符语义一致。热更新采用惰性检测策略,每次查询时检查文件修改时间,避免额外的后台线程。

接下来把这个HostsStore接入Async::DNS的解析流程,形成分层解析器:

require 'async/dns'

class LayeredResolver
  def initialize(hosts_path, servers)
    @hosts = HostsStore.new(hosts_path)
    @upstream = Async::DNS::Resolver.new(servers.map { |h, p| [[:udp, h, p], [:tcp, h, p]] })
  end

  # 先查自定义hosts,未命中再查上游
  def addresses_for(hostname)
    if (ip = @hosts.lookup(hostname))
      return [Resolv::IPv4.create(ip)]
    end
    @upstream.addresses_for(hostname)
  end
end

Async do |task|
  servers = [['8.8.8.8', 53], ['223.5.5.5', 53]]
  resolver = LayeredResolver.new('./custom_hosts', servers)

  addrs = resolver.addresses_for('api.internal.ipipp.com')
  puts "hosts命中: #{addrs.first}"
end

hosts来源的记录建议返回较短的TTL(比如5到30秒),这样修改hosts文件后最多等一个TTL周期就能生效。上面的示例为简洁直接返回无过期概念的结果,实际项目中可以配合后文的缓存层补充TTL语义。

多上游DNS并行查询与性能对比

Async::DNS的Resolver接受多个服务器地址,但默认行为是按顺序尝试,第一个超时后才切换到下一个。如果某个上游服务器响应慢或宕机,每次解析都要白白等一个超时周期。更优的策略是同时向所有上游发起查询,谁先返回就用谁的结果。在传统线程模型里这需要开多个线程再加锁竞争第一个结果,而在async事件循环里,实现非常简洁。

利用Async::ConditionAsync::Barrier实现先到先得:

require 'async'
require 'async/dns'

class RacingResolver
  Result = Struct.new(:addresses, :error)

  def initialize(servers, timeout: 2.0)
    @resolvers = servers.map do |host, port|
      Async::DNS::Resolver.new([[[:udp, host, port]]])
    end
    @timeout = timeout
  end

  # 并行查询所有上游,返回第一个成功结果
  def addresses_for(hostname)
    condition = Async::Condition.new
    barrier = Async::Barrier.new
    winner = nil

    @resolvers.each do |resolver|
      barrier.async(parent: barrier) do
        begin
          addrs = resolver.addresses_for(hostname)
          unless addrs.empty? || winner
            winner = addrs
            condition.signal
          end
        rescue StandardError => e
          # 单个上游失败不影响整体,记录即可
          warn "上游查询失败: #{e.class}: #{e.message}"
        end
      end
    end

    # 等待第一个成功信号或整体超时
    task = Async::Task.current
    waiter = task.async { condition.wait }
    begin
      waiter.wait_with_timeout(@timeout)
    ensure
      barrier.stop  # 取消所有未完成的查询任务
    end

    winner or raise Async::DNS::ResolutionFailure,
      "所有上游均未在 #{@timeout}s 内返回 #{hostname}"
  end
end

servers = [['8.8.8.8', 53], ['1.1.1.1', 53], ['223.5.5.5', 53]]

Async do |task|
  resolver = RacingResolver.new(servers)
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  addrs = resolver.addresses_for('www.example.ipipp.com')
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
  puts "解析结果: #{addrs.first},耗时 #{elapsed.round(3)}s"
end

并行查询的收益在弱网环境或某个上游不稳定时最为明显。假设三个上游中有一台宕机,串行模式下最坏要等第一个超时(比如3秒)才能轮询到健康节点;并行模式下整体耗时约等于最快健康节点的响应时间,通常在几十到几百毫秒。代价则是每次解析的查询量放大N倍,对DNS服务器有一定压力。如果解析量大,可以折中处理:只并行查询两个最稳定的上游,或者在首轮串行失败后再升级为并行重试。

另一个值得做的优化是给并行结果套一层带TTL的本地缓存,减少重复查询:

class TTLCache
  def initialize
    @store = {}
    @lock = Mutex.new
  end

  def fetch(hostname, ttl)
    @lock.synchronize do
      entry = @store[hostname]
      return entry[:value] if entry && entry[:expires_at] > Time.now
    end
    value = yield
    @lock.synchronize do
      @store[hostname] = { value: value, expires_at: Time.now + ttl }
    end
    value
  end
end

# 使用示例:缓存60秒
cache = TTLCache.new
addrs = cache.fetch(hostname, 60) { resolver.addresses_for(hostname) }

异常处理与生产环境注意事项

生产环境必须处理三类异常:上游全部失败、域名不存在、以及查询整体超时。上面的RacingResolver已经演示了整体超时控制和ResolutionFailure的抛出方式,调用方应捕获该异常并做降级,比如回退到系统默认解析或返回缓存中的旧值(stale-while-error策略)。单个上游的瞬时故障则在内部消化,只记日志不上抛,保证解析主流程不被个别节点拖垮。

还需要注意UDP包大小问题。当DNS响应较大(比如开启了DNSSEC)时,上游会返回截断标志并要求TCP重试,因此给Async::DNS::Resolver配置服务器时建议同时声明udp和tcp两种协议。另外,如果解析器运行在容器环境,容器内的/etc/resolv.conf往往指向宿主或虚拟网关,自建解析器时应显式指定公共DNS(如223.5.5.5、1.1.1.1)或内部DNS服务地址,不要依赖环境默认值。

综合来看,一个健壮的Ruby DNS解析器应该是清晰的三层结构:最前面是TTL缓存层,其次是自定义hosts覆盖层,最后是并行上游查询层,外围再包上统一的超时、异常捕获和日志。Async::DNS加上async事件循环,让这套结构在单线程内就能支撑高并发解析请求,无论是做内网开发测试工具,还是嵌入到爬虫、网关类服务中,都是一套切实可行的方案。

Ruby Async::DNS自定义hosts解析并行DNS查询修改时间:2026-08-31 09:03:33

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。