Irbisa · Cheatsheet

Backend System Design

Caching, queues, idempotency, rate limiting, consistency, outbox, pagination, SLAs

Senior Developer · 22 questions

  1. 01

    What is the difference between cache-aside, read-through, write-through and write-behind caching, and when would you pick each?

    They differ in who fills the cache and when the database is written.

  2. 02

    What is the difference between strong and eventual consistency, and what do CAP and PACELC actually tell you when you design a service?

    Strong consistency: every read sees the latest committed write.

  3. 03

    What is consistent hashing, why does hash(key) % N move almost every key when you add a node, and where is it used?

    Consistent hashing puts nodes and keys on one hash ring, and a key belongs to the first node clockwise from it, so adding an eleventh node moves about 1/11 of the keys.

    // Ring maps keys to nodes. Each node sits at many points (virtual nodes),// so the load evens out and a removed node's keys spread over all the others.type Ring struct {	points []uint64          // sorted positions of every virtual node	owner  map[uint64]string // position -> node}…
  4. 04

    How do fixed window, sliding window, token bucket and leaky bucket rate limiting differ, and which one would you choose?

    Fixed window counts requests per clock interval: cheapest, but it lets a double burst through at the boundary.

  5. 05

    Producers enqueue work faster than consumers can process it. What is backpressure, and where do you apply it in a Go service and across a queue?

    Backpressure is the slow side making the fast side slow down instead of buffering without limit.

    // A bounded hand-off between the handler and a fixed number of workers.var jobs = make(chan Job, 1000) func enqueue(w http.ResponseWriter, r *http.Request) {	job, err := decodeJob(r)	if err != nil {…
  6. 06

    How do you implement keyset (cursor) pagination when the sort column is not unique, and what do you give up compared with OFFSET?

    Sort by the column plus a unique tie-breaker and continue from the last row seen with a row comparison: WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC.

    // The cursor is the sort key of the last row returned; id breaks ties between equal timestamps.type cursor struct {	CreatedAt time.Time `json:"t"`	ID        int64     `json:"i"`}…
  7. 07

    UUIDv4, UUIDv7 or a bigint identity as the primary key: what does each do to a Postgres B-tree index, and when do you need Snowflake-style ids?

    A bigint identity is 8 bytes and always inserts at the right edge of the index.

  8. 08

    Find the bug: two retries of the same payment arrive 5 ms apart, and both charge the card.

    func (s *Service) Pay(ctx context.Context, key string, req PayRequest) ([]byte, error) {	var saved []byte	err := s.db.QueryRowContext(ctx,		`SELECT response FROM idempotency_keys WHERE key = $1`, key).Scan(&saved)	if err == nil {		return saved, nil // seen this key before: replay the stored response	}	resp, err := s.psp.Charge(ctx, req)	if err != nil {		return nil, err	}	_, err = s.db.ExecContext(ctx,		`INSERT INTO idempotency_keys (key, response) VALUES ($1, $2)`, key, resp)	return resp, err}

    The check and the insert are separate steps, so two requests with the same key both find nothing and both charge; the key row is written only after the charge.

  9. 09

    Your API calls four dependencies in sequence, each with a 99.9% SLO. What availability can you promise, and how do you promise more than they give you?

    Serial dependencies multiply: 0.999⁴ ≈ 99.6%, about 2.9 hours of downtime a month before any bug of your own.

  10. 10

    When do you put an in-process cache in front of Redis, and how do you keep 40 pods' local copies from going stale or melting on one hot key?

    Add a small in-process cache when Redis round trips or a few hot keys dominate: it answers in nanoseconds and spreads a hot key's load across pods.

  11. 11

    Users sometimes see an old price for hours after an update, although every write deletes the cache key. Which races cause it, and how do you close them?

    Delete-on-write still loses a race: a reader misses, loads the old row, the writer commits and deletes the key, then the reader stores the old value, which then lives for the full TTL.

  12. 12

    A user saves their profile, reloads and sees the old name: reads go to an async replica. How do you give read-your-writes without sending all reads to the primary?

    Send a user's reads to the primary only while their own write may not have reached the replica: remember the write's position (or time) for that user, and use a replica only once it has replayed that position.

    // After a write, hand the primary's WAL position back to the client (cookie or header).func (s *Store) Rename(ctx context.Context, userID int64, name string) (lsn string, err error) {	if _, err = s.primary.ExecContext(ctx,		`UPDATE profiles SET name = $2 WHERE user_id = $1`, userID, name); err != nil {		return "", err	}…
  13. 13

    How do you choose the timeout for a call to a dependency, and when do hedged requests help with tail latency?

    Set the timeout from the dependency's measured latency (a little above its p99 and inside the caller's remaining deadline), not a round number.

    // hedged calls once and, if there is no answer after delay (about the dependency's p95),// sends a second copy. The first success wins; returning cancels the other attempt.func hedged[T any](ctx context.Context, delay time.Duration,	call func(context.Context) (T, error)) (T, error) {	ctx, cancel := context.WithCancel(ctx)	defer cancel()…
  14. 14

    Your outbox relay polls the table every 100 ms. When would you replace it with CDC such as Debezium, and what new failure modes does a replication slot bring?

    Move to CDC when polling's latency or database load starts to hurt, or when you need every change rather than only the events the code remembers to write.

  15. 15

    A flash sale has 100 units and 50,000 buyers in the first second. How do you avoid overselling without the inventory row becoming the bottleneck?

    Make the check and the decrement one atomic statement (`UPDATE ...

    // The check and the write are one statement, run under the row lock.func (r *Repo) Reserve(ctx context.Context, productID int64, qty int) error {	res, err := r.db.ExecContext(ctx, `		UPDATE inventory SET stock = stock - $2		WHERE product_id = $1 AND stock >= $2`, productID, qty)	if err != nil {…
  16. 16

    Design a ledger for money transfers between accounts: double-entry records, idempotency, balances and reconciliation.

    Record every movement as an immutable transfer whose entries sum to zero (minus on one account, plus on the other), written in one database transaction with the cached balances.

    CREATE TABLE accounts (    id       bigint PRIMARY KEY,    currency char(3) NOT NULL,    balance  bigint NOT NULL DEFAULT 0 -- minor units; a cache of SUM(entries.amount));…
  17. 17

    Design a URL shortener like bit.ly: 100 million new links a month, far more reads than writes. Walk through the API, ID generation, storage and redirects.

    Allocate a unique number from a counter (a database sequence or ID ranges per app instance), encode it in base62, store code → URL keyed by the code and serve redirects from a cache: reads outnumber writes about 100 to 1.

    const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" // encode turns a unique id into a short code: ids below 62^7 ≈ 3.5 trillion fit in 7 chars; larger ids give longer codes.func encode(id uint64) string {	if id == 0 {		return "0"…
  18. 18

    Design a view counter for videos that receive 50,000 increments a second: where do you count, how do you persist, and what can you lose?

    Never one UPDATE per view.

    // Views adds views up in memory: however many Inc calls arrive,// each pod writes each video at most once a second.type Views struct {	mu     sync.Mutex	deltas map[int64]int64 // video id -> views since the last flush; make it before use	// flush writes one batch: a Redis pipeline of INCRBY, or one Kafka message.…
  19. 19

    Design a notification service that sends email, SMS and push to millions of users. How do you handle spikes, retries, duplicates and user preferences?

    An API that validates and enqueues, a router that applies preferences and templates, and per-channel queues drained by workers that call the providers within their rate limits.

  20. 20

    Design the backend of a chat: delivery to online and offline users, ordering within a conversation, read receipts and storage.

    Clients keep a WebSocket to a gateway tier.

    CREATE TABLE conversations (    id       bigint PRIMARY KEY,    last_seq bigint NOT NULL DEFAULT 0 -- the next message gets last_seq + 1); CREATE TABLE messages (…
  21. 21

    Design a news feed: fan-out on write versus fan-out on read, and what changes for an account with ten million followers?

    Fan-out on write copies each new post id into every follower's stored feed, so a read is one lookup; fan-out on read merges the latest posts of everyone the user follows at request time.

    // Feed merges the user's precomputed feed (fan-out on write) with the recent// posts of the celebrities they follow (fan-out on read). Post ids are// time-ordered (Snowflake-style), so sorting ids sorts by time.func (s *Service) Feed(ctx context.Context, userID int64, limit int) ([]Post, error) {	ids, err := s.feeds.Recent(ctx, userID, limit) // pushed in when authors posted	if err != nil {…
  22. 22

    Design a rate limiter for a public API on 50 replicas, 100 requests a second per API key. Where does the state live, and what happens when Redis is slow or down?

    Keep a token bucket per API key in Redis and update it with one Lua script, so the check and the update are atomic across every replica and cost one round trip.

    // Token bucket per key, refilled lazily from Redis's own clock, atomic inside Redis.var tokenBucket = redis.NewScript(`local rate, burst = tonumber(ARGV[1]), tonumber(ARGV[2])local t = redis.call('TIME')local now = t[1] * 1000 + math.floor(t[2] / 1000)local s = redis.call('HMGET', KEYS[1], 'tokens', 'ts')…