Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 15 / 18 40 MIN

Infrastructure

Redis Sizing and CDN Behaviour

What the object cache does at maxmemory, how to measure its working set instead of guessing, and how to prove from headers that the CDN served a page.

An object cache is a bet that the same values will be asked for again. Redis keeps the answers in memory, and memory is a fixed resource with a hard edge. Two things happen when the edge arrives. With allkeys-lru, Redis silently discards keys nobody recently asked for and the cache quietly stops being a cache. With noeviction, Redis refuses every write until memory is freed, and the application has to decide what to do about a cache it cannot fill.

Neither failure shows up as an error message in a browser. Both show up as load, which is why this lesson measures the working set instead of picking a maxmemory value from a blog post. A CDN has a milder version of the same problem: it will serve something, and whether that something is the right page for that visitor is decided by rules you either wrote down or inherited by accident.

All numbers here come from a lab box with Redis 8.10, WordPress 7.0.7 and the redis-cache 3.0.0 drop-in talking to Redis over Predis 2.4.1. phpredis was not installed, which changes latency but not the memory arithmetic.

Size the object cache from its working set, not from a guess about traffic

The working set is the total size of the entries that have to stay resident for the hit rate to be useful. Redis answers two of the three questions you need directly, and the third comes from the application:

redis-cli -p 6379 info memory | grep -E 'used_memory_human|maxmemory_human|maxmemory_policy'
redis-cli -p 6379 dbsize
redis-cli -p 6379 --memkeys --memkeys-samples 1000   # approximate bytes per key

On the lab instance, writing 4,000 entries of a 32 KiB payload filled the 64 MB limit after 1,341 keys, which is about 49 KB per key once Redis's own overhead is included. That ratio is the number to carry away: payload + about 50% for small entries, less for large ones. Divide maxmemory by that and you get the number of entries that stay resident. If the working set is larger than that number, the cache will evict and the hit rate will fall to whatever fraction fits.

The second measurement is what the hit rate is worth. The lab wrote a set that did not fit, then read it back with 80% of reads going to the 20% of keys written most recently. Under allkeys-lru the read hit rate was 61.9%. That is the ceiling for a cache whose working set is roughly twice its memory: about 38% of the reads still reach the database, and tuning the eviction policy does not change the arithmetic.

allkeys-lru degrades silently and noeviction fails loudly

The lab ran the same 125 MiB write against two Redis instances, identical except for maxmemory-policy and both capped at 64 MB:

allkeys-lru, 64mb, 4000 keys x 32 KiB
after_write: used=63.96M stored=4000 refused=0 distinct_keys=1341 evicted=7975
reads=8000 hits=4952 misses=3048 hit_rate=61.9%

noeviction, 64mb, same write
after_write: used=63.96M stored=1341 refused=2659 distinct_keys=1341 evicted=0
first_error=OOM command not allowed when used memory > 'maxmemory'
reads=8000 hits=8000 misses=0 hit_rate=100%

Read those two blocks together and the trade is clear. Under allkeys-lru every write succeeded and Redis threw away 7,975 values to make room: nothing in the application notices, and the cost appears as database queries. Under noeviction the first 1,341 writes succeeded, the next 2,659 were refused with an OOM error, and everything that was stored stayed stored. The 100% read hit rate in the second block is not a win; it is an artifact of the write set being the same as the read set, and it hides the part that matters: writes are failing.

How the application responds to a refused write decides whether noeviction is a controlled failure or an outage. A drop-in that logs and continues turns every failed cache write into a recomputation on the next read, which is the database load the cache existed to prevent. A drop-in that throws turns it into an error page. Neither is automatically right, so know which one you have. redis-cli info stats reports evicted_keys, and maxmemory_policy decides whether that number can grow. On a store, a cache that cannot write often surfaces as a checkout error rather than as a cache problem, and the checkout error is what gets escalated.

wp_cache_flush() is fast, and the request after it is not

Flushing looks like the expensive operation and is usually the cheap one. On the lab install, with 50,000 keys of 2 KB seeded under the WordPress prefix, the flush removed every key in 11 ms:

seeded=50000 used=64.00M keys=25104
wp_cache_flush: 0.011s, after used=1.11M keys=0
flush_queries=6

Redis freed the keyspace immediately and reclaims process memory later, which is why used_memory and keys disagree for a moment. The cost is not in the flush. It is in the requests that arrive next, which all miss at once and rebuild from the database in parallel. That is the thundering herd, and it is what makes a deploy that flushes the object cache feel like an outage. Warm what matters after a flush, and treat wp_cache_flush() as a once-a-blue-moon operation rather than part of a routine.

Connection count is a function of PHP workers, not of visitors

Redis enforces maxclients, and the lab measured it by holding connections open against an instance configured with maxclients 1:

attempted=60  refused=59
first_error=ERR max number of clients reached
connections_in_client_list=2

One client was served and the rest were refused with a protocol error, not a connection reset, which means the application sees it as a Redis-level failure. The number to size against is not traffic. It is the maximum number of PHP workers that can exist at one time, because each worker holds its own connection for the life of a request unless the client is configured to pool. A pool of 64 FPM workers with a drop-in that opens a new connection per request needs 64 client slots plus headroom; several sites sharing one Redis instance need the sum of their pools, and their eviction policies need to agree, because a shared instance evicts one site's keys to make room for another's. The redis-cache drop-in reports Persistent as empty by default, which is the setting that decides whether connections are reused across requests or opened every time.

A CDN caches what its rules allow, and the origin has little say

WordPress core sends no Cache-Control, no ETag and no Set-Cookie on an anonymous page. On the lab origin, the headers on the home page are content type and little else, and a static file is served with a Content-Type and nothing about freshness. That means the decision to cache an HTML page for sixty seconds is the CDN's configuration, not the application's, and it is the configuration you have to test.

The rules every shared cache needs, and the lab edge implements, are the same list:

ConditionWhy it must bypass the cache
Method other than GET or HEADA POST changes state and must reach the origin.
Query string presentA URL with parameters is usually a different page, and caching one variant under the bare path serves the wrong thing.
Session, login or cart cookieThe response is per-visitor. A cached page in front of WooCommerce can serve one customer's cart contents to the next visitor.
Origin sends Set-CookieIf the origin is personalising the response, a shared cache must not store it. Treat the header as a stop sign.
Origin sends no-store, private or max-age=0The application has asked for the response not to be stored, and a cache that ignores it is broken by design.

The lab edge is a small server in front of the origin that applies those rules and reports what it did in headers. Measured through it, a page cached at the edge and an origin render differ by two orders of magnitude:

origin (WordPress, 78 KB page)   median 0.394s over 3 requests
edge HIT (same page)             median 0.00074s over 5 requests
edge MISS -> X-Cache: MISS       X-Cache-Served-By: origin  Age: 0
edge HIT  -> X-Cache: HIT        X-Cache-Served-By: edge    Age: 0, then 1

?utm_source=news    -> X-Cache: BYPASS  X-Cache-Bypass-Reason: query-string
cart cookie         -> X-Cache: BYPASS  X-Cache-Bypass-Reason: cookie:woocommerce_items_in_cart
logged-in cookie    -> X-Cache: BYPASS  X-Cache-Bypass-Reason: cookie:wordpress_logged_in

Those four headers are the verification technique: who served the response, was it a hit or a miss, why not, and how old is the copy. A CDN names them differently, and X-Cache, Age, CF-Cache-Status and X-Served-By cover most of them. The reading to distrust is X-Cache: HIT with no Age and a Content-Type you did not expect, which is how a soft 404 gets cached and served as a page.

Publishing does not invalidate the edge, and you have to prove that too

On the lab setup, with the home page resident at the edge, a new post was published directly in the database. The origin showed it immediately. The edge did not:

edge before publish: X-Cache: HIT  Age: 18   (no new title in the body)
publish a post: wp post create --post_status=publish ...
origin:  contains the new title
edge:    X-Cache: HIT  Age: 1   (still no new title)

Nothing there is a bug in the CDN. A cached response is valid until its TTL expires or something purges it, and WordPress cannot reach into an edge cache by itself. The two available answers are a TTL short enough that the staleness is tolerable, and an explicit purge on publish. The second one means the code that invalidates the object cache key also has to call the CDN's purge endpoint. When a deploy flushes Redis and forgets the edge, the site looks new for logged-in readers and old for everybody else.

Lab 45 minutes

Measure the two resources you are paying for.

For Redis, record info memory and dbsize on an idle instance, then run one busy page and record both again. Load the front page fifty times through your warmest code path, then read both again. Divide the memory growth by the key growth to get bytes per entry, and multiply by the entries that have to stay resident. Compare that number against maxmemory and maxmemory-policy, and write down what happens at the boundary on your instance: does evicted_keys move, or do writes start failing?

For the CDN, request the same URL twice and record the four headers from the second response. Then request it with a cart cookie, with a query string, and with your own session cookie, and record which of them the edge treated as cacheable. Finally, publish a post or change a price, and check the same page again without purging anything.

Then answer: how long can your site serve a page that is wrong, and who finds out first?

Show the solution and the reasoning

The Redis measurement has one trap. On a quiet instance, used_memory includes allocator overhead and a baseline of a megabyte or so, so read the delta rather than the absolute value. On the lab instance an empty Redis reported about 1.0 MB, one 32 KiB payload per key pushed it to 49 KB per key, and the instance refused new keys once used_memory reached maxmemory. If your own arithmetic says the working set exceeds maxmemory, the decision is not a tuning decision: either raise memory until the working set fits, or accept evictions and treat the miss path as the hot path.

For the policy, the answer depends on what a failed cache write does in your code. allkeys-lru is the safer default for an object cache, because a cache that is allowed to drop entries keeps working and its failure mode is more database load. noeviction is reasonable when the instance also holds data you cannot recompute, but then the application must handle refused writes, and on a store that usually means the request that fails is the one adding to a cart. Whichever you choose, the number that tells you it is happening is evicted_keys under LRU, and a rising error count or a spike in database queries under noeviction.

For the CDN, the four headers from the second request answer the caching question and the copy from after the publish answers the invalidation question. Most sites find that HTML has a TTL someone set months ago and forgot, that the TTL is longer than the time between deploys, and that the object cache and the edge are invalidated by different code paths. The repair is not a shorter TTL; it is making the publish path call both caches, and then checking the headers after the next publish to confirm it did.

One caveat about the lab numbers. The edge in this lab is a local process, so 0.0007s is not a CDN latency; it is the cost of an in-memory lookup on the same machine. What transfers is the header evidence and the invalidation behaviour, not the timing. Measure your own edge with curl -D - against a real edge node from a real network, because the distance from the visitor to the edge is most of what you are paying for.

Progress

Mark this lesson done and it stays marked in this browser.