<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ReadySet</title>
    <description>The latest articles on DEV Community by ReadySet (@readyset).</description>
    <link>https://dev.to/readyset</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1232832%2Fad488c0d-7def-48e6-a0e2-b8ad5355fabe.png</url>
      <title>DEV Community: ReadySet</title>
      <link>https://dev.to/readyset</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/readyset"/>
    <language>en</language>
    <item>
      <title>Meet the RDST Agent</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Wed, 14 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/readyset/meet-the-rdst-agent-26fb</link>
      <guid>https://dev.to/readyset/meet-the-rdst-agent-26fb</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnbw3j7hng82a0f6kmj3r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnbw3j7hng82a0f6kmj3r.png" alt="Meet the RDST Agent" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An always-on agent that catches every slow query on your PostgreSQL or MySQL database and gives you the exact fix. It runs on your computer.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Product Updates | Readyset team | October 14, 2026&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When a query gets slow, a customer usually notices first. The RDST Agent is built to catch it before that. It's a free, always-on agent that runs on your computer, watches every query on your Postgres or MySQL database, and hands you the exact fix.&lt;/p&gt;

&lt;p&gt;Teams with a DBA or a monitoring budget usually have tooling that watches for this. A lot of teams running Postgres or MySQL on RDS, Supabase, Neon or their own servers don't, so query performance tends to get checked after something breaks.&lt;/p&gt;

&lt;p&gt;Today we're releasing the &lt;strong&gt;RDST Agent&lt;/strong&gt; , a free, always-on agent that runs on your computer, catches every slow query on your database, and gives you the exact fix when something goes wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5wpc13pkwtxekmfz2rql.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5wpc13pkwtxekmfz2rql.png" alt="RDST Agent home screen showing two monitored databases, one item that needs attention, and the day's agent activity" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Home: two databases, one thing that needs you, and everything the agent did today.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To show what that looks like, we'll follow one afternoon on &lt;code&gt;shop-prod&lt;/code&gt;, the PostgreSQL database behind an online store.&lt;/p&gt;

&lt;h2&gt;
  
  
  It keeps a history of every query
&lt;/h2&gt;

&lt;p&gt;When you turn on monitoring, RDST reads your database's query statistics every minute, from &lt;code&gt;pg_stat_statements&lt;/code&gt; on PostgreSQL or &lt;code&gt;performance_schema&lt;/code&gt; on MySQL. It builds a history for every query: how often it runs, how long it takes and how much of the database's time it uses. The last 48 hours stay minute by minute, and older readings roll up into hourly and daily summaries that reach back months.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;shop-prod&lt;/code&gt;, the query behind the store's order history page usually takes about 6 ms. At 2:05 PM a deploy adds a new "My orders" page, and that same query starts taking 184 ms. Within minutes it's using almost a third of the database's time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fijle4rw2lkoffgx79173.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fijle4rw2lkoffgx79173.png" alt="Chart of shop-prod at 2:14 PM showing the order history query spiking from 6 ms to 184 ms after the deploy, with a desktop notification" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;shop-prod at 2:14 PM. Order history jumps after the deploy, and the agent sends a desktop notification.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It notices, and it knows what's worth your time
&lt;/h2&gt;

&lt;p&gt;Every 5 minutes the agent compares each busy query against its own history. It looks for queries that got slower or busier, changed plans, got stuck or started waiting on locks.&lt;/p&gt;

&lt;p&gt;Most of those changes don't deserve an interruption. Jev, Readyset's attention model, decides which ones do. Order history getting 30x slower clears the bar, so at 2:14 PM it lands in the Inbox under &lt;strong&gt;Act now&lt;/strong&gt; and a notification shows up on the desktop. Smaller wobbles stay on the chart for when you go looking.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqpo1zg1daymrpf5oq0f2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqpo1zg1daymrpf5oq0f2.png" alt="Inbox item explaining what changed in the order history query and what to check, with the SQL for each step" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The Inbox item explains what changed and what to check, with the SQL for each step.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It does the analysis nobody has time for
&lt;/h2&gt;

&lt;p&gt;Two minutes later, the agent has a deep analysis. It runs &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on the query with a real &lt;code&gt;customer_id&lt;/code&gt; sampled from live traffic, and the plan shows the problem right away. Postgres is reading all 2.4 million rows of &lt;code&gt;orders&lt;/code&gt; to find one customer's 20 most recent orders. The fix is a composite index on &lt;code&gt;customer_id&lt;/code&gt; and &lt;code&gt;created_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;New heavy queries get this treatment as they show up, and every night the agent writes a report for each database with what to fix first, along with checks for bloat, unused indexes, settings and replication.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2nnoeavthhpdg61lfgs4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2nnoeavthhpdg61lfgs4.png" alt="Deep analysis scoring the query, showing what the execution plan is doing, and naming the composite index to add on customer_id and created_at" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The analysis scores the query, shows what the plan is doing, and names the index to add.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Actions: one list of changes worth making
&lt;/h2&gt;

&lt;p&gt;Everything the agent learns feeds &lt;strong&gt;Actions&lt;/strong&gt; , a ranked list of the changes worth making across your databases. The new index goes straight to the top of &lt;strong&gt;Do first&lt;/strong&gt; , because it saves about 7 seconds of database time every minute.&lt;/p&gt;

&lt;p&gt;Right below it is an index to drop. &lt;code&gt;orders_status_idx&lt;/code&gt; hasn't been used in 30 days, and every one of the 220 writes a second to &lt;code&gt;orders&lt;/code&gt; still has to maintain it. The agent weighs each index against how often its table gets written to, so it'll recommend removing dead weight as readily as adding what's missing. Each action comes with the SQL to run and the SQL to undo it, and you can copy it as a migration for Alembic, Prisma, Rails or Flyway.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fifezi43ezlwc5khqtig6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fifezi43ezlwc5khqtig6.png" alt="Actions list for both databases, ranked into Do first, Then, and Later, each with the SQL to run and to undo" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Actions for both databases, ranked into Do first, Then and Later.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;RDST never writes to your database.&lt;/strong&gt; You ship the change the way you ship any other migration, and the agent checks whether it worked.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At 3:02 PM the new index shows up on &lt;code&gt;shop-prod&lt;/code&gt;. The agent measures Order history before and after: 184 ms down to 3.8 ms, and from 31% of the database's time to 2%. The action closes as Verified.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8spjoz6vkv39dn1i5ag.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8spjoz6vkv39dn1i5ag.png" alt="Verified action showing the order history query measured before and after the index: 184 ms down to 3.8 ms, 31% of database time down to 2%" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Verified: the agent measured the query before and after the index went in.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it runs on your desktop
&lt;/h2&gt;

&lt;p&gt;We could have started with a hosted service. We started on the desktop because it's the shortest path to the databases people actually care about.&lt;/p&gt;

&lt;p&gt;Your database credentials stay in your operating system's keychain. The connection runs from your machine straight to your database, so there's nothing to install on your servers, and Readyset never needs network access to your database. It reaches whatever your machine can reach, including a local Docker container, a database behind your VPN, or a private instance through an SSH jump host. Query history lives in a local SQLite database on your computer.&lt;/p&gt;

&lt;p&gt;There's a tradeoff. The agent only collects while your computer is awake, and right now it's built for one person. For teams that want an agent running around the clock on their own infrastructure, we're working out a cloud version for enterprise teams. If that's you, tell us what you'd need at &lt;a href="mailto:hello@readyset.io"&gt;hello@readyset.io&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets sent to AI
&lt;/h2&gt;

&lt;p&gt;Collecting stats, keeping history, running health checks and doing the arithmetic in reports all happen on your machine with no model involved. Two things go out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep analyses and reports&lt;/strong&gt; run on Claude Haiku from Anthropic. We don't send anything to foreign models. A request carries the query, its execution plan and the schema of the tables it touches, stripped of your data: no table contents, no result rows and no credentials. Anthropic doesn't train on API data. If you'd rather use your own Anthropic key, analysis goes straight from your machine to Anthropic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jev&lt;/strong&gt; sees normalized query text along with table and index statistics, which is what it needs to decide what's worth your attention.&lt;/p&gt;

&lt;p&gt;Every AI call the agent makes is listed on the Agent activity page, with what it did and when. The privacy docs cover the rest.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4hvrq8zjs21nmmexz19w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4hvrq8zjs21nmmexz19w.png" alt="Agent activity page listing every check, analysis, and verification the agent ran that Tuesday" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Agent activity: every check, analysis and verification from that Tuesday.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;RDST is free and open source under Apache 2.0. It supports PostgreSQL 13+ and MySQL 8.0+ and runs on macOS (Apple Silicon), Windows and Linux. There's a demo database built in if you want to watch the agent work before you point it at your own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://readyset.io/downloads" rel="noopener noreferrer"&gt;Download RDST&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the docs | &lt;a href="https://github.com/readysettech/rdst" rel="noopener noreferrer"&gt;RDST on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We'd love to hear what it finds on your database, and what it gets wrong. Come tell us in the &lt;a href="https://go.readyset.io/community" rel="noopener noreferrer"&gt;Readyset Slack&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>productupdates</category>
      <category>querycaching</category>
    </item>
    <item>
      <title>What Is a Wire-Compatible SQL Caching Proxy?</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Thu, 08 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/readyset/what-is-a-wire-compatible-sql-caching-proxy-24oi</link>
      <guid>https://dev.to/readyset/what-is-a-wire-compatible-sql-caching-proxy-24oi</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvqo6ufhdcbtm37fotol.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvqo6ufhdcbtm37fotol.png" alt="What Is a Wire-Compatible SQL Caching Proxy?" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A wire-compatible SQL caching proxy sits between your application and your database, speaking the same protocol the database speaks. Because of this, your app can connect to the proxy instead of the database without any code changes, just swap the hostname or port.&lt;/p&gt;

&lt;p&gt;The idea is simple. Right now, every query goes straight to the database:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmwhxrauwz0qjh5vb7n2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmwhxrauwz0qjh5vb7n2.png" alt="Diagram: application connecting directly to the database" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With a caching proxy in front, the first time a query runs, it goes to the database as usual and the result gets cached:&lt;/p&gt;

&lt;p&gt;The next time that same query runs, the proxy can skip the database entirely and answer from cache.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96ekoq9atsa2b3nzq36r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96ekoq9atsa2b3nzq36r.png" alt="Diagram: application querying through a caching proxy, served from cache on repeat" width="799" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This cuts down on database load: fewer round trips, less query execution, and lower CPU and I/O usage, especially for read-heavy workloads where the same queries run over and over.&lt;/p&gt;

&lt;p&gt;The tricky part is that caching SQL isn't as simple as storing a query string and its result. The proxy has to figure out when two queries are really asking for the same thing, know when cached data has gone stale and needs to be refreshed, and handle things like transactions, prepared statements, and session state correctly, all while staying invisible to the application.&lt;/p&gt;

&lt;p&gt;In short: the goal is to make the database faster without the application ever knowing a cache is there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How is this different from Redis or application-level caching?
&lt;/h2&gt;

&lt;p&gt;The short version: Redis and application-level caching put the caching decisions in your code. A wire-compatible SQL caching proxy puts them in a separate layer, so your application doesn't have to think about caching at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How Redis-style caching works.&lt;/strong&gt; Your application talks to two systems and has to coordinate between them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkawanzrbdbyqgjajeerw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkawanzrbdbyqgjajeerw.png" alt="Diagram: application talking to both Redis and the database" width="800" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your code has to spell out the caching logic itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM users WHERE id = 123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means your application now owns a lot of extra work: deciding on cache keys, converting objects to and from a storable format, setting expiration times, invalidating stale entries, handling cache misses, avoiding the "stampede" problem where many requests miss the cache at once and all hit the database together, and deciding upfront which queries are even worth caching. Redis is a general-purpose store, it doesn't know anything about SQL or your database schema. You decide what goes in it, which gives you a lot of control, but all of that logic lives in your application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How a wire-compatible SQL proxy works.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9km8rah2sbvlk7f7z52v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9km8rah2sbvlk7f7z52v.png" alt="Diagram: application connecting to the database through a SQL caching proxy in line" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your application just sends its normal SQL, exactly as before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r2a3e4zy8ytyrq2i81o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r2a3e4zy8ytyrq2i81o.png" alt="Diagram: the same SELECT query passing untouched through the proxy at every hop, application to proxy to database" width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The proxy recognizes the query, checks whether it already has a valid and fresh cached answer, and returns it if so, otherwise it goes to the database like normal and caches the result on the way back. Your application never sees a difference and never has to check whether a result came from cache or not.&lt;/p&gt;

&lt;p&gt;The core difference, in one line: Redis caches whatever your application explicitly tells it to; a SQL caching proxy caches query results automatically, with no caching API involved, the database connection itself just gets faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They're not competitors.&lt;/strong&gt; A SQL caching proxy doesn't replace Redis, they solve different problems and often work side by side. Redis is still the right fit for sessions, queues, rate limiting, and general application data. A SQL caching proxy takes over one specific job: making repeated database queries faster, without your code managing any of it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fudls0g7r7beivakqzdfk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fudls0g7r7beivakqzdfk.png" alt="Diagram: Redis and a SQL caching proxy serving different jobs side by side" width="799" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How is this different from a plain database caching proxy?
&lt;/h2&gt;

&lt;p&gt;They're closely related, a wire-compatible SQL caching proxy is really just a stricter, more transparent version of a database caching proxy. The difference comes down to one thing: how convincingly it impersonates the real database.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Regular caching proxy&lt;/th&gt;
&lt;th&gt;Wire-compatible SQL caching proxy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Protocol&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Its own separate API&lt;/td&gt;
&lt;td&gt;The database's native wire protocol&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Driver / client library&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Often a different one&lt;/td&gt;
&lt;td&gt;Same driver, unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Connection string&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Points at the caching API&lt;/td&gt;
&lt;td&gt;Same connection string as before&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Code changes required&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Special calls (&lt;code&gt;cache.get(...)&lt;/code&gt;), rewritten queries&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Architecture&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application → Caching API → Database&lt;/td&gt;
&lt;td&gt;Application → Proxy → Database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;App's awareness of the cache&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Knows it's there, cache logic lives in the codebase&lt;/td&gt;
&lt;td&gt;None, has no idea it's not talking to the real database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What actually happens behind the scenes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reads the query, checks for a cached result, returns it if valid and fresh, otherwise queries the database and caches the result&lt;/td&gt;
&lt;td&gt;Same steps, but invisibly, the app just sees a normal query and a normal result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5snii70mau1kzqf8dzjt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5snii70mau1kzqf8dzjt.png" alt="Diagram: side-by-side comparison of a regular caching proxy and a wire-compatible SQL caching proxy" width="798" height="199"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A closer look with MySQL
&lt;/h2&gt;

&lt;p&gt;MySQL is one of the databases this kind of proxy typically supports, alongside PostgreSQL. The idea works exactly the same way, the proxy just needs to speak MySQL's specific wire protocol.&lt;/p&gt;

&lt;p&gt;When a MySQL client connects, the server speaks first: it sends a handshake packet with a protocol version, server version string, and an authentication challenge (a random "salt"). The client responds with its username, a hashed password computed using that salt, its character set, and various capability flags. A wire-compatible proxy has to generate and validate this exact exchange, including MySQL's specific authentication plugins (&lt;code&gt;mysql_native_password&lt;/code&gt;, &lt;code&gt;caching_sha2_password&lt;/code&gt;, and others), so that any standard driver, mysql2 for Node, PyMySQL or mysqlclient for Python, Connector/J for Java, completes its handshake without ever suspecting it isn't talking to real MySQL.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F80kuw3xy0at5z5qoo2me.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F80kuw3xy0at5z5qoo2me.png" alt="Diagram: MySQL handshake and query exchange between application and a wire-compatible proxy" width="800" height="960"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MySQL clients also don't only send raw query text, the protocol defines command types like &lt;code&gt;COM_QUERY&lt;/code&gt; for plain queries and &lt;code&gt;COM_STMT_PREPARE&lt;/code&gt;/&lt;code&gt;COM_STMT_EXECUTE&lt;/code&gt; for prepared statements, which most ORMs and drivers use by default. A proxy that only understood &lt;code&gt;COM_QUERY&lt;/code&gt; would break the moment an application used prepared statements, so it needs to track prepared statement handles per connection and cache based on the query template plus its bound parameters, not just the raw bytes sent over the wire.&lt;/p&gt;

&lt;p&gt;Results come back as a sequence of packets too, column definitions, then row data, then a closing packet, and the proxy has to reconstruct all of this exactly as MySQL would, matching data types and encodings so the driver doesn't choke on it. On top of that, MySQL connections carry session state (the current database, session variables, isolation level, autocommit mode) and support transactions, so the proxy has to track that state and be careful not to serve cached data where it would break consistency, such as inside an open transaction.&lt;/p&gt;

&lt;p&gt;Because all of this is MySQL-specific plumbing, a proxy has to implement it separately from whatever it does for PostgreSQL, which has its own different wire protocol and message framing entirely. Products like ProxySQL, Vitess, and Readyset are examples of tools that do this kind of work for MySQL (some, including Readyset, support both MySQL and PostgreSQL by implementing both protocols side by side). Being "wire-compatible with MySQL" is a specific, real engineering claim: the proxy correctly implements MySQL's handshake, its authentication plugins, its command protocol, its result encoding, and enough of its session and transaction semantics that a MySQL driver, and the application built on top of it, genuinely cannot tell it isn't talking to MySQL itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes in your application code?
&lt;/h2&gt;

&lt;p&gt;This is where a wire-compatible proxy becomes particularly interesting. With Redis-style caching, adding a cache usually means rewriting the code that touches your data. Your simple query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM products WHERE category = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;laptops&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;turns into something with two paths to think about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;products:laptops&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM products WHERE category = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;laptops&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;products:laptops&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every developer touching this code has to understand both the cache-hit path and the cache-miss path.&lt;/p&gt;

&lt;p&gt;With a wire-compatible proxy, your code doesn't change at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM products WHERE category = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;laptops&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proxy silently takes on the hit-or-miss decision, unnoticed by anything above it, answering directly on a cache hit and asking the database then caching the result on a miss.&lt;/p&gt;

&lt;p&gt;Usually, the only real change is where your application points its connection. For a Postgres app, instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgres://app:password@database:5432/shop

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;it connects to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgres://app:password@proxy:5432/shop

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same is true for MySQL. Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mysql://app:password@database:3306/shop

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the application connects to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mysql://app:password@proxy:3306/shop

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. If your app uses mysql2 in Node, PyMySQL in Python, or Connector/J in Java, none of that code changes, you're not swapping libraries or adding a cache client, just pointing the same driver at a different host and port. Your ORM keeps generating the same queries it always did, completely unaware that a proxy is now answering some of them itself.&lt;/p&gt;

&lt;p&gt;Same driver, same SQL, same ORM, same data access code; nothing in the application layer needs to change.&lt;/p&gt;

&lt;p&gt;That said, "no code changes" doesn't mean "no setup at all." Rolling this out in production still usually involves some work: pointing things at the right connection endpoint, adjusting infrastructure, deciding which queries should be cached and for how long, thinking through how the cache stays consistent with the database, and setting up monitoring around it.&lt;/p&gt;

&lt;p&gt;So the more accurate claim is: wire compatibility doesn't eliminate configuration, it eliminates the need to rewrite application code, because the proxy looks exactly like the database to everything talking to it. That's what "drop-in" should actually mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Readyset works as a wire-compatible proxy
&lt;/h2&gt;

&lt;p&gt;Readyset is a real-world example of everything above. It sits directly in the path between your application and your database, rather than off to the side the way Redis does.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8mjf3c81phqfb3f54un0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8mjf3c81phqfb3f54un0.png" alt="The application keeps sending normal SQL and getting normal results back, while Readyset separately watches the database's replication stream for data changes." width="800" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your app keeps sending normal SQL queries and getting normal query results back, it has no idea Readyset is even there. Meanwhile, Readyset watches the database's replication stream (its binlog, in MySQL's case) to learn about every write as it happens, so it always knows when its cached answers need updating.&lt;/p&gt;

&lt;p&gt;Reads and writes travel different paths. Writes always go straight to the real database, they have to land in the source of truth. Reads are where Readyset earns its keep: if the answer's already cached, it hands it back instantly, no round trip, no query execution, nothing asked of the database at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr4rdzemsdd14if96nvz2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr4rdzemsdd14if96nvz2.png" alt="Two common ways to wire it up: the application talking to Readyset directly, or a query router deciding where each query goes. A normal read replica can still exist alongside either setup." width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This shows two common ways to set it up. On the left, your application talks to Readyset directly for anything that might be cached, and talks to the database directly for writes or queries Readyset hasn't cached. On the right, a query router sits in front of both and decides where each query should go, so the application doesn't even need to know Readyset exists as a separate destination. Either way, a normal read replica can still exist alongside Readyset, they're solving different problems: a read replica spreads out database load, while Readyset skips hitting the database at all for the queries it already knows the answer to.&lt;/p&gt;

&lt;p&gt;This is a step up from a typical read-replica setup. Many teams already scale reads with something like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0y7uyanm2efcn0m96qwb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0y7uyanm2efcn0m96qwb.png" alt="A common existing pattern: HAProxy routes writes to a MySQL primary and reads to a read-only replica, kept in sync through logical replication." width="767" height="615"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this setup, HAProxy operates at Layer 4, it just forwards TCP connections to whichever backend a pre-configured port or pool points to, without ever reading what's inside them. It can split traffic toward a writable primary and a read-only replica, but only because the application (or its connection config) already decided which port to use; HAProxy itself has no idea whether a given connection is carrying a SELECT or an INSERT. And either way, every read that reaches the replica still has to run as a full query against a real database. Readyset replaces that pattern with something smarter: because it actually reads and understands the SQL passing through it, it can answer many reads without touching a database at all, not just route them to one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually happens inside Readyset.&lt;/strong&gt; This is the part that makes it more than a simple cache:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4128k3kuju0axm4hgfle.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4128k3kuju0axm4hgfle.png" alt="Readyset builds a small internal pipeline for each cached query: base table nodes feed SQL operator nodes that mirror the query's joins, filters, and aggregations, landing in a reader node that holds the ready-to-serve result." width="800" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Readyset builds a small internal pipeline for each query it's asked to cache. Changes to the underlying tables (the "base table nodes") flow in from the database, pass through a chain of "SQL operator nodes" that mirror the actual joins, filters, and aggregations your query performs, and land in a "reader node" that holds the ready-to-serve result. When a row in the database changes, only the affected part of this pipeline needs to redo its work, not the whole query from scratch. This pattern is known as incremental view maintenance, and it's why Readyset is better described as a live, self-updating computation rather than a store you fill and empty by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: caching, moved to the database wire
&lt;/h2&gt;

&lt;p&gt;The idea behind a wire-compatible SQL caching proxy comes down to one simple move: instead of every application building its own caching logic, you put a proxy between the app and the database that speaks the same protocol, understands the SQL flowing through it, and transparently answers what it can from cache.&lt;/p&gt;

&lt;p&gt;That one change gets you a few things at once. Your existing queries, drivers, and ORM code mostly stay untouched. Your app never has to check a cache or invalidate one; it just sends SQL like normal. Because the proxy actually understands queries and the data behind them, it can keep answers correct as things change, not just guess with a timer. And your database itself sees less repeated work, since the proxy is absorbing traffic it would otherwise have to redo.&lt;/p&gt;

&lt;p&gt;This is genuinely different from something like Redis. Redis gives you full control over what you cache, but you have to build and maintain that logic yourself. A wire-compatible proxy takes on that job itself, sitting closer to the database, so caching becomes something that just happens rather than something your team maintains.&lt;/p&gt;

&lt;p&gt;A plain TCP load balancer like HAProxy can't do this split on its own; it just forwards connections without ever reading what's inside them, so it has no way to tell a cached-eligible read apart from a write. The split only works because the proxy sitting in this position actually speaks SQL and reads every query:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzn2z26xqrrhqkgsoazc0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzn2z26xqrrhqkgsoazc0.png" alt="Diagram: a wire-compatible SQL caching proxy answering cached reads directly and passing writes and uncached reads through to MySQL" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bigger point is simple: don't make every team solve database caching on their own. Put that intelligence in the connection layer once, in a proxy that actually understands SQL, not just one that forwards bytes, and let it quietly make things faster for every application behind it. As more systems lean on read-heavy, increasingly complex queries, that's a real advantage, not just quicker responses, but a way to speed things up without asking developers to rethink how their code talks to the database in the first place.&lt;/p&gt;

&lt;p&gt;Caching doesn't have to live in your application anymore. It can live in the connection itself.&lt;/p&gt;

&lt;p&gt;See deep caching and the command reference for how a cached result stays correct as your data changes. Run &lt;code&gt;rdst analyze&lt;/code&gt; to find out which of your own queries are candidates before you write a line of code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://readyset.io/downloads" rel="noopener noreferrer"&gt;Install RDST&lt;/a&gt;&lt;/p&gt;

</description>
      <category>databasecaching</category>
    </item>
    <item>
      <title>September Product Update 2026</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Thu, 01 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/readyset/september-product-update-2026-4j5m</link>
      <guid>https://dev.to/readyset/september-product-update-2026-4j5m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdu9r4vhthr2zfvheq5s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdu9r4vhthr2zfvheq5s.png" alt="September Product Update 2026" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;September was a packed month at Readyset, especially for RDST, and we're excited to be sharing the full release update with you.&lt;/p&gt;

&lt;h2&gt;
  
  
  RDST Is Now Apache 2 Licensed and Includes Unlimited Free Inferencing
&lt;/h2&gt;

&lt;p&gt;We launched two big changes to RDST this month. First, it's now Apache 2 licensed. &lt;a href="https://github.com/readysettech/rdst" rel="noopener noreferrer"&gt;Use it, fork it, build on it, ship it inside your own tooling&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Second, hosted inference is on us for anyone signed in with a Readyset account. This means no API key required and no trial token limits. The default allowance covers hundreds of real questions a month per user, and if you need more, just reach out and we'll bump your allowance. Inference routes through OpenRouter across four US providers with automatic failover, so a single provider outage doesn't take RDST down with it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://readyset.io/blog/whats-new-in-rdst-free-inference-and-an-apache-20-license" rel="noopener noreferrer"&gt;More details here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Single Readyset Node Now Serves 4.3M Queries/Second
&lt;/h2&gt;

&lt;p&gt;In our latest benchmark, Readyset reached 4.3 million queries per second on a single node, 6.4x the previous release's peak throughput on exactly the same hardware.&lt;/p&gt;

&lt;p&gt;The biggest improvement came from distributing connections across multiple Tokio runtimes, allowing Readyset to use CPU capacity that previously sat idle. Alongside a set of profiling-driven improvements that together cut CPU cost per query from 45.7μs to 21.7μs.&lt;/p&gt;

&lt;p&gt;Latency improved too. At 2,240 connections, throughput rose from 689,186 QPS to 4,111,367 QPS while p95 latency fell from 5.18ms to 0.75ms.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://readyset.io/case-studies/how-we-reached-43-million-qps-for-mysql-and-postgresql-workloads-on-a-single-readyset-node" rel="noopener noreferrer"&gt;Read the full benchmark here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Product Updates
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Passwords Redacted in Logs:&lt;/strong&gt; The adapter and server no longer write upstream, CDC, or client passwords to the log in clear text on startup. Passwords now appear as &lt;code&gt;&amp;lt;redacted&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transient Errors Now Signal Retry-Safe:&lt;/strong&gt; Readyset returns dialect-specific transient error codes on transient failures, giving clients and proxies a signal that the query is safe to retry automatically. Code &lt;code&gt;1317&lt;/code&gt; (query interrupted) for MySQL and &lt;code&gt;55000&lt;/code&gt; (database object not in prerequisite state) for PostgreSQL. Schema generation mismatch errors are reported as transient as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correct Row Comparison Behavior in MySQL:&lt;/strong&gt; A batch of fixes to row comparison behavior under MySQL. Row comparisons that were losing their predicate (widening the query to the whole table) are fixed. Comparisons between a number and a numeric string now resolve correctly instead of as unequal. A &lt;code&gt;NULL&lt;/code&gt; inside a row now compares correctly instead of as an ordinary value. Prepared row comparisons are also no longer advertised to the client as taking no parameters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL Cache Correctness Fixes:&lt;/strong&gt; Named prepared statements filling a shallow cache no longer return binary-encoded values to clients that requested text format. &lt;code&gt;bytea&lt;/code&gt; values are now encoded and decoded in the correct format. Prepared statements planned for a shallow cache that was later dropped now proxy upstream until the cache is recreated, instead of returning an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy-Paste-Ready EXPLAIN CACHES:&lt;/strong&gt; &lt;code&gt;EXPLAIN CACHES&lt;/code&gt; now prefixes each &lt;code&gt;CREATE SHALLOW CACHE&lt;/code&gt; statement with &lt;code&gt;USE &amp;lt;db&amp;gt;;&lt;/code&gt; for MySQL or &lt;code&gt;SET search_path = &amp;lt;schema&amp;gt;;&lt;/code&gt; for PostgreSQL, and renders query placeholders in valid positions. That makes the output copy-paste-ready to recreate shallow caches across multiple databases or schemas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shallow Caches Persist Across Restarts:&lt;/strong&gt; Shallow caches created via &lt;code&gt;--auto-cache&lt;/code&gt;, &lt;code&gt;CREATE CACHE&lt;/code&gt; hints, or &lt;code&gt;CREATE CACHE FROM &amp;lt;query id&amp;gt;&lt;/code&gt; (from &lt;code&gt;SHOW PROXIED QUERIES&lt;/code&gt;) are now correctly recreated when Readyset restarts, instead of being lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching Reliability Fixes:&lt;/strong&gt; A batch of caching reliability fixes. Sessions using different collations now get separate shallow-cache entries so sorting stays correct. The ACL probe no longer incorrectly disables shallow caches when Readyset's parameterization differs from valid placeholder positions on the upstream. &lt;code&gt;CREATE CACHE&lt;/code&gt; no longer crashes on queries with a very large &lt;code&gt;LIMIT&lt;/code&gt;, and a race where an adapter reconnecting during DDL could briefly use an outdated schema, causing &lt;code&gt;CREATE CACHE&lt;/code&gt; to fail, is fixed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus 9 additional fixes and improvements. Check out the full release &lt;a href="https://github.com/readysettech/readyset/releases/tag/stable-260924" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  New on the Blog &amp;amp; YouTube
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://readyset.io/blog/cache-invalidation-sql-queries" rel="noopener noreferrer"&gt;Cache Invalidation for SQL Queries: How to Stop Writing It Yourself&lt;/a&gt;: Traditional caching means deciding what to invalidate after every write. Miss a dependency, and your cache can quietly serve stale results. This benchmark compares six caching strategies against the same workload to show what that mistake costs, and how incremental view maintenance avoids the invalidation problem entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upcoming Events
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL Conference Europe 2026&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We are heading to Valencia on October 20-23 for the 2026 PostgreSQL Conference in Europe. If you'll be there and want to talk caching, query performance, or anything else Postgres-related, we'd love to connect. Coffee's on us. &lt;a href="https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ1IdojpwlPvtg_uhLImDN0lSbGN8jFc5AMaX3yheHv8K97JfDOZjVGEGJDeJ4wpqmEMGpglLde3" rel="noopener noreferrer"&gt;Book a time here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://2026.pgconf.eu/" rel="noopener noreferrer"&gt;Full event details here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Involved
&lt;/h2&gt;

&lt;p&gt;Questions, bugs, ideas, or just want to say hi? Find us on &lt;a href="https://go.readyset.io/community" rel="noopener noreferrer"&gt;Slack&lt;/a&gt;. Looking to contribute? Our &lt;a href="https://github.com/readysettech/readyset/issues?q=is%3Aissue+is%3Aopen+label%3Afirst-issue" rel="noopener noreferrer"&gt;newcomer issues&lt;/a&gt; are a good place to start.&lt;/p&gt;

</description>
      <category>productupdates</category>
    </item>
    <item>
      <title>What's New in RDST: Free Inference and an Apache 2.0 License</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Tue, 29 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/readyset/whats-new-in-rdst-free-inference-and-an-apache-20-license-8n7</link>
      <guid>https://dev.to/readyset/whats-new-in-rdst-free-inference-and-an-apache-20-license-8n7</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1x53s7vy7y6kp5lgzn33.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1x53s7vy7y6kp5lgzn33.png" alt="What's New in RDST: Free Inference and an Apache 2.0 License" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few months ago &lt;a href="https://readyset.io/blog/introducing-rdst-the-client-side-sql-performance-toolkit" rel="noopener noreferrer"&gt;we shipped RDST&lt;/a&gt;, our command-line tool for analyzing query performance on Postgres and MySQL databases. It ranks the queries running against your database by what they cost, and helps you understand and fix the ones slowing you down. It works with managed targets like RDS, Aurora, and Cloud SQL. More recently, we packed it into a &lt;a href="https://readyset.io/downloads" rel="noopener noreferrer"&gt;full desktop app&lt;/a&gt; available on Mac, Windows, and Linux and added natural-language querying, database-level health analysis, and index and rewrite suggestions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://readyset.io/downloads" rel="noopener noreferrer"&gt;RDST&lt;/a&gt; finds the queries costing you the most time on your own database, walks through why they're slow using real execution plans, and hands you specific index changes, query rewrites, or cache candidates that have been checked against your actual schema. When you'd rather just ask a question of your data than write SQL by hand, RDST does that too, and validates the SQL against your schema before it runs.&lt;/p&gt;

&lt;p&gt;We've now expanded RDST with two big upgrades.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free inference, no API key required
&lt;/h2&gt;

&lt;p&gt;Previously, running RDST meant bringing your own Anthropic key and paying for your own tokens. Now, if you sign in with a Readyset account, RDST runs on hosted inference we cover across every AI-powered part of the app.&lt;/p&gt;

&lt;p&gt;The default per-account allowance covers hundreds of real questions a month, which is enough for most day-to-day use. If you need more, email us and we'll raise your limit.&lt;/p&gt;

&lt;p&gt;Anyone running an LLM against their production database queries has a right to know exactly what leaves their machine. Transparency about this has been important to Readyset since day one. The &lt;a href="https://readyset.io/docs/readyset-ai/rdst/desktop/privacy" rel="noopener noreferrer"&gt;RDST privacy notice&lt;/a&gt; covers what gets sent to the model, what stays local, and what happens on our side of the hosted inference. If you find a scenario where our notice on privacy or LLM usage falls short, let us know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apache 2.0
&lt;/h2&gt;

&lt;p&gt;RDST was previously distributed under the Business Source License, which limited commercial redistribution. Under Apache 2.0, that limit is gone. Open source removes the licensing conversation from adoption, so teams evaluating RDST for internal tooling no longer need to route the decision through legal review. Fork it, ship it inside your own tooling, embed it in an internal developer platform, or vendor pieces of it into your own project. The source is on &lt;a href="https://github.com/readysettech/rdst" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and contributions are open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://readyset.io/downloads" rel="noopener noreferrer"&gt;Install RDST&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Have questions or want to talk to other RDST users? Join us &lt;a href="https://go.readyset.io/community" rel="noopener noreferrer"&gt;on the Readyset community Slack&lt;/a&gt;. Full &lt;a href="https://readyset.io/docs/readyset-ai/rdst" rel="noopener noreferrer"&gt;RDST documentation&lt;/a&gt; is available on our docs site.&lt;/p&gt;

</description>
      <category>productupdates</category>
    </item>
    <item>
      <title>Cache Invalidation for SQL Queries: How to stop writing it yourself</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Mon, 21 Sep 2026 14:48:50 +0000</pubDate>
      <link>https://dev.to/readysettech/cache-invalidation-for-sql-queries-how-to-stop-writing-it-yourself-4ho2</link>
      <guid>https://dev.to/readysettech/cache-invalidation-for-sql-queries-how-to-stop-writing-it-yourself-4ho2</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a vote arrives, drop that story's key.&lt;/li&gt;
&lt;li&gt;If a title changes, drop that story's key.&lt;/li&gt;
&lt;li&gt;If an account is renamed, drop that user's key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule per table. It reviews cleanly and it ships.&lt;/p&gt;

&lt;p&gt;One of those three rules is wrong. The page it breaks keeps serving stale data until something unrelated evicts the entry.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one of those rules is wrong
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://pdos.csail.mit.edu/papers/noria:osdi18.pdf" rel="noopener noreferrer"&gt;Noria paper&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;stories&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;votes&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;user&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;story_id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The lab adds a primary key to each of the three tables.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A story page reads across all three. It needs a title from &lt;code&gt;stories&lt;/code&gt;, a display name from &lt;code&gt;users&lt;/code&gt;, and a count from &lt;code&gt;votes&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;stories&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The edge that matters is &lt;code&gt;u.id = s.author&lt;/code&gt;. It says that changing a user dirties story pages. Nothing in the &lt;code&gt;users&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Almost nobody attempts precise invalidation
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://arxiv.org/abs/2311.02384" rel="noopener noreferrer"&gt;2023 survey by Ji, Zhou, Zhou and Wang&lt;/a&gt; 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.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Even teams who do this well find it hard. &lt;a href="https://engineering.fb.com/2022/06/08/core-infra/cache-made-consistent/" rel="noopener noreferrer"&gt;Meta's Polaris service&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache invalidation strategies for SQL queries
&lt;/h2&gt;

&lt;p&gt;Four strategies are in common use:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time to live.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual cache keys, the Redis model.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event-driven invalidation.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incremental view maintenance.&lt;/strong&gt; Apply each change to the stored result instead of discarding it. There is no invalidation step to write, because nothing is deleted.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Who writes the invalidation&lt;/th&gt;
&lt;th&gt;When the cached result is wrong&lt;/th&gt;
&lt;th&gt;Cost of a write&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to live&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;td&gt;Up to the length of the window, after every write&lt;/td&gt;
&lt;td&gt;None, but refreshes run on a clock whether or not anything changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual cache keys&lt;/td&gt;
&lt;td&gt;The application, per query&lt;/td&gt;
&lt;td&gt;Until eviction, whenever the mapping misses an edge&lt;/td&gt;
&lt;td&gt;The invalidation, plus a burst of misses behind it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event-driven invalidation&lt;/td&gt;
&lt;td&gt;The application, in a stream processor&lt;/td&gt;
&lt;td&gt;Until the mapping is corrected&lt;/td&gt;
&lt;td&gt;Stream processing, plus the burst of misses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental view maintenance&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;td&gt;Only while the change propagates&lt;/td&gt;
&lt;td&gt;Maintenance work inside the dataflow graph&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this post measures the first, the second and the fourth against each other on one schema. One write workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we measured it
&lt;/h2&gt;

&lt;p&gt;The lab runs on the three tables declared above, &lt;code&gt;stories&lt;/code&gt;, &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;votes&lt;/code&gt;, seeded at the scale the &lt;a href="https://pdos.csail.mit.edu/papers/noria:osdi18.pdf" rel="noopener noreferrer"&gt;Noria paper&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;The six paths:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the control is measuring
&lt;/h3&gt;

&lt;p&gt;The control path has no cache, so it cannot be stale. It is there to measure the instrument.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;0.19 ms&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the cache ever become correct?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Read path&lt;/th&gt;
&lt;th&gt;Converged&lt;/th&gt;
&lt;th&gt;Never converged&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no cache (control)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;look-aside, three-rule invalidator&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10 of 10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;look-aside, corrected invalidator&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readyset shallow, fixed refresh&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readyset shallow, adaptive refresh&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readyset deep (IVM)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;SELECT&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How stale each cache gets
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Read path&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;Excess over the uncached control, p50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no cache (control)&lt;/td&gt;
&lt;td&gt;0.19 ms&lt;/td&gt;
&lt;td&gt;0.40 ms&lt;/td&gt;
&lt;td&gt;n/a, this is the control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;look-aside, corrected invalidator&lt;/td&gt;
&lt;td&gt;1.11 ms&lt;/td&gt;
&lt;td&gt;1.57 ms&lt;/td&gt;
&lt;td&gt;0.92 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readyset shallow, fixed refresh&lt;/td&gt;
&lt;td&gt;2.68 s&lt;/td&gt;
&lt;td&gt;4.74 s&lt;/td&gt;
&lt;td&gt;2.68 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readyset shallow, adaptive refresh&lt;/td&gt;
&lt;td&gt;2.29 s&lt;/td&gt;
&lt;td&gt;4.28 s&lt;/td&gt;
&lt;td&gt;2.29 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readyset deep (IVM)&lt;/td&gt;
&lt;td&gt;1.66 ms&lt;/td&gt;
&lt;td&gt;2.17 ms&lt;/td&gt;
&lt;td&gt;1.48 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5jfz9nl67n9cuzj1f9k2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5jfz9nl67n9cuzj1f9k2.png" alt="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" width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The look-aside cache is marginally the freshest of the three.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The advantage lasts only while the query is cheap
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the same measurement, on a query with no index to lean on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;votes&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Read path&lt;/th&gt;
&lt;th&gt;Staleness p50&lt;/th&gt;
&lt;th&gt;Staleness p95&lt;/th&gt;
&lt;th&gt;One read, p50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no cache (control)&lt;/td&gt;
&lt;td&gt;59 ms&lt;/td&gt;
&lt;td&gt;102 ms&lt;/td&gt;
&lt;td&gt;36.62 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;look-aside, corrected invalidator&lt;/td&gt;
&lt;td&gt;42 ms&lt;/td&gt;
&lt;td&gt;51 ms&lt;/td&gt;
&lt;td&gt;0.16 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readyset deep (IVM)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.48 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.96 ms&lt;/td&gt;
&lt;td&gt;0.23 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ordering inverts, and the deep cache is now about 28 times fresher rather than half a millisecond behind.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The TTL cache, given its best shot
&lt;/h3&gt;

&lt;p&gt;Comparing against a fixed refresh period alone would be unfair, so the lab also runs Readyset's shallow cache with &lt;code&gt;ADAPTIVE&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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%.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc0e11yzslw2rjhmni5mi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc0e11yzslw2rjhmni5mi.png" alt="Every write in one run of sixty, for the three Readyset lanes" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What an invalidation costs beyond the invalidation
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Read path&lt;/th&gt;
&lt;th&gt;SELECTs in the write second&lt;/th&gt;
&lt;th&gt;In a quiet second&lt;/th&gt;
&lt;th&gt;Increase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;look-aside, corrected invalidator&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;33x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readyset shallow, fixed refresh&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readyset shallow, adaptive refresh&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readyset deep (IVM)&lt;/td&gt;
&lt;td&gt;no write-correlated increase&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;60 s per arm, one write every 10 s. Com_select is MySQL's own counter.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F28xq6eqft346mrspckfe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F28xq6eqft346mrspckfe.png" alt="Upstream SELECTs during the run" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The write rate is what decides this
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Com_select&lt;/code&gt; 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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Writes/sec on the row&lt;/th&gt;
&lt;th&gt;Design&lt;/th&gt;
&lt;th&gt;Cache hit rate&lt;/th&gt;
&lt;th&gt;SELECTs/sec reaching MySQL&lt;/th&gt;
&lt;th&gt;Writes behind, p50&lt;/th&gt;
&lt;th&gt;Writes behind, p95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;td&gt;look-aside, corrected invalidator&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;td&gt;Readyset shallow, fixed refresh&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;td&gt;Readyset deep (IVM)&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;look-aside, corrected invalidator&lt;/td&gt;
&lt;td&gt;99.9%&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Readyset shallow, fixed refresh&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Readyset deep (IVM)&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;look-aside, corrected invalidator&lt;/td&gt;
&lt;td&gt;99.5%&lt;/td&gt;
&lt;td&gt;163&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Readyset shallow, fixed refresh&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Readyset deep (IVM)&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;look-aside, corrected invalidator&lt;/td&gt;
&lt;td&gt;97.9%&lt;/td&gt;
&lt;td&gt;641&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;Readyset shallow, fixed refresh&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;Readyset deep (IVM)&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;look-aside, corrected invalidator&lt;/td&gt;
&lt;td&gt;94.3%&lt;/td&gt;
&lt;td&gt;1600&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;Readyset shallow, fixed refresh&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;111&lt;/td&gt;
&lt;td&gt;235&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;Readyset deep (IVM)&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsqoztv4dkow6no5yy5r2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsqoztv4dkow6no5yy5r2.png" alt="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" width="800" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two panels are the two different bills a cache can be handed, and each design pays only one of them.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;As the write rate climbs, only one of the three changes is about staleness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The look-aside cache stays current and sends the bill to the database.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Its hit rate over that same sweep goes from 99.99% to 94.27%, and the two readings of that pair disagree completely:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc72jhu0wygvkaq5v5lz2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc72jhu0wygvkaq5v5lz2.png" alt="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" width="800" height="335"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The TTL cache holds upstream load flat and pushes the cost onto the reader.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The maintained view does not move on either axis.&lt;/strong&gt; 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 &lt;code&gt;SELECT VERSION()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What incremental view maintenance is
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;COUNT&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc0pxjxtet9ehfo6ylq14.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc0pxjxtet9ehfo6ylq14.gif" alt="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" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 2018 result that changed the memory problem
&lt;/h3&gt;

&lt;p&gt;In October 2018 a group at MIT CSAIL published &lt;a href="https://pdos.csail.mit.edu/papers/noria:osdi18.pdf" rel="noopener noreferrer"&gt;Noria: dynamic, partially-stateful data-flow for high-performance web applications&lt;/a&gt; at the 13th &lt;a href="https://www.usenix.org/conference/osdi18/presentation/gjengset" rel="noopener noreferrer"&gt;USENIX Symposium on Operating Systems Design and Implementation&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;The paper builds on incremental view maintenance rather than inventing it. Its contribution is &lt;strong&gt;partially stateful dataflow&lt;/strong&gt;, 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."&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;upquery&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Our lab shows the same shape. Reading K distinct story pages and asking Readyset what it is holding, via &lt;code&gt;readyset_reader_state_size_bytes&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Distinct story keys read&lt;/th&gt;
&lt;th&gt;Deep cache state&lt;/th&gt;
&lt;th&gt;Bytes per key&lt;/th&gt;
&lt;th&gt;Share of base tables&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 KB&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;45 KB&lt;/td&gt;
&lt;td&gt;464&lt;/td&gt;
&lt;td&gt;0.22%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;453 KB&lt;/td&gt;
&lt;td&gt;464&lt;/td&gt;
&lt;td&gt;2.17%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;2.2 MB&lt;/td&gt;
&lt;td&gt;464&lt;/td&gt;
&lt;td&gt;10.84%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20,000&lt;/td&gt;
&lt;td&gt;8.9 MB&lt;/td&gt;
&lt;td&gt;464&lt;/td&gt;
&lt;td&gt;43.37%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40,000&lt;/td&gt;
&lt;td&gt;17.7 MB&lt;/td&gt;
&lt;td&gt;464&lt;/td&gt;
&lt;td&gt;86.74%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;40,000 stories exist. Base tables are 20.4 MB of data in MySQL. Metric: &lt;code&gt;readyset_reader_state_size_bytes&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15tfd4cl7ob7tl1a5a25.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15tfd4cl7ob7tl1a5a25.png" alt="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" width="799" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The practical version: a deep cache is sized by the working set, the same rule already used for Redis.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to cache expensive SQL queries automatically
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;CACHE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;stories&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The caching is automatic because the invalidation is automatic.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The property to design around
&lt;/h2&gt;

&lt;p&gt;Incremental view maintenance is eventually consistent. The &lt;a href="https://pdos.csail.mit.edu/papers/noria:osdi18.pdf" rel="noopener noreferrer"&gt;Noria paper&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this comes down to
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For how the dataflow engine maintains a cached result, see &lt;a href="https://readyset.io/docs/concepts/deep-caching" rel="noopener noreferrer"&gt;deep caching&lt;/a&gt; and &lt;a href="https://readyset.io/blog/behind-the-magic-how-readyset-speeds-up-queries-with-streaming-dataflow" rel="noopener noreferrer"&gt;How Readyset Speeds Up Queries with Streaming Dataflow&lt;/a&gt;. For the TTL model and when it fits better, see &lt;a href="https://readyset.io/docs/concepts/shallow-caching" rel="noopener noreferrer"&gt;shallow caching&lt;/a&gt;, and the &lt;a href="https://readyset.io/docs/cache/cache" rel="noopener noreferrer"&gt;CREATE CACHE reference&lt;/a&gt; for the statement itself.&lt;/p&gt;

</description>
      <category>database</category>
      <category>mysql</category>
      <category>caching</category>
      <category>performance</category>
    </item>
    <item>
      <title>How We Reached 4.3 Million QPS for MySQL and PostgreSQL workloads on a Single Readyset Node</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Thu, 10 Sep 2026 13:50:24 +0000</pubDate>
      <link>https://dev.to/readyset/how-we-reached-43-million-qps-for-mysql-and-postgresql-workloads-on-a-single-readyset-node-4gd1</link>
      <guid>https://dev.to/readyset/how-we-reached-43-million-qps-for-mysql-and-postgresql-workloads-on-a-single-readyset-node-4gd1</guid>
      <description>&lt;p&gt;A single Readyset node now serves 4.3 million queries per second, more than twice what we measured with PostgreSQL and almost three times what we measured with MySQL on the same server.&lt;/p&gt;

&lt;p&gt;We got there by profiling the serving path, finding the bottlenecks, and measuring again. The full post walks through exactly what changed: how we located the contention in the Tokio runtime, swapped SipHash for ahash on the hot paths, fixed several costs that had been hidden, and kept profiling until CPU cost per query dropped from 45.7µs to 21.7µs. At full throughput, most of the CPU now goes into the kernel network stack rather than the cache lookup, which points to where the next ceiling is.&lt;/p&gt;

&lt;p&gt;Compute keeps getting more expensive and AI workloads are competing for the same CPUs, memory, and networking your database depends on. Serving repeated reads from Readyset keeps that load off the database, so its CPU stays free for writes, cache misses, and the queries that actually need the execution engine, and you get more out of the hardware you already have.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://readyset.io/case-studies/how-we-reached-43-million-qps-for-mysql-and-postgresql-workloads-on-a-single-readyset-node" rel="noopener noreferrer"&gt;Read the full article here.&lt;/a&gt; &lt;/p&gt;

</description>
      <category>mysql</category>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>August Product Update 2026</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Tue, 01 Sep 2026 16:22:51 +0000</pubDate>
      <link>https://dev.to/readyset/august-product-update-2026-3gi9</link>
      <guid>https://dev.to/readyset/august-product-update-2026-3gi9</guid>
      <description>&lt;p&gt;Our August product update is live. 🚀&lt;br&gt;
&amp;nbsp;&lt;br&gt;
We launched the Readyset Platform, a self-tuning query acceleration layer you can demo right in our rdst desktop app. Adaptive Cache shipped and is already dropping p95 staleness by 97% compared to a standard TTL cache. TopK hit GA. And we cut CPU load on shallow cache workloads by roughly 75%.&lt;br&gt;
&amp;nbsp;&lt;br&gt;
&lt;a href="https://readyset.io/blog/august-product-update-2026" rel="noopener noreferrer"&gt;There's a lot more in the full update, including a roundup of recent content and two upcoming events in September to mark on your calendar&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>database</category>
      <category>mysql</category>
      <category>postgres</category>
    </item>
    <item>
      <title>How Readyset Rewrites Your SQL: Subqueries in HAVING, ORDER BY, JOIN ON</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Thu, 20 Aug 2026 14:09:21 +0000</pubDate>
      <link>https://dev.to/readyset/how-readyset-rewrites-your-sql-subqueries-in-having-order-by-join-on-3kg</link>
      <guid>https://dev.to/readyset/how-readyset-rewrites-your-sql-subqueries-in-having-order-by-join-on-3kg</guid>
      <description>&lt;p&gt;Readyset's rewrite pipeline now handles subqueries in four new positions: HAVING, ORDER BY, INNER JOIN ON, and LEFT OUTER JOIN ON. Queries that previously fell back to the upstream database now compile and maintain incrementally.&lt;/p&gt;

&lt;p&gt;BI tools, reporting queries, and hand-written SQL regularly put subqueries in these positions. All four rewrites build on the same underlying idea of moving the subquery to a position that existing decorrelation already handles. &lt;/p&gt;

&lt;p&gt;LEFT OUTER JOIN ON required a different approach entirely, because the obvious move of placing the subquery predicate in WHERE silently drops rows the join was meant to preserve. The post works through what the correct solution looks like and why the mechanics of the dataflow model make it necessary.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://readyset.io/blog/where-a-subquery-can-live" rel="noopener noreferrer"&gt;Read the full article here&lt;/a&gt;. &lt;/p&gt;

</description>
    </item>
    <item>
      <title>Introducing Adaptive Cache for Dynamically Managing Freshness</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:08:23 +0000</pubDate>
      <link>https://dev.to/readyset/introducing-adaptive-cache-for-dynamically-managing-freshness-2cc9</link>
      <guid>https://dev.to/readyset/introducing-adaptive-cache-for-dynamically-managing-freshness-2cc9</guid>
      <description>&lt;p&gt;Traditional caches give you the same tradeoff: pick one refresh interval, and it applies to everything. Stable data gets refreshed more than it needs to while volatile data sits stale longer than you'd like. Tune one, you compromise the other.&lt;/p&gt;

&lt;p&gt;That's why we built Adaptive Cache, which is available today as part of the Readyset Platform. Adaptive Cache adjusts the refresh interval per cached result based on how frequently that result actually changes, with no manual tuning required.&lt;/p&gt;

&lt;p&gt;In our testing, p95 staleness dropped 97% compared to a standard TTL cache, with only a 60% increase in upstream refresh load instead of the 490% increase you would need to achieve the same result by manually shrinking the interval.&lt;/p&gt;

&lt;p&gt;This is a key step toward caching that's truly invisible, no tuning, no babysitting.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://readyset.io/blog/adaptive-cache" rel="noopener noreferrer"&gt;Read the full post here.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>caching</category>
      <category>postgres</category>
      <category>mysql</category>
    </item>
    <item>
      <title>Your Database Bill Is Climbing Faster Than Your Traffic</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Thu, 06 Aug 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/readyset/your-database-bill-is-climbing-faster-than-your-traffic-1330</link>
      <guid>https://dev.to/readyset/your-database-bill-is-climbing-faster-than-your-traffic-1330</guid>
      <description>&lt;p&gt;Cloud infrastructure pricing is moving in a direction nobody wanted. DRAM contract prices rose 93% to 98% in a single quarter, and that cost is landing on database bills across the board, which means the instance you are running today is going to cost more over the next year.&lt;/p&gt;

&lt;p&gt;The cost per query is a lever you can still move, and we ran the benchmark to show what that looks like in practice: Readyset in front of MySQL on the same hardware, 99.94% of reads served from cache, cost per million queries dropping from $1.36 to $0.0064, a gap that held under every projected price increase we modeled.&lt;/p&gt;

&lt;p&gt;If you run a cloud database, here is the &lt;a href="https://readyset.io/blog/your-database-bill-is-climbing-faster-than-your-traffic" rel="noopener noreferrer"&gt;full analysis and reproducible benchmark&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>postgres</category>
      <category>database</category>
    </item>
    <item>
      <title>July Product Update</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Mon, 03 Aug 2026 17:45:14 +0000</pubDate>
      <link>https://dev.to/readyset/july-product-update-23i3</link>
      <guid>https://dev.to/readyset/july-product-update-23i3</guid>
      <description>&lt;p&gt;Our July Product Update is Live!&lt;/p&gt;

&lt;p&gt;We launched the &lt;a href="https://readyset.io/downloads" rel="noopener noreferrer"&gt;Readyset Platform&lt;/a&gt; this month, combining QueryPilot, rdst, and new components into a self-tuning query acceleration layer. Now available as a desktop app so you can run and test it on your own workload. Freshness that tunes itself, eviction that can't be thrashed, built-in debugging. It’s a cache you can just leave on.&lt;/p&gt;

&lt;p&gt;Khanh Tran, VP of Engineering at Hussle, put it well: "Our optimization has moved from low config to no config."&lt;/p&gt;

&lt;p&gt;Plus, we cut CPU load on shallow cache workloads by 75%, shipped TopK as generally available, expanded Postgres cache coverage, and more. The full update covers our latest release, a roundup of our recent content, and details on our upcoming August 18 webinar, where you can see the Readyset Platform in action.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://readyset.io/blog/july-2026-product-update" rel="noopener noreferrer"&gt;Read it here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>database</category>
      <category>mysql</category>
      <category>postgres</category>
    </item>
    <item>
      <title>How the AI Buildout Is Repricing the Rest of Your Infrastructure</title>
      <dc:creator>ReadySet</dc:creator>
      <pubDate>Thu, 30 Jul 2026 14:07:08 +0000</pubDate>
      <link>https://dev.to/readyset/how-the-ai-buildout-is-repricing-the-rest-of-your-infrastructure-jck</link>
      <guid>https://dev.to/readyset/how-the-ai-buildout-is-repricing-the-rest-of-your-infrastructure-jck</guid>
      <description>&lt;p&gt;The biggest near-term cost of the AI boom is what the buildout is doing to the price of everything else you run. DRAM contract prices rose 90 to 95 percent in a single quarter, and memory-heavy infrastructure like databases is taking the hardest hit.&lt;/p&gt;

&lt;p&gt;Our white paper &lt;a href="https://readyset.io/resources/resource-ai-buildout-is-repricing-your-infrastructure-white-paper" rel="noopener noreferrer"&gt;How the AI Buildout Is Repricing the Rest of Your Infrastructure&lt;/a&gt; covers how AI demand is repricing ordinary compute and which changes pay for themselves at current prices.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>mysql</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
