Help us understand the problem. What is going on with this article?

今日からできるRedis~SQLからの脱却~

More than 3 years have passed since last update.

このエントリーは、KLab Advent Calendar 2015 の22日目の記事です。

こんにちは。KLabとしては久々のAdvent Calendar参戦です。22番手も緊張しますね。
大阪事業所でサーバーアプリケーションを細々と書いているわんこ。といいます。
管理系のツールや、運用サポート系のツールつくるのが好きです。

はじめに

この記事は Redis を使ったサーバーアプリケーションの高速化、チューニングについて書いていきます。
基本的にはゲームにマッチするように書いていますが、それ以外のWebアプリケーションでも応用できるのではないかな。と思います。

Redis とは

割愛します。
本家Redis をご覧いただければたいていのことはわかると思います。(投

どんなチューニング?

そんなに難しい話はしません。実例Tipsだと思ってください。
記事内のサンプルコードはPHPで記述していますが、ほとんど、 SQL(MySQL) → Redis への変更なので、コードは気にしないで大丈夫なように書いたつもりです。わかりにくかったらコメントで指摘ください。
ようは既存のアプリケーションで動いている重めな SQL を Reids を使って高速化しちゃおう。ってことです。

こんなことってない??

  • 思いの外、アクティビティ系のページの閲覧数が高め
  • 思ってもみなかった条件でのDB検索ロジックの追加
  • GROUP BY とか ORDER とかの重たい SQL が Home画面 とかで利用されちゃってる

そんなときこそ、 Redis を使えば高速化できる!!!(ことが多いはず
重たいってわかってる SQL だけど、仕方ないんだよ…。って諦めてる方には是非試していただきたい方法です。
では実際に、Redisを使った高速化ということで、(おそらく)一番とっつきやすいランキング処理からはじめてみましょう。

よくあるランキング

ゲーム と Redis といえばこれを思いつく方も多いんじゃないでしょうか。

テーブル:stage_score

id player_id stage_id score created_at
1 472 3 510 2015-10-15 15:38:57
2 998 3 1035 2015-08-30 05:23:03
ranking.sql
SELECT player_id, stage_id, max(score) as score
FROM stage_score
WHERE stage_id = 1
GROUP BY player_id
ORDER BY score DESC
LIMIT 0, 10;

素直にSQLでやると、こうなります。
GROUP BY して、ORDER して、更には仕様的にLIMITも追加なんてよくあることですよね。
実際にMySQLだけで頑張ろうとすると、テーブル全スキャンがかかってしまいとても高負荷に…なんてこともあります。MySQLだけではリアルタイムランキングの実装はかなり厳しいものになってきます。
ではRedisでやってみましょう。

ranking.php
// Redis接続
$redis = new Redis();
$redis->connect("localhost",6379);
// stage:1のTOP 10を取得
$stage_id = 1;
$with_score = true;// スコア情報も結果セットに含むかどうか
$key = "ranking:stage_id:{$stage_id}";// ランキング専用KeyでStageIdごとにkeyを作成
$topPlayerIds = $redis->zRevRange($key, 0, 9, $with_score);

とっても簡単ですね。

どう変わったのか

どのように上に書いたSQLがRedisに置き換わったかというと、
KeyをStageIdごとのものにすることによって、SQLでのWHERE句の処理を引き受けます。
またデータ型にはソート済みセットを用いることにより、ORDER句の処理がデータセット時に勝手に行われて、内部で並び変わっています。

コマンドの意味

今回はzRevRangeというメソッドを使っていますが、実際これはRedis本体のzrevrangeがコールされています。コマンドごとの詳しい解説は省きますが、
z / rev / rangeのように分割できます。

  • z ソート済みセット型に対するコマンド
  • rev reverseの略で降順にソートする
  • range 一定範囲内を指すことが多い

これによりzrevrangeは、ソート済みセットを降順で一定範囲内を取得する。というコマンドであることがわかります。
先にでたSQLの意味とほぼ同等のことを表しますね。そしてコマンド名がわかりやすい

  • じゃあ昇順は?
    • zrange
  • n点〜m点のリストを取得するには?
    • zrangebyscore or zrevrangebyscore
  • この人、何位?
    • zrank

この上なくわかりやすいですね。

ランキング以外のソートデータ

Redisのソート済みセットはランキングのテーマで出てくることが多いんですが、そういう使い方以外にもたくさん用途はあるんです。
ソート済みってことはSQLでいうORDER句やLIMIT句が排除できるっていう考え方もできます。(複合的なものは難しくなってしまいますが…。
例えば、フレンドに挨拶した最新リストとかですね。
テーブル:friend_greet

id player_id target_id created_at
1 799 243 2015-11-02 07:38:04
2 158 462 2015-09-09 18:48:30
friend_greeting.sql
SELECT player_id, target_id, DATE(created_at) AS date, MAX(created_at) as updated_at
FROM friend_greet
WHERE player_id = 123
GROUP BY target_id, player_id, DATE(created_at)
ORDER BY updated_at DESC
LIMIT 20 

パフォーマンスを意識するゲームとかのAPIでこのSQLは発行したくないですね。
SQLの意図としては、TargetIdへ挨拶したPlayerIdの日毎の最終挨拶時間(updated_at)のリストです。
ではRedisを使ってみましょう。
この場合もソート済みセットを使います。大好きですね。
ソート済みセットのscoreは何も本当のスコア値だけではないです。タイムスタンプだっていいんです。
ある意味今回の要件ではタイムスタンプのスコアのようなものですね。
ソート済みセットのmemberは幸いなことにstring型でデータを渡せますので、memberにPlayerIdと日付のデータを持たせてしまいます。

friend_greet.php
$player_id = 123;
// 挨拶用Key
$key = "greet:target_id:{$target_id}";
$member = getGreetMemberString($player_id);

// 追加時
$redis->zAdd($key, time(), $member);

// 抽出時
$with_score = true;
$result = $redis->zRevRange($key, 0, 20, $with_score);

function getGreetMemberString($player_id) {
    return $player_id . "_" . date("Ymd", time());
}

今回のRedis化でのポイントは
複数のGROUP BY 指定を Key,Member に割り振り直すことで単一Keyで管理する
ということです。
フロント側のAPIで出来るだけ発行したくない、GROUP句やORDER句、LIMIT句をことごとく脱却させてくれるRedisさん。まじ感謝。

最後に

JOIN が必須なものもデータのもたせ方によっては単一のKeyで管理することも可能です。
シンプルなデータ設計を意識していけばもっとRedisに頼った高速化も可能だと思います。
逆にMySQLと違って全てのコマンドがアトミックに実行されます。そのため、MySQLで当たり前のように行っているトランザクション処理のようなものや、他のデータを参照しながらの更新をするには一工夫必要です。(Luaでの実装置いときますね)

今回は既存のあるあるSQLをテーマに初心者向けのRedis化について書いてみました。
これを読んで明日にでもgrep "GROUP BY"とかやってくださる方が一人でもいれば美味しいごはんが食べれます。是非やってみてください。

時間の都合上、ここまでとしたいと思いますが、第二弾はもっと凝ったRedisの使い方を書いていきたいなー。と勝手に思ってます。
っていう時間(準備)不足の言い訳をしながらおやすみなさい•̀.̫•́✧

明日は VoQn です。わくてか(๑•̀ㅂ•́)و✧

蛇足(最後じゃなかった

同一スコアでのRankの話

Redisではソートされているだけで、同じスコアの場合も違うスコアの場合も関係なくRank付されてしまいます。
同点一位が5人いても、1~5位という結果になってしまいます。
同点を意識したRankを取得したい場合はzcountなどを利用します。(いくつか方法はあります。
zcount を利用して知りたいランクのスコアより大きいスコアを持ったmemberの数を調べる方法です。

tie_score.php
$score = 12345;
$count = $redis->zCount($key, $score + 1, "+inf");
$rank = $count + 1;

マッチング

プレイヤー同士でステータスの近い人を探しだす機能です。
特定条件を満たす部分集合の中から、ランダムで一部を抽出します。最近のWebサービスなんかでもやってることも多いのではないでしょうか。
MySQLなどでもできなくはないですが、悲しきかな…ランダムで抽出をするときにはクエリキャッシュが意味をなさずパフォーマンスの低下が著しいのです。

では、少し複雑になってしまいますが特定のレベルから±2のプレイヤー20人を抽出しましょう。

matching.php
// ソート済みセットで score=level member=player_id の状態で格納されているKey
$rank_key = "player:rank";

// 抽出の元となるプレイヤーのレベル
$target_level = 10;

// 部分集合の条件
$level_range = 2;
$min = $target_level - $level_range;
$max = $target_level + $level_range;

// min~maxの中で一番レベルの低いPlayerIdを取得する
$min_lv_player = $redis->zRangeByScore($rank_key, $min, $max,["limit"=>[0,1]]);
// 全体で何位のレベルなのかを知る
$min_player_rank = $redis->zRank($rank_key, $min_lv_player[0]);

// min~maxの中で一番レベルの高いPlayerIdを取得する
$max_lv_player = $redis->zRevRangeByScore($rank_key, $max, $min,["limit"=>[0,1]]);
// 全体で何位のレベルなのかを知る
$max_player_rank = $redis->zRank($rank_key, $max_lv_player[0]);

// $min_rank~$max_rankに居るはずなので、その中でランダムなrankを取得する
$target_rank = rand($min_player_rank, $max_player_rank);

// 一時的に取得するプレイヤーの数
$tmp_list_count = 500;
$hit_player_id_list = $redis->zrange($rank_key, $target_rank - $tmp_list_count, $target_rank + $tmp_list_count);

// 取得できたのでこの中から20人取得しましょう。
shuffle($hit_player_id_list);
$return = [];
for($i=0; $i<20; $i++) {
    $return[] = $hit_player_id_list[$i];
}

実際ここはRedisを使っていても結構コストの高い処理になるので、$hit_player_id_listなどを別のキャッシュとして持たせておいて、別プロセスで30秒ごとに更新等やってあげるといいかもしれませんね。

でも、これもリアルタイムでやりたいんです!って言っちゃう方へ

Lua+EVALを使った処理

上記のマッチングだと、処理中にランキング情報が変わってしまった場合が考慮できていません。
そういう時はLuaを使って複数のコマンドを一つのScriptCommandとして実行することが出来ます。

matching
local rank_key = 'player:rank'
local target_level = ARGV[1]
local level_range = ARGV[2]

local min_lv_player = redis.Call(
    'zrangebyscore',
    rank_key,
    target_level - level_range,
    target_level + level_range,
    'LIMIT', 0, 1
)
local min_player_rank = redis.Call('zrank', rank_key, min_lv_player[0])

local max_lv_player = redis.Call(
    'zrangebyscore',
    rank_key,
    target_level + level_range,
    target_level - level_range,
    'LIMIT', 0, 1
)
local max_player_rank = redis.Call('zrank', rank_key, max_lv_player[0])

local target_rank = math.random(min_player_rank, max_player_rank)

local tmp_list_count = ARGV[3]
return redis.Call(
    rank_key,
    target_rank - tmp_list_count,
    target_rank + tmp_list_count
)

このように記述したLuaスクリプトをRedisのEVALコマンドに渡します。

lua_script.php
$lua_script = getLuaScript(); //Lua:matchingの内容を返す関数
$params = [
    $target_level,
    $level_range,
    $tmp_list_count,
];
$hit_player_id_list = $redis->eval($lua_script, $params);

EVALで実行されたScriptはRedis側に保存されるので、可変する値はARGVで渡してしまった方が、コストが安くなります。

wanko
NFC埋め込みお兄さん
iotlt
IoT縛りの勉強会です。 毎月イベントを実施しているので是非遊びに来てください! 登壇者を中心にQiitaでも情報発信していきます。 https://iotlt.connpass.com
https://iotlt.connpass.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away