Java游戏服务器-百万规模实时排行榜实现(桶式)

前面一篇文章介绍了一种基于树形的实时排行榜实现,在较大规模数据下表现良好。但其并不适用于每个场景,尤其在区间较大,但实际数据记录数并不多的情况下,因为它的节点数是与区间相关的,大量的内存耗费在节点上。

针对这种场景,有一种更好的算法,即分桶式。其具体原理引用云风文章的一段话描述:

陌陌争霸中用于排名的分数区间不大,也就是 0 分到 5000 分。而参与排名的人数众多,数以百万计。对百万用户做插入排序,每个插入即使是 O(N) 的也不可接受。可事实是大量玩家的分数相同,都是并列排名的。所以我们只需要做 5000 个桶,每个桶里仅记录这个分数有多少个人就可以了。

当玩家分数变迁,把原来的桶减一,新的桶加一。这个操作就是 O(1) 的。

而排行榜的查询仅需要把当前分数靠前的桶累加,就能获知查询者的名次。对于上百万玩家,看到哪些人和你并列的人的名字是没有意义的。这个查询虽然是 O(n) 复杂度,但 n 只有区区 5000 ,还可以做 cache 以应对查询频率远高于更新频率的情况。

真正需要精确知道人名的是榜单的前 200 个人,而对前 200 个人做插入排序也很快,所以并不会造成性能问题。

其实可以看到,云风描述的应用场景和我描述的是两个方向的,区别就在于是否需要精确计算每个玩家的排名,分桶式在这两种场景下都适用。

算法很简单,按照惯例,直接展示代码,完整代码请参看文末。

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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202

public class LeaderboardPile<Extra extends LeaderboardPileExtra> {

class LeaderboardBucket {
public int lowerKey = 0;
public int upperKey = 0;
public int number = 0;

public ArrayList<Extra> extraList = new ArrayList<Extra>();
}

private int lowerKey = 0;
private int upperKey = 0;
private int bucketNumber = 0;
private ArrayList<LeaderboardBucket> buckets = new ArrayList<LeaderboardBucket>();

public void setup(int lowerKey, int upperKey, int bucketNumber) {
assert (upperKey - lowerKey) / bucketNumber > 0;
assert (upperKey - lowerKey) % bucketNumber == 0;

this.lowerKey = lowerKey;
this.upperKey = upperKey;
this.bucketNumber = bucketNumber;
setupBucket();
}

public void insert(int key, Extra extra) {
insertIntoBucket(key, extra);
}

public void remove(int key, Extra extra) {
removeFromBucket(key, extra);
}

public void change(int oldKey, int newKey, Extra extra) {
remove(oldKey, extra);
insert(newKey, extra);
}

public int getRanking(int key, Extra extra) {
return getRankingOfBucket(key, extra) + 1;
}

public LeaderboardPileData getRankingN(int n) {
LeaderboardPileData data = null;

int ranking = n;
int bucketIndex = 0;
for (LeaderboardBucket bucket : buckets) {
if (ranking > bucket.number) {
ranking -= bucket.number;
bucketIndex += 1;
} else {
break;
}
}

if (bucketIndex < 0 || bucketIndex >= bucketNumber) {
return data;
}

LeaderboardBucket bucket = buckets.get(bucketIndex);
if (bucket == null) {
return data;
}

if (ranking <= 0 || ranking > bucket.number) {
return data;
}

for (Extra extra : bucket.extraList) {
ranking -= 1;
if (ranking <= 0) {
data = new LeaderboardPileData();
data.key = extra.key;
data.ranking = n;
data.extra = extra;
break;
}
}

return data;
}

public ArrayList<LeaderboardPileData> getTopN(int n) {
ArrayList<LeaderboardPileData> dataList = new ArrayList<LeaderboardPileData>();
int count = 0;
for (LeaderboardBucket bucket : buckets) {
for (Extra extra : bucket.extraList) {
LeaderboardPileData data = new LeaderboardPileData();
data.ranking = ++count;
data.key = extra.key;
data.extra = extra;
dataList.add(data);
if (count >= n) {
return dataList;
}
}
}
return dataList;
}

private void setupBucket() {
if (lowerKey > upperKey) {
return;
}

for (int index = 0; index < bucketNumber; ++index) {
LeaderboardBucket bucket = new LeaderboardBucket();
bucket.lowerKey = getBucketLowerKey(index);
bucket.upperKey = getBucketUpperKey(index);
bucket.number = 0;
bucket.extraList.clear();
buckets.add(bucket);
}
}

private void insertIntoBucket(int key, Extra extra) {
final int bucketIndex = getBucketIndex(key);
LeaderboardBucket bucket = buckets.get(bucketIndex);
if (bucket != null) {
extra.key = key;
bucket.extraList.add(extra);
bucket.extraList.sort((Extra left, Extra right) -> left.compareTo(right));
bucket.number += 1;
}
}

private void removeFromBucket(int key, Extra extra) {
final int bucketIndex = getBucketIndex(key);
LeaderboardBucket bucket = buckets.get(bucketIndex);
if (bucket != null) {
extra.key = key;
bucket.extraList.remove(extra);
bucket.extraList.sort((Extra left, Extra right) -> left.compareTo(right));
bucket.number -= 1;
}
}

private int getRankingOfBucket(int key, Extra extra) {
int ranking = 0;

final int bucketIndex = getBucketIndex(key);
for (int index = 0; index < bucketIndex; ++index) {
LeaderboardBucket bucket = buckets.get(index);
if (bucket != null) {
ranking += bucket.number;
}
}

LeaderboardBucket bucket = buckets.get(bucketIndex);
if (bucket != null) {
extra.key = key;
ranking += getBucketRanking(bucket, extra);
}

return ranking;
}

private int getBucketLowerKey(int index) {
final int bucketLowerKey = getBucketUpperKey(index + 1) + 1;
return bucketLowerKey;
}

private int getBucketUpperKey(int index) {
final int stepKey = getStepKey();
final int bucketUpperKey = upperKey - stepKey * index;
return bucketUpperKey;
}

private int getBucketIndex(int key) {
if (key < lowerKey) {
return bucketNumber - 1;
}

if (key > upperKey) {
return 0;
}

final int stepKey = getStepKey();
final int diffKey = upperKey - key;
final int index = diffKey / stepKey;
return index;
}

private int getBucketRanking(LeaderboardBucket bucket, Extra extra) {
int ranking = 0;
for (Extra bucketExtra : bucket.extraList) {
if (extra.compareTo(bucketExtra) > 0) {
ranking += 1;
} else {
break;
}
}
return ranking;
}

public int getStepKey() {
final int stepKey = (upperKey - lowerKey + 1) / bucketNumber;
return stepKey;
}
}

采用这种做法,需要在服务器启动时重新构建排行树,先确定排行区间,然后依次插入每个玩家数据。运行期间,玩家数据等改变时,先删除旧的排行,再插入新的排行。

该算法在面对数据分布不均、同区间玩家过多情况时,性能会大幅退化,可将ArrayList替换为更高效的数据结构,或变通需求。

公共库仓库:JMetazion

服务器示例仓库:JGameDemo