iT邦幫忙

2022 iThome 鐵人賽

DAY 18
2

快取

當大量的請求進來時,快取可以用來降低資料庫的負擔。Moleculer 提供了一個內建的快取解決方案用來快取 Actions 的響應,可以在 ServiceBroker 的選項中設定 cacher 的類型,並在 Actions 中設置 cache: true 以啟用快取。

範例:Actions 快取

const { ServiceBroker } = require("moleculer");

// 建立 Broker
const broker = new ServiceBroker({
    cacher: "Memory"
});

// 建立服務
broker.createService({
    name: "users",
    actions: {
        list: {
            // 在 Action 啟用快取
            cache: true,
            handler(ctx) {
                this.logger.info("Handler called!");
                return [
                    { id: 1, name: "John" },
                    { id: 2, name: "Jane" }
                ];
            }
        }
    }
});

// 測試呼叫 Action
broker.start()
    .then(() => {
        // 第一次呼叫時快取是空的,會執行 Action 處理程序
        return broker.call("users.list").then(
            res => broker.logger.info("Users count:", res.length)
        );
    })
    .then(() => {
        // 第二次呼叫時快取有值,不會執行 Action 處理程序
        return broker.call("users.list").then(
            res => broker.logger.info("Users count from cache:", res.length)
        );
    });

主控台訊息:

[2022-09-17T23:19:05.010Z] INFO  workboxy-32400/BROKER: ✔ ServiceBroker with 2 service(s) started successfully in 15ms.
[2022-09-17T23:19:05.012Z] INFO  workboxy-32400/USERS: Handler called!
[2022-09-17T23:19:05.012Z] INFO  workboxy-32400/BROKER: Users count: 2
[2022-09-17T23:19:05.014Z] INFO  workboxy-32400/BROKER: Users count from cache: 2

你可以發現 Handler called! 只出現一次,因為第二次請求的響應是由快取中回傳的。

快取鍵

快取會根據服務名稱、 Action 名稱及參數的 context 來定義鍵值。

格式:

<服務名稱>.<Action 名稱>:<參數或參數的 Hash>

如果你呼叫 posts.list 並給定參數 { limit: 5, offset: 20 } ,這時候快取會根據參數來計算 Hash 值。當下次使用同樣的參數來呼叫同個動作時,快取就會依相同的鍵值來找到該結果。

範例:

posts.list:limit|5|offset|20

由於參數可能夾帶一些與快取無關的屬性,過長的鍵值也可能導致效能問題。因此建議設定好快取的參數屬性,可以有效縮短鍵值並移除無關的屬性。若需要在快取中使用 meta 資訊,鍵的名稱請使用 # 前綴。如果參數值未定義,則會以 undefined 記錄在鍵值中。

範例:

如果參數為 { limit: 10, offset: 30 } 且 meta 為 { user: { id: 123 } } ,將得到快取鍵值 posts.list:10|30|123

module.exports = {
    name: "posts",
    actions: {
        list: {
            cache: {
                //由 "limit" 、 "offset" 參數及 "user.id" meta 資訊產生鍵值
                keys: ["limit", "offset", "#user.id"]
            },
            handler(ctx) {
                return this.getList(ctx.params.limit, ctx.params.offset);
            }
        }
    }
};

這個解決方案非常的快,官方推薦在生產環境中使用它。

限制快取鍵長度

某些情況下,有效的鍵值可能很長,這可能會導致效能問題。為了避免這種情況,請使用 maxParamsLength 選項來限制鍵值的最大長度。當鍵值長度大於設定的最大長度值時,快取由原始的鍵值計算一個 Hash 值(SHA256) ,然後添加到被裁切的鍵值末端。

maxParamsLength 的最小值為 44 (Base64 的 SHA256 長度),若要關閉此功能,請設為 0 或 null 。

範例:未限制長度的情況

cacher.getCacheKey("posts.find", { id: 2, title: "New post", content: "It can be very very looooooooooooooooooong content. So this key will also be too long" });
// 鍵值: 'posts.find:id|2|title|New post|content|It can be very very looooooooooooooooooong content. So this key will also be too long'

範例:限制長度為 60

const broker = new ServiceBroker({
    cacher: {
        type: "Memory",
        options: {
            maxParamsLength: 60
        }
    }
});

cacher.getCacheKey("posts.find", { id: 2, title: "New post", content: "It can be very very looooooooooooooooooong content. So this key will also be too long" });
// 鍵值: 'posts.find:id|2|title|New pL4ozUU24FATnNpDt1B0t1T5KP/T5/Y+JTIznKDspjT0='

快取條件

快取也允許有條件的跳過快取機制來取得 新的 資料。你可以在呼叫 Action 之前,於 meta 中設定 $cache: false 來關閉此機制。

範例:呼叫 Action 時關閉快取

broker.call("greeter.hello", { name: "Moleculer" }, { meta: { $cache: false }}));

你也可以將快取視為一個選項,客製化一個控制函數來啟動快取。客製化函數能接收 context 實例作為參數,因此可以利用 ctx 來查詢參數或 meta 資訊。

範例:客製化快取條件

greeter.service.js

module.exports = {
    name: "greeter",
    actions: {
        hello: {
            cache: {
                // 由 `noCache` 參數決定是否快取
                enabled: ctx => ctx.params.noCache !== true,
                keys: ["name"]
            },
            handler(ctx) {
                this.logger.debug("Execute handler");
                return `Hello ${ctx.params.name}`;
            }
        }
    }
};

// 使用客製化 `enabled` 函數請求關閉快取
broker.call("greeter.hello", { name: "Moleculer", noCache: true });

存活時間

你可以在 ServiceBroker 設定快取的存活時間 (Time To Live, TTL) ,也可以在 Action 選項中覆蓋此設定。

const { ServiceBroker } = require("moleculer");

const broker = new ServiceBroker({
    cacher: {
        type: "memory",
        options: {
            ttl: 30 // 30 秒
        }
    }
});

broker.createService({
    name: "posts",
    actions: {
        list: {
            cache: {
                // 存活時間將被覆蓋為 5 秒
                ttl: 5
            },
            handler(ctx) {
                // ...
            }
        }
    }
});

客製化鍵值產生器

如果要客製化快取鍵值的生成方式,可以在 keygen 設定你自己的客製化函數,並使用 nameparamsmetakeys 來建構函數規則。

const broker = new ServiceBroker({
    cacher: {
        type: "memory",
        options: {
            keygen(name, params, meta, keys) {
                // 產生快取鍵值
                // name - action 名稱
                // params - ctx.params
                // meta - ctx.meta
                // keys - action 定義的快取鍵名稱
                return "";
            }
        }
    }
});

手動快取

快取模組也可以手動使用。只需要呼叫 broker.cachergetsetdel 方法即可。

// 儲存到快取
broker.cacher.set("mykey.a", { a: 5 });

// 取得快取
const obj = await broker.cacher.get("mykey.a")

// 刪除快取項目
await broker.cacher.del("mykey.a");

// 清除所有 'mykey' 項目
await broker.cacher.clean("mykey.**");

// 清除所有項目
await broker.cacher.clean();

範例:當使用內建 Redis 快取時,可以透過 broker.cacher.client 來使用 ioredis 套件的客戶端 API 。

// 建立 ioredis pipeline
const pipeline = broker.cacher.client.pipeline();
// 設定快取值
pipeline.set('mykey.a', 'myvalue.a');
pipeline.set('mykey.b', 'myvalue.b');
// 執行 pipeline
pipeline.exec();

清除快取

當你在服務中建立新的資料模型時,你必須清除一些舊的快取模型項目,使下次請求能得到新的資訊,並建立更新後的快取資訊。

範例:在 Action 內清除快取

{
    name: "users",
    actions: {
        create(ctx) {
            // 建立新的使用者實體
            const user = new User(ctx.params);

            // 清除所有快取項目
            this.broker.cacher.clean();

            // 清除所有 `users.` 開頭的快取項目
            this.broker.cacher.clean("users.**");

            // 清除多種快取項目
            this.broker.cacher.clean([ "users.**", "posts.**" ]);

            // 刪除一個項目
            this.broker.cacher.del("users.list");

            // 刪除多個項目
            this.broker.cacher.del([ "users.model:5", "users.model:8" ]);
        }
    }
}

清除多個實例服務的快取

如果要清除多個服務實例之間的快取項目,推薦的做法是使用廣播事件。注意,此方法僅適用於非集中管理的快取類型,如 MemoryMemoryLRU

範例:

module.exports = {
    name: "users",
    actions: {
        create(ctx) {
            // 建立新的使用者實體
            const user = new User(ctx.params);

            // 清除快取方法
            this.cleanCache();

            return user;
        }
    },

    methods: {
        cleanCache() {
            // 發送廣播事件,包含自己的所有的服務實例都會收到事件
            this.broker.broadcast("cache.clean.users");
        }
    },

    events: {
        "cache.clean.users"() {
            if (this.broker.cacher) {
                this.broker.cacher.clean("users.**");
            }
        }
    }
};

清除相依的多個服務快取

服務相依是很常見的狀況。例如 posts 服務儲存了一些來自 users 服務的快取項目。

範例:

{
    _id: 1,
    title: "My post",
    content: "Some content",
    author: {
        _id: 130,
        fullName: "John Doe",
        avatar: "https://..."
    },
    createdAt: 1519729167666
}

由於範例中的 author 儲存了一些來自 users 服務的資訊,所以當 users 服務清除了快取項目,連帶 posts 也應該要清除自己的快取項目。這種情況下,你應該也要在 posts 服務中訂閱 cache.clear.users 廣播事件來清除快取。

但有一個更簡單的方法,你可以建立一個 CacheCleaner 的混合函數,並在依賴的服務中加入。

cache.cleaner.mixin.js

module.exports = function (serviceNames) {
    const events = {};

    serviceNames.forEach(name => {
        events[`cache.clean.${name}`] = function () {
            if (this.broker.cacher) {
                this.logger.debug(`Clear local '${this.name}' cache`);
                this.broker.cacher.clean(`${this.name}.*`);
            }
        };
    });

    return {
        events
    };
};

posts.service.js

const CacheCleaner = require("./cache.cleaner.mixin");

module.exports = {
    name: "posts",
    mixins: [CacheCleaner([
        "users",
        "posts"
    ])],

    actions: {
        //...
    }
};

快取鎖定

Moleculer 還支援快取鎖定功能。詳情請參考 Add cache lock[2] 這個 PR。

範例:啟用鎖定

const broker = new ServiceBroker({
    cacher: {
        ttl: 60,
        lock: true, // 啟用鎖定,預設為關閉。
    }
});

範例:帶有存活時間的鎖定

const broker = new ServiceBroker({
    cacher: {
        ttl: 60,
        lock: {
            ttl: 15, // 鎖定最大的存活時間 (秒)
            staleTime: 10, // 如果存活時間小於此時間,表示資源過期了
        }
    }
});

範例:禁用鎖定

const broker = new ServiceBroker({
    cacher: {
        ttl: 60,
        lock: {
            enable: false, // 關閉
            ttl: 15, // 鎖定最大的存活時間 (秒)
            staleTime: 10, // 如果存活時間小於此時間,表示資源過期了
        }
    }
});

範例:Redis 快取帶有 redlock 函式庫

const broker = new ServiceBroker({
  cacher: {
    type: "Redis",
    options: {
      // 鍵的前綴
      prefix: "MOL",
      // 存活時間
      ttl: 30,
      // Redis 客戶端監控
      monitor: false,
      // Redis 設定
      redis: {
        host: "redis-server",
        port: 6379,
        password: "1234",
        db: 0
      },
      lock: {
        ttl: 15, // 鎖定最大的存活時間 (秒)
        staleTime: 10, // 如果存活時間小於此時間,表示資源過期了
      },
      // Redlock 設定
      redlock: {
        // Redis 客戶端。支援 node-redis 或 ioredis 。 預設只用 local 客戶端
        clients: [client1, client2, client3],
        // 預期時間浮動 (毫秒),請參閱:
        // https://redis.io/docs/reference/patterns/distributed-locks/
        driftFactor: 0.01,

        // 在拋出錯誤前,嘗試鎖定資源的最大次數
        retryCount: 10,

        // 嘗試時的等待時間 (毫秒)
        retryDelay: 200,

        // 最大隨機時間,此時間會加到嘗試時間,以提升高度搶奪下的效率 (毫秒),請參閱:
        // https://aws.amazon.com/tw/blogs/architecture/exponential-backoff-and-jitter/
        retryJitter: 200
      }
    }
  }
});

內建快取

記憶體快取

Memory 快取是一個內建的記憶體快取模組,它會將快取項目儲存在記憶體中。

範例:快速使用,可以設為 "Memory"true

const broker = new ServiceBroker({
    cacher: "Memory"
});

範例:選項設定方式

名稱 類型 預設值 說明
ttl <Number> null 存活時間 (秒)
clone <Boolean> | <Function> false 深度複製
keygen <Function> null 客製化鍵值產生器
maxParamsLength <Number> null 最大快取鍵長度
lock <Boolean> | <Object> null 啟用快取鎖定
const broker = new ServiceBroker({
    cacher: {
        type: "Memory",
        options: {
            ttl: 30 // 設定存活時間,若要關閉請設為 0 或 null 。
            clone: true // 深拷貝
        }
    }
});

範例:客製化深度複製函數

快取會使用 Lodash 的 _.cloneDeep 方法來深度複製,如果不想使用,可將 clone 選項設為一個客製化函數來取代。

const broker = new ServiceBroker({
    cacher: {
        type: "Memory",
        options: {
            clone: data => JSON.parse(JSON.stringify(data))
        }
    }
});

LRU 記憶體快取

MemoryLRU 是一個內建的 LRU 快取模組。它會刪除最近最少用的項目。

使用前請安裝 lru-cache[3] 套件 npm install lru-cache --save

範例:快速使用

const broker = new ServiceBroker({
    cacher: "MemoryLRU"
});

範例:選項設定方式

名稱 類型 預設值 說明
ttl <Number> null 存活時間 (秒)
max <Number> null 快取中最大的項目數量
clone <Boolean> | <Function> false 深度複製
keygen <Function> null 客製化鍵值產生器
maxParamsLength <Number> null 最大快取鍵長度
lock <Boolean> | <Object> null 啟用快取鎖定

Redis 快取

Redis 快取是一個內建基於 Redis 分散式的快取模組。如果你有多個服務實例,當一個服務實例儲存了一些快取,其它的實例也能夠透過 Redis 找到它。

使用前請安裝 ioredis[4] 套件 npm install ioredis --save

範例:快速使用,預設連線為 redis://localhost:6379

const broker = new ServiceBroker({
    cacher: "Redis"
});

範例:連線到 Redis 服務器

const broker = new ServiceBroker({
    cacher: "redis://redis-server:6379"
});

範例:選項設定方式

名稱 類型 預設值 說明
prefix <String> null 鍵的前綴。
ttl <Number> null 存活時間 (秒)。
monitor <Boolean> false Redis 客戶端監控[5] 。
redis <Object> null 客製化 Redis 選項[6] 。
keygen <Function> null 客製化鍵值產生器。
maxParamsLength <Number> null 最大快取鍵長度。
serializer <String> "JSON" 內建序列化器。
cluster <Object> null Redis 客戶端叢集設定[7] 。
lock <Boolean> | <Object> null 啟用快取鎖定。
pingInterval <Number> null 每毫秒發出 Redis PING 命令。用於使可能閒置逾時的連線保持活躍狀態。
const broker = new ServiceBroker({
    cacher: {
        type: "Redis",
        options: {
            // 鍵的前綴
            prefix: "MOL",            
            // 存活時間
            ttl: 30, 
            // Redis 客戶端監控
            monitor: false 
            // Redis 設定
            redis: {
                host: "redis-server",
                port: 6379,
                password: "1234",
                db: 0
            }
        }
    }
});

範例:使用 MessagePack 序列化器

你可以設定一個序列化器給 Redis 快取使用,它預設會使用 JSON 序列化器。

const broker = new ServiceBroker({
    nodeID: "node-123",
    cacher: {
        type: "Redis",
        options: {
            ttl: 30,

            // 使用 MessagePack 序列化器儲存資料
            serializer: "MsgPack",

            redis: {
                host: "my-redis"
            }
        }
    }
});

範例:使用 Redis 客戶端叢集

const broker = new ServiceBroker({
    cacher: {
        type: "Redis",
        options: {
            ttl: 30, 

            cluster: {
                nodes: [
                    { port: 6380, host: "127.0.0.1" },
                    { port: 6381, host: "127.0.0.1" },
                    { port: 6382, host: "127.0.0.1" }
                ],
                options: {
					// ...
				}
            }   
        }
    }
});

客製化快取

你也可以建立客製化的快取模組,官方建議可以參考 記憶體快取[8] 或 Redis 快取[9] 的原始碼來修改,再實作 getsetdelclean 方法。

範例:建立客製化快取

my-cacher.js

const BaseCacher = require("moleculer").Cachers.Base;

class MyCacher extends BaseCacher {
    async get(key) { /*...*/ }
    async set(key, data, ttl) { /*...*/ }
    async del(key) { /*...*/ }
    async clean(match = "**") { /*...*/ }
}

module.exports = MyCacher;

範例:使用客製化快取

const { ServiceBroker } = require("moleculer");
const MyCacher = require("./my-cacher");

const broker = new ServiceBroker({
    cacher: new MyCacher()
});

參考文獻

[1] Caching, https://moleculer.services/docs/0.14/caching.html
[2] Add cache lock, https://github.com/moleculerjs/moleculer/pull/490
[3] lru-cache, https://github.com/isaacs/node-lru-cache
[4] ioredis, https://github.com/luin/ioredis
[5] ioredis Monitor, https://github.com/luin/ioredis#monitor
[6] ioredis Connect to Redis, https://github.com/luin/ioredis#connect-to-redis
[7] ioredis Cluster, https://github.com/luin/ioredis#cluster
[8] Moleculer Memory Cacher, https://github.com/moleculerjs/moleculer/blob/master/src/cachers/memory.js
[9] Moleculer Redis Cacher, https://github.com/moleculerjs/moleculer/blob/master/src/cachers/redis.js

家家酒小劇場

  • Otter - 快取機制看起來很複雜,什麼時候才要用呢?
  • Boxy - 快取需要先建立良好的業務邏輯規劃,例如用在很少寫入但經常讀取的資料,就能有效降低系統負擔。若遇到相反的情況,快取機制是有可能會產生反效果的唷。

上一篇
Day 17 : 容錯
下一篇
Day 19 : 參數驗證
系列文
Moleculer 家家酒31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言