bit로 점수 계산하여 레디스 랭킹 시스템 만들기
2023-06-04 23:54:12

랭킹을 구현하다 보면 점수가 동점일 경우 기타 다른 정렬에 의해 상위 랭킹을 매겨야 하는 경우가 있다

예로 점수를 획득한 시간순이 상위 랭킹으로 가는 경우 sql이라면

1
order by score desc,  dt desc;

이렇게 되겠지만 흔히 랭킹을 구현하는데 사용하는 Redis Sorted Set에선 2개이상의 정렬을 사용할 수 없다 (내가 알기론…)

그래서 해당 경우에 쓸수 있는 트릭? 1개를 정리 해보고자 한다

예로 레이드에서 랭킹을 구현하는데 랭킹 매기는 순은 아래로 기획이 되었다고 가정을 하고 구현 한다.

  1. 클리어한 단계 - 단계는 최대 32,767까지 있다고 정의
  2. 전투에 참여한 캐릭터 순 (적은 캐릭로 깰수록 상위) - 전투에는 최대 250개의 캐릭터를 쓸수 있다
  3. 클리어한 시간 - unix sec(32bit)로 비교를 한다고 정의

주의점으로 해당 방법은 각 정렬 단위의 범위를 확실하게 알아야 하며 최대 단위로 테스트도 필요하다, 특히 nodejs(ts, js) 64비트 정수 범위가 2^53 - 1바께 안되기 때문에 더 각별히 주의해서 써야 한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
package main

import (
"bytes"
"context"
"encoding/binary"
"fmt"
"math"
"time"

"github.com/go-redis/redis/v8"
)

const (
MAX_MEMBER_COUNT int16 = 250 // 전투에 참여할 수 있는 최대 전투 캐릭터 순
MAX_32BIT_INT int32 = int32(math.MaxInt32) // 32비트 max, 이건 레이드 종료기간으로 대체해도 되겠군..
)

type ScoreInfo struct {
Score int16 // 클리어한 단계 - 2byte
MemberCount int16 // 전투에 참여한 캐릭터 순 - 2byte
ClearDt int32 // 클리어한 시간 - 4byte
}

/*
8바이트 (64bit) 버퍼를 만들어서
점수 2바이트 | 최대 참여 캐릭 수 - 참여한 캐릭터 수 2바이트 | max int32 - 클리어한 시간 4바이트
를 써서 64비트(uint64) 수로 변환을 한다 (-를 하는 이유는 내림차순이기 때문에 유저마다 큰 수를 구해야 하기 때문)
Float64frombits를 쓰는 이유는 redis score 타입이 float64이기 때문에 uint64의 비트 패턴 -> float64값으로 변환해줘야 한다

nodejs의 경우라면

const buf = Buffer.allocUnsafe(8);
buf.writeIntBE(scoreInfo.score, 0, 2);
buf.writeIntBE(MAX_MEMBER_COUNT - scoreInfo.memberCount, 2, 2);
buf.writeIntBE(MAX_32BIT_INT - scoreInfo.clearDt, 4, 4);
return buf.readBigInt64BE(0).toString();
*/
func (s ScoreInfo) SaveScore() float64 {
buf := new(bytes.Buffer)

binary.Write(buf, binary.BigEndian, s.Score)
binary.Write(buf, binary.BigEndian, MAX_MEMBER_COUNT-s.MemberCount)
binary.Write(buf, binary.BigEndian, MAX_32BIT_INT-s.ClearDt)
return math.Float64frombits(binary.BigEndian.Uint64(buf.Bytes()))
}

/*
Float64bits을 사용하여 float64 -> uint64로 변환을 한다, uint64(2^64-1)의 범위까지는 손실 없이 변경이 가능하다
64비트 정수를 이제 다시 2바이트, 2바이트, 4바이트씩 읽으면 다시 원본 점수, 클리어 캐릭터, 시간을 가져올 수 있다

nodejs의 경우라면

const parsedNumber = parseFloat(saveScore);
const bigintNumber = BigInt(parsedNumber);
const buf = Buffer.allocUnsafe(8);
buf.writeBigInt64BE(bigintNumber, 0);
let score = buf.readIntBE(0, 2);
let memberCount = buf.readIntBE(2, 2);
let clearDt = buf.readIntBE(4, 4);
*/
func (s ScoreInfo) ReadScore(redisScore float64) ScoreInfo {
buf := new(bytes.Buffer)
binary.Write(buf, binary.BigEndian, math.Float64bits(redisScore)) //
s.Score = int16(binary.BigEndian.Uint16(buf.Bytes()[0:2]))
s.MemberCount = MAX_MEMBER_COUNT - int16(binary.BigEndian.Uint16(buf.Bytes()[2:4]))
s.ClearDt = MAX_32BIT_INT - int32(binary.BigEndian.Uint32(buf.Bytes()[4:8]))

return s
}

func main() {
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379", // Redis 서버 주소
Password: "", // Redis 인증 비밀번호 (비어있을 경우 없음)
DB: 0, // 사용할 Redis 데이터베이스 번호
})

// Redis 서버에 연결
err := client.Ping(context.Background()).Err()
if err != nil {
panic(err)
}

scoreInfo := ScoreInfo{
Score: 23346,
MemberCount: 230,
ClearDt: int32(time.Now().Unix()),
}

fmt.Println("prevScoreInfo: ", scoreInfo)

saveScore := scoreInfo.SaveScore()

err = client.ZAdd(context.Background(), "RankKey4", &redis.Z{
Score: saveScore,
Member: "a",
}).Err()

scoreInfo2 := ScoreInfo{
Score: 32130,
MemberCount: 134,
ClearDt: int32(time.Date(2023, time.June, 2, 0, 0, 0, 0, time.UTC).Unix()),
}

fmt.Println("prevScoreInfo2: ", scoreInfo2)

saveScore = scoreInfo2.SaveScore()

err = client.ZAdd(context.Background(), "RankKey4", &redis.Z{
Score: saveScore,
Member: "b",
}).Err()

result, err := client.ZRevRangeWithScores(context.Background(), "RankKey4", 0, -1).Result() // redis에서 bigint를 읽을 때는 지수 표기법으로 표기 된다 참고..
if err != nil {
panic(err)
}

for _, z := range result {
updateScoreInfo := scoreInfo.ReadScore(z.Score)
fmt.Println("member:", z.Member, "afterScoreInfo:", updateScoreInfo)
}
}

실행결과는 아래와 같다

1
2
3
4
prevScoreInfo:  {23346 230 1685892870}
prevScoreInfo2: {32130 134 1685664000}
member: b afterScoreInfo: {32130 134 1685664000}
member: a afterScoreInfo: {23346 230 1685892870}

마지막으로 해당 케이스의 경우는 js 예제코드도 적어놨지만 레디스에서 다시 점수를 읽어올 때 지수표기법으로 읽어 오는데

해당 값을 다시 64비트 정수로 받아오면 오버플로우 나서 부동소수점이 날라가기 때문에 ClearDt 값이 원본 값과 틀어진다

js는 범위에 따라 다르겠지만 2개정도? 가 적당하게 처리 가능.. 한듯 😭

Prev
2023-06-04 23:54:12