Node.js DNS 模塊的小優化
這幾天看到了一個關于緩存 Node.js DNS 結果的 PR,然后看了下 c-ares 的代碼,發現 Node.js DNS 模塊也有些可以改進的小地方,所以提交了兩個 PR 嘗試進行優化,本文簡單介紹下相關的內容。
c-ares
c-ares 庫是一個異步的 DNS 解析庫,在 Node.js 中的工作原理如下。
- Node.js 調用 c-ares 發起一個 DNS 查詢,并注冊回調。
- c-ares 創建一個 socket,通知 Node.js 監聽該 socket 的讀事件。
- 該 socket 可讀,Node.js 通知 c-ares,c-ares 讀取響應,并通知 Node.js。
- Node.js 調 c-ares 接口解析 DNS 響應。
- c-ares 回調 Node.js。
另外 Node.js 還會定時調 c-ares 函數,c-ares 會判斷是否查詢請求是否超時。大致了解 c-ares 的基礎后,接下來看看相關的內容。
1. DNS 緩存
c-ares 支持緩存 DNS 響應,具體的緩存時間取決于 DNS 響應報文的 ttl 和 c-ares 的配置。下面是處理 DNS 響應時記錄緩存的代碼。
static ares_status_t ares_qcache_insert_int(ares_qcache_t *qcache,
ares_dns_record_t *qresp,
const ares_dns_record_t *qreq,
const ares_timeval_t *now)
{
ares_qcache_entry_t *entry;
unsigned int ttl;
// DNS 響應報文信息
ares_dns_rcode_t rcode = ares_dns_record_get_rcode(qresp);
ares_dns_flags_t flags = ares_dns_record_get_flags(qresp);
// 獲取 DNS 響應報文的 ttl
ttl = ares_qcache_calc_minttl(qresp);
// 和用戶配置的 ttl 比較,取最小值
if (ttl > qcache->max_ttl) {
ttl = qcache->max_ttl;
}
// 插入緩存
entry->dnsrec = qresp;
entry->expire_ts = (time_t)now->sec + (time_t)ttl;
entry->insert_ts = (time_t)now->sec;
entry->key = ares_qcache_calc_key(qreq);
ares_htable_strvp_insert(qcache->cache, entry->key, entry);
ares_slist_insert(qcache->expire, entry);
return ARES_SUCCESS;
}
下面是 DNS 查詢時緩存的處理。
if (!(flags & ARES_SEND_FLAG_NOCACHE)) {
status = ares_qcache_fetch(channel, &now, dnsrec, &dnsrec_resp);
// 存在緩存直接返回
if (status != ARES_ENOTFOUND) {
callback(arg, status, 0, dnsrec_resp);
return status;
}
}
但是 c-ares 1.31.0 后自動開啟了緩存,這對于用戶來說可能不是預期的行為,所以 Node.js 提交了 PR 關閉了緩存能力,保證了兼容性。同時,Node.js 后續會提供選項讓用戶可以自定義配置緩存的時間。具體可以參考以下 PR。
- https://github.com/c-ares/c-ares/pull/786
- https://github.com/nodejs/node/pull/57640
- https://github.com/nodejs/node/pull/58404
在了解這個 PR 的同時,也發現了兩個 Node.js DNS 模塊的優化點。
2. 定時器的超時時間
Node.js 會定時調用 c-ares 函數,讓 c-ares 判斷查詢請求是否超時,目前 Node.js DNS 模塊的定時器邏輯如下。
void ChannelWrap::StartTimer() {
int timeout = timeout_;
if (timeout == 0) timeout = 1;
if (timeout < 0 || timeout > 1000) timeout = 1000;
uv_timer_start(timer_handle_, AresTimeout, timeout, timeout);
}
可以看到當 timeout 小于 0 時,定時間隔為 1000ms,也就是說 Node.js 會每隔 1000ms 回調 c-ares 判斷查詢是否超時,而當 timeout 等于 0 時,Node.js 設置的定時間隔為 1ms,但是在 c-ares 中當 timeout 等于 -1 和等于 0 時的邏輯是一樣的,都是使用默認的超時時間 2s,相關代碼如下。
if (optmask & ARES_OPT_TIMEOUTMS) {
// 小于 0 則使用默認值
if (options->timeout <= 0) {
optmask &= ~(ARES_OPT_TIMEOUTMS);
} else {
channel->timeout = (unsigned int)options->timeout;
}
}
if (channel->timeout == 0) {
channel->timeout = DEFAULT_TIMEOUT; // 2s
}
所以如果用戶設置 timeout = 0,Node.js 就會頻繁地調用(每隔 1ms)c-ares 判斷是否超時,但這是沒必要的。優化后的代碼如下。
void ChannelWrap::StartTimer() {
int timeout = timeout_;
if (timeout <= 0 || timeout > 1000) timeout = 1000;
uv_timer_start(timer_handle_, AresTimeout, timeout, timeout);
}
優化的邏輯很簡單,保證 timeout 等于 0 和等于 -1 時的邏輯一致即可。通過測試大概 CPU 使用率下降 2% 左右,測試例子如下。
const { Resolver } = require('dns');
const { createSocket } = require('dgram');
const socket = createSocket('udp4');
socket.bind(0, 'localhost', () => {
const resolver = new Resolver({ timeout: 0, tries: 4 });
resolver.setServers([`${socket.address().address}:${socket.address().port}`])
resolver.resolve('nodejs.org', () => {
socket.close();
});
});
具體可以參考 PR:https://github.com/nodejs/node/pull/58441。
3. 最大超時時間
Node.js DNS 解析有 timeout 和 tries 兩個參數,timeout 表示對于一個 DNS 服務器,一個 DNS 首次查詢的超時時間,tries 表示超時次數,但是超時間隔是按照一定算法計算的(比如指數退避),而不是固定的。看一個例子。
const { Resolver } = require('dns');
const { createSocket } = require('dgram');
const socket = createSocket('udp4');
socket.bind(0, 'localhost', () => {
const resolver = new Resolver({ timeout: 1000, tries: 3 });
resolver.setServers([`${socket.address().address}:${socket.address().port}`])
const start = Date.now();
resolver.resolve('nodejs.org', () => {
socket.close();
console.log(`time: ${Date.now() - start}`);
});
});
例子中輸入的時間大概為 8s,說明不是等間隔重試的,但是有些時候我們希望可以快點重試,比如服務器宕機時快速感知超時,服務重啟時快速獲取結果等,所以我們希望有一種方式可以控制每次重試時的超時時間,而不是使用 c-ares 的默認算法,這個配置就是 c-ares 的 max timeout 配置,最近提了一個 PR 支持該特性,測試例子如下。
const { Resolver } = require('dns');
const { createSocket } = require('dgram');
const socket = createSocket('udp4');
socket.bind(0, 'localhost', () => {
const resolver = new Resolver({ timeout: 1000, tries: 3, maxTimeout: 1000 });
resolver.setServers([`${socket.address().address}:${socket.address().port}`])
const start = Date.now();
resolver.resolve('nodejs.org', () => {
socket.close();
console.log(`time: ${Date.now() - start}`);
});
});
上面代碼輸出是 4s 左右,說明每次重試間隔都是 1s。具體可以參考 PR:https://github.com/nodejs/node/pull/58440。
Node.js DNS 模塊是比較穩定的模塊,功能上變化不大,但是仍然有一些小地方可以進行優化,也算是不斷完善 Node.js 的功能。