---
title: "Retention и log compaction"
description: "Retention в Kafka — это политика хранения журнала: retention.ms и retention.bytes подстригают сегменты, log compaction оставляет последнее значение по ключу, tombstone и чтение компактед-топика через Reader."
course: "Kafka в Go: продюсеры и консьюмеры на kafka-go"
url: "https://golangmaster.ru/courses/kafka-go/lessons/17-retention-kompakciya/"
published: "2026-09-19"
updated: "2026-09-29"
minutes: 18
---

# Retention и log compaction

*Курс «Kafka в Go: продюсеры и консьюмеры на kafka-go», урок 17 из 25. ~18 минут. Обновлено 29.09.2026. Источник: https://golangmaster.ru/courses/kafka-go/lessons/17-retention-kompakciya/*

Retention в Kafka — это политика хранения журнала топика: брокер удаляет сегменты старше retention.ms или крупнее retention.bytes, чтобы диск не кончился. Второй механизм подстригания — log compaction: сжатие по ключу, после которого на каждый ключ остаётся последнее значение. Оба живут в конфигурации топика, которую мы уже умеем менять через CreateTopics из шестнадцатого урока, а читаются они руками Reader'а из четырнадцатого. Разберём сегменты, retention.ms и retention.bytes, cleanup.policy=compact, tombstone с nil-значением и случай, когда компакция не спасает и нужна обычная история.

Сообщения до сих пор казались бессмертными: мы писали, читали, перечитывали — и ничего не исчезало. На локальном стенде это правда, потому что дефолтный ретеншен брокера — семь суток, а песочница живёт часами. В продакшене журнал партиции — расходный материал: он чистится постоянно, и от того, как настроена чистка, зависит, что увидит читатель, открывший топик сегодня. Отсюда вопрос, на который отвечает этот урок: сколько живёт сообщение и что остаётся после его смерти. Ответов два, и они принципиально разные: ретеншен удаляет прошлое по календарю и размеру, компакция — по ключам, оставляя на каждый ключ последнее значение. Первый механизм превращает топик в окно событий, второй — в таблицу, и путать их дорого: компактед-топик никогда не покажет вчерашнюю котировку, а топик с ретеншеном никогда не гарантирует, что последнее значение ключа ещё в нём. Оба механизма настраиваются на топик и не требуют ни строчки в продюсере или консьюмере — только дисциплину на этапе проектирования данных.

### Сегменты лога: из чего состоит партиция

Партиция на диске — не один файл, а последовательность сегментов: брокер пишет активный сегмент, а когда он вырастает до segment.bytes или стареет до segment.ms, закрывает его и открывает следующий. Это устройство важно, потому что чистка работает целыми сегментами, а не сообщениями: удалять середину файла невозможно, поэтому ретеншен действует с гранулярностью сегмента, и мелкие сегменты означают аккуратную чистку ценой большего числа открытых файлов. Настройки ретеншена ставятся на топик через ConfigEntries, знакомые по прошлому уроку. retention.ms — максимальный возраст сегмента: классические значения — сутки 86400000 или неделя 604800000; значение минус единица отключает чистку по времени, а дефолт брокера задаётся настройкой log.retention.hours и равен семи суткам. retention.bytes — максимальный суммарный размер партиции; по умолчанию минус единица, то есть безлимит. Как только лимит превышен, брокер начинает удалять старейшие закрытые сегменты, пока партиция не влезет, — запись при этом не останавливается ни на мгновение. Читатели старых оффсетов в этот момент получают ошибку OffsetOutOfRange: сегмент, где жил их позиция, уже удалён, и читать придётся с kafka.FirstOffset — самого раннего живого сегмента, а не с нуля.

### Проверка: что делает retention.bytes

**Вопрос:** На топик установили retention.bytes в один гигабайт. Что начнёт делать брокер?

- ✅ Удалять старейшие сегменты, когда суммарный размер партиции превысит лимит
- Отклонять новые записи, когда партиция дорастёт до гигабайта
- Ограничивать размер одного сообщения одной партиции
- Сжимать сообщения, чтобы партиция не превышала лимит

retention.bytes — потолок размера журнала партиции, а не сообщения: при превышении брокер выбрасывает старейшие закрытые сегменты до влезания, и запись продолжается. Гранулярность чистки задаёт segment.bytes — размер сегмента, целыми которыми и оперирует ретеншен.

### Log compaction: последнее значение по ключу

Log compaction — второй режим чистки, включаемый брокерской настройкой топика cleanup.policy со значением compact. Правило простое: для каждого ключа в журнале должно остаться как минимум последнее значение, всё более старое компактор имеет право вычистить. Журнал после компакции ведёт себя как таблица key→value: перечитав его целиком, вы получаете актуальное состояние — последнюю цену по каждому тикеру, свежий конфиг каждого сервиса, текущий статус каждого заказа. Две гарантии отличают компакцию от обычной чистки. Первая: оффсеты никогда не меняются — компактор удаляет записи, но не переписывает журнал заново, поэтому порядок и позиции сохраняются, и коммиты групп из восьмого урока остаются валидными. Вторая: компакция не мгновенна — она выполняется фоновым потоком, когда «грязная» доля журнала дожимает min.cleanable.dirty.ratio, а свежие записи защищены min.compaction.lag.ms. Отсюда и обязательное условие: ключ. В компактед-топике запись без ключа не имеет смысла — чистить нечего, и продюсеру с балансировщиком Hash из пятого урока здесь самое место. Компакция выполняется фоновым потоком брокера: он сравнивает «грязную» долю журнала с min.cleanable.dirty.ratio и решает, пора ли запускать проход по сегментам, поэтому наблюдать результат моментально не приходится — это нормально и на локальном стенде тоже.

### Создаём компактед-топик с последними ценами

```go
_, err := client.CreateTopics(ctx, &kafka.CreateTopicsRequest{
        Addr: client.Addr,
        Topics: []kafka.TopicConfig{
                {
                        Topic:             "prices-latest",
                        NumPartitions:     3,
                        ReplicationFactor: 1,
                        ConfigEntries: []kafka.ConfigEntry{
                                {ConfigName: "cleanup.policy", ConfigValue: "compact"},
                                {ConfigName: "min.compaction.lag.ms", ConfigValue: "60000"},
                        },
                },
        },
})
```

cleanup.policy со значением compact включает компакцию; продюсер обязан писать с ключами, иначе чистить будет нечего. min.compaction.lag.ms на минуту защищает свежие версии от сжатия: компактор не тронет записи младше порога. ReplicationFactor 1 — честный бюджет песочницы из третьего урока: на одиночном брокере больше и не создать.

### Tombstone: как удаляют ключ

В таблице из лога нужен способ удалять строки, и он есть: tombstone — сообщение с ключом и пустым значением, nil в kafka-go. Это приказ «ключа больше нет»: компакция вычищает все значения ключа, а сам маркер с исчезновением delete.retention.ms — суток по умолчанию — удаляется и из топика. Итог: после маркера и достаточно долгого ожидания ключ исчезает целиком. Для консьюмера это и есть операция удаления из таблицы: не новый конфиг, а именно исчезновение строки, и обрабатывать её нужно в коде кеша, а не надеяться, что маркер как-то сам расскажет о себе. Тонкость, которая ломает наивных консьюмеров: tombstone — полноценное сообщение, оно доезжает до читателя с обычным ключом и nil-значением, и консьюмер обязан обрабатывать этот случай явно — удалить запись из локального кеша, а не пытаться декодировать пустоту. Вторая тонкость — время: между записью маркера и фактическим исчезновением ключа проходит delete.retention.ms, поэтому читать компактед-топик и наблюдать отсутствие ключа можно только с запасом. Третья — nil легален только для значения: ключ nil в Kafka равнозначен ключу-пустышке, и компакция для таких записей бесполезна, что делает ключ обязательным контрактом компактед-топика.

> **Живое место: tombstone уронил декодер.** Инцидент с кешем цен: сервис перечитывал компактед-топик, декодировал json.Unmarshal каждый Value и однажды упал — в топик приехал tombstone, nil вместо тела. Ошибка «unexpected end of JSON input» повторялась на каждом рестарте, потому что маркер лежал в журнале и перечитывался с начала. Лечение — проверка на nil до декодера: tombstone это команда удаления, а не сообщение для парсинга. Заодно выяснилось, что автор маркера не выставил delete.retention.ms побольше, и ключ исчезал из топика раньше, чем его успевали подхватить все консьюмеры — тайминги компакции тоже часть контракта.

### Пересобираем кеш из компактед-топика

```go
reader := kafka.NewReader(kafka.ReaderConfig{
        Brokers:     []string{"localhost:9092"},
        GroupID:     "cache-rebuild-1", // группа: читаем все партиции
        Topic:       "prices-latest",
        StartOffset: kafka.FirstOffset, // с самого начала живых сегментов
})
defer reader.Close()

for {
        m, err := reader.ReadMessage(ctx)
        if err != nil {
                break // контекст отменён или транспорт умер
        }
        if m.Value == nil {
                cache.Delete(string(m.Key)) // tombstone: ключа больше нет
                continue
        }
        cache.Set(string(m.Key), m.Value) // последнее значение побеждает
}
```

GroupID без группы-конкурента — легальный способ читать все партиции разом, а StartOffset kafka.FirstOffset отправляет новую группу с самого начала журнала, как в четырнадцатом уроке. Благодаря компакции в начале лежит уже сжатый срез, а не вся история. Проверка m.Value nil перехватывает tombstone: ключ удаляется из кеша вместо декодирования.

### Проверка: гарантии компакции

**Вопрос:** Ключ BTC переписали тысячу раз: оффсеты со 100 по 1099. Что останется в журнале после компакции?

- ✅ Последняя версия под оффсетом 1099: порядок и оффсеты неизменны
- Одна версия в начале журнала: компактатор переписывает его заново
- Все тысячу версий: компакция удаляет только tombstone
- Ни одной: старые версии удаляются вместе с оффсетом 100

Компакция оставляет на каждый ключ как минимум последнее значение под его исходным оффсетом; журнал не переписывается, позиции не сдвигаются. Поэтому перечитывание с FirstOffset выдаёт сжатый срез, а коммиты оффсетов групп остаются осмысленными. Удаляет компактор старые версии и tombstone после delete.retention.ms.

### Что увидит читатель после tombstone и компакции?

```go
// Компактед-топик prices-latest, ключ AAPL переписан 500 раз.
// Затем продюсер отправил: kafka.Message{Key: []byte("AAPL")} — Value nil.
// Прошли сутки (delete.retention.ms), компакция отработала.
// Читаем с kafka.FirstOffset.
```

**Что выведет программа?**

- ✅ Ни одного сообщения по ключу AAPL: история вычищена целиком
- Последнее значение и tombstone рядом: маркер живёт вечно
- Все 500 версий и маркер: компакция не тронула ключ
- Одно случайное значение ключа: компакция оставляет любое

Tombstone завершает жизнь ключа: компакция вычищает все версии значения, а после delete.retention.ms удаляется и сам маркер. Журнал показывает, что ключа AAPL больше нет — ровно это и есть способ удалять строки из таблицы на логе. Консьюмер обязан был перехватить nil раньше, чем ключ исчез.

### Retention против компакции: сравнение

Оба механизма удаляют данные, но живут в разных измерениях, и сводить их в одну таблицу полезно перед выбором конфигурации. Ретеншен меряет время и байты: ему всё равно, какие у вас ключи, он честно стирает прошлое за горизонтом и превращает топик в окно событий. Компакция меряет ключи: она стирает прошлые версии каждого ключа, сохраняя настоящее, и превращает топик в снимок состояния. Практическое следствие — ключи нужны только во втором случае, а история значений сохраняется только в первом. Настройки чистки при этом ортогональны дефолтам брокера: топиковые значения перекрывают брокерские, как и min.insync.replicas в прошлом уроке. Выбор режима — это вопрос о читателе: если читателю нужен поток событий, берите ретеншен, если срез состояния — компакцию, и не переезжайте между режимами на живом топике, потому что компакция необратимо удалит историю, которая понадобится завтра. И ещё: cleanup.policy может нести оба значения сразу — compact,delete — компакция плюс окно времени, когда и последнее значение ключа нужно, и совсем старые ключи должны умереть. Такой гибрид типичен для конфигурационных топиков с обязательным временем жизни записей.

### Retention и компакция одним столом

| Критерий | Retention (delete) | Компакция (compact) |
| --- | --- | --- |
| Что удаляется | Сегменты старше retention.ms или сверх retention.bytes | Все версии ключа, кроме последней; tombstone после delete.retention.ms |
| Гарантия после чистки | Только свежее окно событий | Минимум последнее значение каждого ключа |
| Роль ключа | Не важна: чистка по времени и размеру | Обязательна: без ключа чистить нечего |
| Оффсеты и порядок | Исчезают вместе со старыми сегментами | Не меняются: журнал не переписывается |
| Типичный сценарий | События, логи, поток фактов | Снимок состояния: latest price, конфиги, статусы |

### Когда compaction не спасает

Компакция — про настоящее, и её первый предел — потребность в прошлом. История котировок для графика, аудит действий пользователя, ретроспективный разбор инцидента: всё это требует видеть значения, которые компактор уже вычистил, и компактед-топик здесь не поможет по построению. Рецепт — либо обычный топик с ретеншеном, либо база данных, где прошлое — первоклассный гражданин, а компактед-топик в паре хранит лишь текущее состояние. Второй предел — точные временные срезы: «как выглядела таблица в полдень» компакция не гарантирует, потому что чистка фоновая и её границы не совпадают с вашим вопросом. Третий — таблицы без естественного ключа: попытка скомпактировать поток событий без ключей превращает компакцию в бесполезный режим, а случайные ключи вроде идентификатора события делают журнал неотличимым от обычного, но с лишними обязательствами. Наконец, компакция не заменяет ретеншен по размеру: если ключей бесконечно много — сессии, заказы, чаты — журнал растёт с каждым новым ключом, и без retention.bytes или явного удаления tombstone'ами он взорвётся.

> **Про песочницу: компакция не по кнопке.** Локальный Docker-брокер умеет компакцию, но не мгновенно: она запускается фоново, когда «грязная» доля журнала дожимает min.cleanable.dirty.ratio, и ждать результата по нажатию Enter не стоит. Рабочий трюк песочницы: выставьте min.compaction.lag.ms и retention.ms в минуты, напишите десяток версий одного ключа и вернитесь через несколько минут — старые версии исчезнут, а ReadMessage с FirstOffset вернёт только последнюю. Честная оговорка: на маленьких данных компактор может побояться трогать журнал, и это нормально — его триггеры рассчитаны на объём.

### Правила retention и компакции одним списком

- retention.ms и retention.bytes чистят журнал по времени и размеру; минус единица — безлимит, дефолт брокера — семь суток.
- Чистка работает целыми сегментами: segment.bytes задаёт гранулярность, середина файла недостижима.
- cleanup.policy=compact хранит последнее значение каждого ключа под исходным оффсетом — таблица на логе.
- Ключ — обязательный контракт компактед-топика: запись без ключа компакции бесполезна.
- Tombstone — сообщение с nil Value: после delete.retention.ms компакция убирает и ключ, и маркер.
- Консьюмер обязан проверять Value на nil до декодера, иначе tombstone уронит парсинг.
- История значений — работа обычного топика или БД: компакция хранит только настоящее.

### Соберите компактед-топик и полное чтение

Допишите имя настройки, включающей компакцию, и константу стартового оффсета из четырнадцатого урока.

```go
_, err := client.CreateTopics(ctx, &kafka.CreateTopicsRequest{
        Addr: client.Addr,
        Topics: []kafka.TopicConfig{
                {
                        Topic: "prices-latest",
                        ConfigEntries: []kafka.ConfigEntry{
                                {ConfigName: "___", ConfigValue: "compact"},
                        },
                },
        },
})

reader := kafka.NewReader(kafka.ReaderConfig{
        Brokers:     []string{"localhost:9092"},
        GroupID:     "cache-rebuild-1",
        Topic:       "prices-latest",
        StartOffset: kafka.___,
})
```

Подсказка: Режим чистки задаёт настройка cleanup.policy, а чтение с самого начала журнала открывает константа kafka.FirstOffset.

Правильные ответы в пропусках: `cleanup.policy`, `FirstOffset`

### Соберите продюсер последних цен

Расставьте строки: Writer с балансировщиком по ключу, закрытие, сериализация цены, отправка с ключом-тикером.

Правильный порядок строк:

1. `writer := kafka.NewWriter(kafka.WriterConfig{Topic: "prices-latest", Balancer: &kafka.Hash{}})`
2. `defer writer.Close()`
3. `price := Price{Ticker: "AAPL", Value: 187.5}`
4. `payload, err := json.Marshal(price)`
5. `if err != nil { return err }`
6. `err = writer.WriteMessages(ctx, kafka.Message{`
7. `Key:   []byte(price.Ticker),`
8. `Value: payload,`
9. `})`

### Карточки: время жизни данных

- **retention.ms** — Максимальный возраст сегмента партиции; минус единица отключает чистку по времени, дефолт брокера — семь суток.
- **retention.bytes** — Потолок размера журнала партиции: при превышении удаляются старейшие сегменты; минус единица — безлимит.
- **cleanup.policy=compact** — Log compaction: для каждого ключа остаётся минимум последнее значение; оффсеты и порядок не меняются.
- **Tombstone** — Сообщение с ключом и nil-значением: маркер удаления ключа; доезжает до консьюмера как обычное сообщение.
- **delete.retention.ms** — Срок жизни tombstone в компактед-топике; по умолчанию сутки, после — маркер и история ключа исчезают.
- **min.compaction.lag.ms** — Защита свежих записей: компактор не трогает версии младше порога — консьюмеры успевают всё прочитать.

Жизнь сообщения в Kafka определяется конфигурацией топика: ретеншен отмеряет окно времени и байтов, компакция оставляет по последнему значению на ключ, а их сочетание compact,delete закрывает гибридные случаи. Выбор — это вопрос о том, что читатель должен увидеть, открыв топик: поток событий или снимок состояния. Дальше по курсу: [урок про таймауты и контекст](/courses/kafka-go/lessons/18-kontekst-tajmauty/) покажет, как не застрять в ReadMessage компактед-топика; [урок про Reader](/courses/kafka-go/lessons/14-reader-api/) разложит FirstOffset и LastOffset полнее; [урок про ключи и партиции](/courses/kafka-go/lessons/05-klyuchi-particionirovanie/) объяснит, почему балансировщик Hash обязателен для последних цен; а [урок про Admin API](/courses/kafka-go/lessons/16-admin-topiki/) — источник CreateTopics, которым мы задавали cleanup.policy. Эксперимент локально стоит десяти минут: компактед-топик, дюжина версий одного ключа, перечитывание с начала — и разница между логом и таблицей станет вашей мышечной памятью.

## Коротко: что запомнить

- Журнал партиции состоит из сегментов; ретеншен чистит их по времени (retention.ms) и размеру (retention.bytes).
- Дефолт брокера — семь суток; минус единица — безлимит; запись не останавливается при чистке.
- cleanup.policy=compact включает компакцию: остаётся последнее значение каждого ключа под исходным оффсетом.
- Оффсеты и порядок при компакции не меняются, поэтому коммиты групп остаются валидными.
- Tombstone — сообщение с nil Value: после delete.retention.ms компакция убирает и маркер, и ключ.
- Консьюмер компактед-топика обязан обрабатывать nil Value до декодера.
- Компакция фоновая: темп задают min.cleanable.dirty.ratio и min.compaction.lag.ms.
- История значений — работа обычного топика или БД; компактед-топик хранит только настоящее.

## Домашнее задание

Создайте через CreateTopics компактед-топик prices-latest с cleanup.policy=compact и min.compaction.lag.ms в минуту, напишите продюсером десять версий по каждому из трёх тикеров и соберите кеш перечитыванием с kafka.FirstOffset, обрабатывая nil Value как удаление. Отправьте tombstone по одному тикеру и опишите по delete.retention.ms, когда ключ исчезнет из журнала и почему перечитывание до истечения срока увидит маркер. Затем на обычном топике установите retention.ms в одну минуту, запишите события и через несколько минут перечитайте журнал: зафиксируйте, какие оффсеты исчезли, и объясните по устройству сегментов, почему чистка не удалила середину файла. Финал: сформулируйте, какие из ваших потоков — таблицы, а какие — окна событий, и какой cleanup.policy заслуживает каждый.

## Вопросы и ответы по уроку

### Компакция удаляет старые версии сразу после записи новой?

Нет: компакция выполняется фоновым потоком брокера и запускается, когда «грязная» доля журнала дожимает min.cleanable.dirty.ratio. Свежие записи защищены min.compaction.lag.ms, чтобы все консьюмеры успели их прочитать. Поэтому между записью сотой версии ключа и исчезновением девяноста девяти прежних проходит неопределённое время — проектируйте так, как будто старые версии могут доезжать всегда, а «последнее значение» гарантируется только после компакции.

### С какого оффсета читать компактед-топик: с нуля или с последнего?

С kafka.FirstOffset — самого раннего живого оффсета: после компакции в начале журнала лежит уже сжатый срез состояния, и вы соберёте актуальную таблицу за один проход. С kafka.LastOffset вы пропустите всё лежащее и увидите только будущие записи — для пересборки кеша это бесполезно. Конкретный нулевой оффсет тоже плох: старые сегменты могли уйти по ретеншену, и чтение упадёт на отсутствующей позиции, тогда как FirstOffset всегда резолвится в существующее начало.

### Можно ли совместить компакцию и ретеншен на одном топике?

Да: cleanup.policy принимает оба значения — compact,delete. Брокер будет и компактировать журнал по ключам, и удалять сегменты по retention.ms и retention.bytes. Гибрид типичен для топиков конфигураций и статусов, где нужно и последнее значение каждого ключа, и обязательное время жизни совсем старых записей: например, сессии пользователей, которые должны умирать через сутки независимо от того, писал ли их ключ tombstone или нет.
