SevyDevy

Guide · System design

Design a Q&A Site Like Quora: Questions, Answers and Ranking

29 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • A quora system design is the plan for a question-and-answer site. It decides where questions, answers and votes live, how votes are counted, how the best answer reaches the top, and how repeat questions get caught.
  • Reads far outnumber writes, so serve question pages from a cache, a fast in-memory copy of popular data. The database stays the record of truth.
  • Save each vote as its own row at once. Update an answer's totals later, in short batches from a queue (a waiting line of messages), so one popular answer never blocks other votes.
  • Rank answers by the Wilson lower bound: the lowest up-vote share you can be fairly sure of, given the votes so far. It avoids the traps of both net votes and the plain up-vote share.
  • Beginners can stop after the section on keeping a viral page fast. Engineers then get a check for votes stuck behind a locked database row, a vote counter that survives a repeated batch, and a tie-order surprise in Redis, an in-memory store.

What goes wrong when one question on a small Q&A site goes viral?

Pages crawl, votes fail and the wrong answer sits on top. A small question-and-answer site does fresh database work on every page view and every vote, and one hot question multiplies that work. Quora system design is the set of choices that prevents this.

Picture a common setup. A small engineering team runs a Hindi-first question-and-answer site. People ask about pensions, farming, phones and city life, in Hindi, English or Hinglish (Hindi typed in English letters). The setup is made up, but the problems are real.

The site runs on one PostgreSQL server (a popular open-source database) and one app server. It has about 30 lakh (3 million) monthly users. Each day brings about 20,000 new questions and 60,000 answers.

Then a question about a new LPG (cooking gas) subsidy rule spreads through family chat groups. It draws 4 lakh views in two hours. Four things go wrong that week.

  • Every page view sorts all 900 answers inside the database. Pages take 9 seconds to load (made up).
  • Every vote updates the answer's totals and then re-sorts all 900 answers, inside one transaction (a group of database changes saved together). The answer's row stays locked, so no other vote can change it until the transaction ends.
  • A two-vote answer with the wrong deadline sits on top. A careful answer with 412 up-votes and 38 down-votes sits below it.
  • Users post 140 copies of the same question, each with its own scattered answers.

None of these is a one-line bug. Each comes from a design built for a quiet day. This guide rebuilds the site one problem at a time, keeping the same site all the way through.

What does a Quora system design need to do?

A Quora system design needs to let people ask, answer, vote and read the best answers first, quickly, for millions of readers. Before drawing any boxes, write those needs down with numbers.

Designers split needs into two kinds. Functional requirements are what the product does, like posting an answer. Non-functional requirements are how well it does it, like loading a page in under one second.

  • Ask a question, with a title, some detail and up to five topics.
  • Answer a question, and edit your own answer later.
  • Up-vote or down-vote an answer, once per person, with the right to change your mind.
  • See the answers to a question with the best one first.
  • Search questions in Hindi, English or Hinglish.
  • Get a home feed of good questions from the topics you follow.

Now the non-functional side. A question page should load in under one second, even when it goes viral. A vote may take a few seconds to change the order of answers. Losing a written answer is never acceptable.

Next, a rough estimate. The site serves about 50 lakh question page views a day, against 80,000 new questions and answers. That is about 60 page views for every new post.

Votes are writes too. Add about 4 lakh votes a day (made up), and there are still about 10 reads for every write. Either way reads dominate, so reads must be cheap.

Averaged over a day, 50 lakh views is about 58 page views a second. The viral question alone drew about 56 a second for two hours, all on one page. Averages hide spikes like that.

What are the main parts of a Q&A site like Quora?

The main parts are app servers, a database, a cache for hot pages, a queue and a worker for counting votes, and a search index. Each part fixes one problem from the viral week.

A few words are new. A cache is a fast in-memory store that keeps copies of things people read often. A queue is a waiting line of messages that one program writes and another reads later. A worker is a background program that takes jobs from a queue.

A load balancer spreads incoming requests across several app servers. A search index finds questions by their words; a later section shows how. Here the cache is Redis, a popular in-memory data store. Start at the reader and follow the arrows.

The Q&A site after the redesign

Notice that page views reach the cache first, and only votes pass through the queue. The database stays the record of truth.

Diagram connections:

  • App and website to Load balancer
  • Load balancer to Q&A app servers
  • Q&A app servers to Redis cache (Read pages first)
  • Q&A app servers to PostgreSQL shards (Save posts and votes)
  • Q&A app servers to Vote queue (Vote event)
  • Vote queue to Vote counter worker
  • Vote counter worker to PostgreSQL shards (Write new totals)
  • Vote counter worker to Redis cache (New answer order)
  • Q&A app servers to Search index (Find similar questions)
  • App and website: Readers, askers and answerers
  • Load balancer: Spreads requests across app servers
  • Q&A app servers: Pages, posting, votes and search
  • Redis cache: Hot pages and ranked answer lists
  • PostgreSQL shards: Questions, answers and votes: the truth
  • Vote queue: Holds vote events until the worker runs
  • Vote counter worker: Recounts touched answers every 5 seconds
  • Search index: Finds questions by their words

Big sites lean hard on caches for the same reason. Facebook's 2013 paper on scaling memcache, an in-memory cache, describes a system that handles billions of requests per second. This site is far smaller, but the idea is the same: answer most reads from memory.

How do you store questions, answers and votes?

Store them in three tables: questions, answers and votes. Each answer points to its question, and each vote points to one answer. A relational database such as PostgreSQL or MySQL fits, because the data has clear links and needs reliable writes.

The three main tables and why each key was chosen
TableKey columnsWhy it looks this way
questionsquestion_id, title, body, topic_ids, canonical_idcanonical_id points a duplicate to the question it was merged into
answersanswer_id, question_id, body, up_count, down_count, scoreTotals and score are stored, so a page never recounts votes
votesanswer_id and user_id together as the primary key, plus question_id and value (1 or -1)The pair key allows one vote per person per answer

Look at the votes table first. Its primary key, the column or columns that must be unique, is the pair of answer and user. Save votes with an upsert, an insert that updates the row if it already exists. A second tap from the same person then updates the same row, so nobody can vote twice.

The answers table keeps up_count, down_count and score next to the answer. This is denormalisation: storing a copy of a computed value so reads skip the work. The votes table stays the source of truth.

Splitting the data across machines

When one database server is not enough, you split the rows across several servers. This is sharding, and each server holds one shard. The shard key is the column that decides where a row lives.

Shard answers by question_id, so all answers to one question sit on one shard and a question page reads one machine. Give each vote row a question_id too, and shard votes the same way. Recounting an answer's votes then stays on that machine.

Keeping a question's rows together has a second benefit. A foreign key, the rule that a vote must point at a real answer, can only be checked inside one database. Rows split across servers lose that check.

How do you count votes without overloading the database?

Save each vote as its own row at once, but update the answer's totals a few seconds later, in batches, outside the vote request. Above all, keep every transaction that touches the answer's row short.

Here is why the old design stalled. When a transaction updates a row, PostgreSQL locks that row until the transaction ends. Any other update to the same row waits in line until then.

Pause and predict. The hot answer got about 12,000 votes in two hours, roughly 1.7 a second. Each vote held the row for about one second while it re-sorted 900 answers (made up). What happens to the line?

It grows by about 0.7 votes a second, because about 1.7 arrive and only one can finish. Within a minute, a new vote has 30 or more votes ahead of it. It then waits 30 seconds or more, and the app gave up on requests after 30 seconds (made up).

What just happened? The vote rate was small. The trouble was that each vote held the lock while doing slow work that never needed it. By default PostgreSQL 16 sets no limit on lock waits (lock_timeout is 0), so the failures came from the app's own timeout.

Follow one vote below, from the tap to the new order of answers. The voter waits only for the first two steps.

The life of one up-vote

The voter waits only for the first two steps. Everything after the queue happens later, so the answer's row is locked only for a short recount every 5 seconds.

Diagram connections:

  • Tap up-vote to Save the vote row
  • Save the vote row to Publish a vote event
  • Publish a vote event to Collect 5 seconds of votes
  • Collect 5 seconds of votes to Recount touched answers
  • Recount touched answers to Update the answer order
  • Tap up-vote: App → App server
  • Save the vote row: App server → Votes table
  • Publish a vote event: App server → Vote queue
  • Collect 5 seconds of votes: Vote queue → Counter worker
  • Recount touched answers: Counter worker → Votes and answers tables
  • Update the answer order: Counter worker → Redis sorted set

The app shows the voter's new total on their own screen at once. Everyone else sees it a few seconds later, which the requirements allow.

The last step writes into a Redis sorted set, one per question. A sorted set keeps its members ordered by a number called the score; here each member is an answer and the score is its rank score.

Adding or updating a member costs O(log N) time, where N is the set's size. In plain words, the work grows very slowly as the set grows. Reading the top ten answers is one range read, high to low.

With one counter worker and 5-second batches, the hot answer's row gets at most 1,440 updates in two hours instead of 12,000. Each one is a quick recount and a write, not a re-sort of 900 answers, so the lock is held only briefly.

Saving the vote row itself does not wait behind the answer's row either. The engineer section shows this in a real PostgreSQL test.

How does a Q&A site rank answers by votes?

A good Q&A site ranks answers by the lowest up-vote share it can be fairly sure of, not by net votes or the raw share. This is the lower bound of the Wilson score interval, and it rewards both quality and evidence.

Think of a motorcycle touring club's Sunday meet. A new rider asks which rain gear survives a long ride. One member shouts a tip and his two friends cheer. Another tip has been backed by forty members over many meets, with a few disagreeing.

You trust the second tip. Map it part by part: each tip is an answer, each cheer is an up-vote, and each disagreement is a down-vote. Your trust is the Wilson score, high only when many people back the tip.

Where the picture breaks: club members know each other and can judge the speaker. Votes on a site come from strangers, can be faked, and the score sees only the counts.

Why the two simple methods fail

Evan Miller's 2009 article on sorting by rating names both traps. Net score, up-votes minus down-votes, ignores the share. In the table below, an answer with 120 up and 80 down beats one with 9 up and 1 down.

The plain share, up-votes divided by all votes, ignores how few votes there are. That was the viral week's bug. An answer with 2 up-votes and no down-votes has a perfect 100 percent, so it beat the careful answer.

Four answers to the LPG subsidy question, ranked three ways (vote counts made up; Wilson scores computed with z = 1.96)
AnswerUp / down votesNet scoreUp-vote shareWilson lower bound
A: short, wrong deadline2 / 02 (last)100% (first)0.342 (last)
B: careful, with the official rule412 / 38374 (first)91.6% (second)0.886 (first)
C: short but correct9 / 18 (third)90.0% (third)0.596 (second)
D: long, half right120 / 8040 (second)60.0% (last)0.531 (third)

Read the last column. Answer B wins clearly. Answer A drops to the bottom, because two votes prove very little. Answer C beats D, because its votes, though few, are nearly all positive.

The Wilson lower bound in Python

Here is the function the counter worker calls. The value z sets how cautious the score is. Evan Miller's article says to use 1.96 for a confidence level of 0.95, meaning 95 percent.

python
from math import sqrt

def wilson_lower_bound(up, down, z=1.96):
    n = up + down
    if n == 0:
        return 0.0                    # no votes, no evidence
    p = up / n                        # observed up-vote share
    centre = p + z * z / (2 * n)
    spread = z * sqrt((p * (1 - p) + z * z / (4 * n)) / n)
    return (centre - spread) / (1 + z * z / n)

print(round(wilson_lower_bound(2, 0), 3))     # answer A
print(round(wilson_lower_bound(412, 38), 3))  # answer B

Run it with python3 and you should see two lines: 0.342 and then 0.886. Those match rows A and B of the table.

The spread line is the key. It is large when n, the number of votes, is small, so few votes pull the score down hard. At the same 90 percent share, 10 votes score 0.596, 100 votes score 0.826 and 1,000 votes score 0.880.

The z value is a product choice, not a law. Reddit's open-source code, archived in 2017, used this formula for its confidence sort, with z = 1.28, which its code comment labels 80 percent confidence. A lower z is less cautious, so newer answers climb sooner.

Votes are not the only signal. You might start answers from people with a track record on the topic a little higher. With enough data, a learned ranking model can combine many signals, such as reports and reading time.

How do you stop duplicate questions on a Q&A site?

You stop most duplicates before they exist: as the person types, search for similar questions and show them. The rest are merged later, so one question page holds all the answers.

This site has an extra twist. The same question can arrive in Hindi script, in English or in Hinglish. So every question is normalised first: lower case, no punctuation, and Hinglish words mapped to Hindi script.

A transliteration table does that mapping. It pairs each English-letter spelling with its Hindi letters. Search and the duplicate check must use the same normalising code, or one will miss questions the other finds.

The diagram shows the check that runs while someone types. Its decision box sends the question down one of three paths.

Catching a duplicate before it is posted

Look at the decision box: clear matches are shown to the asker, and unsure pairs go to people instead of being guessed.

Diagram connections:

  • User types a question to Normalise the text
  • Normalise the text to Find 20 similar questions
  • Find 20 similar questions to Score each pair
  • Score each pair to Is it a close match?
  • Is it a close match? to Show the existing question (Yes)
  • Is it a close match? to Moderator review queue (Maybe)
  • Is it a close match? to Post a new question (No)
  • User types a question: In Hindi, English or Hinglish
  • Normalise the text: One script, lower case, no punctuation
  • Find 20 similar questions: Search index, by shared words
  • Score each pair: How alike are the two questions
  • Is it a close match?: Thresholds set from past merges
  • Show the existing question: Asker can read it or post anyway
  • Moderator review queue: Moderators merge or keep both
  • Post a new question: Saved and added to the index

Finding candidates is a search problem, covered in the next section. Scoring each pair can start simple, with the share of words two questions have in common. The engineer section shows where that breaks, with numbers.

Merging is a data change. The duplicate's canonical_id is set to the main question, its page redirects there, and its answers move across. Moderators check every pair that falls in the unsure band.

How does search work on a Q&A site?

Search works through an inverted index: a map from each word to the list of questions that contain it. To answer a search, you look up each word, combine the lists, and rank what comes back.

The Stanford textbook Introduction to Information Retrieval builds one in plain steps. Split each document into words, normalise them, then record which documents each word appears in. Each word then gets a postings list, the list of document ids that contain it.

Say a user searches for LPG subsidy deadline. The index returns the list for LPG, the list for subsidy and the list for deadline. Questions on all three lists rank high. You can then boost questions with more answers and higher-scored top answers.

  1. Write to the database first

    A new question is saved in PostgreSQL. That write is the truth, and it succeeds even if search is down.

  2. Send an index event

    The app server publishes a small event with the question id. A search indexing worker reads it from a queue.

  3. Normalise and index

    The worker normalises the text the same way the duplicate check does, then adds the question to the search index.

  4. Accept a short delay

    A new question becomes searchable a few seconds later. That is fine, and it keeps posting fast.

How does the home feed decide which questions to show?

The home feed shows recent, well-answered questions from the topics a person follows, merged and ranked when they open the app. This site builds it at read time, because topics, not friends, drive what people see.

There are two classic ways to build a feed. Fan-out on write pushes each new post into every follower's feed as it is created. Fan-out on read collects posts from everything you follow when you open the app.

A popular topic like farming has lakhs of followers. Pushing every new farming question into lakhs of feeds would cost a huge amount of write work. Instead, each topic keeps its own sorted list of its best recent questions.

  • When the app opens, read the top 50 questions from each followed topic.
  • Merge the lists and drop questions the user has already seen.
  • Score each one by its best answer's score, its freshness and the user's past clicks on that topic.
  • Cache the result for that user for a few minutes, so scrolling stays fast.

A person who follows twenty topics causes twenty small reads, all from memory. That is cheap enough. The full trade-off between the two fan-out styles is covered in the news feed guide linked at the end.

How do you keep a viral question page fast?

Serve it from the cache, and make sure only one request rebuilds it when the cached copy expires. Most of the viral week's damage came from repeated work.

Cache each question page in two layers. The top ten answer ids come from the question's sorted set in Redis. The answer bodies come from a separate cache, keyed by answer id, so an edit clears only that one entry.

When a hot page expires, every request that arrives during the rebuild misses the cache and goes to the database. This is a cache stampede, also called a thundering herd. A plain Redis expiry deletes the page, so no old copy is left to serve.

Instead, store a rebuild-after time inside the cached page and give the key a longer real expiry. When that time passes, one request takes a short lock and rebuilds, while the others keep serving the stored copy.

In Redis the lock can be one command: SET with NX, which sets the key only if it does not exist, and PX, which gives it an expiry in milliseconds. The first request gets OK; the rest get nil, meaning nothing was set.

Facebook's 2013 memcache paper fixes a close cousin of this problem with leases. There, the herd comes from writes that keep clearing a hot key, not from expiry. A cache server hands out a rebuild token for a key only once every 10 seconds by default. Clients without the token are told to wait briefly and retry. For a set of keys prone to stampedes, watched over one week, peak database queries fell from 17,000 to 1,300 a second.

After the redesign, the team replays the LPG subsidy spike against a test copy of the site. Pages load in about 300 milliseconds (made up), and the hot answer's row is updated at most once every 5 seconds.

How can you see votes waiting on a locked row in PostgreSQL?

Query pg_stat_activity, PostgreSQL's built-in view of what each connection is doing. A session stuck behind a row lock shows the wait event type Lock. We tested everything in this section on PostgreSQL 16.15.

sql
SELECT wait_event_type, wait_event, left(query, 40)
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

In our test, one session updated an answer row and held its transaction open for three seconds. A second session then tried to update the same row. Run with psql, PostgreSQL's command-line client, using -At (plain output, no headers), the query above printed one waiting session:

text
Lock|transactionid|UPDATE answers SET up_count = up_count +

The PostgreSQL docs define the wait event transactionid as waiting for a transaction to finish. The second update finished only after the first committed, after about 2.6 seconds in our run.

With lock_timeout at its default of 0, PostgreSQL 16 waits for a lock indefinitely. Setting it for one session turns a stuck vote into a fast error that the app can retry or report.

sql
SET lock_timeout = '1s';
UPDATE answers SET up_count = up_count + 1 WHERE answer_id = 2;

While another transaction held that row, this failed after one second with the message below; psql also prints a CONTEXT line naming the row. The docs advise against setting lock_timeout in postgresql.conf, the server's settings file, because it would affect all sessions.

text
ERROR:  canceling statement due to lock timeout

Why saving a vote did not wait

In the same test, inserting a new vote row for the locked answer took under a tenth of a second. Yet the vote's foreign key points at that answer, so PostgreSQL must check the answer row exists.

The locking docs explain half of it. An update that changes no key column takes a FOR NO KEY UPDATE lock, which does not block the weaker FOR KEY SHARE lock.

We tested the other half. With the answer row held FOR UPDATE, the vote insert waited about 1.6 seconds. Held FOR NO KEY UPDATE, it took under a tenth of a second. So the foreign-key check behaves like FOR KEY SHARE.

How do you make the vote counter safe when the queue repeats a batch?

Recount each touched answer from the votes table instead of adding the batch's changes. A recount gives the same totals however many times it runs, so a repeated batch does no harm.

This matters because a queue that delivers each message at least once can send a batch again. That happens if the worker crashes after writing totals but before confirming the batch.

Adding changes is not safe. In our PostgreSQL test, applying a batch of 9 up-votes and 1 down-vote twice left 18 and 2 on the answer. The votes table still said 9 and 1.

Here is a runnable version that uses SQLite, the small database built into Python, so it needs no server. COUNT(*) FILTER (WHERE value = 1) counts only the rows that match. The same upsert and COUNT FILTER statements also ran on PostgreSQL 16.15, with literal values in place of the ? placeholders. The demo needs SQLite 3.30 or newer, which added FILTER; python3 -c 'import sqlite3; print(sqlite3.sqlite_version)' shows yours.

python
import sqlite3
from math import sqrt

def wilson_lower_bound(up, down, z=1.96):
    n = up + down
    if n == 0:
        return 0.0                       # no votes, no evidence
    p = up / n                           # observed up-vote share
    centre = p + z * z / (2 * n)
    spread = z * sqrt((p * (1 - p) + z * z / (4 * n)) / n)
    return (centre - spread) / (1 + z * z / n)

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE answers (answer_id INTEGER PRIMARY KEY, question_id INTEGER,
  up_count INTEGER DEFAULT 0, down_count INTEGER DEFAULT 0, score REAL DEFAULT 0);
CREATE TABLE votes (answer_id INTEGER, user_id INTEGER,
  value INTEGER CHECK (value IN (-1, 1)), PRIMARY KEY (answer_id, user_id));
INSERT INTO answers (answer_id, question_id) VALUES (1, 7), (2, 7);
""")

def cast_vote(answer_id, user_id, value):
    db.execute("""INSERT INTO votes VALUES (?, ?, ?)
        ON CONFLICT (answer_id, user_id) DO UPDATE SET value = excluded.value""",
        (answer_id, user_id, value))
    db.commit()
    return answer_id                     # the event that goes on the queue

def flush(events, ranks):
    for a in set(events):                # recount each touched answer once
        up, down = db.execute("""SELECT COUNT(*) FILTER (WHERE value = 1),
            COUNT(*) FILTER (WHERE value = -1) FROM votes WHERE answer_id = ?""",
            (a,)).fetchone()
        score = wilson_lower_bound(up, down)
        db.execute("""UPDATE answers SET up_count = ?, down_count = ?, score = ?
            WHERE answer_id = ?""", (up, down, score, a))
        q = db.execute("SELECT question_id FROM answers WHERE answer_id = ?",
                       (a,)).fetchone()[0]
        ranks.setdefault(q, {})[a] = round(score, 3)   # stand-in for ZADD
    db.commit()

ranks = {}
batch = [cast_vote(1, u, 1) for u in (11, 12)]            # 2 up
batch += [cast_vote(2, u, 1) for u in range(20, 29)]      # 9 up
batch += [cast_vote(2, 29, -1), cast_vote(2, 20, 1)]      # 1 down, 1 repeat tap
flush(batch, ranks)
print("after batch: ", ranks[7])
flush(batch, ranks)                      # the queue delivers it again
print("after replay:", ranks[7])
print(db.execute("SELECT answer_id, up_count, down_count FROM answers").fetchall())

We ran it on Python 3.13.16 with SQLite 3.45.1. It printed:

text
after batch:  {1: 0.342, 2: 0.596}
after replay: {1: 0.342, 2: 0.596}
[(1, 2, 0), (2, 9, 1)]

What just happened? User 20 tapped up twice, but the upsert kept one row, so answer 2 has 9 up-votes, not 10. Replaying the whole batch changed nothing.

The trade-off is cost. A recount reads every vote row of the answer, which PostgreSQL can find through the votes primary key, because it starts with answer_id. That is cheap for hundreds of votes; for answers with millions, record processed batch ids and add changes instead.

A second gap sits before the queue. The request saves the vote and then publishes the event, so a failed publish leaves that answer's totals stale until its next vote.

Close it with an outbox: write the event to a table in the same transaction as the vote, and let a worker publish it. A periodic sweep that recounts recently voted answers also works, because recounts are safe to repeat.

What do Redis sorted sets do with ties and scores?

Redis orders members by score, and members with equal scores by their names, compared as bytes. Every new answer scores 0, so new answers tie, and the byte order decides which one shows first.

First, the normal case. We ran these commands with redis-cli, Redis's command-line client, on Redis 8.0.2 and Redis 7.0.15. ZADD adds members with scores, and ZRANGE reads a range of them. The REV option, which returns high to low, needs Redis 6.2 or newer.

text
> ZADD q:7:answers 0.342 a:1 0.886 a:2 0.596 a:3 0.531 a:4
(integer) 4
> ZRANGE q:7:answers 0 2 REV WITHSCORES
1) "a:2"
2) "0.886"
3) "a:3"
4) "0.596"
5) "a:4"
6) "0.531"

Redis stores scores as 64-bit floating-point numbers. Redis 7.2 and newer print the shortest form, as above. Redis 7.0.15 printed float noise for the same scores, such as 0.88600000000000001. Integers up to 2 to the power 53 are stored exactly, which is plenty for vote totals.

Now the surprise. Two new answers, a:9 and a:10, both score 0. In REV order a:9 comes before a:10, because the byte 9 sorts after the byte 1.

text
> ZADD q:7:answers 0 a:10 0 a:9
(integer) 2
> ZRANGE q:7:answers -2 -1 REV
1) "a:9"
2) "a:10"

If you want newer answers first among ties, put that rule in the name or the score. Zero-padded ids such as a:000009 and a:000010 make byte order match number order; in our run REV then listed a:000010 first.

The page-rebuild lock from earlier behaves like this. The second SET returns nil, and PTTL shows the milliseconds left before the lock expires on its own. Your number will be a little under 5000.

text
> SET lock:q:7:page token-1 NX PX 5000
OK
> SET lock:q:7:page token-2 NX PX 5000
(nil)
> PTTL lock:q:7:page
(integer) 4977

The Redis SET docs, as of 19 October 2026, call this simple pattern discouraged for real locks and point to an algorithm called Redlock. For a cache rebuild, a lost lock only means two rebuilds, so the simple version is enough.

Why does word overlap fail at catching duplicate questions?

Word overlap fails in both directions. It misses true duplicates worded differently, and it flags different questions that share most words. This short script shows both.

python
import re

def words(text):
    return set(re.findall(r"[a-z0-9]+", text.lower()))

def overlap(a, b):                       # Jaccard: shared words / all words
    a, b = words(a), words(b)
    return len(a & b) / len(a | b)

def decide(score):
    if score >= 0.6:
        return "show existing"
    if score >= 0.3:
        return "moderator queue"
    return "post new"

new = "LPG subsidy last date?"
for old in ["What is the LPG subsidy last date?",
            "What is the last date for the LPG subsidy?",
            "How do I link my LPG connection to Aadhaar?"]:
    s = overlap(new, old)
    print(f"{s:.2f}  {decide(s):15}  {old}")

s = overlap("When will the LPG subsidy reach Bhopal?",
            "When will the LPG subsidy reach Indore?")
print(f"{s:.2f}  {decide(s):15}  Bhopal vs Indore")

Run it with python3. The thresholds 0.6 and 0.3 are made up for this example. It prints:

text
0.57  moderator queue  What is the LPG subsidy last date?
0.50  moderator queue  What is the last date for the LPG subsidy?
0.08  post new         How do I link my LPG connection to Aadhaar?
0.75  show existing    Bhopal vs Indore

What just happened? Two real duplicates scored 0.57 and 0.50, so they went to moderators instead of being shown to the asker. The Bhopal and Indore questions scored 0.75 and would be shown as the same question.

Set real thresholds from past moderator decisions. When word overlap misses too many pairs, add a model that compares meanings, and keep people on the unsure band.

How do you explain Quora system design in a system design interview?

Explain it as a read-heavy system with three hard parts: counting votes, ranking answers and catching duplicates. Walk through them in this order.

  1. Pin down the requirements

    List ask, answer, vote, read, search and feed. Add numbers: page load under one second, votes may lag a few seconds, answers must never be lost.

  2. Estimate the load

    Give a rough read-to-write ratio, such as 60 page views per new post, and say whether votes count as writes. Mention that one viral page can match the whole site's average traffic.

  3. Draw the core boxes

    App servers, a sharded database, a cache, a vote queue with a worker, and a search index. Shard answers and votes by question id.

  4. Handle the hot answer

    Explain that a row stays locked until its transaction ends, so slow work inside a vote transaction queues every vote. Save each vote, then recount in batches off a queue.

  5. Rank answers fairly

    Reject net score and plain share with one example each. Propose the Wilson lower bound, then name what it misses.

  6. Close with duplicates and feed

    Describe the check while typing, the moderator queue for unsure cases, and a topic-based feed built at read time.

Keep your diagram small at first. Add the queue only when you explain the hot answer, and the search index only when you reach duplicates. Each box then arrives with a reason.

Questions people ask

What database is best for a Quora-like system?
A relational database such as PostgreSQL or MySQL is a sound start. Questions, answers and votes have clear links, and you need reliable writes. Add a cache for reads and a search index for text search. Shard by question id once one server is not enough.
Why not sort answers by the number of up-votes?
Raw up-votes ignore down-votes completely, and an older answer has had more time to collect them. Net score fixes part of this but still ignores the share of positive votes. The Wilson lower bound weighs both the share and the number of votes.
What is the Wilson score in simple words?
It is the lowest up-vote share you can be fairly sure of, given the votes so far. With few votes, the score stays low because the evidence is thin. With many votes, it moves close to the real share. That stops a two-vote answer from beating a well-tested one.
How do you make sure a person votes only once?
Make the pair of answer id and user id the primary key of the votes table. A second vote from the same person then updates that row instead of adding a new one, through an upsert. Changing from up to down just changes the stored value, and the next recount picks it up.
How does a Q&A site detect duplicate questions?
It normalises the new question, searches for similar existing ones, and scores how alike each pair is. Close matches are shown to the asker before posting. Unsure cases go to moderators, who can merge them so one page holds every answer.
Should vote counts be exact in real time?
Usually not. Readers do not notice if a total or the answer order lags by a few seconds. The person who voted sees their own change at once. Exact counts always live in the votes table, and each recount brings the stored totals back in line.
What is a cache stampede and how do you stop it?
A cache stampede happens when a popular cached item expires and many requests miss at once, all rebuilding it from the database. Keep a rebuild-after time inside the cached item and a longer real expiry. When that time passes, one request rebuilds under a short Redis lock (SET NX with an expiry), while the others serve the stored copy.

Sources and further reading

Practise this with Lala

Practise