11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

WSGI(主にDjango)でキャッシュを考えるときの注意と対応方法

Last updated at Posted at 2018-01-29

はじめに

とあるアプリをGoole App Engineで動かしていたのですが、ある処理でタイムアウトが発生するようになりました。
調べてみると同じ処理(結果が変わらない処理)を何回も実行しているのが時間がかかる原因だとわかったため、結果をキャッシュすることを考えました。
この記事では実装したキャッシュの方法、問題点と改善、その他のキャッシュの方法について説明します。

なお、実際のコードを見せてもいいのですが長くなるので今回は「キャッシュ導入により改善されることがわかるコード」を用いて説明します。なので、「このコードなんの意味があるの?」というツッコミはご遠慮ください:-P

キャッシュ導入以前

アプリはDjangoを使って作成しています。まずはキャッシュをしてなかったころのコードです。
_create_messageが実際のアプリでは「結果が変わらないが何回も呼ばれていた処理」という想定です。日付を渡している(時間に関する処理を行う)という点がポイントです。
infoはテンプレートから呼ぶためのメソッドです。1

models.py
class User(models.Model):
    name = models.CharField(max_length=80)
    last_login = models.DateField()

    def _create_message(self, date):
        diff = date - self.last_login
        message = '{}さんは{}日前にログインしました'.format(self.name, diff.days)
        return message

    def info(self):
        today = timezone.now().date()
        return self._create_message(today)

初めに書いたキャッシュ処理

キャッシュを導入するにあたり、「キャッシュのためにモデル作りたくないなー、そうだクラス変数使おう」と思い以下のようなキャッシュを実装しました。すぐに説明するようにこのコードは不具合があります。

models.py
class User(models.Model):
    # 省略

    _cache = {}
    def _info_internal(self, date):
        if self not in self.__class__._cache:
            self.__class__._cache[self] = self._create_message(date)
        return self.__class__._cache[self]

    def info(self):
        today = timezone.now().date()
        return self._info_internal(today)

問題点とその原因

ある日に「Aさんは5日前にログインしました」と表示されたとします。
さて、次の日にはどう表示されるでしょうか。

答えは「Aさんは5日前にログインしました」です。その理由はDjango(というかWSGIアプリ)がどのように実行されるかということに関わってきます。

WSGIではリクエストを受けたサーバはアプリを呼び出します。この際、アプリのオブジェクトは使い回されます。別の言い方をするとリクエストごとにリセットされません
アプリオブジェクトだけでなくimportしたモジュール、そこに書かれているクラスオブジェクト(クラス変数)もそのままメモリ上に待機したままです。ついうっかりリクエストごとにクリアされると思っていました2(1リクエスト内での重複処理が省ければよかった)

修正を加えたキャッシュ処理

というわけでコードを直しました。どう直そうかなと思ったのですが以下のように日付が変わったらキャッシュをクリアするようにしました。

models.py
class User(models.Model):
    # 省略

    _cache = {}
    _date_for_cache = None
    def _info_internal(self, date):
        # 日付が変わったらキャッシュクリア
        if self.__class__._date_for_cache != date:
            self.__class__._cache.clear()
            self.__class__._date_for_cache = date

        if self not in self.__class__._cache:
            self.__class__._cache[self] = self._create_message(date)
        return self.__class__._cache[self]

これにより「その日のうちは重複処理しないけど、日付が変わったらちゃんと動く」、つまり、リクエストをまたいで正しくキャッシュが効くようになりました。当初は1リクエスト内のみでキャッシュする予定でしたがまあ有効範囲が広くなる分には文句はないので。3

Djangoの場合にできた対策

以下、オレオレ実装を書いたときには知らなかった、気づかなかったDjangoの場合に使えるキャッシュ方法です。

cached_property

リクエスト単位でキャッシュをしたいという場合、Djangoにはcached_propertyというものが用意されています。

使い方は簡単、デコレータを付けるだけです。

models.py
from django.utils.functional import cached_property

class User(models.Model):
    # 省略

    @cached_property
    def info(self):
        today = timezone.now().date()
        return self._create_message(today)

infoを複数回参照した場合でもメソッドが実行されるのは1回だけ、2回目以降は1回目の実行の戻り値が使われるようになります。

Django’s cache framework

Djangoにはキャッシュの仕組みが用意されています。キャッシュをどこに保存するのかをバックエンドとして指定することができます。デフォルトはローカルメモリ(memcachedではない)みたいですね。
主にページキャッシュ用ですがlow-level cache APIを使うことでそれ以外のデータでもキャッシュに保存することが可能です。このAPIを使って先のオレオレキャッシュ実装を書き直すと以下のようになります。

models.py
from django.core.cache import cache

class User(models.Model):
    # 省略

    def info_internal(self, date):
        key = 'User_{}'.format(self.id)
        if cache.get(key) is None:
            cache.set(key, self._create_message(date), 3600)
        return cache.get(key)

問題として、日付をまたぐ際にタイムアウトが起こるまでは前日にキャッシュした値が使われてしまうということがあります。まあ、厳密に日が変わったら値が変わらないといけないというのでなければこのcache frameworkも使えるでしょう。(キャッシュした日も格納しておいて前日のものか判断するとか手はありそうですが、それならオレオレ実装でよくね?とも思います)

まとめ

というわけで、手抜きと不理解から微妙な実装を作ってしまったわけですがまとめます。

  • WSGIな環境ではクラスオブジェクトは1つのリクエストが終わってもそのまま残る。リクエスト単位のキャッシュには使えない。工夫すれば使えるが無効化のタイミングに注意。
  • Djangoであればcached_propertyを使うとリクエスト単位でキャッシュができる。
  • Djangoにはcache frameworkというものがあり、これを使う方法もある。ただし日付またぎを厳密に処理しないといけない場合は注意しないといけない。

Flaskについては知見がないので取り上げていませんがキャッシュするための追加パッケージがあるようですね。オレオレ実装をする前に解決方法がないか調べるようにしましょう(笑)

後日談

記事を書いているうちに日付単位キャッシュもエントリごとのクリアがめんどくさいなということで結局cached_propertyを使ったリクエスト単位キャッシュにしようと思ったのですがいざ実装してみるとcached_propertyが使えないことがわかりました。

記事中では話を単純にするためにあまり意味のないコードで説明しましたが実際のコードでは再帰処理を行っています(この再帰処理中で処理の重複が起こるためキャッシュしようと思った次第です)。キャッシュのキーとして記事で示しているようにselfを使っていたのですが実はDBからレコードを取ってきてモデルオブジェクトを作るたびに別インスタンスになるということがわかりcached_propertyでは対応できないことがわかりました。

インスタンスが変わってしまうのであれば、self not in _cacheも毎回Falseになるはずでは??と思ったのに実際キャッシュは効いてる様子。調べてみたところ、models.Modelを継承していると__eq__メソッドがプライマリキーが同じならおkとしていることがわかりました。まあ確かに間違いではない。

結局どうしたかというと、再帰処理の先頭にクラス変数で実装しているキャッシュのクリアを入れリクエスト単位のキャッシュを実現しました。キャッシュこわい。

さらに追記。よく思うとクラス変数使っちゃうとリクエストが同時に来たらまずいですね。
WSGI的にはスレッドセーフ性についてはどちらかと言うと「アプリがスレッドセーフじゃないことも考慮して、アプリサーバはシングルスレッドでの実行も対応すべき」とありますがスレッドセーフである方が望ましいことは確かです。排他なんて言わなくてもすでにリクエスト単位しているのだからキャッシュオブジェクトを再帰呼び出しの際に引き回せばいいです。キャッシュ&並行性こわい。

  1. Djangoのテンプレートは{{ user.info }}みたいに書くと引数なしのメソッドを呼び出すことができます。

  2. リクエスト処理関数内で作ったオブジェクトは参照がなくなったらガベージコレクションされます。念のため

  3. この実装にはまだ示している箇所以外でちゃんと対応しないと不具合が起こる可能性があります。例えばログイン処理でその人に対応するキャッシュをクリアしてやらないとAさんが久しぶりにログインしても日付が変わるまで「Aさんは6日前にログインしました」と表示されることになります。

11
10
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?