地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Redis】キャッシュスタンピード対策をPythonで実装してみる

背景

ryuichi1208.hateblo.jp

この前描いてた記事のスピンオフ的な記事でキャッシュスタンピード対策をPythonで実装するとしたらどんな感じかなと思って調べてみた記事。内容としては以下の記事でやっていることの一部を掘り下げて考えてみたという感じです。

techblog.zozo.com

検証環境

やりたいこととしては重い処理を行う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;
    }
}

github.com

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