Where the Problem Started

Computing ordered data from the database every time is simple, but expensive. For features built around ranking, scores, and range queries, the data structure becomes part of the performance design. This post records how I used Redis ZSET from Spring Boot to handle score-based sorted data.

One way to create a leaderboard is to simply run a query from a JPA repository.

Implementation Path

@Query("SELECT new com.swm.cbz.dto.LeaderboardDTO(u.users.userId, COUNT(u.video.videoId)) " +
            "FROM UserVideo u " +
            "GROUP BY u.users.userId " +
            "ORDER BY COUNT(u.video.videoId) DESC")
    List<LeaderboardDTO> findLeaderboardData();

You can simply create a query like this and use it in the controller and service layers.

Functionally, there is no problem at all, and persistence is strengthened by pulling the data from the RDBMS.

However, as many users send get requests for the leaderboard and the number of users included in the leaderboard grows, performance has plenty of room to degrade. More refined handling logic may also be needed for concurrent write requests to the database.

In this situation, you can use Redis’s zset data structure. ZSET is a sorted set. The Z means Ziplist, one of Redis’s encoding formats for managing memory efficiently. As with a normal Redis Set, all members are unique, and since it is a Sorted Set, they are also sorted. The members are sorted by score, and the score data type is float.

The following is a simple ZSET configuration.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'io.lettuce:lettuce-core:6.1.8.RELEASE'

Add lettuce-core to build.gradle. Lettuce is a Java Redis client and, together with Jedis, one of the common choices. Both support clustering, but Jedis is simpler to use while being limited to synchronous operations.

@Service
public class DatabaseInitializer {

    @Autowired
    private UserVideoRepository userVideoRepository;

    @Autowired
    private UserRepository userRepository;
    @Autowired
    private StringRedisTemplate redisTemplate;

    @EventListener(ApplicationReadyEvent.class)
    public void initializeLeaderboard() {
        List<LeaderboardDTO> leaderboardData = userVideoRepository.findLeaderboardData();
        List<LeaderboardDTO> evaluationLeaderboard = userRepository.findLeaderboardByTotalScore();
        leaderboardData.forEach(entry -> {
            redisTemplate.opsForZSet().add("video:leaderboard", entry.getUserId().toString(), entry.getPoint());
        });
        evaluationLeaderboard.forEach(entry -> {
            redisTemplate.opsForZSet().add("evaluation:leaderboard", entry.getUserId().toString(), entry.getPoint());
        });
    }
}

This loads data from the RDBMS into Redis when the application starts. At this point, all write operations other than leaderboard loading are still managed by the RDBMS, so RDBMS changes are not reflected in Redis immediately. This configuration does not solve synchronization by itself; it simply initializes the Redis-side leaderboard state at application startup.

@Service
public class LeaderboardService {
    @Autowired
    private StringRedisTemplate redisTemplate;

    public void incrementUserScore(String userId) {
        redisTemplate.opsForZSet().incrementScore("video:leaderboard", userId, 1);
    }

    public List<LeaderboardDTO> getLeaderboardData() {
        Set<ZSetOperations.TypedTuple<String>> range = redisTemplate.opsForZSet()
                .reverseRangeWithScores("video:leaderboard", 0, 9); // top 10

        assert range != null;
        return range.stream()
                .map(tuple -> new LeaderboardDTO(
                        Long.parseLong(tuple.getValue()),
                        Math.round(tuple.getScore())
                ))
                .collect(Collectors.toList());
    }

    public List<LeaderboardDTO> getEvaluationLeaderboardData() {
        Set<ZSetOperations.TypedTuple<String>> range = redisTemplate.opsForZSet()
                .reverseRangeWithScores("evaluation:leaderboard", 0, 9); // top 10

        assert range != null;
        return range.stream()
                .map(tuple -> new LeaderboardDTO(
                        Long.parseLong(tuple.getValue()),
                        Math.round(tuple.getScore())
                ))
                .collect(Collectors.toList());
    }
}

After that, implement it in the service layer using StringRedisTemplate. The data is stored in a <string, float> format. In the example above, the top 10 entries are passed as the leaderboard. If you use -1 instead of 9, the entire dataset is returned.

The next problem is keeping Redis ZSET data in sync with the RDBMS. A simple approach is to update Redis through StringRedisTemplate in the same service layer where the database change occurs.

private final StringRedisTemplate redisTemplate;
/*
change in RDBMS logic
*/
redisTemplate.opsForZSet().incrementScore("evaluation:leaderboard", userIdStr, apiResponse.getOverall());

Design Takeaway

ZSET is more than a simple cache primitive. When the score has business meaning, Redis becomes a data structure that changes the query pattern itself. The operational design still needs score updates, concurrency behavior, and a database fallback path to be safe.