掘金 后端 ( ) • 2024-04-24 17:23

theme: juejin

一、背景

关于HTTPDNS的功能和作用网上一搜一大堆,这里简单回答下两个为什么:

为什么要使用HTTPDNS?

HTTPDNS 目的在于解决移动互联网中 DNS 解析异常、域名劫持的问题:

  • 移动 DNS 的现状:运营商 LocalDNS 出口根据权威 DNS 目标 IP 地址进行 NAT,或将解析请求转发到其他 DNS 服务器,导致权威 DNS 无法正确识别运营商的 LocalDNS IP,引发域名解析错误、流量跨网。
  • 域名被劫持的后果:网站无法访问(无法连接服务器)、访问到钓鱼网站等。
  • 解析结果跨域、跨省、跨运营商、国家的后果:网站访问缓慢甚至无法访问。

参考:移动解析 HTTPDNS

image.png

为什么要自研?

为了节约成本。

参考:B站HTTPDNS自研降本之道

image.png

二、需求

  • 支持根据国家、地区、运营商(移动、联通、电信...)进行域名解析,返回一个或多个IP地址
  • 支持方便的运维操作(透明代理,运维依然是阿里云平台操作)
  • 支持向多加第三方HTTPDNS供应商进行查询,并缓存查询结果
  • 支持进行IP定位并识别异常数据
  • 支持特定场景下前端进行降级使用LocalDNS解析
  • IP定位结果异常时
  • 监控需求
    • 支持按{域名,国家,地区,运营商}显示解析QPS
    • 支持按{域名,国家,地区,运营商}显示解析响应时间
  • AB测试
    • 对比开启HTTPDNS和LocalDNS相关数据对比(成功率、延时等)
  • 高性能:单核支持5k以上QPS。

三、架构

整体思路

  1. 构建DNS缓存

尽量确保每次网络交互都是从内存中获取域名解析的IP结果,从而大大降低DNS解析开销。

服务端:根据{域名,国家,地区,运营商}缓存域名解析结果,缓存模式采用集中缓存+本地缓存,提高缓存命中率。尽量避免同步构建缓存。

客户端:根据{域名,国家,地区,运营商}缓存域名解析结果,在网络切换的场景下复用已缓存结果。考虑引入持久存储,在客户端重启时快速从本地读取域名解析结果,大幅提升首页加载效率。

  1. 热点域名解析预热

根据二八定律,我们通过热点域名预热,可以解决80%以上的问题。

服务端:业务域名和CDN域名分开处理。这是因为业务域名的服务器比较集中,不存在边缘节点,可以定时刷新到缓存。CDN域名则可以考虑异步刷新。

客户端:通过启动App获取到的config配置下发的热点域名,监听启动、网络切换、定位变化、配置文件变化等事件,触发热点域名预加载。

  1. 懒更新策略

查询缓存的解析结果时,如果TTL即将过期(e.g. TTL-10),则在后台进行异步的DNS解析并更新缓存结果。甚至,TTL已经过期,也可以先返回,然后在触发后台异步更新。绝大多数时候,业务域名解析的IP列表并不会频繁变化。

  1. 降级兜底

一定要设计好降级兜底,无论任何情况,服务端要做好熔断降级机制。客户端需要保留LocalDNS兜底策略,要能做到快速失败(例如请求httpdns超时时间尽可能短点)走兜底。

服务端

整体架构

服务端架构采用多IDC架构模式,防止单机房故障。机器前面挂7层LB。解决单集故障的同时也可以减少服务端适配各种传输协议的工作量。为了防止HTTPDNS服务入口域名被域名劫持导致整个HTTPDNS解析服务瘫痪,服务端提供给端上HTTPDNS的IP列表(IP列表,多个自带灾备属性)。

image.png

直接访问IP如果支持HTTPS,则涉及到购买企业OV证书。另外,移动端目前仅重试流量会走HTTPDNS。因此,一期先上HTTP,后续有需求再上HTTPS。

HTTPDNS需要支持多区域部署,至少考虑极端情况下超远距离南北网络延时,这对缓存架构选型、对第三方HTTPDNS服务调用次数翻倍有着重大影响。

跨区调度:通过config接口,按IP定位,下发对应的HTTPDNS的IP列表。

服务端系统架构

HTTPDNS系统分为客户端、服务端,还会涉及到数据建设。服务端主要有如下几个模块组成:鉴权模块、IP模块、调度模块、缓存模块、域名探测模块。

image.png

MySQL:仅存储配置,简单的一主多从即可。不承担业务流量。
Redis:由于要支持跨区域多机房部署,且内存占用需求不高,仅当缓存+集群分布式锁用。

鉴权模块

在收到请求后,服务端会先进行鉴权,确认请求来源可靠。鉴权失败则返回失败信息,鉴权通过则进入调度解析核心模块。

域名白名单校验
IP定位结果非法性校验

IP模块

根据客户端IP解析出国家、地区代码、运营商等信息。为什么需要这个模块?IP定位能够让我们实现精准的流量调度。业务域名的服务考虑到高可用,会部署在阿里云、腾讯云、华为云的多个区域不同地区。还要考虑到为海外提供服务。所以,必须要有区域调度能力。CDN域名(html、css、image、api)就更不用说了,也必须依赖定位实现就近访问原则。

调度模块

调度模块主要是域名流量调度。一方面我们要支持通过IP定位实现就近区域调度的能力。另一方面,我们也需要实现自定义调度域名。很多场景下,客户端需要设定特殊的域名-IP对应关系,这类关系无法通过传统的权威DNS语义实现。

前面IP模块提到了,业务域名和CDN域名的域名调度其实是可以分开处理的。

业务域名:服务器部署一般比较集中,不存在边缘节点,通过一种机制同步到HTTPDNS Server即可。可选的方式有:

a. 通过域名探测模块(见下文)根据域名+地区定时同步。

b. 通过域名提供商(阿里云)接口定时同步。(好处是能够直接得到IP列表的权重配置)详见:https://help.aliyun.com/document_detail/2355664.html?spm=a2c4g.2355661.0.0.2526518dUHzB1Q~~ (该接口权限范围太大,且不知道TTL,建议a方式)

c. 通过手动录入到管理后台。

CDN域名:通过域名探测模块获取域名的IP列表。HTTPDNS需要能够获取CDN边缘节点的IP,这就限制了只有像腾讯、阿里这种有自建CDN的大厂才能实现。

域名探测模块

域名探测是整个HTTPDNS设计的核心:

  1. 要保证所探测出来的IP是符合原始配置的权重;(权重配置涉及到商务上的流量配比规划及运维的机器资源准备)
  2. 探测的区域、运营商IP要全。
  3. 而目前我们没有自建CDN服务(权威DNS服务器),访问公共的或者云厂商提供的DNS服务都会有并发访问限制。所以,如果HTTPDNS要上CDN域名调度,就只能调用云厂商提供的HTTPDNS服务。虽然需要付费调用,但是云厂商每个月都有免费配额,即使超额价格也没有超出想象,我们自己的HTTPDNS Server通过{域名,国家,地区,运营商}进行缓存,可以大大减少流量穿透到云厂商HTTPDNS服务。另外,我们可以通过接入多加HTTPDNS云厂商,扩大总量的免费配额,降低费用。

下面我们看看云厂商HTTPDNS收费情况(摘自阿里云HTTPDNS服务产品计费):

image.png

注:

  1. 一般第三方HTTPDNS服务,1次HTTPS协议的解析次数按5次HTTP协议解析次数换算。
  2. 域名列表最多只能添加200条域名规则。例如:www.aliyun.com.aliyun.com.httpdns.aliyun.com

缓存模块

域名探测模块会通过取请求中的域名(白名单)、IP向第三方HTTPDNS供应商获取域名解析配置,最终将探测出来的域名结果和根据解析配置规则组合出来的结果更新到Cache中去。

如何设计好缓存是关键:

  1. 根据TTL进行超时,TTL到期时向第三方HTTPDNS供应商发起查询,成功获得域名结果后,更新缓存,否则不更新缓存(这时缓存结果是stale的)

  2. 缓存更新方式:

    a. 同步更新:请求第三方当次未命中缓存回源会导致APP等待时间加大,其他时候都会命中缓存。(实现简单、未命中缓存体验差)

    b.异步更新:通过IP库(日更新)获取每个地区的N个IP,通过M次云厂商的HTTPDNS服务查询统计出近似原始域名的权重,将最终探测出来的DNS结果存入Cache中。(实现较为合理,一次计算推给多个集群)

    c. 同步+异步更新:请求查询时只是缓存查询,同时查看一下当前缓存域名的TTL是否即将过期(TTL-10s),如果是,则异步的发起更新。并把回源域名IP列表用redis zset存储,每次命中的IP+1,取topN,即近似原始域名权重了。(实现较复杂)

    d. jetcache-go 自动异步刷新方案:利用jetcache-go分布式自动刷新缓存机制和redis多机房同步机制,可以将缓存回源次数降到极低的水平。(思路跟c相似,实现更简单)

状态机

image.png 首先进行鉴权,鉴别请求是否合法,如果鉴权失败,则返回对应错误码。 若鉴权通过,则看请求域名,以及用户IP,来判断是否命中缓存,若命中缓存,且缓存中的TTL未过期,则直接根据权重返回对应的DNS解析结果;若未命中缓存或者命中了但TTL过期,则远程查询第三方HTTPDNS供应商,将查询结果返回给客户端。

核心逻辑

缓存配置

cache:
  base: 
    cacheType: both
    localType: freeCache
    codec: sonic
    redisName: httpdns
    localExpire: 2m
    refreshDuration: 60s
    stopRefreshAfterLastAccess: 1h
  weight:
    cacheType: both
    localType: freeCache
    codec: sonic
    redisName: httpdns
    localExpire: 1m
    refreshDuration: 10s
    stopRefreshAfterLastAccess: 1h

base和weight核心区别就是刷新周期不一样。如果域名对权重配置敏感,可以通过MySQL域名配置切换刷新模式。

核心代码

// dimIP 维度IP。key={域名,国家,地区,运营商} value=ip
var dimIP sync.Map

// ParseIP 解析IP
func (s *Service) ParseIP(ip string) (country, region, isp string, err error) {
    info, err := s.dao.IpDB.Find(net.ParseIP(ip))
    if err != nil {
       return "", "", "", errors.Wrapf(bcode.InvalidIP, "ipdb.Find(%s) error(%v)", ip, err)
    }

    country = info[11] // CN
    region = info[1]   // 地区
    isp = info[4]      // 运营商

    country, region, isp = model.Code(country, region, isp)

    return country, region, isp, nil
}

// UpdateDimIP 更新维度{域名,国家,地区,运营商}的最新IP。
func (s *Service) UpdateDimIP(hosts []string, ip, country, region, isp string) {
    for _, host := range hosts {
       key := s.ipKey(host, country, region, isp)
       dimIP.Store(key, ip)
    }
}

// GetDimIP 保持IP的鲜活。优先获取DimIP,否则用本次ip降级
func (s *Service) GetDimIP(host, ip, country, region, isp string) string {
    key := s.ipKey(host, country, region, isp)
    if v, ok := dimIP.Load(key); ok {
       return v.(string)
    }
    return ip
}

// Probe 域名探测
//
// 缓存key={域名,国家,地区,运营商},缓存value={model.DnsCache},缓存时间1小时
// 利用`jetcache-go`的`autoRefresh`功能实现域名探测,每N秒探测一次,探测的IP为鲜活IP={GetDimIP}
// `jetcache-go`的`autoRefresh`特性是:同一个缓存key,整个集群所有web实例中,只会有1个实例1个协程去刷新缓存,恰到好处。
// 探测的目的:1、为了保证域名解析结果的实时性;2、为了保证探测出来的IP是符合原始配置的权重
//
// DB配置的域名白名单有weightFlag属性,表示是否开启权重:0-否;1-是。
// jetCache分两类缓存,base和weight。base刷新频率低(1m/次),weight刷新频率高(10s/次)。区分的目的是为了权衡调用频率和准确性问题。
// 这里,巧妙的设计是:base、weight两个cache实例有着共同的缓存key和缓存value,那么就可以通过修改DB域名白名单weightFlag值来实现无缝
// 切换。当云平台启用了域名权重,同步修改DB域名白名单配置的weightFlag=1,则切换到较高刷新频率的cache实例(weight),用以保证探测结果接近
// 云平台设置的权重,提升准确性。关闭域名权重时,则使用刷新频率低的cache实例(base),降低调用次数,节约费用。
//
// 如果域名启用了权重,例如:域名`www.example.com`设置1.1.1.1权重90,2.2.2.2权重10,每次请求第三方`httpdns`的解析结果按权重只会返回1个IP。
// 按当前设计:10秒探测一次,容忍时间为`ExpireAt+300s`(见:model/dns.go#balance),而咱们域名解析TTL一般设置 为60s,那么容忍时间大约为
// `60s+300s`,该时间窗口key={域名,国家,地区,运营商}内的探测机会有360s/10s=36次。如果权重设置比较极端,有可能覆盖不到低权重IP了。
func (s *Service) Probe(ctx context.Context, appId int, host, ip, country, region, isp string) (*model.DnsCache, error) {
    var (
       key      = s.dnsKey(host, country, region, isp)
       dns      = &model.DnsCache{}
       jetCache = s.dao.GetCache(appId, host)
       ttl      = time.Duration(jetCache.GetConfig().StopRefreshAfterLastAccess)
    )
    err := jetCache.Once(ctx, key, cache.Value(&dns), cache.TTL(ttl), cache.Refresh(true),
       cache.Do(func(ctx context.Context) (interface{}, error) {
          dimIp := s.GetDimIP(host, ip, country, region, isp)
          remoteDns := &model.DnsCache{}
          if err := jetCache.GetSkippingLocal(ctx, key, remoteDns); err != nil && !errors.Is(err, cache.ErrCacheMiss) {
             xlog.Errorc(ctx, "cache.GetSkippingLocal(%s) error(%v)", dimIp, err)
          }

          name, dns, err := s.dao.Balancer.Resolve(ctx, host, dimIp)
          if err != nil {
             return nil, errors.Wrapf(err, "balancer[%s] Resolve(%s) ip(%s)", name, host, dimIp)
          }
          remoteDns.Merge(dns)

          return remoteDns, nil
       }))

    return dns, err
}
func (s *Service) Resolve(ctx context.Context, req *api.ResolveReq) (res *api.ResolveRes, status int, err error) {
    if err = s.rCheckAugments(req); err != nil {
       return nil, http.StatusBadRequest, err
    }

    country, region, isp, err := s.ParseIP(req.IP)
    if err != nil {
       return nil, http.StatusBadRequest, err
    }

    app, err := s.rCheckSignature(req)
    if err != nil {
       return nil, http.StatusForbidden, err
    }

    dns := s.resolve(ctx, req.AppId, req.Type, req.Hosts, req.IP, country, region, isp)

    resp := &api.ResolveRes{Dns: dns, CIP: req.IP}
    resp.Signature(app.Secret)

    return resp, http.StatusOK, nil
}

得益于jetcache-go提供的自动刷新缓存的能力,缓存加载行为是全局唯一的,也就是说不管有多少台服务器,同时只有一个服务器在刷新,目的是为了降低后端的加载负担。极大的降低了回源httpdns云厂商的请求量。

客户端

示例:

目标URL: http://www.example.com/a/b
通过HTTPDNS/LocalDNS解析出来域名www.example.com的最优IP:112.219.123.25
发起请求:
GET http://112.219.123.25/a/b/ HTTP/1.1
Host: www.example.com

功能需求

为客户端网络请求提供HTTPDNS/DNS解析服务
3. 支持定期更新服务IP,保障HTTPDNS功能的可用性(依赖config接口)
3. IP优选,在解析IP之后,通过Socket连接测量服务连接速度,优先使用最快的IP,提供给用户(不能做IP优选,这会打破运营商流量配比及不可预估的节点负载)
4. 提供预解析机制、IP过期、网络变化、前后台切换等情况时重新解析,提高IP的准确性
5. 支持切换/降级/LOCALDNS降级, 支持配置(高优调度/兜底)
6. 支持多种客户端

可参考阿里云巴巴开源httpdns-sdk:https://github.com/aliyun/alibabacloud-httpdns-android-sdk?spm=a2c6h.12873639.article-detail.8.63b53f58m8ZxmS

DNS解析

首次访问机制

首次(NS缓存库无记录)访问同时使用LocalDNS和HTTPDNS,在HTTPDNS结果返回之前使用LocalDNS,HTTPDNS返回之后使用HTTPDNS。

DNS预加载机制

  1. 利用App启动时的config接口,下发DNS预加载配置参数:开关、时间间隔、需要预加载的域名列表等。(config接口下发)
  2. 监听App启动、网络变化、定位城市变化、配置文件变化、前后台切换等事件,在独立的线程中执行DNS Prefetch逻辑。
  3. 如果开关打开,且上次Prefetch的时间间隔距离当前时间大于阀值,则刷新DNS,触发操作系统/VM层的缓存功能。

image.png

NS缓存是否持久化考量:DNS缓存持久化可以提升首屏加载速度,但DNS持久化缓存会将上一次解析到的结果保存到本地持久层,APP重启后,会优先从持久层加载解析结果。所以存在第一次使用的IP为过期的IP(TTL过期,大多数情况下该IP依然可以正常使用)的可能性。如果业务服务器IP变化较频繁,就尽量不要持久化,以免对业务造成影响。

可以考虑的配置项:是否允许启用持久化缓存、是否允许返回TTL过期域名的IP。(例如:SDK 允许使用过期的解析记录,但不是无限制使用。SDK 只会使用处于过期容忍时间内的解析记录。在默认情况下,过期容忍时间为 60 秒,即 SDK 只会使用过期之后 60 秒内的解析记录。

根据美团数据统计:他们域名在国内DNS解析耗时大概在30-120ms之间,而国外网络下耗时达到200-300ms左右。在2G/3G等弱网环境下,DNS解析失败非常常见。DNS对于首次网络访问的耗时及网络成功率会有很大的影响。

用户访问触发机制

尽管我们考虑到了启动应用+网络变化、定位城市变化、配置文件变化、前后台切换等Event事件触发Prefetch DNS,但依然不能保证用户访问的时候,从NS缓存查询到的IP列表的TTL没有过期。

况且,要知道,Prefetch DNS 的作用仅仅是针对核心域名的预加载。非核心的域名的解析只能由用户访问触发。

image.png

缓存机制

缓存方式:根据配置是否允许启用持久化缓存

缓存刷新:通过Event事件触发Prefech DNS、用户访问触发来刷新缓存。成功则更新缓存,失败缓存不能失效(重试或下次更新来刷新)

探测机制

跑马测试~~(没必要,影响权重配置)

降级方式

指标上报

数据建设

四、效果

压测

压测配置

4C8G AMD 芯片服务器

压测模型

本次压测运行:涉及 3 个场景,顺序串行运行:

  • 场景1 resolveTest 采用 分阶段RPS 执行模式(分 2 个阶段,最大目标RPS:30000,最大VUs:1500), 包含 1 个接口,第:0s 开始运行,运行时长:10m。

  • 场景2 resolveBetchTest 采用 分阶段RPS 执行模式(分 2 个阶段,最大目标RPS:5000,最大VUs:500), 包含 1 个接口,第:0s 开始运行,运行时长:10m。

  • 场景3 metaTest 采用 分阶段RPS 执行模式(分 2 个阶段,最大目标RPS:5000,最大VUs:500), 包含 1 个接口,第:0s 开始运行,运行时长:10m。

压测覆盖33/160+个域名,400+IP(覆盖主流运营商和国内外主要地区)

压测工具

自研压测平台

压测数据

单台4核8G服务器,极限 34K QPS

image.png

pprof 火焰图 image.png

回源云厂商httpdns查询QPS image.png

五、参考