Node.js 指南(不要阻塞事件循環或工做池)

不要阻塞事件循環(或工做池)

你應該閱讀這本指南嗎?

若是你編寫的內容比簡短的命令行腳本更復雜,那麼閱讀本文應該能夠幫助你編寫性能更高、更安全的應用程序。php

本文檔是在考慮Node服務器的狀況下編寫的,但這些概念也適用於複雜的Node應用程序,在特定於操做系統的細節有所不一樣,本文檔以Linux爲中心。html

TL; DR

Node.js在事件循環(初始化和回調)中運行JavaScript代碼,並提供一個工做池來處理如文件I/O之類昂貴的任務,Node能夠很好地擴展,有時比Apache等更重量級的方法更好,Node可擴展性的祕訣在於它使用少許線程來處理許多客戶端。若是Node可使用更少的線程,那麼它能夠將更多的系統時間和內存用於客戶端,而不是爲線程支付空間和時間開銷(內存,上下文切換),但因爲Node只有幾個線程,所以你必須明智地使用它們來構建應用程序。node

這是保持Node服務器快速的一個很好的經驗法則:當在任何給定時間與每一個客戶端相關的工做「很小」時,Node很快。linux

這適用於事件循環上的回調和工做池上的任務。git

爲何要避免阻塞事件循環和工做池?

Node使用少許線程來處理許多客戶端,在Node中有兩種類型的線程:一個事件循環(又稱主循環、主線程、事件線程等),以及一個工做池(也稱爲線程池)中的k個Worker的池。github

若是一個線程須要很長時間來執行回調(事件循環)或任務(Worker),咱們稱之爲「阻塞」,雖然線程被阻塞表明一個客戶端工做,但它沒法處理來自任何其餘客戶端的請求,這提供了阻塞事件循環和工做池的兩個動機:web

  1. 性能:若是你常常在任一類型的線程上執行重量級活動,則服務器的吞吐量(請求/秒)將受到影響。
  2. 安全:若是某個輸入可能會阻塞某個線程,則惡意客戶端可能會提交此「惡意輸入」,使你的線程阻塞,並阻止他們爲其餘客戶工做,這將是拒絕服務攻擊。

快速回顧一下Node

Node使用事件驅動架構:它有一個用於協調的事件循環和一個用於昂貴任務的工做池。正則表達式

什麼代碼在事件循環上運行?

當它們開始時,Node應用程序首先完成初始化階段,require模塊並註冊事件的回調,而後,Node應用程序進入事件循環,經過執行適當的回調來響應傳入的客戶端請求,此回調同步執行,並能夠註冊異步請求以在完成後繼續處理,這些異步請求的回調也將在事件循環上執行。數據庫

事件循環還將完成其回調(例如,網絡I/O)所產生的非阻塞異步請求。express

總之,事件循環執行爲事件註冊的JavaScript回調,而且還負責完成非阻塞異步請求,如網絡I/O。

什麼代碼在工做池上運行?

Node的工做池在libuv(docs)中實現,它公開了通用任務提交API。

Node使用工做池來處理「昂貴」的任務,這包括操做系統不提供非阻塞版本的I/O,以及特別是CPU密集型任務。

這些是使用此工做池的Node模塊API:

  1. I/O密集型

    1. DNS:dns.lookup()dns.lookupService()
    2. 文件系統:除fs.FSWatcher()以外的全部文件系統API和明確同步的API都使用libuv的線程池。
  2. CPU密集型

    1. Crypto:crypto.pbkdf2()crypto.randomBytes()crypto.randomFill()
    2. Zlib:除明確同步的那些以外的全部zlib API都使用libuv的線程池。

在許多Node應用程序中,這些API是工做池的惟一任務源,使用C++插件的應用程序和模塊能夠將其餘任務提交給工做池。

爲了完整起見,咱們注意到當你從事件循環上的回調中調用其中一個API時,事件循環花費一些較小的設置成本,由於它進入該API的Node C++綁定並將任務提交給工做池,與任務的總成本相比,這些成本能夠忽略不計,這就是事件循環卸載它的緣由。將這些任務之一提交給工做池時,Node會在Node C++綁定中提供指向相應C++函數的指針。

Node如何肯定接下來要運行的代碼?

抽象地說,事件循環和工做池分別維護待處理事件和待處理任務的隊列。

實際上,事件循環實際上並不維護隊列,相反,它有一組文件描述符,它要求操做系統使用epoll(Linux)、kqueue(OSX)、事件端口(Solaris)或IOCP(Windows)等機制進行監控。這些文件描述符對應於網絡sockets、它正在監視的任何文件,等等,當操做系統說其中一個文件描述符準備就緒時,事件循環會將其轉換爲相應的事件並調用與該事件關聯的回調,你能夠在這裏瞭解更多關於此過程的信息。

相反,工做池使用一個真正的隊列,其條目是要處理的任務,一個Worker今後隊列中彈出一個任務並對其進行處理,完成後,Worker會爲事件循環引起「至少一個任務已完成」事件。

這對於應用程序設計意味着什麼?

在像Apache這樣的每一個客戶端一個線程的系統中,每一個掛起的客戶端都被分配了本身的線程,若是處理一個客戶端的線程阻塞,操做系統將中斷它並給另外一個客戶端一個機會,所以,操做系統確保須要少許工做的客戶端不會被須要更多工做的客戶端形成不利。

由於Node使用不多的線程處理許多客戶端,若是一個線程阻塞處理一個客戶端的請求,那麼待處理的客戶端請求可能不會輪到,直到線程完成其回調或任務。所以,公平對待客戶端是你應用程序的職責,這意味着你不該該在任何單個回調或任務中爲任何客戶端作太多工做。

這是Node能夠很好地擴展的部分緣由,但這也意味着你有責任確保公平的調度,接下來的部分將討論如何確保事件循環和工做池的公平調度。

不要阻塞事件循環

事件循環通知每一個新客戶端鏈接並協調響應的生成,全部傳入請求和傳出響應都經過事件循環傳遞,這意味着若是事件循環在任什麼時候候花費的時間太長,全部當前和新客戶端都不會得到機會。

你應該確保永遠不會阻塞事件循環,換句話說,每一個JavaScript回調都應該快速完成,這固然也適用於你的await、你的Promise.then等等。

確保這一點的一個好方法是考慮回調的「計算複雜性」,若是你的回調不管參數是什麼,都採起必定數量的步驟,那麼你將始終公平地對待每一個掛起的客戶端,若是你的回調根據其參數採用不一樣的步驟數,那麼你應該考慮參數可能有多長。

示例1:一個固定時間的回調。

app.get('/constant-time', (req, res) => {
  res.sendStatus(200);
});

示例2:O(n)回調,對於小n,此回調將快速運行,對於大n,此回調將緩慢運行。

app.get('/countToN', (req, res) => {
  let n = req.query.n;

  // n iterations before giving someone else a turn
  for (let i = 0; i < n; i++) {
    console.log(`Iter {$i}`);
  }

  res.sendStatus(200);
});

示例3:O(n^2)回調,對於小n,此回調仍將快速運行,但對於大n,它將比前一個O(n)示例運行得慢得多。

app.get('/countToN2', (req, res) => {
  let n = req.query.n;

  // n^2 iterations before giving someone else a turn
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      console.log(`Iter ${i}.${j}`);
    }
  }

  res.sendStatus(200);
});

你應該多麼當心?

Node將Google V8引擎用於JavaScript,這對於許多常見操做來講很是快,此規則的例外是正則表達式和JSON操做,以下所述。

可是,對於複雜的任務,你應該考慮限制輸入並拒絕太長的輸入,這樣,即便你的回調具備很大的複雜性,經過限制輸入,你能夠確保回調不會超過最長可接受輸入的最壞狀況時間,而後,你能夠評估此​​回調的最壞狀況成本,並肯定其上下文中的運行時間是否可接受。

阻塞事件循環:REDOS

阻塞事件循環災難性的一種常見方法是使用「易受攻擊」的正則表達式

避免易受攻擊的正則表達式

正則表達式(regexp)將輸入字符串與模式匹配,咱們一般認爲正則表達式匹配須要單次經過輸入字符串 — O(n)時間,其中n是輸入字符串的長度,在許多狀況下,確實單次經過。

不幸的是,在某些狀況下,正則表達式匹配可能須要經過輸入字符串的指數次數 — O(2^n)時間,指數次數意味着若是引擎須要x次以肯定匹配,若是咱們只在輸入字符串中添加一個字符,它將須要2*x次,因爲次數與所需時間成線性關係,所以該評估的效果將是阻塞事件循環。

一個易受攻擊的正則表達式可能會使你的正則表達式引擎花費指數級的時間,使你暴露在「惡意輸入」上的REDOS中。你的正則表達式模式是否易受攻擊(即正則表達式引擎可能須要指數時間)其實是一個難以回答的問題,並取決於你使用的是Perl、Python、Ruby、Java、JavaScript等,可是這裏有一些適用於全部這些語言的經驗法則:

  1. 避免嵌套量詞,如(a+)*,Node的regexp引擎能夠快速處理其中的一些,但其餘引擎容易受到攻擊。
  2. 避免使用帶有重疊子句的OR,如(a|a)*,一樣,這些有時是快速的。
  3. 避免使用反向引用,例如(a.*) \1,沒有正則表達式引擎能夠保證在線性時間內評估它們。
  4. 若是你正在進行簡單的字符串匹配,請使用indexOf或本地等效項,它會更便宜,永遠不會超過O(n)

若是你不肯定你的正則表達式是否容易受到攻擊,請記住,Node一般不會遇到報告匹配的問題,即便是易受攻擊的正則表達式和長輸入字符串,當存在不匹配時觸發指數行爲,可是在嘗試經過輸入字符串的許多路徑以前,Node沒法肯定。

一個REDOS的例子

如下是將其服務器暴露給REDOS的易受攻擊的正則表達式示例:

app.get('/redos-me', (req, res) => {
  let filePath = req.query.filePath;

  // REDOS
  if (fileName.match(/(\/.+)+$/)) {
    console.log('valid path');
  }
  else {
    console.log('invalid path');
  }

  res.sendStatus(200);
});

這個例子中易受攻擊的正則表達式是一種(糟糕的)方法來檢查Linux上的有效路徑,它匹配的字符串是「/」的序列 — 分隔名稱,如「/a/b/c」,它很危險,由於它違反了規則1:它有一個雙重嵌套的量詞。

若是客戶端使用filePath ///.../\n查詢(100個/後跟換行符,正則表達式的「.」不會匹配),那麼事件循環將永遠有效,阻塞事件循環,此客戶端的REDOS攻擊致使全部其餘客戶端在正則表達式匹配完成以前不會輪到。

所以,你應該謹慎使用複雜的正則表達式來驗證用戶輸入。

Anti-REDOS資源

有一些工具能夠檢查你的正則表達式是否安全,好比

  • safe-regex
  • rxxr2,然而,這些都不能捕獲全部易受攻擊的正則表達式。

另外一種方法是使用不一樣的正則表達式引擎,你可使用node-re2模塊,該模塊使用Google超快的RE2正則表達式引擎,但請注意,RE2與Node的正則表達式不是100%兼容,所以若是你交換node-re2模塊來處理你的正則表達式,請回歸檢查,而且node-re2不支持特別複雜的正則表達式。

若是你正在嘗試匹配「明顯」的內容,例如URL或文件路徑,請在正則表達式庫中查找示例或使用npm模塊,例如:ip-regex

阻塞事件循環:Node核心模塊

幾個Node核心模塊具備同步昂貴的API,包括:

  • Encryption
  • Compression
  • File system
  • Child process

這些API很昂貴,由於它們涉及大量計算(加密、壓縮),須要I/O(文件I/O),或者可能二者(子進程),這些API旨在方便腳本,但不打算在服務器上下文中使用,若是在事件循環上執行它們,它們將比典型的JavaScript指令花費更長的時間來完成,從而阻塞事件循環。

在服務器中,你不該使用如下模塊中的如下同步API:

  • Encryption:

    • crypto.randomBytes(同步版本)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • 你還應該當心爲加密和解密例程提供大量輸入。
  • Compression:

    • zlib.inflateSync
    • zlib.deflateSync
  • File system:

    • 不要使用同步文件系統API,例如,若是你訪問的文件位於NFS分佈式文件系統中,則訪問時間可能會有很大差別。
  • Child process:

    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

從Node v9開始,此列表至關完整。

阻塞事件循環:JSON DOS

JSON.parseJSON.stringify是其餘可能很昂貴的操做,雖然這些在輸入的長度上是O(n),但對於大的n,它們可能花費驚人的長。

若是你的服務器操縱JSON對象,特別是來自客戶端的JSON對象,你應該對在事件循環上使用的對象或字符串的大小保持謹慎。

示例:JSON阻塞,咱們建立一個大小爲2^21的對象obj而且JSON.stringify它,在字符串上運行indexOf,而後JSON.parse它,JSON.stringify的字符串是50MB,字符串化對象須要0.7秒,對50MB字符串的indexOf須要0.03秒,解析字符串須要1.3秒。

var obj = { a: 1 };
var niter = 20;

var before, res, took;

for (var i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj }; // Doubles in size each iter
}

before = process.hrtime();
res = JSON.stringify(obj);
took = process.hrtime(before);
console.log('JSON.stringify took ' + took);

before = process.hrtime();
res = str.indexOf('nomatch');
took = process.hrtime(before);
console.log('Pure indexof took ' + took);

before = process.hrtime();
res = JSON.parse(str);
took = process.hrtime(before);
console.log('JSON.parse took ' + took);

有npm模塊提供異步JSON API,例如:

  • JSONStream,具備流API。
  • Big-Friendly JSON,它具備流API以及標準JSON API的異步版本,使用下面概述的事件循環分區範例。

不阻塞事件循環的複雜計算

假設你想在JavaScript中執行復雜計算而不阻塞事件循環,你有兩種選擇:分區或卸載。

分區

你能夠對計算進行分區,以便每一個計算都在事件循環上運行,但會按期產生(轉向)其餘待處理事件,在JavaScript中,很容易在閉包中保存正在進行的任務的狀態,以下面的示例2所示。

舉一個簡單的例子,假設你想要計算數字1n的平均值。

示例1:未分區求平均值,花費O(n)

for (let i = 0; i < n; i++)
  sum += i;
let avg = sum / n;
console.log('avg: ' + avg);

示例2:分區求平均值,n個異步步驟中的每個都花費O(1)

function asyncAvg(n, avgCB) {
  // Save ongoing sum in JS closure.
  var sum = 0;
  function help(i, cb) {
    sum += i;
    if (i == n) {
      cb(sum);
      return;
    }

    // "Asynchronous recursion".
    // Schedule next operation asynchronously.
    setImmediate(help.bind(null, i+1, cb));
  }

  // Start the helper, with CB to call avgCB.
  help(1, function(sum){
      var avg = sum/n;
      avgCB(avg);
  });
}

asyncAvg(n, function(avg){
  console.log('avg of 1-n: ' + avg);
});

你能夠將此原則應用於數組迭代等。

卸載

若是你須要作一些更復雜的事情,分區不是一個好選擇,這是由於分區僅使用事件循環,你幾乎沒法在計算機上使用多個核心,請記住,事件循環應該協調客戶端請求,而不是本身完成它們,對於複雜的任務,將工做循環的工做移到工​​做池上。

如何卸載

對於要卸載工做的目標工做線池,你有兩個選項。

  1. 你能夠經過開發C++插件來使用內置的Node工做池,在舊版本的Node上,使用NAN構建C++插件,在較新版本上使用N-API,node-webworker-threads提供了一種訪問Node的工做池的JavaScript方法。
  2. 你能夠建立和管理專用於計算的工做池,而不是Node的I/O主題工做池,最直接的方法是使用子進程或羣集。

你不該該只是爲每一個客戶建立一個子進程,你能夠比建立和管理子進程更快地接收客戶機請求,你的服務器可能會成爲一個fork炸彈

卸載的缺點

卸載方法的缺點是它會以通訊成本的形式產生開銷,只容許事件循環查看應用程序的「namespace」(JavaScript狀態),從Worker中,你沒法在事件循環的命名空間中操做JavaScript對象,相反,你必須序列化和反序列化你但願共享的任何對象,而後,Worker能夠對它本身的這些對象的副本進行操做,並將修改後的對象(或「補丁」)返回給事件循環。

有關序列化問題,請參閱有關JSON DOS的部分。

一些卸載的建議

你可能但願區分CPU密集型和I/O密集型任務,由於它們具備明顯不一樣的特徵。

CPU密集型任務僅在調度其Worker時進行,而且必須將Worker調度到計算機的一個邏輯核心上,若是你有4個邏輯核心和5個Worker,則其中一個Worker沒法進行,所以,你爲此Worker支付了開銷(內存和調度成本),而且沒有得到任何回報。

I/O密集型任務涉及查詢外部服務提供者(DNS,文件系統等)並等待其響應,雖然具備I/O密集型任務的Worker正在等待其響應,但它沒有其餘任何操做能夠由操做系統取消調度,從而使另外一個Worker有機會提交其請求,所以,即便關聯的線程未運行,I/O密集型任務也將進行。數據庫和文件系統等外部服務提供者已通過高度優化,能夠同時處理許多待處理的請求,例如,文件系統將檢查大量待處理的寫入和讀取請求,以合併衝突的更新並以最佳順序檢索文件(例如,參見這些幻燈片)。

若是你只依賴一個工做池,例如Node工做器池,而後CPU綁定和I/O綁定工做的不一樣特性可能會損害你的應用程序的性能。

所以,你可能但願維護一個單獨的計算工做池。

卸載:結論

對於簡單的任務,例如迭代任意長數組的元素,分區多是一個不錯的選擇,若是你的計算更復雜,卸載是一種更好的方法:通訊成本,即在事件循環和工做池之間傳遞序列化對象的開銷,被使用多個核心的好處所抵消。

若是你採用卸載方法,請參閱有關不阻塞工做池的部分。

不要阻塞工做池

Node有一個由k個Worker組成的工做池,若是你使用上面討論的卸載範例,你可能會有一個單獨的計算工做池,相同的原則適用於此。在任何一種狀況下,咱們假設k遠小於你可能同時處理的客戶端數量,這與Node的「一個線程用於許多客戶端」理念保持一致,這是其可擴展性的祕訣。

如上所述,每一個Worker在繼續執行工做池隊列中的下一個任務以前完成其當前任務。

如今,處理客戶端請求所需的任務成本會有所不一樣,某些任務能夠快速完成(例如,讀取短文件或緩存文件,或產生少許隨機字節),而其餘任務則須要更長時間(例如讀取較大或未緩存的文件,或生成更多隨機字節),你的目標應該是最小化任務時間的變化,你應該使用任務分區來完成此任務。

最小化任務時間的變化

若是Worker的當前任務比其餘任務昂貴得多,那麼它將沒法用於其餘待處理的任務,換句話說,每一個相對較長的任務有效地將工做池的大小減少,直到它完成。這是不可取的,由於在某種程度上,工做者池中的工做者越多,工做者池吞吐量(任務/秒)越大,所以服務器吞吐量越大(客戶端請求/秒),具備相對昂貴的任務的一個客戶端將下降工做池的吞吐量,從而下降服務器的吞吐量。

爲避免這種狀況,你應該儘可能減小提交給工做池的任務長度的變化,雖然將I/O請求(DB,FS等)訪問的外部系統視爲黑盒是合適的,你應該知道這些I/O請求的相對成本,而且應該避免提交你可能預期特別長的請求。

兩個例子能夠說明任務時間的可能變化。

變化示例:長時間運行的文件系統讀取

假設你的服務器必須讀取文件以處理某些客戶端請求,在諮詢了Node的文件系統API以後,爲了簡單起見,你選擇使用fs.readFile(),可是fs.readFile()當前)未分區:它提交一個跨越整個文件的fs.read()任務,若是爲某些用戶讀取較短的文件,爲其餘用戶讀取較長的文件,fs.readFile()可能會致使任務長度的顯着變化,從而損害工做者池的吞吐量。

對於最壞的狀況,假設攻擊者能夠說服你的服務器讀取任意文件(這是目錄遍歷漏洞),若是你的服務器運行的是Linux,攻擊者能夠命名一個速度極慢的文件:/dev/random,出於全部實際目的,/dev/random是無限慢的,每一個Worker要求從/dev/random讀取將永遠不會完成該任務,而後,攻擊者提交k個請求,每一個Worker一個請求,而且使用工做池的其餘客戶機請求不會取得進展。

變化示例:長時間運行的加密操做

假設你的服務器使用crypto.randomBytes()生成加密安全隨機字節,crypto.randomBytes()沒有被分區:它建立一個randomBytes()任務來生成所請求的字節數,若是爲某些用戶建立更少的字節,爲其餘用戶建立更多字節,則crypto.randomBytes()是任務長度的另外一個變化來源。

任務分區

具備可變時間成本的任務可能會損害工做池的吞吐量,爲了儘可能減小任務時間的變化,你應儘量將每一個任務劃分爲可比較的子任務,當每一個子任務完成時,它應該提交下一個子任務,而且當最後的子任務完成時,它應該通知提交者。

要繼續fs.readFile()示例,你應該使用fs.read()(手動分區)或ReadStream(自動分區)。

一樣的原則適用於CPU綁定任務,asyncAvg示例可能不適合事件循環,但它很是適合工做池。

將任務劃分爲子任務時,較短的任務會擴展爲少許的子任務,較長的任務會擴展爲更多的子任務,在較長任務的每一個子任務之間,分配給它的Worker能夠從另外一個較短的任務處理子任務,從而提升工做池的總體任務吞吐量。

請注意,已完成的子任務數量對於工做池的吞吐量而言並非一個有用的指標,相反,請關注完成的任務數量。

避免任務分區

回想一下,任務分區的目的是最小化任務時間的變化,若是你能夠區分較短的任務和較長的任務(例如,彙總數組與排序數組),你能夠爲每一個任務類建立一個工做池,將較短的任務和較長的任務路由到單獨的工做池是另外一種最小化任務時間變化的方法。

支持這種方法,分區任務會產生開銷(建立工做池任務表示和操做工做池隊列的成本),而且避免分區能夠節省額外訪問工做池的成本,它還能夠防止你在分區任務時出錯。

這種方法的缺點是,全部這些工做池中的Worker都會產生空間和時間開銷,而且會相互競爭CPU時間,請記住,每一個受CPU限制的任務僅在調度時才進行,所以,你應該在仔細分析後才考慮這種方法。

工做池:結論

不管你是僅使用Node工做池仍是維護單獨的工做池,你都應該優化池的任務吞吐量,爲此,請使用任務分區最小化任務時間的變化。

npm模塊的風險

雖然Node核心模塊爲各類應用程序提供了構建塊,但有時須要更多的東西,Node開發人員從npm生態系統中獲益匪淺,數十萬個模塊提供了加速開發過程的功能。

但請記住,大多數這些模塊都是由第三方開發人員編寫的,而且一般只發布盡力而爲的保證,使用npm模塊的開發人員應該關注兩件事,儘管後者常常被遺忘。

  1. 它是否遵循其API?
  2. 它的API可能會阻塞事件循環或Worker嗎?許多模塊都沒有努力代表其API的成本,這對社區不利。

對於簡單的API,你能夠估算API的成本,字符串操做的成本並不難理解,但在許多狀況下,尚不清楚API可能會花費多少。

若是你正在調用可能會執行昂貴操做的API,請仔細檢查成本,要求開發人員記錄它,或者本身檢查源代碼(並提交記錄成本的PR)。

請記住,即便API是異步的,你也不知道它可能花費多少時間在Worker或每一個分區的事件循環上。例如,假設在上面給出的asyncAvg示例中,對helper函數的每次調用將一半的數字相加而不是其中一個,那麼這個函數仍然是異步的,可是每一個分區的成本都是O(n),而不是O(1),這使得用於任意n值的安全性要低得多。

結論

Node有兩種類型的線程:一個事件循環和k個Worker,事件循環負責JavaScript回調和非阻塞I/O,而且Worker執行與完成異步請求的C++代碼相對應的任務,包括阻塞I/O和CPU密集型工做,兩種類型的線程一次只能處理一個活動,若是任何回調或任務須要很長時間,則運行它的線程將被阻塞。若是你的應用程序進行阻塞回調或任務,則可能致使吞吐量(客戶端/秒)降級最多,而且最壞狀況下會致使徹底拒絕服務。

要編寫高吞吐量、更多防DoS的Web服務器,你必須確保在良性和惡意輸入上,你的事件循環和Worker都不會阻塞。


上一篇:Node.js事件循環、定時器和process.nextTick()

下一篇:Node.js中的定時器

相關文章
相關標籤/搜索