這樣加個中間件,接口速度提升 1000%

本文是在開發 mockm 周邊過程中的創作。它可以快速生成 api 以及創造數據,開箱即用,便於部署,懇求不吝提出寶貴意見。

動機

最近在做一個 curd 項目,這裏我們代名爲 myApi ,用於實現 0 代碼、無需聲明模型、自動實現增刪改查一些列的接口,支持任意關係型數據庫。

經過幾天的努力,終於把基本的 curd 實現了。但是性能如何呢?要知道,就是因爲 json-server 的性能瓶頸太低,才創建此項目的。

json-server 使用 json 作爲數據存儲載體,當數據大於 10M 的時候,就觸及了性能瓶頸,好在該庫是用於原型開發,但如果要在生產環境使用,那等於埋了一個雷。

如何進行接口性能測試?

因爲已經安裝了 node,那就使用 autocannon ,它用於對 http/https 進行負載測試。

Alt text

測試的結果如下圖:

Alt text

數據解釋:

Alt text

如何優化接口性能?

談到優化,一般可以有很多個方向,例如業務邏輯、緩存,提升硬件配置等。

但是由於我們的 myApi 項目邏輯本就很簡單,所以就先從緩存入手吧,因爲經過分析,當前我們的項目是缺少緩存這一實現的。

Alt text

緩存之 etag

ETag(Entity Tag)是一種由服務器生成的唯一標識符,用於表示資源的特定版本。服務器可以根據資源的內容、大小、最後修改時間等生成 ETag 值。當客戶端請求資源時,服務器可以將 ETag 值包含在響應的頭部中。客戶端在後續請求中可以使用該 ETag 值來檢查資源是否已經發生變化。

在 Express 中,我們可以使用 etag 中間件來啓用 ETag 緩存。etag 中間件會自動處理 ETag 的生成和比較工作,並在響應頭中添加相應的 ETag 值。

下面是一個示例代碼,展示瞭如何在 Express 中使用 ETag 緩存:

const express = require("express");
const app = express();
app.set("etag", true);

app.use(express.static("public")); // 靜態資源目錄

app.get("/api/data", (req, res) => {
  const data = {
    message: "Hello, World!",
  };
  res.json(data);
});

app.listen(3000, () => {
  console.log("Server started on port 3000");
});

在上述代碼中,我們首先使用 express.static 中間件指定了一個靜態資源目錄 'public'。這意味着 Express 將自動處理位於 'public' 目錄下的靜態文件,併爲它們生成 ETag 值。

然後,我們定義了一個路由 /api/data,用於返回一個 JSON 數據。在每個請求中,Express 會自動檢查客戶端請求的 ETag 值是否與服務器上的資源匹配。如果匹配,則服務器返回 304 Not Modified 狀態碼,表示客戶端可以使用緩存的版本。如果不匹配,則服務器返回新的資源,並在響應頭中添加新的 ETag 值。

Alt text

ETag 的生成規則是響應體的簡短唯一值,可以看成一個哈希值。觀察上面的兩張圖片,第一次請求時,響應碼是 200,並且返回了 Etag 。當下次請求的時候,會把該值帶上,服務器會檢查該值是否與之前的一致,如果一致,說明沒有變化,返回 304,否則返回 200。

Alt text

Alt text

在 chrome 中發起幾個接口請求:可以看到,第一次請求時數據爲 1.6M,第二次爲 214B,請求時間從 155ms 到 99ms 。這得到了以下提升:

  • 響應體減少了,在客戶端網速很差的情況下,不用再次下載 1.6M 的數據。
  • 請求時間變快了,因爲響應體沒有變,服務器可以對客戶端進行響應,再做後續邏輯。

緩存之 cache-control

然後從 cmd 控制檯中可以看到,再次請求都有執行 sql。

Alt text

雖然單個 sql 的查詢速度通常在 幾十到一百毫秒以內。但是爲了實現多數據庫兼容,我們使用了 ORM 框架,這有一定的轉換成本,如果有複雜的業務邏輯存在,又加單次增加不少時間。在這幾種疊加效果上,再加上併發的話,差異就會變成非常明顯。

爲了減少服務器壓力,在數據沒有變更的情況下,我們最好連業務邏輯相關的代碼也不要執行。

那麼問題就來了,我們怎麼知道數據有沒有變更呢?又如何爲沒有變更的數據進行緩存?

首先我們可以使用 http cache-control 對緩存進行精細化控制。

本文重點請幾個屬性,如果要了解更多信息,請在參考一節中查詢相關文章。

在 Web 開發中,no-cachemax-age 是與緩存相關的 HTTP 頭部指令,用於控制客戶端和服務器之間的緩存行爲。它們可以幫助我們更精確地管理緩存策略,以提高性能和資源利用率。

no-cache

no-cache 用於告訴客戶端在使用緩存之前先與服務器確認資源是否已更改。當客戶端收到帶有 no-cache 指令的響應時,它將發送一個條件請求到服務器,以檢查資源是否仍然有效。

服務器在收到條件請求後,可以執行一些驗證邏輯,例如檢查資源的最後修改日期或生成 ETag 值。如果資源未更改,服務器將返回 304 Not Modified 狀態碼,告知客戶端可以使用緩存的版本。如果資源已更改,服務器將返回新的資源,並在響應頭中添加新的緩存控制指令。

使用 no-cache 可以確保客戶端始終與服務器進行驗證,以獲取最新的資源版本。這對於某些內容頻繁更改的動態資源非常有用,但也會增加一定的網絡開銷。

max-age

max-age 用於指定資源在客戶端緩存中的最大有效期。它表示從服務器發送響應後,客戶端可以在指定的時間段內使用緩存的版本,而無需向服務器發出請求。

max-age 的值是以秒爲單位的時間間隔。例如,max-age=3600 表示資源在客戶端緩存中的有效期爲 1 小時。當客戶端再次請求相同的資源時,在 max-age 的時間範圍內,客戶端將直接使用緩存的版本,並避免向服務器發出請求。

使用 max-age 可以減少與服務器的通信次數,提高性能和響應速度。但請注意,這也可能導致客戶端在有效期內使用過時的資源,因此需要根據資源的特性和更新頻率進行適當的設置。

組合使用

no-cachemax-age 可以結合使用,以在客戶端和服務器之間實現更細粒度的緩存控制。例如,可以使用 no-cache 確保客戶端始終與服務器進行驗證,但同時使用 max-age 設置一個相對較長的有效期,以減少與服務器的通信次數。

以下是一個示例響應頭,演示了 no-cachemax-age 的組合使用:

Cache-Control: no-cache, max-age=3600

在上述示例中,響應頭中同時包含了 no-cachemax-age 指令。這意味着客戶端將始終與服務器進行驗證,但在驗證通過後,可以在 1 小時內使用緩存的版本。

通過合理地使用 no-cachemax-age,我們可以根據資源的特性和需求,靈活地控制緩存策略,從而提高應用程序的性能和用戶體驗。

總結

  • no-cache 指令告訴客戶端在使用緩存之前先與服務器確認資源是否已更改。
  • max-age 指令指定資源在客戶端緩存中的最大有效期。
  • no-cachemax-age 可以結合使用,以實現更精確的緩存控制策略。

緩存相關工具庫

apicache

源碼地址爲 https://github.com/kwhitley/apicache ,共 1.2k stars,最近維護時間爲 3 年前。對其嘗試了下,默認情況下它會對所有路由進入緩存。這可以理解。

但是,在 myApi 中,當我首先調用 GET /api/book 獲取所有書本列表,再調用 POST /api/book 創建一本書,這時 POST /api/book 的請求竟然會返回 /GET /api/book 的響應,這讓我很意外。看進來默認情況下實現的是 path 級別的緩存,而不是 method + path 級別的緩存。

apicache 提供了一個選項 appendKey 用於自定義緩存的 key,應該是精細級別的控制。這個函數我沒有測試過。

apicache.options({
  appendKey: (req, res) => req.method + res.session.id,
});

還有另外一個選項叫緩存組,看起來像是我要的東西(我想緩存一個 table 表,如果表中任何數據更新,則此表的緩存也要更新):

var apicache = require("apicache");
var cache = apicache.middleware;

// GET collection/id
app.get("/api/:collection/:id?", cache("1 hour"), function (req, res, next) {
  req.apicacheGroup = req.params.collection;
  // do some work
  res.send({ foo: "bar" });
});

// POST collection/id
app.post("/api/:collection/:id?", function (req, res, next) {
  // update model
  apicache.clear(req.params.collection);
  res.send("added a new item, so the cache has been cleared");
});

觀察上面代碼,/api/:collection 中的 :collection 類比表名,如果通過 POST /api/:collection 進行數據創建時,就調用 apicache.clear(req.params.collection) 清除 :collection 表的緩存。

可是經測試,結果還是不對。可能我的使用方式還不是太正確,但介於此庫 3 年沒有更新,以及我自身有一些想着反正都是根據邏輯來控制緩存策略,那好像和自己寫一個緩存器也沒什麼區別,對於此工具提供的 redis 支持,在 myApi 裏是不需要的。所以沒有繼續探究問題出在哪裏。

lru-cache

star 高達 5k,npm 周使用量 185,936,796 。這是一個基於訪問頻率進入緩存保留的庫。即那些不常用的緩存會先被刪除。

@isaacs/ttlcache

這是一個基於時間的緩存庫,最先到期的緩存會最先被刪除。

選擇和應用

在我的用例中,我認爲基於時間的緩存策略對於我來說是更好的選擇。所以我選擇了 @isaacs/ttlcache

const TTLCache = require("@isaacs/ttlcache");
const cacheStore = {};
function cacheFn(config) {
  return (req, res, next) => {
    const method = req.method.toLowerCase();
    const query = Object.keys(req.query).length ? req.query : ``;
    const { table = ``, id = `` } = req.params;
    const cacheTable = (cacheStore[table] =
      cacheStore[table] || new TTLCache({ max: 10000, ttl: 1e3 * 10 })); // 表級別的緩存
    if (method === `get`) {
      const disableCache = req.headers["cache-control"] === `no-cache`;
      const reqKey = generateMD5(
        [method, table, id, JSON.stringify(query)].join(``)
      );
      const etag = req.headers["if-none-match"];
      console.log({ reqKey, etag });
      // 禁用緩存時,獲取最新數據,並刷新緩存,這樣在開啓緩存時會直接命中緩存
      if (disableCache) {
        cacheTable.set(reqKey, true);
        next();
      } else if (!cacheTable.get(reqKey)) {
        // 緩存失效時,獲取最新數據並刷新緩存
        cacheTable.set(reqKey, true);
        next();
      } else if (cacheTable.get(reqKey)) {
        // 緩存有效時,告訴瀏覽器緩存可用
        res.setHeader("Cache-Control", "no-cache");
        res.status(304).end();
      } else {
        next();
      }
    } else {
      cacheTable.clear();
      next();
    }
    console.log(cacheStore);
  };
}

這段代碼的主要功能是根據請求的不同情況來處理緩存。

  1. 在每個請求中,根據請求的方法、表名、ID 和查詢參數生成一個唯一的請求鍵 reqKey
  2. 如果請求方法是 GET
    • 檢查請求頭中是否包含 cache-control: no-cache 指令,如果是,則禁用緩存,獲取最新數據,並刷新緩存。
    • 如果緩存失效(即 reqKey 在緩存中不存在),則獲取最新數據,並刷新緩存。
    • 如果緩存有效(即 reqKey 在緩存中存在),則設置響應頭 Cache-Control: no-cache,並返回 304 Not Modified 狀態碼,告知瀏覽器可以使用緩存。
    • 如果以上條件都不滿足,則繼續處理下一個中間件或路由處理程序。
  3. 如果請求方法不是 GET,則清除緩存,並繼續處理下一個中間件或路由處理程序。
  4. 在每個請求結束後,打印緩存存儲對象 cacheStore

使用該插件後的測試效果爲: 100 併發 800ms 左右

Alt text

額外的思考和行動

在上面的代碼中,可以看到我們爲每個 table 實現了一個緩存實例。在實例中每個 table 下的 api 是緩存的子項,如果子項有更新,則緩存實例會更新。而所有的實例都保存在 cacheStore 這個對象中。這樣看來,@isaacs/ttlcache 這個庫似乎最大的作用就是用來管理緩存總數、過期時間,提供了一些簡單的 get set clear 方法而已?那我嘗試弄一個看看,這樣又可以少引用一個庫了?

首先,反正大家都是內存緩存,那直接把狀態保存在公用對象中即可。另外,過期時間和緩存大小這些通過 Date.now() 對比,數組 length 對比這些好像就能搞定。試試看:

class TTLCache {
  options = {
    ttl: 60 * 60 * 1e3, // 緩存過期時間
    max: 10 * 1e3, // 最大緩存數量
  };
  store = {}; // 保存的 key 緩存狀態
  constructor(options) {
    this.options = Object.assign(this.options, options);
    setInterval(() => {
      const list = Object.entries(this.store);
      list.map(([key, val], index) => {
        if (
          index < this.options.max || // 溢出的緩存
          Date.now() - val < this.options.ttl // 過期的緩存
        ) {
          delete this.store[key];
        }
      });
    }, this.options.ttl);
    return this;
  }
  get(key) {
    const old = this.store[key];
    return old ? Date.now() - old < this.options.ttl : false;
  }
  set(key) {
    this.store[key] = Date.now();
  }
  clear() {
    this.store = {};
  }
}

經嘗試,使用自己實現的緩存器,減少 60% 以上的時間,從 800ms 到 320ms。爲了保證內存佔用不高,定時根據 max 配置裁剪緩存數量和過期的緩存。

Alt text

去除 console 後 140ms 左右,大約每秒處理 1400 個請求,5 秒處理完 7000 個請求,併發數爲 200,平均每個接口用時 70ms。

Alt text

store 對象的值如下:

store = {
  "651b10c00298624a5b61053c6d970f7c": 1706627825171,
  d980f49196fce9d6d1fd41557c8ed9da: 1706627825353,
  "34260c615ac67e948250222b0a506136": 1706627825367,
};

乍一看,性能好像是提升了不少,但其實是有很多需要完善的地方的,例如:定時器的執行時間是 ttl 的設置時間,假如 ttl 爲 1 小時,那麼在這 1 小時內,是不會觸發緩存數量裁剪的,在特殊情況下這會導致內存增加。

不過,就一個 hash 和時間,這應該也要不了多少內存(如果存儲的時間是應用啓動時間到當前時間的差,並且使用 etag 的計算方式,可能還會減少很多內存)。所以我就先使用自己的這段代碼吧(應該 ttlcache/lru-cache 他們有更完善的實現,例如內存限制等)。

總結

  • etag:服務器讀取真實數據做比較,如果相同則不發送真實數據。
  • cache:服務器不讀取真實數據,而是返回上次請求中的數據或返回未變更標誌。
  • 難點:判斷某請求是否爲同一請求,例如相同的請求參數和身份。
  • 注意:瀏覽器不使用緩存時,就算服務器發送未變更標記,瀏覽器也不從緩存讀取數據,而是從服務器讀取數據,這時要求服務器必須要發送數據。
  • 結果:從併發 100 avg 800ms 到併發 200 avg 70ms 應該有提升 1000% 的吧。
  • 逃:前端是產品形態差異很大的平臺,不談應用場景都是耍流氓。

相關文章

本文是在開發 mockm 周邊過程中的創作。它可以快速生成 api 以及創造數據,開箱即用,便於部署,懇求不吝提出寶貴意見。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章