Skip to content
Cache Invalidation for SQL Queries: How to stop writing it yourself
← Back to blogDatabase Caching

Cache Invalidation for SQL Queries: How to stop writing it yourself

Cache invalidation is the hardest part of caching SQL queries. Here's why TTLs and manual keys break, and how incremental view maintenance avoids both.

Vinicius Grippa

Vinicius Grippa

2026-09-21 · 25 min read

Cache invalidation is the problem of knowing when a cached query result has gone stale and needs replacing. A write changes one row in the database, and every cached answer derived from that row is now wrong. The work is finding those answers and dropping or refreshing them before the next read.

Say you have a read-heavy application and you've been asked to make reads faster. You put a cache in front of the database, the read path gets fast, and that invalidation work lands in your write path as a short list of rules. Here are the rules:

  • If a vote arrives, drop that story's key.
  • If a title changes, drop that story's key.
  • If an account is renamed, drop that user's key.

One rule per table. It reviews cleanly and it ships.

One of those three rules is wrong. The page it breaks keeps serving stale data until something unrelated evicts the entry.

We wanted to know what that mistake actually costs, and how the alternatives compare when they see identical writes. So we built a lab and measured six read paths against one workload.

Why one of those rules is wrong

Writing one of those rules wrong is easy. Here is the mistake in full, with the schema on the page. Three tables, quoted from Figure 2 of the Noria paper:

CREATE TABLE stories (id int, author int, title text, url text);
CREATE TABLE votes   (user int, story_id int);
CREATE TABLE users   (id int, username text);

The lab adds a primary key to each of the three tables.

A story page reads across all three. It needs a title from stories, a display name from users, and a count from votes, and all three collapse into a single cache key. So a cached result is not a copy of a row. It is an answer derived from rows in several tables, and no one of those tables can say which cached answers depend on it.

The code that writes knows which row it changed. The cache knows which key it holds. The mapping between the two lives in exactly one place, the text of the read query, and the read query sits nowhere near the write path:

SELECT s.id, s.title, u.username
  FROM stories s JOIN users u ON u.id = s.author
 WHERE s.id = ?;

The edge that matters is u.id = s.author. It says that changing a user dirties story pages. Nothing in the users table mentions a story, so the rename rule has no way to find the keys it just falsified. It drops a user key that this page never reads, and every story by that author keeps serving the old name.

Nothing catches this. A cache entry that should have been dropped and was not raises no error. It returns a plausible name, on the instances that happen to hold it, only for entries populated before that particular write. It will not reproduce on a laptop where the cache is empty, which is why this class of bug tends to be found by a user rather than by a test.

Almost nobody attempts precise invalidation

A 2023 survey by Ji, Zhou, Zhou and Wang analysed 20 Spring-cache web applications and surveyed more than 50 engineers. Of the cached methods they counted, 80.6% used a time to live or coarse-grained invalidation (dropping every cache entry associated with a table or namespace, rather than working out which specific entries a write affected), and only 10.4% attempted fine-grained invalidation.

Four out of five cached results in real projects do not attempt the mapping. They accept staleness instead. The authors' explanation of why:

All the aforementioned cases converge on a single fundamental challenge: the absence of a link between database updates and cache entries at the application level, making precise cache invalidation difficult to achieve.

Their examples are ordinary. One delete method issues 11 SQL statements across 9 tables, so no application-level rule can say what it dirtied. Another caches a query with range predicates, where deciding whether an update changes the result means evaluating the predicate.

Even teams who do this well find it hard. Meta's Polaris service exists to detect cache inconsistencies that reach production, and it moved TAO, the store behind Facebook's social graph, from 99.9999% to 99.99999999% consistency. Before Polaris, roughly one write in a million was silently wrong, and it took a purpose-built service to find out.

Cache invalidation strategies for SQL queries

Four strategies are in common use:

Time to live. Do not track dependencies at all. Put an expiry on each cached result and re-read the query when it lapses. This is the most widely used strategy because it is the only one with no invalidation code, and the trade is that the cache is correct within N seconds, and you chose N. A TTL cache also never learns that anything changed, so it re-reads on a schedule whether the underlying rows moved or not.

Manual cache keys, the Redis model. Store each result under a key the application chooses and delete those keys from the write path. Precise in principle. The cost is that the write path has to invert the read query by hand, and a missed edge is silent.

Event-driven invalidation. Read the database change stream, the MySQL binlog or the Postgres write-ahead log, and invalidate from it. Moving the trigger out of the application removes the race between a write and its invalidation, which is a real gain. The mapping stays. The team still writes and maintains the code that turns a changed row into the cache keys it affects, now in a stream processor rather than in a write path.

Incremental view maintenance. Apply each change to the stored result instead of discarding it. There is no invalidation step to write, because nothing is deleted.

StrategyWho writes the invalidationWhen the cached result is wrongCost of a write
Time to liveNobodyUp to the length of the window, after every writeNone, but refreshes run on a clock whether or not anything changed
Manual cache keysThe application, per queryUntil eviction, whenever the mapping misses an edgeThe invalidation, plus a burst of misses behind it
Event-driven invalidationThe application, in a stream processorUntil the mapping is correctedStream processing, plus the burst of misses
Incremental view maintenanceNobodyOnly while the change propagatesMaintenance work inside the dataflow graph

The rest of this post measures the first, the second and the fourth against each other on one schema. One write workload.

How we measured it

The lab runs on the three tables declared above, stories, users and votes, seeded at the scale the Noria paper reports for production Lobsters: 10,000 users, 40,000 stories and 500,000 votes. Six read paths served the same story page under one write workload, and saw exactly the same writes. Readyset runs with its query sampler off throughout.

The six paths:

  • MySQL directly, no cache, as a control
  • Redis look-aside with the three-rule invalidator, whose rename rule drops a user key and leaves every story page by that author wrong
  • Redis look-aside with a corrected invalidator
  • Readyset shallow cache, fixed 10 s TTL with a 5 s refresh
  • Readyset shallow cache with adaptive refresh
  • Readyset deep cache, which maintains the result incrementally, a technique called incremental view maintenance and abbreviated IVM in the tables that follow

Reads have to be continuous, or the number measures the cache miss instead of the cache: burst-reading a TTL cache reports 12 ms staleness where continuous reading of the same cache reports seconds. The write interval also has to be jittered. With a fixed interval, two runs of an identical configuration produced 4,783 ms and 177 ms, both arithmetically correct and both meaningless, because the answer was set by the phase relationship between the write clock and the cache's refresh clock.

What the control is measuring

The control path has no cache, so it cannot be stale. It is there to measure the instrument.

The clock starts when the write commits and stops when a reader first sees the new value, so the control's number is whatever that detection costs. Readers issue one query and read back to back with no pause between reads, which puts the control at 0.19 ms, and each observation is bracketed to 0.28 ms by the read that caught it. Every number in this post is measured at that resolution.

Two corrections: the readers used to sleep 2 ms between reads, so a write landed uniformly inside a detection window and half that window showed up as staleness. And each poll ran both cached queries while inspecting only the vote count, paying for a query it never used. Removing the sleep and the spare query moved the control from 2.00 ms to 0.19 ms.

Staggering more reader threads, which looks like the obvious fix, does nothing. For N readers at interval T the aggregate grid is T/N and the read load is N/T. So grid spacing is one over the load, and four readers at 8 ms is the same grid as one reader at 2 ms. The lever is aggregate read rate. Taking the sleep out reaches a grid of one round trip with a single thread.

Does the cache ever become correct?

In ten trials we warmed every path, renamed an author, then read continuously for 45 seconds and recorded when each path first returned the new name.

Read pathConvergedNever converged
no cache (control)100
look-aside, three-rule invalidator010 of 10
look-aside, corrected invalidator100
Readyset shallow, fixed refresh100
Readyset shallow, adaptive refresh100
Readyset deep (IVM)100

Ten out of ten. Every other path recovers. The three-rule invalidator never did, and in production the entry would stay wrong until unrelated memory pressure evicted it. That makes the bug's lifetime a property of the Redis eviction policy rather than of anything in the application.

The fix is more expensive than it looks. The rename rule has to ask the database which stories that author wrote, because that is the only way to learn which keys were dirtied. That puts a SELECT in the write path, with a fan-out equal to the number of stories the author has written, which is unbounded and largest for exactly the accounts that matter most. It also writes the join down a second time, so adding a column to the cached page means finding every write path that can touch it, again, with nothing to signal a miss.

How stale each cache gets

A vote arrives and the count changes. This is the write path the three-rule invalidator handled correctly, so it is the fairest comparison. Three runs of sixty writes, medians across runs.

Read pathp50p95Excess over the uncached control, p50
no cache (control)0.19 ms0.40 msn/a, this is the control
look-aside, corrected invalidator1.11 ms1.57 ms0.92 ms
Readyset shallow, fixed refresh2.68 s4.74 s2.68 s
Readyset shallow, adaptive refresh2.29 s4.28 s2.29 s
Readyset deep (IVM)1.66 ms2.17 ms1.48 ms

60 writes per run, 3 runs, medians across runs. Readers poll back to back at about 3569 reads/sec, so the instrument brackets each observation to 0.28 ms.

Staleness after a write, log scale: no cache 0.19 ms, look-aside 1.11 ms, TTL caches above 2.2 s, incremental view maintenance 1.66 ms

The TTL caches sit between two and five seconds behind. That is what a TTL is. With a 5 s refresh and uniformly phased writes, half the reads land in the first half of a refresh period, so the theoretical p50 is 2.5 s. Measured 2.68 s. Adaptive refresh helped a little and it can't help much, because it moves each entry's period between 10% and 100% of the configured value, so a 5 s refresh has a floor of 500 ms.

The look-aside cache is marginally the freshest of the three. At 1.11 ms it is about half a millisecond ahead of incremental view maintenance at 1.66 ms. Two conditions are doing work behind that number: the query is cheap, and the write rate is one write every seven seconds. That ordering makes sense once you look at what each one does with a write. A look-aside cache deletes the entry and lets the next reader refetch, so what it serves next is whatever MySQL has at that instant. Incremental view maintenance has to carry the write through the binlog and the dataflow graph before the stored result changes, and on this schema that costs about 1.5 ms over an uncached read.

So on the write path a hand-written invalidator gets right, it wins by half a millisecond. A harness that shows otherwise is probably measuring itself.

The advantage lasts only while the query is cheap

The story-page probe query is two indexed point lookups. When the look-aside cache throws its entry away, refetching costs about a millisecond, which is why deleting and refetching looks free. A query like that is rarely worth caching. The reason to cache is that recomputing is expensive.

So the same measurement, on a query with no index to lean on:

SELECT user, COUNT(*) AS c FROM votes WHERE user = ? GROUP BY user;

votes is indexed on story_id, not on user, so this is a full scan of about 500,000 rows and takes roughly 35 ms on this machine. Twenty-five writes, same method:

Read pathStaleness p50Staleness p95One read, p50
no cache (control)59 ms102 ms36.62 ms
look-aside, corrected invalidator42 ms51 ms0.16 ms
Readyset deep (IVM)1.48 ms1.96 ms0.23 ms

The ordering inverts, and the deep cache is now about 28 times fresher rather than half a millisecond behind.

Read the last column next to the first. Both caches answer a single read in a fraction of a millisecond, so both are doing their job as caches. Their staleness differs by a factor of 28 because they recover from a write differently. A look-aside cache recovers by recomputing, so its staleness can never be lower than what the query costs. Incremental view maintenance recovers by applying one delta to the stored result, so its cost is set by the size of the change rather than the size of the scan, and it did not move between the cheap query and this one.

The uncached control is the same story from the other side: at 59 ms it is not stale, it is just slow, and every one of those milliseconds is work the database repeats on every read.

The TTL cache, given its best shot

Comparing against a fixed refresh period alone would be unfair, so the lab also runs Readyset's shallow cache with ADAPTIVE. Each entry's refresh period then moves between 10% and 100% of the configured value. The cache hashes each refreshed result, shrinks the period when the value changed, and grows it when it did not.

Adaptive refresh did engage: across the three runs the vote-count entry recorded 197 refreshes that found a change against 199 that did not, an almost exactly even split. Adaptation has nothing to bite on in that case, because the shrink and the grow are the same size and cancel, and the staleness reflects it: 2.29 s against 2.68 s, an improvement of about 15%.

Plotted per write, the two time-based lanes wander over the same range. The maintained view sits three orders of magnitude below both, on every write rather than on average:

Every write in one run of sixty, for the three Readyset lanes

What an invalidation costs beyond the invalidation

Deleting a hot key produces one cache miss per reader in flight, all at the same instant, all issuing the query to the database the cache was supposed to protect.

We put 32 readers on one story and wrote to it once every ten seconds, counting with MySQL's own Com_select so the harness could not inflate the number.

Read pathSELECTs in the write secondIn a quiet secondIncrease
look-aside, corrected invalidator66233x
Readyset shallow, fixed refresh22none
Readyset shallow, adaptive refresh22none
Readyset deep (IVM)no write-correlated increasenone

60 s per arm, one write every 10 s. Com_select is MySQL's own counter.

Upstream SELECTs during the run

Sixty-six is 32 readers times the two queries a story page needs, plus the couple of background queries present in a quiet second. One vote produced thirty-three times the steady query rate, in one second, against the database the cache exists to shield.

This is what the look-aside cache's half-millisecond freshness advantage costs. It is fresher because it throws the entry away and asks MySQL again, and asking MySQL again is the expensive part. Making its connection handling realistic, which is what closed the gap on the staleness table, did not soften the burst. It made the burst arrive sooner.

Neither Readyset path shows a spike, for different reasons. The TTL cache never learns that the write happened, so nothing changes at write time. The deep cache learns immediately and updates the stored result in place, so there is no entry to refill and no reader to send upstream.

The write rate is what decides this

Everything measured so far ran at one write every seven seconds on the probe row, which is one invalidation per roughly thirty thousand reads. That is the regime where a look-aside cache is at its strongest, because a look-aside cache is only useful in the gap between one invalidation and the next.

So the last measurement holds the readers constant and sweeps the write rate. Sixteen readers on one row, arms run one at a time so Com_select attributes cleanly, and the look-aside arm keeps every advantage it had: the invalidation is still a function call in the writing thread, still one cache key, still no queue and no second cache to fan out to.

Writes/sec on the rowDesignCache hit rateSELECTs/sec reaching MySQLWrites behind, p50Writes behind, p95
0.1look-aside, corrected invalidator100.0%500
0.1Readyset shallow, fixed refresh100.0%201
0.1Readyset deep (IVM)100.0%200
1look-aside, corrected invalidator99.9%3500
1Readyset shallow, fixed refresh100.0%225
1Readyset deep (IVM)100.0%200
5look-aside, corrected invalidator99.5%16300
5Readyset shallow, fixed refresh100.0%21123
5Readyset deep (IVM)100.0%200
20look-aside, corrected invalidator97.9%64100
20Readyset shallow, fixed refresh100.0%24594
20Readyset deep (IVM)100.0%200
50look-aside, corrected invalidator94.3%160001
50Readyset shallow, fixed refresh100.0%2111235
50Readyset deep (IVM)100.0%201

30 s per point, arms run one at a time so Com_select attributes cleanly. The look-aside arm is deliberately at its best case: invalidation in the writing thread, one cache key, no queue and no fan-out.

Two panels against write rate on a log axis. Left: SELECTs per second reaching MySQL, where the look-aside line climbs from 5 to 1,600 while the maintained view stays flat at 2. Right: writes behind, where the TTL line climbs to 111 while look-aside and the maintained view stay at zero

The two panels are the two different bills a cache can be handed, and each design pays only one of them.

The left panel is what the database pays: how many SELECTs per second reach MySQL while a cache is in front of it. The right panel is what the reader pays: how many writes behind the cached answer is when someone reads it. Both axes are log scaled, so a straight rising line is a proportional increase, and lower is better on both. A genuinely free design would be a flat line near the bottom of each panel.

As the write rate climbs, only one of the three changes is about staleness.

The look-aside cache stays current and sends the bill to the database. Its upstream load goes from 5 SELECTs per second to 1,600, a 302-fold rise across the sweep, tracking write rate times reader count almost exactly: 50 writes times 16 readers times the two queries a rebuild needs is 1,600. It is still fresh at 50 writes per second. It is fresh because it is asking MySQL sixteen hundred times a second about a row whose answer changed fifty times.

Its hit rate over that same sweep goes from 99.99% to 94.27%, and the two readings of that pair disagree completely:

Two panels of the same look-aside data. Left: cache hit rate on a 90 to 100 percent axis, drifting from 99.99 percent down to 94.27 percent. Right: cache misses per second on a log axis, rising from 1.6 to 799

On the left is what a hit-rate dashboard shows: a 5.7 point drift, which on any monitoring page reads as a cache doing its job. On the right is the same 5.7 points expressed as the thing the database actually receives, and it is a rise from 1.6 misses per second to 799, a factor of five hundred. Each of those misses costs two upstream queries, which is exactly the 1,600 SELECTs per second in the table.

Hit rate is a ratio, and a database is not charged in ratios. At 94% and sixteen thousand reads per second, the 6% is 799 requests that have to be computed from scratch, every second, on top of the write traffic that caused them.

The TTL cache holds upstream load flat and pushes the cost onto the reader. Two SELECTs per second at every write rate, because it never learns that anything changed. What moves instead is how far behind it is: 2 writes at 1 per second, 11 at 5, 45 at 20, and 111 at 50, with a p95 of 235. A five second refresh window times fifty writes a second is what that number has to be.

The maintained view does not move on either axis. 100% hit rate, zero writes behind, and none of the application's reads reaching MySQL at any write rate. The two SELECTs per second in that column are SELECT VERSION() and a session variable read, issued by Readyset's own connection to the database. Neither touches a table. The write side is doing more work at 50 writes per second than at 0.1, but the work is one delta per write through a graph, and on this query it does not show up as either staleness or database load.

A hand-written look-aside cache really is a shade fresher than incremental view maintenance, and it stays that way as the workload gets busier. It buys that by turning every write into a burst of reads against the database the cache exists to protect, and the bill grows with your write rate and your reader count at the same time.

What incremental view maintenance is

Incremental view maintenance keeps the stored result of a query up to date by applying each change to that result, rather than recomputing the query or discarding the result. A write becomes a delta, the delta flows through the relational operators of the query, and the stored answer is rewritten in place. A COUNT node receives "one more vote for story 1" and emits "the count for story 1 went from 7 to 8". There is no invalidation step, because nothing is deleted.

One write, handled first by a look-aside cache that deletes the key, then by incremental view maintenance that updates the stored result in place

Incremental view maintenance has decades of literature behind it, and memory is what stopped it replacing caches. A maintained view is the whole answer set, so materializing every query an application runs costs more memory than the database. Streaming systems capped that by windowing, keeping only recent records, which is useless for an application that has to serve a four-year-old story as fast as this morning's.

The 2018 result that changed the memory problem

In October 2018 a group at MIT CSAIL published Noria: dynamic, partially-stateful data-flow for high-performance web applications at the 13th USENIX Symposium on Operating Systems Design and Implementation, the peer-reviewed systems conference usually shortened to OSDI. Noria is a database backend that compiles an application's SQL queries into a dataflow graph and keeps every one of their results materialized and current as writes stream in.

The paper builds on incremental view maintenance rather than inventing it. Its contribution is partially stateful dataflow, the mechanism that makes maintaining every query affordable enough to replace a cache rather than sit beside one. Readyset is the production descendant of that work. The project's own history file records the lineage: the codebase "builds on the Noria project, an open-source research prototype built by a team of grad students, post-docs, professors, and research affiliates at the MIT Parallel and Distributed Operating Systems (PDOS) lab, of which ReadySet's founding team was a part."

Partially stateful dataflow works like this. Operators are allowed to be incomplete: an operator holds only the entries that have actually been read, and treats a missing entry as a hole to fill on demand. A read for an absent key issues an upquery, which travels backwards up the dataflow graph to the operators that can recompute that entry, and the response flows forward along the normal path to fill the hole. Under memory pressure an operator evicts an entry and sends an eviction notice downstream, so everything derived from it is dropped rather than left quietly out of date.

Memory therefore tracks the working set instead of the table. In the paper's Lobsters measurements, partial state cut the state that could not be evicted to 73 MB, against 789 MB for full materialization over 137 MB of base tables.

Our lab shows the same shape. Reading K distinct story pages and asking Readyset what it is holding, via readyset_reader_state_size_bytes:

Distinct story keys readDeep cache stateBytes per keyShare of base tables
00 KB00.00%
10045 KB4640.22%
1,000453 KB4642.17%
5,0002.2 MB46410.84%
20,0008.9 MB46443.37%
40,00017.7 MB46486.74%

40,000 stories exist. Base tables are 20.4 MB of data in MySQL. Metric: readyset_reader_state_size_bytes. Deep cache state against distinct keys read, log-log, a straight line at 464 bytes per key against a 20.4 MB base-table ceiling

464 bytes per key, flat across three orders of magnitude, and nothing held for keys nobody asked for. Read a hundred story pages and the caches hold 45 KB against 20.4 MB of base tables. Read all 40,000 and they hold 17.7 MB, still under the base tables, which is the case partial state does nothing for because there is nothing partial about it any more.

The practical version: a deep cache is sized by the working set, the same rule already used for Redis.

How to cache expensive SQL queries automatically

A query is cached automatically when adding the cache needs no application change and keeping it correct needs none either. Readyset gives both. It speaks the MySQL and Postgres wire protocols, so the application connects with its existing driver, and a cache is one statement:

CREATE CACHE FROM
  SELECT s.id, s.title, u.username
    FROM stories s JOIN users u ON u.id = s.author WHERE s.id = 2;

The caching is automatic because the invalidation is automatic. Readyset keeps each cached result current with incremental view maintenance, driven by the MySQL binlog or the Postgres write-ahead log, so there is no invalidation step for the application to write. A caching layer that stores results and hands back an invalidation problem has automated the read path and left the write path alone. Teams comparing SQL query caching tools for MySQL usually weigh hit rates and latency, and the more useful question is what each tool requires in the write path.

The property to design around

Incremental view maintenance is eventually consistent. The Noria paper states the guarantee this way: if writes quiesce, all external views eventually hold results that are the same as if the queries had been executed directly against the base tables. Under two milliseconds is short, and it is still not zero and not a transaction, so a read-your-own-writes path belongs on the database rather than behind any cache, this one included.

Accepting that window is a design decision, and it is the same one already being made with a TTL, except that the window is milliseconds instead of seconds and it does not have to be chosen or tuned.

What this comes down to

The mapping from a write to the cached answers it falsified is a graph, and nothing in application code validates that graph. Four out of five projects in the survey reached the same conclusion and took the TTL, which trades correctness for a window.

Partial state is what makes replacing invalidation practical. On our schema it bought this: on a cheap query, a maintained view sat 1.66 ms behind the database and a correctly written look-aside cache beat it, at 1.11 ms, while a TTL cache sat 2.68 s behind. On a query expensive enough to be worth caching, the same look-aside cache sat 42 ms behind and the maintained view did not move from 1.48 ms, because one design recovers by recomputing and the other by applying a delta.

The look-aside cache also paid for its freshness on both queries with a burst of 66 queries into MySQL after every write. The maintained view produced no burst, needed no window chosen or tuned, and required no invalidation code, because in that design there is nothing to invalidate. The three-rule invalidator, meanwhile, is still serving the wrong author name.

For how the dataflow engine maintains a cached result, see deep caching and How Readyset Speeds Up Queries with Streaming Dataflow. For the TTL model and when it fits better, see shallow caching, and the CREATE CACHE reference for the statement itself.

Want to see Readyset in action?

Book a demo and see how Readyset can accelerate your database.

Still scaling the hard way?

Modern applications demand instant performance, even under unpredictable load. Readyset helps you eliminate slow queries, stabilize latency, and scale confidently.

Revolutionize your database performance with Readyset

Serve requests at sub-millisecond latencies with the modern database scaling and query caching system for MySQL and PostgreSQL.

Join our newsletter

Stay updated with the latest news, insights, and developments from Readyset — straight to your inbox.

© 2026 Readyset. All rights reserved.