Redis-Datenstrukturen jenseits von Key-Value
Redis-Datenstrukturen jenseits von Strings: Sorted Sets für Ranglisten, Streams für Event-Logs, HyperLogLog für Kardinalität, Bitmaps für Flags.

Die meisten Entwickler nutzen Redis nur als Key-Value-Cache: einen String setzen, einen String lesen, eine TTL hinzufügen. Das deckt vielleicht 30 % dessen ab, was Redis kann. Die übrigen Datenstrukturen lösen Probleme, die sich in relationalen Datenbanken nur umständlich abbilden lassen und deren Berechnung zur Laufzeit einer Anfrage unverhältnismäßig teuer wäre.
Sorted Sets, Streams, HyperLogLog und Bitmaps sind gezielt für Muster entwickelt worden, die in Webanwendungen ständig vorkommen: Ranglisten, Activity-Feeds, Zählungen eindeutiger Besucher und Feature Flags. Die richtige Datenstruktur verwandelt ein komplexes Problem in einen einzigen Redis-Befehl.
Sorted Sets für Ranglisten
Sorted Sets speichern Mitglieder zusammen mit einem Score und halten sie automatisch sortiert. Einfüge-, Lösch- und Ranking-Operationen laufen alle in O(log n).
import Redis from 'ioredis';
const redis = new Redis();
// ❌ Leaderboard with SQL — expensive query on every page view
// SELECT user_id, score FROM scores ORDER BY score DESC LIMIT 10;
// Gets slower as the table grows. Needs an index. Still scans rows.// ✅ Sorted set — O(log n) insert, O(log n + m) range query
async function updateScore(userId: string, points: number): Promise<void> {
await redis.zincrby('leaderboard:weekly', points, userId);
}
async function getTopPlayers(count: number): Promise<Array<{ userId: string; score: number }>> {
// ZREVRANGE returns members in descending score order
const results = await redis.zrevrange('leaderboard:weekly', 0, count - 1, 'WITHSCORES');
const players: Array<{ userId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
players.push({
userId: results[i],
score: parseFloat(results[i + 1]),
});
}
return players;
}
async function getPlayerRank(userId: string): Promise<number | null> {
// ZREVRANK returns 0-based rank (0 = highest score)
const rank = await redis.zrevrank('leaderboard:weekly', userId);
return rank !== null ? rank + 1 : null;
}
// Usage:
await updateScore('user:alice', 150);
await updateScore('user:bob', 320);
await updateScore('user:carol', 275);
const top3 = await getTopPlayers(3);
// [{ userId: 'user:bob', score: 320 }, { userId: 'user:carol', score: 275 }, ...]
const aliceRank = await getPlayerRank('user:alice');
// 3Wöchentliche Ranglisten werden zurückgesetzt, indem man den Key löscht oder Key-Namen mit der Wochennummer verwendet: leaderboard:2021-W34. Kein komplexes SQL, keine Sortierung auf Anwendungsebene, keine Cache-Invalidierung.
Streams für Ereignisprotokolle
Redis Streams sind eine Append-only-Log-Struktur mit Consumer Groups – ähnlich wie Kafka, aber direkt in Redis eingebaut. Sie eignen sich für Activity-Feeds, Audit-Logs und Event-Verarbeitung in Echtzeit.
// Producer: append events to a stream
async function logActivity(event: {
userId: string;
action: string;
resource: string;
}): Promise<string> {
const entryId = await redis.xadd(
'activity:stream',
'*', // Auto-generate ID (timestamp-based)
'userId', event.userId,
'action', event.action,
'resource', event.resource,
'timestamp', new Date().toISOString()
);
return entryId;
}
// Consumer: read recent events
async function getRecentActivity(count: number): Promise<Array<Record<string, string>>> {
const entries = await redis.xrevrange('activity:stream', '+', '-', 'COUNT', count);
return entries.map(([id, fields]) => {
const obj: Record<string, string> = { id };
for (let i = 0; i < fields.length; i += 2) {
obj[fields[i]] = fields[i + 1];
}
return obj;
});
}// Consumer group: distribute processing across workers
async function setupConsumerGroup(): Promise<void> {
try {
await redis.xgroup('CREATE', 'activity:stream', 'processors', '0', 'MKSTREAM');
} catch {
// Group already exists — safe to ignore
}
}
async function processEvents(consumerId: string): Promise<void> {
while (true) {
const results = await redis.xreadgroup(
'GROUP', 'processors', consumerId,
'COUNT', 10,
'BLOCK', 5000, // Block for 5s waiting for new events
'STREAMS', 'activity:stream', '>'
);
if (!results) continue;
for (const [, entries] of results) {
for (const [entryId, fields] of entries) {
// Process the event
await handleEvent(entryId, fields);
// Acknowledge processing is complete
await redis.xack('activity:stream', 'processors', entryId);
}
}
}
}Consumer Groups verfolgen, welche Events jeder Consumer bereits verarbeitet hat. Stürzt ein Worker ab, werden nicht bestätigte Events an einen anderen Worker erneut zugestellt. So erhält man At-least-once-Verarbeitung ohne den operativen Overhead von Kafka.
HyperLogLog für eindeutige Zählungen
Um eindeutige Besucher, eindeutige Suchanfragen oder eindeutige IP-Adressen exakt zu zählen, müsste man jeden einzelnen Wert speichern. HyperLogLog schätzt die Kardinalität mit einer Abweichung von ~0,81 % und benötigt dafür unabhängig von der Anzahl nur 12 KB Speicher.
// ❌ Exact unique count — memory grows with unique values
// SET: storing 10M unique visitor IDs = ~400MB
await redis.sadd('visitors:2021-08-25', visitorId);
const count = await redis.scard('visitors:2021-08-25');// ✅ HyperLogLog — 12KB regardless of cardinality
await redis.pfadd('visitors:hll:2021-08-25', visitorId);
const estimated = await redis.pfcount('visitors:hll:2021-08-25');
// 10,000,000 unique visitors ≈ 9,919,000 estimated (0.81% error)
// Memory: 12KB vs ~400MB
// Merge multiple days for weekly unique count
await redis.pfmerge(
'visitors:hll:week-34',
'visitors:hll:2021-08-23',
'visitors:hll:2021-08-24',
'visitors:hll:2021-08-25'
);
const weeklyUniques = await redis.pfcount('visitors:hll:week-34');
// Union of unique visitors across 3 days — still 12KBDer Kompromiss ist offensichtlich: Man erhält eine Schätzung, keine exakte Zahl. Für Analytics-Dashboards, bei denen „etwa 1,2 Millionen eindeutige Besucher" genauso aussagekräftig ist wie „exakt 1.203.847", spart HyperLogLog Speicher in Größenordnungen.
Hashes für die Objektspeicherung
Redis-Hashes speichern Feld-Wert-Paare innerhalb eines einzigen Keys. Nutze sie anstelle der Serialisierung ganzer Objekte in einen String – so lassen sich einzelne Felder lesen und aktualisieren, ohne das gesamte Objekt abzurufen.
// ❌ Storing objects as JSON strings
await redis.set('user:123', JSON.stringify({
name: 'Alice',
email: 'alice@example.com',
loginCount: 42,
lastLogin: '2021-08-25',
}));
// To increment loginCount: read, parse, modify, serialize, write
const user = JSON.parse(await redis.get('user:123') ?? '{}');
user.loginCount += 1;
await redis.set('user:123', JSON.stringify(user));
// Race condition if two requests do this simultaneously// ✅ Hash fields — atomic field-level operations
await redis.hset('user:123', {
name: 'Alice',
email: 'alice@example.com',
loginCount: '42',
lastLogin: '2021-08-25',
});
// Atomic increment — no read-modify-write race condition
await redis.hincrby('user:123', 'loginCount', 1);
// Read only the fields you need
const [name, loginCount] = await redis.hmget('user:123', 'name', 'loginCount');
// Update a single field without touching others
await redis.hset('user:123', 'lastLogin', new Date().toISOString());Hashes sind speichereffizient für kleine Objekte (Redis verwendet für Hashes mit weniger als 128 Feldern eine kompakte Kodierung) und eliminieren Read-Modify-Write-Race-Conditions durch atomare Feldoperationen.
Bitmaps für Feature Flags
Bitmaps verwenden einzelne Bits, um boolesche Zustände darzustellen. Ein einziger Bitmap-Key kann ein boolesches Flag für Millionen von Nutzern in wenigen Megabyte abbilden.
// Track which users have opted into a beta feature
// User IDs map directly to bit offsets
async function enableBetaFeature(userId: number): Promise<void> {
await redis.setbit('feature:dark-mode-beta', userId, 1);
}
async function disableBetaFeature(userId: number): Promise<void> {
await redis.setbit('feature:dark-mode-beta', userId, 0);
}
async function hasBetaFeature(userId: number): Promise<boolean> {
const bit = await redis.getbit('feature:dark-mode-beta', userId);
return bit === 1;
}
// Count how many users have the feature enabled
async function betaUserCount(): Promise<number> {
return redis.bitcount('feature:dark-mode-beta');
}
// Bitwise operations across features
// Users who have BOTH dark-mode AND new-checkout enabled:
await redis.bitop('AND', 'feature:both',
'feature:dark-mode-beta', 'feature:new-checkout-beta');
const bothCount = await redis.bitcount('feature:both');10 Millionen Nutzer = ~1,25 MB pro Feature Flag. Die Prüfung eines Flags ist O(1). Das Zählen aktivierter Nutzer über Millionen hinweg ist eine einzige BITCOUNT-Operation. Bitweise AND/OR-Verknüpfungen über mehrere Flags beantworten die Frage „welche Nutzer haben Feature A und Feature B?" mit einem einzigen Befehl.
Wichtigste Erkenntnisse
- Sorted Sets ersetzen komplexe Ranking-Abfragen — Ranglisten, Top-N-Listen und Bereichsabfragen in O(log n)
- Streams ermöglichen Kafka-ähnliche Event-Verarbeitung mit Consumer Groups, Acknowledgment und erneuter Zustellung
- HyperLogLog schätzt eindeutige Zählungen auf 12 KB, unabhängig von der Kardinalität — ~0,81 % Abweichung
- Hashes speichern Objekte mit atomaren Feldoperationen — keine Read-Modify-Write-Race-Conditions
- Bitmaps verfolgen boolesche Flags für Millionen von Entitäten auf wenigen Megabyte — bitweise Operationen über mehrere Flags hinweg
- Die Datenstruktur zum Zugriffsmuster passend wählen — die richtige Redis-Struktur reduziert die Komplexität der Anwendung


