第 12 篇:加緩存為接口提速

作者: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

然後就可以將服務發佈上線了。


關注公眾號加入我們