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

40 minAdvanced

Google Search: crawling, indexing and answering in a fraction of a second

In March 2025 Google said it sees more than 5 trillion searches a year, which is on the order of 150,000 every second, each answered from an index of hundreds of billions of pages. This lesson takes the system apart: how pages are fetched, how the index is built and split across thousands of machines, and how one query is answered by many servers at once without waiting for the slowest one.

By the end you can

  • Explain an inverted index and why Google splits it by document
  • Trace a query from the search box to the merged top ten, and say where the time goes
  • Explain tail latency at fan-out, and how a hedged request cuts it for about 2% extra load
  • Name the infrastructure underneath (GFS and Colossus, MapReduce, Bigtable, Spanner, Borg) and what each taught
  • Build a tiny sharded search engine, the same with Postgres full-text search, and a hedged-request helper

The everyday picture

Imagine a grocery chain with 320 stores and one question from head office: "Which stores still have basmati rice, and how much?" One way is to phone each store in turn. At two minutes a call, the answer arrives in about a day and a half. The other way is to send the question to all 320 store managers at the same moment and have each one check only their own back room. Each answer takes two minutes, and they all arrive in about two minutes.

Two problems appear at once. First, each manager needs a fast way to check their own stock: a notebook sorted by product, so nobody walks every shelf. That notebook is the index. Second, head office now waits for the slowest manager. If 4 of the 320 are at lunch, everyone waits. Google Search is this picture at planet size: a sorted notebook on every one of thousands of machines, and a set of tricks so the reply never waits for the manager at lunch.

What Search must do

Functional: find pages on the public web and notice when they change; understand each page (words, language, links, whether it copies another page); for any query, return the best few results with a title and a snippet showing the matching words.

Non-functional, which drive the whole design:

  • Fast. In "The Tail at Scale" (2013), Jeff Dean and Luiz Barroso wrote that answers within about 100 ms feel fluid. The target is the , not the average (p99 and SLOs).
  • Read-only at query time. Queries never change the index, so caching and copying it are safe.
  • Fresh, always up, cheap per query. At trillions of queries, a tenth of a cent each is billions of dollars.
  • Polite. The crawler must not overload a small shop's website.

Scale in numbers

Google publishes few numbers, and many are old, so each has its year.

MetricNumberSource, year
Searches per yearmore than 5 trillionGoogle blog, 2025
Pages in the indexhundreds of billions"How Search works" page, current
Size of the indexwell over 100,000,000 GB (100 PB)"How Search works" page, current
Pages processed per second by the indexing systemhundreds of thousandsCaffeine announcement, 2010
Machines serving searchmore than 15,000 commodity PCsIEEE Micro paper, 2003 (very old)
Work per queryhundreds of MB read, tens of billions of CPU cyclesIEEE Micro paper, 2003
Data centre locations30 active locations in 11 countriesGoogle data centres site, current
Network edge locations (PoPs)over 200 metro areasGoogle Cloud docs, updated 2026
The whole web index in 199824 million pages, 147.8 GB of raw HTMLBrin and Page paper, 1998

With the method from the Estimation lesson: 5 trillion a year over about 31.5 million seconds is roughly 160,000 queries per second on average, with higher peaks. In 1998 a query took "1 to 10 seconds". This lesson is how they got from there to here.

The inverted index, and why it is split by document

A normal table answers "what words are in page 7?". Search needs the opposite: "which pages contain the word basmati?". An stores exactly that. For each word it keeps a : the sorted ids of every document containing the word, often with how many times and where. Google's own explainer compares it to the index at the back of a book.

To answer "basmati rice biryani", you fetch three posting lists and intersect them. Start with the shortest list (the rarest word) so there is less to check. Then score each surviving page. A rare word like "basmati" says more about a page than a common word like "rice", so it gets more weight. That idea is called . Real ranking adds hundreds of other signals, such as location, language and device.

No machine can hold a 100 PB index, so it must be split into (Sharding). There are two ways to split it:

  • By word: shard 1 holds every posting list for words starting a to c, and so on. A three-word query touches at most three shards, but each shard must send long lists over the network to be intersected.
  • By document: shard 1 holds a full little index for documents 1 to 1 million, shard 2 for the next million. Every query goes to every shard, but each shard does the whole job locally and sends back only its best few results.

In a 2009 keynote, Jeff Dean showed that Google chose by document: far less network traffic, and per-document data stays in one place. The price is that every query touches every shard, and much of the rest of the design exists to pay that price.

Quick check

A query has three words. Their posting lists hold 9 million, 40,000 and 2 million document ids. Which list should the intersection start from?

Your query nearest site Web server checks cache Root server 1. same query to every shard Shard 1 docs 1..N Shard 2 next N Shard 3 next N Shard 4 slow today 2. top 10 from each late 3. root merges: best 10 4. add titles, snippets
One query, many servers. The root sends the same query to every index shard at once. Each shard searches only its own documents and returns its best few. The root merges them, then document servers add titles and snippets. The answer cannot be ready before the slowest shard (here shard 4) replies.

The request path, step by step

Google's 2003 paper "Web Search for a Planet" and Dean's 2009 keynote describe the serving path. Today's details are not public, but the shape has stayed the same in talks for twenty years:

  1. Pick a site. DNS sends you to a nearby cluster with spare capacity (2003). Your connection enters Google's network at an edge location close to you; the Cloud docs list over 200 metro areas. If your city has none, you enter at the nearest one on the list.
  2. Web server. It checks a cache of recent answers, then asks the index. In 1999 cache servers answered roughly 30% to 60% of queries, because many people search for the same thing.
  3. Fan-out. A root server sends the query to every index shard. Since about 2004 this is a tree (root, parents, leaves), so no machine talks to thousands of others directly.
  4. Leaf work. Each leaf holds its shard in memory; Google moved the whole index from disk to RAM in early 2001. Each shard is , so the root can pick any healthy copy.
  5. Merge, then snippets. Leaves return their top results, parents and root merge them, and document servers (sharded the same way) cut a snippet around your words for each winner.

A 2003 rack held 40 to 80 ordinary x86 servers. The paper's point: "price/performance beats peak performance". Replication makes cheap hardware safe. If a machine dies, the root asks another copy, the same idea as stateless servers behind a load balancer (Scaling up vs out, Load balancers).

The tail at scale: why fan-out is dangerous

"The Tail at Scale" (Dean and Barroso, 2013) gives the arithmetic. Say one server answers in 10 ms, but 1 request in 100 takes a full second (a garbage-collection pause, a busy disk, a noisy neighbour). Now fan out to 100 such servers and wait for all of them: the chance that at least one is slow is 1 − 0.99100, about 63%. The rare slow server becomes the common case.

They measured a real Google service: one leaf alone had a p99 of 10 ms, waiting for all leaves gave 140 ms, and waiting for 95% of them gave 70 ms. The slowest 5% of leaves caused half of the p99 (p99 and SLOs).

The paper's fixes, each one usable by your team:

  • Hedged requests. Send to one replica. If no answer by about the 95th-percentile time, send the same request to a second replica and use whichever answers first. In their Bigtable test (1,000 keys over 100 servers), hedging after 10 ms cut the p99.9 from 1,800 ms to 74 ms for only 2% more requests.
  • Tied requests. Send to two replicas at once; whichever starts first tells the other to cancel. Nearly 40% off the p99.9 for less than 1% extra disk work.
  • Micro-partitions. About 20 small shards per machine instead of one big one, so load moves in roughly 5% steps.
  • Good-enough results. If a few leaves miss the deadline, answer from the rest.
  • Canary requests. Send an unusual query to one or two leaves first, so a query that crashes servers crashes only those.

Quick check

A screen calls 10 services in parallel and waits for all of them. Each service is slow on 1% of requests. Roughly how often is the screen slow?

0 10 ms 20 ms 1000 ms Plain request Replica A a pause: the user waits about 1 s Hedged request Replica A cancelled backup Replica B answer used cost: about 2% more requests
A hedged request (time axis not to scale). If replica A has not answered by about the 95th-percentile time, 10 ms here, the same request goes to replica B. B answers first, its result is used, and A is cancelled. Only the slowest few percent ever get a backup, so the extra load stays small.
Think first: why hedge after the p95 time, and not send two requests every time?

Two copies of every request double the load, and the extra load makes queues longer and the tail worse. Waiting until the p95 time means 95% of requests never need a backup, so the extra load is about 5% at most (2% in Google's test). Hedging is also only safe for reads, or writes with an idempotency key (APIs done right), because the work may run twice.

Building the index: crawl, process, publish

Serving is half the system. The other half runs all the time and turns the web into posting lists.

Crawl. starts from known URLs, sitemaps and links on pages it already has. Google's docs (2025) say it decides how often and how much to fetch per site, renders pages with a recent Chrome so JavaScript content is seen, and slows down when a site returns errors such as HTTP 500: backpressure (Rate limiting) applied politely to other people's servers. In 1998 the whole crawler was about three programs with roughly 300 open connections each, fetching about 100 pages a second.

Process. Each page is parsed into words and positions, its links are extracted (link text describes the page it points to, a key idea of the 1998 paper), and duplicates are grouped under one page. The 2006 Bigtable paper described the "Webtable": one row per page, keyed by the URL with the host reversed (com.cnn.www) so a site's pages sit together, with columns for contents and incoming link text. The crawl table alone was 800 TB.

Invert and publish. Turning "page to words" into "word to pages" is a giant sort. The 2004 MapReduce paper uses it as its example: map emits (word, document id) pairs, reduce sorts all ids for each word. Production indexing then ran as five to ten MapReduce jobs over more than 20 TB. Finished shards are copied to the serving leaves.

From batches to a stream. Batches are slow to show new pages. In 2010 Google said the main layer of its old index updated "every couple of weeks". Its replacement, Caffeine, processed pages continuously and gave 50% fresher results. The Percolator paper (2010) describes the engine: small updates applied with transactions and notifications instead of rewriting everything. It is the move from a nightly report job to an event-driven pipeline (Queues and jobs, Outbox and sagas).

Quick check

Why does Google build index shards in a separate pipeline and copy them to serving replicas, instead of doing that work on the machines that answer queries?

offline: runs all the time The web Crawlers GET HTML save Page store row per URL Process parse, dedupe, find links new URLs word to doc ids Build index shards split by document copy to replicas online: answers queries shard 1 3 copies shard 2 3 copies shard N 3 copies Queries (read only)
Two separate worlds. The offline pipeline crawls, stores and processes pages all the time; links found in pages go back to the crawlers as new URLs. Finished index shards are copied to many serving replicas. Queries only ever read those copies, so heavy indexing work never slows a search.

The data layer, a DBA's view

Search sits on storage systems Google described in papers. Read them as a DBA would: what is the unit, how is it split and copied, and what broke.

SystemWhat it isThe idea to remember
GFS (2003)A file system across many machines: "hundreds of terabytes" on over a thousand machinesOne master holds all the metadata; files are split into 64 MB chunks, each stored on three machines by default. Clients ask the master where a chunk is, then read it straight from a chunk server. Failures are normal.
Colossus (described 2021)The successor to GFSThe single master was the limit, partly because of Search's metadata. Colossus keeps metadata in Bigtable and scales over 100 times past the largest GFS clusters.
MapReduce (2004)Batch jobs over thousands of machinesYou write map and reduce; the system handles splitting and retries. Without backup copies of the last slow tasks, one test job ran 44% longer: hedging again.
Bigtable (2006)A sorted, sparse map from (row, column, time) to bytesRows are split into tablets of about 100 to 200 MB, the unit of moving load. Writes go to memory and are flushed into immutable sorted files. Over sixty products used it in 2006.
Spanner (2012)A globally distributed SQL database with transactionsTrueTime gives the time as an interval with known error (about 1 to 7 ms). A commit waits out the error, so transactions are ordered correctly across continents. Its first big user replaced hand-sharded MySQL for ads.

Three DBA lessons. First, keep the unit of splitting small and movable: moving a 200 MB tablet is easy, moving a 2 TB shard is a project. Instagram did the same with logical shards on Postgres (Instagram). Second, the metadata server is the hidden bottleneck: GFS had plenty of disk but one master. Third, write to memory and a log, flush sorted files later. Postgres also logs first (WAL, Replication), but it updates pages in place, while Bigtable never edits a file once written.

Servers and infrastructure

Everything above runs on . The 2015 Borg paper describes a cluster manager running hundreds of thousands of jobs across clusters of up to tens of thousands of machines each. It packs latency-sensitive serving jobs (the paper names web search as one) and batch jobs on the same machines: 98% of machines in shared cells ran both. Resources that serving jobs reserve but do not use are lent to batch work. The paper estimates that keeping the two apart would need 20% to 30% more machines in a typical cell. That sharing is a big part of why one query is cheap.

Kubernetes was written by engineers who had worked on Borg. A 2015 Kubernetes post lists what came across: pods, services for naming and load balancing, labels, and one IP address per pod, which fixed Borg's shared-IP port planning. If your team runs Kubernetes (Shipping safely), it uses Google's lessons from running Search.

Physically, Google lists 30 active data centre locations in 11 countries. The SRE book (2017) explains the front door: DNS makes a rough first choice of site, then network load balancers spread packets over servers behind one virtual IP, using consistent hashing (Sharding) so adding a server does not break most open connections. Search goes to the nearest site because latency matters most; a big video upload may go to a less busy site because throughput matters more.

Famous decisions and what they taught

  • Many cheap machines, with copies (2003). Software that expects failure made the system more reliable than its hardware. Lesson: design for failure first (Timeouts and retries).
  • Split by document, index in RAM (from 2001). Each shard is fast and independent, but tail latency became the central problem. Lesson: a design choice moves the problem; know where it moved.
  • From batch to incremental (2010). When freshness became a requirement, the batch job became a stream, which needs transactional, idempotent updates (Outbox and sagas).

When it broke: one character flagged the whole web

On 31 January 2009, between about 6:27 and 7:25 a.m. Pacific time, every Google search result carried the warning "This site may harm your computer". Marissa Mayer's post the same day explained it. Google keeps a list of sites known to host malware, updated from an outside partner. In that day's update, someone added the value / to the list by mistake. Since every URL contains /, every site matched. Each user saw it for roughly 40 minutes before the file was reverted. A staggered rollout limited the damage, and Google promised stronger checks on the file.

The lesson has nothing to do with search. A small data file reached the serving fleet with less checking than code gets. Treat config and data pushes like code: validate them ("this list may not match more than 0.1% of URLs"), roll out in stages, and watch a metric that would show the problem (Shipping safely). Facebook learned the same lesson (Facebook).

Build it

Build a tiny document-sharded search engine in TypeScript, the same search in Postgres, and a hedged-request helper you can use for real reads. All of it was run with Node 22, TypeScript and PostgreSQL 16 or later. You can also try the two core ideas in your browser: the Practice section has an auto-checked inverted index and a hedged-request exercise.

  1. One shard: an inverted index with intersection and scoring

    Each shard maps words to posting lists. A search intersects the lists, starting from the shortest, and scores each match so rare words count more.

    TypeScript
    // shard.ts: one index shard, an inverted index over some of the documents
    export type Hit = { doc: number; score: number };
    type Posting = { doc: number; tf: number };          // tf: times the word appears in doc
    
    export const tokenize = (text: string): string[] =>
      text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
    
    export class Shard {
      private postings = new Map<string, Posting[]>();   // word -> posting list
      private docLen = new Map<number, number>();        // doc -> number of words
    
      add(doc: number, text: string): void {
        const words = tokenize(text);
        this.docLen.set(doc, words.length);
        const counts = new Map<string, number>();
        for (const w of words) counts.set(w, (counts.get(w) ?? 0) + 1);
        for (const [w, tf] of counts) {
          const list = this.postings.get(w) ?? [];
          list.push({ doc, tf });
          this.postings.set(w, list);
        }
      }
    
      // Documents that contain ALL query words, best first
      search(query: string, k: number): Hit[] {
        const lists = [...new Set(tokenize(query))].map((w) => this.postings.get(w) ?? []);
        if (lists.length === 0 || lists.some((l) => l.length === 0)) return [];
        lists.sort((a, b) => a.length - b.length);       // rarest word first: smallest list
        let scores = new Map(lists[0].map((p) => [p.doc, 0]));
        for (const list of lists) {
          const idf = Math.log(1 + this.docLen.size / list.length); // rare words weigh more
          const next = new Map<number, number>();
          for (const p of list) {
            const sofar = scores.get(p.doc);
            if (sofar !== undefined) next.set(p.doc, sofar + (p.tf / this.docLen.get(p.doc)!) * idf);
          }
          scores = next;                                 // keep only docs in every list
        }
        return [...scores].map(([doc, score]) => ({ doc, score }))
          .sort((a, b) => b.score - a.score).slice(0, k);
      }
    }
  2. Split documents across shards, fan out, merge

    Documents go to shards by id, split by document like Google's. A query goes to all shards in parallel and each returns only its top k, so the merge is small. Each shard scores with its own document count, a simplification real engines also make.

    TypeScript
    // search.ts: split documents across shards, fan a query out, merge the answers
    import { Shard, Hit } from './shard';
    
    class MiniSearch {
      private shards: Shard[];
      constructor(n: number) { this.shards = Array.from({ length: n }, () => new Shard()); }
    
      add(doc: number, text: string): void {
        this.shards[doc % this.shards.length].add(doc, text);   // document-partitioned
      }
    
      async search(query: string, k = 3): Promise<Hit[]> {
        // Every shard answers in parallel with its own top k ...
        const parts = await Promise.all(this.shards.map(async (s) => s.search(query, k)));
        // ... and the root keeps the best k of all of them
        return parts.flat().sort((a, b) => b.score - a.score).slice(0, k);
      }
    }
    
    const docs = [
      'Jasmine rice 5 kg bag, best price in town',
      'Soybean oil 5 litre bottle, fortified',
      'Basmati rice 1 kg, aromatic, for biryani',
      'Rice cooker 1.8 litre, non-stick pot',
      'Red lentils 1 kg, split',
      'Brown rice 5 kg, long grain, wholegrain',
    ];
    
    const engine = new MiniSearch(3);
    docs.forEach((text, i) => engine.add(i + 1, text));
    
    (async () => {
      for (const q of ['rice 5 kg', 'litre', 'rice biryani', 'sugar']) {
        const hits = await engine.search(q);
        const shown = hits.map((h) => `doc ${h.doc} (${h.score.toFixed(2)})`).join(', ');
        console.log(q.padEnd(12), shown || 'no match');
      }
    })();
    Terminal
    npm init -y && npm i -D typescript @types/node
    npx tsc --target es2022 --module commonjs --strict --types node --outDir dist shard.ts search.ts
    node dist/search.js
    Output
    rice 5 kg    doc 6 (0.35), doc 1 (0.32)
    litre        doc 2 (0.18), doc 4 (0.14)
    rice biryani doc 3 (0.26)
    sugar        no match

    Doc 3 (basmati, 1 kg) does not match "rice 5 kg" because every word must be present. Doc 4, the rice cooker, matches "litre" but scores lower than the oil, because "litre" is a smaller share of its words.

  3. The same search in Postgres

    A tsvector column is the per-row word list, and a GIN index on it is the inverted index: word to rows. Weight the title above the body. Create the database with UTF-8 encoding if you will store names in other scripts, such as Hindi, Arabic or Bengali.

    SQL
    CREATE TABLE page (
      id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      url    text NOT NULL UNIQUE,
      title  text NOT NULL,
      body   text NOT NULL,
      -- normalised words with positions; a title match counts more than a body match
      doc    tsvector GENERATED ALWAYS AS (
               setweight(to_tsvector('english', title), 'A') ||
               setweight(to_tsvector('english', body),  'B')) STORED
    );
    CREATE INDEX page_doc_gin ON page USING gin (doc);   -- word -> list of rows
    
    INSERT INTO page (url, title, body) VALUES
     ('/rice/jasmine',   'Jasmine rice 5 kg',  'Everyday rice for busy kitchens. 5 kg bag.'),
     ('/rice/basmati',   'Basmati rice 1 kg',  'Long aromatic grains, the classic choice for biryani.'),
     ('/oil/soybean',    'Soybean oil 5 litre','Fortified with vitamin A. Cooking oil for frying.'),
     ('/kitchen/cooker', 'Rice cooker',        'Cooks 1.8 litre of rice. Non-stick pot.'),
     ('/blog/biryani',   'How to cook biryani','Use basmati rice, ghee, and whole spices.');
    
    -- Search: intersect, rank, and cut a snippet around the matching words
    SELECT url,
           round(ts_rank(doc, q)::numeric, 3) AS rank,
           ts_headline('english', title || '. ' || body, q, 'MaxWords=14, MinWords=5') AS snippet
    FROM page, websearch_to_tsquery('english', 'basmati rice biryani') AS q
    WHERE doc @@ q
    ORDER BY rank DESC
    LIMIT 10;
    Output
           url       | rank  |                          snippet
    -----------------+-------+-----------------------------------------------------------
     /rice/basmati   | 0.995 | <b>Basmati</b> <b>rice</b> 1 kg. Long aromatic grains, the classic choice for <b>biryani</b>
     /blog/biryani   | 0.913 | <b>biryani</b>. Use <b>basmati</b> <b>rice</b>, ghee
    (2 rows)

    websearch_to_tsquery turns the search box text into 'basmati' & 'rice' & 'biryani': an intersection, as in step 1. It also accepts quotes for phrases ("5 kg" rice) and or. Check that the index is used; on a five-row table you must turn off sequential scans to see it, on a real table the planner picks it alone (Query plans).

    SQL
    SET enable_seqscan = off;   -- only for this demo on a tiny table
    EXPLAIN (COSTS OFF)
    SELECT url FROM page WHERE doc @@ websearch_to_tsquery('english', 'rice biryani');
    Output
     Bitmap Heap Scan on page
       Recheck Cond: (doc @@ '''rice'' & ''biryani'''::tsquery)
       ->  Bitmap Index Scan on page_doc_gin
             Index Cond: (doc @@ '''rice'' & ''biryani'''::tsquery)
  4. A hedged-request helper

    This sends the call to the first replica. If it is still running after hedgeAfterMs, or if it fails, it sends one backup to the second replica. The first answer wins and the other call is cancelled through its AbortSignal. Use it only for reads or idempotent calls. The Practice section asks you to write a smaller version of this logic yourself.

    TypeScript
    // hedge.ts: ask one replica; if it is slow, also ask a second and take the first answer
    export type Call<T> = (replica: string, signal: AbortSignal) => Promise<T>;
    
    export async function hedged<T>(replicas: string[], call: Call<T>, hedgeAfterMs: number): Promise<T> {
      const controllers: AbortController[] = [];
      const send = (replica: string) => {
        const c = new AbortController();
        controllers.push(c);
        return call(replica, c.signal);
      };
      let backup: Promise<T> | undefined;
      const sendBackup = () => (backup ??= send(replicas[1]));   // never more than one backup
    
      let timer: ReturnType<typeof setTimeout> | undefined;
      const first = send(replicas[0]);
      const racers: Promise<T>[] = [first];
      if (replicas.length > 1) {
        racers.push(new Promise<T>((resolve, reject) => {          // slow: hedge after the delay
          timer = setTimeout(() => sendBackup().then(resolve, reject), hedgeAfterMs);
        }));
        racers.push(first.catch(() => sendBackup()));              // failed fast: back up now
      }
      try {
        return await Promise.any(racers);                          // whoever answers first wins
      } catch (e) {
        throw e instanceof AggregateError ? e.errors[0] : e;       // all failed: show the first error
      } finally {
        clearTimeout(timer);
        controllers.forEach((c) => c.abort());                     // cancel the loser
      }
    }

    With a real HTTP read, pass the signal to fetch so the losing request is really cancelled:

    TypeScript
    import { hedged } from './hedge';
    
    type SearchResult = { hits: { id: number; name: string }[] };
    
    export function searchArticles(q: string): Promise<SearchResult> {
      const replicas = ['http://search-a.internal:4100', 'http://search-b.internal:4100'];
      return hedged(replicas, async (base, signal) => {
        const res = await fetch(`${base}/search?q=${encodeURIComponent(q)}`, { signal });
        if (!res.ok) throw new Error(`${base} answered ${res.status}`);   // 5xx: let the backup win
        return (await res.json()) as SearchResult;
      }, 50);                                         // about your p95 for this call, in ms
    }
  5. Prove it with a fan-out simulation

    Each query fans out to 20 shards and waits for all of them. About 1 call in 50 is slow (200 to 300 ms). Run 400 queries plain, then hedged after 20 ms.

    TypeScript
    // tail.ts: 400 queries, each fanning out to 20 shards. About 1 call in 50 is very slow.
    import { hedged, Call } from './hedge';
    
    let calls = 0;
    const fakeLeaf: Call<string> = (replica, signal) => {
      calls++;
      const slow = Math.random() < 0.02;                       // a GC pause, a busy disk ...
      const ms = slow ? 200 + Math.random() * 100 : 5 + Math.random() * 10;
      return new Promise((resolve, reject) => {
        const t = setTimeout(() => resolve(replica), ms);
        signal.addEventListener('abort', () => { clearTimeout(t); reject(new Error('aborted')); });
      });
    };
    
    async function query(shards: number, hedge: boolean): Promise<number> {
      const start = performance.now();
      await Promise.all(Array.from({ length: shards }, (_, i) => {
        const replicas = [`shard${i}-a`, `shard${i}-b`];
        return hedge ? hedged(replicas, fakeLeaf, 20) : fakeLeaf(replicas[0], new AbortController().signal);
      }));
      return performance.now() - start;                        // a query waits for its slowest shard
    }
    
    const pct = (xs: number[], p: number) => xs.sort((a, b) => a - b)[Math.ceil((p / 100) * xs.length) - 1];
    
    (async () => {
      for (const hedge of [false, true]) {
        calls = 0;
        const times: number[] = [];
        for (let batch = 0; batch < 40; batch++) {             // 40 batches of 10 queries
          times.push(...await Promise.all(Array.from({ length: 10 }, () => query(20, hedge))));
        }
        const extra = ((calls / 8000 - 1) * 100).toFixed(1);   // 400 queries x 20 shards = 8000
        console.log(`${hedge ? 'hedged' : 'plain '}  p50 ${pct(times, 50).toFixed(0)} ms` +
          `  p99 ${pct(times, 99).toFixed(0)} ms  calls ${calls} (+${extra}%)`);
      }
    })();
    Terminal
    npx tsc --target es2022 --module commonjs --strict --types node --outDir dist hedge.ts tail.ts
    node dist/tail.js
    Output
    plain   p50 14 ms  p99 296 ms  calls 8000 (+0.0%)
    hedged  p50 15 ms  p99 35 ms  calls 8149 (+1.9%)

    Numbers change a little per run; the shape does not. With 20 shards and a 2% slow rate, about one plain query in three hits a slow shard (1 − 0.9820 is about 33%), so the plain p99 is the slow time. Hedging adds about 2% more calls and brings the p99 near the normal time, the same trade Google measured. Running far more queries at once in one Node process makes timers fire late and the numbers noisy; that is a limit of the simulation.

Bring it to your team

Use these when a design calls many services or shards for one screen, or when someone proposes a search feature.

  • How many backend calls does this screen wait for, and what is the p99 of the slowest one?
  • If one of those calls is slow, do we wait, time out and show partial results, or hedge to a replica?
  • Is this read safe to send twice? If it writes anything, where is the idempotency key?
  • For product search, have we measured Postgres full-text search with a GIN index before adding a search engine?
  • Is the search index built from the live tables, or from a copy, so that heavy indexing never slows checkout?
  • How are config and data files (price lists, block lists, flags) validated and rolled out in stages before every server reads them?

Red flags

  • A dashboard or home screen that waits for every one of many calls with no timeout.
  • Retries or hedges on a POST with no idempotency key.
  • Hedging every request immediately, which doubles load and makes the tail worse.
  • LIKE '%rice%' on a large table as the product search.
  • A config push that goes to all servers at once with no validation step.

Practice

Build a tiny inverted index

Write buildIndex(docs), which turns a list of { id, text } documents into a Map from each word to the sorted ids of the documents that contain it, and search(index, query), which returns the ids of the documents that contain every word of the query. Lowercase all text and split it on anything that is not a letter or a digit. A word that appears twice in one document still appears once in its posting list. This is step 1 of the Build-it without the scoring.

JavaScript exerciseRuns in your browser with tests
// Build an inverted index: word -> sorted list of doc ids, with no repeats.
// Split text into lowercase words on anything that is not a-z or 0-9.
function buildIndex(docs) {
  const index = new Map();
  return index;
}

// Ids of the docs that contain EVERY word of the query, smallest id first.
// Start the intersection from the shortest posting list.
function search(index, query) {
  return [];
}
Ctrl + Enter runs
Show a solution
JavaScript
const words = (text) => text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);

function buildIndex(docs) {
  const index = new Map();
  for (const { id, text } of docs) {
    for (const w of new Set(words(text))) {        // a Set drops repeats inside one doc
      if (!index.has(w)) index.set(w, []);
      index.get(w).push(id);
    }
  }
  for (const list of index.values()) list.sort((a, b) => a - b);
  return index;
}

function search(index, query) {
  const lists = [...new Set(words(query))].map((w) => index.get(w) ?? []);
  if (lists.length === 0) return [];
  lists.sort((a, b) => a.length - b.length);        // rarest word first
  let result = lists[0];
  for (const list of lists.slice(1)) {
    const inList = new Set(list);
    result = result.filter((id) => inList.has(id)); // keep ids found in both
  }
  return result;
}
Pass the exercise to finish this task.

Write a hedged request

Write hedged(replicas, call, hedgeAfterMs). It asks the first replica. If there is no answer after hedgeAfterMs, it also asks the second replica, and the first successful answer wins. If the first replica fails before the timer fires, it asks the second one at once instead of waiting. It must never send more than one backup. Promise.any resolves with the first promise that succeeds, which is useful here. Cancelling the loser is left out to keep the task short.

JavaScript exerciseRuns in your browser with tests
// Ask replicas[0]. If it has not answered after hedgeAfterMs, ALSO ask replicas[1].
// If replicas[0] fails before that, ask replicas[1] at once.
// Return the first successful answer. Never send more than one backup.
// call(replica) returns a Promise.
async function hedged(replicas, call, hedgeAfterMs) {
  return call(replicas[0]);
}
Ctrl + Enter runs
Show a solution
JavaScript
async function hedged(replicas, call, hedgeAfterMs) {
  let backup = null;
  const sendBackup = () => (backup ??= call(replicas[1]));   // at most one backup
  let timer;
  const first = call(replicas[0]);
  const racers = [
    first,
    new Promise((resolve, reject) => {                        // slow: hedge after the delay
      timer = setTimeout(() => sendBackup().then(resolve, reject), hedgeAfterMs);
    }),
    first.catch(() => sendBackup()),                          // failed fast: back up now
  ];
  try {
    return await Promise.any(racers);                         // first success wins
  } finally {
    clearTimeout(timer);
  }
}
Pass the exercise to finish this task.

Check yourself

A query fans out to 100 leaf servers and waits for all of them. Each leaf is slow on 1% of requests. Roughly what share of queries is slow?

Why did Google split its index by document rather than by word?

Your product search calls two replicas of a search service. Which hedging setup is the best default?

Flashcards

Open my review

Key takeaways

  • Search is an inverted index (word to sorted document ids), split by document across thousands of machines, held in memory and replicated.
  • Every query fans out to every shard, so the slowest shard sets the speed. At fan-out, rare slowness becomes common.
  • Hedged requests, partial results and small movable shards tame the tail for a few percent extra load.
  • Indexing is a separate, always-running pipeline. Queries read finished copies, so heavy work never slows a search.
  • The worst outages often come from a small config or data push. Validate it and roll it out in stages, like code.

Sources

Episode transcriptWaiting for the slowest store manager · about 14 minutes · tap any line to play from there
  1. NadiaImran, say head office wants to know which of our three hundred and twenty stores still have basmati rice. Right now. How would you find out?
  2. ImranPhoning them one by one takes days. I'd send one message to every store manager at the same moment, and ask each one to check their own back room.
  3. NadiaGood. And when do you have the full answer?
  4. ImranWhen the slowest manager replies. If one is at lunch, I wait for lunch.
  5. NadiaThat is the hardest problem inside Google Search, in one sentence. Today we take Search apart: crawling, the index, and how one query is answered by thousands of machines without waiting for the one at lunch.
  6. NadiaThe lesson opens with that store picture. Each manager needs a notebook sorted by product, so nobody walks every shelf. That notebook is the index. And head office needs a trick so one late manager doesn't hold up everyone.
  7. ImranStart with the scale. How big is this thing?
  8. NadiaIn March twenty twenty-five, Google said it sees more than five trillion searches a year. Divide by the seconds in a year and that's roughly one hundred and sixty thousand queries a second, on average.
  9. ImranAnd the index?
  10. NadiaGoogle's own explainer says hundreds of billions of pages, and well over one hundred million gigabytes. More than one hundred petabytes.
  11. ImranAnd at the start?
  12. NadiaIn nineteen ninety-eight, twenty-four million pages, and a query took one to ten seconds.
  13. ImranSo what is the index, exactly? I know B-trees from the indexes lesson.
  14. NadiaThis one has a different shape. A normal table answers, what words are in page seven. Search needs the opposite: which pages contain the word basmati. So for every word, you keep a sorted list of the page IDs that contain it.
  15. NadiaThat list is called a posting list, and the whole structure is an inverted index.
  16. ImranLike the index at the back of a book. The word, then the page numbers.
  17. NadiaThat's the comparison Google uses. Now take a three-word query, basmati rice biryani. You fetch three lists and keep only the pages that appear in all three. That's an intersection.
  18. ImranI'd start with the shortest list. Fewer pages to check.
  19. NadiaRight, the rarest word first. And rare words count more in the score too. Basmati tells you more about a page than rice does.
  20. ImranOne hundred petabytes doesn't fit on one machine, so they shard it. My guess: split by word. Words starting with A to C on one shard, and so on. Then a three-word query only touches three shards.
  21. NadiaThat's the natural guess, and Google chose the opposite. In a two thousand nine keynote, Jeff Dean explained that they split by document. Shard one holds a complete little index for one set of pages, shard two for the next set.
  22. ImranBut then every query goes to every shard.
  23. NadiaYes. The upside is that each shard does the whole intersection locally and sends back only its best ten. Split by word, and long posting lists would have to cross the network to be intersected. Far more traffic.
  24. ImranSo they traded network traffic for fan-out.
  25. NadiaExactly. If you can see the screen, the first diagram shows the path. If you're only listening, picture one box at the top, the root server, with arrows spreading down to a row of shards, and one shard coloured as slow today.
  26. NadiaYour query reaches a web server near you, which checks a cache. The root sends it to every shard at once. Each shard returns its top ten, the root merges them into the best ten overall, and document servers add titles and snippets.
  27. ImranWhere does the index live? On disk?
  28. NadiaIn memory. Google moved the whole index into RAM in early two thousand one. And every shard has several copies, so the root can ask any healthy one. Their two thousand three paper said many cheap machines with copies beat a few expensive ones.
  29. ImranSo far it sounds fine. Where's the catch?
  30. NadiaThe manager at lunch. Here's the arithmetic from a two thousand thirteen paper called The Tail at Scale. One server answers in ten milliseconds, but one request in a hundred takes a full second. Maybe a garbage collection pause, or a busy disk.
  31. ImranSo one percent of requests are slow. I can live with that.
  32. NadiaFor one server, yes. Now ask a hundred such servers and wait for all of them. What share of queries is slow?
  33. ImranStill one percent? Each server is only slow one percent of the time.
  34. NadiaThat's the trap. The query is slow if any one of the hundred is slow. That works out to about sixty-three percent. The rare slow case becomes the normal case.
  35. ImranSixty-three percent. So fan-out turns a small problem into a big one.
  36. NadiaThey measured a real Google service too. One leaf alone had a p ninety-nine of ten milliseconds. Waiting for all the leaves, it was one hundred and forty. And the slowest five percent of leaves caused half of that.
  37. ImranSo what do they do about it?
  38. NadiaThe famous fix is the hedged request. The second diagram shows two timelines. On top, a plain request to replica A hits a pause, and the user waits about a second.
  39. NadiaBelow, after ten milliseconds with no answer, the same request also goes to replica B. B answers quickly, that answer is used, and A is cancelled.
  40. ImranWhy wait ten milliseconds? Why not send to both every time?
  41. NadiaBecause that doubles the load, and extra load makes queues longer, which makes the tail worse. You wait until about the ninety-fifth percentile time. Then only the slowest few requests ever get a backup.
  42. ImranAnd what does it cost?
  43. NadiaIn their Bigtable test, hedging after ten milliseconds cut the p ninety-nine point nine from eighteen hundred milliseconds to seventy-four. For only two percent more requests.
  44. ImranTwo percent extra to make the worst case more than twenty times faster. I'd take that deal.
  45. NadiaThe paper has more tricks. Many small shards per machine, so load moves in small steps. Good-enough results when a few leaves miss the deadline. And sending a strange new query to one or two leaves first, so if it crashes servers, it only crashes those.
  46. ImranOne thing worries me. If I hedge, the work might happen twice.
  47. NadiaGood catch, Imran. So hedge only reads, or writes that carry an idempotency key, from the APIs lesson. Never a plain request that creates an order.
  48. ImranNow the other half. How does the index get built?
  49. NadiaThe third diagram shows two separate worlds. On top is the offline world, which runs all the time. Crawlers fetch pages from the web and save them in a page store. Processing parses each page and finds links, and those new links go back to the crawlers as more addresses to visit.
  50. NadiaThen the pipeline turns page to words into word to pages, builds the index shards, and copies them down to the serving replicas. Queries only ever read those copies.
  51. ImranSo heavy indexing never slows a search. Like running reports on a replica instead of the POS primary.
  52. NadiaThe same instinct. One nice detail: the crawler slows down when a website returns server errors, so it doesn't knock over a small site. That's backpressure, used politely.
  53. ImranHow fresh is the index?
  54. NadiaIn twenty ten, Google said the main layer of its old index updated every couple of weeks. The new system, Caffeine, processed pages continuously and gave fifty percent fresher results. Underneath it, a system called Percolator applies small updates with transactions instead of rebuilding everything.
  55. ImranLike moving from a nightly report job to handling events as they happen.
  56. NadiaRight. And under all of this sit the famous papers. The Google File System and its successor Colossus. MapReduce for big batch jobs. Bigtable, a giant sorted table split into small pieces called tablets. Spanner, a global SQL database. And Borg, which runs everything, and which Kubernetes grew out of.
  57. ImranIs there one DBA lesson in that list?
  58. NadiaKeep the unit you split by small and movable. Moving a two hundred megabyte tablet is easy. Moving a two terabyte shard is a project. We'll see Instagram do the same thing with Postgres two lessons from now.
  59. ImranTell me about a time it broke.
  60. NadiaJanuary two thousand nine. For about forty minutes, for each user, every Google result carried the warning, this site may harm your computer.
  61. ImranEvery result? An attack?
  62. NadiaNo. Google keeps a list of sites that host malware. Someone added a single slash character to that list by mistake. Every web address contains a slash, so every site matched.
  63. ImranOne character.
  64. NadiaOne character, in a data file, not in code. The lesson: treat config and data pushes like code. Validate them, for example this list may match only a tiny share of sites. Roll them out in stages, and watch a metric.
  65. ImranWe push price files and feature flags to every store. Same risk.
  66. NadiaSame risk. The At Mangrove Market box in the lesson covers that, and product search too. With about twenty-five thousand products, Postgres full text search on one server is plenty for the Mangrove app and the Partner app.
  67. NadiaNow the Build-it, in three small pieces. First, a tiny search engine in TypeScript. Each shard keeps a map from word to posting list. A search intersects the lists, rarest word first, and scores the matches.
  68. ImranAnd the sharding part?
  69. NadiaYou split six product names across three shards by document ID, send each query to all three in parallel, and merge their top results. You'll see that rice five kg doesn't match the one kilo basmati, because every word must be present.
  70. NadiaSecond, the same thing in Postgres. A generated text search column with the title weighted above the body, and a GIN index on it. The GIN index really is an inverted index, word to rows. You run a ranked query with highlighted snippets, and EXPLAIN shows the index in use.
  71. ImranAnd third, the hedging helper.
  72. NadiaIt sends to the first replica. If there's no answer within your p ninety-five, or the first call fails, it sends one backup. The first answer wins and the loser is cancelled. Then a small simulation fans each query out to twenty shards, with one call in fifty being slow.
  73. ImranWhat did the numbers show?
  74. NadiaPlain, the p ninety-nine was about three hundred milliseconds. Hedged, about thirty-five. For about two percent more calls. The same shape as Google's result.
  75. ImranAnd I can try two of those pieces in the browser, right?
  76. NadiaYes. The practice section asks you to build the inverted index and write the hedged request yourself, and the page checks your code.
  77. ImranMy questions for the next design review. One: how many calls does this screen wait for, and what's the p ninety-nine of the slowest?
  78. ImranTwo: if one call is slow, do we wait, show partial results, or hedge? Three: before we add a search engine, have we measured Postgres full text search with a GIN index?
  79. NadiaI'd add one from the outage story. How are config and data files checked and rolled out in stages before every server reads them?
  80. NadiaNow a question to think about. Why does Google split the index by document, when that means every single query touches every shard? Take a second.
  81. NadiaBecause each shard can finish the whole query locally and send back only ten results. Split by word, and huge posting lists would cross the network to be intersected. Touching every shard is the price, and hedging is how they pay it.
  82. ImranLet me say the takeaways my way. One: Search is an inverted index, split by document across thousands of machines, kept in memory, with copies.
  83. ImranTwo: every query fans out to every shard, so the slowest shard sets the speed, and hedging fixes that for a couple of percent extra load.
  84. NadiaOne nuance. Hedge after about the p ninety-five time, not at once. Sending two copies of everything makes the tail worse.
  85. ImranGot it. Three: building the index is a separate pipeline, and queries only read finished copies. And a bonus: one character in a data file can break everything.
  86. NadiaPerfect. The flashcards at the end of the lesson will bring these back in a few days.
  87. NadiaNext episode, Facebook: how one page view reads hundreds of small things from a cache, and what happens when the cache and the database disagree.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Architecture review

Today's design on the board with the questions a review asks as notes, and criteria for the targets you must keep.

Open in the System Design Studio
Next stopFacebook: the social graph, TAO and memcache at planet scale
Was this lesson helpful?
Questions and discussion

Loading the discussion…