キャッシュ、CDN、ロードバランシング
Redisのキャッシュ層を追加し、静的アセットをCDNへ配置して、ラウンドロビンとコンシステントハッシュを使うロードバランサーでレプリカ間にトラフィックを分散します。
「キャッシュ、CDN、ロードバランシング」はCoddyKit上の無料Coding Interview Prepレッスンです。 これはレッスン3/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはCoding Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Coding Interview Prepコースには全4レッスンが含まれています。
大規模環境でキャッシュが不可欠な理由
キャッシュは、頻繁にアクセスされるデータのコピーをより高速なストレージレイヤーに保存し、以降のリクエストを低速なバックエンドストア(データベースや外部API)にアクセスせずに処理できるようにします。大規模環境では、少数の人気アイテムがリクエストの大半を受け取ります。ここでは80/20の法則(パレートの法則)がよく当てはまり、アイテムの20%がトラフィックの80%を占めます。
ホットな20%をメモリに収められるキャッシュは、データベースの負荷の80%を吸収できます。そのため、Redisキャッシュを追加するだけで、データベースやアプリケーションロジックを大きく変更せずに、データベースのCPU使用率を70~90%削減し、キャッシュヒット時のp99レイテンシを10msから1ms未満に短縮できることがよくあります。
# Demonstrating the 80/20 caching benefit
import random
# Simulate 1000 requests to 100 items with Zipf-like distribution
def zipf_sample(n_items, n_requests):
access_counts = {}
weights = [1.0 / (i + 1) for i in range(n_items)] # Zipf: item 0 most popular
total = sum(weights)
probs = [w / total for w in weights]
for _ in range(n_requests):
item = random.choices(range(n_items), weights=probs)[0]
access_counts[item] = access_counts.get(item, 0) + 1
return access_counts
random.seed(42)
counts = zipf_sample(100, 10000)
top_20_items = sorted(counts, key=counts.get, reverse=True)[:20]
top_20_requests = sum(counts[i] for i in top_20_items)
print(f'Top 20% of items ({20} of 100) handle {top_20_requests/100:.1f}% of requests')Cache-Asideパターン(遅延ロード)
Cache-Asideパターン(遅延ロードとも呼ばれます)は、最も一般的なキャッシュ戦略です。キャッシュの管理はアプリケーションコードが担当します。読み取り時は、まずキャッシュを確認します。キャッシュヒットなら、すぐに結果を返します。キャッシュミスなら、データベースから取得してキャッシュに書き込み、その後に結果を返します。書き込み時は、データベースを更新してキャッシュエントリを無効化(削除)し、次の読み取りで更新されるようにします。
このパターンでは、実際にリクエストされたデータだけがキャッシュに保持されるため、不要な事前ロードを避けられます。また、無効化によってデータベースとの整合性も維持できます。トレードオフとして、キャッシュミス後の最初のアクセスでは、データベースへのアクセスコストをすべて負担します(コールドスタート)。
# Cache-aside pattern in Python
class CacheAsideService:
def __init__(self, db, cache):
self.db = db
self.cache = cache # e.g., Redis client
def get_user(self, user_id):
cache_key = f'user:{user_id}'
# 1. Check cache
cached = self.cache.get(cache_key)
if cached:
return cached # cache hit
# 2. Cache miss: fetch from DB
user = self.db.query('SELECT * FROM users WHERE id=%s', user_id)
# 3. Write to cache with TTL
self.cache.set(cache_key, user, ttl=3600) # 1 hour TTL
return user
def update_user(self, user_id, data):
# 1. Write to DB
self.db.execute('UPDATE users SET ... WHERE id=%s', user_id, data)
# 2. Invalidate cache (delete, not update)
self.cache.delete(f'user:{user_id}')
# Next read will re-populate cache from DB
print('Cache-aside: READ from cache, miss? load from DB + write cache')
print(' WRITE to DB, then DELETE from cache (invalidate)')Write-ThroughとWrite-Behindキャッシュ
Write-Through:書き込みのたびに、データベースとキャッシュの両方を同期的に更新します。キャッシュには常に最新のデータが含まれます。トレードオフとして、2つの操作が必要なため書き込みが遅くなり、再び読み取られることのないデータでキャッシュが埋まる可能性があります。
Write-Behind(Write-Back):書き込み時はキャッシュだけを更新し、後で非同期にデータベースへ書き出します。書き込みは非常に高速になりますが、書き出し前にキャッシュが障害を起こすとデータが失われる危険があります。一部のデータ損失が許容される、書き込みの多いワークロード(閲覧数カウンターや分析など)で使用されます。
# Write-through vs Write-behind comparison
strategies = {
'Cache-aside (Lazy)': {
'read': 'Check cache; miss => DB + populate cache',
'write': 'Write DB; delete from cache (invalidate)',
'consistency': 'Strong (invalidation ensures freshness)',
'write_latency': 'Fast (one DB write)',
'risk': 'Cache stampede on popular key expiry',
},
'Write-through': {
'read': 'Always check cache; miss => DB',
'write': 'Write DB AND cache atomically',
'consistency': 'Strong (cache always has latest)',
'write_latency': 'Slower (two writes per operation)',
'risk': 'Cache polluted with rarely-read data',
},
'Write-behind': {
'read': 'Check cache; miss => DB',
'write': 'Write cache only; async flush to DB',
'consistency': 'Eventual (flush may be delayed)',
'write_latency': 'Very fast (cache write only)',
'risk': 'Data loss if cache crashes before flush',
},
}
for name, info in strategies.items():
print(f'\n{name}:')
for k, v in info.items(): print(f' {k}: {v}')キャッシュのエビクションポリシー
キャッシュが満杯になると、エビクションポリシーによって削除するエントリが決まります。代表的なポリシーは次のとおりです。
- LRU(Least Recently Used):最も長い間アクセスされていないエントリを削除します。時間的局所性のあるワークロードで高い性能を発揮します。Redisのデフォルトで使用されています。
- LFU(Least Frequently Used):アクセス回数が最も少ないエントリを削除します。一部のアイテムが恒常的に人気で、LRUではその傾向を捉えにくいワークロードに適しています。
- FIFO:最も古く挿入されたエントリを削除します。単純ですが、一般的なWebワークロードでは性能がよくありません。
- Random:ランダムなエントリを削除します。非常に大規模なキャッシュでは、実際にはLRUと同等の競争力を示すことがあります。
# Implementing LRU cache
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.cache = OrderedDict() # maintains insertion/access order
def get(self, key):
if key not in self.cache:
return -1
self.cache.move_to_end(key) # mark as recently used
return self.cache[key]
def put(self, key, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.capacity:
self.cache.popitem(last=False) # evict LRU (oldest)
cache = LRUCache(3)
for k, v in [('a',1),('b',2),('c',3)]:
cache.put(k, v)
print('Get a:', cache.get('a')) # 1 (a now most recently used)
cache.put('d', 4) # evicts 'b' (LRU)
print('Get b:', cache.get('b')) # -1 (evicted)
print('Get c:', cache.get('c')) # 3コンテンツ配信ネットワーク(CDN)
CDNは、エンドユーザーの近くで静的および動的コンテンツをキャッシュする、地理的に分散されたエッジサーバー(Points of Presence、PoP)のネットワークです。すべてのユーザーのリクエストが1か所のデータセンターにあるオリジンサーバーまで移動する代わりに、CDNのエッジノードが最寄りのPoPからコンテンツを配信します。これにより、レイテンシーを約200ミリ秒(大陸間)から約5ミリ秒(近隣のPoP)まで削減できます。
CDNは、静的アセット(画像、CSS、JS)、動画ストリーミング(HLSセグメント)、さらに近年ではAPIレスポンスやサーバーサイドでレンダリングされたHTMLにも不可欠です。CDNはエッジキャッシュを確認し、キャッシュミスが発生するとオリジンからコンテンツを取得して、今後のリクエストに備えてキャッシュします。
# CDN architecture flow
cdn_flow = [
'User requests https://example.com/image.jpg',
'DNS resolves to the nearest CDN PoP (e.g., Frankfurt for EU users)',
'CDN edge checks its local cache:',
' HIT: Return cached image directly (5ms latency)',
' MISS: Fetch from origin server (e.g., AWS S3 in us-east-1)',
' Cache image at edge with Cache-Control: max-age=86400',
' Future requests for this image served from edge (HIT)',
'Cache-Control headers control CDN behaviour:',
' max-age=31536000 s-maxage=31536000 -- cache 1 year',
' no-cache -- always revalidate',
' private -- CDN must not cache (user-specific)',
]
for step in cdn_flow:
print(step)
print('\nCDN providers: Cloudflare, AWS CloudFront, Fastly, Akamai')負荷分散:トラフィックの分散
ロードバランサーは、受信したリクエストを複数のバックエンドサーバーに分散し、特定のサーバーだけがボトルネックになることを防ぎます。また、高可用性も提供します。1台のサーバーに障害が発生すると、ロードバランサーが正常なサーバーへ自動的にトラフィックをルーティングします(ヘルスチェックは5〜30秒ごとに実行されます)。
ロードバランサーは、異なるOSIレイヤーで動作します。レイヤー4(トランスポート層 — IPアドレスとポートに基づいてルーティングするため非常に高速)と、レイヤー7(アプリケーション層 — URLパス、ヘッダー、Cookieに基づいてルーティングでき、よりインテリジェントなルーティングが可能)です。AWS ALB、Nginx、HAProxyは、一般的なレイヤー7ロードバランサーです。AWS NLBはレイヤー4ロードバランサーです。
# Load balancing algorithms
algorithms = {
'Round Robin': {
'how': 'Rotate through servers in sequence',
'best_for': 'Stateless servers with similar capacity',
'weakness': 'Does not account for server load or response time',
},
'Weighted Round Robin': {
'how': 'Round robin but servers with more capacity get more requests',
'best_for': 'Heterogeneous server fleet',
'weakness': 'Static weights; does not adapt to runtime load',
},
'Least Connections': {
'how': 'Send to server with fewest active connections',
'best_for': 'Long-lived connections (WebSocket, streaming)',
'weakness': 'More complex tracking of connection state',
},
'Consistent Hashing': {
'how': 'Hash request key (user_id, session) to server',
'best_for': 'Sticky sessions, cache locality per server',
'weakness': 'Uneven distribution if hash space is not balanced',
},
'Random': {
'how': 'Choose server at random',
'best_for': 'Simple stateless workloads',
'weakness': 'No guarantee of load balance in short windows',
},
}
for alg, info in algorithms.items():
print(f'{alg}: {info["how"]}')コンシステントハッシュ法:ノードの追加と削除
コンシステントハッシュ法は、サーバーの追加や削除に伴ってキャッシュキーを再分散する問題を解決します。単純な剰余ハッシュ(server = hash(key) % n)では、nが変わるとほぼすべてのキーの割り当てが変わり、キャッシュスタンピードが発生します。コンシステントハッシュ法では、キーとサーバーの両方をリング上にマッピングし、各キーを時計回りに最も近いサーバーが処理します。サーバーを1台追加した場合、新しいサーバーとその直前のサーバーの間にあるキーだけが再マッピングされるため、全キーの約1/nで済みます。
仮想ノード(vnode)を使うと負荷分散が改善されます。各物理サーバーにリング上の複数の位置を割り当てることで、サーバー数が少ない場合でもキーをより均等に分散できます。
import hashlib
import bisect
class ConsistentHashRing:
def __init__(self, replicas=100):
self.replicas = replicas # virtual nodes per server
self.ring = {}
self.sorted_keys = []
def add_server(self, server):
for i in range(self.replicas):
key = int(hashlib.md5(f'{server}:{i}'.encode()).hexdigest(), 16)
self.ring[key] = server
bisect.insort(self.sorted_keys, key)
def remove_server(self, server):
for i in range(self.replicas):
key = int(hashlib.md5(f'{server}:{i}'.encode()).hexdigest(), 16)
del self.ring[key]
self.sorted_keys.remove(key)
def get_server(self, item):
key = int(hashlib.md5(item.encode()).hexdigest(), 16)
idx = bisect.bisect(self.sorted_keys, key) % len(self.sorted_keys)
return self.ring[self.sorted_keys[idx]]
ring = ConsistentHashRing()
for s in ['server-1', 'server-2', 'server-3']:
ring.add_server(s)
for item in ['user:1', 'user:2', 'product:abc', 'session:xyz']:
print(f'{item} => {ring.get_server(item)}')キャッシュスタンピードと解決策
キャッシュスタンピード(またはthundering herd)は、人気のあるキャッシュエントリの有効期限が切れた際に、多数の同時リクエストが一斉にキャッシュミスを起こし、同じクエリでデータベースに殺到する現象です。解決策には次のものがあります。
- ミューテックス/ロック:1つのリクエストだけが値を計算し、他のリクエストは待機します
- 確率的な早期有効期限切れ:TTLの期限が来る少し前に、リクエストがランダムにキャッシュ更新を行うか判断し、同時的な期限切れを防ぎます
- Stale-while-revalidate:古いコンテンツをすぐに返しながら、非同期でキャッシュを更新します
- バックグラウンド更新:別のプロセスが、人気のあるキーを有効期限切れ前に更新します
import time, threading, random
# Probabilistic early expiry (XFetch algorithm)
class ProbabilisticCache:
def __init__(self):
self._cache = {}
def get(self, key, ttl, recompute_fn, beta=1.0):
if key in self._cache:
value, expiry, delta = self._cache[key]
# XFetch: decide to refresh early with probability proportional to delta/TTL
remaining = expiry - time.time()
if remaining > 0:
early_refresh_score = delta * beta * (-1) * (remaining / ttl)
if random.random() > (1 - early_refresh_score): # simplified
pass # could trigger async refresh here
return value
# Cache miss or expired
start = time.time()
value = recompute_fn()
delta = time.time() - start # computation time
expiry = time.time() + ttl
self._cache[key] = (value, expiry, delta)
return value
print('XFetch: refresh probabilistically before expiry based on computation cost')
print('High-cost computations => refresh earlier to avoid stampede')
print('Low-cost computations => refresh closer to TTL')CDNキャッシュの無効化
キャッシュの無効化は、よく知られているとおり困難です。「コンピューターサイエンスにおける難しい問題は、キャッシュの無効化と名前付けの2つだけです」。オリジンのコンテンツが変更されると、CDNのエッジノードは新しいバージョンを配信する必要があります。戦略には次のものがあります。
- TTLベースの有効期限切れ:コンテンツを自然に期限切れにします(単純ですが、古いコンテンツが残る期間があります)
- URLバージョニング:URLにコンテンツのハッシュを埋め込みます(例:
main.a3f2b.js)。新しいコンテンツには新しいURLを使うため、無効化は必要ありません - CDN APIによるパージ:デプロイ後にAPI呼び出しでURLを明示的にパージします(高速ですが、CDN APIとの統合が必要です)
# Cache invalidation strategies for CDN/browser
strategies = [
{
'name': 'Long TTL + URL versioning (best for static assets)',
'example': '<script src="/app.a3f2b1c.js"></script>',
'ttl': 'Cache-Control: max-age=31536000 (1 year)',
'how': 'Content hash in filename; new deploy = new URL; old URL cached forever (OK)',
},
{
'name': 'Short TTL (for frequently changing content)',
'example': '/api/v1/config',
'ttl': 'Cache-Control: max-age=60 (1 minute)',
'how': 'Simple; content is at most 60s stale; no invalidation needed',
},
{
'name': 'CDN API purge (for news / social media)',
'example': '/news/breaking-story.html',
'ttl': 'Cache-Control: s-maxage=3600',
'how': 'On publish, call CDN.purge(url); edge serves new version immediately',
},
]
for s in strategies:
print(f'{s["name"]}:')
print(f' Example: {s["example"]}')
print(f' TTL: {s["ttl"]}')
print(f' Strategy: {s["how"]}\n')アーキテクチャ:すべてを組み合わせる
十分にスケールしたWebアプリケーション層では、3つの技術をすべて組み合わせます。ロードバランシングでトラフィックを分散し、CDNで静的リクエストとキャッシュ可能なAPIリクエストを吸収し、Redisで動的データをキャッシュします。データベースが受け取るのはキャッシュミスだけで、通常はリクエスト全体の5〜20%です。
読み取り負荷の高いAPIにおける典型的なリクエストフローは次のとおりです。ユーザー → DNS → CDNエッジ(キャッシュヒット:すぐに配信) → CDNミス → ロードバランサー → アプリサーバープール → Redisキャッシュ(ヒット:1ミリ秒で応答) → Redisミス → データベース(10〜50ミリ秒) → Redisと必要に応じてCDNにレスポンスをキャッシュ → ユーザー。各レイヤーがデータベースの負荷を大幅に軽減します。
# Request flow with cache hit rates
request_flow = [
('Browser Cache', '10%', '0ms', 'Browser caches GET responses per Cache-Control'),
('CDN Edge Cache', '60%', '5ms', 'CloudFront/Fastly caches cacheable API responses'),
('Load Balancer', None, '1ms', 'Routes to healthy app server replica'),
('App Server', None, '2ms', 'Business logic, auth check'),
('Redis Cache', '25%', '1ms', 'Caches computed data, hot DB rows'),
('Database Read Replica','5%', '10ms', 'Cache miss: query read replica'),
('Database Primary', '0.1%', '15ms', 'Cache+replica miss: query primary (rare for reads)'),
]
print(f'{'Layer':30s} {'Hit Rate':10s} {'Latency':10s} {'Notes'}')
print('-'*80)
for layer, hit_rate, latency, note in request_flow:
hr = hit_rate if hit_rate else '-'
print(f'{layer:30s} {hr:10s} {latency:10s} {note}')
print('\nResult: DB sees ~5% of requests; Redis sees ~25%; CDN absorbs 60%; browser 10%')面接のヒント:キャッシュと負荷分散
システム設計の面接でキャッシュについて説明するときは、必ず何をキャッシュするか(頻繁に使うデータ、コストの高い計算結果)、どこにキャッシュするか(ブラウザー、CDN、アプリケーション、データベースのクエリキャッシュ)、いつ無効化するか(書き込み時、TTLの期限切れ時、またはバックグラウンド更新によるか)、そしてどのような整合性保証を許容するかを説明してください。キャッシュを導入すると整合性の遅延期間が生じるため、その点を明確にしてください。
負荷分散については、アルゴリズムの選択、ヘルスチェック、スティッキーセッション(必要な場合)、ステートレスなアプリサーバーを水平方向にスケールできるかどうかに触れてください。アプリケーションが状態(WebSocket接続やセッション)を持つ場合は、レプリカ間でその状態をどのように管理するかを説明してください。
# Caching design questions checklist
cache_checklist = [
'What data to cache? (read-heavy, expensive to compute, rarely updated)',
'Cache layer: client-side / CDN / app-level / DB query cache?',
'Cache invalidation strategy: TTL / event-driven / write-through?',
'Eviction policy: LRU / LFU?',
'Cache key design: ensure uniqueness, avoid hotspots',
'Consistency window: acceptable staleness in seconds?',
'Cache stampede prevention: mutex / stale-while-revalidate?',
'Cache capacity: how much RAM needed for hot set?',
]
lb_checklist = [
'Layer 4 vs Layer 7: routing by IP or by URL/headers?',
'Algorithm: round robin / least-connections / consistent hashing?',
'Health checks: interval, failure threshold, recovery',
'Session stickiness: needed? Use cookie-based affinity or external session store',
'Auto-scaling: scale out when CPU > 70%; scale in when < 30%',
]
print('Cache checklist:')
for item in cache_checklist: print(f' [ ] {item}')
print('\nLoad balancer checklist:')
for item in lb_checklist: print(f' [ ] {item}')理解度チェック
このレッスンで扱った Data Structures & Algorithms — Coding Interview Prep の概念について、理解度を確認します。
レッスンのまとめ
このレッスンでは、キャッシュはホットデータを高速なメモリ層(Redis、CDN)に保存して、読み取りの大部分を吸収し、データベースの負荷を軽減すること、キャッシュアサイドが最も一般的なパターンであり、ミス時はDBから読み込み、ヒット時はすぐに返し、書き込み時はキャッシュから削除すること、そしてコンシステントハッシュ法はキャッシュキーをノード間に分散し、ノードの追加や削除時に再マッピングするキーを全体の約1/nに抑えることを学びました。次は、レートリミッターとTwitterフィードを設計し、システム設計のすべての概念をエンドツーエンドの問題に応用します。
よくある質問
「キャッシュ、CDN、ロードバランシング」レッスンは無料ですか?
はい。「キャッシュ、CDN、ロードバランシング」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Coding Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Coding Interview Prepコースには全4レッスンが含まれています。
「キャッシュ、CDN、ロードバランシング」で何を学びますか?
Redisのキャッシュ層を追加し、静的アセットをCDNへ配置して、ラウンドロビンとコンシステントハッシュを使うロードバランサーでレプリカ間にトラフィックを分散します。 ブラウザで直接実行するハンズオンコードでCoding Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
Coding Interview Prepを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのCoding Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン3/4です。
「キャッシュ、CDN、ロードバランシング」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このCoding Interview Prepレッスンでコードを書いて実行できますか?
はい。すべてのCoding Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。
このコースのすべてのレッスン
- システム設計面接のフレームワーク
- スケーラブルなデータストレージ:SQLとNoSQL
- キャッシュ、CDN、ロードバランシング
- Rate Limiterの設計とTwitterフィードの設計