Когда сервис получает SIGTERM, он начинает корректное завершение: перестаёт принимать новые запросы, дожидается текущих и останавливается. Большинство операций успевает завершиться в отведённые 25–30 секунд. Но если операция не уложилась — её можно повторить только при одном условии: повторный запрос не должен создавать дубль.
Это и есть идемпотентность — свойство операции, при котором выполнить её один раз или несколько раз одинаково безопасно.
Почему одного graceful shutdown недостаточно
Представьте: сервис обрабатывает заказ и посылает запрос на списание денег. В этот момент приходит SIGTERM. http.Server.Shutdown в Go дожидается, пока HTTP-соединения завершатся, но у него есть таймаут — обычно 25–30 секунд. Если за это время списание не завершилось, сервис останавливается принудительно.
Новый экземпляр сервиса поднимается и обрабатывает тот же заказ заново. Если запрос на списание не идемпотентен — деньги спишутся дважды.
Graceful shutdown даёт время, но не гарантирует завершённость. Идемпотентность — страховка на тот случай, когда времени не хватило.
Исходящий HTTP-запрос: Idempotency-Key
Самый распространённый случай — сервис вызывает внешний платёжный провайдер или другой сервис через HTTP. При повторном запросе сервер должен понять: «этот запрос я уже обработал».
Для этого используется заголовок Idempotency-Key — уникальный ключ, который клиент ставит на запрос. Сервер запоминает ключ и при повторе возвращает прежний результат, не выполняя операцию заново.
Главное правило: ключ генерируется один раз до всех повторных попыток, а не при каждой попытке.
// internal/adapters/out/payment/client.go
type ChargeCommand struct {
IdempotencyKey string
OrderID string
CustomerID string
AmountKopecks int64
}
func (c *Client) Charge(ctx context.Context, cmd ChargeCommand) error {
body, _ := json.Marshal(map[string]any{
"order_id": cmd.OrderID,
"customer_id": cmd.CustomerID,
"amount": cmd.AmountKopecks,
})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/charges", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", cmd.IdempotencyKey) // один ключ на всю операцию
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("charge request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("charge failed: status %d", resp.StatusCode)
}
return nil
}
Ключ формируется на уровне бизнес-логики — детерминированно, из данных самой операции:
// internal/usecase/confirm_order.go
func (uc *ConfirmOrderUseCase) Execute(ctx context.Context, cmd ConfirmOrderCommand) error {
order, err := uc.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("find order: %w", err)
}
// Детерминированный ключ: один и тот же для одного и того же запроса
idempotencyKey := order.ID + ":charge:" + cmd.RequestID
return uc.payment.Charge(ctx, payment.ChargeCommand{
IdempotencyKey: idempotencyKey,
OrderID: order.ID,
CustomerID: order.CustomerID,
AmountKopecks: order.TotalKopecks,
})
}
Частая ошибка — вызывать uuid.New() при каждой попытке. Тогда каждый повторный запрос уходит с новым ключом, и провайдер считает его отдельной операцией.
Входящий POST: Idempotency-Key на сервере
Исходящий ключ защищает соседа от нашего повтора. Входящий защищает нас от повтора клиента: мобильное приложение не получило ответ из-за обрыва и повторило POST /payments. Без ключа это второе списание.
// internal/adapters/in/http/payment_handler.go
func (h *PaymentHandler) Charge(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
httperr.Write(w, r, apperr.BadRequest("IDEMPOTENCY_KEY_REQUIRED", "нужен заголовок Idempotency-Key"))
return
}
var req ChargeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, err)
return
}
saved, err := h.keys.Find(r.Context(), key)
switch {
case err == nil && saved.RequestHash != hashOf(req): // тот же ключ, другое тело
httperr.Write(w, r, apperr.Conflict("IDEMPOTENCY_KEY_REUSED", "ключ уже использован с другим запросом"))
return
case err == nil && saved.Response != nil: // повтор: отдаём сохранённый ответ
writeStored(w, saved.Response)
return
}
...
}
Запись ключа делают до выполнения операции и атомарно, иначе два одновременных повтора оба не найдут ключ и оба спишут:
// internal/adapters/out/persistence/idempotency_keys.go
func (r *IdempotencyKeys) TryStart(ctx context.Context, key, requestHash string) (bool, error) {
tag, err := r.pool.Exec(ctx, `
INSERT INTO idempotency_key (key, request_hash, started_at)
VALUES ($1, $2, now())
ON CONFLICT (key) DO NOTHING`, key, requestHash)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil // 0 строк: ключ уже взят другим запросом
}
Первый запрос вставляет ключ и выполняет списание, второй получает ноль вставленных строк и либо отдаёт сохранённый ответ, либо отвечает 409, если первый ещё в работе. Ключ хранят с ответом и сроком жизни в сутки, чтобы таблица не росла бесконечно.
Kafka-consumer: дедупликация через processed_event
Потребитель на FetchMessage коммитит offset явно, через CommitMessages после обработки (автокоммит ReadMessage ровно поэтому и не берут). Если сервис получил сообщение из Kafka, обработал его, но не успел закоммитить offset до SIGTERM — следующий запуск consumer прочитает то же сообщение снова.
Стандартное решение: таблица processed_event, запись в которую делается в одной транзакции с основным действием. Если сообщение пришло повторно — запись уже есть, и операцию пропускаем.
// internal/consumer/order_consumer.go
func (c *OrderConsumer) handle(ctx context.Context, msg kafka.Message) error {
var event OrderConfirmedEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
return fmt.Errorf("unmarshal: %w", err)
}
tx, err := c.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
q := c.queries.WithTx(tx)
// Записываем факт обработки — в той же транзакции, что и само действие
inserted, err := q.InsertProcessedEvent(ctx, db.InsertProcessedEventParams{
EventID: event.ID,
ConsumerID: "billing-service",
})
if err != nil {
return fmt.Errorf("insert processed event: %w", err)
}
if !inserted {
return nil // уже обработано — пропускаем
}
if err := q.InsertOutboxEvent(ctx, db.InsertOutboxEventParams{
EventType: "ChargeRequested",
Payload: chargePayload(event, event.ID+":billing"),
}); err != nil {
return fmt.Errorf("enqueue charge for order %s: %w", event.OrderID, err)
}
return tx.Commit(ctx)
}
Самого списания — HTTP к платёжному провайдеру — в обработчике нет, и это не упрощение. Вызов провайдера с повторами длится до 20–30 секунд, а транзакция всё это время держала бы строку processed_event открытой; почему это опасно на остановке, разобрано в статье про Kafka shutdown. В транзакции остаются два факта: событие обработано и списание заказано — строка outbox ChargeRequested уже несёт ключ идемпотентности. HTTP делает отдельный воркер, и повтор с тем же ключом провайдер узнает.
SQL-запрос для дедупликации требует внимания: нельзя использовать ON CONFLICT DO NOTHING, потому что тогда RETURNING при конфликте вернёт 0 строк, что приведёт к ошибке pgx.ErrNoRows. Правильный вариант — DO UPDATE, который всегда возвращает строку:
-- name: InsertProcessedEvent :one
INSERT INTO processed_event (event_id, consumer_id, processed_at)
VALUES (@event_id, @consumer_id, now())
ON CONFLICT (event_id, consumer_id) DO UPDATE
SET processed_at = EXCLUDED.processed_at
RETURNING (xmax = 0) AS inserted;
При первой вставке xmax = 0 и возвращается inserted = true. При повторе строка обновляется, xmax становится ненулевым и возвращается inserted = false.
Есть и второй честный вариант: ON CONFLICT DO NOTHING без RETURNING, а запрос объявить в sqlc как :execrows — метод вернёт число затронутых строк, 1 или 0. Он не переписывает строку на каждом повторе, что заметно на горячих таблицах; xmax = 0 удобнее, когда нужна сама строка.
Outbox-relay: двухфазная публикация
Outbox — это таблица событий, которые нужно отправить в Kafka. Relay-горутина периодически читает из неё и публикует. Проблема: если сообщение в Kafka отправлено, но статус в таблице не обновлён до SIGTERM — relay отправит его снова.
Есть два подхода.
Если все downstream-consumer реализуют processed_event — relay может публиковать дубли, consumer их отфильтрует. Это проще:
// internal/scheduler/outbox_relay.go
func (r *OutboxRelay) processOneBatch(ctx context.Context) error {
// ctx приходит из Run: без отмены родителя, зато со своим таймаутом на пачку
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
q := r.queries.WithTx(tx)
events, err := q.LockPendingOutboxBatch(ctx, r.batchSize) // FOR UPDATE SKIP LOCKED
if err != nil {
return fmt.Errorf("lock outbox batch: %w", err)
}
if len(events) == 0 {
return nil
}
msgs := make([]kafka.Message, 0, len(events))
for _, e := range events {
msgs = append(msgs, kafka.Message{
Key: []byte(e.AggregateID),
Value: e.Payload,
Headers: []kafka.Header{{Key: "event-id", Value: []byte(e.ID)}},
})
}
if err := r.writer.WriteMessages(ctx, msgs...); err != nil {
return fmt.Errorf("write outbox batch: %w", err)
}
for _, e := range events {
if err := q.MarkOutboxDispatched(ctx, e.ID); err != nil {
return fmt.Errorf("mark dispatched %s: %w", e.ID, err)
}
}
return tx.Commit(ctx)
}
Два момента в этом коде. Пачка уходит одним WriteMessages: синхронный writer держит каждый вызов до полной пачки или до BatchTimeout (по умолчанию секунда), так что отправка по одному стоила бы секунду на событие. И контекст один на всё — и на транзакцию, и на Kafka. Частая ошибка — уберечь транзакцию контекстом без отмены, а WriteMessages оставить на отменяемом: SIGTERM роняет запись в Kafka, и пачка откатывается ровно там, где её берегли.
Если downstream-consumer не контролируется — нужна явная двухфазная публикация через статусы PENDING → PUBLISHING → PUBLISHED:
-- Перевод в PUBLISHING атомарно (FOR UPDATE SKIP LOCKED)
-- name: LockAndMarkPublishing :many
UPDATE outbox_event
SET status = 'PUBLISHING', locked_at = now()
WHERE id IN (
SELECT id FROM outbox_event
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT @batch_size
FOR UPDATE SKIP LOCKED
)
RETURNING *;
-- Перевод в PUBLISHED после успешной отправки
-- name: MarkPublished :exec
UPDATE outbox_event SET status = 'PUBLISHED', published_at = now() WHERE id = @id;
Если SIGTERM пришёл между отправкой и MarkPublished, строки зависают в PUBLISHING. Cleanup-горутина через настраиваемый TTL возвращает их в PENDING:
func (c *OutboxCleanup) Run(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := c.queries.ResetStuckPublishing(ctx, db.ResetStuckPublishingParams{
StuckAfter: pgtype.Interval{Microseconds: int64(c.stuckAfter / time.Microsecond), Valid: true},
}); err != nil {
slog.WarnContext(ctx, "outbox cleanup failed", "error", err)
}
}
}
}
-- name: ResetStuckPublishing :exec
UPDATE outbox_event
SET status = 'PENDING', locked_at = NULL
WHERE status = 'PUBLISHING'
AND locked_at < now() - @stuck_after::interval;
Контекст пачки в relay — зачем он отдельный
Relay-горутина получает отменяемый контекст, который отменяется при SIGTERM. Но если передавать этот же контекст в pgx-транзакцию — SIGTERM отменит транзакцию прямо в момент коммита, и пачка откатится.
Решение: контекст пачки строить не от отменяемого ctx, а от context.WithoutCancel(ctx) (Go 1.21) — значения контекста, trace и поля логгера, остаются, отмена родителя снимается, — и ограничить его собственным таймаутом; проверку отмены делать между итерациями:
func (r *OutboxRelay) Run(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return // не начинаем новый batch
case <-ticker.C:
}
batchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.batchTimeout)
if err := r.processOneBatch(batchCtx); err != nil {
slog.WarnContext(ctx, "outbox relay batch", "error", err)
}
cancel()
}
}
ctx.Done() — сигнал остановить цикл. Контекст пачки без отмены родителя — чтобы уже начатая транзакция могла закоммититься, а таймаут на нём — чтобы зависшая база не держала остановку до SIGKILL.
Повторные попытки без идемпотентного ключа — опасная комбинация
Повтор на уровне клиента полезен ровно до тех пор, пока у вызова есть ключ. Без ключа повтор после таймаута это второе списание, потому что первый запрос мог дойти:
// опасно: ключа нет, второй вызов после таймаута спишет повторно
receipt, err := retry.Do(ctx, func() (Receipt, error) {
return payments.Charge(ctx, ChargeRequest{OrderID: orderID, Amount: amount})
})
// безопасно: один ключ на все попытки одной команды
key := idempotencyKeyFor(cmd)
receipt, err := retry.Do(ctx, func() (Receipt, error) {
return payments.Charge(ctx, ChargeRequest{IdempotencyKey: key, OrderID: orderID, Amount: amount})
})
Ключ выводят из команды (идентификатор заказа плюс номер попытки оплаты), а не генерируют в момент вызова: тогда он переживёт и повтор внутри процесса, и повтор после перезапуска пода.
Коротко
- Graceful shutdown даёт время завершить операции, но не гарантирует это. Операции должны быть безопасны для повтора — это и есть идемпотентность.
- Для исходящего HTTP POST: ставить заголовок
Idempotency-Keyв адаптере. Ключ формируется детерминированно один раз на бизнес-операцию, а не генерируется заново при каждой попытке. - Для Kafka-consumer: записывать факт обработки в таблицу
processed_eventв одной pgx-транзакции с основным действием. SQL —ON CONFLICT DO UPDATE ... RETURNING (xmax = 0) AS insertedлибоDO NOTHINGс:execrows;DO NOTHINGс:oneпри конфликте даётpgx.ErrNoRows. Внешний HTTP из обработчика не зовут — заказывают его строкой outbox с готовым ключом. - Для outbox-relay: если downstream не контролируется — двухфазный статус
PENDING → PUBLISHING → PUBLISHEDплюс cleanup-горутина для зависших записей. - В relay-горутине контекст пачки строить от
context.WithoutCancel(ctx)с таймаутом и отдавать его и транзакции, иWriteMessages, а проверку отмены делать между итерациями черезselect { case <-ctx.Done(): return }.
Что почитать дальше
- HTTP drain в Go — как
http.Server.Shutdownдожидается запросов и что делать с долгими эндпоинтами. - Kafka shutdown в Go — consumer через context cancellation,
writer.Close(). - Фоновые задачи и outbox в Go —
sync.WaitGroup, outbox-relay цикл, cleanup-горутина. - БД и persistence в Go — порядок shutdown-последовательности,
pgxpool.Pool.Close()последним.