Deep Engineering
Advanced·Published·65 MIN

The stretched cache: why the analogy with the database is false

"Our database is stretched across two data centres with replicas and it is fine" — and about the database that is true, but "stretching" means different things for a database and for a cache. A cache rests on a comparative property: going to it has to cost less than the source operation it replaces. On a cheap read by key across a link between data centres nothing of that property survives — 41.09 ms against 0.09 ms to the database at hand. And a cluster split evenly between the two sites failed in both halves when the link went down — including the half that held the majority of masters. This revision also checks the measuring rig itself, and one published number turned out to be measuring a defect in the instrument rather than distance.

Full technical treatment

TL;DR

The thinking sounds reasonable: "our database is stretched across two data centres, so let us stretch the cache too". The analogy is false, and it breaks in three separate places at once.

  • A cache rests on a comparative property: going to it has to cost less than the source operation it replaces. On a cheap read by key across a link between data centres nothing of that property survives: a hit in a cache in the other site costs 41.09 ms, reading the same row from the database at hand costs 0.09 ms.
  • The master does not wait for the replica. A link cut immediately after a burst of writes lost 93 of 200; the client got OK for all two hundred. This is not a fault but the declared contract of asynchronous replication.
  • WAIT buys something other than what people take it for: the write gets 171.6 times more expensive, and there is nothing to roll it back with — by the time the reply comes the write has already been applied. The price is exactly one round trip to the replica, checked at three distances.
  • The loss window is set by the replication lag, not by the round trip. On one and the same link, three write rates give 104, 3 and 0 lost writes; what to look at is the difference of offsets in INFO replication.
  • The main result. A six-node cluster split evenly between the two sites failed in both halves when the link went down — including the half that held the majority of masters. Each half was observed on its own fresh cluster. A majority of votes is necessary but not sufficient.
  • The measuring rig was checked too, and it cost one number. The price of WAIT carried "a distance-independent term of 42–44 ms" that nothing here could explain. The explanation is that the relay standing in for the inter-site link had no TCP_NODELAY: the instrument was measuring forty milliseconds of its own stall.
  • The same claims were re-run on Redis 8.10.1 and Valkey 9.1.2. None diverged.
  • What to do instead. A separate cache in each site, filled by its own application. Divergence between sites is the sum of the source's own lag, the invalidation delay, the freshness policy and the aftermath of failures — a TTL is one term out of four.

Three ways to stretch a cache

A cache gets stretched in one of three ways, and they are so different that all they share is the word "stretch".

  1. One cluster across two sites, nodes split evenly. The most convincing version on a whiteboard: three boxes on the left, three on the right, a break down the middle — and it looks as though each half will carry on.
  2. Master in one site, replica in the other. A direct tracing of the database: there is a replica next door there, let there be one here.
  3. An application in both sites talking to one cache. Formally this is not even a stretched cluster — just one address serving two sites.

They have to be taken apart separately, because they break in different places. But all three grow from one thought, and that is where to start.

The analogy everything grows from

The argument behind stretching is usually left unsaid, because it feels self-evident: our database is stretched across two data centres with replicas, it has worked for years, nobody complains — so the cache can work that way too.

This is not a foolish argument. It rests on real and successful experience: a stretched database does work, and the engineer who built it has earned the right to generalise. The generalisation simply turns out to be wrong — and not in a detail, but in all three places where it leans on the words "the same way".

A database and a cache have different jobs. They have different costs for losing one record. And the word "stretch" means different things for them. What follows is one difference per section, and each one has a measurement.

A caveat: what it was measured on, and what carries over

Every number in this article was taken on Redis 7.0.15, and what follows holds for Valkey and for current Redis as well. Here is what that means and what it does not.

Valkey is a fork of Redis, and says so itself: Valkey is a fork of the open-source Redis (REmote DIctionary Server) database created in 2009 by the Italian hacker Salvatore "antirez" Sanfilippo. That licenses carrying over not the numbers, but the contract: where the Valkey documentation describes the same behaviour in the same words, the article's claim rests on a quotation from it rather than on an analogy. That is the case for asynchronous replication and for the cluster's survival condition — both wordings in the fork match the Redis ones down to the product name, and both are quoted below.

Appealing to a shared past is not enough, though: behaviour changes with versions, and that has to be checked rather than assumed. So every substantive claim in this article was re-run on Redis 8.10.1 and Valkey 9.1.2 — the table is in a section of its own below. None of them diverged.

The numbers carry over nowhere. They are not needed as absolute values here anyway: what carries meaning is the ratio, the sign, and which half of the cluster answered and which did not.

Difference one: a cache and a database have different jobs

The database is the source of truth. It must not be lost, and for that it is worth paying a round trip between sites: a row that took forty milliseconds to reach the second site is still cheaper than a row that exists nowhere.

A cache exists so that getting the same data again costs less than going to the source. "Less" does not necessarily mean "faster": it can equally be load taken off the source, an expensive recomputation not performed, a quota on an external call not spent, a spike smoothed out, a hot key shielded. Either way the property is comparative, not absolute, and so it can be lost without breaking a single setting — simply by moving the cache further away than the source.

What follows measures one of those sides — latency — and measures it on the case least favourable to a cache.

measured observationbench/stretched-cache/latency.py, Redis 7.0.15 and PostgreSQL 16.13 on one host, link between sites 20 ms one way, round trip 40 ms. A 5000-row table, read by primary key, 200 samples per row of output. The absolute milliseconds depend on the machine and on the distance; what carries meaning is the order of the rows and the fact that the third one is two orders of magnitude above the first two. This is the one class of operation for which the rig is verified as accurate: the calibration block shows that on request/response it yields exactly the round trip.
median, msp95, ms
cache in the same site0.100.14
database in the same site0.090.13
cache in the other site41.0941.36

The run states the ratio outright: a cache in the other site is roughly 450 times slower than the database in your own.

The third way — "an application in both sites talking to one cache" — breaks right here, though not in general: it breaks for this class of queries. Half the requests will travel to the other site, pay the round trip and come back later than a trip to the local database would have. If the cache was there for latency on cheap reads, it does not reduce that latency, it increases it, and on top of that it puts one more failure-prone system between the application and its data.

What this measurement does not say, and that matters more than the numbers. The first two rows nearly coincide, and it does not follow that a cache is pointless. What was measured is the cheapest operation a database performs: five thousand rows, everything in memory, a read by primary key, no contention for connections. A cache is not deployed against that query — it is deployed against connections, aggregates over millions of rows, and the same query repeated a thousand times a second.

Exactly one thing follows, and it is a rule rather than a number: a cache's advantage is taken from a comparison with the source, so the question to check is not "is Redis fast" but "does going to the cache cost less than the source operation it replaces, at this distance". For a cheap read by key out of a warm table, the answer at forty milliseconds is no. For an aggregate that takes a hundred milliseconds it is no longer no — and that case was not measured in this article.

Difference two: losing a row and losing a cache entry cost different things

The second way — master in one site, replica in the other — traces the database literally. The first thing the measurement shows is that the distance is visible.

measured observationbench/stretched-cache/replication.py, Redis 7.0.15, link between sites 20 ms one way. In this low-load experiment the extra visibility latency nearly coincided with the artificially added one-way delay. A coincidence is not a law: processing, buffering and load add terms of their own, and under a loaded replication stream the lag will exceed the flight time.
a write becomes visible on the replica afterms
replica in the same site1.1
replica in the other site20.8
difference19.7

The lag by itself is not a problem. The client got its OK long before: the master answers without waiting for the replica, and that is not a quirk of configuration but a declared contract.

Redis uses by default asynchronous replication, which being low latency and high performance, is the natural replication mode for the vast majority of Redis use cases.

Redis — Redis replication

So the master does not wait every time for a command to be processed by the replicas, however it knows, if needed, what replica already processed what command.

Ibid.

The same page in Valkey says the same thing: Valkey uses by default asynchronous replication — and word for word from there on.

One misunderstanding is worth clearing away here, because half the arguments rest on it: asynchrony is not what separates a cache from a database. Postgres streaming replication is asynchronous by default too, and the documentation names the consequence outright:

If the primary server crashes then some transactions that were committed may not have been replicated to the standby server, causing data loss.

PostgreSQL — High Availability, Load Balancing, and Replication

So both have a loss window. What differs is not the existence of the window but the cost of falling into it — and that is where this is going.

The lag becomes a problem at the moment the replica is promoted in the master's place — that is, in the very scenario the replica is kept in the other site for.

measured observationbench/stretched-cache/replication.py, Redis 7.0.15, link 20 ms one way, 200 writes in a row. Both rows are the same experiment with one difference: whether replication had caught up before the break. The proportion depends on how many writes fit inside the link rather than on Redis, and it swings noticeably from run to run.
200 writes acknowledged to the clienton the replicalost
link cut immediately after the writes10793 (46.5%)
link cut one second after the writes2000 (0.0%)

Both rows have to be read at once, or the first one is carried away as "almost everything is lost" and the second as "nothing is". Neither is true: what is lost is whatever did not manage to reach the replica. Not one of those writes reported an error: the client got OK for all of them.

What sets the loss window, and why it is not the round trip

The experiment invites a rule: "at risk is everything written during the last round trip before the break". The rule circulates in exactly that form, and it is wrong: here the round trip coincided with the loss by accident — two hundred fast writes happened to fit inside it. This is checked by changing the write RATE alone, over the very same link.

measured observationbench/stretched-cache/offsets.py, Redis 7.0.15, link 20 ms one way, round trip 40 ms in all three experiments. The offsets are read before the cut: the question is precisely what was in flight at that moment. All three experiments acknowledged all 200 writes to the client.
200 writes, then the link is cuton the replicalostmaster offsetreplica offsetlag, bytes
in a burst, cut immediately96104640328553548
5 ms apart, cut immediately1973128381273999
in a burst, cut one second later200019273192730

The link is the same in all three, and so is the round trip. What changes is the lag, and with it the loss.

Hence the rule in its correct form: at risk is whatever lies between the master's master_repl_offset and the replica's offset at the moment of the break. Redis reports both numbers in INFO replication, and their difference shows how much you would lose if the failover happened right now. The round trip only sets a lower bound on how quickly the lag can come down to zero; the lag itself also depends on the write rate, the size of the commands and on whether the replica keeps up in applying them.

This is where the analogy with the database breaks a second time. The loss window, as we have just seen, exists for the database too. What differs is not the mechanics but the cost: for a database that lost tail is an incident — nobody will recompute the row, it exists nowhere. For a cache, losing an entry is the norm the whole thing is built around: miss, go to the source, write back. So the machinery that justifies a stretched database is not needed by default by a cache — and would still have to be paid for.

The bill can be read directly. WAIT is the very instrument usually proposed to "make replication synchronous".

measured observationbench/stretched-cache/replication.py, Redis 7.0.15, link 20 ms one way, 20 writes. The ratio depends on what it is compared against — the cheaper the ordinary write, the larger it gets.
ms
ordinary write0.24
write with WAIT 141.73
how many times more expensive171.6
replicas that acknowledged (out of 20 writes, 1 requested)20

Forty-one point seven milliseconds instead of nought point two four is the cost of waiting, added to every write. What that cost is made of cannot be told from a single point — and the guess "that is two round trips" suggests itself. The same experiment at three distances answers more precisely, and answers something else.

measured observationbench/stretched-cache/waitcost.py, Redis 7.0.15, 20 writes per row, median. The delay sits only on the master-to-replica link; the client talks to the master directly. The last column is the check itself: with a fixed number of round trips it would be the same in every row.
one wayround tripordinarywith WAIT 1WAIT / round trip
5 ms10 ms0.15 ms10.90 ms1.09
20 ms40 ms0.14 ms41.52 ms1.04
40 ms80 ms0.20 ms81.40 ms1.02

The last column is one at all three distances. So WAIT costs exactly one round trip to the replica, and grows with distance one for one: the round trip grew by 30 ms and the price grew by 30.62 ms; the round trip grew by 40 ms and the price grew by 39.87 ms. Less than a millisecond and a half remains above the round trip, and that is the work of Redis itself rather than distance.

This block is published in its second revision, and the first one was wrong — wrong in its numbers rather than its conclusion. It gave ratios to the round trip of 5.20 / 2.10 / 1.55, concluded that no fixed number of round trips described the price, and left a term of 42–44 ms above the round trip whose cause the measurement does not establish. The conclusion "with distance the price grows by one round trip" was right even then, because it rested on the differences. The term, however, turned out to be a defect of the measuring instrument rather than a property of Redis — and that is the next section.

The property bought is weaker than expected as well: WAIT reports how many replicas acknowledged, but it does not cancel the write if fewer did. By the time it answers, the write on the master has been applied and is already visible to readers — there is nothing to roll back with. The documentation says so outright:

However WAIT is only able to ensure there are the specified number of acknowledged copies in the other Redis instances, it does not turn a set of Redis instances into a CP system with strong consistency: acknowledged writes can still be lost during a failover, depending on the exact configuration of the Redis persistence.

Redis — Redis replication

So that 171.6-fold price buys not a guarantee but a report on the number of copies. Whether it is worth it is a question not about Redis but about what refilling the cache costs; the article returns to it in the section on what to do instead.

This section came out of an external review, and the question it asked sat one level below all the previous ones. The earlier ones asked what we concluded from a measurement. This one asked what the measurement actually modelled.

In every run the link between data centres is portrayed not by tc netem but by a relay in user code: a process listens on a port, reads a chunk, waits, and passes it on. The objection was specific: the delay is applied to every chunk recv() returns, and TCP preserves no message boundaries. So what was being measured was not "Redis plus a fixed propagation delay" but "Redis plus a delay multiplied by however many chunks the stream broke into".

Cross-checking against netem, as the review asked, is impossible in this environment: the kernel is built without CONFIG_NET_SCH_NETEM, and tc qdisc add ... netem answers Error: Specified qdisc kind is unknown. Privileges have nothing to do with it — the queuing discipline simply is not in the kernel. So the cross-check had to be against what was available: measuring the instrument itself.

The first thing it showed was that the suspicion was aimed at the wrong thing.

measured observationbench/stretched-cache/relaycheck.py, Redis 7.0.15. There is no distance in any of these rows: the link delay is zero throughout, and what differs is a single socket flag. That is exactly why the rows are comparable.
SET + WAIT 1, no distance involvedordinary write, mswith WAIT 1, ms
no link at all0.200.42
link at 0 ms, without TCP_NODELAY (the defective rig)0.2444.00
link at 0 ms, with TCP_NODELAY (the corrected rig)0.180.70

There is the origin of the "42–44 ms term" the WAIT block found and could not explain. It belongs entirely to the measuring instrument. Redis sets TCP_NODELAY on its own connections; the relay creates two new connections, and the flag was not set on those. From there the familiar pair takes over: Nagle's algorithm holds a small chunk back until the previous one is acknowledged, and the delayed acknowledgement is held for up to forty milliseconds. Hence the value of just over forty, its independence from distance, and the fact that it only appeared where several small messages travelled the link in succession.

Now the review's original objection, also in numbers. How many times the delay is applied per operation:

measured observationbench/stretched-cache/relaycheck.py, Redis 7.0.15. The chunk counter sits inside the relay itself; an idle control window separates replication's own housekeeping traffic from ours. The number of chunks per operation is not guaranteed: TCP preserves no message boundaries, and adjacent messages may merge or split.
serializing link, 20 ms one way, 20 × SET+WAITchunks
master → replica, idle window0
replica → master, idle window1
master → replica, under load40
replica → master, under load21
above background, total60
per operation3.00
delay applied, ms per operation60.0
measured, ms per operation61.81

Three chunks per operation: the write itself, the acknowledgement request, and the acknowledgement. But only two of them are on the critical path — the first two travel in the same direction and could have left together. With a serializing relay they do not: while the thread sleeps it does not read the next chunk. The cost of that is measured directly:

measured observationbench/stretched-cache/relaycheck.py, Redis 7.0.15, both links with TCP_NODELAY. The pipelined relay stamps each chunk with a delivery deadline and keeps reading; the serializing one sleeps between read and send on one thread. That is the only difference between them.
one wayround tripserializingpipelineddifference
5 ms10 ms16.06 ms11.06 ms+5.00
20 ms40 ms61.64 ms41.48 ms+20.16
40 ms80 ms121.86 ms81.36 ms+40.51

The review was right: the serializing link costs exactly one extra one-way flight at every distance. Not some arbitrary amount — precisely the delay it applies to a chunk.

And here is the part more instructive than the error itself. While the forty- millisecond stall sat in both links, this difference was invisible: it drowned in an artefact four times its size. Two defects of the rig were masking each other. Finding either one by staring at the resulting milliseconds was impossible — only taking the instrument apart did it.

Last comes the calibration, which is also the boundary of what the rig is good for:

measured observationbench/stretched-cache/relaycheck.py, Redis 7.0.15. On plain request/response both relays give the same thing: one chunk out, one chunk back, nothing to stack up.
one wayround tripserializingpipelined
direct GET0.10 ms0.10 ms
5 ms10 ms10.81 ms10.79 ms
20 ms40 ms40.98 ms41.00 ms
40 ms80 ms81.10 ms81.11 ms

How to read the rest of the article after this. Where one chunk travels in each direction per operation — the latency run and the replication-visibility run — the relay yields exactly the round trip, and the numbers can be taken as they are. Where several messages travel in succession — replication under load, command pipelining, WAIT — the instrument was adding something of its own, and both terms have now been measured and removed. A third kind of error remains and cannot be removed here: the relay has no queues, no loss, no jitter and no bandwidth limit. It is an approximation of delay, not a packet-level network emulator, and absolute milliseconds on streaming operations should be read that way.

The qualitative conclusions — sign, ratio, the order of events, which half of the cluster answered — do not depend on any of this.

Difference three: "stretch" means different things

That leaves the main one — the first way, "nodes split evenly". And here the analogy breaks not in the pricing but in the meaning of the word.

Take the configuration people usually mean when they say "the database is stretched": an asynchronous hot standby of Postgres in another site. It is a separate system: read only, lagging, and the application knows it. It does not vote and does not decide the fate of the first one; when the link goes down the master keeps working and the replica keeps lagging until the link comes back.

The caveat here is mandatory, or the section commits the very mistake the article argues against. "The database is stretched" is not a description of an architecture: Postgres also has synchronous replication, in which a commit waits for the remote standby (if the standby is the last one in a synchronous group, setting this to on will result in commits waiting for the standby to confirm receipt), and there are databases that use a quorum. What has to be compared is not "a database" against "a cache" but one specific replication model against another specific replication model — with answers about synchrony, about who decides on a failover, and about behaviour under a partition. What is compared below is an asynchronous Postgres replica against a Redis cluster, and both ends are named for a reason.

A Redis cluster across two sites is one system, making decisions by majority vote. The condition under which it survives a break is written into the specification, and it contains two requirements, not one:

Redis Cluster is able to survive partitions where the majority of the master nodes are reachable and there is at least one reachable replica for every master node that is no longer reachable.

Redis — Redis cluster specification

Everyone remembers the first requirement — "you need a majority". The second one gets skimmed, and this article is essentially about it. Valkey words it the same way: Valkey Cluster is able to survive partitions where the majority of the primary nodes are reachable and there is at least one reachable replica for every primary node that is no longer reachable.

Before getting to the even split, it is worth looking at the layout that seems the most cautious: all masters in one site, all replicas in the other.

measured observationbench/stretched-cache/cluster.py, Redis 7.0.15, six nodes, cluster-node-timeout 2000 ms, eight seconds allowed for elections after the break. Each half is observed on its OWN fresh cluster with the same topology: two independent experiments rather than two checks in a row. The port numbers are random and differ in every run; what is read is the roles and the state column.
sitenodecluster stateGET returned
site 1 — three masters46073okthe value
38365okhanded the slot to another node
48713okhanded the slot to another node
site 2 — three replicas (own cluster)47655failCLUSTERDOWN
34721failCLUSTERDOWN
52771failCLUSTERDOWN

Neither half changed roles during the break, and on both clusters every replica had master_link_status: up before the link went down — so nothing was blocked by staleness. That detail matters two sections down.

Site 1 works, site 2 answers nothing at all. The replicas did not become masters: promotion needs the vote of a majority of masters — Once the replica receives ACKs from the majority of masters, it wins the election — and every master stayed on the far side of the break.

That is the first result worth taking away: the second half is not a spare. If the first one is lost, it will not replace it — not on its own, at any rate.

The culmination: both halves failed

Now the layout the whole idea is built around. Six nodes, three in each site, the masters split two and one. On a whiteboard it looks symmetric, and it is the symmetry that convinces.

The map the run printed before cutting the link, on the cluster where site 1 was the half under observation:

site 1 — 2 masters of 3site 2 — 1 master of 3
52467: master35445: master
45191: master32777: replica of 45191
54701: replica of 5246738349: replica of 35445
measured observationbench/stretched-cache/cluster.py, Redis 7.0.15. Two independent experiments on two fresh clusters with identical topology. This is deliberate: if both halves are checked in turn on one cluster, a promotion during the first check changes the topology for the second, and the second answer then belongs to a different cluster.
sitenodecluster stateGET returned
site 1 — 2 masters52467failCLUSTERDOWN
45191failCLUSTERDOWN
54701failCLUSTERDOWN
site 2 — 1 master (own cluster)49019failCLUSTERDOWN
40491failCLUSTERDOWN
48663failCLUSTERDOWN

The cache was gone everywhere. In neither of the two sites, for no key at all.

Before taking apart why, it is worth saying how this result was obtained, because the method was corrected after the review. Previously both halves were checked in sequence on one cluster: stop the far one, poll the near one, resume, stop the near one, poll the far one. That cannot be done. After the first check the topology need not return to its original shape: a replica may have been promoted, the configEpoch may have changed, slots may have moved — and the second half would then be measured on a different cluster. Two checks in sequence are not two sides of one partition. Now each side is observed on its own fresh cluster with the same, hand-specified topology.

Why site 2 lost is the expected part. It holds one master out of three, it has no majority, and the specification leaves no room: Redis Cluster is not available in the minority side of the partition. That is how it should be: if a minority kept serving writes, the cluster would drift apart into two diverging data sets.

Site 1 is the interesting one. It holds the majority of masters — two out of three. It has enough votes to promote a replica. And it answers CLUSTERDOWN all the same.

The reason is visible on the map above, and it is worth tracing with a finger. The master left in site 2 is node 35445. Its replica — node 38349 — stayed in site 2 as well. So there is nobody to promote in site 1: that master's slots are covered by no reachable node. This is precisely the second requirement from the specification, the one that gets skimmed: a reachable majority is not enough, you also need a reachable replica of every lost master.

Then the setting that turns the failure total rather than partial kicks in:

If this is set to yes, as it is by default, the cluster stops accepting writes if some percentage of the key space is not covered by any node. If the option is set to no, the cluster will still serve queries even if only requests about a subset of keys can be processed.

Redis — Scale with Redis Cluster, cluster-require-full-coverage

This invites the conclusion that the observed behaviour is "wider than the documented one": the documentation talks about writes, while a GET also came back CLUSTERDOWN. The conclusion is wrong. Reads are governed by a different setting, and that one is documented too — cluster-allow-reads-when-down, no by default. The chain is this: slot coverage incomplete → the cluster is marked as failed → a node of a failed cluster does not serve reads either.

This is checked with the same layout, run twice.

measured observationbench/stretched-cache/readsdown.py, Redis 7.0.15. The same even split and the same break, polling the half that holds the majority of masters; one setting differs between the two runs. Twenty keys rather than one because keys are spread across slots by hash, and a single key cannot tell you what happens to the rest of the key space.
settingnoderolecluster state20 keys read
cluster-allow-reads-when-down no (default)43151masterfail20 CLUSTERDOWN
47729masterfail20 CLUSTERDOWN
35021replicafail20 CLUSTERDOWN
cluster-allow-reads-when-down yes54835masterfail5 answered, 15 redirected
46259masterfail9 answered, 11 redirected
48093replicafail20 redirected

A SET on the same node came back CLUSTERDOWN in both runs.

And here is what actually follows from it — more interesting than the corrected mistake. With yes the node answers only for those keys whose slots stayed with it: out of twenty, the surviving half returned fourteen, and redirected the rest to the master that is now behind the severed link. Writes are refused either way.

So the setting does not bring the cache back. It turns "nothing works" into "some reads work on part of the key space", and a third of the keys — the slots of the master behind the break — are unreachable whatever its value.

The rule the measurement was set up for. A majority of votes is necessary but not sufficient. The full condition has three parts, and the third is usually not stated at all:

a majority of masters on your side
  + a reachable replica of every lost master
  + that replica fresh enough to be allowed to stand

The third part is not a quibble: a replica's freshness is governed by cluster-replica-validity-factor, and a replica lagging beyond the allowance will not start an election. It did not get in the way in the runs above — every replica had master_link_status: up before the break, which the run prints — but the specification's condition is incomplete without it, and relying on two parts out of three means checking the wrong thing.

Under an even split the condition does not always hold — and, more to the point, you cannot check it by eye on a diagram of three boxes: a diagram shows how many nodes there are, not whose replica sits where. The cluster assigns replicas itself, at creation and after every subsequent failover.

And this is where the central conclusion comes from. Stretching is done for symmetry — "so that both survive". Symmetry is exactly what kills it: in an evenly split cluster the survival condition holds for nobody, and at the moment it matters the cache turns out to be missing in both sites at once.

The layout that survives, and what it actually chooses

The condition can be satisfied by hand: keep in site 1 not its own replica, but the replica of the master that is going to site 2.

The map after the rearrangement — one replica moved, nothing else:

site 1 — 2 masters and the third one's replicasite 2 — 1 master and two foreign replicas
46819: master53509: master
40665: master39129: replica of 46819
36267: replica of 5350947199: replica of 40665
measured observationbench/stretched-cache/cluster.py, Redis 7.0.15, the same split of nodes as in the previous run — only the ownership of the replicas changed. The 'what changed in the roles' line is printed on purpose: it shows the promotion rather than letting it be assumed. The other half runs on its own cluster; see the recorded run.
sitenodecluster stateGET returned
site 1 — 2 masters + the third one's replica46819okthe value
40665okhanded the slot to another node
36267okhanded the slot to another node
site 2 — 1 master (own cluster)47073failCLUSTERDOWN
40639failCLUSTERDOWN
34151failCLUSTERDOWN

Now site 1 survives: it had the votes and it had someone to promote. The run states it outright — node 36267: replica of 53509 -> master. The replica brought home took over the lost master's slots, and all three nodes came back to state ok.

But symmetry did not appear. There is still no cache in site 2, and that is not a defect of the layout but its point. What a layout like this chooses is which data centre survives the break.

The boundary of that conclusion is worth drawing precisely, or it turns into a slogan. The claim is not "both data centres cannot work"; it is this: in one ordinary Redis cluster you cannot get a symmetric partition in which both halves independently keep accepting writes and are then merged back automatically. The reason is not the layout but the fact that the cluster is one and the decision it takes is one.

Other outcomes are possible — it is just that they are no longer "one stretched cluster": two independent caches, read-only operation in a reduced form, a product with active-active and an explicit conflict resolution model (Conflict resolution is handled by conflict-free replicated data types (CRDTs)), conflict resolution on the application side. Each has its own price, and this article measured none of them.

The fragility of the arrangement is visible in passing. Between the two runs there is a single replica swap. It is written down nowhere as a requirement, is not checked at startup, and does not survive an automatic failover: after the first master failure the roles are redistributed, and the "correct" layout silently becomes the one that failed in both halves.

The same claims on current builds

Every number above was taken on Redis 7.0.15. That branch is old: by September 2026 the current Redis Open Source line is 8.10.x and Valkey has moved to 9.1. The cluster implementation changed noticeably between Redis 7 and 8, and "it matched once" does not make a property invariant. So the same claims were re-run on all three builds — not performance, but the claims themselves.

measured observationbench/stretched-cache/versions.py, link between sites 20 ms one way, one host. Redis 8.10.1 and Valkey 9.1.2 built from source. The columns are not required to agree on numbers; what carries meaning is the rows that state properties. The 'lost out of 200' row swings from run to run within every column and is not comparable across them.
Redis 7.0.15Redis 8.10.1Valkey 9.1.2
version7.0.158.10.19.1.2
ordinary write, ms0.170.180.18
master waits for the replicanonono
visible on the replica after, ms21.421.921.1
WAIT 1, ms41.4641.4841.29
WAIT / round trip1.041.041.03
lost out of 200 on a cut72106116
even split: state of the majority halffailfailfail
even split: any GET answerednonono
reads-when-down no: keys served out of 60000
reads-when-down yes: keys served out of 60151515

Not one claim in this article diverged: the master waits for the replica nowhere, WAIT costs a round trip everywhere, the half holding the majority of masters fails entirely everywhere, and cluster-allow-reads-when-down decides the fate of reads everywhere. This also closes the question of Valkey: the article used to rest on its documentation and say honestly that nothing had been measured on the fork. Now it has been.

What this table does not do. It does not carry the numbers of the earlier runs over to the new versions, and it does not replace them. The published baseline stays on 7.0.15; the table answers a different question — whether the behaviour has drifted.

What to do instead

The analysis is useless without this section, so here it is by points. The first answer is a good default, not the only one: the cases where people choose otherwise are listed at the end of the section.

A separate, independent cache in each site. Not a replica, not half a cluster — its own installation, filled by its own application from its own source. A link failure is then not an event for the cache: both sites carry on, each with its own data.

The caveat matters, and without it this becomes a slogan. Independent regional caches remove the WAN dependency from the path to the cache, but they do not make the application as a whole independent of the inter-site link. If writes still go to a database master in the other site, a break is still an event for the application — it has merely stopped being an event for the cache. Those two availabilities have to be separated explicitly, or "an independent cache" turns into a promise it never made.

The price is extra misses. The first request for a key misses in site 1 and goes to the source; the first identical request in site 2 misses again. But this has to be stated carefully: in an ideal steady state, with no eviction and no restarts, the minimum extra cost is one fill of every hot key in every region. That is a lower bound, not an estimate.

The real load on the source is set not by the arithmetic of keys but by eviction, restarts, failovers, resharding, deployments, key version changes, invalidation, cold starts and traffic being moved between sites. Every one of those events refills the cache, and now it does so in each data centre separately. Hence something a single shared cache did not have: two independent stampede domains, two different hit rates and two sets of graphs to watch.

Divergence between sites is acceptable and is managed — but not by a TTL alone. Two independent caches will inevitably diverge, and the question is not "how do we avoid this" but "how many seconds may we diverge by". A TTL is the most direct knob here, which is exactly why it is easy to mistake for the whole answer — "a TTL just is the budget for inconsistency, named in seconds". That is wrong, and here is what the observed divergence is actually made of:

  • the lag of the source itself. If each site has its own read replica of the database, its lag sits underneath the divergence of the caches rather than in place of it;
  • the invalidation delay — how long the "this key changed" event takes to reach the second site, if it travels at all;
  • the freshness policy — the TTL proper, and with it its relatives: early refresh, sliding expiry, serving a stale value while it is recomputed;
  • the aftermath of failures and recovery — after a failover or a traffic move, the cache in one site is cold while the other is warm.

A TTL is one term out of four. What deserves the name "budget" is the sum, and it has to be worked out from your own topology rather than from this article.

Synchronous cross-site invalidation is an expensive deal, and a TTL is not what settles it. The temptation is there: we deleted a key here, delete it there too, and wait for confirmation. That brings back the round trip we have just walked away from, and brings it back into the hot path of a write. For a disposable cache the deal is almost always bad: locality was the whole reason for splitting the cache by region in the first place.

But if waiting really is required, the conclusion "then the TTL was chosen wrong" is too narrow, and wrong by more than a nuance. A requirement that a write be acknowledged only once another region can see it is a requirement on the consistency model of the cache layer, not on the lifetime of a key. A TTL will not deliver it: you need a different model (versioning, routing a reader back to the same region, bounded staleness with an explicit bound), or the cache has to come out of the correctness path. A TTL governs how far you may diverge, not whether you may diverge at all.

And WAIT is not meaningless for a cache — it just rarely pays off. The blunt form — "WAIT is meaningless for a cache" — is an error of the same kind as the previous two. For a cheaply rebuilt cache, paying a round trip on every write really is hard to justify: losing an entry is by design. But caches differ. If warming takes hours, if the source will not survive a cold-start stampede, if behind it sits a rate-limited external API or a heavy recomputation — then replication durability acquires a value, and it has to be compared not against zero but against the cost of refilling and the acceptable loss window. An industry account of the same trap puts it without hedging: a cache can be a big and highly damaging blast radius. Note that this very article later defends a remote replica kept for a warm cache: same argument, different instrument.

The source of truth need not be a database, but there has to be one. The circulating form — "anything that cannot be recomputed from the database has no business being in the cache" — is too narrow for a systems discussion. The rule is wider: a disposable cache must be recoverable from an authoritative source or from a repeatable computation. That source may be a database, an object store, an event log, an external API, another service, or a canonical computation result. Which one does not matter; what matters is that it exists and that the path back is known.

A shared cache is still a cache, and "shared" is not evidence. The circulating rule goes: "if the cache has to be shared, then what is in it is not a cache". That is not so. A shared external cache that several applications talk to is a normal and common arrangement: it takes load off the source better than a set of in-process caches, it removes divergence between application nodes, and it survives a deployment of the application without starting cold.

The red flag is not the word "shared" but irrecoverability. A cache stops being a cache when losing an entry turns not into a miss but into a loss of state: the only copy of a session, a lock, a counter, process state, data that exists nowhere else. The test for this is simple and needs no argument about terminology: delete the cache entirely and see what happened. It got slower and the data came back — that is a cache, and there is no reason to stretch it across sites. Money, sessions and locks were lost — that is not a cache but a state store, and the conversation about it is a different one; it needs an article of its own, and it will get one.

Six layouts side by side, and what they cost

Everything above takes apart three ways to stretch a cache and one way not to. There are more, and holding them in your head as a list is awkward — so here they are together. The table is not a measurement: it contains no numbers, and cannot, because three of its six rows were never measured by anyone. It is a map for deciding what to measure next.

model with assumptionsThe properties are stated qualitatively and derived from how each arrangement is built, not measured. Three rows are covered by this article's runs: one cache across two sites, a cluster across two sites, and master plus replica in the other site. The other three are listed for completeness and were not checked. RPO and RTO here are likewise properties rather than measured values: they depend on your write rate and on how long your cache takes to warm.
LayoutLocal latencyWhen the link breaksRPO of cache contentsRTO: warm again
One cache across two sites, both talk to itpoor on the far side (measured)the far side loses the cachenot applicable: one copyimmediately, if the near side lives
Redis cluster across two sitesmixeddepends on quorum and slot coverage (measured)the shard replica's lagafter elections, if there was someone to promote
Master here, replica in the other sitegood on the master's sidemanual failover plus a loss window (measured)the replication lag at the moment of the breakthe time it takes to switch by hand
A separate cache in each sitegoodthe cache survives the breakloss is disposable by constructionwarming your own region
In-process cache plus a regional onebestisolation by regiontwo layers, both disposablewarming two layers
Active-active with conflict resolutiongoodwhat it is built forproduct- and model-specificlocal availability is not lost

Read this table not as a ranking but as a list of questions to ask about your own topology. In this order: what exactly is cached and can it be lost; what does the source operation the cache replaces cost; how far away is the source; how much divergence is acceptable; what has to survive a break; how long does the cache take to warm again. The answers pick the row — not how the arrangement looks on a diagram.

When a stretched cache is nonetheless defensible

This article argues against a stretched cache, not against every case of one. There are four in which a stretched configuration is defensible — and all four rest on the same measurements.

When one designated site has to survive the break, not both. The layout in the last run works: site 1 answered on all three nodes. If you have a primary site and a standby rather than two equals, "the cluster survives in the primary" is a legitimate goal. But on three conditions: you must understand that you are picking the winner in advance; that there will be no cache in the standby site; and that the replica layout has to be re-checked after every failover, because the cluster arranges it itself.

When a replica in the other site is there not to serve traffic but to avoid filling the cache from scratch. A large warm cache is hours of work and a noticeable load on the source at a cold start — which is what the warning that a service is likely to be unable to handle the resulting load on its dependencies is about. A replica promoted by hand after an incident removes that load. The terms are honest and both were measured: the replicas do not promote themselves (three nodes in state fail, zero masters) and a promotion loses whatever did not arrive — at risk is the lag at the moment of the break. For a cache the second is acceptable: the lost entries get recomputed on a miss. Which is exactly why this holds only for a cache, and only if you accept switching over by hand.

When the write rate is sparse. The second row of the failover run is not a caveat but a separate result: after one second of silence all two hundred writes were on the replica, none lost. The offsets run shows the same thing again and more precisely: what is dangerous is not the volume of data but the lag the write rate manages to create. If the replica keeps up on average, a promotion has almost nothing to lose — and you can check that from the difference of offsets instead of guessing.

When the source operation costs more than the round trip. The latency run compares a cache in the other site against the cheapest thing a database can do: a read by primary key out of a warm table. For an aggregate that takes a hundred milliseconds the comparison is a different one, and a cache at forty milliseconds wins it. The rule from that measurement is stated without numbers and works both ways: going to the cache has to cost less than the source operation it replaces, at this distance. No numbers for expensive operations were taken here and quoting any would be dishonest; but neither does this measurement give any right to forbid such a cache.

What all four have in common is what is absent from them. Not one of them promises that both halves of one cluster independently keep accepting writes. As soon as the word "both" appears in the requirement, nothing on this list will do, and the choice has to come from elsewhere: a separate cache in each site, a layered cache, or a product with active-active and an explicit conflict model.

How to reproduce the numbers

Eight scripts, all against a live Redis, all printing what the processes returned.

python3 bench/stretched-cache/replication.py   # master and replica across sites
python3 bench/stretched-cache/cluster.py       # three cluster layouts
python3 bench/stretched-cache/latency.py       # cache in the other site vs local database
python3 bench/stretched-cache/readsdown.py     # reads in a failed cluster
python3 bench/stretched-cache/offsets.py       # the loss window and the offsets
python3 bench/stretched-cache/waitcost.py      # the price of WAIT at three distances
python3 bench/stretched-cache/relaycheck.py    # the measuring rig itself
python3 bench/stretched-cache/versions.py      # the same claims on current builds

redis-server is needed on the PATH, plus redis-cli for the cluster run and a running PostgreSQL for the latency one (address in DE_TEST_DATABASE_URL). A different build can be pointed at through REDIS_SERVER_BIN and REDIS_CLI_BIN — that is how the version table was taken. The scripts leave nothing behind: nodes come up in temporary directories and are killed in finally.

The limits of the rig, stated outright

The delay is produced by a relay in user code, not by netem. The relay adds delay to every chunk recv() returns, and TCP preserves no message boundaries. This is a reproducible approximation of latency, not a packet-level network emulator: it has no queues, no loss, no jitter and no bandwidth limit. Plain request/response measurements can be read directly — the calibration block shows the relay yields exactly the round trip there. Streaming replication and WAIT would ideally be confirmed separately through netem or network namespaces; in the environment this article was measured in, the kernel is built without CONFIG_NET_SCH_NETEM and there was nothing to do it with. Instead the instrument itself was measured, and two of its defects were found and removed.

The break in the cluster run is portrayed with STOP. The process stays alive and the port stays open, but the node neither answers nor sends heartbeats: to the remaining half it is indistinguishable from a node behind a severed link. Killing them was not an option — the second half had to be polled too. Each side is observed on its own fresh cluster with a hand-specified topology, so the aftermath of the first check cannot reach the second; a real network partition through firewall rules or network namespaces would be more accurate still.

The relay has a cut() switch, and it is not decorative: a break between data centres is not "it got slow" but "it stopped going through at all", and portraying it as increased latency would be wrong.

The version boundary

The core properties are checked against the current Redis and Valkey documentation. The numbers of the main run were taken on Redis 7.0.15 and kept as a reproducible baseline. Redis 8.x developed the cluster implementation considerably, so the key claims — replication, WAIT, the even split, cluster-allow-reads-when-down — are additionally re-run on Redis 8.10.1 and Valkey 9.1.2, and are not treated as invariant merely because they matched once.

The published run: Redis 7.0.15, PostgreSQL 16.13, one host, link 20 ms one way — except for the runs where the distance is the variable of the experiment. Twenty milliseconds were chosen not to make things look worse but as an ordinary distance between data centres within one country.

Three of these scripts appeared after the first review of this article: they check exactly the three places where a conclusion had been drawn from a single point — reads in a failed cluster, the loss window, and the price of WAIT. Two more appeared after the second review, and they check not Redis but the measurement. Four of those five checks found an error, and all four corrections stand in the text where they belong rather than as an afterword.

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and how to read it.

Corrections

12 September 2026

Collected here is what this material got wrong, and what settled it. Every entry rests on a run or on a document rather than on our having changed our minds.

Was

"At risk is everything written during the last round trip before the break."

Now

At risk is whatever lies, at the moment of the break, between the master's master_repl_offset and the replica's offset. The round trip only sets a lower bound on how quickly the lag can fall to zero.

What settled it

Three write rates over ONE and the same link: in a burst — 104 writes lost at a lag of 3548 bytes; with a 5 ms pause — 3 writes at 99 bytes; after a second of silence — none at zero lag. The round trip is identical in all three; the loss is not (bench/stretched-cache/offsets.py).

Was

Beyond the round trip, WAIT showed "an addend of 42–44 ms, the same at every distance", and its origin was declared unestablished.

Now

There is no addend. It belonged to the instrument: the link between sites is played by a relay in user code, and TCP_NODELAY was not set on its two sockets. The pair "Nagle's algorithm plus delayed acknowledgement" added exactly forty milliseconds.

What settled it

An experiment with no distance at all: over a link with ZERO delay a write with WAIT 1 cost 44.00 ms without the flag and 0.70 ms with it. A second defect of the same rig turned up separately — serialising the stream added one more one-way flight, and it became visible only once the four-times-larger stall was removed (bench/stretched-cache/relaycheck.py).

Was

The cluster's refusal of reads was described as behaviour "wider than the documented one": the documentation talks about writes, while a GET also came back CLUSTERDOWN.

Now

Not wider. Reads are governed by a separate setting, cluster-allow-reads-when-down, no by default, and it is documented too. The chain: slot coverage incomplete → the cluster is marked as failed → a node of a failed cluster does not serve reads either.

What settled it

Measured on both settings: with no, all 20 keys out of 20 get CLUSTERDOWN on every node of the surviving half (bench/stretched-cache/readsdown.py).

Was

Both halves of the split cluster were checked in turn on one and the same cluster.

Now

Each half is checked on its own fresh cluster with identical topology.

What settled it

A promotion during the first check changes the topology for the second, so the second answer belongs to a different cluster. This is a correction of METHOD rather than of a number: the conclusion did not change afterwards, but before it rested on an invalid experiment (bench/stretched-cache/cluster.py).

Was

"If the cache has to be shared, then what is in it is not a cache."

Now

A shared external cache is a normal arrangement: it takes load off the source better than a set of in-process caches, removes divergence between application nodes, and survives a deployment. The red flag is not "shared" but irrecoverability.

What settled it

The test that separates the two cases is in the text: delete the cache entirely. It got slower and the data came back — that is a cache. Sessions, locks and counters were lost — that is a state store, and it is a different conversation.

Was

"A TTL is the budget for inconsistency, named in seconds."

Now

A TTL is one term of three. Underneath the observed divergence also sit the lag of the source itself and the delay of invalidation, and a TTL shortens neither.

What settled it

The source's lag was measured separately: a replica in the other site sees a write after 20.8 ms against 1.1 ms in its own (bench/stretched-cache/replication.py). That term sits under the divergence of the caches whatever TTL is chosen.

Was

"Anything that cannot be recomputed from the database has no business being in a cache."

Now

A disposable cache must be recoverable from an authoritative source or from a repeatable computation. The authoritative source may be a database, an object store, an event log, an external API, another service, or the canonical result of a calculation.

What settled it

This is a narrowing of wording rather than a measurement: the earlier form excluded arrangements that work — a cache in front of an external API, for one. What matters is not where an entry is recovered from but that the path back exists and is known.

Common misconceptions

Claim

Our database is stretched across two sites and it works — so the cache can be too

Actually

The analogy breaks in three places at once. The jobs differ: a database is the source of truth, for which a round trip between sites is worth paying, while a cache exists so that getting the same data again costs LESS than going to the source. "Less" covers latency, load taken off the source, a recomputation not performed, a quota on an external call; the property is comparative either way. One side was measured — latency — on the case least favourable to a cache: 41.09 ms in the other site against 0.09 ms to the database at hand. The cost of a loss differs: losing a database row is an incident, losing a cache entry is a miss the design already expects. And the word "stretch" itself means different things: an asynchronous Postgres hot standby is a separate lagging system, while a Redis cluster across two sites is one system making decisions by majority vote. What has to be compared is one specific replication model against another — Postgres also has synchronous replication, and there are databases that use a quorum.

Claim

Split the nodes evenly between the sites and we survive the loss of either one

Actually

The measurement shows the opposite: when the link went down, BOTH halves failed. Six nodes, three per site, masters split 2 and 1 — and all six answered CLUSTERDOWN. Each half was checked on its own fresh cluster with the same topology rather than by two checks in a row on one: the aftermath of the first check could not reach the second. Site 1 held the majority of masters and had votes enough to promote, but the replica of the master left in site 2 stayed in site 2 — there was nobody to promote. The specification names two requirements, not one: the majority of the master nodes are reachable and there is at least one reachable replica for every master node that is no longer reachable. A majority is necessary but not sufficient — and the full condition has not two parts but three: the third is that the replica must be fresh enough, or cluster-replica-validity-factor will not let it start an election.

Claim

The replicas in the second site are a spare cluster: if the first site dies, they take over

Actually

They do not. In the layout "all masters in site 1, all replicas in site 2", after the break site 2 held zero masters out of three nodes, all three were in state fail, and all three answered CLUSTERDOWN to a GET. A replica needs the vote of a majority of masters to be promoted — Once the replica receives ACKs from the majority of masters, it wins the election — and every master stayed on the far side of the break. The second half is not a spare: if the first is lost, it will not replace it, at least not on its own.

Claim

The correct replica layout can be checked by eye on a diagram

Actually

It cannot: a diagram shows how many nodes sit in each site, not whose replica sits where. Between the run where both halves failed and the run where site 1 survived, the difference is one replica swap. It is written down nowhere as a requirement, is not checked at startup, and does not survive an automatic failover: after the first master failure the cluster redistributes the roles itself, and the "correct" layout silently becomes the failing one.

Claim

WAIT makes replication synchronous and closes the question of losses

Actually

Measured: an ordinary write 0.24 ms, a write with WAIT 1 41.73 ms — 171.6 times more expensive. The price is EXACTLY ONE round trip to the replica: at 5, 20 and 40 ms one way it comes to 10.90, 41.52 and 81.40 ms, and the ratio to the round trip is 1.09, 1.04 and 1.02. The property bought is weaker than expected: by the time it replies, the write on the master has been applied and is visible to readers, with nothing to roll it back. The documentation says so outright: it does not turn a set of Redis instances into a CP system with strong consistency: acknowledged writes can still be lost during a failover. For a cheaply rebuilt cache the deal usually does not pay off: it spends a round trip on every write to protect data whose loss is by design. But not "meaningless": if warming the cache takes hours or the source will not survive a cold-start stampede, replication durability has a value, and it has to be weighed against the cost of refilling and the acceptable loss window.

Claim

With slots left uncovered, only the missing keys stop working

Actually

The refusal is total: in the run a plain GET came back CLUSTERDOWN on every node of the surviving half, not only for the lost master's slots. This invites the conclusion that the observed behaviour is "wider than the documented one". It is not: cluster-require-full-coverage governs writes (If this is set to yes, as it is by default, the cluster stops accepting writes if some percentage of the key space is not covered by any node), while reads are governed by a separate setting, cluster-allow-reads-when-down, no by default — and it is documented as well. The chain is: slot coverage incomplete → the cluster is marked as failed → a node of a failed cluster does not serve reads either. Measured on both settings: with no, all 20 keys out of 20 come back CLUSTERDOWN on every node.

Claim

I will set cluster-allow-reads-when-down yes and the cache will survive the break

Actually

It will not. With yes a node answers only for THOSE keys whose slots stayed with it: out of twenty keys the surviving half returned fourteen (5 on one master, 9 on the other) and redirected the rest to the master that is now behind the severed link — where the client will not get through. The replica in the same half redirected all twenty. Writes are refused at either value of the setting. So yes turns "nothing works" into "some reads work on part of the key space", and a third of the key space — the slots of the master behind the break — is unreachable whatever the value.

Claim

A 20 ms replica lag is a trifle — a cache can live with that

Actually

It can, for as long as the replica stays a replica. The lag turns into loss at exactly the moment the replica is kept for: a promotion. Measured: a break immediately after a burst of two hundred writes lost 93 of them (46.5%), and not one returned an error to the client — all of them got OK. A break after one second of silence lost nothing. The circulating rule "at risk is everything written during the last round trip" is wrong: the round trip coincided with the loss by accident. On one and the same link three write rates give 104, 3 and 0 lost writes at lags of 3548, 99 and 0 bytes. What is at risk is whatever lies between the master's master_repl_offset and the replica's offset at the moment of the break; Redis reports both in INFO replication. The round trip only sets a lower bound on how quickly the lag can come down to zero.

Claim

A shared cache across two sites is needed because the application runs in both

Actually

An application in two sites is an argument for two caches, not for one shared one: half the requests would travel to the other site and pay the round trip. But "shared" is not itself the evidence, and the circulating rule "if the cache has to be shared, then what is in it is not a cache" is wrong. A shared external cache that several applications talk to is a normal arrangement: it takes load off the source better than a set of in-process caches, removes divergence between application nodes and survives a deployment. The red flag is not "shared" but IRRECOVERABILITY. The test is simple: delete the cache entirely and look. It got slower and the data came back — that is a cache, and there is no reason to stretch it across sites. Sessions, locks and counters were lost — that is a state store, and a different conversation.

Claim

If a number was measured, it measures what we meant to measure

Actually

Not necessarily, and this article is its own counter-example. The price of WAIT carried "a term of 42–44 ms above the round trip, the same at every distance", and its origin stayed unestablished for a long time. The origin turned out to lie not in Redis but in the measuring instrument: the inter-site link is portrayed by a relay in user code, and its two connections had no TCP_NODELAY. The pair of Nagle's algorithm and delayed acknowledgement was adding exactly forty milliseconds to every operation. This is checked by an experiment with no distance in it at all: through a link with ZERO delay, a write with WAIT 1 cost 44.00 ms without the flag and 0.70 ms with it. What makes it more instructive still is that there were two defects and they masked each other: the relay also serialised the stream, adding one more one-way flight, and that became visible only after the stall four times its size was removed.

Check yourself

Question 1 of 6

A six-node cluster is split evenly: site 1 has two masters and one replica, site 2 has one master and two replicas. The link between the sites is cut. What happens to site 1, which holds the majority of masters?

Sources & further reading

10 SOURCES

  1. Redis — Redis replicationOfficial documentation. The replication contract, stated outright: «Redis uses by default asynchronous replication, which being low latency and high performance, is the natural replication mode for the vast majority of Redis use cases». From the same page, on the master not waiting: «So the master does not wait every time for a command to be processed by the replicas, however it knows, if needed, what replica already processed what command». And the caveat about WAIT, for which this page is cited a third time: «However WAIT is only able to ensure there are the specified number of acknowledged copies in the other Redis instances, it does not turn a set of Redis instances into a CP system with strong consistency: acknowledged writes can still be lost during a failover, depending on the exact configuration of the Redis persistence».https://redis.io/docs/latest/operate/oss_and_stack/management/replication/
  2. Redis — Redis cluster specificationOfficial documentation. The condition under which a cluster survives a partition, and it carries two requirements rather than one: «Redis Cluster is able to survive partitions where the majority of the master nodes are reachable and there is at least one reachable replica for every master node that is no longer reachable». The second requirement is the one this article is about. From the same page, the verdict on the losing side: «Redis Cluster is not available in the minority side of the partition». And the election mechanics that explain why an even split leaves nobody with enough votes: «Once the replica receives ACKs from the majority of masters, it wins the election».https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/
  3. Redis — Scale with Redis ClusterOfficial documentation. The page describing the setting that turns a partial loss into a total refusal: «cluster-require-full-coverage <yes/no>: If this is set to yes, as it is by default, the cluster stops accepting writes if some percentage of the key space is not covered by any node. If the option is set to no, the cluster will still serve queries even if only requests about a subset of keys can be processed». The same page also documents the setting that governs reads: «cluster-allow-reads-when-down <yes/no>», no by default. It is that setting, not the coverage requirement, that decides whether a node of a failed cluster answers a GET — measured here in a run of its own.https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/
  4. Redis — Active-Active geo-distributed databasesOfficial documentation. Cited so that the active-active row of the layout table does not hang without a primary source. The arrangement is described as «Active-Active databases are geo-distributed databases that span multiple Redis Enterprise Software clusters», and its conflict handling is explicit and built in: «Conflict resolution is handled by conflict-free replicated data types (CRDTs)». A different product and a different model, which this article did not measure — but after a page like that one cannot pretend that "both halves accepting writes independently" is impossible in principle.https://redis.io/docs/latest/operate/rs/databases/active-active/
  5. PostgreSQL — High Availability, Load Balancing, and ReplicationOfficial documentation. The source for the half of the comparison that used to rest on general knowledge. On asynchrony by default: «When using streaming replication, servers will typically be configured as asynchronous», and on what that implies: «If the primary server crashes then some transactions that were committed may not have been replicated to the standby server, causing data loss». So "the database is stretched" implies neither synchrony nor the absence of a loss window — which is exactly this article's claim.https://www.postgresql.org/docs/current/high-availability.html
  6. PostgreSQL — Replication configuration parametersOfficial documentation. The settings that make "a stretched database" mean quite different things: synchronous_standby_names and synchronous_commit. Of the latter the documentation says outright: «if the standby is the last one in a synchronous group, setting this to on will result in commits waiting for the standby to confirm receipt». One specific replication model has to be compared against another, not "a database" against "a cache": a single database already has several of those models.https://www.postgresql.org/docs/current/runtime-config-replication.html
  7. Amazon — Caching challenges and strategiesSource. An industry account of the very traps this article measures: «a cache can be a big and highly damaging blast radius», and separately on why a cold cache is worse than no cache: «the service is likely to be unable to handle the resulting load on its dependencies». This is also where the argument for a warm replica in the other site comes from — the one case in which this article defends a stretched arrangement.https://aws.amazon.com/builders-library/caching-challenges-and-strategies/
  8. Valkey — ReplicationOfficial documentation. The same page in the fork, word for word with the name substituted: «Valkey uses by default asynchronous replication, which being low latency and high performance, is the natural replication mode for the vast majority of Valkey use cases». And on WAIT: «However WAIT is only able to ensure there are the specified number of acknowledged copies in the other Valkey instances, it does not turn a set of Valkey instances into a CP system with strong consistency». The link is here so that the article's claims about the replication contract need not be carried over to Valkey by analogy.https://valkey.io/topics/replication/
  9. Valkey — Cluster specificationOfficial documentation. The survival condition in the fork is worded the same way, with the same pair of requirements: «Valkey Cluster is able to survive partitions where the majority of the primary nodes are reachable and there is at least one reachable replica for every primary node that is no longer reachable». And from the same page: «Valkey Cluster is not available in the minority side of the partition».https://valkey.io/topics/cluster-spec/
  10. Valkey — HistorySource. The origin of the fork as the project states it: «Valkey is a fork of the open-source Redis (REmote DIctionary Server) database created in 2009 by the Italian hacker Salvatore "antirez" Sanfilippo». Hence this article's rule: the shared inheritance lets us carry over to Valkey what its own documentation states in the same words, and does not let us carry over the numbers of a measurement.https://valkey.io/topics/history/