0Pricing
DSA Interview Prep · レッスン

Rate Limiterの設計とTwitterフィードの設計

このフレームワークを、token-bucket/sliding-window方式のレート制限と、fan-out-on-write対fan-out-on-read方式のニュースフィードという2つの典型的な設計問題に適用します。

「Rate Limiterの設計とTwitterフィードの設計」はCoddyKit上の無料DSA Interview Prepレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはDSA Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 DSA Interview Prepコースには全4レッスンが含まれています。

レートリミットが不可欠な理由

レート制限は、一定の時間枠内でクライアントがAPIに送信できるリクエスト数を制御します。レート制限がないと、1つの問題のあるクライアント(またはDDoS攻撃)によってサーバーリソースが飽和し、すべてのユーザーのサービス品質が低下する可能性があります。レート制限は、ブルートフォース攻撃からの保護、APIスクレイピングの防止、共有リソースの公平な利用の確保にも役立ちます。

レート制限の一般的な単位には、ユーザーID単位、APIキー単位、IPアドレス単位、エンドポイント単位、またはそれらの組み合わせがあります。典型的な制限は、ユーザーごとに1分あたり100リクエスト、APIキーごとに1時間あたり1000リクエストです。レートリミッターは高速で(オーバーヘッドを<1msに抑え)、分散構成にする必要があります(すべての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}')

レート制限アルゴリズム1:トークンバケット

トークンバケットアルゴリズムは、最大容量N個のトークンを保持するバケットを管理します。トークンは一定の割合(例:1秒あたり10個)で追加されます。各リクエストはトークンを1個消費します。バケットが空の場合、リクエストは拒否されます。容量に達していなければ、リクエストは受け付けられ、トークンが消費されます。

トークンバケットではバーストが許容されます。5秒間リクエストが来なければ、バケットはN個までトークンで満たされ、その後N個のリクエストをすぐに受け付けられます。これは、時折バーストが発生しても問題ないAPIに適しています。パラメーターは、容量(バーストサイズ)と補充レートの2つです。

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:スライディングウィンドウログ

スライディングウィンドウログは、すべてのリクエストのタイムスタンプをソート済みセットに保存します。新しいリクエストごとに、ウィンドウの開始時刻より古いタイムスタンプを削除し、残ったタイムスタンプ数が制限値未満か確認します。未満であれば現在のタイムスタンプを追加して許可し、そうでなければ拒否します。

この方法は、直近N秒間に発生したリクエスト数を正確に数えられます。一方で、ユーザーごとにリクエスト1件につき1エントリを保存するため、メモリ使用量が大きくなります。10万ユーザーに対して1分あたり1000リクエストという制限を設ける場合、最悪のケースでは1億件のログエントリになります。シャーディングと組み合わせない限り、非常にトラフィックの多い環境には適していません。

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:スライディングウィンドウカウンター

スライディングウィンドウカウンターは、現在の分と前の分という2つのバケットを使い、現在の分のどこまで進んでいるかに応じて重み付けすることで、スライディングウィンドウを近似します。これにより、メモリ使用量をO(requests)からユーザーあたりO(1)に削減しながら、正確なスライディングウィンドウのカウントを高い精度で近似できます。

計算式は次のとおりです:estimated_count = prev_count × (1 - fraction_of_window_elapsed) + curr_count。この推定カウントが制限値を超えた場合は拒否します。ユーザーあたりのメモリ使用量がO(1)で精度も高いため、大規模環境ではCloudflareやKongがこのアルゴリズムを使用しています。

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操作をアトミックに実行でき、2台のサーバーが制限値をわずかに下回る状態で同時にインクリメントするような競合状態を防げます。RedisはLuaスクリプトを1つのコマンドとして処理するため、分散ロックなしでアトミック性を保証できます。

# 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文字)を投稿できること、他のユーザーをフォローできること、フォローしているユーザーのツイートを新しい順に表示するフィードを閲覧できることです。非機能要件は、日次アクティブユーザー3億人、1日5億ツイート、フィードの読み込み時間2秒未満、読み取りと書き込みの比率約100:1です。

キャパシティの見積もりは次のとおりです。1日5億ツイート ÷ 86400 ≈ 毎秒5800ツイートです。読み取りは毎秒約58万件です。1ツイートを約300バイトとすると、5億 × 300B = 1日あたり150GBの新しいツイートストレージが必要です。フィードの集約が、中心となるエンジニアリング上の課題です。

# 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に保存されています。そのため、通常は最大1000ツイートに制限されるフィードサイズをkとすると、O(k)の単純なRedisリスト読み取りで取得できます。

課題は、数百万人のフォロワーを持つ著名人が大規模なファンアウト処理を発生させることです。Justin Bieberがツイートを投稿すると、1億人以上のフォロワーのフィードに同時に書き込む必要があります。これは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')

ハイブリッドファンアウト:著名人問題の解決

ハイブリッド方式では、一般ユーザーには書き込み時ファンアウトを、著名人には読み取り時ファンアウトを使用します。フォロワー数が基準値(例:100万人)を超えるユーザーを著名人として分類します。一般ユーザーの場合、投稿時にツイートをすべてのフォロワーのフィードへプッシュします。著名人の場合、ツイートはプッシュしません。代わりにフォロワーがフィードを読むとき、システムが著名人の最近のツイートを取得し、事前計算済みのフィードとマージします。

このハイブリッドモデルは、Twitterが実際に採用している方式に近いものです。著名人は投稿頻度が低く、マージ対象となる著名人アカウント数fも通常は少ないため、マージ処理は高速で、計算量はO(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を完全なツイートオブジェクトに展開し、著名人のツイートをマージします
  • フォローサービス:グラフDBまたはシャーディングしたSQLでソーシャルグラフ(誰が誰をフォローしているか)を管理します
  • タイムラインサービス:ユーザー自身のツイートを提供します(ホームフィードとは別です)
# 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)

レートリミッターのヘッダーとエラーレスポンス

適切に設計されたレートリミッターは、HTTPレスポンスヘッダーを通じて制限値をクライアントに伝えます。これにより、クライアントは再試行までの待機処理を実装でき、ダッシュボードには使用状況を表示できます。標準的なヘッダーは次のとおりです。

  • X-RateLimit-Limit:時間枠内で許可される最大リクエスト数
  • X-RateLimit-Remaining:現在の時間枠に残っているリクエスト数
  • X-RateLimit-Reset:時間枠がリセットされる時刻を表すUnixタイムスタンプ
  • Retry-After:再試行まで待機する秒数(429レスポンスの場合)

レート制限されたレスポンスに使用するHTTPステータスコードは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}')

レート制限アルゴリズムの比較

面接での選択に役立つ、すべてのレート制限アルゴリズムの概要比較です。

  • トークンバケット:バーストを許容し、一定の速度で補充します。ときどきバーストが発生しても問題ないAPIに最適です(最も一般的な選択肢です)。
  • リーキーバケット:バーストの有無にかかわらず、一定の出力レートでリクエストを処理します。トラフィックを一定の流れに整形する場合に最適です。
  • 固定ウィンドウカウンター:最も単純で、空間計算量はO(1)です。問題点は、ウィンドウの境界で制限の2倍のバーストが発生することです(例:11:59に100件、12:00に100件)。
  • スライディングウィンドウログ:最も正確で、境界でのスパイクが発生しません。問題点は、メモリ使用量がO(requests)になることです。
  • スライディングウィンドウカウンター:空間計算量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}')

理解度チェック

このレッスンで扱ったData Structures & Algorithms — Coding Interview Prepの概念について、理解度を確認しましょう。

レッスンのまとめ

このレッスンでは、レートリミッターでは、バーストを許容するトークンバケット、メモリ使用量がO(1)のスライディングウィンドウカウンター、または最も正確なスライディングウィンドウログを使ってリクエストレートを制御し、Redisのアトミック操作によって分散レート制限を実現すること、Twitterのフィードでは、書き込み時のファンアウトを使ってフォロワーのフィードをRedisに事前計算し、高速な読み取りを実現していること。ただし、有名アカウントでは大規模な書き込み増幅を避けるため、ハイブリッドなプルモデルを使っていることを学びました。次は、本題のまとめとして、問題のシグナルと、それらを最も速く解決できるアルゴリズムパターンを対応付けたパターン認識チートシートに進みます。

よくある質問

「Rate Limiterの設計とTwitterフィードの設計」レッスンは無料ですか?

はい。「Rate Limiterの設計とTwitterフィードの設計」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、DSA Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 DSA Interview Prepコースには全4レッスンが含まれています。

「Rate Limiterの設計とTwitterフィードの設計」で何を学びますか?

このフレームワークを、token-bucket/sliding-window方式のレート制限と、fan-out-on-write対fan-out-on-read方式のニュースフィードという2つの典型的な設計問題に適用します。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

DSA Interview Prepを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのDSA Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「Rate Limiterの設計とTwitterフィードの設計」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このDSA Interview Prepレッスンでコードを書いて実行できますか?

はい。すべてのDSA Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. システム設計面接のフレームワーク
  2. スケーラブルなデータストレージ:SQLとNoSQL
  3. キャッシュ、CDN、ロードバランシング
  4. Rate Limiterの設計とTwitterフィードの設計
← DSA Interview Prepに戻る