Senior Go Interview Prep - Core Go: https://go.vbloher.org/docs/01-core-go/ - Механика defer в Go: https://go.vbloher.org/docs/01-core-go/defer/ - Встраивание структур и интерфейсов (Embedding): https://go.vbloher.org/docs/01-core-go/embedding/ - Ошибки в Go: error, wrapping, errors.Is/As/Join: https://go.vbloher.org/docs/01-core-go/errors/ - Дженерики в Go (1.18+): https://go.vbloher.org/docs/01-core-go/generics/ - Интерфейсы в Go: https://go.vbloher.org/docs/01-core-go/interfaces/ - Устройство map в Go: https://go.vbloher.org/docs/01-core-go/maps/ - panic / recover: механика, раскрутка стека и runtime-паники: https://go.vbloher.org/docs/01-core-go/panic-recover/ - Указатели в Go: https://go.vbloher.org/docs/01-core-go/pointers/ - Рефлексия в Go (reflect): https://go.vbloher.org/docs/01-core-go/reflection/ - Внутреннее устройство слайсов в Go: https://go.vbloher.org/docs/01-core-go/slices/ - Строки, руны и байты в Go: https://go.vbloher.org/docs/01-core-go/strings-runes-bytes/ - Система типов Go: defined types, alignment, memory layout: https://go.vbloher.org/docs/01-core-go/type-system/ - Concurrency: https://go.vbloher.org/docs/02-concurrency/ - sync/atomic: https://go.vbloher.org/docs/02-concurrency/atomic/ - Буферизованные vs небуферизованные каналы: https://go.vbloher.org/docs/02-concurrency/buffered-unbuffered/ - Канал vs Mutex: когда что выбрать: https://go.vbloher.org/docs/02-concurrency/channel-vs-mutex/ - Каналы: устройство hchan: https://go.vbloher.org/docs/02-concurrency/channels/ - Утечки горутин, дедлоки, livelock, starvation: https://go.vbloher.org/docs/02-concurrency/common-leaks-deadlocks/ - sync.Cond: https://go.vbloher.org/docs/02-concurrency/cond/ - context: https://go.vbloher.org/docs/02-concurrency/context/ - Горутины: жизненный цикл, стоимость, стек: https://go.vbloher.org/docs/02-concurrency/goroutines-lifecycle/ - sync.Mutex и sync.RWMutex: https://go.vbloher.org/docs/02-concurrency/mutex-rwmutex/ - sync.Once: https://go.vbloher.org/docs/02-concurrency/once/ - Паттерны конкурентности: https://go.vbloher.org/docs/02-concurrency/patterns/ - Race Detector (гонки данных и -race): https://go.vbloher.org/docs/02-concurrency/race-detector/ - Планировщик GMP: https://go.vbloher.org/docs/02-concurrency/scheduler-gmp/ - select: https://go.vbloher.org/docs/02-concurrency/select/ - sync.WaitGroup: https://go.vbloher.org/docs/02-concurrency/waitgroup/ - Runtime и память: https://go.vbloher.org/docs/03-runtime-memory/ - Паттерны аллокаций и снижение давления на GC: https://go.vbloher.org/docs/03-runtime-memory/allocation-patterns/ - Escape Analysis: когда переменная убегает в кучу: https://go.vbloher.org/docs/03-runtime-memory/escape-analysis/ - Сборщик мусора Go: concurrent tri-color mark-sweep: https://go.vbloher.org/docs/03-runtime-memory/gc/ - Тюнинг GC: GOGC и GOMEMLIMIT: https://go.vbloher.org/docs/03-runtime-memory/gogc-gomemlimit/ - GOMAXPROCS: параллелизм планировщика и проблема контейнеров: https://go.vbloher.org/docs/03-runtime-memory/gomaxprocs/ - Утечки горутин (goroutine leaks): https://go.vbloher.org/docs/03-runtime-memory/goroutine-leaks/ - Утечки памяти в Go (несмотря на GC): https://go.vbloher.org/docs/03-runtime-memory/memory-leaks/ - Модель памяти Go (Go Memory Model): happens-before и синхронизация: https://go.vbloher.org/docs/03-runtime-memory/memory-model/ - pprof: профилирование CPU, памяти и блокировок в Go: https://go.vbloher.org/docs/03-runtime-memory/pprof/ - Execution Tracer и runtime/trace: тайминги вместо агрегатов: https://go.vbloher.org/docs/03-runtime-memory/runtime-tracing/ - Стек vs Куча: где живут данные в Go: https://go.vbloher.org/docs/03-runtime-memory/stack-vs-heap/ - Тестирование: https://go.vbloher.org/docs/04-testing/ - testify, assert/require и golden files: https://go.vbloher.org/docs/04-testing/assertions-testify/ - Бенчмарки в Go: https://go.vbloher.org/docs/04-testing/benchmarks/ - Покрытие, -race и флаки-тесты: https://go.vbloher.org/docs/04-testing/coverage-race/ - Нативный fuzzing в Go (1.18+): https://go.vbloher.org/docs/04-testing/fuzzing/ - Интеграционные тесты, testcontainers-go, TestMain: https://go.vbloher.org/docs/04-testing/integration-testcontainers/ - Моки, стабы и тестируемость: https://go.vbloher.org/docs/04-testing/mocks/ - Table-driven тесты, subtests и параллельность: https://go.vbloher.org/docs/04-testing/table-driven/ - Backend: https://go.vbloher.org/docs/05-backend/ - Аутентификация и авторизация: AuthN/AuthZ, сессии vs токены, RBAC/ABAC, API keys, mTLS, секреты: https://go.vbloher.org/docs/05-backend/auth-authz/ - Graceful Shutdown HTTP/gRPC сервера в Go: https://go.vbloher.org/docs/05-backend/graceful-shutdown/ - gRPC: типы RPC, интерсепторы, контекст, метаданные, error model: https://go.vbloher.org/docs/05-backend/grpc/ - JWT (JSON Web Token): https://go.vbloher.org/docs/05-backend/jwt/ - Middleware-паттерн в Go: https://go.vbloher.org/docs/05-backend/middleware/ - net/http: Server, Handler, ServeMux, таймауты, Client и контекст: https://go.vbloher.org/docs/05-backend/net-http/ - OAuth2: роли, grant types, OIDC, токены и типовые ошибки: https://go.vbloher.org/docs/05-backend/oauth2/ - OpenAPI/Swagger, code generation, contract-first vs code-first, валидация: https://go.vbloher.org/docs/05-backend/openapi/ - Protocol Buffers: схемы, wire format, эволюция и совместимость: https://go.vbloher.org/docs/05-backend/protobuf/ - REST: принципы, версионирование, идемпотентность, статусы, пагинация, ошибки: https://go.vbloher.org/docs/05-backend/rest/ - Сети и протоколы: https://go.vbloher.org/docs/06-networking/ - Пулы соединений: http.Transport, БД, утечки: https://go.vbloher.org/docs/06-networking/connection-pooling/ - DNS: записи, резолвинг, кэширование, DNS в Go: https://go.vbloher.org/docs/06-networking/dns/ - Версии HTTP: 1.1, 2, 3: https://go.vbloher.org/docs/06-networking/http-versions/ - TCP/IP: модель, транспорт и что важно бэкендеру: https://go.vbloher.org/docs/06-networking/tcp-ip/ - TLS: handshake, сертификаты, mTLS, производительность: https://go.vbloher.org/docs/06-networking/tls/ - UDP и надёжность поверх UDP: https://go.vbloher.org/docs/06-networking/udp/ - WebSocket: upgrade, фреймы, масштабирование: https://go.vbloher.org/docs/06-networking/websocket/ - Базы данных: https://go.vbloher.org/docs/07-databases/ - Пул соединений к PostgreSQL в Go: database/sql, pgx, pgxpool, PgBouncer: https://go.vbloher.org/docs/07-databases/connection-pooling-pgx/ - Взаимоблокировки (Deadlocks) в PostgreSQL: https://go.vbloher.org/docs/07-databases/deadlocks/ - Индексы в PostgreSQL: https://go.vbloher.org/docs/07-databases/indexes/ - Уровни изоляции транзакций в PostgreSQL: https://go.vbloher.org/docs/07-databases/isolation-levels/ - MVCC в PostgreSQL: версии строк, видимость, VACUUM и bloat: https://go.vbloher.org/docs/07-databases/mvcc/ - Обзор NoSQL и Redis: https://go.vbloher.org/docs/07-databases/nosql-redis/ - Партиционирование таблиц в PostgreSQL: https://go.vbloher.org/docs/07-databases/partitioning/ - Архитектура PostgreSQL: https://go.vbloher.org/docs/07-databases/postgresql-architecture/ - Планирование и оптимизация запросов в PostgreSQL: https://go.vbloher.org/docs/07-databases/query-planning/ - Репликация в PostgreSQL: https://go.vbloher.org/docs/07-databases/replication/ - Шардирование (горизонтальное масштабирование): https://go.vbloher.org/docs/07-databases/sharding/ - Транзакции в PostgreSQL и Go (database/sql, pgx): https://go.vbloher.org/docs/07-databases/transactions/ - Распределённые системы: https://go.vbloher.org/docs/08-distributed-systems/ - CAP теорема: https://go.vbloher.org/docs/08-distributed-systems/cap-theorem/ - Circuit Breaker: https://go.vbloher.org/docs/08-distributed-systems/circuit-breaker/ - Консенсус и Raft: репликация состояния в присутствии отказов: https://go.vbloher.org/docs/08-distributed-systems/consensus-raft/ - Модели согласованности: https://go.vbloher.org/docs/08-distributed-systems/consistency/ - Гарантии доставки сообщений: at-most-once / at-least-once / exactly-once: https://go.vbloher.org/docs/08-distributed-systems/delivery-guarantees/ - Eventual Consistency: https://go.vbloher.org/docs/08-distributed-systems/eventual-consistency/ - Идемпотентность в распределённых системах: https://go.vbloher.org/docs/08-distributed-systems/idempotency/ - Apache Kafka: https://go.vbloher.org/docs/08-distributed-systems/kafka/ - Transactional Outbox: https://go.vbloher.org/docs/08-distributed-systems/outbox/ - RabbitMQ: AMQP 0-9-1, маршрутизация, надёжность доставки и сравнение с Kafka: https://go.vbloher.org/docs/08-distributed-systems/rabbitmq/ - Ретраи: backoff, jitter, budgets и идемпотентность: https://go.vbloher.org/docs/08-distributed-systems/retries/ - Saga Pattern: https://go.vbloher.org/docs/08-distributed-systems/saga/ - Observability: https://go.vbloher.org/docs/09-observability/ - Grafana: https://go.vbloher.org/docs/09-observability/grafana/ - Метрики: RED, USE, Golden Signals: https://go.vbloher.org/docs/09-observability/metrics/ - OpenTelemetry: https://go.vbloher.org/docs/09-observability/opentelemetry/ - Prometheus: https://go.vbloher.org/docs/09-observability/prometheus/ - SLI / SLO / SLA: https://go.vbloher.org/docs/09-observability/slo-sli/ - Структурированное логирование (slog): https://go.vbloher.org/docs/09-observability/structured-logging/ - Distributed Tracing: https://go.vbloher.org/docs/09-observability/tracing/ - System Design: https://go.vbloher.org/docs/10-system-design/ - Analytics Pipeline: https://go.vbloher.org/docs/10-system-design/analytics-pipeline/ - Chat System: https://go.vbloher.org/docs/10-system-design/chat/ - Фреймворк System Design интервью: https://go.vbloher.org/docs/10-system-design/framework/ - Notification Service: https://go.vbloher.org/docs/10-system-design/notification-service/ - Order Service: https://go.vbloher.org/docs/10-system-design/order-service/ - Payment Service: https://go.vbloher.org/docs/10-system-design/payment-service/ - Rate Limiter: https://go.vbloher.org/docs/10-system-design/rate-limiter/ - URL Shortener: https://go.vbloher.org/docs/10-system-design/url-shortener/ - DevOps: https://go.vbloher.org/docs/11-devops/ - CI/CD: пайплайны, стадии, стратегии деплоя: https://go.vbloher.org/docs/11-devops/cicd/ - Облака (AWS / GCP) для бэкендера: https://go.vbloher.org/docs/11-devops/cloud-aws-gcp/ - Docker для Go-разработчика: https://go.vbloher.org/docs/11-devops/docker/ - GitHub Actions и GitLab CI: https://go.vbloher.org/docs/11-devops/github-gitlab-ci/ - Kubernetes для Go-разработчика: https://go.vbloher.org/docs/11-devops/kubernetes/ - Terraform / Infrastructure as Code: https://go.vbloher.org/docs/11-devops/terraform/ - Алгоритмы: https://go.vbloher.org/docs/12-algorithms/ - Типовые алгоритмические задачи и паттерны: https://go.vbloher.org/docs/12-algorithms/common-problems/ - Асимптотическая сложность (Big-O): https://go.vbloher.org/docs/12-algorithms/complexity/ - Структуры данных в Go: https://go.vbloher.org/docs/12-algorithms/data-structures/ - Специфика live-coding на Go: https://go.vbloher.org/docs/12-algorithms/go-specifics/ - Behavioral: https://go.vbloher.org/docs/13-behavioral/ - Конфликты, разногласия и работа со стейкхолдерами: https://go.vbloher.org/docs/13-behavioral/conflicts/ - Как проходит senior-интервью: этапы, оценка, оффер: https://go.vbloher.org/docs/13-behavioral/interview-flow/ - Лидерство и менторство: https://go.vbloher.org/docs/13-behavioral/leadership-mentoring/ - Типовые поведенческие вопросы для Senior: https://go.vbloher.org/docs/13-behavioral/senior-questions/ > Модуль: System Design · Уровень: Senior ## TL;DR Rate Limiter ограничивает число запросов от клиента (по ключу: user_id, API-key, IP) за единицу времени. Базовый ответ при превышении — `HTTP 429 Too Many Requests` с `Retry-After`. Ключевой выбор — алгоритм (точность vs память) и место размещения (gateway/middleware/sidecar). В распределённой системе состояние обычно держат в Redis с атомарными операциями через Lua-скрипты; для снижения нагрузки на Redis применяют гибрид **local + global** (локальный токен-бакет на ноде + периодическая синхронизация). Главные trade-offs: - **Точность ↔ память**: sliding window log (точный, дорогой по памяти) vs sliding window counter (аппроксимация, O(1) памяти). - **Гладкость ↔ всплески**: leaky bucket выравнивает трафик; token bucket допускает контролируемые всплески (burst). - **Согласованность ↔ латентность**: строгий глобальный лимит через Redis (сетевой hop на каждый запрос) vs eventual consistency при локальных счётчиках. ## Требования ### Functional - Ограничить запросы по конфигурируемому правилу: `N запросов / окно T` для ключа. - Поддержка нескольких размерностей ключа: per-user, per-API-key, per-IP, per-endpoint, глобально. - Несколько уровней лимитов одновременно (например, 10 req/s И 1000 req/day). - При превышении — отклонить с `429`, вернуть `Retry-After` и `X-RateLimit-*` заголовки. - Поддержка burst (кратковременных всплесков) — настраиваемо. - Динамическое обновление правил без рестарта (hot reload). - Разные тарифы (free/pro/enterprise) — лимит зависит от плана клиента. ### Non-functional - **Низкая латентность**: rate limiter добавляет < 1–2 ms к p99 запроса. - **Высокая доступность**: отказ rate limiter не должен ронять сервис. Политика fail-open (пропускать при недоступности backend) или fail-closed (блокировать) — осознанный выбор. - **Точность**: не выпускать существенно больше лимита (приемлемая погрешность зависит от алгоритма). - **Масштабируемость**: десятки/сотни тысяч RPS, миллионы уникальных ключей. - **Эффективность по памяти**: O(1) на ключ предпочтительно. - **Распределённость**: согласованный лимит между N инстансами сервиса. ## Оценки нагрузки Допустим: API-сервис, **100 000 RPS**, **10 млн уникальных активных клиентов** в сутки, окно лимита 1 минута. **QPS на backend счётчиков (Redis):** - При проверке на каждый запрос — 100k операций/с к Redis (INCR/EVAL). Один Redis-инстанс держит ~100k–200k простых операций/с — мы на грани, нужен шардинг/кластер или local cache. **Память на ключ по алгоритмам:** | Алгоритм | Состояние на ключ | Память на 10M ключей | |---|---|---| | Fixed window counter | 1 счётчик + TTL (~16 B полезных, ~60–90 B с оверхедом Redis-ключа) | ~600–900 MB | | Sliding window counter | 2 счётчика (текущее+предыдущее окно), ~2× | ~1.2–1.8 GB | | Token bucket | tokens(float) + last_refill(ts) = ~16 B полезных | ~600–900 MB | | Sliding window log | timestamp на каждый запрос в окне. При лимите 100/мин — до 100×8 B = 800 B на ключ (только активных) | очень дорого: при 1M активных × 100 запросов = ~800 MB только данных, плюс оверхед ZSET | Вывод по цифрам: log-подход не масштабируется на миллионы ключей с высокими лимитами; counter-подходы дают O(1) и реалистичны. Реальный оверхед Redis-ключа существенен (минимум ~50–90 B на маленький ключ), поэтому считаем именно его, а не голый payload. **Уникальные клиенты:** активных одновременно (за минуту) обычно гораздо меньше суточных — например 1–2M. Память считаем по пику активных ключей с TTL = окно (мёртвые ключи сами истекают). ## Архитектура ``` ┌──────────────────────────┐ │ Config / Rules store │ │ (plan limits, hot reload)│ └───────────┬──────────────┘ │ Client ──HTTP──> ┌─────────────────▼─────────────────┐ │ API Gateway │ │ ┌───────────────────────────┐ │ │ │ Rate Limiter middleware │ │ │ │ 1) build key (user/ip/ep) │ │ │ │ 2) check local bucket ────┼────┼──> local in-memory │ │ 3) (sync) check Redis ────┼────┼──┐ token bucket │ └───────────────────────────┘ │ │ └────────┬───────────────▲───────────┘ │ │ allow │ 429 + │ ▼ │ Retry-After ▼ ┌────────────┐ │ ┌──────────────┐ │ Upstream │ └────────│ Redis Cluster│ │ services │ atomic EVAL │ (sharded by │ └────────────┘ (Lua script) │ key) │ └──────────────┘ ``` ### Где размещать rate limiter | Уровень | Плюсы | Минусы | |---|---|---| | **Client-side** | экономит сеть, мгновенно | клиенту нельзя доверять, легко обойти; только как "вежливость" | | **API Gateway / Edge (Nginx, Envoy, Kong)** | единая точка, до бизнес-логики, защищает все сервисы | глобальное состояние нужно шарить между edge-нодами | | **Middleware в сервисе** | доступ к контексту приложения (план юзера, auth), просто внедрить | дублируется в каждом сервисе; запрос уже потратил ресурсы на маршрутизацию | | **Sidecar (service mesh, Envoy + RLS)** | вынесено из кода приложения, единый policy для mesh | оперсложность mesh; ещё один сетевой hop | Практика senior-уровня: **первичный лимит на gateway/edge** (грубый, защита от DDoS/абуза) + **точный бизнес-лимит в middleware/RLS** (per-plan, per-endpoint). Глобальное состояние — в Redis, локальный кэш — на каждой ноде. ## Ключевые решения и trade-offs ### Token Bucket Бакет вместимостью `capacity` токенов пополняется со скоростью `refillRate` токенов/сек. Каждый запрос забирает 1 (или N) токенов; нет токена — отказ. - **Плюсы**: допускает контролируемые всплески (до `capacity`), O(1) памяти (2 числа на ключ), интуитивно, легко выразить "burst + sustained rate". - **Минусы**: всплеск может пройти сразу до capacity (если нужна строгая гладкость — не подходит). - **Точность/память**: точный по среднему rate, O(1). ### Leaky Bucket Очередь (или счётчик "уровня воды") фиксированной ёмкости, "вытекающая" с постоянной скоростью. Запросы добавляются в очередь; переполнение — отказ. По сути queue-as-buffer + постоянный output rate. - **Плюсы**: **выравнивает** исходящий трафик до строго постоянной скорости (хорошо для защиты медленного downstream). - **Минусы**: всплески не пропускает (запросы ждут или отбрасываются), добавляет латентность при буферизации, очередь = память. - **Token vs leaky**: token bucket разрешает burst, leaky — сглаживает. Token bucket чаще выбирают для API. ### Fixed Window Counter Счётчик на окно `[t0, t0+T)`. INCR на запрос, сброс в начале нового окна. - **Плюсы**: тривиально, O(1), 1 счётчик + TTL. - **Минус (всплеск на границе)**: при лимите 100/мин клиент может сделать 100 запросов в 00:59 и ещё 100 в 01:00 — **200 запросов за 2 секунды** на стыке окон. Эффективно до 2× лимита локально. ### Sliding Window Log Хранит timestamp каждого запроса (в Redis — ZSET). При проверке удаляет записи старше `now - T`, считает оставшиеся, сравнивает с лимитом. - **Плюсы**: **абсолютно точный**, нет проблемы границы. - **Минусы**: память O(N) — по timestamp на каждый запрос в окне (при высоких лимитах — дорого, см. таблицу), ZSET-операции тяжелее. ### Sliding Window Counter Аппроксимация: хранит счётчики текущего и предыдущего окна, взвешивает предыдущее по доле перекрытия. `estimate = curr + prev * (1 - elapsed_in_curr / T)`. - **Плюсы**: O(1) памяти (2 счётчика), сглаживает границу гораздо лучше fixed window, дёшево. - **Минусы**: аппроксимация — предполагает равномерное распределение запросов в предыдущем окне; погрешность обычно < 1%, но при неравномерном трафике может слегка пере-/недопускать. Практический выбор: **token bucket** (если нужен burst) или **sliding window counter** (если нужна гладкость без затрат log). Это наиболее частые ответы на senior-собеседовании. ### Distributed: Redis, атомарность, гонки Проблема: с несколькими инстансами сервиса наивный `GET → проверка → SET` создаёт **race condition** — два инстанса читают счётчик 99, оба разрешают, лимит превышен. Решения: - `INCR` атомарен, но "проверить-и-откатить" из нескольких команд — нет. - **Lua-скрипт в Redis выполняется атомарно** (single-threaded, скрипт = одна транзакция без вытеснения). Вся логика «прочитать-вычислить-записать-вернуть решение» — внутри `EVAL`. Это канонический способ. - `WATCH/MULTI/EXEC` (optimistic) возможен, но Lua проще и быстрее (один round-trip). Синхронизация между нодами: при чистом Redis-подходе ноды stateless, состояние в Redis — согласованность строгая (с точностью до атомарности скрипта). При гибриде — eventual (см. ниже). ### HTTP-семантика При отказе: - Статус **429 Too Many Requests**. - `Retry-After: <секунды>` — когда можно повторить. - `X-RateLimit-Limit: 100` — лимит. - `X-RateLimit-Remaining: 0` — остаток. - `X-RateLimit-Reset: ` — когда окно сбросится. (Стандартизируемый вариант — заголовки `RateLimit` / `RateLimit-Policy` из черновика IETF, но `X-RateLimit-*` де-факто распространены.) ## Go-реализация ### Token Bucket (in-memory, потокобезопасный) ```go package ratelimit import ( "sync" "time" ) // TokenBucket — потокобезопасный токен-бакет с ленивым (lazy) пополнением. type TokenBucket struct { mu sync.Mutex capacity float64 // максимум токенов (burst) refillRate float64 // токенов в секунду (sustained rate) tokens float64 // текущее число токенов lastRefill time.Time } func NewTokenBucket(capacity, refillRate float64) *TokenBucket { return &TokenBucket{ capacity: capacity, refillRate: refillRate, tokens: capacity, // стартуем заполненными lastRefill: time.Now(), } } // Allow пытается списать n токенов. Возвращает (разрешено, сколько ждать до следующей попытки). func (tb *TokenBucket) Allow(n float64) (bool, time.Duration) { tb.mu.Lock() defer tb.mu.Unlock() now := time.Now() // Ленивое пополнение: считаем токены по прошедшему времени, без фонового тикера. elapsed := now.Sub(tb.lastRefill).Seconds() tb.tokens = min(tb.capacity, tb.tokens+elapsed*tb.refillRate) tb.lastRefill = now if tb.tokens >= n { tb.tokens -= n return true, 0 } // Сколько ждать, пока накопится недостающее. deficit := n - tb.tokens wait := time.Duration(deficit / tb.refillRate * float64(time.Second)) return false, wait } func min(a, b float64) float64 { if a < b { return a } return b } ``` Менеджер бакетов по ключу с очисткой простаивающих (чтобы не течь по памяти): ```go type Limiter struct { mu sync.Mutex buckets map[string]*bucketEntry cap, rate float64 ttl time.Duration } type bucketEntry struct { tb *TokenBucket lastSeen time.Time } func NewLimiter(capacity, rate float64, ttl time.Duration) *Limiter { l := &Limiter{ buckets: make(map[string]*bucketEntry), cap: capacity, rate: rate, ttl: ttl, } go l.gc() return l } func (l *Limiter) Allow(key string) (bool, time.Duration) { l.mu.Lock() e, ok := l.buckets[key] if !ok { e = &bucketEntry{tb: NewTokenBucket(l.cap, l.rate)} l.buckets[key] = e } e.lastSeen = time.Now() l.mu.Unlock() return e.tb.Allow(1) } func (l *Limiter) gc() { t := time.NewTicker(time.Minute) for range t.C { cutoff := time.Now().Add(-l.ttl) l.mu.Lock() for k, e := range l.buckets { if e.lastSeen.Before(cutoff) { delete(l.buckets, k) } } l.mu.Unlock() } } ``` HTTP middleware с корректными заголовками: ```go func RateLimitMiddleware(l *Limiter, limit int, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key := clientKey(r) // например, из API-key или r.RemoteAddr ok, retry := l.Allow(key) w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit)) if !ok { secs := int(retry.Seconds()) + 1 w.Header().Set("X-RateLimit-Remaining", "0") w.Header().Set("Retry-After", strconv.Itoa(secs)) w.Header().Set("X-RateLimit-Reset", strconv.Itoa(secs)) http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) } ``` ### Distributed: Sliding Window Counter на Redis + Lua Атомарный Lua-скрипт: считает аппроксимацию по текущему и предыдущему окну, инкрементит текущее при разрешении. ```go // KEYS[1] = базовый ключ (например "rl:user:42") // ARGV[1] = limit, ARGV[2] = window_seconds, ARGV[3] = now_ms const slidingWindowLua = ` local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) * 1000 -- в мс local now = tonumber(ARGV[3]) local curr_win = math.floor(now / window) local prev_win = curr_win - 1 local curr_key = key .. ":" .. curr_win local prev_key = key .. ":" .. prev_win local curr = tonumber(redis.call("GET", curr_key) or "0") local prev = tonumber(redis.call("GET", prev_key) or "0") -- доля предыдущего окна, попадающая в скользящее окно local elapsed = (now % window) / window local weighted = prev * (1 - elapsed) + curr if weighted >= limit then -- сколько примерно ждать до освобождения слота local reset = window - (now % window) return {0, math.floor(reset)} -- отказ + retry_after_ms end -- разрешаем: инкремент текущего окна, TTL = 2 окна (нужно prev на стыке) redis.call("INCR", curr_key) redis.call("PEXPIRE", curr_key, window * 2) local remaining = math.floor(limit - weighted - 1) return {1, remaining} ` ``` Использование из Go (`github.com/redis/go-redis/v9`): ```go type RedisLimiter struct { rdb *redis.Client script *redis.Script limit int window time.Duration } func NewRedisLimiter(rdb *redis.Client, limit int, window time.Duration) *RedisLimiter { return &RedisLimiter{ rdb: rdb, script: redis.NewScript(slidingWindowLua), // использует EVALSHA с фоллбэком на EVAL limit: limit, window: window, } } func (rl *RedisLimiter) Allow(ctx context.Context, key string) (bool, time.Duration, error) { now := time.Now().UnixMilli() res, err := rl.script.Run(ctx, rl.rdb, []string{"rl:" + key}, rl.limit, int(rl.window.Seconds()), now, ).Int64Slice() if err != nil { // Политика при недоступности Redis: fail-open (разрешить) либо fail-closed. return true, 0, err // здесь fail-open } allowed := res[0] == 1 retry := time.Duration(res[1]) * time.Millisecond return allowed, retry, nil } ``` Замечания по реализации: - `redis.NewScript(...).Run` сначала пытается `EVALSHA` (скрипт закэширован на сервере), при `NOSCRIPT` — `EVAL`. Меньше трафика. - TTL = `2 × window`, потому что на стыке нужен счётчик предыдущего окна. - В **Redis Cluster** ключи одного лимита (`curr_key`, `prev_key`) должны попадать в один слот — используйте hash tag: `rl:{user:42}:...`, иначе `CROSSSLOT` ошибка для multi-key операций. ## Масштабирование и узкие места - **Redis — главный боттлнек**: каждый запрос = round-trip + EVAL. При 100k RPS один инстанс перегружен. - **Шардинг по ключу** (Redis Cluster, hash tag для co-location ключей одного лимита). - **Read replicas не помогают** — операции пишущие; масштабируем шардами, не репликами. - **Pipeline/батчинг** при множественных лимитах на запрос. - **Local + global hybrid**: каждая нода держит локальный token bucket на долю глобального лимита; периодически (например, раз в 1–10 с) синхронизирует фактический расход с Redis и перераспределяет квоту. Снижает round-trips на порядок ценой точности на коротких интервалах. - Простейший вариант: глобальный лимит L, N нод → каждой L/N локально. Минус: неравномерная нагрузка между нодами недоиспользует общий лимит. - Умный вариант: ноды отчитываются о расходе, координатор/Redis перераспределяет неиспользованную квоту. - **Eventual consistency лимитов**: при гибриде глобальный лимит соблюдается приблизительно — на коротком окне возможен перерасход до (число нод × локальный буфер). Для биллинга/жёстких квот это неприемлемо → строгий Redis-путь; для защиты от абуза — допустимо. - **Latency / availability**: вынос Redis в тот же AZ, таймаут на EVAL (например 5–10 ms), circuit breaker. **Fail-open vs fail-closed** — продуктовое решение: для публичного API часто fail-open (доступность важнее), для защиты дорогого ресурса — fail-closed. - **Hot keys**: один очень активный ключ (например, шумный клиент) бьёт в один шард → горячий слот. Митигация: локальный pre-throttle на ноде до обращения к Redis. - **Clock skew**: sliding window и token bucket зависят от времени. Для Redis-варианта используйте серверное время Redis (`TIME`) вместо времени клиента, чтобы избежать рассинхрона часов между нодами. ## Вопросы на собеседовании **В:** Чем token bucket отличается от leaky bucket и когда выбрать каждый? **О:** Token bucket пополняется токенами и разрешает burst до capacity — хорош для API, где допустимы всплески при соблюдении среднего rate. Leaky bucket выпускает запросы с постоянной скоростью (выравнивает трафик), запросы буферизуются/отбрасываются — хорош для защиты медленного downstream от всплесков. Для большинства публичных API выбирают token bucket из-за burst-дружелюбности. **В:** В чём проблема fixed window counter и как её решает sliding window? **О:** На стыке окон клиент может отправить полный лимит в конце одного окна и сразу полный в начале следующего — до 2× лимита за короткий промежуток. Sliding window log решает точно (хранит timestamps), sliding window counter — аппроксимацией через взвешивание предыдущего окна, давая O(1) памяти и приемлемую погрешность (<1%). **В:** Почему именно Lua-скрипт, а не INCR + проверка в коде? **О:** Между чтением счётчика и записью в распределённой системе возникает race condition: несколько инстансов прочитают значение ниже лимита и все разрешат запрос. Redis выполняет Lua-скрипт атомарно (single-threaded, без вытеснения), поэтому вся логика «прочитать-вычислить-решить-записать» выполняется как единая неделимая операция в один round-trip. **В:** Как считать память и выдержит ли один Redis миллионы ключей? **О:** Для counter/token-bucket — O(1) на ключ, но с учётом оверхеда Redis-ключа реально ~60–90 B на маленький ключ. 10M ключей ≈ 0.6–0.9 GB. Sliding window log хранит timestamp на запрос — не масштабируется при высоких лимитах. Активных одновременно ключей меньше суточных, а TTL = окно сам чистит мёртвые ключи. **В:** Что произойдёт, если Redis недоступен? Какую политику выбрать? **О:** Это выбор fail-open (пропускать запросы — доступность важнее, риск перегрузки) vs fail-closed (блокировать — защита важнее, риск отказа сервиса). Для публичного API часто fail-open с локальным fallback-лимитом и circuit breaker. Нужно явно проговорить, что rate limiter не должен становиться SPOF. **В:** Как масштабировать, когда один Redis не тянет QPS? **О:** Шардинг по ключу (Redis Cluster), с hash tag для co-location ключей одного лимита в один слот (иначе CROSSSLOT). Реплики не помогают — операции пишущие. Дополнительно — гибрид local+global: локальный токен-бакет на ноде снижает round-trips на порядок ценой точности. **В:** Как работает local+global hybrid и какой компромисс по точности? **О:** Каждая нода держит локальный бакет на долю глобального лимита и периодически синхронизирует расход с центральным стором. Это сокращает обращения к Redis, но даёт eventual consistency: на коротком интервале возможен перерасход до суммы локальных буферов всех нод. Подходит для anti-abuse, не подходит для биллинговых квот. **В:** Какие HTTP-заголовки вернуть при превышении и почему важен Retry-After? **О:** Статус 429, `Retry-After` (когда повторить — даёт клиенту корректную backoff-стратегию вместо хаотичных ретраев), `X-RateLimit-Limit/Remaining/Reset` для прозрачности. Без Retry-After клиенты ретраят агрессивно и усугубляют перегрузку. **В:** Где разместить rate limiter — gateway, middleware или sidecar? **О:** Часто комбинация: грубый защитный лимит на edge/gateway (до бизнес-логики, защита от DDoS), точный per-plan/per-endpoint лимит в middleware или sidecar (RLS), где доступен контекст приложения. Client-side — только как вежливость, ему нельзя доверять. ## На что копают на senior+ - **Атомарность в распределённой среде**: понимает ли кандидат, почему GET+SET ломается и как именно Lua/EVALSHA это решает; что `WATCH/MULTI` — альтернатива, но дороже. - **Clock skew и источник времени**: использование серверного времени Redis (`TIME`) вместо клиентского, чтобы лимиты не «плыли» при рассинхроне часов между нодами. - **Redis Cluster нюансы**: hash tags для co-location, CROSSSLOT при multi-key Lua, почему реплики не масштабируют запись. - **Граница точность/память/латентность**: умеет ли осознанно выбрать алгоритм под требования, а не назвать один «правильный». - **Fail-open vs fail-closed** как продуктовое, а не техническое решение; rate limiter не должен быть SPOF; circuit breaker и таймауты на backend. - **Hot key / hot shard**: один шумный клиент бьёт в один слот; pre-throttle на ноде. - **Eventual consistency границ лимита**: честная оценка перерасхода при гибриде и когда это недопустимо (биллинг). - **Утечки памяти**: GC простаивающих бакетов in-memory, TTL для Redis-ключей; что будет с памятью при миллионах редких ключей. - **Несколько лимитов сразу** (per-second + per-day): как скомпоновать атомарно и эффективно (пайплайн, один Lua на несколько окон). - **Идемпотентность и точность списания**: списывать токен до или после обработки; что делать при ошибке upstream (возврат токена?).