MariaDB9 min read

Galera Stale Reads on MariaDB: wsrep_sync_wait and the Read-After-Write Trap

Users updated their profiles, hit save, reloaded — and saw the old values. The write went to Galera node A, the read to node B, and node B had not applied the writeset yet. wsrep_sync_wait was 0, the default. The fix is one variable; the cost model is the hard part.

The support tickets were maddening in their consistency: a user edits their profile, clicks save, the page reloads, and their old values stare back at them. Refresh again, and the new values appear. Intermittent, unrepeatable on demand, and it only happened under load. The architecture was a three-node MariaDB Galera cluster behind a load balancer that sprayed both reads and writes across all nodes, and the mechanism, once I finally saw it, was embarrassingly simple. The UPDATE went to node A and committed. The SELECT a hundred milliseconds later went to node B. Galera had certified and delivered the writeset to node B — that part is synchronous — but node B's applier had not replayed it yet, because apply is asynchronous. Node B answered the read honestly from state that was two hundred milliseconds old. To the user, their write had vanished. wsrep_sync_wait was 0, which is the default, which means no node waits for anything before answering a read.

This is the least understood corner of Galera, because "synchronous replication" is the marketing phrase and "virtually synchronous" is the truth. The gap between those two phrases is where stale reads live. Here is the model, the variable that closes the gap, what closing it costs, and the architectural alternatives for when the variable's price is too high.

Why are Galera reads stale if replication is synchronous?

Because only certification and ordered delivery are synchronous — when your transaction commits, every node has agreed the writeset is valid and holds it in its receive queue — while applying that writeset to actual table data happens asynchronously on each node, at each node's own pace. The commit acknowledgment the client receives means "the cluster has durably agreed on this write," not "every node can already see it." Most of the time the apply gap is single-digit milliseconds and invisible. Under write load, on a busy applier, or during the flow-control episodes I covered in the Galera flow control piece, the gap stretches to hundreds of milliseconds or seconds — and any read routed to a lagging node in that window sees the past.

The number that tells you how exposed you are is wsrep_local_recv_queue_avg: the average depth of received-but-unapplied writesets. When it hovers near zero, stale reads are a theoretical concern. When it climbs into the hundreds during your daily write peaks — ours hit 400 during the batch window that generated those support tickets — every read routed across nodes is a coin flip weighted by apply lag. The certification machinery that creates this window is the same one that produces write conflicts under multi-writer load, covered in the certification conflicts notes; both are symptoms of the same honestly-asynchronous apply phase.

What does wsrep_sync_wait actually do?

wsrep_sync_wait makes the session's statements wait until the node has applied every writeset that was certified before the statement began — restoring causality at the cost of latency. It is a bitmask over statement types, it defaults to 0 (wait for nothing), and it is settable globally or per session, which is the detail that makes it usable in production:

-- the bitmask, as documented:
--   1 = READ (SELECT)      2 = UPDATE and DELETE
--   4 = INSERT and REPLACE 8 = SHOW (MariaDB added this bit)
-- common values: 7 = full causality for reads and DML,
--               15 = the above plus SHOW. default: 0.

SELECT @@GLOBAL.wsrep_sync_wait, @@SESSION.wsrep_sync_wait;

-- the surgical production pattern: causality ONLY where the
-- application flow writes-then-reads
SET SESSION wsrep_sync_wait = 1;
UPDATE profiles SET display_name = 'Ada' WHERE id = 42;
SELECT * FROM profiles WHERE id = 42;   -- waits for apply, sees the write

Two facts keep this honest. First, the waiting happens on whichever node serves the statement, against its own apply position — so wsrep_sync_wait=1 fixes the read-your-write case regardless of which node the load balancer picks, which was exactly our bug. Second, the older boolean wsrep_causal_reads is the same idea restricted to reads and is deprecated in favor of the bitmask; if you inherit a cluster with it set, it is doing roughly sync_wait=1 for that session and you should migrate the setting, not stack both. We never set the variable globally. We set it per session in exactly the application flows that write and then immediately read — profile edits, cart updates, permission changes — which turned out to be under 5% of sessions.

What does causality cost in latency?

A causality-waiting statement costs however far behind the node's applier is at that instant — near zero on an idle cluster, and exactly the apply lag during write bursts. That means wsrep_sync_wait does not add a fixed tax; it couples your read latency to your cluster's apply health. On our cluster, sync-waiting SELECTs added single-digit milliseconds at 2am and 300 to 800 milliseconds during the evening write peak, tracking wsrep_local_recv_queue_avg almost perfectly. Size your connection pools for the bad case, not the average: a flow-control episode that stalls apply also stalls every sync-waiting read, and a pool exhausted by waiting reads turns a replication hiccup into an application outage. This is the same coupling that makes flow control a whole-cluster event — the latency you pay for causality and the latency flow control imposes come out of the same apply queue.

The practical guidance that survived a year of operation: never set sync_wait globally on a write-heavy cluster, because every reporting query and every background job inherits the wait and your p99 becomes your apply lag. Set it per session in write-then-read flows. Keep bitmask value 1 unless you have a demonstrated need for more — the DML bits (2 and 4) matter for multi-statement transaction flows that must observe their own earlier statements cluster-wide, which is rarer than the read case. And measure: graph the wait time of sync sessions separately from general query latency, or you will misdiagnose the first flow-control stall after rollout as an application regression.

What are the alternatives when the wait is too expensive?

Three architectures avoid paying apply-lag latency on reads, in ascending order of machinery. The simplest is read-your-writes routing: send a session's reads to the same node that took its writes, which most proxies can do with sticky routing, and causality is free because a node always applies its own writes before acknowledging commit — the trade is losing read distribution for those sessions. The middle option is explicit GTID waiting: after a write, capture its GTID and call SELECT MASTER_GTID_WAIT('domain-server-seq', timeout) on the node you are about to read from; you wait only when you must and you bound the wait with the timeout. The full-machinery option is letting the proxy do it — MaxScale's readwritesplit router has a causal_reads mode that tracks the session's writes and injects the GTID wait before reads automatically, which is the same mechanism productized; the surrounding topology is covered in the MaxScale read-write splitting notes.

We ended up hybrid: sticky read-your-writes routing for the interactive application tier, per-session wsrep_sync_wait=1 in the handful of flows that could not guarantee stickiness, and nothing at all for reporting traffic, whose readers neither know nor care that their data is 200 milliseconds old. The unifying principle is that causality is a per-flow requirement, not a cluster-wide property — pay for it exactly where the user experience breaks without it, and let everything else enjoy the asynchronous apply that makes Galera fast. The broader when-Galera-at-all question is mapped in the replication vs Galera tradeoffs piece.

Where MonPG fits

The signals worth trending here are the exposure metrics: wsrep_local_recv_queue_avg per node as your stale-read risk gauge, wait latency on sync_wait sessions as the price you are paying, and the correlation between the two during write peaks. Full disclosure, as in every article of this series: I work on MonPG, which monitors PostgreSQL in production today and does not monitor MariaDB yet. MariaDB support is coming soon and in active development — the /mariadb-monitoring page tracks where it stands — and Galera apply health is on the list of signals it is being built around: apply-queue depth graphed per node so a stale-read window shows up on a dashboard before it shows up in support tickets. Until that ships, the status variables above are your kit. If PostgreSQL is also in your fleet, that monitoring is live today — see the PostgreSQL overview, or browse more field notes on the blog.