今更Redisのsorted set を知ってリアルタイムランキングの実装が超楽だった件


最近ランキングを実装する機会があって、どうやって実装しようかって話をしていたら、@attakei さんがRedisのsorted set使うと楽よ!って教えてくれたので、使ってみたら超楽ちんでした
2年位前に流行ったんですね、知らんかった。

リアルタイムランキング

旧時代の実装

私がゲーム制作していた時代は、ランキングというと一定時間ごとにバッチを回して、ランキングテーブルにソート済みのデータを流しこむという感じの実装でした。
おかげで、ユーザー参照用とバッチ作業用の二つのランキングテーブルと、ランキングテーブルを切り替えるためのスイッチングテーブルが必要になったり、バッチ流す必要があったり、とにかく面倒くさかったように覚えています
リアルタイムなんて、いちいち全ユーザーの集計をしてデータをソートして順位を割り出すことになり、そんなことは無理な話でした

Redis の sorted set

ところが、時代は変わる者で、ただのキャッシュだと思っていたRedisですが、sorted set、つまりソート済みデータを格納する機能があったのです。

http://redis.shibu.jp/commandreference/sortedsets.html
http://redis.io/commands#sorted_set

これによると、データ内の各基準スコアに対して、データを昇順に並べておいてくれる機能があるということです。
普通ランキングはスコアの大きいものから順に並べるので、一瞬心配にはなりますが、逆順からの走査もできるので、この心配は杞憂でした。

新しいランキングの実装方針

というわけで、ランキングの実装方針は驚くほど簡単です。

  1. 得点が更新される
  2. その得点をスコアとしてユーザーIDをredisのsorted setに格納する

これだけです。
リアルタイムなのに、従来の実装よりも早いんじゃないかって思います

PHP で実装

早速PHPで実装してみましょう。

phpredis extension

composerで導入できるライブラリが使いにくそうだったので、ここはエクステンション入れちゃいます。
最近はdockerで環境作っているので、そのDockerfile を以下のようにしています

FROM php:latest

RUN apt-get update && apt-get install -y unzip git && \
    docker-php-ext-install pdo_mysql mysqli mbstring

# エクステンションはgit経由でビルドする
RUN cd /tmp && git clone https://github.com/phpredis/phpredis.git && \
cd phpredis && git checkout php7 && \
phpize && ./configure && make && make install

RUN echo extension=redis.so >> /usr/local/etc/php/conf.d/redis.ini

RUN mkdir /var/php -p
WORKDIR /var/php

CMD /bin/bash

Redisは公式イメージ使います
いちいち設定を書くのも面倒なので、docker-composeを使って環境を立ち上げます

docker-compose.yml
version: '2'
services:
    web:
        build: .
        ports:
            - '3000:3000'
        volumes:
            - '.:/var/php'
        depends_on:
            - redis

    redis:
        image: redis

あとは

$ docker-compose up -d
$ docker-compose run web

これでテスト環境内で実験ができます

実装する

基本的にsorted setのAPIをいい感じに使うだけです。
ちょっと長いのでGist においておきます
https://gist.github.com/niisan-tokyo/a1a2b4de17d63aa4da174c5961578a55

このクラスはこんな感じで使います

test1.php
require './Ranking.php';

use NiisanTokyo\Ranking;

$ranking = Ranking::getInstance('ranking1');

// Test data
$data = [
    'niisan' => 19,
    'jiisan' => 20,
    'mossan' => 11,
    'mk2'    => 15
];

foreach ($data as $key => $val) {
    $ranking->setScore($key, $val);// データのセット
}

print_r($ranking->getRange());// 全件取得
print_r($ranking->getRange(1, 2));// 1位から順に2件

すると、以下の様なソート済みのデータが出力されます

Array
(
    [jiisan] => 20
    [niisan] => 19
    [mk2] => 15
    [mossan] => 11
)
Array
(
    [jiisan] => 20
    [niisan] => 19
)

各個人の順位などは以下のように取ります

print_r($ranking1->getRank('jiisan'));// 1
print_r($ranking1->getRank('noosan'));// 0(ランキングにいない)

また、以下のように同時に幾つものランキングを生成・参照できます
例えば、ゲームとかだと個人ランキングとギルドランキングを作るときには便利です

test2.php
$user_ranking = Ranking::getInstance('user');
$guild_ranking = Ranking::getInstance('guild');

$user_ranking->setScore(1, 15);
$guild_ranking->updateScore(1, 15);
$user_ranking->setScore(2, 12);
$guild_ranking->updateScore(1, 12);// ユーザー2がユーザー1と同じギルドに所属しているような状況

echo $user_ranking->getScore(1) . "\n";// 15
echo $guild_ranking->getScore(1) . "\n";// 27

こんな感じです
差分で投入できるならupdateScoreを使うほうがいいですが、一度データがずれると立て直せなくなるので、使い方は慎重にって感じです

nodeでは

nodeでもredisは当然使えるのですが、なかなか使いにくいように思います
phpredisが使いやすいだけかもしれませんが

まず、redisクライアントを導入します

npm install redis

コードは次のようになります

const redis = require('redis');
const client = redis.createClient({host: 'redis'});

[{key: 1, score:15}, {key: 2, score:7},{key: 3, score:25},{key: 4, score:19}].forEach(v => {
    client.zadd('hoge', v.score, v.key);
});

client.zrevrange(['hoge', '0', '-1', 'WITHSCORES'], redis.print);// Reply: 3,25,4,19,1,15,2,7

本当に単純にRedisとつなぎこんだだけです。
クライアントメソッドは、基本的にRedisのコマンドをまんま小文字にして、パラメータを配列で渡すというものです。

で、返却データが id, スコア, id, スコア,... みたいになっていて、ちゃんと加工して上げる必要があります
Redisの生データ触りたいって人にはいいかもしれないですが、phpredisに慣れると、うーんって感じがします

まとめ

ランキングの実装って今までバッチの実装が盛り込まれて随分面倒だったのですが、Redis のsorted set 使うと非常に楽ちんでした。
あと、phpredisライブラリが結構良く出来てると思いました
さすがPHP、PHPerでよかった!

そんな感じで、今回はこの辺で

参考

http://damepg.hatenablog.com/entry/2014/08/07/231828
https://github.com/phpredis/phpredis#zscore
https://github.com/NodeRedis/node_redis