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:
| Condition | Why it must bypass the cache |
|---|---|
| Method other than GET or HEAD | A POST changes state and must reach the origin. |
| Query string present | A URL with parameters is usually a different page, and caching one variant under the bare path serves the wrong thing. |
| Session, login or cart cookie | The 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-Cookie | If 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=0 | The 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.
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.