Irbisa · Шпаргалка

Backend system design

Кэширование, очереди, идемпотентность, rate limiting, консистентность, outbox, пагинация, SLA

Senior-разработчик · 22 вопроса

  1. 01

    Чем отличаются cache-aside, read-through, write-through и write-behind, и когда какой выбирать?

    Они различаются тем, кто заполняет кэш и когда пишется база.

  2. 02

    Чем строгая консистентность отличается от eventual consistency, и что на самом деле дают CAP и PACELC при проектировании сервиса?

    Строгая консистентность: чтение всегда видит последнюю закоммиченную запись.

  3. 03

    Что такое консистентное хеширование (consistent hashing), почему hash(key) % N переносит почти все ключи при добавлении узла и где его используют?

    Консистентное хеширование размещает узлы и ключи на одном хеш-кольце, и ключ принадлежит первому узлу по часовой стрелке от него, поэтому одиннадцатый узел забирает около 1/11 ключей.

    // 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

    Чем различаются rate limiting по фиксированному окну, скользящему окну, token bucket и leaky bucket, и что бы вы выбрали?

    Фиксированное окно считает запросы за интервал: дешевле всего, но на границе пропускает двойной всплеск.

  5. 05

    Продюсеры кладут задачи быстрее, чем консьюмеры успевают их обработать. Что такое backpressure, и где его применять в Go-сервисе и в очереди?

    Backpressure означает, что медленная сторона заставляет быструю замедлиться, а не буферизует без ограничений.

    // 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

    Как реализовать keyset-пагинацию (по курсору), если колонка сортировки не уникальна, и что вы теряете по сравнению с OFFSET?

    Сортируйте по колонке плюс уникальный tie-breaker и продолжайте с последней увиденной строки через сравнение кортежей: 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 или bigint identity в качестве первичного ключа: что каждый из них делает с B-tree индексом в Postgres и когда нужны id в стиле Snowflake?

    bigint identity занимает 8 байт и всегда вставляется в правый край индекса.

  8. 08

    Найдите баг: два ретрая одного платежа приходят с разницей в 5 мс, и оба списывают деньги с карты.

    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}

    Проверка и вставка идут двумя отдельными шагами, поэтому два запроса с одним ключом оба ничего не находят и оба списывают деньги; строка с ключом пишется только после списания.

  9. 09

    Ваш API последовательно вызывает четыре зависимости, у каждой SLO 99,9%. Какую доступность вы можете обещать, и как обещать больше, чем дают они?

    Последовательные зависимости перемножаются: 0.999⁴ ≈ 99.6%, около 2,9 часа простоя в месяц ещё до ваших собственных багов.

  10. 10

    Когда ставить in-process кэш перед Redis, и как не дать локальным копиям на 40 подах устаревать или падать под одним горячим ключом?

    Небольшой in-process кэш стоит добавить, когда доминируют сетевые круги до Redis или несколько горячих ключей: он отвечает за наносекунды и разгружает горячий ключ по подам.

  11. 11

    Пользователи иногда часами видят старую цену после обновления, хотя каждая запись удаляет ключ кэша. Какие гонки к этому приводят, и как их закрыть?

    Удаление при записи всё равно проигрывает гонку: читатель промахивается, загружает старую строку, писатель коммитит и удаляет ключ, а затем читатель кладёт старое значение, и оно живёт весь TTL.

  12. 12

    Пользователь сохраняет профиль, перезагружает страницу и видит старое имя: чтения идут на асинхронную реплику. Как дать read-your-writes, не отправляя все чтения на primary?

    Отправляйте чтения пользователя на primary, только пока его собственная запись может ещё не дойти до реплики: запомните позицию (или время) записи для этого пользователя и используйте реплику, только когда она эту позицию уже применила.

    // 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

    Как выбрать таймаут для вызова зависимости, и когда hedged requests помогают с хвостовой латентностью?

    Таймаут задаётся по измеренной латентности зависимости (чуть выше её p99 и в пределах оставшегося дедлайна вызывающего), а не круглым числом.

    // 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

    Relay вашего outbox опрашивает таблицу каждые 100 мс. Когда стоит заменить его на CDC, например Debezium, и какие новые отказы приносит слот репликации?

    Переходите на CDC, когда латентность или нагрузка на базу от поллинга начинают мешать, или когда нужны все изменения, а не только события, которые код не забыл записать.

  15. 15

    На флеш-распродаже 100 единиц товара и 50 000 покупателей в первую секунду. Как не продать лишнего и не сделать строку остатков узким местом?

    Сделайте проверку и уменьшение одной атомарной операцией (`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

    Спроектируйте леджер для денежных переводов между счетами: двойная запись, идемпотентность, балансы и сверка.

    Записывайте каждое движение денег как неизменяемый перевод, проводки которого в сумме дают ноль (минус на одном счёте, плюс на другом), в одной транзакции базы вместе с кэшированными балансами.

    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

    Спроектируйте сокращатель ссылок вроде bit.ly: 100 миллионов новых ссылок в месяц, чтений намного больше, чем записей. Расскажите про API, генерацию ID, хранение и редиректы.

    Берите уникальное число из счётчика (sequence в базе или диапазоны ID на каждый инстанс), кодируйте его в base62, храните код → URL с ключом по коду и отдавайте редиректы из кэша: чтений примерно в 100 раз больше, чем записей.

    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

    Спроектируйте счётчик просмотров для видео, которые получают 50 000 инкрементов в секунду: где считать, как сохранять и что можно потерять?

    Никогда не делайте UPDATE на каждый просмотр.

    // 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

    Спроектируйте сервис уведомлений, который отправляет email, SMS и push миллионам пользователей. Как справляться со всплесками, ретраями, дублями и настройками пользователей?

    API, который валидирует и ставит в очередь, роутер, который применяет настройки и шаблоны, и очереди по каналам, которые разбирают воркеры, вызывающие провайдеров в рамках их лимитов.

  20. 20

    Спроектируйте бэкенд чата: доставка онлайн- и офлайн-пользователям, порядок внутри диалога, отметки о прочтении и хранение.

    Клиенты держат WebSocket к слою гейтвеев.

    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

    Спроектируйте ленту новостей: fan-out on write против fan-out on read, и что меняется для аккаунта с десятью миллионами подписчиков?

    Fan-out on write копирует id каждого нового поста в сохранённую ленту каждого подписчика, поэтому чтение стоит один запрос; fan-out on read сливает последние посты всех подписок в момент запроса.

    // 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

    Спроектируйте rate limiter для публичного API на 50 репликах, 100 запросов в секунду на API-ключ. Где живёт состояние, и что происходит, когда Redis тормозит или лежит?

    Держите token bucket на каждый API-ключ в Redis и обновляйте его одним Lua-скриптом: проверка и обновление атомарны для всех реплик и стоят один сетевой круг.

    // 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')…