背景
この前描いてた記事のスピンオフ的な記事でキャッシュスタンピード対策をPythonで実装するとしたらどんな感じかなと思って調べてみた記事。内容としては以下の記事でやっていることの一部を掘り下げて考えてみたという感じです。
検証環境
やりたいこととしては重い処理を行うWebサーバがあるような構成でそこへのリクエストがその前段のクライアントで絞れるかを実装するのがゴール。以下のような構成を作って検証を行う。
* サーバ -> Flaskを用いたアプリケーションサーバ。"/sleepで重い処理を再現" * クライアント -> Python * ロック用サーバ -> Redis: 6.3
検証用のソース
get_and_set
という関数でロックの取得とバックエンドへのデータの取得/kvsへのデータの設定を行う。
#!/usr/bin/env python import sys import os import redis import requests import time r = redis.Redis(host='redis', port=6379, db=0) def get_and_set(key: str): val = r.get(key) if val: return val.decode() # keyがnullの場合はこちらの処理に来る。ロック取得成功でバックエンドにリクエストを飛ばし、valueをsetする if r.set(f"lock_{key}", "1", ex=10, nx=True): print("get lock: pid=", os.getpid()) val = "" try: val = requests.get("http://localhost:5000/sleep").text #激重処理 r.set(key, val, ex=10) except: r.delete(f"lock_{key}") # バックエンドへの疎通失敗時もロックは解除 return val print("didn't get lock: pid=", os.getpid()) # keyが設定されるまでsleepしつつポーリング while val is None: val = r.get(key) time.sleep(0.2) # sleep時間がレスポンスタイムへのボトルネックにならないようなるべく短くする return val.decode() def main(args): print(get_and_set(args[1])) if __name__ == "__main__": main(sys.argv)
ポイントはこの部分でsetにオプションnx=Trueを加えることでkeyの存在確認を行なって存在しなければsetするようになる。また、exを設定することで例えばバックエンドへのリクエスト中のpythonで補足できない突然死が発生してもロックが自動的に解放される。
if r.set(f"lock_{key}", "1", ex=10, nx=True):
ちなみにRedisサーバが死んだとしてもttl自体はUnixTimestampで保存されているのでフェールオーバ先でEXPIREされるので無駄に長く待ちすぎるみたいなケースは発生しづらい。
EXPIREAT は EXPIRE と同じ効果と意味を持ちますが、TTL (生存期間)を表す秒数を指定する代わりに、Unixの絶対タイムスタンプ (January 1, 1970 からの秒数)を取ります。過去のタイムスタンプはキーを直ちに削除します。 参考: http://mogile.web.fc2.com/redis/commands/expireat.html
実験
クライアントを2プロセス起動させてみる。それぞれロック取得の成功可否で処理が分かれてうまくいっている!良かった!
# クライアント① root@8bac3935d5ce:/# python redis_cli.py key get lock: pid= 217 keys-py # クライアント② root@8bac3935d5ce:/# python redis_cli.py key didn't get lock: pid= 236 keys-py # バックエンドのログも1回しかきていないことを確認 flask_1 | 127.0.0.1 - - [28/Aug/2021 04:33:42] "GET /sleep HTTP/1.1" 200 -
負荷的な話を気にしてみる
# keyが設定されるまでsleepしつつポーリング while val is None: val = r.get(key) time.sleep(0.2) # sleep時間がレスポンスタイムへのボトルネックにならないようなるべく短くする return val.decode()
負荷が気になるのはこの部分。ポーリングしてビジーウェイト的にロックを取得したプロセスがvalueを設定するまで待ち続ける。気になるポイントの1つはgetをひたすら回し続けることによるRedisサーバへの負荷で、もう一つはsleepで回し続けることによるクライアント側のCPU負荷。Redisが暇なサーバだったりプロセス数が少ないようなモデルのアプリケーションならそれぞれ無視することはできそう。
以下は待つ側のプロセスのstraceによるsystemcall呼び出しの回数。getも40回近くが1プロセスあたり呼び出すことになる。
root@8bac3935d5ce:/# strace -wc python redis_cli.py ddd % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 96.40 8.486300 202054 42 select 0.77 0.067874 74 907 69 stat 0.36 0.031764 352 90 44 recvfrom
どうするのが良いのか?
Redisが提供しているXADDやXREADを使うのがよさそうと思った。ストリームとしての使い方としてタイムアウト機能を備えたコマンドとなっている。以下のようにBLOCKにタイムアウト秒を指定することでクライアントはその時間までデータの到着を待つという仕組み。
XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...]
参考: Redisストリームについて
待ち方としてはサーバにタイムアウトの時間を送信してクライアントはその時間になるまで以下でブロックする。Redisの場合はコネクション管理をサーバでやってるのでクライアントはとりあえず待っているだけでタイムアウトの処理も行われる。
ssize_t redisNetRead(redisContext *c, char *buf, size_t bufcap) { // 指定した時間を以下で待つ。タイムアウトはサーバ側で行う ssize_t nread = recv(c->fd, buf, bufcap, 0); if (nread == -1) { if ((errno == EWOULDBLOCK && !(c->flags & REDIS_BLOCK)) || (errno == EINTR)) { /* Try again later */ return 0; } else if(errno == ETIMEDOUT && (c->flags & REDIS_BLOCK)) { /* especially in windows */ __redisSetError(c, REDIS_ERR_TIMEOUT, "recv timeout"); return -1; } else { __redisSetError(c, REDIS_ERR_IO, NULL); return -1; } } else if (nread == 0) { __redisSetError(c, REDIS_ERR_EOF, "Server closed the connection"); return -1; // エラー以外はここに入ってない場合はnilが返る } else { return nread; } }
straceするとこんな感じ。recvfrom(2)でほぼほぼ待つだけと言った結果になる。Redis側でのクエリ数を見ても増えていないので負荷が減っているはず。
% time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 98.73 3.122772 1561386 2 recvfrom 0.43 0.013498 207 65 write 0.20 0.006480 137 47 mmap 0.09 0.002700 128 21 close 0.07 0.002266 141 16 openat