Redis: In-Memory Data Store for Caching, Sessions, and Fast Access
A practical guide to Redis — the in-memory data store used for caching, session storage, rate limiting, pub/sub messaging, and other latency-sensitive workloads, covering data structures, persistence, expiration, common patterns, high availability, and .NET integration.
Table of Contents
- Introduction
- Why Redis Is Fast
- Data Structures
- Expiration and Eviction
- Persistence
- Common Patterns
- Pub/Sub and Streams
- Distributed Locking
- High Availability and Scaling
- .NET Integration
- Caching Pitfalls
- Redis vs. Alternatives
- Quick Reference Table
- Conclusion
Introduction
Redis (REmote DIctionary Server) is an in-memory data store that functions as a cache, message broker, and lightweight database — all built around simple, well-understood data structures (strings, hashes, lists, sets, sorted sets, and more) that map cleanly onto common application needs. Its defining characteristic is speed: because data lives in memory rather than on disk, most operations complete in well under a millisecond, which is precisely the property that makes it the default choice for caching, session storage, and any workload where database round-trip latency is a bottleneck.
var db = redis.GetDatabase();
await db.StringSetAsync("product:42", productJson, TimeSpan.FromMinutes(10));
var cached = await db.StringGetAsync("product:42");
Enter fullscreen mode Exit fullscreen mode
That round trip — set a value with an expiration, read it back — is the single most common Redis usage pattern in production applications, and it’s deliberately simple by design.
1. Why Redis Is Fast
In-memory by default
Unlike a relational or document database, where data primarily lives on disk and is cached in memory opportunistically, Redis inverts that relationship: data lives in memory as the primary copy, with disk persistence as an optional, secondary durability mechanism (Section 4). Reading and writing RAM is orders of magnitude faster than disk I/O, even against an SSD — this is the single biggest reason Redis operations are so much faster than a typical database round trip.
Single-threaded command execution
Redis’s core command processing is (for the traditional, still-dominant configuration) single-threaded — every command executes atomically, one at a time, with no locking overhead between concurrent clients for a given command. This sounds like it should limit throughput, but in practice, avoiding lock contention and context-switching overhead makes single-threaded execution genuinely very fast for the kind of small, quick operations Redis is built for; multi-threading was added for I/O handling in later versions (6.0+) to help with network throughput at high connection counts, but command execution itself remains effectively single-threaded per data shard.
Simple, purpose-built data structures
Redis doesn’t try to be a general-purpose query engine — its data structures (Section 2) are chosen specifically because their common operations (append to a list, increment a counter, add to a set) can be implemented with predictable, typically O(1) or O(log N) time complexity, rather than the more open-ended, sometimes unpredictable query planning a relational database’s SQL engine has to do.
2. Data Structures
Redis’s value isn’t just “a fast key-value store” — it’s a fast store for several genuinely useful data structures, each suited to different application problems.
Strings
SET user:42:name "Ada Lovelace"
GET user:42:name
INCR page:views # atomic increment — useful for counters
EXPIRE user:42:name 3600 # set/refresh a TTL in seconds
Enter fullscreen mode Exit fullscreen mode
The simplest structure — a key mapped to a single string (which can also hold serialized JSON, a number, or binary data). INCR/INCRBY provide atomic counters without a separate read-modify-write round trip.
Hashes
HSET user:42 name "Ada Lovelace" email "[email protected]" loginCount 5
HGET user:42 email
HINCRBY user:42 loginCount 1
HGETALL user:42
Enter fullscreen mode Exit fullscreen mode
A hash maps field names to values within a single key — a natural fit for representing an object (like a user profile) without serializing the whole thing to a JSON string, letting you read or update individual fields directly rather than the entire blob.
Lists
LPUSH recent-orders:user42 "order-1001"
LPUSH recent-orders:user42 "order-1002"
LRANGE recent-orders:user42 0 9 # get the 10 most recent
LTRIM recent-orders:user42 0 99 # keep only the most recent 100
Enter fullscreen mode Exit fullscreen mode
An ordered collection, efficient for push/pop at either end — commonly used for activity feeds, recent-items lists, or as a simple queue (LPUSH/RPOP or RPUSH/LPOP).
Sets
SADD product:42:viewed-by "user1" "user2" "user3"
SISMEMBER product:42:viewed-by "user1"
SINTER product:42:viewed-by product:17:viewed-by # users who viewed both products
SCARD product:42:viewed-by # count of members
Enter fullscreen mode Exit fullscreen mode
An unordered collection of unique values, with efficient membership checks and set operations (union, intersection, difference) — useful for tagging, deduplication, and “users who did both X and Y” style queries.
Sorted sets
ZADD leaderboard 1500 "player1" 2200 "player2" 1800 "player3"
ZREVRANGE leaderboard 0 9 WITHSCORES # top 10 by score, descending
ZRANK leaderboard "player1" # player1's rank
ZINCRBY leaderboard 50 "player1" # increment a score atomically
Enter fullscreen mode Exit fullscreen mode
Every member has an associated numeric score, and Redis keeps the set ordered by score automatically — the canonical structure for leaderboards, priority queues, and any “top N by some ranking value” problem, with efficient range queries by rank or by score.
Streams
XADD orders:stream * orderId 1001 status "created"
XREAD COUNT 10 STREAMS orders:stream 0
Enter fullscreen mode Exit fullscreen mode
An append-only log structure (conceptually similar to a simplified Kafka topic) supporting consumer groups for distributed processing — covered further in Section 6.
HyperLogLog and Bitmaps (specialized structures)
PFADD unique-visitors:2026-07-11 "user1" "user2" "user1"
PFCOUNT unique-visitors:2026-07-11 # approximate distinct count, ~0.81% error, tiny memory footprint
SETBIT user:42:daily-active 200 1 # mark day 200 as active
BITCOUNT user:42:daily-active # count active days
Enter fullscreen mode Exit fullscreen mode
HyperLogLog provides an approximate but extremely memory-efficient distinct-count (perfect for “unique visitors today” at massive scale where exact precision isn’t worth the memory cost); bitmaps pack boolean flags into individual bits, useful for compact activity/attendance tracking across large populations.
3. Expiration and Eviction
Time-to-live (TTL)
SET session:abc123 "user-data" EX 1800 # expires in 30 minutes
TTL session:abc123 # seconds remaining, or -1 if no expiry, -2 if key doesn't exist
PERSIST session:abc123 # remove the expiration, make it permanent
Enter fullscreen mode Exit fullscreen mode
TTLs are Redis’s built-in mechanism for automatic cleanup — essential for caches (data should expire and be refreshed) and session stores (a session should end automatically after inactivity) without application code needing to run a separate cleanup job.
Eviction policies when memory fills up
Because Redis is fundamentally memory-bound, a policy for what happens when the configured memory limit is reached matters:
maxmemory 2gb
maxmemory-policy allkeys-lru
Enter fullscreen mode Exit fullscreen mode
Policy Behaviornoeviction
Reject writes once memory is full — safest for non-cache use cases where data loss is unacceptable
allkeys-lru
Evict the least-recently-used key across all keys — most common choice for pure caching workloads
volatile-lru
Evict least-recently-used, but only among keys that have a TTL set
allkeys-random
Evict a random key
volatile-ttl
Evict the key with the nearest expiration time first
For a workload using Redis purely as a cache, allkeys-lru (or the more recent, generally preferred allkeys-lfu, which tracks access frequency rather than just recency) is the typical choice — it lets Redis behave like a proper cache, automatically discarding the least valuable data under memory pressure rather than rejecting new writes or crashing.
4. Persistence
Redis is an in-memory store, but it’s not necessarily volatile — it offers two mechanisms (usable independently or together) for persisting data to disk, in case the process restarts or crashes.
RDB (Redis Database) snapshots
save 900 1 # snapshot if at least 1 key changed in 900 seconds
save 300 10 # snapshot if at least 10 keys changed in 300 seconds
Enter fullscreen mode Exit fullscreen mode
RDB takes periodic point-in-time snapshots of the entire dataset to disk — compact, fast to restart from, but any writes since the last snapshot are lost if the process crashes before the next one completes.
AOF (Append-Only File)
appendonly yes
appendfsync everysec # fsync to disk roughly once per second
Enter fullscreen mode Exit fullscreen mode
AOF logs every write operation to an append-only file, replayed on restart to reconstruct the dataset — offers much better durability (at most ~1 second of potential data loss with everysec, or none at all with the more conservative but slower always setting) at the cost of a larger file and slightly slower write throughput than RDB alone.
Choosing a persistence strategy
- Pure cache, fully rebuildable from a source of truth (a database) — persistence may not matter at all; losing the cache on restart just means a temporary wave of cache misses, not data loss.
- Session store or anything where losing recent writes matters — AOF (or RDB + AOF together, which is a common, robust combination) is worth the overhead.
- Redis as a primary data store for genuinely important data (less common, but done for certain workloads) — both RDB and AOF, tuned conservatively, plus replication (Section 8), become essential rather than optional.
5. Common Patterns
Cache-aside (lazy loading)
public async Task<Product?> GetProductAsync(int id)
{
var cacheKey = $"product:{id}";
var cached = await _db.StringGetAsync(cacheKey);
if (cached.HasValue)
return JsonSerializer.Deserialize<Product>(cached!);
var product = await _repository.GetByIdAsync(id); // cache miss — fetch from the source of truth
if (product is not null)
await _db.StringSetAsync(cacheKey, JsonSerializer.Serialize(product), TimeSpan.FromMinutes(10));
return product;
}
Enter fullscreen mode Exit fullscreen mode
This is the most common caching pattern by far: check the cache first, fall back to the real data source on a miss, and populate the cache for next time. Simple, and it self-heals — if Redis is cleared or a key expires, the next request just re-fetches from the source.
Write-through
public async Task UpdateProductAsync(Product product)
{
await _repository.UpdateAsync(product); // write to the source of truth
var cacheKey = $"product:{product.Id}";
await _db.StringSetAsync(cacheKey, JsonSerializer.Serialize(product), TimeSpan.FromMinutes(10)); // keep cache in sync
}
Enter fullscreen mode Exit fullscreen mode
Updating the cache at the same time as the underlying data source keeps them in sync immediately, avoiding a stale-read window — at the cost of every write needing to touch both systems, and more complexity if the cache write fails after the database write succeeds.
Session storage
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
builder.Services.AddSession();
Enter fullscreen mode Exit fullscreen mode
Storing session state in Redis instead of in-process memory is what makes ASP.NET Core session state work correctly across multiple server instances (see the SignalR and Background Services guides in this series for the related “don’t assume single-instance state” theme) — any instance behind a load balancer can read the same session data, since it lives centrally in Redis rather than in one specific server’s memory.
Rate limiting
public async Task<bool> IsAllowedAsync(string clientId, int maxRequests, TimeSpan window)
{
var key = $"ratelimit:{clientId}";
var count = await _db.StringIncrementAsync(key);
if (count == 1)
await _db.KeyExpireAsync(key, window); // set the window's expiry only on the first request
return count <= maxRequests;
}
Enter fullscreen mode Exit fullscreen mode
This fixed-window rate limiter relies on Redis’s atomic INCR — even under highly concurrent requests, the counter increments correctly without a race condition, since each INCR call is atomic at the Redis server itself, not something the client needs to coordinate.
Rank/leaderboard queries
ZADD game:leaderboard 12500 "player42"
ZREVRANK game:leaderboard "player42" # this player's current rank
ZREVRANGE game:leaderboard 0 9 WITHSCORES # top 10 players
Enter fullscreen mode Exit fullscreen mode
As covered in Section 2, sorted sets make leaderboard-style features nearly trivial to implement correctly and efficiently, something that would require a more involved query (and likely an index) in a relational database for comparable performance at scale.
6. Pub/Sub and Streams
Pub/Sub: fire-and-forget messaging
var subscriber = redis.GetSubscriber();
await subscriber.SubscribeAsync(RedisChannel.Literal("notifications"), (channel, message) =>
{
Console.WriteLine($"Received: {message}");
});
await subscriber.PublishAsync(RedisChannel.Literal("notifications"), "New order placed");
Enter fullscreen mode Exit fullscreen mode
Redis Pub/Sub delivers a published message to every currently-subscribed client — but messages aren’t persisted or queued: a subscriber that wasn’t connected when a message was published simply never receives it. This makes Pub/Sub well-suited for ephemeral, best-effort notifications (like fanning out a SignalR message across multiple server instances via a backplane, as covered in this series’ SignalR guide) but a poor fit for anything requiring guaranteed delivery.
Streams: durable, replayable messaging
XADD orders:stream * orderId 1001 status "created"
XGROUP CREATE orders:stream order-processors 0
XREADGROUP GROUP order-processors consumer1 COUNT 10 STREAMS orders:stream >
XACK orders:stream order-processors 1234567890-0
Enter fullscreen mode Exit fullscreen mode
Unlike Pub/Sub, Redis Streams persist messages (an append-only log, much like Kafka conceptually) and support consumer groups — multiple consumers splitting the work of processing a stream, with acknowledgment (XACK) tracking what’s been successfully processed, and the ability for a new consumer joining later to catch up on messages it missed. This makes Streams a genuinely durable, at-least-once delivery messaging option, positioned between Pub/Sub’s fire-and-forget simplicity and a dedicated message broker’s (Kafka, RabbitMQ, Azure Service Bus) full feature set.
7. Distributed Locking
The problem
When multiple application instances need to coordinate exclusive access to a resource (ensuring only one instance runs a particular scheduled job, or processes a specific piece of work at a time), a distributed lock — held in a shared system all instances can see — solves it, as covered more generally in this series’ Background Services guide.
A basic Redis lock
var lockKey = "lock:nightly-report";
var lockValue = Guid.NewGuid().ToString(); // unique per lock attempt, used to safely release only your own lock
bool acquired = await _db.StringSetAsync(lockKey, lockValue, TimeSpan.FromMinutes(5), When.NotExists);
if (acquired)
{
try
{
await RunNightlyReportAsync();
}
finally
{
// Release only if we still hold the lock (avoids releasing someone else's lock after our TTL expired)
var script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
await _db.ScriptEvaluateAsync(script, new RedisKey[] { lockKey }, new RedisValue[] { lockValue });
}
}
Enter fullscreen mode Exit fullscreen mode
SET key value NX (set only if the key doesn’t already exist) atomically acquires the lock; the Lua script on release ensures you only delete the lock if it’s still the one you acquired (protecting against accidentally releasing a different instance’s lock that was acquired after your original one expired).
Redlock: the more rigorous algorithm
For scenarios where lock correctness genuinely matters under adversarial conditions (network partitions, clock drift), Redis’s creator proposed the Redlock algorithm — acquiring the lock across a majority of independent Redis instances rather than trusting a single instance. Redlock is somewhat contested in distributed systems circles (there’s legitimate, publicly documented debate about the strength of its guarantees under certain failure conditions), so for most practical application-level locking needs (like the scheduled-job coordination example above), a single well-configured, highly-available Redis instance with the pattern shown above is often pragmatically sufficient — reach for a rigorously reviewed Redlock client library specifically when the cost of an occasional double-execution would be genuinely severe.
8. High Availability and Scaling
Replication
# On a replica
replicaof primary-host 6379
Enter fullscreen mode Exit fullscreen mode
A Redis primary can have one or more replicas that continuously receive a stream of write commands, providing both read scaling (replicas can serve read traffic) and a standby copy of the data in case the primary fails.
Redis Sentinel: automated failover
Sentinel is a separate, lightweight process (typically run in a group of 3+ for quorum) that monitors a primary/replica setup and automatically promotes a replica to primary if the current primary becomes unreachable — clients connect through Sentinel (or are configured to discover the current primary via it) rather than hardcoding a single Redis address, so a failover doesn’t require manual reconfiguration.
Redis Cluster: horizontal scaling
redis-cli --cluster create node1:6379 node2:6379 node3:6379 node4:6379 node5:6379 node6:6379 --cluster-replicas 1
Enter fullscreen mode Exit fullscreen mode
For datasets or throughput needs beyond what a single Redis instance’s memory/CPU can handle, Redis Cluster shards data across multiple nodes automatically, using a hash-slot mechanism (16,384 fixed hash slots, distributed across the cluster’s nodes) to determine which node owns which keys — conceptually similar to the sharding approaches covered in this series’ MongoDB/Cosmos DB guide, adapted to Redis’s specific data model.
Managed Redis offerings
Azure Cache for Redis and Amazon ElastiCache for Redis provide fully managed Redis deployments handling replication, failover, and patching automatically — for most production workloads, a managed offering removes a meaningful amount of the operational complexity described in this section, similar to the tradeoff described for managed databases elsewhere in this series.
9. .NET Integration
StackExchange.Redis: the standard client
var redis = ConnectionMultiplexer.Connect("localhost:6379");
builder.Services.AddSingleton<IConnectionMultiplexer>(redis);
Enter fullscreen mode Exit fullscreen mode
ConnectionMultiplexer is designed to be created once and shared for the lifetime of the application (typically registered as a singleton) — it manages an efficient pool of connections internally and multiplexes many concurrent commands over them, so creating a new one per request is a common and costly anti-pattern, not a safer default.
public class ProductCache
{
private readonly IDatabase _db;
public ProductCache(IConnectionMultiplexer redis) => _db = redis.GetDatabase();
public async Task<Product?> GetAsync(int id)
{
var value = await _db.StringGetAsync($"product:{id}");
return value.HasValue ? JsonSerializer.Deserialize<Product>(value!) : null;
}
}
Enter fullscreen mode Exit fullscreen mode
IDistributedCache abstraction
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "MyApp:";
});
Enter fullscreen mode Exit fullscreen mode
public class ProductService
{
private readonly IDistributedCache _cache;
public ProductService(IDistributedCache cache) => _cache = cache;
public async Task<Product?> GetProductAsync(int id)
{
var cached = await _cache.GetStringAsync($"product:{id}");
if (cached is not null)
return JsonSerializer.Deserialize<Product>(cached);
var product = await _repository.GetByIdAsync(id);
if (product is not null)
{
await _cache.SetStringAsync($"product:{id}", JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
}
return product;
}
}
Enter fullscreen mode Exit fullscreen mode
IDistributedCache is ASP.NET Core’s abstraction over distributed caching providers — coding against this interface (rather than directly against StackExchange.Redis‘s IDatabase) means swapping the underlying cache provider later doesn’t require touching application code, at the cost of a somewhat narrower feature surface than Redis’s full native API (no direct access to sorted sets, pub/sub, or other Redis-specific structures through this abstraction — those still require IConnectionMultiplexer directly).
HybridCache (newer .NET pattern)
.NET 9 introduced HybridCache, which layers a fast in-process memory cache in front of a distributed cache like Redis — reducing network round trips for very hot keys while still benefiting from Redis’s shared, cross-instance consistency for everything else, and it also solves the “cache stampede” problem (many concurrent requests all missing the cache simultaneously and hammering the source of truth) by coordinating concurrent loads for the same key.
10. Caching Pitfalls
Pitfall Why it hurts Better approach No expiration on cached data Stale data served indefinitely, or unbounded memory growth Always set a sensible TTL, even a generous one Caching data that changes very frequently Cache is constantly stale or provides little benefit Cache more stable data, or use short TTLs/write-through for volatile data Cache stampede (many requests miss simultaneously, all hit the source at once) Sudden load spike on the underlying database exactly when it’s most vulnerable Use request coalescing (HybridCache, or a simple in-flight-request lock) so only one request refreshes the cache while others wait
Treating Redis as a guaranteed-durable primary datastore without configuring persistence
Data loss on restart if RDB/AOF isn’t configured appropriately
Explicitly decide and configure a persistence strategy matching the data’s actual durability needs (Section 4)
Using a new ConnectionMultiplexer per request
Connection overhead, exhausted connection limits under load
Register one ConnectionMultiplexer as a singleton for the app’s lifetime
Storing very large values in a single key
Slower serialization/network transfer, potential impact on Redis single-threaded latency for that operation
Consider whether the value should be broken into a hash or multiple keys, or cached in a more targeted, smaller shape
No monitoring of memory usage/eviction rate
Silent degradation as increasingly relevant data gets evicted under memory pressure
Monitor used_memory, eviction counts, and hit/miss ratio; alert on sustained high eviction rates
11. Redis vs. Alternatives
Redis Memcached In-process memory cache Data structures Rich (strings, hashes, lists, sets, sorted sets, streams) Simple key-value only Whatever .NET objects you want, natively Persistence Optional (RDB/AOF) None — pure cache, data lost on restart always N/A — tied to process lifetime Shared across instances Yes Yes No — each instance has its own separate cache Pub/sub, streams, locking Yes No No Typical use General-purpose cache, sessions, leaderboards, rate limiting, messaging Simple, high-throughput pure caching with minimal features needed Very hot, small, non-shared data (e.g.,HybridCache‘s local tier)
Redis’s broader feature set (data structures beyond simple key-value, persistence options, pub/sub, streams) is why it’s become the default choice over Memcached for most new projects, even when the immediate need is “just caching” — the additional capabilities are frequently useful later without needing to introduce a second system. An in-process memory cache remains valuable specifically for very hot, small, per-instance data where the overhead of a network round trip to Redis isn’t worth paying, which is exactly the gap HybridCache‘s two-tier design is meant to fill.
Quick Reference Table
Concept Purpose Strings/INCR
Simple values, atomic counters
Hashes
Object-like data with independently updatable fields
Lists
Ordered collections, simple queues, recent-items feeds
Sets
Unique membership, set operations (union/intersect)
Sorted sets
Leaderboards, ranked/priority data
TTL/EXPIRE
Automatic key expiration for caches and sessions
maxmemory-policy
Eviction behavior once memory is full
RDB / AOF
Snapshot vs. append-log persistence strategies
Pub/Sub
Ephemeral, non-persisted fan-out messaging
Streams
Durable, replayable messaging with consumer groups
SET ... NX
Atomic basis for simple distributed locks
Sentinel
Automated primary/replica failover
Redis Cluster
Horizontal sharding across many nodes
IDistributedCache / HybridCache
.NET abstractions over Redis for caching
Conclusion
Redis’s enduring popularity comes from a genuinely rare combination: it’s extremely fast (in-memory, simple single-threaded command execution), but it’s not just a fast key-value store — its rich set of purpose-built data structures turns problems that would otherwise need custom application logic (leaderboards, rate limiting, activity feeds, distributed locks) into a handful of well-understood, atomic commands. Layer in optional persistence, pub/sub and streams for messaging, and Sentinel/Cluster for high availability and scale, and it’s easy to see why Redis has become the default answer to “we need this to be fast and shared across instances” for such a wide range of application needs.
The main discipline required to use it well isn’t really about Redis itself — it’s about being deliberate: choosing an appropriate TTL and eviction policy for cached data, deciding explicitly whether persistence matters for a given use case, and picking the right data structure for the actual access pattern rather than defaulting to strings and JSON serialization for everything, even when a hash, set, or sorted set would fit the problem far more naturally and efficiently.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the caching bug that taught you to respect TTLs and cache-stampede protection.
답글 남기기