[創意料理] 前端及後端常見的 Cache 更新策略:定時更新、準時更新

Cache 是在 Web 應用程式開發領域,無論前端或後端都需要深入了解的一件事情,良好的 Cache 機制是可以降低網頁的回應時間,以及同時節省後端伺服器的運算資源,其中關乎到 Cache 品質的兩項因素是:新鮮度命中率,而影響到這兩項因素的關鍵就在於我們的更新策略。

接下來,我們就會在假定我們正在開發維護一個稍具規模的中大型網站的情境之下,來研究 Cache 的更新機制,一個 Request 到 Response 的過程,至少就有三個地方可以來讓我們存放 Cache:BrowserCDNWeb Server,我們就這三個地方來探討 Cache 該怎麼做定時更新跟準時更新?

定時更新

定時更新是在 Cache 的內容上指定一段時間(比如:10秒、20秒、...),經過了指定的時間之後,Cache 的內容即消失,需要從原始的資料來源(檔案系統、資料庫、...etc)抓取資料重新建立 Cache,這是我們最熟悉不過的一種 Cache 更新策略。

在前端我們只要在 Response Header 中加上 Cache-Control Header,值為 public,max-age=n(n 為秒數),Browser 及 CDN 就會按照我們指定的秒數,將回應的內容 Cache 起來,直到時間結束,才會讓 Request 穿透過去往後端伺服器打去。

這邊有一件事情要注意,我們的 URL 要確定有在 CDN 的守備範圍之內,CDN 才會幫我們建立 Cache。

後端的部分,依照不同的程式語言會有不同的實作,以 ASP.NET Core 為例,就需要像是 ResultCacheAttribute 這樣子的工具來幫忙處理 Cache 的機制,雖然沒有像是 HTTP 那樣有制定統一的標準,但是換來的是我們實作 Cache 機制的彈性會大很多。

後端的 Cache 更新邏輯應該要有 Locking 機制的介入,藉以保護原始的資料來源,避免在 Cache 過期的一瞬間,湧入大量的流量沖垮原始的資料來源,而 Double-Checked Locking 即是其中一種。(延伸閱讀:跨應用程式鎖定

準時更新

準時更新是設定一個特定的時間(比如:每天 09:00、每天 18:00),在這個特定時間到的時候,Cache 即消失,需要從原始的資料來源抓取資料重新建立 Cache。

這個就比定時更新要來的複雜一點點,在前端會需要一個相對可信的當下時間,在客戶端取得的當下時間是不可信的,所以會需要搭配後端應用程式來計算出一個相對可信的當下時間,這個可以參考我的另外一篇文章 - 幾乎不用經過後端 Web 應用程式的運算,就能取得伺服器時間。

取得相對可信的當下時間後,比對得知如果指定的特定時間已經到了或過了,在發出 Request 的時候在 Query String 加上 t={timestamp} 的參數,因為不同的 URL 即使內容是一樣的,Browser 及 CDN 也會視為是不同的,我們利用這個特性來抓取更新的資料。

後端的話,一樣是以 ASP.NET Core 為例,我們可以在 ResultCacheAttribute 加上屬性 AbsoluteTimes,型別是 string[],值則以天為基準的多個時間點。

寫一段邏輯去計算建立 Cache 的當下時間,離下一個未來的時間點還有多久,以此做為 Cache 的時間。

double duration = 0;

if (this.AbsoluteTimes != null && this.AbsoluteTimes.Length > 0)
{
    var absoluteTimes = this.AbsoluteTimes.Select(ts => TimeSpan.Parse(ts)).OrderBy(ts => ts).ToArray();
    var len = absoluteTimes.Length;
    var index = 0;

    do
    {
        var offsetDays = index / len;
        var absoluteTime = DateTime.Today.AddDays(offsetDays).Add(absoluteTimes[index % len]);

        duration = absoluteTime.Subtract(DateTime.Now).TotalSeconds;

        index++;
    }
    while (duration < 0);
}

同場加映:善用 304(Not Modified)

304(Not Modified) 是個好東西,如果 Browser 之前抓到資料沒有異動,那麼伺服器只要回應 304 給 Browser 請它繼續使用之前抓到的資料就好了,304 的回應通常不帶 Response Body,所以可以減少不少傳輸的流量。

要回應 304 只能由後端發動,前端只是配合,其中一種方式就是透過 HTTP ETag,在後端的 Cache 機制中,為 Cache 的內容做 Hash(MD5、SHA1、... 都可以),將 Hash 的結果當成 ETag Response Header 的值,前端在下次 Cache 過期往後端發送 Request 時,會將這個值放在 If-None-Match Request Header 中,後端收到之後就可以藉此比對是不是跟原本 Hash 的結果是一致的,若是一致的就回傳 304,不是一致的就將更新的 Cache 內容回傳給前端。

internal class CacheResult : IActionResult
{
    private string content;

    public string Content
    {
        get => this.content;
        set
        {
            this.content = value;

            this.Hash = BitConverter.ToString(MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-", string.Empty);
        }
    }

    public string ContentType { get; set; }

    public int? StatusCode { get; set; }

    public string Hash { get; private set; }

    public Task ExecuteResultAsync(ActionContext context)
    {
        throw new NotImplementedException();
    }
}

private static IActionResult CorrectResult(IActionResult result, ActionContext context)
{
    if (result is CacheResult cacheResult)
    {
        var etag = $"W/\"{cacheResult.Hash}\"";

        var ifNoneMatch = context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch];

        if (!string.IsNullOrEmpty(ifNoneMatch) && ifNoneMatch == etag)
        {
            return new StatusCodeResult(304);
        }
        else
        {
            context.HttpContext.Response.Headers[HeaderNames.ETag] = etag;

            return new ContentResult
                   {
                       Content = cacheResult.Content,
                       ContentType = cacheResult.ContentType,
                       StatusCode = cacheResult.StatusCode
                   };
        }
    }
    else
    {
        return result;
    }
}

「Cache 用得好,Server 沒煩惱。」,在我的工作經驗當中,曾經有一個 API 在評估過後上了 Cache 機制,運算資源只要原本 20%,雖然省的是老闆的錢,但是這也是一種具體化我們工程師價值的一種方式。

相關資源

C# 指南
ASP.NET 教學
ASP.NET MVC 指引
Azure SQL Database 教學
SQL Server 教學
Xamarin.Forms 教學