0Pricing
DSA Interview Prep · Урок

Проектирование ограничителя частоты и ленты Twitter

Примените структуру к двум классическим задачам проектирования: ограничению частоты по алгоритмам token bucket и скользящего окна, а также ленте новостей с fan-out-on-write и fan-out-on-read

«Проектирование ограничителя частоты и ленты Twitter» — бесплатный урок DSA Interview Prep на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения DSA Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс DSA Interview Prep содержит 4 уроков всего.

Почему ограничение частоты запросов необходимо

Ограничение частоты запросов контролирует число запросов, которое клиент может отправить программному интерфейсу приложения за определённый промежуток времени. Без него один неисправный клиент (или атака типа отказа в обслуживании) может исчерпать ресурсы серверов и ухудшить обслуживание всех пользователей. Ограничение частоты запросов также защищает от атак перебором, препятствует массовому сбору данных и обеспечивает справедливое использование общих ресурсов.

Распространённые уровни ограничения: на пользователя по ID, на ключ программного интерфейса, на IP-адрес, на конечную точку или их комбинация. Типичные ограничения: 100 запросов в минуту на пользователя, 1000 запросов в час на ключ программного интерфейса. Ограничитель частоты запросов должен работать быстро (добавляя <1 мс накладных расходов) и быть распределённым (обеспечивать единые ограничения на всех репликах серверов приложения).

# Rate limiting scenarios
use_cases = [
    ('API authentication endpoint', '5 attempts per 15 min per IP', 'Brute-force protection'),
    ('Public search API',           '100 requests per minute per key', 'Fair use enforcement'),
    ('Email sending',               '50 emails per hour per user', 'Spam prevention'),
    ('Payment processing',          '10 transactions per second per account', 'Fraud prevention'),
    ('File upload',                 '5 uploads per minute per user', 'Resource quota'),
    ('Notification service',        '1000 pushes per second globally', 'Cost control'),
]
print(f'{'Endpoint/Feature':35s} {'Limit':40s} {'Reason'}')
print('-'*95)
for endpoint, limit, reason in use_cases:
    print(f'{endpoint:35s} {limit:40s} {reason}')

Алгоритм ограничения частоты запросов 1: корзина токенов

Алгоритм корзины токенов поддерживает корзину с максимальной вместимостью N токенов. Токены добавляются с фиксированной скоростью (например, 10 в секунду). Каждый запрос расходует один токен. Если корзина пуста, запрос отклоняется. Если корзина заполнена не полностью, запрос принимается, а токен расходуется.

Корзина токенов допускает всплески: если в течение 5 секунд не поступает запросов, корзина заполняется до N токенов, после чего N запросов могут поступить немедленно. Это подходит для программных интерфейсов приложения, где периодические всплески допустимы. Два параметра — вместимость (размер всплеска) и скорость пополнения.

import time

class TokenBucket:
    def __init__(self, capacity, refill_rate):
        self.capacity = capacity       # max tokens (burst size)
        self.refill_rate = refill_rate # tokens added per second
        self.tokens = capacity         # start full
        self.last_refill = time.time()

    def allow(self):
        now = time.time()
        elapsed = now - self.last_refill
        # Refill tokens based on elapsed time
        self.tokens = min(self.capacity,
                          self.tokens + elapsed * self.refill_rate)
        self.last_refill = now
        if self.tokens >= 1:
            self.tokens -= 1
            return True    # request allowed
        return False       # rate limited

bucket = TokenBucket(capacity=5, refill_rate=2)  # 2 tokens/sec, burst=5
for i in range(8):
    allowed = bucket.allow()
    print(f'Request {i+1}: {"ALLOWED" if allowed else "REJECTED"} (tokens={bucket.tokens:.1f})')
    time.sleep(0.1)  # 0.1s between requests

Алгоритм ограничения частоты запросов 2: журнал скользящего окна

Журнал скользящего окна хранит отметку времени для каждого запроса в отсортированном множестве. Для каждого нового запроса удалите отметки времени, которые старше начала окна, а затем проверьте, меньше ли количество оставшихся отметок установленного предела. Если да, добавьте текущую отметку времени и выполните allow; в противном случае отклоните запрос.

Этот метод точен: он учитывает ровно столько запросов, сколько было выполнено за последние N секунд. Недостаток — большой расход памяти (одна запись на запрос для каждого пользователя). При ограничении в 1000 запросов в минуту для 100 000 пользователей в худшем случае получится 100 млн записей журнала. Метод не подходит для очень высокого трафика, если не использовать горизонтальное разделение данных.

import time
from collections import deque

class SlidingWindowLog:
    def __init__(self, limit, window_seconds):
        self.limit = limit
        self.window = window_seconds
        self.logs = {}     # user_id -> deque of timestamps

    def allow(self, user_id):
        now = time.time()
        if user_id not in self.logs:
            self.logs[user_id] = deque()
        log = self.logs[user_id]
        window_start = now - self.window
        # Remove expired timestamps
        while log and log[0] <= window_start:
            log.popleft()
        # Check limit
        if len(log) < self.limit:
            log.append(now)
            return True
        return False

limiter = SlidingWindowLog(limit=3, window_seconds=10)
for i in range(5):
    allowed = limiter.allow('user123')
    print(f'Request {i+1}: {"ALLOWED" if allowed else "REJECTED"}')
    time.sleep(0.5)

Алгоритм ограничения частоты запросов 3: счётчик скользящего окна

Счётчик скользящего окна приближённо вычисляет скользящее окно с помощью двух сегментов — текущей и предыдущей минуты, — взвешенных в зависимости от того, какая доля текущей минуты уже прошла. Это уменьшает объём памяти с O(числа запросов) до O(1) на пользователя, сохраняя близкое приближение к точному числу запросов в окне.

Формула: estimated_count = prev_count × (1 - fraction_of_window_elapsed) + curr_count. Если это оценочное количество превышает предел, запрос отклоняется. Этот алгоритм используют Cloudflare и Kong в системах большого масштаба благодаря постоянному объёму памяти O(1) на пользователя и высокой точности.

import time
import math

class SlidingWindowCounter:
    def __init__(self, limit, window_seconds=60):
        self.limit = limit
        self.window = window_seconds
        self.buckets = {}   # user_id -> {prev_count, curr_count, curr_window_start}

    def allow(self, user_id):
        now = time.time()
        window_start = int(now // self.window) * self.window

        if user_id not in self.buckets or self.buckets[user_id]['window'] < window_start - self.window:
            self.buckets[user_id] = {'prev': 0, 'curr': 0, 'window': window_start}
        elif self.buckets[user_id]['window'] < window_start:
            self.buckets[user_id] = {'prev': self.buckets[user_id]['curr'], 'curr': 0, 'window': window_start}

        b = self.buckets[user_id]
        fraction = (now - window_start) / self.window
        estimated = b['prev'] * (1 - fraction) + b['curr']

        if estimated < self.limit:
            b['curr'] += 1
            return True
        return False

limiter = SlidingWindowCounter(limit=5, window_seconds=10)
for i in range(7):
    print(f'Request {i+1}: {"OK" if limiter.allow("user1") else "RATE LIMITED"}')
    time.sleep(0.3)

Распределённое ограничение частоты запросов с Redis

В распределённой системе с несколькими серверами приложения ограничение частоты запросов должно быть централизованным — иначе каждый сервер ведёт собственный счётчик, и ограничения фактически умножаются на количество серверов. Redis с атомарными операциями — стандартное решение: используйте INCR и EXPIRE для счётчика фиксированного окна или ZADD и ZCOUNT для журнала скользящего окна.

Подход со скриптами Lua делает несколько операций Redis атомарными и предотвращает состояния гонки, при которых два сервера одновременно увеличивают счётчик, находящийся чуть ниже предела. Redis выполняет скрипты Lua как одну команду, обеспечивая атомарность без распределённых блокировок.

# Distributed rate limiting with Redis (pseudocode / simulation)

# Fixed window counter using Redis INCR + EXPIRE
def redis_fixed_window(redis_client, user_id, limit, window_sec):
    key = f'rl:{user_id}:{int(time.time() // window_sec)}'
    count = redis_client.incr(key)          # atomic increment
    if count == 1:
        redis_client.expire(key, window_sec)  # set TTL on first request
    return count <= limit

# Sliding window with sorted set
def redis_sliding_window(redis_client, user_id, limit, window_sec):
    now = time.time()
    key = f'rl:{user_id}'
    # Remove old entries, count recent, add current
    # Atomic with Lua: multi-step operation
    lua_script = '''
    local key = KEYS[1]
    local now = ARGV[1]
    local window = ARGV[2]
    local limit = ARGV[3]
    redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
    local count = redis.call('ZCARD', key)
    if count < tonumber(limit) then
        redis.call('ZADD', key, now, now)
        redis.call('EXPIRE', key, window)
        return 1  -- allowed
    end
    return 0  -- rejected
    '''
    print('Redis Lua script ensures atomicity across ZREM + ZCARD + ZADD')

Проектирование ленты Twitter: требования

Рассмотрим проектирование системы новостной ленты в стиле Twitter. Функциональные требования: пользователи могут публиковать твиты (до 280 символов), подписываться на других пользователей и просматривать ленту твитов от пользователей, на которых они подписаны, в порядке от новых к старым. Нефункциональные требования: 300 млн ежедневно активных пользователей, 500 млн твитов в день, загрузка ленты менее чем за 2 секунды, соотношение чтений и записей ~100:1.

Оценка ёмкости: 500 млн твитов в день ÷ 86400 ≈ 5800 твитов в секунду. Чтений ≈ 580 тыс. в секунду. Каждый твит занимает примерно 300 байт; 500 млн × 300 байт = 150 GB новых данных о твитах в день. Агрегация ленты — главная инженерная задача.

# Twitter feed requirements and estimates
reqs = {
    'Functional': [
        'Post tweet (text, image, video)',
        'Follow/unfollow users',
        'View home feed (tweets from followees, newest first)',
        'View user timeline (all tweets by one user)',
        'Like and retweet',
        'Search tweets (basic keyword)',
    ],
    'Non-functional': [
        '300M DAU, 500M tweets/day => 5800 writes/sec',
        '100:1 read:write => 580K feed reads/sec',
        'Feed load < 2 seconds (p95)',
        '99.99% availability',
        'Tweets retained indefinitely (tweets never deleted by default)',
    ],
    'Estimates': [
        'Storage: 500M tweets * 300B = 150 GB/day, 54 TB/year',
        'Media: separate object store (S3), CDN-served',
        'Feed cache: 300M users * top-100-tweets * 100B = 3 TB (hot feeds in Redis)',
    ],
}
for category, items in reqs.items():
    print(f'{category}:')
    for item in items: print(f'  - {item}')
    print()

Распространение при записи: предварительно рассчитанные ленты

При распространении при записи, когда пользователь A публикует твит, система немедленно добавляет его в ленту каждого подписчика. Когда подписчик запрашивает свою ленту, она уже предварительно рассчитана и сохранена в Redis — это простое чтение списка Redis с O(k), где k — размер ленты (обычно не более 1000 твитов).

Проблема возникает со знаменитостями, у которых миллионы подписчиков: они создают огромные операции распространения. Публикация твита Justin Bieber требует одновременно записать его в ленты более 100 млн подписчиков — это реальная проблема, с которой столкнулся Twitter и которую назвали «проблемой знаменитостей». Сервис распространения записей должен работать асинхронно и на основе очереди, чтобы обрабатывать такие всплески.

# Fan-out on write (push model)
fan_out_steps = [
    '1. User posts tweet => write to tweets table (source of truth)',
    '2. Publish event to message queue (Kafka topic: tweet-created)',
    '3. Fan-out workers consume from queue:',
    '   a. Fetch list of followers from follows table',
    '   b. For each follower: LPUSH feed:{follower_id} tweet_id',
    '   c. Trim feed to last 1000 tweets: LTRIM feed:{follower_id} 0 999',
    '4. Feed read: LRANGE feed:{user_id} 0 99 => hydrate tweet_ids => response',
]
for step in fan_out_steps:
    print(step)

print('\nPros:')
print('  - Feed reads are O(1): just read from Redis list')
print('  - Feed is always sorted by recency automatically')
print('\nCons:')
print('  - Celebrities with 100M followers => 100M Redis writes per tweet')
print('  - Fan-out lag: followers may see tweet 10-30 seconds late at peak')
print('  - Inactive users waste Redis storage for precomputed feeds')

Гибридное распространение: решение проблемы знаменитостей

Гибридный подход объединяет распространение при записи для обычных пользователей и распространение при чтении для знаменитостей. Пользователь считается знаменитостью, если число его подписчиков превышает установленный порог (например, 1 миллион подписчиков). Для обычных пользователей твиты отправляются во все ленты подписчиков во время публикации. Для знаменитостей их твиты NOT отправляются; вместо этого при чтении ленты подписчиком система получает последние твиты знаменитости и объединяет их с предварительно рассчитанной лентой.

Эта гибридная модель близка к той, которую фактически использует Twitter. Объединение выполняется быстро, поскольку знаменитости публикуют твиты редко, а сложность объединения составляет O(f), где f — количество аккаунтов знаменитостей, на которые подписан пользователь (обычно небольшое).

# Hybrid fan-out implementation sketch
CELEBRITY_THRESHOLD = 1_000_000   # followers > 1M => celebrity

def on_post_tweet(user_id, tweet_id, follower_count):
    if follower_count <= CELEBRITY_THRESHOLD:
        # Fan-out to all followers (async via Kafka)
        print(f'User {user_id}: fan-out tweet {tweet_id} to {follower_count} followers')
        # => queue to fan-out workers
    else:
        print(f'Celebrity {user_id}: tweet {tweet_id} stored in timeline only')
        # => only write to tweets table + user timeline
        # => followers get it on demand when reading feed

def get_home_feed(user_id, followees):
    # 1. Get precomputed feed (fan-out on write tweets)
    precomputed = f'LRANGE feed:{user_id} 0 499'   # up to 500 tweets

    # 2. Find celebrity followees
    celebrity_followees = [u for u in followees if is_celebrity(u)]

    # 3. Fetch recent tweets from celebrities (fan-out on read)
    celebrity_tweets = []
    for celeb in celebrity_followees:
        tweets = f'GET tweets WHERE user_id={celeb} ORDER BY created_at DESC LIMIT 20'
        celebrity_tweets.extend(tweets)

    # 4. Merge and sort by recency
    combined = merge_and_sort(precomputed, celebrity_tweets)
    return combined[:100]

print('on_post_tweet for regular user:')
on_post_tweet('user123', 'tweet_abc', 500)
print('on_post_tweet for celebrity:')
on_post_tweet('celebrity456', 'tweet_xyz', 50_000_000)

Лента Twitter: полная архитектура

Полная архитектура ленты Twitter объединяет несколько систем:

  • Сервис твитов: записывает твиты в Cassandra (высокая пропускная способность записи, данные временных рядов)
  • Сервис распространения: асинхронные рабочие процессы (потребители Kafka), которые помещают ID твитов в ленты подписчиков в Redis
  • Сервис ленты: читает ленту из Redis, дополняет ID твитов полными объектами твитов и объединяет твиты знаменитостей
  • Сервис подписок: управляет социальным графом (кто на кого подписан) в графовой базе данных или разделённой реляционной базе данных
  • Сервис хроники: показывает собственные твиты пользователя (отдельно от домашней ленты)
# Twitter architecture summary
architecture = '''
[User] --> [API Gateway + Load Balancer]
                |
    +-----------+-----------+
    |           |           |
[Tweet Svc] [Feed Svc]  [Follow Svc]
    |           |           |
[Cassandra] [Redis Feeds] [Graph DB]
    |           |
[Kafka] <-- [Fan-out
    |        Workers]
[S3 + CDN]  (tweet_ids
(media)      => follower
             feed lists)

Key design choices:
- Tweets stored in Cassandra (PRIMARY KEY (user_id, created_at))
- Feed stored in Redis as list of tweet_ids per user (LPUSH/LTRIM/LRANGE)
- Fan-out via Kafka + workers (decoupled, retryable, scalable)
- Hybrid: regular users = push; celebrities = pull-on-read
- Hydration: tweet_ids -> full tweet objects via Cassandra read
'''
print(architecture)

Заголовки ограничителя частоты запросов и ответы об ошибках

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

  • X-RateLimit-Limit: максимальное число запросов, разрешённых в окне
  • X-RateLimit-Remaining: число запросов, оставшихся в текущем окне
  • X-RateLimit-Reset: метка времени Unix, когда окно сбрасывается
  • Retry-After: число секунд ожидания перед повторной попыткой (при ответе 429)

Код состояния протокола передачи гипертекста для ответов с ограничением частоты запросов — 429 Слишком много запросов.

# Rate limit response headers
def build_rate_limit_headers(limit, remaining, reset_timestamp, retry_after=None):
    headers = {
        'X-RateLimit-Limit': str(limit),
        'X-RateLimit-Remaining': str(max(0, remaining)),
        'X-RateLimit-Reset': str(int(reset_timestamp)),
    }
    if retry_after is not None:
        headers['Retry-After'] = str(retry_after)
    return headers

import time

# Simulated response for allowed request
headers = build_rate_limit_headers(
    limit=100,
    remaining=73,
    reset_timestamp=time.time() + 45
)
print('Allowed request headers:')
for k, v in headers.items():
    print(f'  {k}: {v}')

# Rate limited response
headers_429 = build_rate_limit_headers(
    limit=100,
    remaining=0,
    reset_timestamp=time.time() + 30,
    retry_after=30
)
print('\n429 Too Many Requests headers:')
for k, v in headers_429.items():
    print(f'  {k}: {v}')

Сравнение алгоритмов ограничения частоты запросов

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

  • Корзина токенов: допускает всплески и обеспечивает равномерное пополнение. Лучше всего подходит для программных интерфейсов, где допустимы периодические всплески (самый распространённый выбор).
  • Ведро с утечкой: обрабатывает запросы с фиксированной скоростью выдачи независимо от всплесков. Лучше всего подходит для формирования трафика в постоянный поток.
  • Счётчик фиксированного окна: самый простой вариант, O(1) по памяти. Проблема: всплеск, вдвое превышающий лимит, на границе окна (например, 100 в 11:59 + 100 в 12:00).
  • Журнал скользящего окна: самый точный вариант, без скачка на границе. Проблема: память O(запросов).
  • Счётчик скользящего окна: приближённо заменяет журнал скользящего окна, используя O(1) памяти. Используется Cloudflare.
# Algorithm comparison matrix
comparison = [
    ('Token Bucket',          'Allows bursts',     'O(1)',         'Most APIs, default choice'),
    ('Leaky Bucket',          'Smooth output rate', 'O(1)',         'Traffic shaping, message queues'),
    ('Fixed Window Counter',  'Very simple',        'O(1)',         'Low-traffic, approximate OK'),
    ('Sliding Window Log',    'Most accurate',      'O(requests)', 'High-accuracy, low traffic'),
    ('Sliding Window Counter','Approximate+fast',   'O(1)',         'High-traffic, Cloudflare-style'),
]
print(f'{'Algorithm':30s} {'Burst Handling':20s} {'Memory':15s} {'Use Case'}')
print('-'*85)
for name, burst, mem, use in comparison:
    print(f'{name:30s} {burst:20s} {mem:15s} {use}')

Быстрая проверка

Проверьте, насколько хорошо Вы усвоили изложенные в этом уроке концепции курса «Структуры данных и алгоритмы — подготовка к собеседованию по программированию».

Итоги урока

В этом уроке Вы узнали: ограничители частоты запросов используют корзину токенов (допускает всплески), счётчик скользящего окна (память O(1)) или журнал скользящего окна (самый точный вариант) для управления частотой запросов, а атомарные операции Redis позволяют ограничивать частоту запросов в распределённой системе, лента Twitter использует распределение при записи, чтобы заранее формировать ленты подписчиков в Redis для быстрого чтения, а для аккаунтов знаменитостей применяется гибридная модель чтения, чтобы избежать огромного увеличения объёма записей. Далее мы переходим к заключительному разделу — шпаргалке по распознаванию паттернов, связывающей признаки задач с алгоритмическими паттернами, которые быстрее всего их решают.

Часто задаваемые вопросы

Урок «Проектирование ограничителя частоты и ленты Twitter» бесплатный?

Да — полный текст урока «Проектирование ограничителя частоты и ленты Twitter» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс DSA Interview Prep, подпишись на CoddyKit PRO. Курс DSA Interview Prep содержит 4 уроков всего.

Чему я научусь в уроке «Проектирование ограничителя частоты и ленты Twitter»?

Примените структуру к двум классическим задачам проектирования: ограничению частоты по алгоритмам token bucket и скользящего окна, а также ленте новостей с fan-out-on-write и fan-out-on-read Ты практикуешь DSA Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать DSA Interview Prep?

Предыдущий опыт не требуется. DSA Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 4 из 4.

Сколько времени занимает урок «Проектирование ограничителя частоты и ленты Twitter»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке DSA Interview Prep?

Да. Каждый урок DSA Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Структура собеседования по проектированию систем
  2. Масштабируемое хранение данных: SQL и NoSQL
  3. Кэширование, CDN и балансировка нагрузки
  4. Проектирование ограничителя частоты и ленты Twitter
← Назад к DSA Interview Prep