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 idea "stretch the cache across two data centres" rests on an analogy with the database. The database is stretched and it works, so the cache can be too. The analogy is wrong.
- 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. 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 master does not wait for the replica. With the link cut right after a
burst of writes, 93 of 200 were lost. The client got
OKfor all two hundred. - 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.
- An evenly split cluster 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.
- The measuring rig was checked too. The relay standing in for the
inter-site link had no
TCP_NODELAY, so the instrument was adding forty milliseconds of its own stall to every write withWAIT. Those forty milliseconds are exactly what was long carried here as "a term of unknown origin". - The same claims were re-run on Redis 8.10.1 and Valkey 9.1.2 — none diverged.
- What to do: a separate cache in each site. Divergence between them is acceptable and is the sum of four terms — 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:
- one cluster across two sites, nodes split evenly;
- master in one site, replica in the other;
- an application in both sites talking to one cache.
The argument under all three is the same: our database is stretched with replicas and it works, so the cache can work that way too.
It is not a foolish argument. It is simply wrong, and wrong in three separate places.
A cache and a database do different jobs
The database is the source of truth. It must not be lost, and for that it is worth paying the travel time between sites.
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 hot key shielded. Either way the property is comparative: move the cache further away than the source and it is gone.
What follows measures one of those sides — latency — on the case least favourable to a cache.
| median, ms | p95, ms | |
|---|---|---|
| cache in the same site | 0.10 | 0.14 |
| database in the same site | 0.09 | 0.13 |
| cache in the other site | 41.09 | 41.36 |
Roughly 450 times slower than the database in your own site — that is the ratio the run prints.
The third way dies here. Half the requests will travel to the other site, pay the trip there and back, and return later than a trip to the local database would have.
It does not follow from the first two rows that a cache is pointless: what was measured there is a single row read by key from a small in-memory table — the cheapest thing a database does. What does follow is a rule rather than a number: going to the cache has to cost less than the source operation it replaces, at this distance. For a cheap read by key 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 here.
Losing cache entries is normal; losing database rows is an incident
The second form: master here, replica there. The master answers the client OK
without waiting for the replica. That is not a setting but declared behaviour:
Redis uses by default asynchronous replication
.
Now promote the replica in the master's place — that is, do the thing the replica is kept for.
| 200 writes acknowledged to the client | on the replica | lost |
|---|---|---|
| link cut immediately after the writes | 107 | 93 (46.5%) |
| link cut one second after the writes | 200 | 0 (0.0%) |
Both rows have to be read together. What is lost is not "almost everything" and
not "nothing" — it is whatever did not manage to reach the replica. Not one
of the lost writes reported an error: the client got OK for all of them.
The loss window is set by the lag, not by the round trip
The experiment invites a rule: "at risk is everything written during the last round trip". The rule circulates in exactly that form, and it is wrong — here the round trip coincided with the loss by accident. This is checked by changing the write rate alone, over the very same link.
| 200 writes, then the link is cut | on the replica | lost | lag, bytes |
|---|---|---|---|
| in a burst, cut immediately | 96 | 104 | 3548 |
| 5 ms apart, cut immediately | 197 | 3 | 99 |
| in a burst, cut one second later | 200 | 0 | 0 |
The link and the round trip are the same in all three. What changes is the lag,
and with it the loss. So 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 in INFO replication.
And this is where the analogy breaks. For a database that lost tail is an incident: nobody will recompute the row. For a cache, losing an entry is routine: miss, go to the source, write back.
So the machinery a stretched database is tolerated for is not needed by default
by a cache. And it would still have to be paid for: WAIT, the command usually
proposed to "make replication synchronous", makes a write 171.6 times more
expensive — 41.73 ms instead of 0.24 ms on every write.
What that price is made of cannot be told from a single point — and the guess "two round trips" suggests itself. The same experiment at three distances answers more precisely, and answers something else.
| one way | round trip | ordinary | with WAIT 1 | WAIT / round trip |
|---|---|---|---|---|
| 5 ms | 10 ms | 0.15 ms | 10.90 ms | 1.09 |
| 20 ms | 40 ms | 0.14 ms | 41.52 ms | 1.04 |
| 40 ms | 80 ms | 0.20 ms | 81.40 ms | 1.02 |
The ratio to the round trip is one at all three distances. So WAIT costs
exactly one round trip to the replica, and grows with distance one for one.
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 of 5.20, 2.10
and 1.55, with "a distance-independent term of 42–44 ms of unknown origin" left
over. The origin turned out not to be Redis at all: the relay standing in for
the inter-site link had no TCP_NODELAY, and the pair of Nagle's algorithm and
delayed acknowledgement was adding forty milliseconds to every operation where
several small messages crossed the link in succession. The instrument was
measuring itself. The full account is in the detailed version of this article.
And WAIT does not buy a guarantee either: it reports how many replicas
acknowledged, but it cannot cancel the write — on the master it has already been
applied and is already visible to readers. For a cheaply rebuilt cache, paying a
round trip on every write is hard to justify. But if warming takes hours, or the
source will not survive a cold-start stampede, replication durability acquires a
value — and it has to be compared against the cost of refilling, not against
zero.
"Stretch" means different things for a database and a cache
An asynchronous hot standby of Postgres in another site is a separate system: read only, lagging, and the application knows it. It decides nothing on the master's behalf.
The caveat is mandatory: "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, and there are databases that use a quorum. What has to be compared is one specific replication model against another, with both ends named — below it is an asynchronous Postgres replica against a Redis cluster.
A Redis cluster across two sites is one system that makes decisions by vote. The survival condition is in the specification, and it has two requirements:
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.
Everyone remembers the first: you need a majority. The second gets skimmed. It is the second that does all the damage.
The main result: both halves failed
Six nodes, three in each site, the masters split two and one. The diagram is symmetric, and that is what convinces.
The map before the link was cut:
| site 1 — 2 masters of 3 | site 2 — 1 master of 3 |
|---|---|
| 52467: master | 35445: master |
| 45191: master | 32777: replica of 45191 |
| 54701: replica of 52467 | 38349: replica of 35445 |
| site | node | cluster state | GET returned |
|---|---|---|---|
| site 1 — 2 masters | 52467 | fail | CLUSTERDOWN |
| 45191 | fail | CLUSTERDOWN | |
| 54701 | fail | CLUSTERDOWN | |
| site 2 — 1 master (own cluster) | 49019 | fail | CLUSTERDOWN |
| 40491 | fail | CLUSTERDOWN | |
| 48663 | fail | CLUSTERDOWN |
The cache was gone everywhere. In neither of the two data centres.
Site 2 is straightforward: one master out of three, no majority.
But site 1 did have the majority — two masters of three. It had the votes. There
was simply nobody to promote: the replica of the master left in site 2 stayed in
site 2 as well (look at the map above). Nobody covered that master's slots, and a
cluster with incomplete coverage (cluster-require-full-coverage, yes by
default) stops accepting writes.
Why the reads failed too
Reads are governed by a different setting — cluster-allow-reads-when-down,
no by default. This invites the conclusion that the observed behaviour is
"wider than the documented one"; the conclusion is wrong — the behaviour is
documented. The chain is: slot coverage incomplete → the cluster is marked as
failed → a node of a failed cluster does not serve reads either. Checked with
the same layout, run twice.
| setting | node | role | cluster state | 20 keys read |
|---|---|---|---|---|
cluster-allow-reads-when-down no (default) | 43151 | master | fail | 20 CLUSTERDOWN |
| 47729 | master | fail | 20 CLUSTERDOWN | |
| 35021 | replica | fail | 20 CLUSTERDOWN | |
cluster-allow-reads-when-down yes | 54835 | master | fail | 5 answered, 15 redirected |
| 46259 | master | fail | 9 answered, 11 redirected | |
| 48093 | replica | fail | 20 redirected |
A SET on the same node came back CLUSTERDOWN in both runs. 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 behind
the severed link. The setting does not bring the cache back — it turns "nothing
works" into "some reads work on part of the key space".
Hence the rule: a majority of votes is necessary but not sufficient. The
full condition has three parts: a majority of masters on your side, plus a
reachable replica of every lost master, plus that replica being fresh enough —
one lagging beyond cluster-replica-validity-factor will not start an election.
Freshness did not get in the way in these runs, and the run prints the line that
shows it.
You cannot check this by eye on a diagram: a diagram shows how many nodes there are, not whose replica sits where. The cluster arranges the replicas itself — at creation and after every failover.
You can move a replica by hand, and then one half will survive. But only one: what you are choosing that way is which data centre survives the break.
The boundary of that conclusion has to be drawn precisely. 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. Other outcomes are possible — two independent caches, read-only operation in a reduced form, a product with active-active and an explicit conflict resolution model — it is just that they are no longer "one stretched cluster", and none of them was measured here.
What to do instead
- A separate cache in each site, filled by its own application from its own source. A link failure is not an event for it.
- In an ideal steady state the price is one fill of every hot key in every region. That is a lower bound, not an estimate: "two misses and that is it" would be a deception, the real load comes from eviction, restarts, failovers, deployments, invalidation, cold starts and traffic moved between sites. Hence something a single shared cache did not have — two independent stampede domains.
- Divergence between sites is acceptable, but a TTL alone does not manage it. The observed divergence is the sum of four terms: the lag of the source itself, the invalidation delay, the freshness policy (the TTL proper and its relatives — early refresh, sliding expiry, serving a stale value while it is recomputed) and the aftermath of failures and recovery. What deserves the name "budget" is the sum.
- Synchronous cross-site invalidation is an expensive deal: waiting for confirmation brings back the round trip we walked away from, and puts it in the hot path of a write. If waiting really is required, the problem is not the TTL: "acknowledge the write only once another region can see it" is a requirement on the consistency model of the cache layer, met by versioning, by routing the reader back to the same region, or by taking the cache out of the correctness path.
- 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 or a canonical computation — what matters is not which, but that the path back is known.
- A separate cache per site removes the inter-site link from the path to the cache, but does not make the application independent of it. If writes still go to a database master in the other site, a break is still an event for the application.
- A shared cache is still a cache. The circulating rule goes: "if the cache has to be shared, then what is in it is not a cache". That is wrong: a shared external cache serving several applications is a normal arrangement — it takes load off the source better than a set of in-process caches and survives a deployment without starting cold. The red flag is not "shared" but irrecoverability. The test: 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 a different conversation.
When a stretched cache is nonetheless defensible
- If one designated site has to survive the break, not both. This works — but you are picking the winner in advance, there will be no cache in the second site, and the replica layout has to be re-checked after every failover.
- If a replica in the other site is there not to serve traffic but to avoid filling the cache from scratch. The terms: replicas do not promote themselves, you will be switching over by hand, and whatever lay between the master's and the replica's offsets at the moment of the break will be lost. For a cache the last part is tolerable — those entries get recomputed on a miss.
- If the write rate is sparse. The second row of the failover run and the offsets run: what is dangerous is not the volume of data but the lag the write rate manages to create. Check it from the difference of offsets, not from the distance between sites.
- If the source operation costs more than the round trip. The latency run compared a cache in the other site against the cheapest thing a database can do. For an aggregate that takes a hundred milliseconds the comparison is a different one, and a cache at forty milliseconds wins it. No numbers for expensive operations were taken here.
What all four have in common is what is absent: not one of them promises that both halves of one cluster independently keep accepting writes.
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
OKfor all two hundred. This is not a fault but the declared contract of asynchronous replication. WAITbuys 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
WAITcarried "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 noTCP_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".
- 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.
- 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.
- 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.
| median, ms | p95, ms | |
|---|---|---|
| cache in the same site | 0.10 | 0.14 |
| database in the same site | 0.09 | 0.13 |
| cache in the other site | 41.09 | 41.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.
| a write becomes visible on the replica after | ms |
|---|---|
| replica in the same site | 1.1 |
| replica in the other site | 20.8 |
| difference | 19.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.
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.
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.
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.
| 200 writes acknowledged to the client | on the replica | lost |
|---|---|---|
| link cut immediately after the writes | 107 | 93 (46.5%) |
| link cut one second after the writes | 200 | 0 (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.
| 200 writes, then the link is cut | on the replica | lost | master offset | replica offset | lag, bytes |
|---|---|---|---|---|---|
| in a burst, cut immediately | 96 | 104 | 6403 | 2855 | 3548 |
| 5 ms apart, cut immediately | 197 | 3 | 12838 | 12739 | 99 |
| in a burst, cut one second later | 200 | 0 | 19273 | 19273 | 0 |
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".
| ms | |
|---|---|
| ordinary write | 0.24 |
write with WAIT 1 | 41.73 |
| how many times more expensive | 171.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.
| one way | round trip | ordinary | with WAIT 1 | WAIT / round trip |
|---|---|---|---|---|
| 5 ms | 10 ms | 0.15 ms | 10.90 ms | 1.09 |
| 20 ms | 40 ms | 0.14 ms | 41.52 ms | 1.04 |
| 40 ms | 80 ms | 0.20 ms | 81.40 ms | 1.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.
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.
Checking the instrument: what the inter-site link actually was
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.
SET + WAIT 1, no distance involved | ordinary write, ms | with WAIT 1, ms |
|---|---|---|
| no link at all | 0.20 | 0.42 |
link at 0 ms, without TCP_NODELAY (the defective rig) | 0.24 | 44.00 |
link at 0 ms, with TCP_NODELAY (the corrected rig) | 0.18 | 0.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:
serializing link, 20 ms one way, 20 × SET+WAIT | chunks |
|---|---|
| master → replica, idle window | 0 |
| replica → master, idle window | 1 |
| master → replica, under load | 40 |
| replica → master, under load | 21 |
| above background, total | 60 |
| per operation | 3.00 |
| delay applied, ms per operation | 60.0 |
| measured, ms per operation | 61.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:
| one way | round trip | serializing | pipelined | difference |
|---|---|---|---|---|
| 5 ms | 10 ms | 16.06 ms | 11.06 ms | +5.00 |
| 20 ms | 40 ms | 61.64 ms | 41.48 ms | +20.16 |
| 40 ms | 80 ms | 121.86 ms | 81.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:
| one way | round trip | serializing | pipelined |
|---|---|---|---|
direct GET | — | 0.10 ms | 0.10 ms |
| 5 ms | 10 ms | 10.81 ms | 10.79 ms |
| 20 ms | 40 ms | 40.98 ms | 41.00 ms |
| 40 ms | 80 ms | 81.10 ms | 81.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.
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.
| site | node | cluster state | GET returned |
|---|---|---|---|
| site 1 — three masters | 46073 | ok | the value |
| 38365 | ok | handed the slot to another node | |
| 48713 | ok | handed the slot to another node | |
| site 2 — three replicas (own cluster) | 47655 | fail | CLUSTERDOWN |
| 34721 | fail | CLUSTERDOWN | |
| 52771 | fail | CLUSTERDOWN |
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 3 | site 2 — 1 master of 3 |
|---|---|
| 52467: master | 35445: master |
| 45191: master | 32777: replica of 45191 |
| 54701: replica of 52467 | 38349: replica of 35445 |
| site | node | cluster state | GET returned |
|---|---|---|---|
| site 1 — 2 masters | 52467 | fail | CLUSTERDOWN |
| 45191 | fail | CLUSTERDOWN | |
| 54701 | fail | CLUSTERDOWN | |
| site 2 — 1 master (own cluster) | 49019 | fail | CLUSTERDOWN |
| 40491 | fail | CLUSTERDOWN | |
| 48663 | fail | CLUSTERDOWN |
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.
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.
| setting | node | role | cluster state | 20 keys read |
|---|---|---|---|---|
cluster-allow-reads-when-down no (default) | 43151 | master | fail | 20 CLUSTERDOWN |
| 47729 | master | fail | 20 CLUSTERDOWN | |
| 35021 | replica | fail | 20 CLUSTERDOWN | |
cluster-allow-reads-when-down yes | 54835 | master | fail | 5 answered, 15 redirected |
| 46259 | master | fail | 9 answered, 11 redirected | |
| 48093 | replica | fail | 20 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 replica | site 2 — 1 master and two foreign replicas |
|---|---|
| 46819: master | 53509: master |
| 40665: master | 39129: replica of 46819 |
| 36267: replica of 53509 | 47199: replica of 40665 |
| site | node | cluster state | GET returned |
|---|---|---|---|
| site 1 — 2 masters + the third one's replica | 46819 | ok | the value |
| 40665 | ok | handed the slot to another node | |
| 36267 | ok | handed the slot to another node | |
| site 2 — 1 master (own cluster) | 47073 | fail | CLUSTERDOWN |
| 40639 | fail | CLUSTERDOWN | |
| 34151 | fail | CLUSTERDOWN |
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.
| Redis 7.0.15 | Redis 8.10.1 | Valkey 9.1.2 | |
|---|---|---|---|
| version | 7.0.15 | 8.10.1 | 9.1.2 |
| ordinary write, ms | 0.17 | 0.18 | 0.18 |
| master waits for the replica | no | no | no |
| visible on the replica after, ms | 21.4 | 21.9 | 21.1 |
WAIT 1, ms | 41.46 | 41.48 | 41.29 |
| WAIT / round trip | 1.04 | 1.04 | 1.03 |
| lost out of 200 on a cut | 72 | 106 | 116 |
| even split: state of the majority half | fail | fail | fail |
even split: any GET answered | no | no | no |
reads-when-down no: keys served out of 60 | 0 | 0 | 0 |
reads-when-down yes: keys served out of 60 | 15 | 15 | 15 |
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.
| Layout | Local latency | When the link breaks | RPO of cache contents | RTO: warm again |
|---|---|---|---|---|
| One cache across two sites, both talk to it | poor on the far side (measured) | the far side loses the cache | not applicable: one copy | immediately, if the near side lives |
| Redis cluster across two sites | mixed | depends on quorum and slot coverage (measured) | the shard replica's lag | after elections, if there was someone to promote |
| Master here, replica in the other site | good on the master's side | manual failover plus a loss window (measured) | the replication lag at the moment of the break | the time it takes to switch by hand |
| A separate cache in each site | good | the cache survives the break | loss is disposable by construction | warming your own region |
| In-process cache plus a regional one | best | isolation by region | two layers, both disposable | warming two layers |
| Active-active with conflict resolution | good | what it is built for | product- and model-specific | local 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.
bench/stretched-cache/replication.pybench/stretched-cache/cluster.pybench/stretched-cache/latency.pybench/stretched-cache/readsdown.pybench/stretched-cache/offsets.pybench/stretched-cache/waitcost.pybench/stretched-cache/relaycheck.pybench/stretched-cache/versions.pybench/stretched-cache/link.py
Corrections
12 September 2026Collected 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.
"At risk is everything written during the last round trip before the break."
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.
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).
Beyond the round trip, WAIT showed "an addend of 42–44 ms, the same at every distance", and its origin was declared unestablished.
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.
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).
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.
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.
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).
Both halves of the split cluster were checked in turn on one and the same cluster.
Each half is checked on its own fresh cluster with identical topology.
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).
"If the cache has to be shared, then what is in it is not a cache."
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.
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.
"A TTL is the budget for inconsistency, named in seconds."
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.
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.
"Anything that cannot be recomputed from the database has no business being in a cache."
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.
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.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- 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
OKfor all two hundred. This is not a fault but the declared contract of asynchronous replication. WAITbuys 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
WAITcarried "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 noTCP_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.
In fact
- 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.
- 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, orcluster-replica-validity-factorwill not let it start an election. - 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 answeredCLUSTERDOWNto aGET. 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. - 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.
- Measured: an ordinary write 0.24 ms, a write with
WAIT 141.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. - The refusal is total: in the run a plain
GETcame backCLUSTERDOWNon 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-coveragegoverns 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,noby 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: withno, all 20 keys out of 20 come backCLUSTERDOWNon every node. - It will not. With
yesa 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. Soyesturns "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. - 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'smaster_repl_offsetand the replica's offset at the moment of the break; Redis reports both inINFO replication. The round trip only sets a lower bound on how quickly the lag can come down to zero. - 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.
- Not necessarily, and this article is its own counter-example. The price of
WAITcarried "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 noTCP_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 withWAIT 1cost 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.
What is covered
- Three ways to stretch a cache
- The analogy everything grows from
- A caveat: what it was measured on, and what carries over
- Difference one: a cache and a database have different jobs
- Difference two: losing a row and losing a cache entry cost different things
- Checking the instrument: what the inter-site link actually was
- Difference three: "stretch" means different things
- The culmination: both halves failed
- The layout that survives, and what it actually chooses
- The same claims on current builds
- What to do instead
- Six layouts side by side, and what they cost
- When a stretched cache is nonetheless defensible
- How to reproduce the numbers
- What measured this
Common misconceptions
Our database is stretched across two sites and it works — so the cache can be too
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.
Split the nodes evenly between the sites and we survive the loss of either one
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.
The replicas in the second site are a spare cluster: if the first site dies, they take over
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.
The correct replica layout can be checked by eye on a diagram
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.
WAIT makes replication synchronous and closes the question of losses
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.
With slots left uncovered, only the missing keys stop working
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.
I will set cluster-allow-reads-when-down yes and the cache will survive the break
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.
A 20 ms replica lag is a trifle — a cache can live with that
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.
A shared cache across two sites is needed because the application runs in both
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.
If a number was measured, it measures what we meant to measure
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
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
- 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/
- 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/
- 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/
- 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/
- 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
- 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
- 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/
- 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/
- 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/
- 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/