Projektowanie ogranicznika przepustowości i kanału Twittera
Stosować schemat do dwóch klasycznych problemów projektowych: ograniczania przepustowości token-bucket/sliding-window oraz kanału aktualności z fan-out-on-write i fan-out-on-read
Projektowanie ogranicznika przepustowości i kanału Twittera to bezpłatna lekcja DSA Interview Prep na CoddyKit. To lekcja 4 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej DSA Interview Prep, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs DSA Interview Prep zawiera 4 lekcji w sumie.
Dlaczego ograniczanie liczby żądań jest niezbędne
Ograniczanie liczby żądań kontroluje liczbę żądań, jakie klient może wysłać do API w określonym oknie czasowym. Bez tego pojedynczy nieprawidłowo działający klient (lub atak DDoS) może wyczerpać zasoby serwera, pogarszając jakość usługi dla wszystkich użytkowników. Ograniczanie liczby żądań chroni również przed atakami siłowymi, zapobiega scrapowaniu API i wymusza uczciwe korzystanie ze współdzielonych zasobów.
Najczęstsze poziomy limitowania: na identyfikator użytkownika, na klucz API, na adres IP, na endpoint lub ich kombinacja. Typowe limity to 100 żądań na minutę na użytkownika i 1000 na godzinę na klucz API. Ogranicznik musi działać szybko (narzut <1ms) i być rozproszony (spójny na wszystkich replikach serwerów API).
# 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}')Algorytm ograniczania liczby żądań 1: Token Bucket
Algorytm token bucket utrzymuje wiadro o maksymalnej pojemności N tokenów. Tokeny są dodawane ze stałą częstotliwością (np. 10 na sekundę). Każde żądanie zużywa jeden token. Jeśli wiadro jest puste, żądanie zostaje odrzucone. Jeśli liczba tokenów jest niższa od pojemności, żądanie zostaje zaakceptowane, a token zostaje zużyty.
Token bucket pozwala obsługiwać serie żądań (bursty): jeśli przez 5 sekund nie nadejdą żadne żądania, wiadro wypełni się do N tokenów, a następnie N żądań może nadejść natychmiast. Jest to odpowiednie dla API, w których sporadyczne nagłe zwiększenie ruchu jest akceptowalne. Dwa parametry to pojemność (rozmiar serii) i częstotliwość uzupełniania.
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 requestsAlgorytm ograniczania liczby żądań 2: log przesuwnego okna
Log przesuwnego okna przechowuje znacznik czasu każdego żądania w posortowanym zbiorze. Dla każdego nowego żądania usuwa znaczniki czasu starsze niż początek okna, a następnie sprawdza, czy liczba pozostałych znaczników jest niższa od limitu. Jeśli tak, dodaje bieżący znacznik czasu i zezwala na żądanie; w przeciwnym razie je odrzuca.
To rozwiązanie jest precyzyjne — zlicza dokładnie, ile żądań wystąpiło w ciągu ostatnich N sekund. Kompromisem jest wysokie zużycie pamięci (jeden wpis na żądanie na użytkownika). Przy limicie 1000 żądań na minutę i 100 tys. użytkowników najgorszy przypadek to 100 mln wpisów w logu. Nie nadaje się do bardzo dużego ruchu, chyba że zostanie połączone z partycjonowaniem (shardingiem).
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)Algorytm ograniczania liczby żądań 3: licznik przesuwnego okna
Licznik przesuwnego okna przybliża działanie przesuwnego okna za pomocą dwóch kubełków — bieżącej i poprzedniej minuty — ważonych zależnie od tego, jak daleko jesteśmy w bieżącej minucie. Zmniejsza to zużycie pamięci z O(requests) do O(1) na użytkownika, jednocześnie dokładnie przybliżając wynik zliczania w przesuwanym oknie.
Wzór: estimated_count = prev_count × (1 - fraction_of_window_elapsed) + curr_count. Jeśli oszacowana liczba przekracza limit, żądanie zostaje odrzucone. Tego algorytmu używają Cloudflare i Kong na dużą skalę ze względu na zużycie pamięci O(1) na użytkownika i wysoką dokładność.
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)Rozproszone ograniczanie liczby żądań za pomocą Redis
W systemie rozproszonym z wieloma serwerami aplikacji ograniczanie liczby żądań musi być scentralizowane — w przeciwnym razie każdy serwer śledzi własny licznik, a limity są w praktyce mnożone przez liczbę serwerów. Redis z operacjami atomowymi to standardowe rozwiązanie: użyj INCR i EXPIRE dla licznika stałego okna albo ZADD i ZCOUNT dla logu przesuwnego okna.
Podejście oparte na skrypcie Lua sprawia, że wiele operacji Redis jest atomowych, zapobiegając wyścigom, w których dwa serwery jednocześnie zwiększają licznik, gdy jest on jeszcze nieco poniżej limitu. Redis wykonuje skrypty Lua jako pojedyncze polecenie, zapewniając atomowość bez użycia blokad rozproszonych.
# 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')Projektowanie kanału Twittera: wymagania
Zaprojektujmy system kanału aktualności podobnego do Twittera. Wymagania funkcjonalne: użytkownicy mogą publikować tweety (do 280 znaków), obserwować innych użytkowników i wyświetlać kanał tweetów publikowanych przez obserwowane osoby, uporządkowany od najnowszych. Wymagania niefunkcjonalne: 300 mln aktywnych użytkowników dziennie, 500 mln tweetów dziennie, czas ładowania kanału poniżej 2 sekund, stosunek odczytów do zapisów około 100:1.
Szacunki pojemności: 500 mln tweetów dziennie ÷ 86400 ≈ 5800 tweetów na sekundę. Odczyty ≈ 580 tys. na sekundę. Każdy tweet ma około 300 bajtów; 500 mln × 300 B = 150 GB nowych danych tweetów dziennie. Agregacja kanału jest głównym wyzwaniem inżynieryjnym.
# 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()Fan-out podczas zapisu: wstępnie obliczone kanały
W przypadku fan-out podczas zapisu, gdy użytkownik A publikuje tweeta, system natychmiast rozsyła go do kanału każdego obserwującego. Gdy obserwujący żąda swojego kanału, jest on już wstępnie obliczony i zapisany w Redis — wystarczy prosty odczyt listy Redis o złożoności O(k), gdzie k oznacza rozmiar kanału (zwykle ograniczony do 1000 tweetów).
Wyzwaniem są celebryci mający miliony obserwujących, ponieważ generują ogromne operacje fan-out. Publikacja tweeta przez Justina Biebera wymaga jednoczesnego zapisania go do kanałów ponad 100 mln obserwujących — był to rzeczywisty problem, z którym mierzył się Twitter, nazwany „problemem celebrytów”. Usługa fan-out podczas zapisu musi działać asynchronicznie i korzystać z kolejek, aby obsługiwać takie skoki obciążenia.
# 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')Hybrydowy fan-out: rozwiązanie problemu celebrytów
Podejście hybrydowe łączy fan-out podczas zapisu dla zwykłych użytkowników z fan-out podczas odczytu dla celebrytów. Użytkownik jest uznawany za celebrytę, jeśli liczba jego obserwujących przekracza określony próg (np. 1 milion obserwujących). W przypadku zwykłych użytkowników tweety są rozsyłane do wszystkich kanałów obserwujących w momencie publikacji. W przypadku celebrytów ich tweety NIE są rozsyłane; zamiast tego, gdy obserwujący wyświetla swój kanał, system pobiera najnowsze tweety celebryty i scala je ze wstępnie obliczonym kanałem.
Ten model hybrydowy jest zbliżony do rozwiązania faktycznie używanego przez Twitter. Etap scalania jest szybki, ponieważ celebryci publikują rzadko, a scalanie ma złożoność O(f), gdzie f oznacza liczbę kont celebrytów obserwowanych przez użytkownika (zwykle niewielką).
# 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)Kompletna architektura kanału Twittera
Kompletna architektura kanału Twittera łączy kilka systemów:
- Usługa tweetów: zapisuje tweety w Cassandra (duża przepustowość zapisu, dane szeregów czasowych)
- Usługa fan-out: asynchroniczne procesy robocze (konsumenci Kafka), które dodają identyfikatory tweetów do kanałów obserwujących w Redis
- Usługa kanału: odczytuje kanał z Redis, pobiera pełne obiekty tweetów na podstawie identyfikatorów i scala tweety celebrytów
- Usługa obserwowania: zarządza grafem społecznościowym (kto kogo obserwuje) w bazie grafowej lub partycjonowanej bazie SQL
- Usługa osi czasu: udostępnia własne tweety użytkownika (oddzielnie od kanału głównego)
# 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)Nagłówki i odpowiedzi błędów ogranicznika liczby żądań
Dobrze zaprojektowany ogranicznik liczby żądań komunikuje klientom limity za pomocą nagłówków odpowiedzi HTTP. Dzięki temu klienci mogą zaimplementować logikę ponawiania, a panele mogą wyświetlać wykorzystanie limitu. Standardowe nagłówki:
X-RateLimit-Limit: maksymalna liczba żądań dozwolonych w oknieX-RateLimit-Remaining: liczba żądań pozostałych w bieżącym oknieX-RateLimit-Reset: uniksowy znacznik czasu resetowania oknaRetry-After: liczba sekund oczekiwania przed ponowieniem (w odpowiedzi 429)
Kodem statusu HTTP dla odpowiedzi odrzuconych z powodu przekroczenia limitu jest 429 Too Many Requests.
# 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}')Porównanie algorytmów ograniczania częstotliwości żądań
Podsumowujące porównanie wszystkich algorytmów ograniczania częstotliwości żądań, które pomoże Państwu dokonać wyboru podczas rozmów rekrutacyjnych:
- Token Bucket: umożliwia obsługę nagłych skoków ruchu i zapewnia płynne tempo uzupełniania. Najlepszy w przypadku interfejsów API, w których sporadyczne skoki ruchu są akceptowalne (najczęściej wybierane rozwiązanie).
- Leaky Bucket: przetwarza żądania ze stałą szybkością wyjściową, niezależnie od skoku ruchu. Najlepszy do kształtowania ruchu tak, aby tworzył stały strumień.
- Fixed Window Counter: najprostszy i wymagający stałej ilości pamięci O(1). Problemem jest skok na poziomie dwukrotności limitu na granicy okna (np. 100 o 11:59 + 100 o 12:00).
- Sliding Window Log: najdokładniejszy, bez skoków na granicy okna. Problemem jest zużycie pamięci O(liczba żądań).
- Sliding Window Counter: przybliża działanie logu przesuwnego, wymagając stałej ilości pamięci O(1). Jest używany przez 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}')Szybki test
Sprawdźcie Państwo swoją wiedzę na temat zagadnień Data Structures & Algorithms — Coding Interview Prep z tej lekcji.
Podsumowanie lekcji
W tej lekcji nauczyli się Państwo, że: mechanizmy ograniczania częstotliwości żądań korzystają z algorytmu Token Bucket (umożliwia obsługę nagłych skoków ruchu), licznika przesuwnego okna (pamięć O(1)) lub logu przesuwnego okna (największa dokładność) do kontrolowania częstotliwości żądań, a operacje atomowe Redis umożliwiają rozproszone ograniczanie częstotliwości żądań, a także że kanał aktualności Twittera korzysta z mechanizmu fan-out podczas zapisu, aby wstępnie obliczać kanały obserwujących w Redisie i zapewniać szybki odczyt, używając hybrydowego modelu pull dla kont o dużej liczbie obserwujących, aby uniknąć ogromnego wzrostu liczby operacji zapisu. Następnie przejdziemy do sekcji podsumowującej, w której znajdzie się ściągawka dotycząca rozpoznawania wzorców. Mapuje ona sygnały zadań na wzorce algorytmiczne, które pozwalają najszybciej je rozwiązywać.
Często zadawane pytania
Czy lekcja „Projektowanie ogranicznika przepustowości i kanału Twittera” jest bezpłatna?
Tak — pełny tekst „Projektowanie ogranicznika przepustowości i kanału Twittera” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu DSA Interview Prep, przejdź na CoddyKit PRO. Kurs DSA Interview Prep zawiera 4 lekcji w sumie.
Co nauczysz się w „Projektowanie ogranicznika przepustowości i kanału Twittera”?
Stosować schemat do dwóch klasycznych problemów projektowych: ograniczania przepustowości token-bucket/sliding-window oraz kanału aktualności z fan-out-on-write i fan-out-on-read Ćwiczysz DSA Interview Prep z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.
Czy potrzebuję doświadczenia, aby zacząć DSA Interview Prep?
Nie wymagamy żadnego doświadczenia. DSA Interview Prep w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 4 z 4.
Ile czasu zajmuje lekcja „Projektowanie ogranicznika przepustowości i kanału Twittera”?
Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.
Czy mogę pisać i uruchamiać kod w tej lekcji DSA Interview Prep?
Tak. Każda lekcja DSA Interview Prep zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.
Wszystkie lekcje w tym kursie
- Schemat rozmowy technicznej o projektowaniu systemów
- Skalowalne przechowywanie danych: SQL a NoSQL
- Buforowanie, CDN-y i równoważenie obciążenia
- Projektowanie ogranicznika przepustowości i kanału Twittera