Memcached 實作示範 — 用Memory Cache優化系統效能

本文同步刊載於 ALPHA Camp Blog

「為什麼網頁這麼慢!」

相信每個人都有類似經驗:遇到熱門新聞、限量特價商品、演唱會搶票,當短時間內大量流量湧入,網頁的存取常常因此失敗或異常緩慢,使用者抱怨連連。這不是因為程式功能寫錯,而是因大量運算或大流量導致系統表現不佳所產生的問題,屬於非功能性需求,也就是「效能優化」的範疇。隨著接觸的專案規模越大,除了功能實作,效能也是重要的課題。

效能優化牽涉的範疇非常廣泛,除了基本的程式面演算法效率,前後端在系統面也有很多優化議題。前端常見議題如頁面的渲染效率,後端如大量資料運算、資料庫存取、伺服器分流、分散式運算等。

其中,資料庫頻繁存取是後端很常見的效能瓶頸。對於有些不常變動、但查詢頻率很高的資料,例如熱門商品的名稱,如果每一次都要從資料庫重新 query,對資料庫來說不啻是一大負擔。

在後端效能優化中,快取(Cache)是常用的作法。透過快取來優化效能有多種實作技術,本文將介紹其中一種作法 — — Memory Cache,並透過一個實際專案來示範。

用記憶體幫你快取:Memory Cache

要將資料進行永續化儲存(Persistence),利用資料庫系統將資料存到硬碟裡,是一般系統不可或缺的一環。但當有大量查詢流量湧入時,容易對資料庫造成負擔,甚至導致整體系統的效能拖累。

我們都知道電腦對記憶體的操作要比對硬碟操作快得多,因此就有一種快取方式,將某些會被頻繁查詢的資料暫存在記憶體裡,當有查詢需求時,會優先去記憶體裡找,而不用每次都重新 query 資料庫,提升查詢效能。

Memory Cache 的工具有很多,常見的如 memcached、Redis。其中 memcached 是一套開放原始碼的分散式快取記憶體系統,目前被許多網站服務廣泛使用,包含 Facebook、YouTube、Twitter 等等。memcached 使用 Key-Value 的形式來儲存。

接下來會實際進行一個小專案,具體示範如何利用 memcached 提升系統的查詢效率。

示範專案:短網址系統

短網址是十分普遍的應用,功能也非常單純,很適合作為示範。

系統功能分析

一個基本的短網址系統,主要提供兩個功能:

  1. 讓使用者輸入想縮短的長網址,產生短網址。
  2. 使用者在瀏覽器輸入短網址,能導到原始網址。

實作要點

  • 一個簡單的 hash 演算法,將長網址加上時間戳記,hash 成一個唯一的簡短代碼(如 Qwbrolrv)。
  • 一支 API 負責產生新的短網址,並將 hashCode 和對應的原始網址存入資料庫 (POST /tinyUrl/)。
  • 一支 API 負責根據 hashCode 去資料庫查詢並回傳原始網址資訊(GET /tinyUrl/{hashCode})。
  • 一支 API,根據 hashCode 去資料庫查詢原始網址,並 redirect。
  • 一個主頁面,提供使用者輸入長網址,並顯示短網址結果。

實作成果示意

以下是使用 Node.js + Express + MongoDB 所實作的成果示意 — — GMTU(Give Me Tiny URL):

使用者輸入原始網址,按下「Let’s Go!」,得到短網址:

在瀏覽器輸入短網址,會自動導到原始網址:

如何提升短網址搜尋的效能

當我們將短網址放在一篇熱門文章內,吸引到大量訪客頻繁存取這個短網址。如果每一位訪客來存取,系統都要從資料庫重新 query,無論在效率和系統負擔上都不盡理想,尤其每個短網址對應的原始網址是不變的,為了同一個結果而在短時間內對資料庫做頻繁檢索,非常浪費資源。

這時候我們就可以利用快取。

當短網址 A 第一次被訪客 1 存取,從資料庫 query 出原始網址 A 的資訊,這時候我們可以將短網址 A 和原始網址 A 當成一對 Key-Value 存入 memcached 裡。

下一次訪客 2 來存取短網址 A 時,就可以直接從 memcached 取出原始網址 A,而不用重新 query 資料庫。

概念示意圖如下:

使用 Node.js 實作 memcached 快取

要在 Node.js 上使用 memcached,有個幾個動作:

  1. 安裝 memcached server
  2. Node.js 安裝 memcached 套件
  3. 改寫既有程式,在 query 資料庫前先查詢 memcached

安裝 memcached

我們簡單用 localhost 開發機作為 memcached server,在上面安裝 memcached。在安裝 memcached 前,可能需要安裝幾個 dependencies。

以下是 Mac 上用 homebrew 安裝 memcached 的指令:

$ brew install autoconf automake doxygen libtool pkg-config libevent openssl libevent
$ brew install memcached

安裝完畢後需要啟動 memcached 的 service:

$ brew services start memcached

可以用以下指令檢查 memcached service 是否成功啟動:

$ ps -few | grep memcached

確定啟動後,可以用>確定啟動後,可以用 telnet 連上 memcached server 進行操作,測試是否正常。預設的 port 是 11211:

$ telnet 127.0.0.1 11211

Node.js 引用 memcached 套件

Node.js 提供操作 memcached 的套件,使用上很容易。

安裝 memcached 的套件:

$ npm install memcached

在程式中引用套件的語法:

const memcached = require(‘memcached’);

在進行任何讀取或寫入前,需要先連線到 memcached server:

let cache = new memcached("localhost:11211");

memcached 最主要的操作就是 get 和 set。

在 set 時,需要指定 Key 和 Value,以及一個 lifetime,代表這個快取的存活時間。語法範例如下:

const memcached = require('memcached');
let key = "name";
ley value = "OneJar";
let lifetime = 60; // seconds

let cache = new memcached("localhost:11211");
cache.set(key, value, lifetime, function(err) {
    if (err) {
        throw err;
    }
    console.log('Set value ok!');
    cache.end(); // close connection
});

在 get 時,指定想查詢的 Key,就能回傳對應的 Value。語法範例如下:

const memcached = require('memcached');
let key = "name";

let cache = new memcached("localhost:11211");
cache.get(key, function(err, value) {
    if (err) {
        throw err;
    }
    console.log(value);
    cache.end(); // close connection
});

在既有程式增加快取機制

知道怎麼在 Node.js 裡操作 memcached 後,就來將快取機制加入程式裡。

原本查詢短網址的函數,每次都從資料庫查詢:

const getTinyUrl = (hashCode) => {
    if (hashCode === null || hashCode.trim() === '') throw Error('Please specify code');
    TinyUrl.findOne({ hashCode })
        .then(doc => {
            if (doc === null) {
                throw Error('Not found');
            }
            return doc;
        })
        .catch(e => {
            throw Error('Getting TinyUrl failed! Error: ' + e);
        });
}

加入快取機制後,會先從快取查詢,如果沒有才會從資料庫查詢。從資料庫查詢成功後,也要記得回存快取:

const getTinyUrl = (hashCode) => {
    if (hashCode === null || hashCode.trim() === '') throw Error('Please specify code');

    // 先從快取 get
    cacheUtil.get(hashCode, (ret)=>{
        // 從快取裡得到結果,直接回傳
        if(ret !== undefined){
            return JSON.parse(ret);
        }
        // 如果快取不存在,則從資料庫查詢
        TinyUrl.findOne({ hashCode })
            .then(doc => {
                if (doc === null) {
                    throw Error('Not found');
                }
                // 查詢成功,存入快取,以便下次使用
                cacheUtil.set(hashCode, doc, lifetime, () => {
                    return doc;
                }, (e) => {
                    throw Error('Set memcached failed: ' + e);
                });

            })
            .catch(e => {
                throw Error('Getting TinyUrl failed! Error: ' + e);
            });
    },
    (e)=>{
        throw Error('Get memcached failed: ' + e);
    });
}

真的有提升效能嗎? — — 簡單實驗

前面提到實作要點時,有一支 API 負責根據 hashCode 回傳原始網址資訊(GET /tinyUrl/{hashCode})。實驗方式是另外寫一支小程式,利用 axios 套件去呼叫這支 API。

這邊可以利用 JavaScript 異步的特性輕易模擬多個 client 同時 request。

增加快取機制前

每次從資料庫重新 query,每一個 request 處理時間平均需要花 112 ms。

增加快取機制後

從 memcached 取資料,每一個 request 處理時間平均只需要花 58 ms,提升了大約 48% 的效能。

結語

本文簡單介紹了何謂 Memory Cache、memcached、如何安裝、如何在 Node.js 裡引用,並用一個短網址系統的小專案做具體的快取設計示範,在最後的實驗也成功驗證 Memory Cache 對系統效能有明顯的提升。

本文中使用 memcached 作為示範,在使用上非常容易入門。然而 memcached 有個缺點,一旦 memcached service 發生中斷,重啟後 cache 會消失。這方面可以參考另一套 Memory Cache 工具 — — Redis,也是一套 Key-Value 的快取工具,具有永續儲存的特性。

在進行快取時,快取存活時間的設計是一個重點,也就是本文範例中的 lifetime。由於 Memory Cache 是佔用記憶體,如果把一大堆存取頻率低的資料放在快取裡霸佔記憶體資源,是另一種資源的浪費。在設計時應該根據系統實際需求,評估資料特性來設定適當的存活時間。例如以短網址系統來說,很多短網址經過一段時間後可能沒人存取,這種資料佔用 Memory Cache 太久的意義就不大。

事實上效能優化的議題五花八門,快取只是其中一種方式,在規劃適合的優化方式時需要考量實際的需求場景。隨著開發經驗累積,除了思考如何實作出能動的系統功能,也需要關注如何改善效能,才能開發出撐得住實際市場流量的服務。

註:本文中的完整範例程式碼可參考 GitHub

References

發表留言