リアルタイムでスコアランキングを登録・表示するツール

はじめに

この記事はGo3 Advent Calendar 2020の2日目の記事として記載してます。 リアルタイムでランキングの更新・表示ができるpackageをつくったのでご紹介させていただきます。

スコア降順のランキングシステム

例えばユーザーであれギルドのようなグループ単位であれ、スコアが高い順に並べるランキングがあるとします。複数スコアラーが同点である場合も加味すると大体下記のような仕様であることが多いかと思われます。

  1. ランキングはスコアの降順で高くなります
  2. 同じスコアの場合、登録時間が早いスコアラーがより高いランクになります

問題点

そのとき単にスコアの降順で表示するだけ(上記の1の仕様だけ)ならRedisのSortedSetsのZREVRANGEを使用すれば一発で解決します。

では2の同点であった場合は早く登録したスコアラーを上位にする仕様はどうでしょうか?

f:id:muroon:20201201210621p:plain

RedisのSortedSetsのZREVRANGEではスコアが同点の場合、キー(uid)の降順で並べてしまいます。よって例えばuid:102, 103が同点の場合、そのスコアの更新時間に関わらず103が上位になってしまいます。このようなときは本当は更新時間の昇順に並んでほしいところです。

このようなとき、リレーショナルデーターベースを使用すれば取得可能ですが件数が多いときのパフォーマンスに難点があります。またわざわざバッチで並べ替えるようなことも避けたいところです。

esrank

github.com

そこでこちらのアプリをつくってみました。

しくみ

esrankではやはりRedisのSortedSetsを内部で使用しています。ではどのように理想の通りに実現しているかというと各スコアラーのキーを工夫しています。

f:id:muroon:20201201213614p:plain

64bitのキーを使用してますが上位32bitに登録時間(厳密にはMAX値から登録時間を引いたもの)、下位32bitにスコアラーのキーを配置してます。 これをスコア登録時に使用すれば、ZREVRANGEにてリスト取得すると都合よく仕様が満たせます。

パフォーマンス

RankingList(ランキング1〜100位のリストを取得)とGetRanking(個人のランキング・スコアを取得)それぞれのレスポンス時間になります。

response time

- esrank(redis) (ms) DB(mysql) (ms) esrank/DB (%)
RankingList 0.02816603333 0.4238205667 6
GetRanking 0.01488041333 0.1489615633 10

使用法

import(
    goredislib "github.com/go-redis/redis/v8"
    "github.com/muroon/esrank"
)
    
    # using go-redis/v8
    client = goredislib.NewClient(&goredislib.Options{
        Addr:     "localhost:6379",
    })

    # new monthly ranking
    now := time.Now()
    st := time.Date(now.Year(), now.Month(), 1, 0, 0, 0,0, time.UTC)
    rank := NewRanking(
        client, // redis client
        Name("my_monthly_ranking"), // ranking name
        StartTime(st), // start time 
    )

スコアの追加

 userID := 1
    score := 100
    err := rank.AddRankingScore(ctx, userID, score)

ランキング1〜100位のリストを取得

 rankingList, err := rank.RankingList(ctx, 0, 99)

個人のランキング・スコアを取得

 rank, score, err := rank.GetRanking(ctx, userID)

規制

使用するにあたり意識してもらうことが2つあります。

  • StartTime(ランキング計測・表示開始時間)
  • TimeMode

StartTime

上記で示している通りある時間からの経過を元にランキングのキーとしています。 どんなに多くても最大32bitしか取れないため、該当するランキングに適切な開始時間を設定する日通用があります。

TimeMode

ランキング計測開始(StartTime)からの最大経過時間は、TimeModeによって異なります。

TimeModeがTimeModeMilliSecの場合、同じスコアが複数ある場合は1ミリ秒以上のスコア登録時間の差を比較してソートします。 1ミリ秒未満の時差は処理できません。 同様に、TimeModeSecの場合は1秒以上の時差を比較し、TimeModeMicroSecの場合は1マイクロ秒です。

TimeMode 同一スコア精度 最大計測期間
TimeModeSec 秒単位 136 年
TimeModeMilliSec (Default) ミリ秒単位 1.36 年
TimeModeMicroSec マイクロ秒単位 4.97 日
 rank := NewRanking(
        client,
        Name("my_monthly_ranking"),
        StartTime(st),
        SetTimeMode(TimeModeMicroSec), // timemode(microsec)
    )

最後に

上記のようなシンプルな仕様のみであればバッチによる集計を必要とせずにランタイムで対応可能だと思います。機会があったら使用していただけたら幸いです。