Eviction Policies

LRU, LFU and TTL, what each is bad at, and why one nightly table scan can flatten an LRU cache's hit rate.

SyntaxCONFIG SET maxmemory 4gb; CONFIG SET maxmemory-policy allkeys-lru

A cache is a fixed amount of memory pretending to be unbounded. The eviction policy is what it does when the pretence runs out, and it decides your hit rate far more than the cache size does.

The policies

  • LRU — evict the least recently used key. Cheap, and right whenever recent access predicts future access.
  • LFU — evict the least frequently used key. Added in Redis 4.0 as allkeys-lfu and volatile-lfu. Resists one-off traffic.
  • TTL-based — volatile-ttl evicts the key closest to expiring. Only considers keys that have a TTL at all.
  • Random — allkeys-random. Cheaper than it deserves to be, and not much worse on uniform workloads.

Redis does not keep a true LRU list. It samples maxmemory-samples keys (default 5) and evicts the best candidate from that sample. Its LFU counters are logarithmic (lfu-log-factor, default 10) and decay over time (lfu-decay-time, default 1 minute), so a key that was hot last week does not stay protected forever.

Why a scan destroys an LRU cache

A nightly export touches every row once. Under LRU each of those keys becomes the most recently used thing in the cache, so the working set that live traffic needs is evicted, and the hit rate stays down until normal traffic refills it. LFU barely notices a single access, so it is resistant in principle. Databases fixed this long ago: InnoDB inserts a newly read page at the midpoint of its LRU list (innodb_old_blocks_pct, default 37) and promotes it to the young sublist only if it is read again at least innodb_old_blocks_time milliseconds later (default 1000). Memcached 1.5.0 made its segmented HOT/WARM/COLD LRU the default for the same reason.

Under a volatile-* policy, keys written without a TTL can never be evicted at all.

Example

Example · javascript
// Scan-resistance you control from the application: a read path that can
// deliberately decline to populate the cache.
async function getOrder(id, { populate = true } = {}) {
  const key = `order:${id}`;
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit);

  const order = await db.order(id);
  if (order && populate) {
    await redis.set(key, JSON.stringify(order), { EX: 600 });
  }
  return order;
}

// Interactive traffic: cache normally. These keys are the working set.
app.get('/orders/:id', async (req, res) => {
  res.json(await getOrder(req.params.id));
});

// Nightly export: reads every order exactly once. Under LRU, populating here
// would evict the working set and flatten tomorrow morning's hit rate.
export async function exportAllOrders(ids, write) {
  for (const id of ids) {
    write(await getOrder(id, { populate: false })); // read through, store nothing
  }
}

When to use it

  • Diagnosing a hit rate that drops every morning after a batch job runs.
  • Choosing between allkeys-lru and allkeys-lfu for a catalogue with a long tail.
  • Deciding whether a Redis instance is a cache (evict) or a datastore (do not evict).
  • Explaining why adding RAM did not improve the hit rate as much as expected.

More examples

Configuring and then measuring the policy

Switch to LFU when a small set of keys carries most of the traffic and periodic jobs keep sweeping them out. Watch evicted_keys alongside the hit ratio: rising evictions with a falling hit ratio means the cache is thrashing, and the policy, not the size, is usually the cause.

Example · bash
# Runtime change; CONFIG REWRITE persists it into redis.conf.
redis-cli CONFIG SET maxmemory 4gb
redis-cli CONFIG SET maxmemory-policy allkeys-lfu   # LFU policies: Redis 4.0+
redis-cli CONFIG SET maxmemory-samples 10           # better approximation, more CPU
redis-cli CONFIG REWRITE

# Per-key access frequency counter. Only valid under an LFU policy.
redis-cli OBJECT FREQ product:912

# The three numbers that tell you whether the policy is working.
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses|evicted_keys'

How a database makes its own buffer cache scan-resistant

Worth reading even if you never touch these variables, because it is the canonical fix for scan pollution and the model to copy in an application cache: admit new entries on probation, promote only on a second access. Reach for the counters when a reporting query makes unrelated OLTP latency worse.

Example · sql
-- MySQL 8.0/8.4 InnoDB: the LRU list is split, and a page read by a scan
-- lands in the old sublist where it expires before it can displace hot pages.
SELECT @@innodb_old_blocks_pct,   -- 37 by default: ~3/8 of the pool is "old"
       @@innodb_old_blocks_time;  -- 1000 ms: a page must be re-read after this
                                  -- delay to be promoted to the young sublist

-- Raising the delay helps when several large scans run back to back.
SET GLOBAL innodb_old_blocks_time = 2000;

-- Promotion counters: a scan shows up as PAGES_NOT_MADE_YOUNG climbing fast.
SELECT POOL_ID, DATABASE_PAGES, OLD_DATABASE_PAGES,
       PAGES_MADE_YOUNG, PAGES_NOT_MADE_YOUNG
  FROM information_schema.INNODB_BUFFER_POOL_STATS;

A bounded in-process cache in front of Redis

A per-process LRU removes the network hop for a handful of very hot keys, which is the cheapest fix for a hot-key problem. The cost is that every process can be stale independently for up to its TTL, so only put values here whose staleness budget you have written down.

Example · javascript
import { LRUCache } from 'lru-cache'; // v10+: named export, options object

const hot = new LRUCache({
  max: 5000,             // always an item count; a byte budget is maxSize + sizeCalculation
  ttl: 30_000,           // ms; a short TTL bounds how stale a replica can be
  updateAgeOnGet: false, // a read must not extend the TTL, or hot keys never refresh
  allowStale: false,
});

async function getFeatureFlags(tenantId) {
  const local = hot.get(tenantId);
  if (local) return local;                 // ~0 ms, no network
  const shared = await getFromRedis(tenantId); // ~1 ms, one network hop
  hot.set(tenantId, shared);
  return shared;
}

Discussion

  • Be the first to comment on this lesson.