Они различаются тем, кто заполняет кэш и когда пишется база. Cache-aside и read-through заполняют кэш при промахе; write-through пишет в кэш и в базу вместе; write-behind пишет в кэш сразу, а в базу позже: скорость записи в обмен на надёжность.
Cache-aside: читаем кэш, при промахе грузим базу и кладём результат с TTL (задавайте его каждому ключу); при записи обновляем базу и удаляем ключ. Вариант по умолчанию: кэш остаётся необязательным.
Read-through: та же схема внутри библиотеки кэша с функцией-загрузчиком, так что вызывающий код обращается только к кэшу.
Write-through: каждая запись идёт в базу и в кэш одним путём, поэтому следующее чтение уже из кэша, но каждая запись теперь зависит от Redis.
Write-behind: запись попадает в кэш и сбрасывается в базу пачками. Подходит для счётчиков просмотров; падение до сброса теряет записи, о которых клиенту уже сказали «успешно», поэтому для заказов и денег не годится никогда.
GoSenior
Backend system design
Кэширование, очереди, идемпотентность, rate limiting, консистентность, outbox, пагинация, SLA
Закрыто вопросов
0 / 22
- 0Повторить
Вопросы
Строгая консистентность: чтение всегда видит последнюю закоммиченную запись. Eventual: реплики сходятся, когда записи прекращаются. CAP: при разделении сети выбираете консистентность или доступность. PACELC: и без разделения платите латентностью за консистентность.
CAP на практике: разделения сети случаются в любом случае, поэтому реальный выбор стоит между CP и AP. CP отказывает в запросах, которые не дотягиваются до большинства (etcd, primary Postgres с синхронными standby); AP отвечает с любой доступной реплики и согласует данные позже (Cassandra с уровнем консистентности ONE).
PACELC: компромисс на каждый день: синхронная реплика или кворумное чтение стоит лишнего сетевого круга на каждом запросе, даже когда ничего не сломано.
Выбирайте для конкретных данных, а не для системы целиком: баланс счёта, остаток на складе или уникальный логин требуют строгого чтения с primary; лента новостей, счётчик лайков или поисковый индекс могут отставать на секунды.
Гарантии посередине: read-your-writes (пользователь видит своё изменение), monotonic reads (для одного пользователя время не идёт назад), causal (ответ не виден раньше поста, на который он отвечает). Часто именно они и нужны продукту, и они гораздо дешевле строгой консистентности везде. Поставьте границу на «когда-нибудь» («поиск отражает изменение в течение 5 секунд») и мониторьте её по лагу репликации и консьюмеров.
Консистентное хеширование размещает узлы и ключи на одном хеш-кольце, и ключ принадлежит первому узлу по часовой стрелке от него, поэтому одиннадцатый узел забирает около 1/11 ключей. При hash(key) % N владелец зависит от самого N: переход с 10 узлов на 11 переносит около 91% ключей.
Почему модуль не работает: ключ остаётся на месте, только если h % 10 == h % 11, а это верно примерно для одного ключа из 11. Кластер кэшей, который вырос или потерял узел, разом промахивается почти по всем ключам, и вся нагрузка на чтение уходит в базу.
Кольцо: каждый узел хешируется на одну и ту же 64-битную окружность. Новый узел забирает только дугу прямо перед собой; дуга удалённого узла переходит к следующему. В симуляции на 200 000 ключей 11-й узел перенёс около 9% из них.
Виртуальные узлы: одна точка на узел даёт очень неравные дуги: в той же симуляции самый загруженный из 10 узлов обычно держал в 2–3 раза больше своей доли. 100–200 точек на узел укладывают каждый узел примерно в 10–20% от среднего и раскладывают ключи упавшего узла по всем оставшимся.
Альтернативы: rendezvous hashing (оценить каждый узел через hash(key, node) и взять максимум), jump consistent hash для пронумерованных шардов или фиксированные слоты, разложенные по узлам таблицей, как в Redis Cluster.
Где встречается: клиенты memcached (ketama), token ring в Cassandra, балансировщики RING_HASH и MAGLEV в Envoy для липкой маршрутизации к кэшу, groupcache.
Чего оно не решает: перенесённые ключи начинают холодными, горячий ключ по-прежнему живёт на одном узле, а базе данных, в отличие от кэша, перенесённые данные нужно ещё и скопировать.
Фиксированное окно считает запросы за интервал: дешевле всего, но на границе пропускает двойной всплеск. Скользящие окна исправляют это ценой памяти или приближения; token bucket допускает заданный всплеск, затем ровный темп; leaky bucket выпускает запросы из очереди с постоянной скоростью.
Фиксированное окно: один счётчик на ключ за минуту (INCR плюс EXPIRE). Лимит 100 в минуту пропустит 100 запросов в 12:00:59 и ещё 100 в 12:01:00, то есть 200 за две секунды.
Sliding window log: храните время каждого запроса (sorted set в Redis), выбрасывайте те, что старше окна, и считайте остальные. Точно, но память растёт с лимитом: 10 000 в час означают до 10 000 записей на клиента.
Sliding window counter: предыдущее окно учитывается с весом той доли, что ещё перекрывается: prev*(1 - elapsed/window) + curr. Два счётчика на ключ, точность в пределах нескольких процентов при ровном трафике.
Token bucket: пополняем r токенов в секунду до b, каждый запрос забирает один. Два числа на ключ и явный всплеск; именно это реализует golang.org/x/time/rate, и именно это обычно подразумевают лимиты API; вариант по умолчанию для публичного API.
Leaky bucket: запросы попадают в очередь, которая опустошается с фиксированной скоростью. Сглаживает поток к хрупкому downstream (провайдеру, который допускает 50 вызовов в секунду) ценой задержки и отбрасывает запросы, когда очередь полна.
Backpressure означает, что медленная сторона заставляет быструю замедлиться, а не буферизует без ограничений. В процессе это ограниченный канал или семафор, который блокирует или отклоняет; через брокер это ограниченный бэклог под наблюдением, а продюсеры получают 429 или 503, когда он полон.
Безлимитный буфер только откладывает падение: go handle(job) на каждый запрос или канал на миллион слотов превращают медленного консьюмера в OOM kill, который теряет всё, что было в буфере.
Блокировать или отклонять: блокирующая отправка передаёт замедление вверх по цепочке, и это правильно между стадиями пайплайна. На входе в сервис отклоняйте сразу: заблокированный хендлер по-прежнему держит соединение и горутину, пока у клиента истекает таймер.
Очередь тоже буфер: Kafka хранит сообщения на диске, и отстающий консьюмер продюсеров не тормозит (тормозят только квоты и переполненный буфер продюсера), поэтому вы следите за лагом консьюмеров (в сообщениях или секундах) и действуете раньше, чем retention удалит непрочитанное. RabbitMQ притормаживает паблишеров при нехватке памяти и умеет ограничить очередь через x-max-length и x-overflow: reject-publish.
Затем добавляйте мощность там, где это помогает: больше партиций и консьюмеров, пакетная запись в базу, больше параллельных воркеров, до того предела, который выдерживает база ниже по цепочке.
Отбрасывайте по приоритету: если что-то приходится терять, теряйте сначала наименее ценное (аналитику раньше платежей), а клиентов заставляйте повторять с backoff.
Сортируйте по колонке плюс уникальный tie-breaker и продолжайте с последней увиденной строки через сравнение кортежей: WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC. Каждая страница сводится к range scan по индексу; теряете переход на страницу N и дешёвый общий счётчик.
Зачем tie-breaker: с одним created_at строки с одинаковым временем на границе страницы пропускаются или повторяются. Добавленный id делает порядок полным.
Индекс должен совпадать с порядком: (created_at DESC, id DESC), те же колонки и направления. Сравнение кортежей работает, только когда все колонки сортируются в одну сторону; для смешанных направлений разверните его в a < $1 OR (a = $1 AND b > $2).
Непрозрачный курсор: base64 от значений сортировки последней строки. Клиенты его не собирают сами, поэтому содержимое можно поменять позже. Проверяйте его при декодировании и привязывайте к нему фильтры и порядок сортировки, чтобы курсор нельзя было подставить в другой запрос.
**limit+1**: запросите на одну строку больше, чтобы узнать, есть ли следующая страница, без COUNT(*).
Что теряете: нет «страницы 37 из 120», а точный итог требует отдельного дорогого COUNT; показывайте «загрузить ещё» или оценку. Для небольших админских таблиц OFFSET по-прежнему годится.
Что получаете: устойчивость к записи: новый пост не сдвигает следующую страницу и не повторяет элемент, как это бывает с OFFSET 40.
bigint identity занимает 8 байт и всегда вставляется в правый край индекса. Случайный UUIDv4 (16 байт) попадает на случайную страницу-лист, поэтому индекс фрагментируется и перестаёт помещаться в память. UUIDv7 начинается с метки времени: вставляется почти как последовательность, а создаётся без базы.
**bigint GENERATED ALWAYS AS IDENTITY**: самый маленький ключ, который повторяется в каждом внешнем ключе и индексе. Выдаёт его только база, так что клиент узнаёт id после вставки, id из объединённых баз пересекаются, а /orders/10452 выдаёт ваш объём.
UUIDv4: создаётся где угодно (в Go до вставки, на телефоне без сети), угадать его нельзя, но каждая вставка попадает в случайный лист: страницы делятся и остаются полупустыми, в WAL уходит больше полных образов страниц, а когда индекс перерастает память, вставки превращаются в чтения с диска. На 2 миллионах строк в Postgres 18 индекс v4 занял 76 МБ при заполнении листьев на 71%; у v7 вышло 60 МБ и 90%, у bigint 43 МБ.
UUIDv7 (RFC 9562): 48-битная метка времени в миллисекундах, затем случайные биты: uuidv7() в Postgres 18, uuid.NewV7() в github.com/google/uuid. Время создания из него может прочитать кто угодно.
**Храните его в типе uuid** (16 байт), а не в text (37 байт и более медленные сравнения).
Id в стиле Snowflake (64 бита из метки времени, номера машины и счётчика): упорядочены по времени, как v7, но занимают вдвое меньше, а это заметно на миллиардах строк и в каждом индексе. Цена: уникальный номер машины на каждый генератор и часы, которые никогда не идут назад.
Частое разделение: bigint как ключ для join внутри базы и UUIDv7 как публичный id в URL; или UUIDv7 везде, когда id создаются вне базы.
Проверка и вставка идут двумя отдельными шагами, поэтому два запроса с одним ключом оба ничего не находят и оба списывают деньги; строка с ключом пишется только после списания. Ключ нужно захватить атомарно, до любого побочного эффекта.
Гонка check-then-act: SELECT возвращает sql.ErrNoRows обоим запросам; второй INSERT потом падает на первичном ключе, но карта уже списана дважды.
Второй баг: любая ошибка SELECT, кроме «нет строк» (таймаут, оборванное соединение), считается «ключ не встречался», и деньги списываются снова.
Захватить, выполнить, записать: INSERT ... ON CONFLICT DO NOTHING со статусом pending: ровно один запрос видит одну затронутую строку и идёт дальше. Остальные получают сохранённый ответ, 409, пока первый ещё выполняется, или 422, если тот же ключ пришёл с другим телом (сравнивайте хеш запроса).
Передайте ключ провайдеру: собственный idempotency key платёжного провайдера закрывает окно, когда процесс падает после списания и до сохранения ответа.
Восстановление: после падения строка остаётся pending; фоновый reaper перезапускает строки старше минуты с тем же ключом у провайдера, чтобы клиент не застрял на 409 навсегда.
Срок хранения: держите ключи как минимум столько, сколько клиенты делают ретраи (Stripe удаляет их через 24 часа), затем удаляйте пачками.
Последовательные зависимости перемножаются: 0.999⁴ ≈ 99.6%, около 2,9 часа простоя в месяц ещё до ваших собственных багов. Чтобы обещать больше, делайте зависимости необязательными или избыточными (кэш, fallback, асинхронная работа, вторая реплика), чтобы их отказ перестал быть вашим.
Последовательно: доступность равна произведению всего, что стоит на пути запроса: балансировщик, ваш код, база, каждая жёсткая зависимость. Десять звеньев по 99,9% ограничивают вас примерно 99%.
Параллельно: две реплики по 99% дают 1 - 0.01² = 99.99%, но только если они отказывают независимо. Общая база, общая зона или общий выкат конфига делают их отказы коррелированными, и расчёт перестаёт работать.
Мягкие зависимости: с таймаутом и fallback (цена из кэша, рекомендации по умолчанию, «отзывы недоступны») зависимость выпадает из формулы. Ставьте таймаут заметно ниже собственного дедлайна, чтобы медленная зависимость не съела весь бюджет, и circuit breaker, чтобы мёртвую не дёргали на каждом запросе. Спрашивайте про каждый вызов: падает ли запрос пользователя, если упал этот вызов?
Асинхронность: работа, вынесенная с пути запроса (письмо уходит через очередь), превращает отказ зависимости в задержку, а не в ошибку.
Небольшой in-process кэш стоит добавить, когда доминируют сетевые круги до Redis или несколько горячих ключей: он отвечает за наносекунды и разгружает горячий ключ по подам. Платить за это приходится 40 независимыми копиями: короткий TTL, рассылка инвалидаций и секунды устаревания.
Горячий ключ: один ключ Redis, который читают 200 000 раз в секунду, попадает на один шард, то есть на одно ядро CPU, как бы ни был велик кластер. Локальная копия на каждом поде или ключ, разбитый на реплики key#0..key#7 со случайным чтением, распределяют нагрузку.
Ограниченный размер: обычная map без вытеснения превращается в утечку памяти. Берите LRU или кэш с ограничением по стоимости (ristretto, otter) и держите там только маленькие, горячие, редко меняющиеся значения: конфиг, фича-флаги, карточки товаров.
Инвалидация: при записи публикуйте ключ в канал Redis Pub/Sub, и каждый под его выбрасывает. Pub/Sub работает по принципу fire-and-forget: под, который в этот момент переподключался, пропускает сообщения, поэтому короткий локальный TTL (5–30 с) служит обязательной страховкой, а не опцией.
Client-side caching в Redis: начиная с Redis 6 CLIENT TRACKING заставляет сервер слать инвалидации по ключам, которые соединение читало; rueidis использует это из коробки.
Промахи на каждом поде: singleflight на ключ при локальном промахе, чтобы 40 подов отправили не больше 40 загрузок, а не тысячи.
Не для данных, где нужен read-your-writes: пользователь, который только что что-то поменял, может попасть на под со старой копией, если только под, который писал, не обновляет свою копию и запросы не прилипают к нему.
Удаление при записи всё равно проигрывает гонку: читатель промахивается, загружает старую строку, писатель коммитит и удаляет ключ, а затем читатель кладёт старое значение, и оно живёт весь TTL. Лаг реплик и потерянные удаления расширяют это окно.
Откуда берутся устаревшие значения
Гонка read-then-set: загрузка медленнее записи; поздний SET побеждает. Редко для одного ключа, неизбежно на масштабе.
Лаг реплики: путь промаха читает реплику, которая ещё не применила запись, поэтому повторное заполнение после удаления уже устаревшее.
Потерянное удаление: таймаут Redis или падение между коммитом и DEL, и никто его не повторяет.
Удаление до коммита: удаление внутри транзакции позволяет читателю заново заполнить кэш из старой закоммиченной строки до того, как коммит пройдёт.
Исправления, от дешёвых к дорогим
TTL как граница: он есть у каждого ключа и ограничивает, сколько живёт любой из этих багов. TTL в часы означает часы устаревших данных.
Отложенное двойное удаление: удалить после коммита и ещё раз примерно через секунду задачей из очереди, чтобы накрыть читателя, который был в полёте. Дешёвая заплатка, а не гарантия.
Версионированная запись: вместо удаления писатель кладёт новое значение с version строки, а каждое заполнение идёт через Lua-скрипт, который пишет, только если его версия выше закэшированной. Старая версия опоздавшего читателя отклоняется.
Инвалидация из потока изменений: консьюмер изменений базы (Debezium или outbox) удаляет ключи после каждого закоммиченного изменения и повторяет при ошибке. Потерянный DEL повторяется, а записи из сервисов, которые про кэш и не слышали, тоже его инвалидируют.
Заполняйте с primary данные, устаревание которых стоит денег, или вообще их не кэшируйте.
Отправляйте чтения пользователя на primary, только пока его собственная запись может ещё не дойти до реплики: запомните позицию (или время) записи для этого пользователя и используйте реплику, только когда она эту позицию уже применила. Все остальные продолжают читать с реплик.
Временное окно: самый простой вариант: после записи ставьте cookie wrote_at и N секунд читайте с primary, где N больше обычного лага. Ломается, когда лаг подскакивает выше N.
Позиция в WAL: после записи верните pg_current_wal_lsn(); перед чтением с реплики проверьте pg_last_wal_replay_lsn() >= $lsn и, если она отстаёт, идите на primary. Точно, ценой одного дешёвого запроса (или кэшируйте позицию реплики на несколько миллисекунд).
Где хранить токен: cookie или заголовок, который клиент присылает обратно, работает между подами; память сервера не работает, потому что следующий запрос попадёт на другой под.
Кэши тоже считаются: кэш, заполненный с реплики, возвращает проблему; при записи удаляйте ключи пользователя и заполняйте их с primary.
Monotonic reads: если закрепить пользователя за одной репликой, он не увидит сначала новое значение, а потом старое, когда две реплики отстают по-разному.
Между устройствами: cookie живёт в одном браузере. Для сценария «поменял на телефоне, проверил на ноутбуке» храните позицию для пользователя на сервере (в Redis) или всегда читайте собственный профиль пользователя с primary.
Таймаут задаётся по измеренной латентности зависимости (чуть выше её p99 и в пределах оставшегося дедлайна вызывающего), а не круглым числом. Hedging отправляет вторую копию медленного идемпотентного чтения примерно после p95, и одна отстающая реплика больше не определяет ваш p99.
По данным: если p99 равен 80 мс, а p99.9 равен 200 мс, то 250 мс отсекают только настоящие зависания. Таймаут 30 с по умолчанию позволяет зависшей зависимости держать ваши горутины и соединения по 30 с каждое.
Бюджет, а не число на каждом звене: край системы задаёт общий дедлайн; каждое звено берёт меньшее из своего таймаута и того, что осталось в ctx, оставляя место для собственной работы и одного ретрая.
Fan-out усиливает хвост: запрос, который ждёт 100 шардов, каждый из которых медленный в 1% случаев, медленный в 63% случаев (1 - 0.99¹⁰⁰). Именно здесь hedging окупается.
Правила hedging: только идемпотентные чтения; копию отправляйте на другую реплику; ограничьте hedge несколькими процентами трафика, иначе больная зависимость удвоит собственную нагрузку; проигравшую попытку отменяйте.
Вне кода: Envoy умеет делать hedge по таймауту попытки (hedge_on_per_try_timeout), и тогда политика живёт в mesh, а не в каждом клиенте.
Переходите на CDC, когда латентность или нагрузка на базу от поллинга начинают мешать, или когда нужны все изменения, а не только события, которые код не забыл записать. Цена в том, что появляется новый компонент, читающий WAL, а застрявший слот репликации заставляет Postgres хранить WAL, пока не кончится диск.
Relay с поллингом
Просто: SELECT ... FOR UPDATE SKIP LOCKED LIMIT 100, публикация, пометка (всё это ваш собственный Go-код). Латентность равна интервалу опроса, а на больших объёмах постоянные апдейты и удаления плодят мёртвые кортежи и работу для vacuum.
С порядком нужна аккуратность: несколько воркеров relay со SKIP LOCKED могут опубликовать два события одного заказа не по порядку. Разбейте relay по id агрегата или держите один активный relay.
CDC
Коннектор читает закоммиченные изменения из WAL в порядке коммитов, почти без задержки и без запросов-опросов. Outbox event router в Debezium превращает вставки в outbox в события; строки outbox можно удалять сразу после вставки, потому что они уже есть в WAL.
Слот репликации: Postgres хранит WAL, пока консьюмер слота не подтвердит его. Остановившийся коннектор означает растущий pg_wal и в итоге полный диск на primary. Ставьте алерт на отставание слота в pg_replication_slots и ограничивайте его через max_slot_wal_keep_size; при превышении слот инвалидируется, и коннектору нужен новый снапшот.
Failover: до Postgres 17 логический слот не переживал повышение standby; в Postgres 17 появились failover-слоты: создайте слот с failover = true и включите sync_replication_slots = on на standby, которому также нужны primary_slot_name, hot_standby_feedback = on и dbname в primary_conninfo. Физический слот standby настоятельно рекомендуется указать в synchronized_standby_slots на primary, чтобы коннектор не обогнал standby; коннектор тоже должен использовать такой слот.
Доставка: по-прежнему at-least-once: после рестарта коннектор переигрывает с последнего сохранённого offset, поэтому консьюмеры дедуплицируют по id события, как и раньше.
Связность схем: CDC по сырым таблицам превращает внутренние колонки в публичный контракт; публикация только таблицы outbox оставляет схему событий осознанной.
Сделайте проверку и уменьшение одной атомарной операцией (UPDATE ... SET stock = stock - $2 WHERE stock >= $2), и продать лишнее станет невозможно. Затем уберите толпу от этой горячей строки: пропускайте покупателей через Redis, продавайте резервы со сроком действия, а оплату принимайте после.
Почему read-then-write продаёт лишнее: под Read Committed две транзакции читают stock = 1, обе проходят проверку, обе пишут 0. Условный UPDATE перепроверяет stock после получения блокировки строки, поэтому вторая видит 0 и не обновляет ни одной строки.
Горячая строка: каждый покупатель ждёт одну и ту же блокировку строки, по одному, и каждый ожидающий держит соединение из пула. 50 000 таких исчерпают пул задолго до того, как кончится товар.
Шлюз в Redis: DECR stock:{sku} атомарен: результат 0 и больше значит, что покупатель пропущен, а отрицательный значит, что всё распродано, поэтому остальные 49 900 получают «распродано», не трогая базу. Источником правды остаётся условный апдейт в базе, потому что Redis может потерять записи при failover.
Резервы, а не продажи: пропущенный покупатель получает резерв, который истекает через 10 минут, и оплачивает уже после. Истёкший резерв или неудачная оплата возвращает единицу в оба счётчика.
Разделите строку: при большом остатке без шлюза разложите его на N строк-корзин и уменьшайте случайную непустую, чтобы покупатели конкурировали за N блокировок, а не за одну.
Serializable не решение: он тоже не даст продать лишнее, но при такой конкуренции прервёт большую часть транзакций с 40001.
Записывайте каждое движение денег как неизменяемый перевод, проводки которого в сумме дают ноль (минус на одном счёте, плюс на другом), в одной транзакции базы вместе с кэшированными балансами. Ошибку исправляют обратным переводом, а не правкой, а сверка проверяет, что суммы по-прежнему сходятся.
Модель
Суммы: целые числа со знаком в минимальных единицах (центы, тиыны), валюта указана на счёте; никакого float64. Обмен валюты оформляется двумя переводами через счёт конвертации.
Деньги извне тоже живут на счёте: пополнение картой списывает с psp:incoming и зачисляет пользователю, поэтому все счета вместе всегда дают ноль. Системные счета могут уходить в минус, клиентские нет.
Только добавление: роль сервиса в базе не получает UPDATE и DELETE на проводки. История и служит аудитом, а баланс в прошлом считается как сумма до нужного момента.
Балансы
Суммировать все проводки при каждом чтении со временем становится медленно, поэтому accounts.balance обновляется в той же транзакции, а balance >= $amount в WHERE служит проверкой на овердрафт. Блокируйте оба счёта в порядке id, как в вопросе про дедлоки.
Мерчант, которому приходят тысячи платежей в секунду, превращает свою строку баланса в горячую точку: разбейте её на субсчета, сумма которых и есть баланс, или зачисляйте асинхронно, ведь входящему зачислению проверка на овердрафт не нужна.
Идемпотентность и внешний мир
Строка перевода хранит idempotency key запроса под уникальным ограничением, поэтому повтор находит первый перевод (схема «сначала захватить ключ» из вопроса про платежи).
Никогда не вызывайте банк или платёжного провайдера внутри транзакции леджера. Проведите холд на счёт ожидания, вызовите провайдера, а когда он ответит, проведите перевод окончательно или снимите холд. Каждый шаг идемпотентен, а зависший холд виден в отчёте.
Сверка
Внутренняя и постоянная: каждый перевод в сумме даёт ноль, каждый кэшированный баланс равен сумме своих проводок, все счета вместе дают ноль.
Внешняя и ежедневная: сопоставьте леджер с файлами расчётов банка и провайдера. Расхождение становится задачей для человека, а не поводом для автоматического исправления.
Берите уникальное число из счётчика (sequence в базе или диапазоны ID на каждый инстанс), кодируйте его в base62, храните код → URL с ключом по коду и отдавайте редиректы из кэша: чтений примерно в 100 раз больше, чем записей. Основная часть дизайна приходится на генерацию ID и путь чтения.
Оценки
100 миллионов ссылок в месяц дают около 40 записей в секунду в среднем (около 400 в пике) и при 100:1 около 4 000 редиректов в секунду в среднем (около 40 000 в пике).
500 байт на строку × 1,2 миллиарда ссылок в год ≈ 600 ГБ в год: один партиционированный Postgres или key-value хранилище вмещает это на годы вперёд.
62⁷ ≈ 3,5 триллиона кодов: 7 символов хватит на века при таком темпе.
API и ID
POST /links {url} → 201 {code}; GET /{code} → редирект с Location. Используйте 302, если считаете клики: браузеры кэшируют 301 и перестают спрашивать.
ID берутся из sequence в базе или диапазонов от неё, выданных каждому инстансу, и кодируются в base62. Сырой Snowflake ID занимает 64 бита и кодируется примерно в 11 символов; для кодов из 7 символов берите счётчик меньше 62⁷ или случайный код из 7 символов с уникальным ограничением и повтором. Последовательные коды можно угадать; если это важно, перемешайте счётчик биекцией.
Хеширование URL (первые 7 символов дайджеста) приносит коллизии, которые надо обрабатывать, и всегда даёт одному URL один код, а это может быть нужно, а может и нет.
Путь чтения
Кэшируйте код → URL в Redis, а для самых горячих ссылок ещё и в процессе; большая часть трафика приходится на небольшой набор кодов. Промах обходится поиском по первичному ключу.
Ненадолго кэшируйте «не найдено», чтобы перебор случайных кодов не доходил до базы.
Клики считайте через очередь и агрегируйте асинхронно, а не UPDATE на каждый редирект.
Стоит упомянуть
Пользовательские алиасы (уникальное ограничение), срок жизни (колонка плюс задача очистки), защиту от злоупотреблений: rate limit на создание ссылок для пользователя и проверку URL по спискам вредоносных сайтов.
Никогда не делайте UPDATE на каждый просмотр. Каждый под складывает просмотры в памяти и раз в секунду сбрасывает дельты в Redis или в лог вроде Kafka, а задача пачками переносит итоги в Postgres. Счётчику можно немного опаздывать и быть чуть приблизительным, и именно это делает 50 000 в секунду дешёвыми.
Почему очевидный дизайн не работает
UPDATE videos SET views = views + 1 берёт блокировку строки, поэтому инкременты вирусного видео выстраиваются в очередь на одну строку, каждый со своей записью в WAL и мёртвым кортежем, а ожидающие держат соединения из пула. Одна горячая строка выдерживает намного меньше 50 000 обновлений в секунду.
Подсчёт
Агрегируйте в процессе: map[videoID]int64 в каждом поде, сброс по тикеру. При 20 подах вирусное видео стоит 20 записей в секунду вместо 50 000.
Куда сбрасывать: один пайплайн INCRBY в Redis на сброс или по сообщению на видео за сброс в топик с ключом по id видео, а консьюмер их суммирует. Redis отвечает на вопрос «сколько просмотров сейчас»; при экстремальной нагрузке разбейте ключ одного видео на несколько подключей и складывайте их при чтении.
Сохраняйте пачками: каждые 30–60 с задача копирует итоги в Postgres одним запросом (UPDATE videos SET views = GREATEST(views, t.n) FROM (VALUES ...) AS t(id, n) WHERE videos.id = t.id). Итоги, а не дельты, делают задачу безопасной для повтора, а Redis, потерявший последнюю секунду, не сдвинет сохранённое значение назад.
Что можно потерять или посчитать дважды
Упавший под теряет несброшенную секунду; сбрасывайте по SIGTERM, чтобы деплой ничего не терял. Failover Redis может потерять последние подтверждённые инкременты. Назовите бюджет потерь («не больше секунды просмотров на сбой»), продукт обычно его принимает.
Сброс, повторённый после таймаута, может посчитаться дважды. Если это важно, помечайте каждый сброс подом и порядковым номером и пропускайте уже виденные.
Уникальным зрителям нужен другой счётчик: HyperLogLog (PFADD / PFCOUNT) считает разных пользователей в 12 КБ на ключ с погрешностью около 0,81%, тогда как множество растёт с каждым зрителем.
Боты и обновления страницы: засчитывайте просмотр один раз на пользователя и видео за окно времени или после нескольких секунд воспроизведения, до того как он попадёт в счётчик.
API, который валидирует и ставит в очередь, роутер, который применяет настройки и шаблоны, и очереди по каналам, которые разбирают воркеры, вызывающие провайдеров в рамках их лимитов. Доставка работает в режиме at-least-once, поэтому каждое сообщение несёт idempotency key и дедуплицируется перед отправкой.
Поток
Другие сервисы вызывают POST /notifications или публикуют событие: пользователь, тип, данные, idempotency key. API сохраняет его и отвечает 202.
Роутер учитывает настройки, отписки и тихие часы, рендерит шаблон на языке пользователя и пишет по одному сообщению на канал в топики email, sms и push.
Воркеры каналов вызывают провайдеров (email API, SMS-шлюз, APNs и FCM), у каждого свой лимит параллельности.
Надёжность
Дедупликация: перед отправкой вставьте уникальную строку (idempotency_key, channel), чтобы повторно доставленное сообщение не ушло дважды; передавайте ключ провайдеру, если он его принимает.
Ретраи: временные ошибки провайдера повторяются с backoff через топики задержки; постоянные (невалидный токен устройства, hard bounce) отключают адрес и останавливают попытки. После N попыток сообщение уходит в DLQ с инструментом для переигрывания.
Изоляция: отдельные очереди по каналам и приоритетам: маркетинговая рассылка на 10 миллионов писем не должна задерживать код входа по SMS.
Масштаб и лимиты
Лимиты провайдеров: token bucket на провайдера в воркерах; когда он пуст, сообщения ждут в очереди, а не падают с ошибкой.
Лимиты на пользователя: не больше N push в час на пользователя, а дубли схлопываются («3 новых комментария»).
Массовые рассылки: планировщик разворачивает кампанию в пачки, вместо того чтобы вызывающий слал по запросу на пользователя.
Статусы: отслеживайте каждое сообщение (в очереди, отправлено, доставлено, ошибка) по вебхукам провайдеров, для поддержки и аналитики.
Клиенты держат WebSocket к слою гейтвеев. Сообщение сначала сохраняется со следующим порядковым номером своего диалога, а затем через реестр присутствия уходит гейтвеям получателей; офлайн-устройства получают push, а при переподключении забирают всё после последнего номера, который у них есть.
Отправка и порядок
Клиент отправляет conversation_id, сгенерированный им client_msg_id (UUID) и текст. Сервер увеличивает last_seq диалога, в той же транзакции вставляет сообщение с этим номером и подтверждает клиенту seq.
Порядок задаёт seq, а не время: часы устройства и сервера расходятся, а двум сообщениям в одну миллисекунду всё равно нужен порядок. Нужен только порядок внутри диалога, глобальный не нужен никогда.
Повторная отправка после потерянного подтверждения упирается в уникальный (conversation_id, client_msg_id): транзакция откатывается, не израсходовав номер, и сервер подтверждает сохранённый seq.
Доставка
Онлайн: реестр присутствия (пользователь и его ноды гейтвеев, по записи на устройство, обновляется хартбитами с TTL) говорит, где сокеты каждого получателя; чат-сервис отправляет сообщение этим гейтвеям.
Сначала хранилище: real-time путь может терять сообщения, потому что ничего не живёт только в нём. Клиент, увидевший 41, а потом 43, запрашивает 42; при переподключении он отправляет последний seq по каждому диалогу и забирает пропуск.
Офлайн: устройства без сокета получают push через APNs или FCM.
Гейтвеи хранят состояние: при деплое выводите их постепенно, иначе миллион клиентов переподключится разом.
Прочтения и группы
Храните один last_read_seq на участника, а не строку на каждое сообщение и каждого читателя. «Прочитано» означает seq <= last_read_seq, непрочитанных last_seq - last_read_seq, а значение никогда не идёт назад.
Канал на 100 000 участников получает вместо каждого сообщения лёгкий сигнал «есть новые сообщения», и открытые клиенты забирают их сами.
Хранение
Горячие запросы выглядят как «последние 50 сообщений» и «всё после seq N», поэтому ключ сообщения (conversation_id, seq). Postgres с партициями справляется долго; на масштабе Discord это превратилось в wide-column хранилище с партициями по каналу и временному бакету. Медиа уходят в объектное хранилище, а в сообщении остаётся ссылка.
Fan-out on write копирует id каждого нового поста в сохранённую ленту каждого подписчика, поэтому чтение стоит один запрос; fan-out on read сливает последние посты всех подписок в момент запроса. Реальные системы их смешивают: рассылка при записи для большинства авторов и слияние при чтении для немногих с миллионами подписчиков.
Fan-out on write
Воркер читает подписчиков автора и добавляет id поста в начало ленты каждого из них: список или sorted set в Redis, обрезанный до последних 500–800 id, или таблица лент. Чтение сводится к одному запросу и пакетной загрузке постов.
Рассылка идёт из очереди после коммита поста, поэтому публикация возвращается сразу; автор видит свой пост, потому что его собственные посты подмешиваются при чтении.
Цена ложится на запись. 300 подписчиков означают 300 маленьких записей. Десять миллионов подписчиков означают десять миллионов записей на пост, минуты, пока пост увидит последний подписчик, и большинство записей попадает в ленты людей, которые на этой неделе приложение не откроют.
Fan-out on read
По запросу берутся свежие посты каждого аккаунта из подписок (таймлайн каждого автора закэширован) и сливаются по времени. Публикация ничего лишнего не стоит.
Чтение дорогое: 2 000 подписок означают 2 000 чтений таймлайнов при каждом открытии ленты, а открытий ленты на порядки больше, чем постов.
Гибрид
Рассылайте при записи для авторов ниже порога подписчиков; для знаменитостей рассылку пропускайте и подмешивайте их свежие посты, когда подписчик читает ленту. Так работала домашняя лента Twitter.
Рассылайте только пользователям, активным в последние несколько недель; ленту вернувшегося пользователя пересобирают чтением.
Ленты хранят id, а не тела постов: правка или удаление меняет один пост в кэше постов, а не миллионы копий в лентах, а удалённые id отфильтровываются при чтении.
Листайте курсором по id поста или скору, а не через OFFSET: новые посты приходят сверху, пока пользователь скроллит.
Ранжированная лента добавляет шаг скоринга над теми же кандидатами; устройство хранения не меняется.
Держите token bucket на каждый API-ключ в Redis и обновляйте его одним Lua-скриптом: проверка и обновление атомарны для всех реплик и стоят один сетевой круг. Ограничьте вызов Redis коротким таймаутом, а при ошибке переходите на локальный лимитер с долей каждого пода, а не снимайте лимит.
Дизайн
Проверяйте в middleware или на шлюзе, до любой работы; при превышении отвечайте 429 с Retry-After.
Скрипт читает токены и время последнего пополнения, добавляет elapsed × rate, обрезает до всплеска, забирает один токен и записывает обратно с expiry. Он берёт время из TIME Redis, а не с часов каждого пода, поэтому расхождение часов между подами не важно.
Всё состояние одного ключа лежит в одном ключе Redis, поэтому в Redis Cluster скрипт никогда не выходит за пределы одного hash slot.
Стоимость и масштаб
Один сетевой круг на запрос, заметно меньше миллисекунды в той же зоне; при 100 000 запросов в секунду это кластер Redis, под который нужно подбирать размер и который нужно мониторить.
Огромный клиент превращается в горячий ключ на одном шарде. Если брать токены пачками (под резервирует 10 и тратит их локально), сетевых кругов меньше ценой некоторой точности.
Отказ
Таймаут на Redis 10–20 мс. При ошибке делайте fail open, но с ограничением: in-process rate.Limiter на ключ с лимитом limit / replicas.
Fail closed превращает отказ Redis в полный отказ API; полностью открытый fail open снимает защиту именно тогда, когда что-то уже идёт не так.
Лимиты, которые защищают от злоупотреблений, например попытки входа, могут вместо этого работать как fail closed.