0Pricing
Coding Interview Prep · Урок

Кэширование, CDN и балансировка нагрузки

Добавьте уровни кэширования Redis, разместите статические ресурсы в CDN и распределяйте трафик между репликами с помощью балансировщиков по круговой схеме и с согласованным хешированием

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

Почему кэширование необходимо при больших масштабах

Кэширование хранит копии часто используемых данных в более быстром слое хранения, чтобы обслуживать последующие запросы без обращения к более медленному исходному хранилищу (базе данных или внешнему API). При больших масштабах небольшое число популярных объектов получает подавляющее большинство запросов — часто действует правило 80/20 (принцип Парето): 20% объектов обеспечивают 80% трафика.

Кэш, в памяти которого помещаются самые востребованные 20% данных, может принять на себя 80% нагрузки базы данных. Поэтому добавление кэша Redis часто снижает загрузку CPU базы данных на 70–90% и уменьшает задержку p99 с 10 мс до менее 1 мс при попадании в кэш — без существенных изменений базы данных или логики приложения.

# 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')

Шаблон кэша по требованию (ленивая загрузка)

Шаблон кэша по требованию (также называемый ленивой загрузкой) — самая распространённая стратегия кэширования. За управление кэшем отвечает код приложения: при чтении сначала проверьте кэш. При попадании в кэш немедленно верните результат. При промахе кэша получите данные из базы данных, запишите их в кэш, а затем верните результат. При записи обновите базу данных и сделайте запись в кэше недействительной (delete), чтобы следующее чтение обновило её.

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

# 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 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 (используемая реже всего в последнее время): вытеснять запись, к которой дольше всего не обращались. Хорошо работает при рабочей нагрузке с временной локальностью. Используется в Redis по умолчанию.
  • LFU (используемая реже всего): вытеснять запись, к которой обращались меньше всего раз. Лучше подходит для рабочих нагрузок, в которых некоторые объекты постоянно популярны, а LRU не смог бы это учесть.
  • FIFO: вытеснять запись, добавленную раньше всех. Простой подход, но для типичных веб-нагрузок он работает плохо.
  • Случайный выбор: вытеснять случайную запись. На практике при очень больших кэшах этот подход неожиданно сопоставим с 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 — это географически распределённая сеть пограничных серверов (точек присутствия, PoPs), которая кэширует статический и динамический контент вблизи конечных пользователей. Вместо того чтобы запрос каждого пользователя проходил к исходному серверу в одном центре обработки данных, пограничные узлы CDN обслуживают контент из ближайшего PoP, уменьшая задержку примерно с 200 мс (при передаче между континентами) до 5 мс (при обращении к ближайшему PoP).

CDN необходимы для статических ресурсов (изображений, CSS, JS), потокового видео (сегментов HLS), а всё чаще — для ответов программного интерфейса приложения и 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')

Балансировка нагрузки: распределение трафика

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

Балансировщики нагрузки работают на разных уровнях OSI: уровень 4 (транспортный — маршрутизация по IP и порту, очень высокая скорость) и уровень 7 (прикладной — маршрутизация по пути запроса, заголовкам и данным браузера, что позволяет выполнять более интеллектуальную маршрутизацию). 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/n всех ключей.

Виртуальные узлы улучшают распределение нагрузки: каждому физическому серверу назначается несколько позиций на кольце, поэтому ключи распределяются равномернее даже при небольшом количестве серверов.

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)}')

Лавина запросов к кэшу и решения

Лавина запросов к кэшу (или эффект стада) возникает, когда срок действия популярной записи в кэше истекает и множество одновременных запросов одновременно обнаруживает промах, перегружая базу данных одним и тем же запросом. Решения:

  • Мьютекс/блокировка: только один запрос вычисляет значение, остальные ждут
  • Вероятностное досрочное истечение: немного раньше истечения TTL запрос случайным образом решает обновить кэш, предотвращая одновременное истечение срока действия
  • Выдача устаревших данных во время проверки: немедленно отдавать устаревший контент, одновременно асинхронно обновляя кэш
  • Фоновое обновление: отдельный процесс обновляет популярные ключи до истечения их срока действия
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

Инвалидация кэша славится своей сложностью: «В информатике есть только две по-настоящему сложные задачи: инвалидация кэша и именование вещей». Когда содержимое меняется на исходном сервере, пограничные узлы CDN должны отдавать новую версию. Стратегии:

  • Истечение по TTL: позволить содержимому истечь естественным образом (просто, но создаёт окно устаревших данных)
  • Версионирование адресов: встраивать хеш содержимого в адрес (например, main.a3f2b.js); новое содержимое = новый адрес, поэтому инвалидация не требуется
  • Очистка через программный интерфейс CDN: явно очищать адреса через вызов интерфейса после развёртывания (быстро, но требует интеграции с интерфейсом CDN)
# 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')

Архитектура: объединяем всё вместе

Полностью масштабируемый уровень веб-приложения использует все три метода одновременно: балансировка нагрузки распределяет трафик, CDN принимает на себя статические и кэшируемые запросы программного интерфейса приложения, а Redis кэширует динамические данные. База данных видит только промахи кэша — обычно 5–20% запросов.

Типичный путь запроса для программного интерфейса приложения с преобладанием чтения: пользователь → 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}')

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

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

Итоги урока

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

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

Урок «Кэширование, CDN и балансировка нагрузки» бесплатный?

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

Чему я научусь в уроке «Кэширование, CDN и балансировка нагрузки»?

Добавьте уровни кэширования Redis, разместите статические ресурсы в CDN и распределяйте трафик между репликами с помощью балансировщиков по круговой схеме и с согласованным хешированием Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

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

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

Сколько времени занимает урок «Кэширование, CDN и балансировка нагрузки»?

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

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

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

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

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