User activity ranking system
In TechPi, a user activity leaderboard is provided. While a blog community would usually rank authors, we chose to highlight user activity to encourage greater participation. We provide daily and monthly leaderboard variants.
User activity score calculation rules:
- Visiting a new page: +1 point
- Liking or bookmarking an article: +2 points
Canceling a like/bookmark: −2 points
- Commenting on an article: +3 points
Delete comment on an articel: -3 points
- Publishing an approved article: +10 points
Design
The leaderboard business logic is relatively straightforward, making data structure design simple as well.
Data model for a leaderboard entry:
long userId; // user identifier
long rank; // user's rank in the leaderboard
long score; // user's accumulated activity score
Initial data structure consideration:
A LinkedList
was considered since rankings are continuous and changes in position don’t require costly array copying. However, it has several downsides:
Problems with LinkedList
- Retrieving a user’s rank is inefficient (O(n))—random access is slow.
- Concurrency issues arise when multiple users update scores simultaneously.
Rather than building a custom structure from scratch, Redis provides an elegant and efficient solution using its ZSet (sorted set).
Redis-Based Approach
Redis’s ZSet is perfect for this use case:
- Ensures uniqueness of elements (users)
- Each element (user) has a score (activity)
- Maintains elements sorted by score
By using ZSet, we store user scores directly, and Redis handles the ranking automatically.
Leaderboard Implementation
1. Updating User Activity Scores
Business logic steps:
- Compute the score change based on the activity type.
- For score increases:
- Check for idempotency to avoid double-counting..
- Store the user’s activity history directly in a Redis hash structure, with one record per day.
- Key:
activity_rank_{user_id}_{yyyyMMdd}
- Field: unique key representing the type of activity (e.g., “article_123_praise”)
- Value: the score added for that activity
- If already added, return early.
- Otherwise, proceed and record the operation.
- Check for idempotency to avoid double-counting..
- For score decreases:
- Only deduct if a prior addition exists.
- Prevent negative scores.
Here’s the core logic for updating activity scores:
public void modifyActivityScore(Long userId, ActivityScoreEntity activityScore) {
if (userId == null) return;
// Determine activity type and score
String field;
int score = 0;
// visiting new page
if (activityScore.getPath() != null) {
field = "path_" + activityScore.getPath();
score = 1;
} else if (activityScore.getArticleId() != null) {
field = activityScore.getArticleId() + "_";
// (un)like an article
if (activityScore.getPraise() != null) {
field += "praise";
score = BooleanUtils.isTrue(activityScore.getPraise()) ? 2 : -2;
// (un)bookmark an article
} else if (activityScore.getCollect() != null) {
field += "collect";
score = BooleanUtils.isTrue(activityScore.getCollect()) ? 2 : -2;
// (un)Commenting on an article
} else if (activityScore.getRate() != null) {
field += "rate";
score = BooleanUtils.isTrue(activityScore.getRate()) ? 3 : -3;
// publish an article
} else if (BooleanUtils.isTrue(activityScore.getPublishArticle())) {
field += "publish";
score += 10;
}
// (un)follow a user
} else if (activityScore.getFollowedUserId() != null) {
field = activityScore.getFollowedUserId() + "_follow";
score = BooleanUtils.isTrue(activityScore.getFollow()) ? 2 : -2;
} else {
return;
}
final String todayRankKey = todayRankKey();
final String monthRankKey = monthRankKey();
final String userActionKey = ACTIVITY_SCORE_KEY + userId + DateUtil.format(DateTimeFormatter.ofPattern("yyyyMMdd"), System.currentTimeMillis());
Integer existingScore = RedisClient.hGet(userActionKey, field, Integer.class);
if (existingScore == null) {
// No prior score -> add new entry
if (score > 0) {
RedisClient.hSet(userActionKey, field, score);
RedisClient.expire(userActionKey, 31 * DateUtil.ONE_DAY_SECONDS);
RedisClient.zIncrBy(todayRankKey, String.valueOf(userId), score);
RedisClient.zIncrBy(monthRankKey, String.valueOf(userId), score);
}
} else if (existingScore > 0 && score < 0) {
// Prior score exists -> allow deduction
if (RedisClient.hDel(userActionKey, field)) {
RedisClient.zIncrBy(todayRankKey, String.valueOf(userId), score);
RedisClient.zIncrBy(monthRankKey, String.valueOf(userId), score);
}
}
}
2. Querying the Leaderboard
Now that we’re tracking scores in Redis, querying the leaderboard is simple.
- Use
zRevRangeWithScores
to get the top N users. - Fetch user details from DB/cache.
- Format and return leaderboard entries with rank and score.
@Override
public List<RankItemDTO> queryRankList(ActivityRankTimeEnum time, int size) {
// 1. Determine Redis key based on the requested time period (daily or monthly)
String rankKey = time == ActivityRankTimeEnum.DAY ? getTodayRankKey() : getMonthRankKey();
// 2. Get top N active users and their scores from Redis
List<ImmutablePair<String, Double>> rankList = RedisClient.zTopNScore(rankKey, size);
if (CollectionUtils.isEmpty(rankList)) {
return Collections.emptyList();
}
// 3. Map userId (String) to score
Map<Long, Integer> userScoreMap = rankList.stream()
.collect(Collectors.toMap(
pair -> Long.valueOf(pair.getLeft()),
pair -> pair.getRight().intValue()
));
// 4. Batch query user basic information
List<SimpleUserInfoDTO> users = userService.batchQuerySimpleUserInfo(userScoreMap.keySet());
// 5. Build final ranked list (Redis already sorted the results)
List<RankItemDTO> rank = new ArrayList<>();
for (SimpleUserInfoDTO user : users) {
Integer score = userScoreMap.getOrDefault(user.getUserId(), 0);
rank.add(new RankItemDTO()
.setUser(user)
.setScore(score));
}
// 6. Assign rank numbers (1-based)
IntStream.range(0, rank.size())
.forEach(i -> rank.get(i).setRank(i + 1));
return rank;
}
public static List<ImmutablePair<String, Double>> zTopNScore(String key, int n) {
return template.execute((RedisCallback<List<ImmutablePair<String, Double>>>) connection -> {
// Use zRevRangeWithScores to get top N elements in descending score order
Set<RedisZSetCommands.Tuple> set = connection.zRevRangeWithScores(keyBytes(key), 0, n - 1);
if (set == null) {
return Collections.emptyList();
}
// Convert Redis Tuple into (userId, score) pairs
return set.stream()
.map(tuple -> ImmutablePair.of(
toObj(tuple.getValue(), String.class),
tuple.getScore()))
.collect(Collectors.toList());
});
}