Consistency Models
Strong, eventual, causal, read-your-writes and monotonic reads, stated as promises to a user rather than as database settings.
strength: linearizable > sequential > causal > session guarantees (read-your-writes, monotonic reads) > eventualA consistency model is the set of promises a system makes about what a read is allowed to return. Stating them as promises to a person, rather than as database options, is what makes them usable when you are designing something.
The models as promises
- Linearizable (strong) — every read returns the latest completed write, as if there were one copy. "Two people looking at the last seat see the same answer."
- Eventual — if writes stop, replicas converge. It promises nothing about when, and nothing at all about what you see before then.
- Causal — anything that could have influenced an operation is seen before it. "A reply never appears above the comment it replies to."
- Read-your-writes — your own write is visible to you afterwards, even if nobody else can see it yet. "Your comment is there when the page reloads."
- Monotonic reads — one reader never moves backwards in time. "Refreshing does not un-post what you just saw."
The last two are session guarantees, named in Terry's 1994 Bayou work, which also names monotonic writes and writes-follow-reads; causal consistency is a system-wide model rather than a session guarantee. Session guarantees are per-session, which is both why they are cheap and why they leak.
What trips people
These are not one dial with settings. A system can give read-your-writes and still break monotonic reads, if the second read lands on a replica that is further behind than the first. And a strongly consistent store buys nothing if something in front of it serves the old bytes: S3 has provided strong read-after-write consistency for GET, PUT and LIST since December 2020, but the cache you put in front of S3 still has not.
Example
// Read-your-writes on Postgres without pinning every read to the primary.
// The write returns its WAL position; the read uses it to decide whether a
// replica is caught up enough to answer.
//
// pg_current_wal_lsn() / pg_last_wal_replay_lsn() are PostgreSQL 10+ names;
// before 10 they were pg_current_xlog_location / pg_last_xlog_replay_location.
import { Pool } from "pg";
const primary = new Pool({ host: "db-primary" });
const replica = new Pool({ host: "db-replica" });
export async function postComment(threadId, body) {
const { rows } = await primary.query(
`WITH ins AS (
INSERT INTO comments (thread_id, body) VALUES ($1, $2) RETURNING id
)
SELECT ins.id, pg_current_wal_lsn() AS lsn FROM ins`,
[threadId, body],
);
// Hand the position back to the caller: a cookie, a header, or the body.
return { id: rows[0].id, lsn: rows[0].lsn };
}
export async function readThread(threadId, lsn) {
let pool = replica;
if (lsn) {
// Compare in SQL, as pg_lsn. Comparing the text form in JavaScript is
// wrong: '0/9FFFFFF' sorts above '0/10000000'.
const { rows } = await replica.query(
"SELECT pg_last_wal_replay_lsn() >= $1::pg_lsn AS caught_up",
[lsn],
);
// NULL (we are talking to a primary) is falsy here, which is the safe way
// to be wrong: fall back to the primary.
if (rows[0].caught_up !== true) pool = primary;
}
return pool.query(
"SELECT id, body FROM comments WHERE thread_id = $1 ORDER BY id",
[threadId],
);
}When to use it
- A user posts a comment, reloads, and it is missing because the read hit a lagging replica
- Deciding which endpoints may be served from replicas and which must go to the primary
- Explaining to a product owner what "eventually consistent" means for their screen
- Picking a guarantee for a feed where ordering matters more than freshness
More examples
Monotonic reads by pinning a session to one replica
Shows the cheapest implementation of a session guarantee and exactly where it stops holding. Reach for it on read-heavy list endpoints where users notice flapping, before reaching for anything that involves the primary.
// Without this, two consecutive reads from one user can hit replicas with
// different lag, and the second read shows OLDER data than the first.
import crypto from "node:crypto";
const replicas = [replicaA, replicaB, replicaC];
function replicaForSession(sessionId) {
// Stable hash -> stable replica for the life of the session. The guarantee
// is only monotonic reads, not read-your-writes: a write still has to be
// read from the primary or gated on an LSN.
const h = crypto.createHash("sha1").update(sessionId).digest();
return replicas[h.readUInt32BE(0) % replicas.length];
}
export async function listNotifications(sessionId, userId) {
const pool = replicaForSession(sessionId);
const { rows } = await pool.query(
"SELECT id, body, created_at FROM notifications WHERE user_id = $1 ORDER BY id DESC LIMIT 50",
[userId],
);
return rows;
}
// If that replica is removed from the pool, the session rehashes to another
// one and may jump backwards once. Monotonic reads is a promise about the
// common case, not a guarantee across a failover.Causal consistency through a session token
Causal consistency is the model most social and messaging features actually need, and it is far cheaper than linearizability. Use this when the bug report is "the reply showed up before the message" rather than "the number was stale".
// MongoDB driver. Causally consistent sessions arrived in MongoDB 3.6: the
// driver carries the cluster time forward, so a read in the session sees
// everything the session's earlier writes saw.
//
// The guarantee only holds with majority read and write concern.
const comments = db.collection("comments", {
readConcern: { level: "majority" },
writeConcern: { w: "majority" },
});
const session = client.startSession({ causalConsistency: true });
try {
const parent = await comments.insertOne(
{ threadId, body: "original", parentId: null },
{ session },
);
// A reply causally depends on the comment above. Inside the session, this
// read cannot land on a secondary that has not applied the insert yet, so
// the reply can never be rendered before its parent exists.
const thread = await comments
.find({ threadId }, { session })
.sort({ _id: 1 })
.toArray();
await comments.insertOne(
{ threadId, body: "a reply", parentId: parent.insertedId },
{ session },
);
} finally {
await session.endSession();
}The cache that cancels the guarantee
The commonest way a consistency model is lost in practice: the database kept its promise and the edge cache broke it. Use this pattern on any write-then-view flow that sits behind a CDN or a shared Redis cache, which is most publishing and profile-editing screens.
// A strongly consistent store behind a CDN gives the writer a stale page.
// Fix: make the writer's own requests bypass the shared cache for a while,
// and purge the shared copy.
app.post("/articles/:slug", async (req, res) => {
await saveArticle(req.params.slug, req.body); // strongly consistent store
// 1. Drop the shared copy. Purges are asynchronous, so do not rely on this
// having finished by the time the author's browser issues its next GET.
await cdn.purge(`/articles/${req.params.slug}`);
// 2. Give the author a short-lived cookie that is part of the cache key, so
// their next few reads cannot be served the pre-write body.
res.cookie("fresh", "1", { maxAge: 60_000, httpOnly: true, path: "/articles" });
res.status(204).end();
});
app.get("/articles/:slug", async (req, res) => {
const bypass = req.cookies.fresh === "1";
// Vary on the cookie so the CDN keeps the author's response out of the
// copy it serves to everybody else.
res.set("Vary", "Cookie");
res.set("Cache-Control", bypass ? "private, no-store" : "public, max-age=60");
res.send(await renderArticle(req.params.slug, { fromPrimary: bypass }));
});
Discussion