request 包中出现 DNS 解析超时的探究

事情的起因是这样的,公司使用自建 dns 服务器,但是有一个致命缺陷,不支持 ipv6 格式的地址解析,而 node 的 DNS 解析默认是同时请求 v4 和 v6 的地址的,这样会导致偶尔在解析 v6 地址的时候出现超时。

本文链接地址 https://blog.whyun.com/posts/request-dns/the-problem-of-dns-timeout-on-request-package/index.html ,转载请注明出处。

我们的程序中是使用的 request 这个包,查看了一下官方文档,请求 options 中并没有涉及跟 DNS 有关的配置,于是乎求教运维同事。运维同事告诉我在 docker run 的时候加参数 --sysctl net.ipv6.conf.all.disable_ipv6=1,试用了一下,并且写出来如下测试代码:

const dns = require('dns');
const domain = process.argv[2] || 'baidu.com';
const begin = Date.now();
dns.lookup(domain,function(err ,address , family) {
    console.log('耗时',Date.now() - begin, err ,address , family);
});

代码 1.1 DNS 查询测试代码

运行 代码 1.1 ,同时使用命令 tcpdump -i eth0 -n -s 500 port domain 来抓取 DNS 解析的数据包:

20:47:28.917563 IP 10.6.13.67.38050 > 10.7.11.219.domain: 40621+ A? baidu.com. (27)
20:47:28.917582 IP 10.6.13.67.38050 > 10.7.11.219.domain: 32393+ AAAA? baidu.com. (27)
20:47:28.921061 IP 10.7.11.219.domain > 10.6.13.67.38050: 40621 2/0/0 A 220.181.38.148, A 39.156.69.79 (59)
20:47:28.921114 IP 10.7.11.219.domain > 10.6.13.67.38050: 32393 0/1/0 (70)

从输出来看依然会请求 ipv6 的地址解析,所以当时我的判断是运维的配置是不生效的。

后来又有了些空闲的时间,所以研究了一下官方文档,看看是否有参数可以控制 http 请求的 DNS 协议版本,没有想到还真有,http.request 的 options 中可以设置 family 参数,可选值为 4 6, 即 ipv4 或者 ipv6,如果不指定这个参数,将同时使用 ipv4 和 ipv6。按理来说看到这里,我就应该死心了,如果不传这个参数,肯定会同时做 ipv4 和 ipv6 的地址解析,但是我还是抱着试试看的态度写下了如下测试代码:

var domain = process.argv[2] || 'baidu.com';
require('http').request('http://' + domain,function(res) {
    console.log(`STATUS: ${res.statusCode}`);
  console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
  res.setEncoding('utf8');
  res.on('data', (chunk) => {
    //console.log(`BODY: ${chunk}`);
  });
  res.on('end', () => {
    console.log('No more data in response.');
  });
}).end();

代码 1.2 http 请求测试

没有想到 代码 1.2 执行完成后竟然只做了 ipv4 的解析:

21:01:04.247650 IP 10.6.12.158.51977 > 10.7.11.219.domain: 21651+ A? docker.17zuoye.net. (36)
21:01:04.248316 IP 10.7.11.219.domain > 10.6.12.158.51977: 21651* 1/0/0 A 10.7.11.98 (52)
21:01:06.593429 IP 10.6.12.158.48479 > 10.7.11.219.domain: 10352+ A? baidu.com. (27)
21:01:06.596978 IP 10.7.11.219.domain > 10.6.12.158.48479: 10352 2/0/0 A 39.156.69.79, A 220.181.38.148 (59)

这就很神奇了,node 的 http 的代码封装中肯定做了什么!带着这个疑问,我阅读了 node 的源码,首先看 ClientRequest 的初始化代码中,连接初始化部分:

  // initiate connection
  if (this.agent) {
    this.agent.addRequest(this, options);
  } else {
    // No agent, default to Connection:close.
    this._last = true;
    this.shouldKeepAlive = false;
    if (typeof options.createConnection === 'function') {
      const newSocket = options.createConnection(options, oncreate);
      if (newSocket && !called) {
        called = true;
        this.onSocket(newSocket);
      } else {
        return;
      }
    } else {
      debug('CLIENT use net.createConnection', options);
      this.onSocket(net.createConnection(options));
    }
  }

代码 1.3 ClientRequest 类的连接初始化

http.request 没有加任何参数的情况,默认走到 this.onSocket(net.createConnection(options)); 这句话,然后看 net 包的代码,其中一端跟 DNS 相关的代码:

  if (dns === undefined) dns = require('dns');
  const dnsopts = {
    family: options.family,
    hints: options.hints || 0
  };

  if (process.platform !== 'win32' &&
      dnsopts.family !== 4 &&
      dnsopts.family !== 6 &&
      dnsopts.hints === 0) {
    dnsopts.hints = dns.ADDRCONFIG;
  }

  debug('connect: find host', host);
  debug('connect: dns options', dnsopts);
  self._host = host;
  const lookup = options.lookup || dns.lookup;

代码 1.4 net 包中 DNS 查询参数代码

然后我们再看 lookup 函数的源码:

// Easy DNS A/AAAA look up
// lookup(hostname, [options,] callback)
function lookup(hostname, options, callback) {
  var hints = 0;
  var family = -1;
  var all = false;
  var verbatim = false;

  // Parse arguments
  if (hostname && typeof hostname !== 'string') {
    throw new ERR_INVALID_ARG_TYPE('hostname', 'string', hostname);
  } else if (typeof options === 'function') {
    callback = options;
    family = 0;
  } else if (typeof callback !== 'function') {
    throw new ERR_INVALID_CALLBACK(callback);
  } else if (options !== null && typeof options === 'object') {
    hints = options.hints >>> 0;
    family = options.family >>> 0;
    all = options.all === true;
    verbatim = options.verbatim === true;

    validateHints(hints);
  } else {
    family = options >>> 0;
  }

  if (family !== 0 && family !== 4 && family !== 6)
    throw new ERR_INVALID_OPT_VALUE('family', family);

  if (!hostname) {
    emitInvalidHostnameWarning(hostname);
    if (all) {
      process.nextTick(callback, null, []);
    } else {
      process.nextTick(callback, null, null, family === 6 ? 6 : 4);
    }
    return {};
  }

  const matchedFamily = isIP(hostname);
  if (matchedFamily) {
    if (all) {
      process.nextTick(
        callback, null, [{ address: hostname, family: matchedFamily }]);
    } else {
      process.nextTick(callback, null, hostname, matchedFamily);
    }
    return {};
  }

  const req = new GetAddrInfoReqWrap();
  req.callback = callback;
  req.family = family;
  req.hostname = hostname;
  req.oncomplete = all ? onlookupall : onlookup;

  const err = cares.getaddrinfo(
    req, toASCII(hostname), family, hints, verbatim
  );
  if (err) {
    process.nextTick(callback, dnsException(err, 'getaddrinfo', hostname));
    return {};
  }
  return req;
}

代码 1.5 lookup 函数源码

通过代码 1.5 发现最终 DNS 查询是要调用 C++ 绑定类的,于是我又查看了 C++ 的代码:

void GetAddrInfo(const FunctionCallbackInfo<Value>& args) {
  Environment* env = Environment::GetCurrent(args);

  CHECK(args[0]->IsObject());
  CHECK(args[1]->IsString());
  CHECK(args[2]->IsInt32());
  CHECK(args[4]->IsBoolean());
  Local<Object> req_wrap_obj = args[0].As<Object>();
  node::Utf8Value hostname(env->isolate(), args[1]);

  int32_t flags = 0;
  if (args[3]->IsInt32()) {
    flags = args[3].As<Int32>()->Value();
  }

  int family;

  switch (args[2].As<Int32>()->Value()) {
    case 0:
      family = AF_UNSPEC;
      break;
    case 4:
      family = AF_INET;
      break;
    case 6:
      family = AF_INET6;
      break;
    default:
      CHECK(0 && "bad address family");
  }

  auto req_wrap = std::make_unique<GetAddrInfoReqWrap>(env,
                                                       req_wrap_obj,
                                                       args[4]->IsTrue());

  struct addrinfo hints;
  memset(&hints, 0, sizeof(hints));
  hints.ai_family = family;
  hints.ai_socktype = SOCK_STREAM;
  hints.ai_flags = flags;

  TRACE_EVENT_NESTABLE_ASYNC_BEGIN2(
      TRACING_CATEGORY_NODE2(dns, native), "lookup", req_wrap.get(),
      "hostname", TRACE_STR_COPY(*hostname),
      "family",
      family == AF_INET ? "ipv4" : family == AF_INET6 ? "ipv6" : "unspec");

  int err = req_wrap->Dispatch(uv_getaddrinfo,
                               AfterGetAddrInfo,
                               *hostname,
                               nullptr,
                               &hints);
  if (err == 0)
    // Release ownership of the pointer allowing the ownership to be transferred
    USE(req_wrap.release());

  args.GetReturnValue().Set(err);
}

代码 1.6 C++ 中 DNS 的查询代码

注意 代码 1.5 中的 family hints 最终会分别转化为 结构体变量 struct addrinfo hints 中的 ai_family 和 ai_flags。

最终这个结构体 hints 会层层传递到 libuv 中:

static void uv__getaddrinfo_work(struct uv__work* w) {
  uv_getaddrinfo_t* req;
  int err;

  req = container_of(w, uv_getaddrinfo_t, work_req);
  err = getaddrinfo(req->hostname, req->service, req->hints, &req->addrinfo);
  req->retcode = uv__getaddrinfo_translate_error(err);
}

代码 1.7 libuv 中的 dns 查询函数代码

注意到我们在 代码 1.4 中的 hints 参数,最终会作为 req->hints->ai_flags 参数,最终我在 man7 文档上找到了AI_ADDRCONFIG 的这个参数的说明:

If hints.ai_flags includes the AI_ADDRCONFIG flag, then IPv4
addresses are returned in the list pointed to by res only if the
local system has at least one IPv4 address configured, and IPv6
addresses are returned only if the local system has at least one IPv6
address configured.  The loopback address is not considered for this
case as valid as a configured address.  This flag is useful on, for
example, IPv4-only systems, to ensure that getaddrinfo() does not
return IPv6 socket addresses that would always fail in connect(2) or
bind(2).

大体意思是说,系统配置了 ipv4 才返回 ipv4的地址,系统配置了 ipv6 才返回 ipv6 的地址,而 docker 的启动参数 --sysctl net.ipv6.conf.all.disable_ipv6=1 等同于系统只支持 ipv4 的声明,所以操作系统函数 getaddrinfo 就只返回 ipv4 的地址。

重新验证这个问题,将代码 1.1 做改造:

const dns = require('dns');
const domain = process.argv[2] || 'baidu.com';
const begin = Date.now();
dns.lookup(domain,{hints:32},function(err ,address , family) {
    console.log('耗时',Date.now() - begin, err ,address , family);
});

代码 1.8 使用 ADDRCONFIG 参数做 DNS 查询

这里面之所以取值的 hints:32,是因为 AI_ADDRCONFIG 的值为32。通过设置环境变量 NODE_DEBUG=net 后启动 代码1.2 ,会发现 debug('connect: dns options', dnsopts); 打印的 hints 值为 32。

重新运行,发现果然只查询了 ipv4 的地址。

到此为止,其实可以算是圆满收官了,但是对于 request 包还是不死心,心想如果当前开源源码不支持,是否可以做一个 pull request 呢,于是我看了一下他们的官方源码,结果就发现了新大陆:

  var reqOptions = copy(self)
  delete reqOptions.auth

  debug('make request', self.uri.href)

  // node v6.8.0 now supports a `timeout` value in `http.request()`, but we
  // should delete it for now since we handle timeouts manually for better
  // consistency with node versions before v6.8.0
  delete reqOptions.timeout

  try {
    self.req = self.httpModule.request(reqOptions)
  } catch (err) {
    self.emit('error', err)
    return
  }

代码 1.9 request 源码片段

self.httpModule.request(reqOptions) 等同于 http.request(reqOptions) 或者 https.request(reqOptions),也就是说 http 模块的所有参数其实在 request 上也是适用的,但是 request 的官方文档却没有指出!

最圆满的方案出炉了,在调用 request 函数的时候,指定 family 为 4,即可屏蔽 ipv6 解析。

Comments

Node 缓存优化之路

当今互联网,越来越往大数据的方向发展。大数据的背后是大用户,你的后端服务必须得有能力承载大用户、高并发的冲击。从宏观上讲,提高并发能力,无怪乎横向扩展(增加服务器节点个数)、纵向扩展(提高单点服务器的处理能力)。横向扩展,常见的有负载均衡,是一种部署的拓扑结构;纵向扩展,更多的体现的在应用程序本身性能提升。计算机中,我们常用拿空间换时间,来提升程序性能,缓存正是这种思想的体现。

本文链接地址 https://blog.whyun.com/posts/node/the-cache-design-in-node ,转载请注明出处。

我们这篇文章讲 Node 的缓存设计,不过在切入正题之前,我还是要先讲一下 Node 的线程模型,如下图:

Node 线程模型

图片0.1 Node 线程模型

我们常说 Node 是单线程程序,这是由于我们 JavaScript 代码(基于 V8 引擎)正是运行在这个单线程中的,此外我们常说的事件轮询、网络 IO 都是运行在这个线程中的。不过 Node 进程中,并不仅仅只包含这一个线程,它还有一个线程池,用来处理 文件 IO 、crypto 、zlib,还有异步 C++ 模块等操作,只不过,我们平常在 Node 中运用这些操作比较少。

1. 缓存分类

从生命周期上讲,缓存大体上分以下三类,

  1. 会话 跟用户登陆状态关联的数据,生命周期长

  2. 临时数据,短时间内有效,生命周期短

  3. 镜像 数据库的数据镜像,生命周期特长

2. 缓存设计思路

2.1 会话

现如今,各种设备终端兴起,很多情况下,我们要自己设计用户会话,而不是直接使用传统 web 中使用的 session,以便更好的适配多端设备,并且能够更好的对性能进行调优。

一个简单的 session 设计思路,就是生成一个 token,然后将用户数据序列化成 JSON 字符串,最后将 token 和 JSON 字符串的映射关系写入 redis。在读取的时候,根据 token 查询到 JSON 字符串,然后反序列化即可。

咋一看,这个设计是没有问题的,不过经过性能测试发现,Node 中 JSON 的反序列化性能是不高的,解决这个问题也很简单,我们将 redis 中反序列化的数据缓存到内存即可,这样用户下次请求的 token 验证就直接走内存了。 不过这又会导致一个问题,如果你的用户量数据巨大,那么内存是不够用的。我们必须得有内存清理机制,否则早晚内存会炸掉。

应该怎么清理呢?每天凌晨将内存中的数据清理掉,但是如果白天内存就告急了怎么半?限制内存存储数据个数,在达到内存限制个数后,内存中存储的都是老数据,如果这些老数据都是冷数据,新来的请求还算是会频繁的请求 redis ,做反序列化。 怎么办?有没有既限制内存数量,且又能读写热数据的思路呢?还真有,这就是 L(Least)R(Recently)U(Used) 算法.。不过如果你使用 JavaScript 编写一个 LRU 算法,就会在 V8 引擎中引入一个耗时操作,这在 Node 中是大忌。我们开头图0.1讲过,Node 进程中其实是有一个线程池的,我们何不将这个 LRU 实现代码,在这里实现?

我写了一个 c++ addon 来处理这个 LRU 算法,刚开始测试的时候性能很不错,看上去完美的解决了这个问题。不过随着时间的推移,我最担心的事情还是发生了,就是进程崩溃了!刚才我们说过,我们借用的是线程池来处理 LRU 算法,但是我们的程序中对于多线程操作,并没有加锁,导致处理的过程中出现脏数据,然后读写非法内存地址,进程就崩溃了。

我将线程操作加上了锁,加锁所导致的性能又一步做出了损耗,但依然在可控范围内。但是我们依然忽略了一个问题,我们做 LRU 就是要减少内存使用的,但是现在我们反而把 每个用户的 token 数据都写到内存中 LRU 关联的链表中,长此以往,内存依然会爆。

说到这里,我们的优化之路,貌似进入了死胡同,不过当你为一件事情殚精竭虑的时候,上天一定会眷顾你。一个偶然的机会,我发现了 Redis 的内存清理也是使用 LRU 算法的,同时在启用 LRU 时,性能也会下降,这跟我们使用LRU算法的结果是一样的。不过在 Redis 中还有一种算法,用来清理过期key,就是定期遍历 key 列表,从 key 列表中取出 N 个 key,判断其是否过期,如果过期,就删除。Redis 也是单线程,其线程模型和 Node 是类似的,所以它在遍历列表的时候,不会做全量遍历,而是使用定时器。增加定时定量的清理算法后,做性能测试,性能并没有损耗,同时还保证了内存占用在可控范围之内。

以上就是会话类型的缓存设计历程,对应的源代码已经开源,参见 session-token

2.2 闪存

使用闪存,一定是我们允许在短暂时间内缓存数据和真实数据有一定的误差,但是这个误差一定要在允许的范围之内。比如说做阈值处理时,通过 Redis 的 incr 操作做计数器,在达到阈值时就可以把这个值缓存到内存,在达到阈值的短暂时间内,真实的计数器的值我们并不关心。

整个设计思路类似于 JVM 或者 V8 的新生代 GC 算法:

young/old

图 2 .2 young/old

缓存数据在写入的时候,现如 young 区域,程序内置定时器每隔固定时间将 young 区域赋值到 old 区域,同时清空 young 区域。

使用时要注意缓存击穿问题。比如说笔者曾经犯过的一个问题,在将 Redis 的查询的结果缓存到闪存,但是如果查询结果为空,没有将空的这个结果缓存下来,所以说对于 key 值不存在的情况,每次都是缓存不命中中的,每次都会请求 Redis。解决的问题也很简单,就是在闪存中保存一个空串。

对应的源代码解决方案参见 flash-cache

2.3 镜像

前段时间,我们接手了一个 IM 项目,要求实现类似于钉钉的功能,在线可以实时接收数据,离线后上线,可以查看未读历史记录。考虑到是一个实时系统,我们的没有选择直接读取 mongodb,而是将数据先写入 Redis 中。同时将实时聊天记录打入 Kafka,然后通过消费者程序同步到 mongodb。Redis 中存储的数据可以理解成 mongodb 中的镜像,读取的时候,先读取 Redis,只有在 Redis 中查询不到的时候才读取 mongodb (实际上这种情况几乎不可能发生,因为我们在 Redis 中缓存失效时间很长)。

镜像中一般用来映射数据库中的表记录,采用的 key 类型为 sorted set。但是数据库中的数据量是巨大的,全部缓存到一个 key 中,对于 Redis 是不友好的,所以说需要按天拆分 key。

前面说到 key 的类型为 sorted set,那么 score 是什么呢?score 是一个自增数字,我们用自增数字来唯一标识一条聊天记录。者个自增数字使用的是 Redis 的 incr 操作客户端本地存留一个上次阅读到的聊天 Id,服务器端可以根据上次阅读Id,就可以给客户端返回历史记录(操作过程中会涉及到分页查询)。

3. 性能测试

关于性能调优的方法和工具,可以参见我写的《Node 基础教程》中的第11章 Node 调优

Comments