目的
RedisのSorted Setsを使うと点数を競うゲームなどでランキングを作ることができます。同点を同順位にするのは簡単に実装できるのですが、同点の人を先着順に点数をつけるとなると、一筋縄ではいきません。
ここでは
id : 人にそれぞれつくID
count : ゲームの点数
ustm : ゲームで点数を取った時のUnixTimestampのミリ秒
ranking : ランキングを保存するRedisのSortedSets
games : ユーザーの参加情報を保存するRedisのHash
とします。言語はRubyで書いてみます。
何度でも参加可能で参加したデータはRedisのHashに保存します。fieldはidでvalueは以下のようにします。参加するたびに配列の後に保存していきます。
{
count: [100, 200],
utsm: [1639001950000, 1639001951000],
}
SortedSets
RedisのSortedSetsはキーに対してscoreとmemberを登録します。memberは一意な値でユーザーIDみたいなものを使います。scoreは順位を決定する値で整数値の範囲で-9007199254740992 から 9007199254740992まで登録できます。
scoreを単純な点数で使えば9000兆点、マイナスの範囲まで使えば1京点までいけます。
SortedSetで自分よりも多く点数を取っている人の数は以下で数えられます。
redis.zcount('ranking', "(#{target_count}", "+inf")
この値に+1すれば自分の順位(同点数、同順位)が取得できます。
SortedSetsではscoreが同じなら同じ順位になります。同じ点数なら先着順というランキングの場合単純には「ゲームの点数+出した時間」をscoreに利用すれば実現できます。
今のUnixTimestampを取得すると「1639001950」となりました。
9007199254740992
1639001950
740992
するとゲームの点数として利用できるのは740992で5桁の99999まで管理できます。今自分がやろうとしてるゲームの最高点は「2000000」だったのでこの作戦はうまくいきません。
結局scoreは全て点数として利用できる方が良いと判断しました。UnixTimestampはsocreでは無くmemberにつけました。
「member = id + '_' + UnixTimestamp」
同じscoreの人を集めるには以下のようにします。
redis.zrangebyscore('ranking', target_count, target_count)
結果
101_1639001950000
102_1639001951000
103_1639001952000
これを分解して時間を回収して、同じ点数の中での順位を数えます。念のためUnixTimestampはミリ秒にしてかつそれでも同じ場合は無理やりIDの順にすることにしました。
コード
require 'json'
require 'time'
require 'redis'
# 自分の点数の最大値とその点数を出したもっとも早い時間を計算する
def calc_max_count_and_utsm(hash)
max_count = hash["count"].max
return [nil, nil] if max_count.nil?
index = hash["count"].index(max_count)
[max_count, hash["utsm"][index]]
end
# ハッシュに保存されているIDのデータを取得する
def get_hash(redis, id)
value = redis.hget('games', id)
value.nil? ? {"count" => [], "utsm" => []} || JSON.parse(value)
end
# あるIDの点数をRedisに追加する。
def add(redis, id, count)
utsm = Time.now.strftime('%s%L').to_i
hash = get_hash(redis, id)
max_count, max_utsm = calc_max_count_and_utsm(hash)
hash["count"] << count
hash["utsm"] << utsm
redis.pipelined do |redis|
if max_count.nil? || count > max_count
# 最高点数の更新
redis.zdel('ranking', "#{id}_#{max_utsm}") unless max_utsm.nil?
redis.zadd('ranking', count, "#{id}_#{utsm}")
end
# データを更新
redis.hset('games', id, hash.to_json)
end
end
# ランキングを取得する
def get(redis, id)
# IDのデータを取得
hash = get_hash(redis, id)
max_count, max_utsm = calc_max_count_and_utsm(hash)
return 0 if max_count.nil?
pre_count_account_count, accounts = redis.pipelined do |redis|
# 自分よりも点数が多い人の数を取得
redis.zadd('ranking', "(#{max_count}", "+inf")
# 自分と同じ点数の人たちを取得
redis.zrangebyscore("ranking", max_count, max_count)
end
# 同じ点数の人の中で早い人、同じ時間でidが小さい人の数を調べる
pre_utsm_account_count = accounts.filter do |member|
other_id, utsm = member.split(/_/)
utsm = utsm.to_i
ustm < max_utsm || utsm == max_utsm && other_id < id
end.size
pre_count_account_count + 1 + pre_utsm_account_count
end
redis = Redis.new
add(redis, "100", 1639001950)
add(redis, "101", 1639001951)
puts get(redis, "100")
puts get(redis, "101")