今回はuWSGIのワーカプロセス間でキャッシュを共有できる、cache framework
を試してみます。ISUCONをPythonで戦うとなると、大抵はRedisあたりだと思うのですが、それだと1プロセスで動作するGoやNode.jsに対してディスアドバンテージをもつことになるので、どうにかできないかというのが本記事の主題です。
PythonのWEBアプリケーションサーバ
PythonはGILがある関係上、CPUバウンドな処理はマルチプロセスにしなければ性能が出にくいです。そのためWEBアプリケーション(ここではwsgi)も複数のワーカプロセスを用意して振り分けるような実装になっているものが多いです。
よく名前が上がるgunicornやuWSGIもワーカプロセスを複数用意する利用方法が一般的です。
プロセスが複数に別れるということは、それらの間で共有すべき情報がある場合は何らかの共通レイヤが必要になります。ざっくり考えると以下のような形式になります。
方法 | メリット | デメリット |
---|---|---|
RDBMSを背後に配置する | もっとも基本的な方法であり、多種多様なデータを共有できます | 他の方法に比べるとパフォーマンス面で劣る可能性があります |
Redis等を背後に配置する | RDBMSよりパフォーマンスが優れます。データの形式によってはよく取られる方法の一つです | 複雑なデータ処理等は実装が面倒です。 |
ソースコードに必要な情報を事前にべた書きする | 更新が一切かからないデータであれば最速です。 | 更新がかかる場合は、プロセス間の整合性が保てません |
The uWSGI caching framework
uWSGIには、ワーカ間で情報を共有するためのキャッシュフレームワークがあります。この機能がRedisを利用する場合よりもパフォーマンス的に優れるかを確認していきます。
uWSGIのキャッシュフレームワークではuwsgi.cache_set(key, value)
やuwsgi.cache_get(key)
でDictライクに扱うことができます。
用意されているメソッドはこちらから確認できます。なお、uwsgi起動時に--cache2 name=mycache
といった形でキャッシュを明示する必要があります。
環境情報
今回は以下の環境で試しました。試験用のアプリはbottleで用意したのでそちらもいれておきます。
- Ubuntu 16.04
- Python 3.6.2
- uWSGI 2.0.15
- redis-py 2.10.6
- hiredis 0.2.0
- bottle 0.12.13
(uwsgi) denzow@denzow-ubuntu:~$ pip install uwsgi redis hiredis bottle
検証(uwsgiキャッシュフレームワーク)
以下のようなコードを用意しました。
# coding: utf-8 # bottle.pyから使う関数をインポート from bottle import Bottle, route, run import uwsgi import pickle import os app = Bottle() @app.route('/') def index(): # キャッシュがなければ0で初期化 if not uwsgi.cache_exists("access_count"): uwsgi.cache_set("access_count", pickle.dumps(0)) # キャッシュからアクセス数の情報を取得し、1を加算 count_num = pickle.loads(uwsgi.cache_get("access_count")) count_num += 1 # 新しい値でキャッシュを更新 uwsgi.cache_update("access_count", pickle.dumps(count_num)) # ワーカのプロセスIDとアクセス数をレスポンスとして返却 return 'process:{pid}, count:{count_num}\n'.format(count_num=count_num, pid=os.getpid()) if __name__ == "__main__": # テスト用のサーバをlocalhost:8080で起動する run(app, host='localhost', port=8080)
以下のようなコマンドで本アプリを起動します。
(uwsgi) denzow@denzow-ubuntu:~/work$ uwsgi --http :8080 -w app:app --workers=2 --cache2 name=mycache,items=1000 --chmod-socket 666
これで、ワーカプロセス2本が起動されます。それに対してアクセスを繰り返すと以下のようにプロセスが異なっても一貫性のとれた値が戻っていることが確認できます。
: process:30555, count:27 process:30555, count:28 process:30555, count:29 process:30553, count:30 # プロセスが違っても正しくインクリメントされている process:30555, count:31 process:30555, count:32 process:30555, count:33 :
ではパフォーマンスを確認します。100アクセスをシリアルで行った場合の合計時間を測定します。
$ for x in `seq 1 100`; do curl localhost:8080 ; done
結果は以下のようになりました。
real 0m1.426s user 0m0.446s sys 0m0.417s
検証(redis-py)
同じ処理をredis-pyでも実装します。redisへの接続が追加されている以外はほぼメソッドの置き換えのみです。なお、この処理であればredisのincr
等でもっと効率的に実装できますが、あくまで比較のためそのままとしています。
# coding: utf-8 # bottle.pyから使う関数をインポート from bottle import Bottle, route, run import redis import pickle import os app = Bottle() POOL = redis.ConnectionPool(host='localhost', port=6379, db=0) @app.route('/') def index(): r = redis.StrictRedis(connection_pool=POOL) # キャッシュがなければ0で初期化 if not r.exists("access_count"): r.set("access_count", pickle.dumps(0)) # キャッシュからアクセス数の情報を取得し、1を加算 count_num = pickle.loads(r.get("access_count")) count_num += 1 # 新しい値でキャッシュを更新 r.set("access_count", pickle.dumps(count_num)) # ワーカのプロセスIDとアクセス数をレスポンスとして返却 return 'process:{pid}, count:{count_num}\n'.format(count_num=count_num, pid=os.getpid()) if __name__ == "__main__": # テスト用のサーバをlocalhost:8080で起動する run(app, host='localhost', port=8080)
uwsgiでこのアプリケーションを起動した上で、同じように結果を測定してみます。
real 0m1.483s user 0m0.444s sys 0m0.445s
若干ではありますがuwsgi のキャッシュフレームワークのほうが早いようです。
まとめ
uWSGI caching frameworkのパフォーマンスを試してみました。Redisをつかうよりも僅かではありますがパフォーマンスがあがりました。ただし、今回は処理の流れを揃えていましたがRedisのほうが改善できる箇所が多いため最終的には逆転し得る結果だと思います。もう少し試して自分の中で落とし込みたいと思います。