はじめに
とあるアプリをGoole App Engineで動かしていたのですが、ある処理でタイムアウトが発生するようになりました。
調べてみると同じ処理(結果が変わらない処理)を何回も実行しているのが時間がかかる原因だとわかったため、結果をキャッシュすることを考えました。
この記事では実装したキャッシュの方法、問題点と改善、その他のキャッシュの方法について説明します。
なお、実際のコードを見せてもいいのですが長くなるので今回は「キャッシュ導入により改善されることがわかるコード」を用いて説明します。なので、「このコードなんの意味があるの?」というツッコミはご遠慮ください:-P
キャッシュ導入以前
アプリはDjangoを使って作成しています。まずはキャッシュをしてなかったころのコードです。
_create_message
が実際のアプリでは「結果が変わらないが何回も呼ばれていた処理」という想定です。日付を渡している(時間に関する処理を行う)という点がポイントです。
info
はテンプレートから呼ぶためのメソッドです。1
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)
初めに書いたキャッシュ処理
キャッシュを導入するにあたり、「キャッシュのためにモデル作りたくないなー、そうだクラス変数使おう」と思い以下のようなキャッシュを実装しました。すぐに説明するようにこのコードは不具合があります。
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リクエスト内での重複処理が省ければよかった)
修正を加えたキャッシュ処理
というわけでコードを直しました。どう直そうかなと思ったのですが以下のように日付が変わったらキャッシュをクリアするようにしました。
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というものが用意されています。
使い方は簡単、デコレータを付けるだけです。
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を使って先のオレオレキャッシュ実装を書き直すと以下のようになります。
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的にはスレッドセーフ性についてはどちらかと言うと「アプリがスレッドセーフじゃないことも考慮して、アプリサーバはシングルスレッドでの実行も対応すべき」とありますがスレッドセーフである方が望ましいことは確かです。排他なんて言わなくてもすでにリクエスト単位しているのだからキャッシュオブジェクトを再帰呼び出しの際に引き回せばいいです。キャッシュ&並行性こわい。