Стратегии записи: write-through и write-behind

Cache-aside хорош для чтения. Но как быть с записью? Тут выбор стратегии определяет компромисс между скоростью, согласованностью и риском потери данных.

Записать сначала в кэш или сначала в БД? Синхронно или отложенно? Ответ зависит от того, чем вы готовы пожертвовать.

Если cache-aside отвечал на вопрос «как читать», то стратегии записи отвечают «как обновлять». Основных подхода два: write-through и write-behind. Плюс важная техника инвалидации.

Write-through: запись насквозь

При write-through приложение пишет одновременно и в кэш, и в БД, синхронно. Данные в кэше всегда свежие, но запись чуть медленнее (два хранилища вместо одного).

   Write-through

   App --запись--> [Кэш] --запись--> [БД]
                    свежо           надёжно
   Ответ только после записи в ОБА хранилища

Плюс: кэш всегда консистентен с БД. Минус: каждая запись платит за два хранилища; кэшируются и данные, которые могут никогда не прочитать.

Write-behind: отложенная запись

При write-behind (write-back) приложение пишет только в кэш и сразу отвечает. В БД данные попадают позже, асинхронно, пачкой. Это очень быстро, но рискованно.

   Write-behind

   App --запись--> [Кэш] --(быстрый ответ)
                     |
                     | асинхронно, позже, пачкой
                     v
                   [БД]

Плюс: минимальная задержка записи, БД разгружена. Минус: если кэш упадёт до сброса в БД — данные потеряны. Используется там, где допустима небольшая потеря (метрики, счётчики).

Инвалидация при cache-aside

Чаще всего на практике используют cache-aside + явную инвалидацию: при обновлении данных просто удаляют кэш-ключ, чтобы он перечитался при следующем запросе.

# Обновили запись в БД, затем:
DEL cache:user:42
# Следующий GET будет промахом -> перечитает свежее

Демонстрация: сравнение write-through и write-behind на Python

cache = {}
db = {}
db_writes = 0

def write_through(key, value):
    global db_writes
    cache[key] = value      # пишем в кэш
    db[key] = value         # И сразу в БД (синхронно)
    db_writes += 1

# write-behind: копим в кэше, в БД сбрасываем пачкой
dirty = set()
def write_behind(key, value):
    cache[key] = value      # только кэш — быстрый ответ
    dirty.add(key)          # помечаем "надо сбросить"

def flush():                # периодический сброс в БД
    global db_writes
    for k in dirty:
        db[k] = cache[k]
        db_writes += 1
    dirty.clear()

print("--- Write-through: 3 записи ---")
for i in range(3):
    write_through(f"k{i}", i)
print(f"Записей в БД: {db_writes} (по одной на каждую запись)")

db_writes = 0
print("\n--- Write-behind: 3 записи + сброс ---")
for i in range(3):
    write_behind(f"k{i}", i)
print(f"Записей в БД пока: {db_writes} (БД ещё не тронута!)")
flush()
print(f"После flush записей в БД: {db_writes} (одна пачка)")
print("\nWrite-behind разгружает БД, но рискует потерять несброшенное.")

Как работает под капотом

Ключевой компромисс — согласованность против скорости и риска. Write-through жертвует скоростью записи ради консистентности. Write-behind жертвует надёжностью ради скорости и снижения нагрузки на БД. Cache-aside с инвалидацией — золотая середина для большинства веб-приложений: чтения через кэш, записи в БД с удалением кэш-ключа. Выбор диктуется требованиями: финансы — write-through, метрики — write-behind.

Частые ошибки

  • Write-behind для критичных данных. Потеря несброшенного буфера = потеря данных. Не для денег и заказов.
  • Обновить кэш, но забыть БД (или наоборот). Источник рассогласования.
  • Гонка «обновление vs инвалидация». При высокой конкуренции порядок операций важен; иногда надёжнее удалять ключ, а не обновлять.

Best practices

  • По умолчанию — cache-aside для чтения + DEL кэша при записи.
  • Write-through там, где важна свежесть кэша и допустима чуть бóльшая задержка записи.
  • Write-behind только для толерантных к потере данных (счётчики, аналитика).

Итог: Write-through пишет в кэш и БД синхронно (свежо, но медленнее). Write-behind пишет в кэш, в БД — отложенно (быстро, но рискованно). На практике чаще всего — cache-aside с инвалидацией ключа при обновлении.

Read-through и refresh-ahead

Помимо трёх основных стратегий записи, полезно знать два «соседних» паттерна, дополняющих картину кэширования.

  • Read-through. Похож на cache-aside, но логику «промахнулись — сходили в БД — заполнили кэш» инкапсулирует сам слой кэша (библиотека или прокси), а не приложение. Приложение просто запрашивает данные, не зная, откуда они пришли. Это чище архитектурно, но требует поддержки на уровне инфраструктуры кэша.
  • Refresh-ahead. Кэш заранее, до истечения TTL, асинхронно обновляет популярные ключи. Пользователь почти никогда не ловит промах по горячим данным, потому что значение освежается «на опережение». Это прямое лекарство от cache stampede для предсказуемо горячих ключей.

Выбор стратегии всегда сводится к одному вопросу: чем вы готовы пожертвовать? Свежестью данных, задержкой записи, нагрузкой на БД или риском потери? Универсального ответа нет — есть набор инструментов под разные требования. Для большинства веб-сценариев хватает cache-aside с инвалидацией, а более сложные стратегии вводятся точечно для горячих участков.

Проверьте себя
1. В чём главный риск стратегии write-behind (отложенная запись)?
AОна медленнее всех остальных стратегий
BЕсли кэш упадёт до сброса данных в БД, несброшенные данные будут потеряны
CОна невозможна в Redis
DОна удваивает объём хранимых данных
2. Какой подход чаще всего используют в веб-приложениях по умолчанию?
AWrite-behind для всех данных
BCache-aside для чтения с инвалидацией (удалением) кэш-ключа при обновлении
CТолько прямые запросы в БД без кэша
DWrite-through для абсолютно всех операций