ローカルRedisでアプリケーションを高速にする(Linux+PHP+DB+Redis)

イラスト_PHP_AdventCalendar7.png

はじめに

ローカルRedisを使うと外部DB・キャッシュへの接続回数、通信回数、通信データ量を減らすことができます。
特にオートスケールでWebサーバが増減するシステムに合っていると思います。
Memcachedでも同じことができますが、ランキングやるならばRedisのsorted setだったりするし、ローカルと外部で統一した方がいいかなーということで今回はRedisでやってみました。

以前に投稿した「あるSPソーシャルゲームのチューニングメモ」のキャッシュ処理と高速化についてより細かくまとめたものです。
高速化の話をするとどうしても精神論みたいな話が多くなってしまうので、なるべく具体的なコード例を書いたつもりです。
(でも精神論も大事なので全部読んでほしぃ…)
今回はphpredisを使ってシンプルに書いたので分かりやすくなっているはず!!

Redis使い分けの例

  • ローカルRedis
    主にマスターデータ(今回の本題)

  • 外部Redis
    セッション管理、ランキング、マルチバトル、ユーザー固有のデータなど

キャッシュを利用する時は外部キャッシュサーバーを使うケースが多いのではないでしょうか。
規模が小さければそれでだけで問題なく捌けるかもしれません。
Webサーバーが数十台以上になるとキャッシュサーバーが負荷でダウンするケースもあります。
そんな時におススメしたいのがローカルキャッシュです。
ローカルでどうやってキャッシュを共有するんだ?と疑問に思ってしまった方はもう一度上の使い分けの例を見てください。
マスターデータのような動的に変更されないデータをローカルキャッシュします。
難しく考えずWebサーバー全台でキャッシュして大丈夫です。
各サーバーでキャッシュするタイミングがズレたとしても問題ありません。
もしかすると複数のサーバに同じキャッシュがあることに違和感とか非効率など感じるかもしれませんね。
そういった固定概念は一旦忘れましょう。
キレイに整頓されていても動かなければ意味がないのです。

環境構築

  • WebサーバにRedisをインストールする。
  • RedisはUnixドメインソケットで接続する。
  • Redisに接続する際には持続的接続(pconnect)を使う。
  • Redisには主にマスターデータをキャッシュする。

インストール&確認

ざっくりと。手元の環境がCentOS7とPHP7.0だったので以下のような感じ。

php7とredisのインストール
$ sudo yum install epel-release
$ rpm -ivh http://rpms.famillecollet.com/enterprise/remi-release-7.rpm
$ sudo yum install redis
$ sudo yum --enablerepo=remi-php70 install php
$ sudo yum --enablerepo=remi-php70 install php-pecl-redis
.sock用のディレクトリ設定を追加
$ sudo vi /usr/lib/tmpfiles.d/redis.conf
d /run/redis 775 root redis
redisにUnixドメインソケットの設定を追加
$ sudo vi /etc/redis.conf
unixsocket /var/run/redis/redis.sock
unixsocketperm 777
PHPの確認
$ php -v
PHP 7.0.25 (cli) (built: Oct 24 2017 18:17:05) ( NTS )
Copyright (c) 1997-2017 The PHP Group
Zend Engine v3.0.0, Copyright (c) 1998-2017 Zend Technologies
redisの確認
$ sudo systemctl start redis.service
$ redis-cli get test
(nil)
$ redis-cli set test 10
OK
$ redis-cli get test
"10"

redisの設定調整

せっかくなのでデーモン化とかいろいろ試してみました。
メモリ量はサーバスペックに合わせて調整します。

/etc/redis.conf
daemonize yes
pidfile /var/run/redis/redis.pid
maxmemory 1gb
maxmemory-policy volatile-lru
supervised systemd
systemdの設定変更して反映
$ sudo vi /etc/systemd/system/multi-user.target.wants/redis.service
After=network.target syslog.target
Type=notify
ExecStart=/usr/bin/redis-server /etc/redis.conf
Restart=on-failure

$ sudo systemctl daemon-reload
$ sudo systemctl start redis.service
$ sudo systemctl enable redis.service

Transparent Huge Pages (THP)が有効だとRedis起動時にWARNINGが出るので無効にする。

WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled.

メッセージ通りにやってもWARNING出てしまったので、以下のように設定変更しました。

transparent_hugepage=neverを追記して設定を反映
$ sudo vi /etc/default/grub
GRUB_CMDLINE_LINUX="transparent_hugepage=never ....." #もとの設定は消さずに追記

$ sudo grub2-mkconfig -o /etc/grub2.cfg

OS再起動してneverに設定されていれば成功です。
Redis起動時のWARNINGも出なくなっているはず。

$ sudo cat /sys/kernel/mm/transparent_hugepage/enabled
always madvise [never]

Redisでキャッシュする

何かしらの計算過程で複数のレコードを取得する(IN句を使うSELECT)が大量にあると効果を実感しやすいと思います。
RPGやカードゲームのバトル計算が近いでしょうか。
(キャラクター、属性、スキルなど複数データを組み合わせて計算する)

サンプルソース

https://github.com/Fearinota/RedisWrapper
以降で紹介するコードはこのラッパークラスを使っています。
Wrapper/Redis.php:本体
Class/xxx.php:本体をさらにラップして使う例
Batch/Redis.php:バッチ処理用のクラス
Sql/create_schema.sql:サンプルスクリプト用のテーブル作成DDL(MySQLで作成したDDL)
Sample/sample.php:1レコードづつキャッシュするサンプルスクリプト(PHP+MySQL+Redis)
※サンプルはMySQL用の記述になっています。他のDBで使う場合はDSN等を書き換えてください。

phpredisをそのまま使っても実現できます。
用途別に設定変更できた方が便利かなという程度。
説明用にざっくり作ったので実用するには要調整かな…。

1レコードづつキャッシュする

予めバッチ処理でローカルRedisにキャッシュデータ(ハッシュ型)を作っておきます。
KEYはテーブル名とユニークなIDとします。(自分のやりやすい形式でOK)
マスターデータが大量にあり、1度に複数のレコードを取得する(IN句を使っている)場合に有効です。

$pdo = new \PDO('dsn', 'user', 'password');
$redis = new Redis();
$cache = [];
$sql = 'SELECT * FROM character';
$rows = $pdo->query($sql, \PDO::FETCH_ASSOC);
foreach ($rows as $row) {
    $cache[$row['id']] = $row;
}
$redis->hMSet('character', $cache);

キャッシュを利用するにはまずユーザーの所持リスト(アイテム、キャラクターなどのID)をDBから取得してKEYの配列を作成します。
KEYの配列を使って1回でマスタデータを取得(hMGet)します。
(ループで1個ずつ取得するのはNG)

$pdo = new \PDO('dsn', 'user', 'password');
$redis = new Redis();
$keys = [];
$sql = 'SELECT * FROM user_character_list'; //ユーザーの所持リストを取得
$rows = $pdo->query($sql, \PDO::FETCH_ASSOC);
foreach ($rows as $row) {
    $keys[] = $row['id'];
}
$data = $redis->hMGet('character', $keys);

想定されるフロー
1.バトル開始時にキャラクターやモンスターのIDをDBから取得
2.キャラクターやモンスターのマスターデータをローカルRedisから取得
3.ユーザー固有のデータ(キャラクターのステータスや習得済みスキルなど)をDBから取得
4.バトルの計算をする

マスターデータが多いほど効果が出ます。
ユーザー固有のデータも再取得することがあれば外部Redisでキャッシュしておくと高速化できるかもしれません。(1と3)
過去に携わったソシャゲでは同じカードデッキで何度もバトルする個所があったのでデッキ丸ごと(1と3に相当するデータ)キャッシュして高速化できました。

複数レコードをまとめてキャッシュする

テーブルの全レコード

基本的にはレコード数が少なくてほぼ毎回全スキャンして使うようなテーブルが対象です。
データ量が少ないならまとめて配列でキャッシュしましょう。
やり方は1レコードづつキャッシュとほぼ同じ。
KEYをテーブル名と"ALL"としておきます。
可能であれば予めバッチでキャッシュ作成しておくとよいでしょう。
データ量が少ないものが対象なので動的にキャッシュ作成しても大丈夫かなと思います。

$pdo = new \PDO('dsn', 'user', 'password');
$redis = new Redis();
$rows = $redis->hGet('table', 'ALL');
if (empty($rows)) {
    $sql = "SELECT * FROM table";
    $rows = $pdo->query($sql, \PDO::FETCH_ASSOC);
    $redis->hSet('table', 'ALL', $rows);
}

1レコードづつキャッシュしてhGetAllする方が汎用的な気もしますが、
必要なレコードを複数回抽出するより1回で全部取得して変数に残しておいた方がよいでしょう。
KEYを逆('ALL','table')にすれば1つのハッシュ型で複数のキャッシュを管理できます。
こうするとhGetAllでまとめて取得できます。(まとめすぎて肥大化しないように注意
後述のstatic変数キャッシュと合わせて使うのをお勧めします。

クエリ結果

実行回数が多い全ユーザー共通のクエリの結果をキャッシュします。
キャッシュがあればキャッシュを返し、なければDBから取得してローカルRedisに入れます。
既存のシステムを高速化する時によく使う方法です。

$pdo = new \PDO('dsn', 'user', 'password');
$redis = new Redis();
$rows = $redis->hGet('table', $id);
if (empty($rows)) {
    $sql = "SELECT * FROM table WHERE id={$id}";
    $rows = $pdo->query($sql, \PDO::FETCH_ASSOC);
    $redis->hSet('table', $id, $rows);
}

※むやみにキャッシュしないこと!
更新頻度の高いデータはキャッシュしても意味がないので止めましょう。
よく考えずにキャッシュしすぎるとキャッシュがボトルネックとなってしまいます。

ユーザー固有のデータをキャッシュする(外部Redis)

COUNT()など性質上重くなってしまう集計系SQLはキャッシュに向いています。
データ更新と連動する必要があるため外部Redisでやりましょう。
重いものを頻繁に集計したくないので有効期限は1時間とか長めにしてよいです。
データ更新する時にキャッシュも更新(or 削除)すれば整合性は保てます。
有効期限はシステムごとに調整してください。
Redisのincrでカウントアップする方法もありますが、障害から復旧した時などキャッシュを作り直す必要がある場合を考えると取得したデータをキャッシュしておく方法が無難かなと思います。

データ取得時(例:新着メッセージ数)
$expire = 3600;  //1h
$pdo = new \PDO('dsn', 'user', 'password');
$redis = new Redis();
$count = $redis->get('user:'.$user_id.':message_count');
if (empty($count)) {
    $sql = "SELECT COUNT(*) AS count FROM message WHERE user_id={$user_id} AND new_flag=1";
    $row = $pdo->query($sql, \PDO::FETCH_ASSOC);
    $redis->setex('user:'.$user_id.':message_count', $expire, $row[0]['count']);
}
データ登録時(例:新着メッセージが増えるのでキャッシュ削除)
$pdo = new \PDO('dsn', 'user', 'password');
$redis = new Redis();
$sql = "INSERT INTO message VALUES({$user_id}, 'message')";
$pdo->query($sql);
$redis->del('user:'.$user_id.':message_count');
データ更新時(例:メッセージを既読にする際にキャッシュ削除)
$pdo = new \PDO('dsn', 'user', 'password');
$redis = new Redis();
$sql = "UPDATE message SET new_flag=0 WHERE user_id={$user_id}";
$pdo->query($sql);
$redis->del('user:'.$user_id.':message_count');

例えばKEYをuser:id:xxxxのようにするとユーザー別にまとめて取得・削除ができます。

redisのKeyをまとめて削除
$redis = new Redis();
$redis->del($redis->keys('user:'.$user_id.':*'));

KEYの形式はシステムごとに最適なものを考えるしかないです。
なるべく取得・更新・削除の回数が少なくなる形式であるとよいです。

static変数でキャッシュする

1処理内で全く同じクエリを何度も実行しているならば、クエリ結果を丸ごとstatic変数でキャッシュするとよいです。
そうすれば2回目以降は変数の内容を参照するだけになります。
(Redisから全く同じキャッシュを何度も取得している場合も同じ)

特にORMを使っているシステムでは効果が高いかもしれません。
処理中にデータ変更されるものには注意しましょう。
基本的に変更されるものはキャッシュしなくてよいです。
もしデータ変更時に変数をクリアする方が速くなるならば面倒でもキャッシュした方がいいでしょう。
※(念のため書いておきますが)変数なのでPHPの処理が終わると消えます。

変更前
/**
 * データを1行取得する
 * @param   PDO $pdo   PDOオブジェクト
 * @param   int $id    プライマリID
 * @return  array      結果配列
 */
function select($pdo, $id) {
    $sql = "SELECT * FROM table WHERE id={$id}";
    return $pdo->query($sql, \PDO::FETCH_ASSOC);
}
変更後
static $_cacheSelect = array();
function select($pdo, $id) {
    $cacheKey = 'table_'.$id;
    if (isset(self::$_cacheSelect[$cacheKey])) {
        $rows = self::$_cacheSelect[$cacheKey];
    } else {
        $redis = new Redis();
        $rows = $redis->hGet($key, $cacheKey);
        if (empty($rows)) {
            $sql = "SELECT * FROM table WHERE id={$id}";
            $rows = $pdo->query($sql, \PDO::FETCH_ASSOC);
            $redis->hSet($key, $cacheKey, $rows);
        }
        self::$_cacheSelect[$cacheKey] = $rows;
    }
    return $rows;
}

目安として3回以上同じクエリが実行されていれば変数キャッシュで速くなると思います。
Redisキャッシュと併用すると効果的かもしれません。
状況に応じて使い分けしましょう。

キャッシュの有効期限について

基本的にマスターデータは無期限でよいです。
全ユーザーが使うので期限を設定してもあまり意味がなく再度キャッシュするコストが無駄です。
マスターデータ以外もキャッシュするならばそれらは1~5分程度でよいでしょう。
1分で100回実行されているならば5分キャッシュで外部への通信を499回減らせます。
これで十分です。
例えば1時間に延ばしたところで11回しか差がありません。
無駄にキャッシュを肥大化させないように利用頻度を確認しましょう。

高速化するには

  • なるべく接続回数(DB接続…etc)を減らす
  • なるべく通信回数(クエリ実行…etc)を減らす
  • なるべく通信データ量(クエリの結果データ量…etc)を減らす

こういった地味な作業の積み重ねです。
地味な作業の連続なので速度計測してちゃんと達成感を得ると楽しくなるかも!
まだ実感がない人は実際に外部接続やクエリ実行の速度を計ってみましょう。
計ってみると単純な計算より多くの時間がかかることが分かると思います。
(100~1000回ぐらいループさせて計ると比較しやすいかな)

そういえば最近SQLのselect文でカラム名の指定が無いのをよく見かけます。
ここでは説明しやすいので「*」を使いましたが、なるべく必要カラムだけ指定するようにしましょう。
1レコードだけ取得する場合はクエリが短くなるので良いのかもしれませんが…。
とりあえず更新日時と作成日時は絶対いらないよねっ!
これだけでもDatetime型×2=16Bとなり、文字列として返してる場合は38Bですね。
例えば38B×1万(人アクセス)×10(回select実行)とすると3MBぐらいですね。
3MBのデータを送るのに何秒かかるでしょうか?

こういったことを考えるところからが高速化の第一歩です。
何か高度な仕組みやコードを書けないと出来ないと思っていませんか…?
一番必要なのは気合いと根性!

参考

phpredis/phpredis(GitHub)
redis

おまけ:キャッシュ作成バッチをsystemdに設定する

設定ファイルを作ります。
今回はrediscache.serviceとしました。(ファイル名は任意)

/etc/systemd/system/multi-user.target.wants/rediscache.service
[Unit]
Description=Create Redis Cache
After=network.target redis.service

[Service]
Type=simple
ExecStart=/usr/bin/php /path/to/batch.php
User=root
Group=root
Restart=on-failure
RestartSec=10s

[Install]
WantedBy=multi-user.target
自動起動の設定をする
$ sudo systemctl daemon-reload
$ sudo systemctl enable rediscache.service

※実行するファイルがVagrantのconfig.vm.synced_folderでマウントしているディレクトリにある場合
マウントが完了する前に実行しているのかエラーになってしまいました。

localhost php: Could not open input file: /path/to/batch.php

いろいろ設定変更を試してもダメだったのでRestartSecを設定しました。
手元の環境では1回再起動しますがキャッシュ登録できています。
とりあえずこれで…。(まぁ実際にサーバーマシンで設定する時は発生しないでしょうし)
あとはExecStopにキャッシュクリアするバッチを設定しておくとよいかも。

Jenkinsで全Webサーバ一括実行できるように自動化しておくと、運用でキャッシュを更新・削除するのが簡単になります。
オートスケールしている場合は現在起動中のWebサーバーを抽出して実行するようなものが必要ですね。

編集後記

MemcachedをRedisに差し替えるだけなんて軽い気持ちで手を出したら調べて確認することが満載でした。
本題と関係ないところでCentOS7での設定に詰まったりとか…。
めちゃくちゃ時間かかりましたがいろいろ調べて試したので勉強になりました。
最初ひと通り書くのに10時間ぐらいかかり、その後修正に修正を繰り返していてもはや合計何時間かかったのかわかりません…w
日本語って難しいなー(遠い目

Webサーバのメモリが余っていたらローカルRedisキャッシュで高速化してみよう!
static変数キャッシュも添えて。