venish.dev
← writing
redis / redis-caching-explained

Redis Caching Explained

published 2026-07-042 min read313 words

Most caching failures I care about are failures of the miss path. The cache is empty, slow, or carrying an old value; the rest of the system still has to make a deliberate choice.

Cache-aside is the default

Cache-aside keeps the database authoritative. A read checks Redis first, falls back to the source on a miss, and stores the result with an expiration. Writes usually invalidate the key so the next read repopulates it. Updating the cache directly can be appropriate, but it also makes ordering between two systems part of the correctness model.

product_service.go
func (s *Service) Product(ctx context.Context, id string) (Product, error) {
	key := "product:" + id
 
	cached, err := s.cache.Get(ctx, key).Bytes()
	if err == nil {
		var product Product
		if json.Unmarshal(cached, &product) == nil {
			return product, nil
		}
		slog.WarnContext(ctx, "invalid cached product", "key", key)
	} else if !errors.Is(err, redis.Nil) {
		slog.WarnContext(ctx, "redis read failed", "key", key, "error", err)
	}
 
	product, err := s.products.ByID(ctx, id)
	if err != nil {
		return Product{}, err
	}
 
	if encoded, err := json.Marshal(product); err == nil {
		if err := s.cache.Set(ctx, key, encoded, 10*time.Minute).Err(); err != nil {
			slog.WarnContext(ctx, "redis write failed", "key", key, "error", err)
		}
	}
 
	return product, nil
}

redis.Nil is the expected miss. Other Redis errors are observable, but they do not turn a successful source-of-truth read into an application failure. That trade-off only makes sense because this Redis instance is a disposable cache.

Stampedes

When a hot key expires, every concurrent reader misses at once and they all hit the database together. TTL jitter prevents related keys from expiring in lockstep. Request coalescing or a short distributed lock lets one caller recompute while the rest wait briefly. Systems that can tolerate bounded staleness can serve the previous value during that refresh instead.

The right mechanism depends on the cost of recomputation and the consequence of stale data. A lock is not automatically worth adding to every cached read.

Picking an eviction policy

For a Redis instance containing only reproducible cache entries, an allkeys-* policy lets Redis reclaim memory instead of rejecting writes. LRU is a sensible starting point when recent access predicts reuse; LFU can fit workloads where a stable hot set matters more.

If Redis contains data that cannot be regenerated, it is no longer merely a cache. noeviction turns memory pressure into a visible write failure rather than silently discarding that data, but the larger fix is to separate durable state from disposable cache entries and operate them with different policies.