---
title: "Durability: acks и ISR"
description: "Durability в kafka-go: RequiredAcks RequireNone, RequireOne и RequireAll, ISR и min.insync.replicas, unclean leader election и ошибка NotEnoughReplicas."
course: "Kafka в Go: продюсеры и консьюмеры на kafka-go"
url: "https://golangmaster.ru/courses/kafka-go/lessons/15-durability/"
published: "2026-09-19"
updated: "2026-09-29"
minutes: 20
---

# Durability: acks и ISR

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

Kafka durability — это договор о том, сколько узлов кластера обязаны получить событие, прежде чем продюсер сочтёт его записанным, и в kafka-go за него отвечают поле RequiredAcks и брокерская настройка min.insync.replicas. Разберём механику ISR, цену каждого уровня acks, арифметику кворума и ошибку NotEnoughReplicas, которая приходит, когда надёжность настроена, а кластер похудел.

Продюсер отчитался: ошибка nil, сообщение доставлено. Через час узел, на котором жила партиция, вывели из кластера вместе с отказавшим диском — и события нет. Код не врал: он честно сказал, что получил подтверждение, вопрос лишь в том, чьё это было подтверждение — одного брокера или всей команды копий. Durability в Kafka определяется не флагом «надёжно» и не доброй волей брокера, а арифметикой: сколько копий партиции существует, сколько из них сейчас догнали лидера и сколько обязаны подтвердить запись, чтобы WriteMessages вернул nil. В арифметике участвуют три числа: replication factor топика, уровень RequiredAcks на продюсере и min.insync.replicas на брокере, а связывает их ISR — набор живых реплик. Второй урок показал реплики с высоты птичьего полёта и обещал полную механику, десятый трогал RequiredAcks как рычаг производительности; здесь собираем обе линии в одну систему и считаем цену надёжности в миллисекундах, ошибках и потерянных записях.

### ISR kafka: реплики, догнавшие лидера

ISR (in-sync replicas) — центральное понятие всей kafka durability: набор реплик партиции, которые успевают за лидером. Лидер принимает записи и чтения, фолловеры непрерывно подтягивают его журнал себе; пока фолловер укладывается в отставание, он числится синхронным, отстал или умер — вылетает из ISR, догнал — возвращается. Отсюда ключевое свойство: ISR — не константа конфига, а состояние кластера в момент записи. Replication factor задаёт, сколько копий партиции существует вообще, ISR говорит, сколько из них живы и синхронны прямо сейчас, и эти числа расходятся при каждом сбое. В kafka-go состав ISR напрямую не виден: клиент узнаёт о его сужении только по ответам брокера — ошибкам записи или паузам. Из этого следует практический вывод, о котором честно предупредим сразу: durability нельзя проверить на одиночном брокере — ISR в полный рост существует только в кластере из трёх узлов, а Docker-стенд из третьего урока хорош для механики API, но не для модели отказов.

### RequiredAcks: RequireNone, RequireOne, RequireAll

Поле RequiredAcks в WriterConfig задаёт, чьё подтверждение ждёт продюсер. В kafka-go это отдельный тип kafka.RequiredAcks с тремя константами: RequireNone — ноль, RequireOne — единица, RequireAll — минус один. RequireNone — fire-and-forget: продюсер не ждёт ответа брокера вовсе, и даже факт записи остаётся неизвестным; скорость максимальная, гарантий ноль. RequireOne — подтверждение лидера партиции: событие легло в его журнал, но о репликах никто не спрашивал. RequireAll — запись должна подтвердить весь текущий ISR: сколько синхронных копий живо в этот момент, столько и подтвердят. Важная деталь конструктора: NewWriter при нулевом значении поля подставляет RequireAll — это историческое поведение, закомментированное прямо в исходниках writer.go, поэтому дефолтный Writer из четвёртого урока оказался самым надёжным, а не самым быстрым. Отсюда правило записи конфига: константы пишутся по именам, никогда числами — иначе RequiredAcks: 0 в конфиге читается как «ноль подтверждений», хотя фактически означает «весь ISR».

### Writer с явным уровнем подтверждений

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:      []string{"broker-1:9092", "broker-2:9092", "broker-3:9092"},
        Topic:        "payments",
        Balancer:     &kafka.Hash{},
        RequiredAcks: kafka.RequireAll, // ждём подтверждение всего ISR
})
defer writer.Close()

err := writer.WriteMessages(ctx, kafka.Message{
        Key:   []byte(order.ID),
        Value: payload,
})
if err != nil {
        log.Printf("payment %s: %v", order.ID, err)
}
```

RequiredAcks записан константой kafka.RequireAll, а не минус единицей: нулевое значение поля конфликтует с логикой NewWriter, которая подставляет RequireAll вместо него, и читается неоднозначно. Три адреса брокеров — не украшение: Writer должен достучаться до кластера, чтобы узнать состав партиций и их реплик. Ошибка из WriteMessages — послеретраевый итог: Writer повторит пачку до MaxAttempts с бэкоффом, прежде чем отдать её вызывающему.

### Проверка: дефолт NewWriter

**Вопрос:** Writer создан через kafka.NewWriter без явного RequiredAcks. Чего ждёт продюсер при каждой записи?

- ✅ Подтверждения всего текущего ISR: NewWriter подставляет RequireAll при нуле
- Подтверждения только лидера партиции
- Никакого подтверждения: ноль означает fire-and-forget
- Подтверждения ровно двух реплик: это дефолтный кворум

В NewWriter нулевое значение RequiredAcks заменяется на RequireAll — историческое поведение конструктора, зафиксированное в исходниках. Дефолтный Writer поэтому надёжен, а не быстр; чтобы получить fire-and-forget, потребуется явно записать kafka.RequireNone в конфиге.

### min.insync.replicas: арифметика кворума записи

min.insync.replicas — брокерский минимум: сколько реплик партиции должны состоять в ISR, чтобы лидер вообще принимал записи с RequireAll. Три числа складываются в триаду: replication factor — сколько копий всего, min.insync.replicas — сколько минимум живых и синхронных, RequiredAcks — что требует продюсер. Классическая конфигурация прод-класса: три реплики, минимум две, RequireAll. Живы все три — запись подтверждает весь ISR. Умер один — ISR сжался до двух, минимум выполнен, записи идут, никто ничего не заметил. Умер второй — ISR равен одному, ниже минимума, и брокер отвечает на produce-запрос кодом NotEnoughReplicas: продюсер не может записать событие, и это не сбой, а срабатывание защиты от молчаливой потери. Kafka здесь осознанно жертвует доступностью записи ради сохранности данных — инвертировать выбор умеет unclean leader election, о нём ниже. Минимум ставится на уровне брокера или отдельного топика, и топиковый уровень перекрывает брокерский — на критичных топиках его фиксируют явно, а не наследуют. Ещё один аргумент фиксировать: минимум действует только против RequireAll. Для RequireOne он прозрачен: лидер подтвердил — запись состоялась, живи ISR или нет, поэтому топик с одинаковым min.insync.replicas ведёт себя по-разному под разными уровнями acks.

> **Живое место: прод-конфиг на локальном брокере.** Инцидент переноса конфига: разработчик скопировал на локальный Docker-брокер настройки продакшена, включая min.insync.replicas=2, и каждое сообщение стало падать с NotEnoughReplicas. Арифметика беспощадна: топик на одиночном брокере имеет одну реплику, ISR равен единице, минимум требует двух — писать некому, и защита сработала по расписанию. Рядом вторая ловушка: ReplicationFactor выше числа брокеров невозможен — CreateTopics откажет, копиям негде жить. Правило: локальный стенд живёт с ReplicationFactor: 1 и минимумом по умолчанию, а кластерная арифметика проверяется на кластере — три ноды в Docker собираются за вечер, если она действительно нужна.

### Ловим NotEnoughReplicas по позициям пачки

```go
err := writer.WriteMessages(ctx, msgs...)
if err != nil {
        var werr kafka.WriteErrors
        if errors.As(err, &werr) {
                for i := range werr {
                        if errors.Is(werr[i], kafka.NotEnoughReplicas) {
                                log.Printf("msg %d: ISR ниже минимума", i)
                                continue
                        }
                        if werr[i] != nil {
                                log.Printf("msg %d: %v", i, werr[i])
                        }
                }
        }
}
```

NotEnoughReplicas приходит на конкретные сообщения пачки, поэтому проверка идёт по позициям WriteErrors, как в двенадцатом уроке. errors.Is сравнивает с именованным кодом kafka.NotEnoughReplicas — целочисленной ошибкой протокола, объявленной в пакете kafka. Не путать с NotEnoughReplicasAfterAppend: первая означает отказ до записи, вторая — что сообщение легло в журнал лидера, но синхронных копий оказалось меньше требуемого.

### Проверка: NotEnoughReplicasAfterAppend

**Вопрос:** Брокер вернул NotEnoughReplicasAfterAppend. Что произошло с сообщением?

- ✅ Оно записано в журнал лидера, но синхронных копий оказалось меньше требуемого
- Оно отклонено до записи: ISR сжался ниже минимума
- Оно записано на все реплики: ошибка — ложное срабатывание
- Оно перенаправлено в другой топик: партиция недоступна

Суффикс AfterAppend — разница между «не записал» и «записал недостаточно надёжно»: сообщение уже в журнале лидера, но подтвердить его нужным числом реплик не удалось. При падении лидера такое событие может исчезнуть, поэтому его судьба — вопрос к мониторингу и повторам, а не к return nil.

### Что вернёт WriteMessages после перезапуска лидера?

```go
// кластер из трёх брокеров, топик payments:
// ReplicationFactor 3, min.insync.replicas 2,
// Writer с RequiredAcks: kafka.RequireAll.
// Брокер с лидером партиции уходит на рестарт,
// фолловеры живы и догнаны. MaxAttempts — дефолтные.
err := writer.WriteMessages(ctx, msgs...)
```

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

- ✅ nil: координатор переизберёт лидера, Writer отретраит пачку по бэкоффу
- NotEnoughReplicas: без лидера записывать нельзя, попыток не будет
- Панику Writer: рестарт лидера — штатная причина паники
- MessageTooLargeError: ISR сжался, и батчи уменьшились

Рестарт лидера — временная неприятность: фолловеры живы, после выборов ISR снова полный, минимум выполняется. Writer ретраит временные ошибки до десяти попыток с бэкоффом, и вызов завершится nil, просто дольше обычного. Затяжное сужение ISR ниже минимума — другой сценарий: после исчерпания попыток вернётся NotEnoughReplicas.

### RequireOne и unclean leader election: где живут потери

RequireOne — компромиссный уровень, и его окно потери стоит увидеть глазами. Лидер подтвердил запись и умер до того, как фолловеры успели её скопировать: подтверждение было, данных больше нет. Новый лидер избирается из ISR, и записей, которых в его копии нет, просто не существует. У брокера есть ручка, способная сделать хуже: unclean.leader.election.enable. По умолчанию false — выборы идут только среди синхронных реплик, партиция может остаться недоступной, но подтверждённые данные переживают катастрофу. Если включить true — лидером может стать отставшая копия: доступность вернётся, а часть подтверждённых записей исчезнет; это осознанный выбор «доступность дороже согласованности», приемлемый для телеметрии и запрещённый для платежей. В kafka-go этой настройки нет — она живёт на брокере, и клиент узнаёт о её последствиях по пропавшим сообщениям. А RequireNone стирает сам вопрос durability: продюсер не получает ответа, и даже факт записи остаётся предметом веры, поэтому уровень годится только для потоков, чья потеря ничего не стоит.

> **RequireAll не работает вместе с Async.** RequiredAcks отвечает за то, чьё подтверждение ждать; Async — за то, ждать ли вообще. В асинхронном Writer вызов WriteMessages ставит сообщения в очередь и возвращается мгновенно, а подтверждения ISR проверяются фоновыми горутинами, чей результат виден только в Stats и Completion из десятого урока. Для платёжного потока это противоречие в самом ядре: лучший уровень acks при Async не даёт ни блокировки вызова, ни ошибки вызывающему. Durability собирается синхронным Writer: err из WriteMessages — единственное место, где отказ записи способен остановить бизнес-операцию до того, как она случилась.

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

- Триада: ReplicationFactor — копий всего, min.insync.replicas — минимум живых, RequiredAcks — требование продюсера.
- NewWriter при нуле подставляет RequireAll: дефолт надёжен; RequireNone пишите явно и только там, где потеря легальна.
- RequireAll с минимумом два на кластере из трёх узлов переживает смерть одного брокера без паузы записи и без потери.
- NotEnoughReplicas — не сбой, а защита: кластер отказывается писать, пока ISR ниже минимума, вместо того чтобы терять молча.
- Unclean leader election: false — данные важнее доступности; true — только для потоков, где потеря подтверждённых записей легальна.
- Durability проверяется на кластере: одиночный Docker-брокер показывает механику API, но не модель отказов.

### Durability топика в kafka-go: ReplicationFactor и ConfigEntries

Половина kafka durability живёт в конфигурации топика, и kafka-go позволяет задать её при создании. TopicConfig в CreateTopicsRequest несёт ReplicationFactor, а ConfigEntries — брокерские настройки топика парами имя-значение: kafka.ConfigEntry с полями ConfigName и ConfigValue, значение строковое. Так min.insync.replicas фиксируется в коде вместе с топиком, а не вспоминается в консоли. CreateTopics — метод Client из третьего урока, полное управление конфигами топиков — тема шестнадцатого. Отсюда практическое следствие о сочетаниях: RequireAll против топика с ReplicationFactor: 1 — деньги в никуда, потому что ISR всегда состоит из одного лидера, и подтверждение всего набора совпадает с RequireOne. Долю надёжности поднимает число копий, уровень acks лишь решает, ждать ли их согласованности на каждой записи. И ещё раз про формат честности: интерактивы ниже разыгрывают арифметику ISR на программах в чистом Go без сети — но сам продюсер против брокера запускается только локально.

### Уровни durability одним столом

| Конфигурация | Что даёт | Цена |
| --- | --- | --- |
| RequireNone | Ноль подтверждений: даже факт записи неизвестен | Максимальная скорость, потеря при любом сбое |
| RequireOne | Подтверждение лидера: запись в его журнал | Быстро; окно потери до репликации |
| RequireAll | Подтверждение всего текущего ISR | Медленнее; потеря лишь при утрате всего ISR |
| RequireAll + min.insync.replicas 2 | Кворум: минимум два живых узла на запись | Пишет, пока живы две реплики; дальше NotEnoughReplicas |
| RequireAll + unclean leader election | Строгие acks, но выборы из отставших копий стирают подтверждённое | Сочетание противоречиво: строгие acks требуют false |

### Соберите строгий Writer и топик под него

Допишите уровень подтверждений в конфиге Writer и имя брокерского минимума в настройках топика.

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:      []string{"broker-1:9092"},
        Topic:        "payments",
        RequiredAcks: kafka.___,
})

_, err := client.CreateTopics(ctx, &kafka.CreateTopicsRequest{
        Topics: []kafka.TopicConfig{
                {
                        Topic:             "payments",
                        NumPartitions:     3,
                        ReplicationFactor: 3,
                        ConfigEntries: []kafka.ConfigEntry{
                                {ConfigName: "___", ConfigValue: "2"},
                        },
                },
        },
})
```

Подсказка: Самый строгий уровень подтверждений — константа RequireAll, а минимум живых реплик называется min.insync.replicas.

Правильные ответы в пропусках: `RequireAll`, `min.insync.replicas`

### Соберите запись с разбором durability-ошибок

Расставьте строки: конфиг с RequireAll, закрытие, отправка пачки, распознавание WriteErrors, цикл по позициям с проверкой NotEnoughReplicas.

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

1. `writer := kafka.NewWriter(kafka.WriterConfig{Topic: "payments", RequiredAcks: kafka.RequireAll})`
2. `defer writer.Close()`
3. `err := writer.WriteMessages(ctx, msgs...)`
4. `var werr kafka.WriteErrors`
5. `if !errors.As(err, &werr) { return err }`
6. `for i := range werr {`
7. `if errors.Is(werr[i], kafka.NotEnoughReplicas) { log.Printf("msg %d: ISR ниже минимума", i) }`
8. `}`

### Карточки: acks, ISR и цена надёжности

- **ISR** — In-sync replicas: реплики партиции, догнавшие лидера. Набор динамический: отстал или умер — вылетел, догнал — вернулся.
- **kafka.RequireNone** — Ноль подтверждений: fire-and-forget. NewWriter заменяет ноль на RequireAll, поэтому константа пишется явно.
- **kafka.RequireOne** — Подтверждение лидера партиции: быстро, но между подтверждением и репликацией остаётся окно потери.
- **kafka.RequireAll** — Минус один: подтверждение всего текущего ISR. Самый строгий уровень acks в kafka-go.
- **min.insync.replicas** — Брокерский минимум живых реплик для записи с RequireAll; ниже — брокер отвечает NotEnoughReplicas.
- **NotEnoughReplicasAfterAppend** — Сообщение записано в журнал лидера, но синхронных копий оказалось меньше требуемого: судьба при падении лидера не гарантирована.

Контракт durability собран: три числа и одно состояние. ReplicationFactor задаёт копии, min.insync.replicas — минимальный кворум записи, RequiredAcks — требование продюсера, а ISR сводит их в решение, которое брокер принимает на каждой записи: подтвердить, отретраить или отказать с NotEnoughReplicas. Дальше по курсу: [урок про концепции брокера](/courses/kafka-go/lessons/02-koncepty-brokera/) покажет исходную картину реплик, с которой мы здесь сверялись; [урок про батчи](/courses/kafka-go/lessons/10-producer-batchi/) — где RequiredAcks стоял среди рычагов производительности; [урок про ошибки продюсера](/courses/kafka-go/lessons/12-oshibki-producer/) — устройство WriteErrors, по которому мы ловили отказ кворума; а [урок про Admin API](/courses/kafka-go/lessons/16-admin-topiki/) вырастит CreateTopics в полноценное управление топиками. Для живого прогона нужен локальный кластер: одиночный брокер из Docker покажет механику констант и ошибок, арифметику ISR — только три ноды. Начните с одиночного: даже на нём NotEnoughReplicas при min.insync.replicas=2 воспроизводится за минуту и объясняет цену настройки лучше любой схемы.

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

- Durability — арифметика: replication factor, min.insync.replicas и RequiredAcks складываются в решение брокера через ISR.
- ISR — живые догнавшие лидера реплики партиции: состав меняется при каждом сбое и восстановлении.
- RequiredAcks: RequireNone — ноль, RequireOne — лидер, RequireAll — весь текущий ISR; NewWriter при нуле подставляет RequireAll.
- RequireAll с минимумом два на трёх репликах — конфигурация прод-класса: переживает смерть одного узла без потери.
- NotEnoughReplicas — защита от молчаливой потери: кластер отказывает в записи, пока ISR ниже минимума.
- Unclean leader election включается только там, где потеря подтверждённых записей легальна; для платежей — false.
- RequireAll имеет смысл с синхронным Writer: при Async ошибка не доходит до вызывающего кода.
- Проверка durability — на кластере: одиночный Docker-брокер показывает API, но не модель отказов.

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

Поднимите локальный стенд из третьего урока и создайте топик payments с ReplicationFactor: 1, затем Writer с RequiredAcks: kafka.RequireAll — убедитесь, что записи проходят: ISR из одной реплики удовлетворяет дефолтному min.insync.replicas. Пересоздайте топик с ConfigEntries, задающими min.insync.replicas=2, повторите отправку и разберите из WriteErrors код NotEnoughReplicas, объяснив по арифметике ISR, почему брокер прав. Если соберёте кластер из трёх нод — уроните один узел и проверьте, что запись продолжилась, а после гибели второго включился кворум-отказ; если нет — запишите, какие сценарии на одиночном стенде недоступны и почему. Финал: отправьте ту же пачку с Async: true и сформулируйте, почему RequiredAcks перестал влиять на судьбу вызывающего кода.

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

### Почему Writer с RequireAll работает на локальном Docker-брокере, хотя ISR всегда один?

Потому что дефолт min.insync.replicas — единица, а ISR из одного лидера ему удовлетворяет: RequireAll ждёт подтверждения всех синхронных реплик, сколько бы их ни было сейчас. Проблемы начинаются с переносом конфига: min.insync.replicas=2 на одиночном брокере отказывает в каждой записи с NotEnoughReplicas. RequireAll задаёт не число узлов, а требование к текущему ISR: один живой — одно подтверждение, три живых — три.

### Отличается ли RequireAll от RequireOne на топике с одной репликой?

Практически нет: ISR всегда состоит из лидера, поэтому подтверждение всего набора совпадает с подтверждением лидера. Долю надёжности здесь поднимает не RequiredAcks, а replication factor: с ReplicationFactor: 1 любые acks дают единственную копию, и гибель диска означает потерю. Durability начинается с числа копий, а уровень acks лишь решает, ждать ли их согласованности на каждой записи. Разница всё же есть и проявляется при расширении: если число реплик поднимут до двух, топик с RequireAll сразу начнёт ждать вторую копию, а RequireOne продолжит жить по-старому — за таким переходом стоит следить в обоих местах конфигурации.

### Что делать с NotEnoughReplicas в продакшене?

Сначала понять, что это сигнал, а не баг: ISR сжался ниже минимума, и запись останавливается намеренно. Writer ретраит пачку сам, до MaxAttempts с бэкоффом, поэтому кратковременный рестарт брокера проходит незаметно. Длительная ошибка — повод смотреть кластер: упавшие узлы, отставшие фолловеры, место на диске. Понижать min.insync.replicas ради восстановления записи — путь к потере данных: сначала решите, зачем ставили минимум, а потом уже меняйте его осознанно.
