Redis caching solves a simple problem. Your database answers the same question over and over, and every repeat costs a query. If a hotel booking site shows the same "popular hotels in Bangkok" list to every visitor, the database runs that query for every page view even though the answer rarely changes. Redis keeps that answer in memory and serves it from there, so the database does the work once instead of on every request.
Redis is more than a place to park query results, though. This guide covers the cache-aside pattern, Redis data structures and their uses, TTL and eviction, the single-threaded model, persistence, and where Pub/Sub fits.
What Redis is
Redis is an in-memory key-value data store. Every key maps to a value, and the whole dataset lives in RAM. Reading from memory avoids disk I/O and query planning entirely, so typical operations complete in well under a millisecond on the server. In practice, the network round trip is usually the largest part of a Redis call.
The trade-off is that RAM is limited and expensive, so you decide what goes in, how long it stays, and what happens when memory fills up.
The cache-aside pattern for Redis caching
Cache-aside (also called lazy loading) is the pattern most applications use:
- Look up the key in Redis.
- On a hit, return the cached value.
- On a miss, query the database, store the result in Redis with a TTL, and return it.
In Go 1.22 with github.com/redis/go-redis/v9:
func (s *HotelService) Popular(ctx context.Context, city string) ([]Hotel, error) {
key := "hotels:popular:" + city
b, err := s.rdb.Get(ctx, key).Bytes()
if err == nil {
var hotels []Hotel
if json.Unmarshal(b, &hotels) == nil {
return hotels, nil // cache hit
}
} else if !errors.Is(err, redis.Nil) {
s.log.Warn("redis get failed, falling back to database", "err", err)
}
hotels, err := s.repo.PopularHotels(ctx, city) // cache miss
if err != nil {
return nil, err
}
if b, err := json.Marshal(hotels); err == nil {
ttl := 5*time.Minute + time.Duration(rand.Int64N(int64(time.Minute)))
s.rdb.Set(ctx, key, b, ttl)
}
return hotels, nil
}Details that matter in production:
- A Redis error is treated as a miss. If Redis is unavailable, the service gets slower but keeps working.
- The TTL has random jitter, so keys written together don't all expire together and hit the database at once.
redis.Nilmeans "key not found". It isn't an error.
Cache invalidation
When the underlying data changes, the cached copy becomes stale. The common strategies:
| Strategy | How it works | Trade-off |
|---|---|---|
| TTL only | Let entries expire on their own | Simple. Data can be stale for up to one TTL |
| Delete on write | After the database commit, DEL the affected keys | Fresh data. You must know every key a write affects |
| Write-through | Write to the database and update the cache in the same code path | Cache always warm. More write latency and code |
A good default is delete on write plus a TTL as a safety net. Delete rather than overwrite: two concurrent writers updating the cache can leave the older value in place.
Watch out for a cache stampede: when a hot key expires, many requests miss at once and all query the database. In Go, golang.org/x/sync/singleflight collapses concurrent loads of the same key into one database query per instance.
Redis data structures and what they're for
A plain cache stores strings. Redis stores typed values with operations that run on the server, which is what makes it useful beyond caching:
| Type | Key commands | Typical use |
|---|---|---|
| String | GET, SET, INCR, SET ... NX | Cached values, counters, locks |
| Hash | HSET, HGET, HINCRBY | Objects with fields, such as a user profile or session |
| List | LPUSH, RPOP, BLMOVE | Simple queues, recent-items lists |
| Set | SADD, SISMEMBER, SCARD | Tags, unique members, "has this user voted?" |
| Sorted set | ZADD, ZINCRBY, ZRANGE ... REV | Leaderboards, rankings, time-ordered indexes |
| Stream | XADD, XREADGROUP, XACK | Durable event logs with consumer groups |
| HyperLogLog | PFADD, PFCOUNT | Approximate unique counts in very little memory |
Rate limiting with INCR and EXPIRE
A fixed-window rate limiter needs one counter per client per minute. INCR is atomic, so concurrent requests can't miscount:
func Allow(ctx context.Context, rdb *redis.Client, ip string, limit int64) (bool, error) {
key := fmt.Sprintf("rate:%s:%d", ip, time.Now().Unix()/60)
pipe := rdb.TxPipeline()
count := pipe.Incr(ctx, key)
pipe.Expire(ctx, key, 2*time.Minute)
if _, err := pipe.Exec(ctx); err != nil {
return false, err
}
return count.Val() <= limit, nil
}Leaderboards with sorted sets
A sorted set keeps members ordered by score, so ranking is a single command:
rdb.ZIncrBy(ctx, "hotels:bookings:2026-09", 1, "hotel:4821")
top, err := rdb.ZRangeArgsWithScores(ctx, redis.ZRangeArgs{
Key: "hotels:bookings:2026-09", Start: 0, Stop: 9, Rev: true,
}).Result()Sessions and distributed locks
Hashes with a TTL make a straightforward session store that every application instance can read.
For a distributed lock, use SET key token NX PX 30000. It sets the key only if it doesn't exist and gives it an expiry in one atomic command. The older SETNX plus a separate EXPIRE can leave a lock without an expiry if the process dies in between. Release the lock with a Lua script that deletes the key only if it still holds your token, so you never release someone else's lock.
A Redis lock is fine for avoiding duplicate work, such as two instances running the same cron job. It isn't a correctness guarantee: a long GC pause or a failover can let two holders overlap. For money or inventory, enforce the rule in the database, as described in preventing race conditions in payment systems.
TTL and eviction: when memory fills up
Set a TTL with EXPIRE or with the EX/PX options on SET. Redis removes expired keys lazily, when they're accessed, and actively, by sampling keys with a TTL in the background.
TTLs alone don't bound memory. Set maxmemory and choose what happens at the limit:
maxmemory 2gb
maxmemory-policy allkeys-lfu| Policy | Evicts |
|---|---|
noeviction (default) | Nothing. Writes fail with an error when memory is full |
allkeys-lru | Least recently used keys |
allkeys-lfu | Least frequently used keys |
volatile-lru / volatile-lfu | Same, but only keys that have a TTL |
volatile-ttl | Keys closest to expiring |
allkeys-random / volatile-random | Random keys |
For a pure cache, allkeys-lru or allkeys-lfu is the usual choice. Keep noeviction when Redis holds data you can't lose, such as queues or sessions, and alert on memory use instead.
Why single-threaded Redis is fast
Redis executes commands on a single main thread. That sounds like a bottleneck, but it works well because:
- data is in memory, so commands are short,
- an event loop handles thousands of connections without a thread per client, and
- there are no locks between threads to contend on.
The single thread also means every command is atomic. INCR from two clients can never lose an update. For multi-step logic, use MULTI/EXEC or a Lua script, which also run without interleaving.
The flip side is that one slow command blocks everyone. Use SCAN instead of KEYS *, avoid fetching huge collections in one call, and use UNLINK for large keys. Since Redis 6, optional I/O threads can handle network reads and writes, but command execution is still single-threaded.
Redis persistence: RDB vs AOF
Redis keeps data in memory, but it doesn't have to lose everything on restart. It offers two persistence mechanisms:
| RDB (snapshot) | AOF (append-only file) | |
|---|---|---|
| How | Forks and writes a point-in-time snapshot of the dataset | Logs every write command and replays it on startup |
| Data loss on crash | Everything since the last snapshot | With appendfsync everysec, about one second of writes |
| File size and restart | Compact file, fast restart | Larger, periodically rewritten to compact it |
| Runtime cost | Fork can be heavy on large datasets | Constant disk writes |
You can enable both. Since Redis 7, AOF uses a multi-part format, and rewrites start from an RDB preamble, so restarts stay fast:
appendonly yes
appendfsync everysec
save 3600 1 300 100 60 10000How to choose:
- Pure cache that can be rebuilt from the database: RDB only, or no persistence at all.
- Sessions, rate limits, queues: AOF with
everysec, plus RDB for backups. - Replication is not a backup. A replica copies your mistakes as quickly as your data.
With AOF and replicas, Redis can be the primary store for some data, as long as you know how much loss each dataset tolerates. For running stateful services in general, see running databases in production.
Redis Pub/Sub basics
Pub/Sub is a messaging pattern where a publisher sends a message to a channel, and every client currently subscribed to that channel receives it. The publisher doesn't know who, or how many, are listening.
The difference from a queue is the difference between a phone call and a radio broadcast. A queue delivers each message to one consumer, which processes it. Pub/Sub delivers each message to everyone who is tuned in right now.
// subscriber
sub := rdb.Subscribe(ctx, "price-updates")
defer sub.Close()
for msg := range sub.Channel() {
log.Printf("%s: %s", msg.Channel, msg.Payload)
}
// publisher, elsewhere
rdb.Publish(ctx, "price-updates", `{"hotel":4821,"price":1990}`)Redis Pub/Sub is fire-and-forget. Messages aren't stored, so a subscriber that is disconnected or slow simply misses them. That suits live notifications, cache-invalidation signals and fanning out messages to WebSocket servers, a pattern covered in scaling WebSockets with Pub/Sub. When messages must not be lost, use Redis Streams or a broker with acknowledgements, such as RabbitMQ.
FAQ
Is Redis a cache or a database?
Both. It's most often used as a cache, but with AOF persistence and replication it can be the primary store for data such as sessions, counters and leaderboards.
Does Redis lose data on restart?
Not if persistence is enabled. With RDB you lose writes since the last snapshot. With AOF and appendfsync everysec, you lose about a second of writes at most.
Which eviction policy should I use for a Redis cache?
allkeys-lru or allkeys-lfu. LFU keeps frequently used keys even if they weren't touched in the last few seconds, which often suits hot-key workloads.
Is Redis single-threaded?
Command execution happens on one main thread, which makes each command atomic. Redis uses extra threads for background tasks and, optionally, for network I/O.
What is the difference between Redis Pub/Sub and Redis Streams?
Pub/Sub delivers only to currently connected subscribers and stores nothing. Streams store messages, support consumer groups and acknowledgements, and let consumers catch up after a disconnect.
Redis caching checklist
- Cache-aside with a TTL on every cache key, plus jitter
- Redis failures treated as cache misses
- Keys deleted after database commits, with the TTL as a safety net
-
maxmemoryand an eviction policy that matches the data - No
KEYS *or huge single-call reads in production - Persistence chosen per dataset: none, RDB, or AOF plus RDB
- Pub/Sub only where losing a message is acceptable
If you're adding a caching layer or reviewing one that misbehaves under load, Vectorkub builds and tunes backend systems with Redis.
