beginner~3h

Redis — Core Data Structures & Commands

Before persistence, replication, caching strategy, or Spring Data Redis mean anything, you need fluency with the five core data structures and the single-threaded model that makes every one of them atomic.

Learning objectives

  • Choose the correct Redis data structure (string, list, hash, set, sorted set) for a given access pattern.
  • Build a real-time leaderboard using ZADD, ZRANGE/ZREVRANGE, and ZRANK.
  • Explain why Redis commands are atomic and why one slow command can stall an entire Redis instance.
  • Know when Bitmaps or HyperLogLog are the right tool instead of a naive counter or set.

◆ The problem

A product page needs the current price, stock count, and star rating for an item on every single page load. Hitting Postgres for these three columns is fast in isolation, but at real traffic — thousands of requests per second, most of them re-reading the exact same handful of hot products — that's thousands of redundant round trips to a disk-backed database for data that changed, at most, a few times today.

Redis solves this by keeping data in RAM and serving it back in well under a millisecond, but calling it "just a cache" undersells it — the more precise description is an in-memory data structure server. A plain key-value cache (like Memcached) gives you a key and an opaque blob; Redis gives you a key and a choice of structure — string, list, hash, set, sorted set, and a few more — each with its own commands that operate directly on that structure server-side, without you ever pulling the whole blob into your application, mutating it, and writing it back.

LevelDefinition
BeginnerRedis is a super-fast database that lives in your server's memory instead of on disk, so reads and writes happen in fractions of a millisecond.
TechnicalRedis is an in-memory data structure store, usable as a cache, message broker, or primary database, offering atomic operations on strings, lists, hashes, sets, and sorted sets.
Interview-gradeRedis is a single-threaded, in-memory key-value engine where every command executes atomically because only one command runs at a time (§1.5); durability is optional and configurable (Chapter 02), and its value is less "it's a cache" and more "it exposes rich, atomic data structures over the network with sub-millisecond latency."

The core commands you'll use constantly from day one: SET key value / GET key, MSET/MGET for multiple keys at once, DEL key to remove a key entirely, and INCR/INCRBY/INCRBYFLOAT for atomically bumping a numeric value (atomic meaning two concurrent INCR calls never lose an update to each other — more on exactly why in §1.5). Every value Redis stores for a plain string key is, internally, a string — SET age 20 and later INCR age both work, because Redis parses the stored string as a number on demand; there's no separate integer type for you to think about.

💻 Code example

SET product:101:price 4999 GET product:101:price MSET product:101:stock 42 product:101:rating 4.6 MGET product:101:stock product:101:rating INCR product:101:views -- atomically +1, returns the new value INCRBY product:101:views 10 -- atomically +10 DEL product:101:views

A raw string key is fine for a single value, but most real data has shape — a user has multiple fields, a queue has an order, a tag list has no duplicates. Redis gives you a structure for each of these shapes directly, instead of making you serialize everything into one string and parse it back out on every read.

StructureShapeReal use caseKey commands
HashField → value map, like one flat JSON objectA user or product record — user:42 holding name, email, plan as fieldsHSET, HGET, HGETALL, HDEL, HINCRBY
ListOrdered sequence, push/pop from either endA recent-activity feed or a simple job queueLPUSH, RPUSH, LRANGE, LPOP, RPOP
SetUnordered, unique membersTags on a post, "has this user already voted" checksSADD, SREM, SISMEMBER, SMEMBERS

A hash is the natural fit whenever you'd otherwise reach for a small object: update one field (HSET user:42 plan "pro") without touching or even transferring the others, unlike a plain string key holding a serialized JSON blob where every update means re-serializing and rewriting the whole value. A list keeps insertion order and supports O(1) pushes/pops from either end, which is exactly the shape a queue or a capped "last 20 events" feed needs (LPUSH to add newest first, LTRIM to cap the length). A set gives you O(1) membership checks and automatic de-duplication for free — SADD post:99:likes user:42 twice is a no-op the second time, no application-side "have I seen this before" logic required.

▲ Common mistake

Storing a whole object as a JSON string in a plain Redis string key (SET user:42 '{"name":...}') instead of a hash. It works, but every update requires reading the whole blob, deserializing it in your app, changing one field, re-serializing, and writing the whole thing back — throwing away exactly the field-level atomicity (HSET, HINCRBY) a hash gives you for free, and risking a lost update if two requests do this read-modify-write dance concurrently.

💻 Code example

HSET user:42 name "Asha" email "asha@example.com" plan "free" HGET user:42 plan HGETALL user:42 HINCRBY user:42 login_count 1 LPUSH feed:global "user:42 liked post:99" LTRIM feed:global 0 19 -- keep only the newest 20 entries LRANGE feed:global 0 -1 SADD post:99:likes user:42 user:7 SISMEMBER post:99:likes user:42 -- 1 SMEMBERS post:99:likes

◆ Story

A mobile game needs two things from its leaderboard: the current top 10 players, refreshed live as scores change, and any single player's exact rank so the app can show "you're #4,231" even when that player is nowhere near the top. A regular sorted list recomputed on every score change doesn't scale past a handful of players; a SQL table with ORDER BY score DESC LIMIT 10 works for the top 10 but a WHERE user_id = ? rank lookup means scanning or a window function over the whole table.

A Redis sorted set (ZSET) is built for exactly this: every member has a score, the set is always kept in score order internally, and both "give me the top N" and "give me this member's rank" are fast, dedicated operations — no scan, no separate query shape for each.

ZADD adds or updates a member's score; calling it again on the same member just updates the score (a sorted set, like a plain set, never has duplicate members). ZRANGE/ZREVRANGE return members by position — ascending or descending — and WITHSCORES includes the score alongside each member. ZRANK/ZREVRANK return a specific member's 0-indexed position directly. ZINCRBY atomically bumps a score, which is the natural way to record "+50 points" without a separate read-modify-write.

💻 Code example

ZADD leaderboard 4500 "player:42" ZADD leaderboard 9800 "player:7" ZADD leaderboard 6200 "player:99" -- top 3, highest score first, with scores ZREVRANGE leaderboard 0 2 WITHSCORES -- player:42's exact rank, 0-indexed from the bottom ZRANK leaderboard "player:42" -- player:42's rank from the top instead ZREVRANK leaderboard "player:42" -- award +50 points atomically, no read-modify-write ZINCRBY leaderboard 50 "player:42" -- everyone scoring between 5000 and 8000 ZRANGE leaderboard 5000 8000 BYSCORE WITHSCORES

Two more structures are worth knowing exist, even without going deep on either — both trade a small amount of precision or flexibility for a large memory win on a specific, common problem.

Bitmaps aren't a separate data type — they're bit-level operations (SETBIT, GETBIT, BITCOUNT) on an ordinary string, treating it as a raw sequence of bits. The classic use is a daily-active-user flag: SETBIT active:2026-08-13 42 1 marks user ID 42 as active today, and BITCOUNT active:2026-08-13 gives you the total active-user count for the day — millions of users tracked in a few hundred KB, since each user costs exactly one bit, not a whole key.

HyperLogLog (PFADD, PFCOUNT) solves a different problem: counting approximate unique values ("how many distinct visitors hit this page today") without storing every distinct value. A Redis SET of visitor IDs gives you an exact count but grows linearly with cardinality — millions of unique visitors means millions of stored members. A HyperLogLog structure gives you a count that's off by roughly 0.81% in the standard configuration, in exchange for a fixed memory footprint regardless of how many uniques you've added — a few KB whether you've tracked ten thousand unique visitors or ten million.

▲ Common mistake

Reaching for a SET to count uniques "just to be exact," then discovering months later that the set itself has become one of the largest keys in the whole instance. If the exact identities of the unique items don't matter — only the count does — HyperLogLog is almost always the right tool, and the ~0.81% error is rarely the deciding factor for a metric like "unique visitors today."

💻 Code example

SETBIT active:2026-08-13 42 1 SETBIT active:2026-08-13 7 1 BITCOUNT active:2026-08-13 -- 2 active users today PFADD unique_visitors:2026-08-13 "user:42" "user:7" "user:42" PFCOUNT unique_visitors:2026-08-13 -- approximately 2, not 3 — duplicates aren't double-counted

◆ Under the hood

Redis's command execution is single-threaded: one thread reads a command off the socket, runs it against the in-memory data, writes the reply, and only then moves to the next queued command — regardless of how many clients are connected or how many commands arrive in the same instant. (Newer Redis versions can parallelize the network I/O of reading requests and writing responses across multiple threads, but the actual execution of a command against the data structures is still handled by that one thread, one command at a time.)

This single fact has two direct, practical payoffs:

Every individual command is atomic, for free. INCR counter run by two clients at exactly the same instant never loses an update the way GET then SET in application code would — one INCR fully completes before the other begins, because there's physically only one thread executing commands. This is why §1.1's INCR needs no explicit lock, and it's the foundation MULTI/EXEC (Chapter 05) and Lua scripting (Chapter 05) both build on: a queued batch of commands, or a whole script, runs with the same one-command-at-a-time guarantee extended across the whole batch.

One slow command blocks every other client, with no exception. If a command takes 200ms to compute — scanning a huge list, computing an expensive sorted-set range, or the classic offender, KEYS * on a keyspace with millions of keys — every other client's request queues up behind it for that entire 200ms, even requests that have nothing to do with the slow one. This isn't a tuning problem to work around; it's a direct consequence of the single-threaded model, and it's the reason KEYS * is treated as a production hazard rather than a convenience command — it walks the entire keyspace inline, and on a large instance that can mean the whole server goes unresponsive for everyone for the duration of the scan.

▲ Common mistake

Running KEYS pattern* against a production instance to "just check something quickly." Use SCAN instead — it walks the keyspace in small increments across multiple round trips, so any single call is fast and the rest of the instance stays responsive between them, at the cost of the result no longer being a single atomic snapshot (keys added or removed mid-scan may or may not appear).

Want a visual for this concept?

Generate a diagram tailored to “Redis — Core Data Structures & Commands” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Redis — Persistence: RDB & AOF← Back to all Redis chapters