本文是在開發 mockm 周邊過程中的創作。它可以快速生成 api 以及創造數據,開箱即用,便於部署,懇求不吝提出寶貴意見。
動機
最近在做一個 curd 項目,這裏我們代名爲 myApi ,用於實現 0 代碼、無需聲明模型、自動實現增刪改查一些列的接口,支持任意關係型數據庫。
經過幾天的努力,終於把基本的 curd 實現了。但是性能如何呢?要知道,就是因爲 json-server 的性能瓶頸太低,才創建此項目的。
json-server 使用 json 作爲數據存儲載體,當數據大於 10M 的時候,就觸及了性能瓶頸,好在該庫是用於原型開發,但如果要在生產環境使用,那等於埋了一個雷。
如何進行接口性能測試?
因爲已經安裝了 node,那就使用 autocannon ,它用於對 http/https 進行負載測試。
測試的結果如下圖:
數據解釋:
如何優化接口性能?
談到優化,一般可以有很多個方向,例如業務邏輯、緩存,提升硬件配置等。
但是由於我們的 myApi 項目邏輯本就很簡單,所以就先從緩存入手吧,因爲經過分析,當前我們的項目是缺少緩存這一實現的。
緩存之 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 值。
ETag 的生成規則是響應體的簡短唯一值,可以看成一個哈希值。觀察上面的兩張圖片,第一次請求時,響應碼是 200,並且返回了 Etag 。當下次請求的時候,會把該值帶上,服務器會檢查該值是否與之前的一致,如果一致,說明沒有變化,返回 304,否則返回 200。
在 chrome 中發起幾個接口請求:可以看到,第一次請求時數據爲 1.6M,第二次爲 214B,請求時間從 155ms 到 99ms 。這得到了以下提升:
- 響應體減少了,在客戶端網速很差的情況下,不用再次下載 1.6M 的數據。
- 請求時間變快了,因爲響應體沒有變,服務器可以對客戶端進行響應,再做後續邏輯。
緩存之 cache-control
然後從 cmd 控制檯中可以看到,再次請求都有執行 sql。
雖然單個 sql 的查詢速度通常在 幾十到一百毫秒以內。但是爲了實現多數據庫兼容,我們使用了 ORM 框架,這有一定的轉換成本,如果有複雜的業務邏輯存在,又加單次增加不少時間。在這幾種疊加效果上,再加上併發的話,差異就會變成非常明顯。
爲了減少服務器壓力,在數據沒有變更的情況下,我們最好連業務邏輯相關的代碼也不要執行。
那麼問題就來了,我們怎麼知道數據有沒有變更呢?又如何爲沒有變更的數據進行緩存?
首先我們可以使用 http cache-control 對緩存進行精細化控制。
本文重點請幾個屬性,如果要了解更多信息,請在參考一節中查詢相關文章。
在 Web 開發中,no-cache
和 max-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-cache
和 max-age
可以結合使用,以在客戶端和服務器之間實現更細粒度的緩存控制。例如,可以使用 no-cache
確保客戶端始終與服務器進行驗證,但同時使用 max-age
設置一個相對較長的有效期,以減少與服務器的通信次數。
以下是一個示例響應頭,演示了 no-cache
和 max-age
的組合使用:
Cache-Control: no-cache, max-age=3600
在上述示例中,響應頭中同時包含了 no-cache
和 max-age
指令。這意味着客戶端將始終與服務器進行驗證,但在驗證通過後,可以在 1 小時內使用緩存的版本。
通過合理地使用 no-cache
和 max-age
,我們可以根據資源的特性和需求,靈活地控制緩存策略,從而提高應用程序的性能和用戶體驗。
總結
no-cache
指令告訴客戶端在使用緩存之前先與服務器確認資源是否已更改。max-age
指令指定資源在客戶端緩存中的最大有效期。no-cache
和max-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);
};
}
這段代碼的主要功能是根據請求的不同情況來處理緩存。
- 在每個請求中,根據請求的方法、表名、ID 和查詢參數生成一個唯一的請求鍵
reqKey
。 - 如果請求方法是
GET
:- 檢查請求頭中是否包含
cache-control: no-cache
指令,如果是,則禁用緩存,獲取最新數據,並刷新緩存。 - 如果緩存失效(即
reqKey
在緩存中不存在),則獲取最新數據,並刷新緩存。 - 如果緩存有效(即
reqKey
在緩存中存在),則設置響應頭Cache-Control: no-cache
,並返回 304 Not Modified 狀態碼,告知瀏覽器可以使用緩存。 - 如果以上條件都不滿足,則繼續處理下一個中間件或路由處理程序。
- 檢查請求頭中是否包含
- 如果請求方法不是
GET
,則清除緩存,並繼續處理下一個中間件或路由處理程序。 - 在每個請求結束後,打印緩存存儲對象
cacheStore
。
使用該插件後的測試效果爲: 100 併發 800ms 左右
額外的思考和行動
在上面的代碼中,可以看到我們爲每個 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 配置裁剪緩存數量和過期的緩存。
去除 console 後 140ms 左右,大約每秒處理 1400 個請求,5 秒處理完 7000 個請求,併發數爲 200,平均每個接口用時 70ms。
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% 的吧。
- 逃:前端是產品形態差異很大的平臺,不談應用場景都是耍流氓。
相關文章
- 內存緩存 https://www.npmjs.com/package/memory-cache
- Express 使用服務端緩存 https://cloud.tencent.com/developer/article/2041817
- etag 在 expressjs 中如何工作 https://stackoverflow.com/questions/24542959/how-does-a-etag-work-in-expressjs
本文是在開發 mockm 周邊過程中的創作。它可以快速生成 api 以及創造數據,開箱即用,便於部署,懇求不吝提出寶貴意見。