第 12 篇:加快取為介面提速

削微寒發表於2020-07-17

作者:HelloGitHub——追夢人物

目前,使用者對於介面的操作基本都需要查詢資料庫。獲取文章列表需要從資料庫查詢,獲取單篇文章需要從資料庫查詢,獲取評論列表也需要查詢資料。但是,對於部落格中的很多資源來說,在某個時間段內,他們的內容幾乎都不會發生更新。例如文章詳情,文章發表後,除非對其內容做了修改,否則內容就不會變化。還有評論列表,如果沒人釋出新評論,評論列表也不會變化。

要知道查詢資料庫的操作相對而言是比較緩慢的,而直接從記憶體中直接讀取資料就會快很多,因此快取系統應運而生。將那些變化不那麼頻繁的資料快取到記憶體中,記憶體中的資料相當於資料庫中的一個副本,使用者查詢資料時,不從資料庫查詢而是直接從快取中讀取,資料庫的資料發生了變化時再更新快取,這樣,資料查詢的效能就大大提升了。

當然資料庫效能也沒有說的那麼不堪,對於大部分訪問量不大的個人部落格而言,任何關係型資料庫都足以應付。但是我們學習 django-rest-framework 不僅僅是為了寫部落格,也許你在工作中,面對的是流量非常大的系統,這時候快取就不可或缺。

確定需快取的介面

先來整理一下我們已有的介面,看看哪些介面是需要快取的:

介面名 URL 需快取
文章列表 /api/posts/
文章詳情 /api/posts/:id/
分類列表 /categories/
標籤列表 /tags/
歸檔日期列表 /posts/archive/dates/
評論列表 /api/posts/:id/comments/
文章搜尋結果 /api/search/

補充說明

  1. 文章列表:需要快取,但如果有文章修改、新增或者刪除時應使快取失效。
  2. 文章詳情:需要快取,但如果文章內容修改或者刪除了應使快取失效。
  3. 分類、標籤、歸檔日期:可以快取,但同樣要注意在相應的資料變化時使快取失效。
  4. 評論列表:可以快取,新增或者刪除評論時應使快取失效。
  5. 搜尋介面:因為搜尋的關鍵詞是多種多樣的,可以快取常見搜尋關鍵詞的搜尋結果,但如何確定常見搜尋關鍵詞是一個複雜的優化問題,這裡我們不做任何快取處理。

配置快取

django 為我們提供了一套開箱即用的快取框架,快取框架對快取的操作做了抽象,提供了統一的讀寫快取的介面。無論底層使用什麼樣的快取服務(例如常用的 Redis、Memcached、檔案系統等),對上層應用來說,操作邏輯和呼叫的介面都是一樣的。

配置 django 快取,最重要的就是選擇一個快取服務,即快取結果儲存和讀取的地方。本專案中我們決定開發環境使用本地記憶體(Local Memory)快取服務,線上環境使用 Redis 快取。

開發環境配置

在開發環境的配置檔案 settings/local.py 中加入以下的配置項即開啟本地記憶體快取服務。

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
    }
}

線上環境配置

線上環境使用到 Redis 快取服務,django 並未內建 Redis 快取服務的支援,不過對於 Redis 來說當然不缺乏第三方庫的支援,我們選擇 django-redis-cache,先來安裝它:

$ pipenv install django-redis-cache

然後在專案的線上環境配置檔案 settings/production.py 中加入以下配置:

CACHES = {
    "default": {
        "BACKEND": "redis_cache.RedisCache",
        "LOCATION": "redis://:UJaoRZlNrH40BDaWU6fi@redis:6379/0",
        "OPTIONS": {
            "CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool",
            "CONNECTION_POOL_CLASS_KWARGS": {"max_connections": 50, "timeout": 20},
            "MAX_CONNECTIONS": 1000,
            "PICKLE_VERSION": -1,
        },
    },
}

這樣,django 的快取功能就啟用了。至於如何啟動 Redis 服務,請參考教程最後的 Redis 服務部分。

drf-extensions Cache

django 的快取框架比較底層,drf-extensions 在 django 快取框架的基礎上,針對 django-rest-framework 封裝了更多快取相關的輔助函式和類,我們將藉助這個第三方庫來大大簡化快取邏輯的實現。

首先安裝它:

$ pipenv install drf-extensions

那麼 drf-extensions 對快取提供了哪些輔助函式和類呢?我們需要用到的主要有這些:

KeyConstructor

可以理解為快取鍵生成類。我們先來看看 API 介面快取的邏輯,虛擬碼是這樣的:

給定一個 URL, 嘗試從快取中查詢這個 URL 介面的響應結果
if 結果在快取中:
    return 快取中的結果
else:
    生成響應結果
    將響應結果存入快取 (以便下一次查詢)
    return 生成的響應結果

快取結果是以 key-value 的鍵值對形式儲存的,這裡關鍵的地方在於儲存或者查詢快取結果時,需要生成相應的 key。例如我們可以把 API 請求的 URL 作為快取的 key,這樣同一個介面請求將返回相同的快取內容。但是在更為複雜的場景下,不能簡單使用 URL 作為 key,比如即使是同一個 API 請求,已認證和未認證的使用者呼叫介面得到的結果是不一樣的,所以 drf-extensions 使用 KeyConstructor 輔助基類來提供靈活的 key 生成方式。

KeyBit

可以理解為 KeyConstructor 定義的 key 生成規則中的某一項規則定義。例如,同一個 API 請求,已認證和未認證的使用者將得到不同的響應結果,我們可以定義 key 的生成規則為請求的 URL + 使用者的認證 id。那麼 URL 可以看成一個 KeyBit,使用者 id 是另一個 KeyBit。

cache_response 裝飾器

這個裝飾器用來裝飾 django-rest-framework 的檢視(單個檢視函式、檢視集中的 action 等),被裝飾的檢視將具備快取功能。

快取部落格文章

我們首先來使用 cache_response 裝飾器快取文章列表介面,程式碼如下:

blog/views.py

from rest_framework_extensions.cache.decorators import cache_response

class PostViewSet(
    mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
    # ...
    @cache_response(timeout=5 * 60, key_func=PostListKeyConstructor())
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

    @cache_response(timeout=5 * 60, key_func=PostObjectKeyConstructor())
    def retrieve(self, request, *args, **kwargs):
        return super().retrieve(request, *args, **kwargs)

這裡我們分別裝飾了 list(獲取文章列表的 action)和 retrieve(獲取單篇文章),timeout 引數用於指定快取失效時間, key_func 指定快取 key 的生成類(即 KeyConstructor),當然 PostListKeyConstructor、和 PostObjectKeyConstructor 還未定義,接下來我們就來定義這兩個快取 key 生成類:

blog/views.py

from rest_framework_extensions.key_constructor.bits import (
    ListSqlQueryKeyBit,
    PaginationKeyBit,
    RetrieveSqlQueryKeyBit,
)
from rest_framework_extensions.key_constructor.constructors import DefaultKeyConstructor

class PostListKeyConstructor(DefaultKeyConstructor):
    list_sql = ListSqlQueryKeyBit()
    pagination = PaginationKeyBit()
    updated_at = PostUpdatedAtKeyBit()


class PostObjectKeyConstructor(DefaultKeyConstructor):
    retrieve_sql = RetrieveSqlQueryKeyBit()
    updated_at = PostUpdatedAtKeyBit()

PostListKeyConstructor 用於文章列表介面快取 key 的生成,它繼承自 DefaultKeyConstructor,這個基類中定義了 3 條快取 key 的 KeyBit:

  1. 介面呼叫的檢視方法的 id,例如 blog.views. PostViewSet.list。
  2. 客戶端請求的介面返回的資料格式,例如 json、xml。
  3. 客戶端請求的語言型別。

另外我們還新增了 3 條自定義的快取 key 的 KeyBit:

  1. 執行資料庫查詢的 sql 查詢語句
  2. 分頁請求的查詢引數
  3. Post 資源的最新更新時間

以上 6 條分別對應一個 KeyBit,KeyBit 將提供生成快取鍵所需要的值,如果任何一個 KeyBit 提供的值發生了變化,生成的快取 key 就會不同,查詢到的快取結果也就不一樣,這個方式為我們提供了一種有效的快取失效機制。例如 PostUpdatedAtKeyBit 是我們自定義的一個 KeyBit,它提供 Post 資源最近一次的更新時間,如果資源發生了更新,返回的值就會發生變化,生成的快取 key 就會不同,從而不會讓介面讀到舊的快取值。PostUpdatedAtKeyBit的程式碼如下:

blog/views.py

from .utils import UpdatedAtKeyBit

class PostUpdatedAtKeyBit(UpdatedAtKeyBit):
    key = "post_updated_at"

因為資源更新時間的 KeyBit 是比較通用的(後面我們還會用於評論資源),所以我們定義了一個基類 UpdatedAtKeyBit,程式碼如下:

blog/utils.py

from datetime import datetime
from django.core.cache import cache
from rest_framework_extensions.key_constructor.bits import KeyBitBase

class UpdatedAtKeyBit(KeyBitBase):
    key = "updated_at"

    def get_data(self, **kwargs):
        value = cache.get(self.key, None)
        if not value:
            value = datetime.utcnow()
            cache.set(self.key, value=value)
        return str(value)

get_data 方法返回這個 KeyBit 對應的值,UpdatedAtKeyBit 首先根據設定的 key 從快取中讀取資源最近更新的時間,如果讀不到就將資源最近更新的時間設為當前時間,然後返回這個時間。

當然,我們需要自動維護快取中記錄的資源更新時間,這可以通過 django 的 signal 來完成:

blog/models.py

from django.db.models.signals import post_delete, post_save

def change_post_updated_at(sender=None, instance=None, *args, **kwargs):
    cache.set("post_updated_at", datetime.utcnow())

post_save.connect(receiver=change_post_updated_at, sender=Post)
post_delete.connect(receiver=change_post_updated_at, sender=Post)

每當有文章(Post)被新增、修改或者刪除時,django 會發出 post_save 或者 post_delete 訊號,post_save.connect 和 post_delete.connect 設定了這兩個訊號的接收器為 change_post_updated_at,訊號發出後該方法將被呼叫,往快取中寫入文章資源的更新時間。

整理一下請求被快取的邏輯:

  1. 請求文章列表介面
  2. 根據 PostListKeyConstructor 生成快取 key,如果使用這個 key 讀取到了快取結果,就直接返回讀取到的結果,否則從資料庫查詢結果,並把查詢的結果寫入快取。
  3. 再次請求文章列表介面,PostListKeyConstructor 將生成同樣的快取 key,這時就可以直接從快取中讀到結果並返回了。

快取更新的邏輯:

  1. 新增、修改或者刪除文章,觸發 post_delete, post_save 訊號,文章資源的更新時間將被修改。
  2. 再次請求文章列表介面,PostListKeyConstructor 將生成不同的快取 key,這個新的 key 不在快取中,因此將從資料庫查詢最新結果,並把查詢的結果寫入快取。
  3. 再次請求文章列表介面,PostListKeyConstructor 將生成同樣的快取 key,這時就可以直接從快取中讀到結果並返回了。

PostObjectKeyConstructor 用於文章詳情介面快取 key 的生成,邏輯和 PostListKeyConstructor 是完全一樣。

快取評論列表

有了文章列表的快取,評論列表的快取只需要依葫蘆畫瓢。

KeyBit 定義:

blog/views.py

class CommentUpdatedAtKeyBit(UpdatedAtKeyBit):
    key = "comment_updated_at"

KeyConstructor 定義:

blog/views.py

class CommentListKeyConstructor(DefaultKeyConstructor):
    list_sql = ListSqlQueryKeyBit()
    pagination = PaginationKeyBit()
    updated_at = CommentUpdatedAtKeyBit()

檢視集:

@cache_response(timeout=5 * 60, key_func=CommentListKeyConstructor())
@action(
        methods=["GET"],
        detail=True,
        url_path="comments",
        url_name="comment",
        pagination_class=LimitOffsetPagination,
        serializer_class=CommentSerializer,
    )
    def list_comments(self, request, *args, **kwargs):
        # ...

快取其它介面

其它介面的快取大家可以根據上述介紹的方法來完成,就留作練習了。

Redis 服務

本地記憶體快取服務配置簡單,適合在開發環境使用,但無法適應多執行緒和多程式適的環境,線上環境我們使用 Redis 做快取。有了 Docker,啟動一個 Redis 服務就是一件非常簡單的事。

線上上環境的容器編排檔案 production.yml 中加入一個 Redis 服務:

version: '3'

volumes:
  static:
  database:
  esdata:
  redis_data:

services:
  hellodjango.rest.framework.tutorial:
    ...
    depends_on:
      - elasticsearch
      - redis
  
  redis:
    image: 'bitnami/redis:5.0'
    container_name: hellodjango_rest_framework_tutorial_redis
    ports:
      - '6379:6379'
    volumes:
      - 'redis_data:/bitnami/redis/data'
    env_file:
      - .envs/.production

然後在 .envs/.production 檔案中新增如下的環境變數,這個值將作為 redis 連線的密碼:

REDIS_PASSWORD=055EDy65AAhLgBxMp1u1

然後就可以將服務釋出上線了。


關注公眾號加入我們

相關文章