Кэш и кэширование: что это, как работает и где применяется

Кэш и кэширование: что это, как работает и где применяется Полезное

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

Важно различать три вещи: источник (база, сервер, диск — там «правда»), кэш (копия, которая может устареть) и политику (когда копию класть, сколько хранить и когда выбрасывать). Почти все проблемы с кэшем — из-за того, что копия разошлась с источником. Ниже — уровни кэширования, стратегии записи, TTL, LRU и инвалидация с запускаемыми примерами.

Как работает кэш: попадание и промах

Любой кэш отвечает на один вопрос: есть ли у меня свежая копия по этому ключу?

  • Попадание (cache hit) — копия есть и еще годна, ответ отдается быстро.
  • Промах (cache miss) — копии нет или она устарела, запрос идет в источник, результат кладется в кэш.
  • Доля попаданий (hit ratio) — главная метрика: если попаданий мало, кэш только добавляет лишний шаг и расход памяти.

Минимальный рабочий пример — кэш перед «медленной базой» со сроком жизни записи (TTL). Проверено на Python 3.14:

import time

DB = {"course:1": "Python Developer"}   # "медленная" база
db_reads = 0

def db_get(key):
    global db_reads
    db_reads += 1
    time.sleep(0.2)                     # имитация запроса к БД
    return DB.get(key)

cache = {}                              # key -> (value, expires_at)
TTL = 60                                # секунд

def get(key):
    item = cache.get(key)
    if item and item[1] > time.monotonic():
        return item[0], "hit"
    value = db_get(key)                 # промах: идем в источник
    cache[key] = (value, time.monotonic() + TTL)
    return value, "miss"

for _ in range(3):
    t0 = time.perf_counter()
    value, status = get("course:1")
    ms = (time.perf_counter() - t0) * 1000
    print(f"{status:4} {value} {ms:.0f} мс")
print("обращений к БД:", db_reads)

Результат прогона: первый вызов — промах и около 200 мс (время зависит от машины), два следующих — попадания почти за 0 мс, в базу ушел один запрос.

miss Python Developer 204 мс
hit  Python Developer 0 мс
hit  Python Developer 0 мс
обращений к БД: 1

Что здесь важно. Ключ course:1 определяет, какие данные лежат в записи. expires_at хранит момент истечения, а time.monotonic() не зависит от перевода системных часов. Когда TTL истечет, следующий вызов снова станет промахом и перечитает базу. Этот пример — учебный: он однопоточный и растет без ограничения размера, про это ниже.

Уровни кэширования: от процессора до базы данных

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

Уровень Что кэширует Кто управляет Как сбрасывают
Процессор (L1/L2/L3) строки оперативной памяти аппаратура, прозрачно для программ автоматически, протоколы когерентности
Страничный кэш ОС блоки файлов с диска ядро ОС вытеснение при нехватке памяти
Браузер HTML, CSS, JS, картинки, шрифты заголовки HTTP от сервера жесткая перезагрузка, очистка данных сайта
CDN и прокси ответы сервера целиком настройки CDN + заголовки TTL, сброс (purge) через CDN
Приложение результаты запросов и вычислений код, Redis/Memcached, память процесса TTL, удаление ключа при записи
База данных страницы таблиц и индексов в памяти СУБД (например, shared_buffers в PostgreSQL) вытеснение по внутренней политике

Кэш процессора здесь упомянут для полноты картины: он работает без участия программиста, и его устройство — отдельная тема. Разработчику чаще приходится настраивать три уровня: браузер, CDN и кэш приложения.

Кэш в браузере и HTTP-заголовки

Браузер решает, можно ли взять файл из кэша, по заголовкам ответа сервера. Основные директивы Cache-Control:

Директива Что означает
max-age=3600 ответ свежий, пока его возраст меньше 3600 секунд; в это время кэш отдает его, не спрашивая сервер
no-cache хранить можно, но перед использованием надо сверить с сервером
no-store не сохранять ни в каком кэше (для личных и чувствительных данных)
private / public общим кэшам (CDN, прокси) хранить запрещено, можно только в браузере / разрешает хранить даже там, где по умолчанию нельзя (например, ответ на запрос с авторизацией)
s-maxage=600 срок свежести только для общих кэшей: перекрывает max-age, браузер его не учитывает

Частая путаница: no-cache не означает «не кэшировать». Файл сохраняется, но при каждом использовании браузер делает условный запрос с If-None-Match (если сервер прислал ETag) или If-Modified-Since (если был Last-Modified). Если файл не менялся, сервер отвечает 304 Not Modified без тела — трафик экономится, но запрос все равно идет.

Для статики обычно используют прием с версией в имени файла: app.3f9c2a.js отдают с долгим max-age, а при изменении кода меняется имя. Так долгий кэш не мешает выкатывать обновления.

Стратегии кэширования в приложении

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

Стратегия Как работает Плюс Риск
Cache-aside (ленивая загрузка) приложение читает кэш, при промахе — источник, затем кладет в кэш просто, в кэше только нужное первый запрос медленный, возможны устаревшие данные
Read-through то же, но загрузку при промахе делает сам кэш-слой код приложения проще нужна библиотека или сервис с такой функцией
Write-through запись идет в кэш и сразу синхронно в источник кэш согласован с источником после записи запись медленнее, в кэш попадает и то, что не читают
Write-behind (write-back) запись в кэш, в источник — позже пачкой быстрая запись при сбое до сброса данные теряются
Write-around запись только в источник, кэш заполняется при чтении кэш не засоряется разовыми записями первое чтение после записи — промах

Пример из первого раздела — это cache-aside, самая распространенная схема для веб-приложений. Когда выбирать иначе: если данные после записи сразу читают и важна согласованность — write-through; если поток записей большой, а потеря последних секунд допустима — write-behind.

Инвалидация: главная ошибка при кэшировании

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

DB = {"price:1": 100}
cache = {}

def get(key):
    if key not in cache:
        cache[key] = DB[key]
    return cache[key]

def update_wrong(key, value):
    DB[key] = value                     # кэш не тронут

def update_right(key, value):
    DB[key] = value
    cache.pop(key, None)                # инвалидация: удаляем ключ

print(get("price:1"))                   # 100, кэш заполнен
update_wrong("price:1", 120)
print(get("price:1"), "в БД:", DB["price:1"])
update_right("price:1", 150)
print(get("price:1"), "в БД:", DB["price:1"])

Результат прогона на Python 3.14:

100
100 в БД: 120
150 в БД: 150

После update_wrong база хранит 120, а кэш продолжает отдавать 100. После update_right ключ удален, следующее чтение — промах, и приложение берет из базы актуальные 150. Удаление ключа обычно надежнее, чем запись нового значения в кэш: при двух параллельных обновлениях запись в кэш может прийти в неверном порядке.

Граница этого приема: в многопоточной или распределенной системе удаление ключа не исключает гонку полностью (чтение старого значения может успеть положить его обратно). Поэтому к инвалидации почти всегда добавляют TTL как страховку: даже если удаление не сработало, устаревшая копия проживет ограниченное время.

TTL и вытеснение: LRU, LFU, FIFO

TTL и вытеснение решают разные задачи:

  • TTL (time to live) — ограничивает устаревание: запись живет не дольше заданного времени, даже если места хватает.
  • Вытеснение (eviction) — ограничивает размер: когда память кончилась, политика выбирает, что выбросить.

Популярные политики вытеснения: LRU (least recently used) выбрасывает то, к чему дольше всего не обращались; LFU (least frequently used) — то, к чему обращались реже всего; FIFO — самое старое по времени добавления. Простая реализация LRU:

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.data = OrderedDict()

    def get(self, key):
        if key not in self.data:
            return None
        self.data.move_to_end(key)          # отметили как свежий
        return self.data[key]

    def put(self, key, value):
        self.data[key] = value
        self.data.move_to_end(key)
        if len(self.data) > self.capacity:
            old, _ = self.data.popitem(last=False)   # самый давний
            print("вытеснен:", old)

c = LRUCache(2)
c.put("a", 1)
c.put("b", 2)
c.get("a")          # a стал свежее b
c.put("c", 3)       # места нет -> уходит b
print(list(c.data))

Вывод:

вытеснен: b
['a', 'c']

Ключ b добавили позже a, но вытеснен именно он: обращение get("a") сделало a недавно использованным. В этом и отличие LRU от FIFO, который выбросил бы a.

В Python для кэширования результатов функций есть готовый декоратор functools.lru_cache:

from functools import lru_cache

@lru_cache(maxsize=128)
def square(n):
    print("считаю", n)
    return n * n

square(4); square(4); square(5)
print(square.cache_info())
square.cache_clear()
print(square.cache_info())
считаю 4
считаю 5
CacheInfo(hits=1, misses=2, maxsize=128, currsize=2)
CacheInfo(hits=0, misses=0, maxsize=128, currsize=0)

Второй вызов square(4) не печатает «считаю» — это попадание. Условие корректности: результат функции должен полностью определяться аргументами, а аргументы должны быть хешируемыми (список вызовет TypeError). TTL у lru_cache нет, поэтому для данных из базы он не подходит без доработки.

Когда кэш вредит и как почистить кэш вручную

Кэш не бесплатен. Типовые проблемы: устаревшие данные без инвалидации; утечка приватных данных, если личный ответ попал в общий кэш CDN (для таких ответов — private или no-store); лавина промахов (cache stampede), когда у популярного ключа истек TTL и сотни запросов одновременно идут в базу. От лавины помогают блокировка на пересчет ключа, случайный разброс TTL и отдача устаревшей копии на время обновления.

Если сайт после обновления отображается криво, обычно достаточно жесткой перезагрузки страницы: Ctrl+F5 или Ctrl+Shift+R в Windows и Linux, Cmd+Shift+R в macOS. Полная очистка в Chrome открывается сочетанием Ctrl+Shift+Delete (в macOS — Cmd+Shift+Delete) или через меню «Удалить данные о работе в браузере»: там отмечают «Изображения и другие файлы, сохраненные в кеше» и выбирают временной диапазон, например «Все время». На Android кэш отдельного приложения чистят в настройках: «Приложения» -> нужное приложение -> «Хранилище и кеш» -> «Очистить кеш» (так в стандартном Android; в оболочках производителей пункт может называться «Память» или «Хранилище»). Не путайте с кнопкой «Очистить хранилище» («Удалить данные»): она стирает все данные приложения, включая вход в аккаунт. В Windows 11 временные файлы удаляются через «Параметры» -> «Система» -> «Память» (в справке Microsoft раздел назван «Хранилище») -> «Временные файлы».

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

Выводы

  • Кэш — копия данных рядом с потребителем; источник истины остается в базе или на сервере, а копия может устареть.
  • Кэширование работает на всех уровнях: процессор, ОС, браузер, CDN, приложение, СУБД; разработчик чаще всего настраивает HTTP-заголовки, CDN и кэш приложения.
  • Стратегию выбирают по приоритету: cache-aside — просто и распространено, write-through — согласованность после записи, write-behind — скорость записи ценой риска потери.
  • TTL ограничивает устаревание, политика вытеснения (LRU, LFU, FIFO) — размер; это разные механизмы, и обычно нужны оба.
  • Главная ошибка — забытая инвалидация: при изменении данных ключ удаляют, а TTL оставляют как страховку.

Где применяется / связь с практикой

Освойте тему на практике

Кэширование — одна из первых тем на собеседованиях по проектированию систем: как разгрузить базу, где поставить Redis, как не отдать пользователю чужие или устаревшие данные, что делать при лавине промахов. Эти решения разбирают вместе с балансировкой, репликацией и очередями на курсе «System Design».

Освойте тему на практике

Посмотреть формат обучения и разобрать практические кейсы с преподавателями можно на бесплатных открытых уроках.

FAQ

Кэш и буфер — это одно и то же?
Нет. Буфер временно держит данные при передаче между компонентами с разной скоростью и после передачи не нужен, а кэш хранит копию, чтобы повторно отдать ее без обращения к источнику.

Почему кэш называют «кеш» и «кэш»?
Оба написания встречаются и в словарях, и в документации: в IT-текстах распространено «кэш», а в интерфейсах Google Chrome и Android используется «кеш». Слово происходит от английского cache, то есть «тайник».

Можно ли хранить в кэше пароли и токены?
Лучше нет: ответы с личными данными отдают с Cache-Control: no-store, а в кэше приложения хранят только то, что безопасно потерять или раскрыть при ошибке настройки.

OTUS Журнал