redisとGoでランキングを作ってみた

「アクセスの多かったページ」のようなランキングを実装したかったので、redisを使って実装してみました。 redis自体がキャッシュとして使うことはあったのですが、そのほかで使ったことがなく、redisを使えば簡単に実現できるということだったのでredisを使用しました。

今回ランキングのサンプルとして、次の3つのapiを作ってみました。

  • idのスコアを返すapi
  • 全体のランキングを返すapi
  • 週間ランキングを生成するapi

apiはGoで実現して、redis環境についてはdockerを使いました。

ランキングのやり方

ランキングを生成には redisの sorted set というデータ型を用いて実現します。 この sorted set はキー単位で集合を持ち、各メンバにはスコアを持っており、そのメンバがスコア順に並ぶというものです。

次のようなイメージです。

key member score
20170701 1 10
2 15
3 20

アクセスの多いページの日別ランキングを生成するのであれば、キーが日付でメンバーがそのページのid、スコアにアクセス数となればその日のランキングを生成できるということです。

実装にはGoのredisライブラリ go-redis/redis を使用しました。

github.com

idのsocreを返すapi

[GET] /rankings/:id

アクセスされたidの現在のスコアを+1したスコアを返すapiです。

レスポンス例

{
  "id": 1,
  "score": 10
}

Goで日別のアクセス数を+1する部分の実装は次のようになります.

  key := fmt.Sprintf("daily:%s", time.Now().Format("20060102"))
    score, err := redisClient.ZIncrBy(key, 1, id).Result()
    if err != nil {
        return nil, err
    }

    if _, err := redisClient.Expire("key", 7*24*time.Hour).Result(); err != nil {
        return nil, err
    }

+1加算するだけでキーがずっと残ってしまうので、不要にたまらないようにするためにもexpireを設定しています。 (今回の場合は7日)

なので、実際の+1する部分は、redisClient.ZIncrBy(key, 1, id).Result() の部分のみです。 ちなみにZIncrByした時に、memberが存在しないものは初期値が0として扱われるため、初めてのidのものは0に+1されスコアが1のメンバが生成されます。 またここで実行しているのはredisコマンドの ZINCRBY key 1, member に相当しているものです。

全体のランキングを返すapi

[GET] /rankings

その日の日別ランキングをスコア順の高いもの順に返すapiです。

レスポンス例

[
  {
    "id": 2,
    "score": 15
  },
  {
    "id": 1,
    "score": 10
  }
]

日別ランキングをアクセス数の降順で取得する部分の実装は嗣のようになります。

  key := fmt.Sprintf("daily:%s", time.Now().Format("20060102"))

    result, err := redisClient.ZRevRangeWithScores(key, 0, -1).Result()
    if err != nil {
        return nil, err
    }

ZRevRangeWithScoresでスコアを降順にしたメンバをscoreとともに取得できます。 これだけでランキングをアクセス数の降順取得できます。ソートをする必要がなく取得も簡単ですね。

今回はZRevRangeWithScores(key, 0, -1)で指定しているので、そのキーの全てのメンバを取得しています。取得部分を絞る時には, ZRevRangeWithScores(key, 2, 5) にすると 上位の 2,3,4,5 番のメンバを取得できます(0始まり) ちなみにここではredisコマンドの ZRANGE key 0 -1 WITHSCORES に相当しています。

週間ランキング生成のapi

[POST] /rankings

その日の週間ランキングを生成するapiです。

週間ランキングの生成では、日別のランキングを合成することで生成します。

Goでの実装部分は次の通りです。

day := 7
now := time.Now()
dateFormat := "20060102"

distKey := fmt.Sprintf("weekly:%s", now.Format(dateFormat))
weights := make([]float64, 0, day)
targetKeys := make([]string, 0, day)

for i := 0; i < day; i++ {
  t := now.Add(time.Duration(i) * -24 * time.Hour)
  targetKeys = append(targetKeys, fmt.Sprintf("daily:%s", t.Format(dateFormat)))
  weights = append(weights, 1)
}

if _, err := redisClient.ZUnionStore(distKey, redis.ZStore{Weights: weights}, targetKeys...).Result(); err != nil {
  return err
}

if _, err := redisClient.Expire("key", 7*24*time.Hour).Result(); err != nil {
  return err
}

その日から7日分の日別ランキングをマージして週間ランキングを生成しています。 マージ部分には redisClient.ZUnionStore(distKey, redis.ZStore{Weights: weights} を行うとそれぞれのsorted setの和集合を取ることができるので、7日分が1つになった週間ランキングを作ることができます

今回使用した ZUnionStore(distKey, redis.ZStore{Weights: weights}, targetKeys...) はredisコマンドの ZUNIONSTORE distKey 7 targetKey1, targetKey2 ... WEIGHTS 1 1 .... に相当しています。 ZUNIONSTOREは各キーに対して重みをつけることができますが、今回はアクセス数ランキングなので、それぞれの日別のランキングをそのまま使用するために重みを1にしてます。 また月間ランキングもこの週間ランキングと同様にすることで実現することができます。

今回は簡単に実現するためと他と合わせるためという理由でapiとして実現しましたが、特定の時間で動くバッチで実行するのも良いと思います。バッチ処理に分けることで、特定のタイミングでDBに永続化させる処理なども入れることができるので、実用を考えるとバッチ処理は必要になるとおもうのでそちらでやった方が良さそうです。もちろん週間ランキングのユースケースにもよりますが

まとめ

redisの sored set とGoでランキングapi作成しました。redisを使うことで集計とソートを意識することなく、ランキングを簡単に実現することができました。 今回は ZUNIONSTORE で全ての和集合を取るやり方を行いましたが、使用するsorted setを増えることでパフォーマンスがどうなるかなど調べられていない部分があるので、また別の機会に確認してみようと思います。

コード

ランキング部分を断片的に載せましたが、githubに今回作成したコードはあります。docker-composeで動作確認できるようにしてあるので試してみてください。

github.com