A Sorted Set keeps its members ordered by a numeric score, and Redis maintains that order on every write.
Ranking, top-N, "your position" and score-range queries are all O(log N) lookups rather than a database
ORDER BY over the whole table. That makes Sorted Sets the natural fit for leaderboards, trending lists,
priority queues and anything else that's read in order.
Record and update scores​
AddItemToSortedSet sets a member's score, adding the member if it doesn't exist. IncrementItemInSortedSet
adds to the current score atomically and returns the new total, so concurrent updates from different servers
can't overwrite each other:
const string Board = "leaderboard:global";
using var redis = redisManager.GetClient();
redis.AddItemToSortedSet(Board, "alice", 1200);
redis.AddItemToSortedSet(Board, "bob", 950);
redis.AddItemToSortedSet(Board, "carol", 1100);
redis.AddItemToSortedSet(Board, "dave", 700);
redis.AddItemToSortedSet(Board, "erin", 1300);
double bobScore = redis.IncrementItemInSortedSet(Board, "bob", 200); // 1150
Use the player's ID as the member and keep the profile in its own key, e.g. a Hash or a POCO stored with the typed client. Sorted Set members are unique, so a player is never listed twice.
Top N​
GetRangeWithScoresFromSortedSetDesc returns members from highest to lowest score, with their scores, by
0-based rank range:
IDictionary<string, double> top3 = redis.GetRangeWithScoresFromSortedSetDesc(Board, 0, 2);
// erin: 1300, alice: 1200, bob: 1150
The returned dictionary preserves rank order. Pages are just rank ranges: page n of size 20 is
GetRangeWithScoresFromSortedSetDesc(Board, n * 20, n * 20 + 19). Use GetRangeFromSortedSetDesc when you only
need the members.
A player's rank and score​
long rank = redis.GetItemIndexInSortedSetDesc(Board, "carol"); // 3, i.e. 4th place
double score = redis.GetItemScoreInSortedSet(Board, "carol"); // 1100
Ranks are 0-based. A member that isn't in the set returns a rank of -1 and a score of double.NaN, so
check for both before displaying them.
Players around me​
Most players are nowhere near the top, so show them their neighbours instead. Read their rank, then the range around it:
public record RankedPlayer(long Rank, string Player, double Score);
public static List<RankedPlayer> AroundPlayer(IRedisClient redis, string board, string player,
int neighbours = 2)
{
var rank = redis.GetItemIndexInSortedSetDesc(board, player);
if (rank < 0) return new();
var from = (int)Math.Max(0, rank - neighbours);
var results = redis.GetRangeWithScoresFromSortedSetDesc(board, from, (int)rank + neighbours);
return results.Select((x, i) => new RankedPlayer(from + i + 1, x.Key, x.Value)).ToList();
}
AroundPlayer(redis, Board, "carol", neighbours: 1);
// 3: bob 1150, 4: carol 1100, 5: dave 700
Score ranges and percentiles​
Score queries answer questions like "how many players have at least 1,000 points" and "what share of players does carol beat":
long above1000 = redis.GetSortedSetCount(Board, 1000, double.MaxValue); // 4
long total = redis.GetSortedSetCount(Board); // 5
double beats = 100.0 * (total - rank - 1) / total; // carol: 20%
List<string> gold = redis.GetRangeFromSortedSetByHighestScore(Board, 1200, double.MaxValue);
Daily, weekly and all-time boards​
Keep a board per period and let old ones expire. Record each score in the current day's board as well as the all-time board, in one pipeline:
public static void RecordScore(IRedisClient redis, string player, int points)
{
var today = $"leaderboard:{DateTime.UtcNow:yyyy-MM-dd}";
using var pipeline = redis.CreatePipeline();
pipeline.QueueCommand(r => r.IncrementItemInSortedSet("leaderboard:global", player, points));
pipeline.QueueCommand(r => r.IncrementItemInSortedSet(today, player, points));
pipeline.QueueCommand(r => r.ExpireEntryIn(today, TimeSpan.FromDays(35)));
pipeline.Flush();
}
Build a weekly board by summing the daily ones. StoreUnionFromSortedSets adds the scores of members that
appear in several sets and stores the result as a new Sorted Set:
var days = Enumerable.Range(0, 7)
.Select(i => $"leaderboard:{DateTime.UtcNow.AddDays(-i):yyyy-MM-dd}")
.ToArray();
redis.StoreUnionFromSortedSets("leaderboard:week", days);
redis.ExpireEntryIn("leaderboard:week", TimeSpan.FromMinutes(5));
var weeklyTop10 = redis.GetRangeWithScoresFromSortedSetDesc("leaderboard:week", 0, 9);
The weekly board is a cache: recompute it on a schedule, or when it has expired, rather than on every request.
Pass "AGGREGATE", "MAX" as args to StoreUnionFromSortedSets(intoSetId, setIds, args) to keep each player's
best day instead of their total.
Removing players and trimming​
redis.RemoveItemFromSortedSet(Board, "dave"); // remove one player
redis.RemoveRangeFromSortedSet(Board, 0, -1001); // keep only the top 1,000
redis.RemoveRangeFromSortedSetByScore(Board, 0, 99); // remove everyone under 100 points
RemoveRangeFromSortedSet removes by ascending rank, so 0..-1001 removes everything except the 1,000
highest scores. Trimming bounds memory on boards that only ever display the top entries.
Typed leaderboards​
The typed client stores POCOs as members, serialized as JSON. It's convenient for small boards that should be read with their data in one call:
var typed = redis.As<Player>();
var board = typed.SortedSets["leaderboard:typed"];
typed.AddItemToSortedSet(board, new Player { Id = 1, Name = "alice" }, 1200);
List<Player> top = typed.GetRangeFromSortedSetDesc(board, 0, 9);
Because the whole serialized POCO is the member, changing any of its properties creates a different member. For boards whose profiles change, store IDs as members as shown above.
Async​
Every Sorted Set API has an async equivalent on IRedisClientAsync:
await using var redis = await redisManager.GetClientAsync(token);
double score = await redis.IncrementItemInSortedSetAsync(Board, "bob", 200, token);
var top10 = await redis.GetRangeWithScoresFromSortedSetDescAsync(Board, 0, 9, token);
long rank = await redis.GetItemIndexInSortedSetDescAsync(Board, "bob", token);
Beyond games​
The same operations solve many other ordering problems:
| Use-case | Member | Score |
|---|---|---|
| Trending posts | Post ID | Votes, decayed by age |
| Recently viewed | Product ID | Unix time of the view |
| Delayed jobs | Job payload | Unix time it becomes due, see Queues |
| Sliding-window rate limits | Request ID | Unix time of the request, see Rate Limiting |
| Autocomplete | Search term | Popularity, or 0 with lexical range queries, see Lex Operations |
| Geospatial | Location ID | A geohash, see Geospatial |