一次 Node.js 服務線上問題引出的 DNS 快取方案研究與思考
原創作者: @guanyu,畢業於吉林大學·軟體工程專業,感謝 @guanyu 提供案例以及文章。
問題背景
某天上午,運營同學突然在群裡反饋很多使用者來報登入問題。起初以為是內網介面服務異常了,但介面反饋沒有產生異常的日誌,也就是說異常請求還沒打過去。於是我們登入伺服器,篩選了下 Node。js 服務的日誌:
透過日誌,我們可以很直觀的看出問題所在:
DNS 解析失敗
整理思路
作為一個日均流量過千萬的 Node。js 服務,每個請求都需要
解析 N 個內網介面域名
。
平時還好,如果
DNS 服務出現了問題
,或者
網路抖動
,很容易在 Node。js 服務與內網介面服務都正常的情況下,導致
線上業務不可用。
針對這種情況,我們需要在 Node。js 服務端
對 DNS 解析做一層快取。
首先我們需要明確一點:
!!!Node.js 本身不做 DNS 查詢結果的快取!!!
預設 DNS 查詢方案
Node。js 下預設的 DNS 查詢方案:
Node。js
內建的 http 模組的 http.request() 請求
時,會
使用 dns.lookup() 進行查詢:
方法呼叫鏈條是
http.request() -> net.createConnection() -> dns.lookup()
function
lookupAndConnect
(
self
,
options
)
{
// 。。。
const
lookup
=
options
。
lookup
||
dns
。
lookup
;
defaultTriggerAsyncIdScope
(
self
[
async_id_symbol
],
function
()
{
lookup
(
host
,
dnsopts
,
function
emitLookup
(
err
,
ip
,
addressType
)
{
self
。
emit
(
‘lookup’
,
err
,
ip
,
addressType
,
host
);
// 。。。
});
});
}
透過這段程式碼我們可以看出,
options.lookup 引數可以自行設定,可以傳入 dns.resolve 或者自定義的符合要求的方法。
getaddrinfo 函式
在 C/C++ 程式碼中
p()
方法呼叫到最終,呼叫的是
底層的 getaddrinfo() 函式
(也就是上文報錯點)。
在 C/C++ 程式碼中
getaddrinfo
函式是
同步呼叫
,所以
需要 libuv 透過執行緒池來實現 Node.js 的非同步 I/O。
注:查閱相關資料,我們可以看到
執行緒池預設大小是 4。
當請求在 DNS 查詢階段耗時過長時,由於預設執行緒池過小,服務處理請求的速度跟請求數量遠遠不匹配,
為 1024。
可能會出現的問題
當請求在 DNS 查詢階段耗時過長時,由於預設執行緒池過小,服務處理請求的速度跟請求數量遠遠不匹配,
服務執行時間越長積壓的請求數連線數就越多。
關於預設快取
使用 DNS快取
本身不做 DNS 查詢結果的快取!!!
,Node。js
每次域名請求時都會請求 DNS Server
使用 DNS 快取
注意快取的過期時間
實現 DNS 快取的相關依賴
lookup-dns-cache
lookup-dns-cache 是很成熟的 DNS 快取庫,但比較古老:
他的思路比較簡單:
底層查詢使用了
dns.resolve() 來替換 dns.lookup
避免並行的 DNS請求存已經解析出來的 hostname 資訊
避免並行的 DNS 請求
。同一時間只執行一個對相同 hostname 的查詢請求,透過 Map 來實現
dns。resolve
與
dns。lookup
區別
透過官方文件可以看出:
dns.resolve 不使用 getaddrinfo()
dns.resolve 是非同步實現的
dns.resolve 不使用 析本地 hosts 檔案,直接走網路解析
詳情可以檢視:
示例程式碼
const { resolve, lookup } = require(‘dns’);
lookup(‘preview4。xx。xx。com’, (err, address, family) => {
console。log(‘地址: %j 地址族: IPv%s’, address, family); // 地址: “xxx。xxx。xx。xx” 地址族: IPv4
});
resolve(‘preview4。xx。xx。com’, (err, records) => {
console。log(records); // undefined
});
preview4。xx。xx。com
是
本地 host 配置的域名
。
由於
dns.resolve() 不使用 getaddrinfo(),所以此時解析出來的地址為 undefined。
避免並行請求實現
利用 Map 對正在查詢的 hostname 做快取。查詢結束後從 Map 中刪除:
let task = this。_tasksManager。find(key);
if (task) {
task。addResolvedCallback(callback);
} else {
task = new ResolveTask(hostname, ipVersion);
this。_tasksManager。add(key, task);
task。on(‘addresses’, addresses => {
this。_addressCache。set(key, addresses);
});
task。on(‘done’, () => {
this。_tasksManager。done(key);
});
task。addResolvedCallback(callback);
task。run();
}
透過
ttl 的快取
透過 dns.reslove 方法設定 ttl:tru
這個庫,作者是 got 的作者 szmarczak。
判斷 hostname 還在快取且未過期時直接返回快取,否則進行查詢
/**
* @param {string} key
* @returns {Address[]|undefined}
*/
find(key) {
if (!this。_cache。has(key)) {
return;
}
const addresses = this。_cache。get(key);
if (this。_isExpired(addresses)) {
return;
}
return addresses;
}
cacheable-lookup
在實際使用中,發現了
dns.resolve()無法解析本地 hosts 配置
的域名,單純使用
lookup-dns-cache
會導致本地開發環境出現報錯。
經過調研,發現了 cacheable-lookup 這個庫,作者是 got 的作者 szmarczak。
透過提交記錄我們可以看出,作者還在保持的對庫的持續更新。
對比 lookup-dns-cache
async queryAndCache(hostname) {
if (this。_hostnamesToFallback。has(hostname)) {
return this。_dnsLookup(hostname, all);
}
let query = await this。_resolve(hostname);
if (query。entries。length === 0 && this。_dnsLookup) {
query = await this。_lookup(hostname);
if (query。entries。length !== 0 && this。fallbackDuration > 0) {
// Use `dns。lookup(。。。)` for that particular hostname
this。_hostnamesToFallback。add(hostname);
}
}
const cacheTtl = query。entries。length === 0 ? this。errorTtl : query。cacheTtl;
await this。_set(hostname, query。entries, cacheTtl);
return query。entries;
}
透過原始碼我們可以看出
基於 ttl 的快取方法沒有解析成功時,會使用 lookup 方法進行兜底
,更符合我們
本地開發環境更改 hosts 檔案
的場景。
同時,這個庫也提供了
基於 ttl 的快取
、
阻止並行請求
等功能點。
於是,我們可以解決我們的問題了!
解決方案
在內網介面呼叫處統一增加
cacheable-lookup
進行 DNS 解析的快取。本文透過一個實際案例,剖析了 Node。js 中關於 DNS cache 的處理和進一步方案,希望對大家有所啟發,當然,相關話題仍然可以挖掘更深,實踐更深,後續我們將會繼續這方面的探索和輸出。
Happy coding!