Saltar al contenido principal

Ranked in One Hop: Real-Time Leaderboards on Native Sorted Sets

· 13 min de lectura
Clustron Team
Ingeniería de Sistemas Distribuidos

A leaderboard looks trivial until it has to be live. Players earn points constantly, and at any instant you need to answer three questions fast: what are the top ten, what's this player's rank, and who is just above and below them. On a single process with a few hundred players you can keep a list, sort it, and move on. The moment the board is big, busy, and spread across more than one app instance, the naive shapes fall apart in two different ways.

Keep the board in each instance's memory and you have the same problem every stateful-in-process design has: four instances, four different ideas of who's in first. A score earned on instance A is invisible to instance B until something reconciles them, and nothing does. Push the board into a SQL table instead and the correctness problem goes away, but now every "top ten" is an ORDER BY score DESC LIMIT 10 over a table that's being written to on every single point scored, and every "what's my rank" is a COUNT(*) WHERE score > mine. Those queries re-sort or re-scan on each call. Under real traffic that is the most expensive thing in your system, and you end up bolting a cache in front of it that is immediately stale.

The data structure that fixes this is a sorted set: a collection of unique members, each with a score, kept permanently in score order so that rank is an index, not a computation. Redis made it famous. Zaris ships it as a first-class native .NET type — ISortedSetClient — with the ordering maintained on the server as scores change, so a point scored is one atomic increment and the top ten is one range read. This post builds a working leaderboard on it, verified against the shipping API.

Why a sorted set, and why it has to be server-side​

The whole value of a sorted set is that it keeps itself ordered. You never sort it; you ask it for a slice of an order it already maintains. Adding a member, bumping a score, reading the top N, and finding one member's rank are all cheap because the structure does the bookkeeping on write, not on read. That inverts the SQL cost model: the work happens once, when the point is scored, instead of every time someone looks at the board.

Two properties have to hold for this to be correct across a fleet of app instances:

  • A score change is atomic and lands in one place. "Earn 50 points" has to be a single server-side step that adds 50 to whatever the member's score is now — not a read-the-score, add-50, write-it-back dance that two instances can interleave and corrupt. In a Zaris cluster a key has exactly one active partition owner, and every operation on that key is applied on that owner in order. The leaderboard is one key, so every score change for it is serialized on one node. That is what makes the ranking global: instances aren't each holding a copy they have to reconcile, they're each sending a delta to the one node that owns the board.
  • Rank is read, not recomputed. Because the owner holds the members in score order, "rank of this player" and "top ten" are index operations over a structure that's already sorted. No instance re-sorts anything, and there is no window where two readers disagree about the order.

Zaris's IncrementAsync gives you the atomic score change, and RangeByRankAsync / RankAsync give you the reads, all against the one owner of the board's key.

The connection string and wiring​

Nothing about a leaderboard is special at the connection layer. Point the app at a store by name, the same as anything else:

// appsettings.json
{
"ConnectionStrings": {
"Leaderboard": "zariss://zaris-a.example.com:7861,zaris-b.example.com:7861/?store=leaderboard&token=env:ZARIS_TOKEN"
}
}

zariss:// is the TLS scheme (the extra s, same convention as https); list more than one host and the client fails over between them. Register the client from configuration and you get an IZarisClientProvider to resolve a store from:

using Clustron.Zaris.Client.DependencyInjection;

builder.Services.AddClustronZarisFromConnectionStrings(builder.Configuration);

The leaderboard​

The sorted-set surface hangs off IZaris as .SortedSets. A member is a ScoredMember — just a name and a double score — and every operation returns a typed KvResult. Here is a complete leaderboard service: submit points, read the top N, find a player's rank, and pull the neighbours just above and below them.

using Clustron.Zaris.Abstractions;
using Clustron.Zaris.Client;

public sealed class Leaderboard
{
private readonly IZarisClientProvider _provider;
private const string Store = "Leaderboard";

public Leaderboard(IZarisClientProvider provider) => _provider = provider;

private async Task<ISortedSetClient> BoardAsync()
=> ((IZaris)await _provider.GetAsync(Store)).SortedSets;

// A player earns points. One atomic server-side step: add delta to their
// current score (creating them at 0 first), return the resulting score.
public async Task<double> AwardAsync(string board, string player, double points, CancellationToken ct = default)
{
var ss = await BoardAsync();
var result = await ss.IncrementAsync(board, player, points, ct);
if (!result.IsSuccess)
throw new InvalidOperationException($"award failed: {result.Status}");
return result.Value; // the member's new total
}

// The top N, highest score first. reverse:true walks the order descending;
// the slice comes back already ranked, no client-side sort.
public async Task<IReadOnlyList<(string Player, double Score, long Rank)>> TopAsync(
string board, int count, CancellationToken ct = default)
{
var ss = await BoardAsync();
var range = await ss.RangeByRankAsync(board, 0, count - 1, reverse: true, ct);
if (!range.IsSuccess)
throw new InvalidOperationException($"top failed: {range.Status}");

var rows = new List<(string, double, long)>(range.Value.Count);
for (var i = 0; i < range.Value.Count; i++)
rows.Add((range.Value[i].Member, range.Value[i].Score, i + 1)); // 1-based rank
return rows;
}

// One player's standing. RankAsync is 0-based, so add 1 to show it to a human.
public async Task<(double Score, long Rank)?> StandingAsync(
string board, string player, CancellationToken ct = default)
{
var ss = await BoardAsync();
var score = await ss.ScoreAsync(board, player, ct);
if (!score.IsSuccess || score.Value is null)
return null; // player not on the board

var rank = await ss.RankAsync(board, player, reverse: true, ct);
return (score.Value.Value, rank.Value!.Value + 1);
}
}

A few things are worth pausing on:

  • IncrementAsync is the whole write path. It adds the delta to the member's current score on the owner and returns the new total, creating the member at 0 first if they're new. There is no read-modify-write on the client, so two instances awarding points to the same player at the same moment both land, in order, and neither is lost.
  • RangeByRankAsync(board, 0, count - 1, reverse: true) is the top N. The members come back already in descending-score order because that's how the owner stores them. reverse: true walks that order from the top; 0 to count - 1 is an inclusive index range (and negative indices count from the end, so 0, -1 is the entire board). You never sort on the client.
  • RankAsync is an index, not a scan. It returns the member's 0-based position in the order — 0 is first place with reverse: true — without counting rows. Ties between equal scores break lexicographically by member, so the order is always well defined.

Showing a player their own slice of the board — themselves plus a few rivals on each side — is just a rank lookup feeding a range read:

public async Task<IReadOnlyList<(string Player, double Score, long Rank)>> NeighboursAsync(
string board, string player, int radius, CancellationToken ct = default)
{
var ss = await BoardAsync();
var rank = await ss.RankAsync(board, player, reverse: true, ct);
if (!rank.IsSuccess || rank.Value is null)
return Array.Empty<(string, double, long)>();

long center = rank.Value.Value; // 0-based, descending
long start = Math.Max(0, center - radius);
long stop = center + radius;

var range = await ss.RangeByRankAsync(board, start, stop, reverse: true, ct);
var rows = new List<(string, double, long)>(range.Value.Count);
for (var i = 0; i < range.Value.Count; i++)
rows.Add((range.Value[i].Member, range.Value[i].Score, start + i + 1));
return rows;
}

Two reads, no sort, no scan: find where the player sits, then ask for the window around that position.

What actually happens on a score​

App instance AApp instance BApp instance Cincrement scoreatomic · in orderPartition ownerone sorted set · kept in order1 margaret 50102 ada 40963 grace 3120top Nrank ofa playerread a slice

Every instance sends a score increment for one player to the single node that owns the board's key. That node applies the increments one after another — there is no interleaving to lose a point to — and keeps the members ordered by score as it goes. When any instance asks for the top three or for one player's rank, it gets back a slice of an order the owner already maintains. The ranking lives in exactly one place, so all three instances agree on who's winning, and nobody ever re-sorts the board to answer a question.

Trade-offs, and when to reach for something else​

One board is one key, so it's one partition. The property that makes ranking correct — the whole sorted set lives on a single owner — also means a wildly popular single board concentrates its write traffic on one node. For most leaderboards that is a non-issue; a board taking thousands of score updates a second is still one node doing cheap ordered inserts. If a board genuinely outgrows a partition, the lever is to split it: separate keys per region, per cohort, or per time window (a daily and a weekly board are just different keys), each owned independently, merged only when you actually need a combined view.

Time-windowed boards need their old windows cleaned up. "Top scorers today" means a new key each day, and yesterday's key should go away. There's no magic sweeper — you either stamp window keys with the date and delete the stale ones on a schedule, or put a TTL on the key when you first write it so the window evaporates on its own. Pick one deliberately; an unbounded set of daily-board keys is a slow memory leak.

Equal scores tie-break lexicographically, not by time. Two players on the same score are ordered by member name, not by who got there first. If "first to the score ranks higher" matters for your game, fold the timestamp into the score itself (a common trick: score in the integer part, an inverted time fraction below it) so the single double encodes both. The sorted set will order it correctly; it just can't guess your tie-break rule for you.

When to use this versus the RESP front-end. If you're migrating an app that already drives a leaderboard with ZADD / ZINCRBY / ZREVRANGE, Zaris speaks the Redis protocol and those commands work unchanged. The native ISortedSetClient shown here is the first-class .NET surface: it hands you ScoredMember values and typed KvResults instead of stringly-typed replies you parse by hand, and it routes each call straight to the board's owner. Reach for RESP when you're porting; reach for the native API when you're writing new .NET code.

Wrap-up​

A real-time leaderboard is a sort that never stops, and the trick is to stop paying for the sort on every read. Keep the board in process and your instances disagree; rebuild it with ORDER BY on every write and the database is the bottleneck. A sorted set moves the work to write time and keeps itself ordered, so a point scored is one atomic IncrementAsync and the top ten is one RangeByRankAsync — no re-sort, no fan-out, and one place that owns the truth of who's winning. Zaris ships that structure as a native, typed .NET API, correct across every instance because the board lives on a single partition owner that applies every change in order.

The sorted set is one of five native collection types Zaris ships alongside the key-value core; a leaderboard is simply its most vivid use. If you want to see it run end to end — score submission, top N, rank, neighbours, and a live feed of changes — the SortedSets sample and the leaderboard-presence solution in the Zaris samples repository are this post in executable form.