1. Courses
  2. System Design
  3. Inside the Tech Giants
  4. Stop 2

40 minAdvanced

Facebook: the social graph, TAO and memcache at planet scale

In June 2026 Meta reported 3.6 billion people using at least one of its apps every day, and its graph store, TAO, was serving over ten billion reads a second back in 2021. This lesson shows how that is built from parts you already know: MySQL, a look-aside cache, sharding and replication, plus a few clever fixes for the problems that only appear when a cache is that big.

By the end you can

  • Model a social product as objects and associations, and say why that fits a cache
  • Trace a read and a write through TAO's caches and MySQL shards, across regions
  • Explain memcache leases: how they stop stale sets and thundering herds
  • Explain why invalidations come from the database's commit log, not from the web server
  • Build an objects and associations schema in Postgres and a Redis cache with leases

The everyday picture

Picture the noticeboard at a big office. Every staff member has a small card: name, team, phone. String connects the cards: who reports to whom, who is in which WhatsApp group, who liked the canteen post. Almost everyone comes to read the board. Only a few people change it each hour.

The real records live in the HR office's files. Reading those files is slow, so the office keeps a photocopy of the board on every floor. That raises two questions. When someone changes a card in the HR file, how does every floor's copy learn about it? And when the copy on floor 5 is torn down for an update, how do you stop 200 people from all running to the HR office at once to ask for the same card? Facebook's data layer is the answer to those two questions, at the scale of billions of people.

What the product must do

Functional: people, pages, posts, photos, comments and check-ins, with connections between them (friend of, likes, authored, tagged in, commented on). A news feed or profile page is built by reading hundreds of these small pieces and their connections.

Non-functional, the ones that shape the design:

  • Reads dominate. The 2013 memcache paper says users consume an order of magnitude more content than they create. In the 2013 TAO paper, 99.8% of requests were reads.
  • Each page touches many items. One popular page fetched 521 distinct items from memcache on average (2013).
  • Low latency everywhere in the world, so data must be copied to several regions.
  • Seeing your own action matters more than global order. After you comment, you must see your comment. A friend in another country seeing it one second later is fine. This is consistency for the writer, and eventual consistency for everyone else (CAP and consistency).

Scale in numbers

MetricNumberSource, year
Daily active people across Meta's apps3.60 billion (June average)Meta Q2 results, 2026
TAO reads per secondover 10 billionRAMP-TAO paper, 2021
TAO writes per secondtens of millionsRAMP-TAO paper, 2021
Share of TAO requests that are reads99.8%TAO paper, 2013
TAO cache hit rate for reads96.4%TAO paper, 2013
MySQL fleetmillions of shards, petabytes of dataMeta engineering blog, 2023
mcrouter peak trafficclose to 5 billion requests per secondMeta engineering blog, 2014
Photos stored (Haystack)260 billion images, over 20 PBHaystack paper, 2010
Data centre campusesabout 30 listed, most in the USMeta data centres site, 2026 (our count)

Two numbers explain most of the design. 99.8% reads means a cache in front of the database pays off enormously. A 96.4% hit rate means only about 1 read in 28 reaches MySQL. If a similar hit rate held at 2021's ten billion reads a second (our assumption; the papers are eight years apart), that is still hundreds of millions of database reads a second, which is why MySQL is split into millions of shards.

Objects and associations: the data model

TAO stores the whole social graph with two shapes only.

  • An has a 64-bit id, a type (user, post, comment, location) and a few key-value fields.
  • An is a typed, directed edge: (id1, type, id2), plus a time and a few fields. "Leila likes post 20" is (Leila, likes, 20). There is at most one association of a type between two objects.

Many edges come in pairs. If Leila likes post 20, post 20 is liked by Leila. TAO can be told that likes has the inverse liked_by, and it writes both. All the edges of one type from one object form an association list, kept newest first. That is exactly what a screen needs: "the 50 newest comments on this post" or "how many people like it".

The API is small on purpose: get an object; get a few specific edges; get a range of an association list ("newest 50"); get edges in a time window; count an association list. In 2013, assoc_range was 40.9% of reads and obj_get 28.9%. There are no joins and no general queries. A small API is easy to cache, because the cache knows exactly what each answer depends on.

Underneath, it is plain MySQL. The paper describes an objects table with the fields serialised into one data column, and an associations table with an index on (id1, type, time) so the range read is one index scan, the composite index rule from Indexes from the inside. Counts are kept in their own table, so "how many likes" reads one row instead of counting thousands.

Quick check

Why does TAO's small API, with no joins and no general queries, make the cache easier to keep correct?

Sharding: the shard is inside the id

The data is split into many , "hundreds of thousands" in 2013. Each logical shard is a MySQL database, and one server holds many of them, so a shard can be moved to a new server when one gets too busy (Sharding). The neat part: an object's id contains its shard number, so any server can tell from the id alone where the object lives. An association is stored on the shard of its id1. So "the newest comments on post 20" is answered by one shard, with no fan-out across servers.

Hot spots still happen: a celebrity's post may get millions of reads. The paper describes copying a hot shard's cached data to several cache servers, and clients keeping very hot items briefly in their own memory.

Quick check

The comments on post 20 are stored as edges (post 20, has_comment, comment id). Which database shard answers "the newest 50 comments on post 20"?

MySQL keeps evolving under TAO. In 2016-2017 Facebook moved its main user database from InnoDB to , a storage engine built on RocksDB, which cut storage to about half of compressed InnoDB. In 2023 Meta described moving MySQL replication to Raft, a consensus protocol, so a failed primary is replaced in about 2 seconds instead of the 20 to 40 seconds the older setup took. Both are DBA work at scale: change the engine, keep the API.

The read path

Caches in TAO are arranged in two layers. A region has several follower tiers, which answer reads from web servers. Behind them is one leader tier per region, which talks to MySQL. Within a tier, consistent hashing decides which cache server holds which shard.

Web server newest 50 comments 1. ask Follower cache tier hit: about 1 ms (96% of reads) 2. miss Leader cache tier one per region; only it talks to the database 3. miss: SELECT shard 4,812 shard 4,813 many more MySQL
A TAO read. The web server asks a follower cache, which answers most reads in about a millisecond. On a miss, the follower asks the region's leader cache, and only on a second miss does the leader read MySQL. The shard is known from the object id, so exactly one database is asked.

The write path, and how other regions find out

Facebook runs in several regions. In the 2013 design, each shard has one master region where its MySQL primary lives; other regions hold read-only MySQL replicas (Replication). Reads are served locally everywhere. Writes take a longer road:

  1. The web server sends the write to its follower cache, which forwards it to the leader.
  2. In a replica region, the local leader forwards the write to the leader in the shard's master region, which writes MySQL synchronously.
  3. The reply carries the new value back down the same path, so the local leader and the user's follower tier update their copies at once. The user sees their own comment immediately: read-after-write within the tier.
  4. MySQL replication carries the change to every replica region. Riding along in that replication stream are cache invalidation messages. When a region's replica applies the change, the invalidation is applied to that region's caches too. In the 2013 paper, replication lag was under one second in 85% of the measured time windows.

Step 4 is the important design choice. Why not have the web server delete the key in every region right after the write? Because the replica in a far region may not have the new row yet. If the cache there is cleared too early, the next reader misses, reads the old row from the lagging replica, and puts it back in the cache, where it can stay for a long time. The memcache paper calls a web-server invalidation in a replica region "premature" for exactly this reason. Sending invalidations through the database's own change stream guarantees the delete happens after the data is there.

The memcache paper describes the same idea with a daemon called mcsqueal on every database server: it reads committed statements, pulls out the cache deletes, and broadcasts them to every cache cluster in its region. Batching those deletes gave an 18 times improvement in deletes per network packet. This is the outbox pattern and change data capture from Outbox and sagas, used for cache correctness.

Master region Replica region You comment Local caches 1. forward the write MySQL primary 2. new value back: you see your comment 3. replication: row + invalidation MySQL replica 4. then delete Other caches in this region Clear caches only after the replica has the new row
A write from a replica region. The write goes to the primary in the shard's master region, and the new value comes straight back so the writer sees it. Everyone else in the replica region gets it through MySQL replication, which also carries the cache invalidation. The cache is cleared only after the replica has the new row, so a reader cannot refill it with old data.

Memcache before TAO: leases

In 2013, Facebook wrote that TAO had been in production for several years. Before it, and alongside it for other data, Facebook used plain memcached as a look-aside cache, the cache-aside pattern from Caching: read the cache, on a miss read MySQL and set the cache; on a write, update MySQL and then delete the key. The paper says they delete instead of updating because deletes are idempotent: deleting twice is the same as deleting once.

At that scale, two problems from Caching became daily events.

  • Stale sets. Reader A misses and reads the old row. A writer updates the row and deletes the key. Then A, slow for some reason, sets the old row into the cache. The cache now holds an old value with no delete coming to fix it.
  • Thundering herds. A hot key is deleted by a write, and thousands of web servers miss at once and all query MySQL.

One mechanism fixes both: a . On a miss, memcached gives the client a 64-bit token. The client reads MySQL and sets the value with the token. If a delete arrived in between, memcached has cancelled the token and refuses the set: no stale set. And memcached hands out at most one token per key every 10 seconds. Other clients that miss in that window are told to wait a moment and retry; usually the lease holder fills the key within a few milliseconds. In the paper, leases cut the peak database query rate for one hot set of keys from 17,000 to 1,300 per second.

A refinement: when a key is deleted, memcached keeps the old value briefly in a recently-deleted area. A client that can live with slightly old data, such as a like count, can take that stale value instead of waiting. Instagram later described using the same lease idea on its own memcache, with "first miss" and "hot miss" answers (Instagram).

The paper's other tricks are worth one line each. A small pool of spare cache servers, called Gutter (about 1% of the cache servers in a cluster), takes over for failed servers so a dead cache box does not send its whole load to MySQL. A new, cold cluster warms up by reading from a warm cluster's cache instead of the database, which brings it to full capacity in a few hours. And , a routing proxy open-sourced in 2014, sits between web servers and caches and was handling close to 5 billion requests a second at peak.

Quick check

A hot key is deleted by a write, and 3,000 web servers miss it within the same second. With memcache leases, roughly how many of them query the database?

Think first: why does a lease stop the stale set, when a plain delete-after-write does not?

Delete-after-write only removes what is in the cache at that moment. The slow reader is not in the cache yet; it is still carrying the old row in its hand. When it arrives and sets the key, nothing remembers that a delete happened. A lease gives the cache a memory: the delete cancels the token, and the set without a valid token is refused. The cache becomes the place that decides who may fill a key, instead of trusting whoever arrives last.

Photos, the edge and shipping code

Photos. In 2009, photos were stored as one file each on network file servers, and reading one photo took several disk operations just to find the file's metadata. Haystack (described in 2009 and in an OSDI 2010 paper) packs many photos into one huge file and keeps a tiny in-memory index from photo id to offset, so a read is one disk operation. The CDN (Files and CDNs) serves popular photos; Haystack exists for the long tail of old photos that the CDN does not hold. The 2010 numbers: 260 billion images, over 20 PB, about a billion new photos a week, and over a million images served per second at peak.

The edge. Your connection ends at a nearby point of presence (PoP), a small Facebook site connected to many local networks. DNS picks the PoP; requests that need data travel over Facebook's backbone to a data centre and back (2017 post on Edge Fabric). Meta's own site lists about thirty data centre campuses, most of them in the US, with others in Europe and Singapore. In 2011 Facebook published its server and data centre designs as the Open Compute Project; it said the Prineville, Oregon building used 38% less energy for the same work and cost 24% less.

Shipping code. In 2017 Facebook described moving facebook.com from three pushes a day to a "quasi-continuous" model: tens to hundreds of changes every few hours, released first to employees, then to 2% of production, then to everyone, with features hidden behind a flag system called Gatekeeper. It is the canary release from Shipping safely at the scale of thousands of engineers.

Famous decisions and what they taught

  • Keep MySQL as the source of truth. Facebook scaled with caching and sharding around a boring, well-understood database, then improved the engine underneath (MyRocks, Raft). Lesson: grow around your database before you replace it (Choosing a data store).
  • A cache that understands the data (TAO). Plain memcache needed every team to write its own invalidation code, and changing one edge in a list meant reloading the whole list. TAO's small graph API let the cache maintain lists itself. Lesson: when every team repeats the same cache logic, it belongs in one shared service.
  • Invalidate from the commit log. Deletes ride with the data they describe, so they can never arrive before it. Lesson: when two systems must agree, drive the second from the first one's log (Outbox and sagas).

When it broke

23 September 2010: a cache that tried to fix itself. Robert Johnson's postmortem explains it. An automated system checked configuration values in the cache and, if one looked invalid, replaced it from the database. Then a bad value was written to the database itself. Every client saw the bad value, decided it was invalid and queried the database to fix it, hundreds of thousands of queries a second. Worse, when a query to the overloaded database failed, the client treated that as an invalid value too and deleted the cache key, which caused more queries. The only way out was to stop all traffic to that database cluster, which meant turning the site off. It was down for about 2.5 hours, the worst outage in over four years. The repair system was switched off until it could be redesigned. The lesson: automatic fixers need a brake. Limit how often they act, and never treat "the database is slow" as "the data is wrong". Leases, which allow one refill per key at a time, are that kind of brake.

4 October 2021: the network that disappeared. During routine maintenance, a command meant to check the capacity of the global backbone took down all the links between Meta's data centres. A bug in the audit tool that should have blocked the command let it through. Meta's DNS servers are designed to withdraw their BGP route announcements when they cannot reach the data centres, so the whole internet lost the way to facebook.com, Instagram and WhatsApp. Cloudflare saw the routes vanish at about 15:40 UTC and come back around 21:00, with service restored by about 21:20, roughly five and a half hours. Engineers had to go on site, and the buildings' security slowed them down. Meta also restored power and traffic in stages, because data centres were drawing tens of megawatts less, and switching all of it back at once could have damaged electrical systems and overloaded cold caches. Practice "storm" drills helped. Lessons: keep a way in that does not depend on the system you are fixing, and bring a cold system back slowly, the same reason the Caching lesson warns about stampedes.

Build it

Build a small TAO in Postgres (objects, associations, counts, inverses), invalidate the cache from committed changes, and put a Redis look-aside cache with leases in front. Tested with PostgreSQL 16, Redis 7, Node 22, and ioredis 5 and 6. The lease rules and the association-list read are also exercises in the Practice section, so you can try them in your browser without Redis.

  1. Objects, associations and counts

    Two shapes hold the whole graph. The index on (id1, atype, time DESC) serves the main read, "newest N edges of this type", as one index scan.

    SQL
    -- Objects: nodes of the graph. One table for every type, typed by otype.
    CREATE TABLE obj (
      id         bigint      PRIMARY KEY,               -- globally unique (the Instagram lesson shows how to mint one)
      otype      text        NOT NULL,                  -- 'user', 'site', 'post', 'comment'
      data       jsonb       NOT NULL DEFAULT '{}',
      version    bigint      NOT NULL DEFAULT 1,        -- bumped on every update
      updated_at timestamptz NOT NULL DEFAULT now()
    );
    
    -- Associations: directed, typed edges. At most one edge of a type between two objects.
    CREATE TABLE assoc (
      id1    bigint      NOT NULL REFERENCES obj(id),
      atype  text        NOT NULL,                      -- 'follows', 'likes', 'authored', 'manages'
      id2    bigint      NOT NULL REFERENCES obj(id),
      time   timestamptz NOT NULL DEFAULT now(),        -- lists are read newest first
      data   jsonb       NOT NULL DEFAULT '{}',
      PRIMARY KEY (id1, atype, id2)
    );
    CREATE INDEX assoc_list ON assoc (id1, atype, time DESC);
    
    -- Counts are read far more than edges are written, so keep them in their own table
    CREATE TABLE assoc_count (
      id1   bigint NOT NULL,
      atype text   NOT NULL,
      n     bigint NOT NULL DEFAULT 0,
      PRIMARY KEY (id1, atype)
    );
    
    -- Edge types whose inverse must be written in the same transaction
    CREATE TABLE assoc_inverse (atype text PRIMARY KEY, inverse text NOT NULL);
    INSERT INTO assoc_inverse VALUES ('follows', 'followed_by'), ('followed_by', 'follows'),
                                     ('likes', 'liked_by'), ('liked_by', 'likes');
  2. An assoc_add that writes both directions and both counts

    One function call is one transaction: the edge, its inverse and both counts, or nothing. ON CONFLICT DO NOTHING makes a double tap harmless (APIs done right).

    SQL
    CREATE OR REPLACE FUNCTION assoc_add(p_id1 bigint, p_atype text, p_id2 bigint)
    RETURNS boolean LANGUAGE plpgsql AS $$
    DECLARE
      inv text;
    BEGIN
      INSERT INTO assoc (id1, atype, id2) VALUES (p_id1, p_atype, p_id2)
      ON CONFLICT DO NOTHING;                        -- a retry adds nothing
      IF NOT FOUND THEN RETURN false; END IF;        -- the edge already existed
    
      INSERT INTO assoc_count AS c (id1, atype, n) VALUES (p_id1, p_atype, 1)
      ON CONFLICT (id1, atype) DO UPDATE SET n = c.n + 1;
    
      SELECT inverse INTO inv FROM assoc_inverse WHERE atype = p_atype;
      IF inv IS NOT NULL THEN
        INSERT INTO assoc (id1, atype, id2) VALUES (p_id2, inv, p_id1) ON CONFLICT DO NOTHING;
        INSERT INTO assoc_count AS c (id1, atype, n) VALUES (p_id2, inv, 1)
        ON CONFLICT (id1, atype) DO UPDATE SET n = c.n + 1;
      END IF;
      RETURN true;
    END $$;
    SQL
    INSERT INTO obj (id, otype, data) VALUES
      (1, 'user', '{"name": "Ama", "role": "store manager"}'),
      (2, 'user', '{"name": "Imran"}'),
      (3, 'user', '{"name": "Leila"}'),
      (20, 'post', '{"text": "Festival display is ready at store 14"}');
    
    SELECT assoc_add(2, 'follows', 1);   -- Imran follows Ama
    SELECT assoc_add(3, 'follows', 1);   -- Leila follows Ama
    SELECT assoc_add(2, 'likes', 20);
    SELECT assoc_add(3, 'likes', 20);
    SELECT assoc_add(3, 'likes', 20);    -- double tap: returns false, nothing changes
    
    -- assoc_range: Ama's newest followers (one index scan on assoc_list)
    SELECT id2 FROM assoc WHERE id1 = 1 AND atype = 'followed_by' ORDER BY time DESC LIMIT 10;
    -- assoc_count: one row, never COUNT(*)
    SELECT n FROM assoc_count WHERE id1 = 20 AND atype = 'liked_by';
    Output
     id2
    -----
       3
       2
    (2 rows)
    
     n
    ---
     2
    (1 row)
  3. Invalidate from the commit, not from app code

    A trigger names the cache keys a change affects. pg_notify messages are delivered only when the transaction commits, and never if it rolls back, so the invalidation can never arrive before the data, the same promise mcsqueal gives. For a guarantee that also survives the listener being down, write an outbox row instead (Outbox and sagas).

    SQL
    CREATE OR REPLACE FUNCTION notify_cache_inval() RETURNS trigger LANGUAGE plpgsql AS $$
    DECLARE
      r record := COALESCE(NEW, OLD);
    BEGIN
      IF TG_TABLE_NAME = 'obj' THEN
        PERFORM pg_notify('cache_inval', 'obj:' || r.id);
      ELSIF TG_TABLE_NAME = 'assoc' THEN
        PERFORM pg_notify('cache_inval', 'assoc:' || r.id1 || ':' || r.atype);
      END IF;
      RETURN NULL;                                   -- AFTER trigger: return value is ignored
    END $$;
    
    CREATE TRIGGER obj_inval AFTER UPDATE OR DELETE ON obj
      FOR EACH ROW EXECUTE FUNCTION notify_cache_inval();
    CREATE TRIGGER assoc_inval AFTER INSERT OR UPDATE OR DELETE ON assoc
      FOR EACH ROW EXECUTE FUNCTION notify_cache_inval();
  4. Three Lua scripts: lease-get, lease-set, invalidate

    Redis runs each script atomically, so no other command can slip in between its steps. Each cache entry uses three keys: the value, its lease, and a short-lived stale copy.

    TypeScript
    // lease-scripts.ts. Keys for one entry k: k (value), lease:k (who may fill it), stale:k (old copy)
    
    // Hit: return the value. Miss: the FIRST caller gets a lease; later callers are told to wait
    // (and get the stale copy, if there is one).   ARGV: our token, lease length in ms
    export const LEASE_GET = `
    local v = redis.call('GET', KEYS[1])
    if v then return {'hit', v} end
    if redis.call('SET', KEYS[2], ARGV[1], 'NX', 'PX', ARGV[2]) then return {'lease', ARGV[1]} end
    return {'wait', redis.call('GET', KEYS[3])}`;
    
    // Fill the cache ONLY if our lease is still there. A write since our miss deleted it.
    // ARGV: our token, value, TTL in seconds
    export const LEASE_SET = `
    if redis.call('GET', KEYS[2]) ~= ARGV[1] then return 0 end
    redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
    redis.call('DEL', KEYS[2])
    return 1`;
    
    // After a database write: delete the value AND any lease. Keep the old value as stale:k
    // for a few seconds, for readers who said a slightly old answer is fine.  ARGV: stale TTL
    export const INVALIDATE = `
    local v = redis.call('GET', KEYS[1])
    if v then redis.call('SET', KEYS[3], v, 'EX', ARGV[1]) end
    redis.call('DEL', KEYS[1], KEYS[2])
    return 1`;
  5. The lease cache

    One caller per key loads from the database; the others wait about 20 ms and look again, or take the stale copy if the screen allows it. Keys from different shards are fine, but on Redis Cluster all three keys of an entry must hash to the same slot; wrap the key in braces, such as {assoc:1:likes}, to make that true.

    TypeScript
    // lease-cache.ts: look-aside cache with memcache-style leases, on Redis (ioredis 5 or 6)
    import Redis from 'ioredis';
    import { randomUUID } from 'node:crypto';
    import { LEASE_GET, LEASE_SET, INVALIDATE } from './lease-scripts';
    
    const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
    
    export class LeaseCache {
      constructor(private redis: Redis, private leaseMs = 2000, private staleS = 5) {}
    
      async get<T>(key: string, ttlS: number, load: () => Promise<T>, allowStale = false): Promise<T> {
        const keys = [key, `lease:${key}`, `stale:${key}`];
        for (let i = 0; i < 100; i++) {                           // wait at most about 2 s
          const token = randomUUID();
          const [state, v] = (await this.redis.eval(LEASE_GET, 3, ...keys, token, this.leaseMs)) as
            [string, string | null];
          if (state === 'hit') return JSON.parse(v!) as T;
          if (state === 'lease') {                                // we are the ONE caller that loads
            const value = await load();
            await this.redis.eval(LEASE_SET, 2, key, keys[1], token, JSON.stringify(value), ttlS);
            return value;                                         // if the set was refused, only we see it
          }
          if (allowStale && v !== null) return JSON.parse(v) as T; // old is OK for this screen
          await sleep(20);                                        // someone else is loading: wait, retry
        }
        return load();                                            // the loader died: stop waiting
      }
    
      async invalidate(key: string): Promise<void> {              // call AFTER the write commits
        await this.redis.eval(INVALIDATE, 3, key, `lease:${key}`, `stale:${key}`, this.staleS);
      }
    }
  6. The invalidator process

    Run one per region (or per Redis). It listens for committed changes and calls invalidate, which also cancels any lease, so a slow reader cannot put old data back.

    TypeScript
    // invalidator.ts
    import Redis from 'ioredis';
    import { Client } from 'pg';
    import { LeaseCache } from './lease-cache';
    
    (async () => {
      const cache = new LeaseCache(new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'));
      const pg = new Client({ connectionString: process.env.DATABASE_URL });
      await pg.connect();
      pg.on('notification', async (msg) => {
        if (!msg.payload) return;
        await cache.invalidate(msg.payload);
        console.log('invalidated', msg.payload);
      });
      await pg.query('LISTEN cache_inval');
    })();
    SQL
    SELECT assoc_add(1, 'follows', 2);
    BEGIN; UPDATE obj SET data = data || '{"name": "Imran (draft)"}' WHERE id = 2; ROLLBACK;
    UPDATE obj SET data = data || '{"name": "Imran, Store app PM"}', version = version + 1 WHERE id = 2;
    Output
    invalidated assoc:1:follows
    invalidated assoc:2:followed_by
    invalidated obj:2
    # the rolled-back UPDATE sent nothing
  7. Prove both guarantees

    Two small scripts in the same project test the lease cache against plain cache-aside. The first sends 200 requests at once for a cold key whose query takes 100 ms (pg_sleep(0.1) stands in for a heavy join). The second runs the stale-set race: a slow reader reads price 120, a writer commits 135 and invalidates, then the slow reader tries to set 120.

    Terminal
    npm i ioredis pg && npm i -D typescript @types/node @types/pg
    npx tsc -p .
    export REDIS_URL=redis://127.0.0.1:6379 DATABASE_URL=postgresql://app@localhost/graph
    node dist/herd.js
    node dist/stale-set.js
    Output
    plain: 200 requests -> 200 DB queries in 497 ms
    lease: 200 requests -> 1 DB queries in 128 ms
    plain: cache holds "120.00", database holds "135.00"
    lease: cache holds null, database holds "135.00"

    Plain cache-aside sent all 200 misses to Postgres and later kept a wrong price for the whole TTL. With leases, one query filled the key for everyone, and the late set was refused, so the next read loads 135 from the database.

Bring it to your team

Use these whenever a cache sits in front of data that changes.

  • When this row changes, which cache keys become wrong, and what deletes them? Is that tied to the commit, or to app code that may be skipped?
  • Do we delete keys on write, or update them? If we update, what happens when two writes arrive out of order?
  • What happens when the hottest key is deleted at the festival peak: one database query, or hundreds?
  • Could a slow reader put an old value back after a write? What stops it?
  • Which screens may show a slightly old value while the cache refills, and which must wait?
  • Does any automatic job repair or refill data by itself? What limits how fast it can act?

Red flags

  • Cache invalidation code copied into many services, each written slightly differently.
  • A cache delete that runs before the database commit, or in a replica region before the replica has the data.
  • COUNT(*) over a large table on every page view for a like or follower count.
  • Retry loops or self-healing jobs with no rate limit and no backoff.
  • An admin or recovery path that depends on the same network or DNS as the product.

Practice

Give a cache memcache-style leases

Finish the in-memory LeaseCache. get returns a hit, or hands the first caller that misses a lease token, or tells later callers to wait (with the old value, if one was kept). set stores a value only if the caller's token is still the lease for that key. invalidate, called after a database write, removes the value and any lease, and keeps the old value as a stale copy. These are the three Lua scripts from step 4 of the Build-it, without Redis.

JavaScript exerciseRuns in your browser with tests
// A look-aside cache with memcache-style leases, kept in memory.
class LeaseCache {
  constructor() {
    this.values = new Map();   // key -> cached value
    this.leases = new Map();   // key -> token of the ONE caller allowed to fill it
    this.stale = new Map();    // key -> the value it had before the last invalidate
    this.nextToken = 1;
  }
  // Hit: { state: 'hit', value }. First miss: { state: 'lease', token }.
  // Any other miss while a lease is out: { state: 'wait', stale }.
  get(key) {
    return { state: 'lease', token: this.nextToken++ };
  }
  // Store the value only if token is still the lease for this key. Return true if stored.
  set(key, token, value) {
    this.values.set(key, value);
    return true;
  }
  // Called after a database write: remove the value AND any lease; keep the old value as stale.
  invalidate(key) {
    this.values.delete(key);
  }
}
Ctrl + Enter runs
Show a solution
JavaScript
class LeaseCache {
  constructor() {
    this.values = new Map();
    this.leases = new Map();
    this.stale = new Map();
    this.nextToken = 1;
  }
  get(key) {
    if (this.values.has(key)) return { state: 'hit', value: this.values.get(key) };
    if (!this.leases.has(key)) {                        // first miss: hand out the lease
      const token = this.nextToken++;
      this.leases.set(key, token);
      return { state: 'lease', token };
    }
    return { state: 'wait', stale: this.stale.get(key) };
  }
  set(key, token, value) {
    if (this.leases.get(key) !== token) return false;   // a write cancelled our lease
    this.values.set(key, value);
    this.leases.delete(key);
    return true;
  }
  invalidate(key) {
    if (this.values.has(key)) this.stale.set(key, this.values.get(key));
    this.values.delete(key);
    this.leases.delete(key);                            // cancel any lease in flight
  }
}
Pass the exercise to finish this task.

Read an association list

The tables obj and assoc from the Build-it hold a few users, post 20, and some edges. Write TAO's assoc_range read for one screen: the names of the 3 newest people who liked post 20, newest first, in a column called name. Names live in obj.data, so use data ->> 'name'. Only edges of type liked_by count.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- The 3 newest people who liked post 20, newest first, as a column called name
SELECT id2
FROM assoc
WHERE id1 = 20;
Ctrl + Enter runs
Show a solution
SQL
SELECT o.data ->> 'name' AS name
FROM assoc a
JOIN obj o ON o.id = a.id2
WHERE a.id1 = 20 AND a.atype = 'liked_by'
ORDER BY a.time DESC
LIMIT 3;
Pass the exercise to finish this task.

Check yourself

Reader A misses the cache and reads the old price. A writer commits a new price and deletes the key. Then A sets the old price into the cache. What stops this in Facebook's memcache design?

Why does Facebook send cache invalidations in a replica region through MySQL replication instead of from the web server right after the write?

In TAO, how does a server know which MySQL shard holds the comments on post 20?

Flashcards

Open my review

Key takeaways

  • The social graph is two shapes: objects and typed edges. A small API over them is easy to cache correctly.
  • MySQL stays the source of truth, split into many logical shards; the shard is inside the id, so most reads hit one shard.
  • Caches answer about 96% of reads. Writes go to the master region, and invalidations ride the replication stream so they never arrive before the data.
  • Leases fix both stale sets and thundering herds: one filler per key, and a delete cancels a late set.
  • Automatic fixers and recovery paths need brakes and independent access; both big outages taught that.

Sources

Episode transcriptTwo hundred misses and one late reader · about 13 minutes · tap any line to play from there
  1. NadiaImran, picture midnight before a big festival, Eid or Diwali. Head office publishes the new price list, and the cache for the catalogue is cleared. In the next second, three hundred partner shops open the same screen in the Partner app.
  2. ImranThree hundred misses at once. All of them hit Postgres for the same rows.
  3. NadiaAnd one slow request, which read the old price just before the update, finishes last and writes the old price back into the cache.
  4. ImranSo we get a slow database and wrong prices. Both at midnight.
  5. NadiaFacebook hit both problems every day, at a scale of billions of people. Today's episode is how they built their data layer, and the small trick, called a lease, that fixes both.
  6. NadiaThe lesson starts with an office noticeboard. Every staff member has a card, and string connects the cards: who reports to whom, who liked the canteen post. The real records live in HR's files, which are slow, so every floor gets a photocopy of the board.
  7. ImranAnd the two questions are: when a card changes, how does every floor's copy find out? And when a copy is taken down, how do you stop two hundred people running to HR at once?
  8. NadiaExactly those two. Now the scale. In June twenty twenty-six, Meta reported three point six billion people using at least one of its apps every day.
  9. ImranAnd how many reads does that mean for the database?
  10. NadiaTheir graph store is called TAO. A twenty twenty-one paper said it serves over ten billion reads a second, and tens of millions of writes. In the twenty thirteen paper, ninety-nine point eight percent of requests were reads.
  11. ImranSo almost everything is a read. That's why caching wins.
  12. NadiaYes. And one popular page fetched five hundred and twenty-one separate items from the cache on average. A page is hundreds of tiny reads, not one big query.
  13. ImranWhat does the data look like? Hundreds of tables, I'd guess, one per feature.
  14. NadiaJust two shapes. Objects, which are things: a user, a post, a comment, a place. Each has an ID, a type, and a few fields. And associations, which are typed arrows from one object to another. Imran likes post twenty. Leila follows Ama.
  15. ImranAnd many arrows come in pairs. If I like the post, the post is liked by me.
  16. NadiaRight, TAO writes both directions. And all arrows of one type from one object form a list, newest first. That's exactly what a screen needs. The fifty newest comments on this post. How many likes it has.
  17. ImranIs there a fancy graph database underneath?
  18. NadiaNo, plain MySQL. An objects table, an associations table with an index on the source ID, the type and the time, and counts kept in their own table so nobody counts thousands of rows on every page view.
  19. ImranAnd how is it sharded?
  20. NadiaInto many small logical shards, hundreds of thousands of them in twenty thirteen, with many shards on each MySQL server. The clever part is that the shard number is inside the object's ID. And an arrow lives on the shard of the object it starts from.
  21. ImranSo the comments on post twenty live with post twenty. One shard answers. No fan-out like Google.
  22. NadiaThat's the contrast with the last episode, yes.
  23. NadiaIf you can see the screen, the first diagram is the read path, top to bottom. If you're listening, picture three layers. A web server asks a follower cache. Most of the time, about ninety-six percent, the answer is there in about a millisecond.
  24. NadiaOn a miss, the follower asks a leader cache, one per region. Only on a second miss does the leader read the one MySQL shard named by the ID.
  25. ImranNinety-six percent hits still leaves a lot of database reads at ten billion a second.
  26. NadiaIf the hit rate is still similar, that's roughly hundreds of millions of database reads a second. That's why there are millions of MySQL shards. A twenty twenty-three post from Meta said exactly that: millions of shards, petabytes of data.
  27. ImranNow writes. Facebook runs in several regions. Where does a write go?
  28. NadiaIn the twenty thirteen design, each shard has one master region with the MySQL primary. Other regions have read-only replicas. Reads stay local everywhere. A write is forwarded to the primary.
  29. NadiaThe second diagram shows a write from a replica region. On the right, you comment. The write goes left, to the primary in the master region. The new value comes straight back, so you see your own comment at once.
  30. ImranAnd everyone else in my region?
  31. NadiaThey get it through MySQL replication. And riding inside that same replication stream are the cache delete messages. So a region's caches are cleared only after its replica has the new row.
  32. ImranWhy so complicated? The web server could just delete the cache key in every region right after the write.
  33. NadiaThat's the natural idea, and it has a nasty bug. The replica far away may not have the new row yet. You clear its cache, the next reader misses, reads the old row from the lagging replica, and puts it back in the cache.
  34. ImranSo the delete arrives before the data, and the old value comes back.
  35. NadiaThe memcache paper calls that kind of delete premature. Sending deletes through the database's own log means they can never arrive before the data. It's the outbox idea from the events lesson, used for the cache.
  36. ImranNow my midnight problems. The herd and the late reader.
  37. NadiaBefore TAO, and next to it, Facebook used plain memcached as a look-aside cache. Read the cache, on a miss read MySQL and set the cache. On a write, update MySQL, then delete the key.
  38. ImranDelete, not update. Because deleting twice is the same as deleting once.
  39. NadiaThat's their exact reason. Now the late reader. Reader A misses and reads the old price. A writer commits the new price and deletes the key. Then A, slow for some reason, sets the old price.
  40. ImranAnd the delete is already done, so nothing fixes it until the TTL runs out.
  41. NadiaThe fix is a lease. When you miss, the cache hands you a token. You read the database, then set the value along with your token. If a delete arrived in between, the cache cancelled your token, and your set is refused.
  42. ImranSo the cache remembers that a delete happened. Clever. And the herd?
  43. NadiaSame token. The cache gives out at most one token per key every ten seconds. Everyone else who misses is told to wait a moment and try again. Usually the lease holder fills the key within a few milliseconds.
  44. ImranSo three hundred misses become one database query.
  45. NadiaIn the paper, leases cut the peak database rate for one hot group of keys from seventeen thousand queries a second to thirteen hundred. And there's a gentle version. A screen that can live with a slightly old value, like a like count, can take the old copy instead of waiting.
  46. ImranDid anything big go wrong?
  47. NadiaTwo good stories. September twenty ten. Facebook had an automatic system that checked config values in the cache, and if one looked invalid, fetched a fresh one from the database.
  48. NadiaThen a bad value was written into the database itself. Every client saw it, decided it was invalid, and asked the database. Hundreds of thousands of queries a second.
  49. ImranAnd the database fell over.
  50. NadiaWorse. When a query failed, clients treated the error as another invalid value and deleted the cache key, which caused even more queries. They had to turn the whole site off to stop it. About two and a half hours.
  51. ImranSo the self-repair tool was the attacker.
  52. NadiaThat's the lesson. Automatic fixers need a brake. Limit how often they act, and never read the database is slow as the data is wrong.
  53. ImranAnd the second story?
  54. NadiaOctober twenty twenty-one. A maintenance command took down every link between Meta's data centres, and a bug in the safety check let it through. Their DNS servers are built to stop announcing their routes when they can't reach the data centres.
  55. ImranSo the whole internet forgot how to reach Facebook, Instagram and WhatsApp.
  56. NadiaFor roughly five and a half hours. Engineers had to go to the buildings in person. And they brought power and traffic back in stages, because switching tens of megawatts and cold caches on at once could cause a second failure.
  57. ImranKeep a way in that doesn't depend on the system that's broken. And bring cold systems back slowly.
  58. NadiaNow the Build-it. First you make a small TAO in Postgres. An objects table, an associations table with an index on source, type and time, a counts table, and a list of which arrow types have an inverse.
  59. NadiaThen one function, assoc add, writes the arrow, its inverse, and both counts in one transaction. A double tap adds nothing, because of on conflict do nothing.
  60. ImranAnd the invalidation?
  61. NadiaA trigger sends a Postgres notify with the cache key that changed. Those messages are delivered only when the transaction commits, never on a rollback. A small listener process deletes the key in Redis.
  62. ImranAnd the lease cache itself?
  63. NadiaThree tiny Lua scripts in Redis. Lease get: hit, or the first caller gets a lease, or later callers wait. Lease set: store the value only if your lease is still there. Invalidate: delete the value and any lease, and keep the old value for a few seconds for screens that allow it.
  64. ImranWhat did the test show?
  65. NadiaTwo hundred requests for a cold key with a slow query. Plain cache-aside sent two hundred queries to Postgres. With leases, one. And in the late-reader race, plain caching kept the old price, while the lease cache refused the late set.
  66. ImranAnd I can practise the lease rules without installing Redis?
  67. NadiaYes. The practice section has an in-memory lease cache for you to finish, and a short SQL query that reads an association list, newest first.
  68. ImranMy review questions. One: when this row changes, which cache keys become wrong, and is the delete tied to the commit or to app code someone might skip?
  69. ImranTwo: what happens when the hottest key is deleted at the festival peak, one query or hundreds? Three: could a slow reader put an old value back?
  70. NadiaAnd one from the outage. Does any automatic job repair data on its own? What limits how fast it can act?
  71. ImranAnd at our size? The Mangrove box in the lesson says one Postgres primary, a replica and one Redis are enough, as long as deletes are tied to the commit and hot keys get a lease.
  72. NadiaRight. The separate cache tier and the many regions are for a hundred times that. Not today.
  73. NadiaHere's a question to think about. Why doesn't deleting the key after the write stop the late reader, when a lease does? Take a second.
  74. NadiaBecause the late reader isn't in the cache yet. It's holding the old row in its hand. The delete only clears what's in the cache at that moment. The lease gives the cache a memory, so the late set is refused.
  75. ImranTakeaways, my way. One: the whole social graph is objects and typed arrows on plain MySQL, with the shard number inside the ID.
  76. ImranTwo: caches answer about ninety-six percent of reads, and cache deletes travel with the replication stream, so they never arrive before the data.
  77. ImranThree: leases fix both the herd and the late reader.
  78. NadiaOne nuance on the second point. The writer's own region sees the new value at once, because it comes back with the reply. Everyone else catches up through replication, and in the twenty thirteen paper the lag was under a second most of the time.
  79. NadiaGood summary. The flashcards will ask you about leases and stale sets later this week.
  80. NadiaNext episode, Instagram. Three engineers, Postgres, and an ID generator that packs time and the shard number into sixty-four bits.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Read-heavy with a cache

A cache and a read replica take repeated reads off the primary.

Open in the System Design Studio
Next stopInstagram: from 3 engineers on Postgres to a billion users
Was this lesson helpful?
Questions and discussion

Loading the discussion…