惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Y
Y Combinator Blog
有赞技术团队
有赞技术团队
J
Java Code Geeks
H
Hackread – Cybersecurity News, Data Breaches, AI and More
美团技术团队
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Hugging Face - Blog
Hugging Face - Blog
人人都是产品经理
人人都是产品经理
酷 壳 – CoolShell
酷 壳 – CoolShell
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
C
Check Point Blog
博客园 - 【当耐特】
The GitHub Blog
The GitHub Blog
Recent Announcements
Recent Announcements
The Cloudflare Blog
Microsoft Azure Blog
Microsoft Azure Blog
腾讯CDC
Vercel News
Vercel News
IT之家
IT之家
MyScale Blog
MyScale Blog
博客园_首页
Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
罗磊的独立博客

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
Idempotency keys: 5 граблей, которые мы поймали на проде
Владимир Синявский · 2026-05-27 · via Все публикации подряд на Хабре

Сложный

11 мин

18K

Пятница, 23:47. PagerDuty: “Платёж AmEx, провайдер вернул 5xx три раза подряд, билеты не зарезервированы.” Открываю логи – действительно 3 ответа провайдера с 5xx, ни одной успешной транзакции по нашей базе. Закрываю как временный сбой на стороне провайдера, пишу короткую сводку в дежурный чат и иду досматривать. Через 40 минут второй алерт – уже от ночной поддержки: клиент прислал скрин выписки, 3 списания подряд за одну бронь. У клиента рейс через 6 часов, ему нужна действующая бронь и подтверждение, что он завтра нормально улетит, а не тикет в поддержку.

Мы делали b2b-платформу для деловых поездок: бронь авиа, отели, трансфер, страховка, в финале – оплата корпоративной картой через платежный шлюз. С этой ночи началась история, которая закончилась переписыванием всего платёжного слоя нашего booking-сервиса. По дороге мы поймали 5 граблей, о которых хочу рассказать.

Idempotency key – это контракт: если запрос дойдёт до сервера дважды, операция выполнится один раз. Звучит как простая техническая договорённость. На проде контракт размазан между мобильным клиентом, нашим сервером, базой и внешним карточным провайдером – и каждый участник понимает слово “дважды” по-своему. Клиент считает дублем повторный запрос с тем же UUID. Провайдер – запрос с теми же реквизитами в течение нескольких секунд. База данных вообще не знает про ключи, только про строки в таблице. Сеть ненадёжна: запрос может дойти до провайдера, обработаться и вернуть таймаут на нашей стороне. Мы не знаем, что деньги уже списаны, и повторяем запрос. Двойное списание гарантировано.

Интуитивное решение – генерировать UUID на клиенте, класть в заголовок Idempotency-Key, сохранять ответ в Redis на 24 часа. Покрывает один сценарий из пяти. Остальные четыре выясняются, когда повторы уже летят и откатиться некуда. Я был уверен, что понимаю, как это работает – пока мы с командой не начали разбирать, что именно произошло с теми тремя транзакциями. Оказалось, у нас не было единого ответа даже на вопрос “кто должен генерировать ключ”.

Грабля 1: ключ создаётся клиентом

Первое, что показал постмортем – ключ генерился не там, где надо. В нашем мобильном PWA на Angular был http-интерсептор, который централизованно навешивал Idempotency-Key на каждый исходящий платёжный запрос. На бумаге решение правильное: централизованно, без копипасты по сервисам, разработчики гарантированно не забудут. На практике в коде это выглядело примерно так:

// http-интерсептор: централизованно навешивает Idempotency-Key
@Injectable()
export class IdempotencyInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<unknown>, next: HttpHandler) {
    if (!req.url.includes('/payments')) return next.handle(req);
    const idempotencyKey = crypto.randomUUID(); // BAD: новый UUID на каждом исходящем запросе
    return next.handle(req.clone({ setHeaders: { 'Idempotency-Key': idempotencyKey } }));
  }
}

Метод сервиса pay вызывался один раз на клик – тут всё корректно. А вот в подписке поверх его observable стоял rxjs retry({ count: 3, delay: 500 }) – защита от коротких сетевых сбоев, чтобы коротко-живущие 5xx не доходили до пользователя. На каждой retry-попытке observable переподписывался, http-запрос отправлялся заново, проходил через интерсептор – и crypto.randomUUID() в intercept() генерил новый ключ. Из логов мы потом нашли: тот самый инцидент дал 3 разных UUID в течение полутора секунд. С точки зрения пользователя – одно нажатие. С точки зрения нашей дедупликации – три события.

Лекарство простое, как только понимаешь проблему: ключ должен жить столько же, сколько намерение пользователя. Один ключ на одно нажатие “Оплатить”, а не один на одну отправку. Где именно генерировать – в обработчике клика и пробрасывать в pay() параметром, в локальном хранилище клиента, или на сервере как идемпотентный POST /payment-intents – вопрос архитектуры. Принципиально другое: ключ привязан к намерению пользователя, не к технической попытке. На весь период повторов ключ один, даже если попыток было десять.

Грабля 2: TTL ключа короче окна retry

С генерацией ключа на клиенте разобрались, перешли к серверу. Хранилище ключей идемпотентности было в Redis с ttl 24 часа. На дашборде эта цифра выглядела разумно: за сутки любой сценарий повторов покрыт, новые ключи живут долго, старые освобождают память.

Только service worker нашего PWA об этой цифре не знал. У него своя политика повторов: при потере связи запрос складывается в очередь background sync API, и service worker пытается допослать его, пока связь не вернётся – до недели или дольше, в зависимости от настроек браузера. Это норма: пользователь в самолёте, в метро, в зоне без покрытия – клиенту негде хранить, кроме как у себя.

У нас был случай с менеджером в командировке, который потерял связь, вернулся через 4 дня, открыл приложение – и service worker тихонько дослал застрявшие платежи из background-очереди. К этому моменту Redis-хранилище забыло про эти ключи на трое суток раньше. Сервер обработал запросы как новые. Ушли двойные операции, разбираться пришлось через провайдера.

Лекарство: TTL ключа покрывает максимальное окно повторов клиента плюс запас. Если политика повторов клиента 7 дней, ttl 30 дней – не “слишком долго”, а “правильно”. Память дешевле двойных списаний. Если массив ключей растёт неприемлемо – переноси ключи в надёжное хранилище (Postgres вместо Redis, или S3 плюс быстрый кэш), но не урезай ttl до уровня “хватит на один поход в метро”.

Грабля 3: идемпотентность по намерению vs по запросу

После исправления первой грабли у нас была чистая логика: 1 ключ на 1 нажатие “Оплатить”. Думали, что закрыли тему. Через 2 недели прилетел почти идентичный инцидент. Покопались – на этот раз дело было не в клиенте.

Пользователь нажал “Оплатить” на зависшем фронте. Спиннер крутился, ответа от сервера не было – он нажал ещё раз. Через минуту, всё ещё без ответа, нажал ещё раз. Это типичный паттерн: важная транзакция, медленная сеть, пользователь не уверен, что первое нажатие сработало. С точки зрения трёх http-запросов это были три абсолютно легальных вызова с тремя разными intent-id (мы поправили клиента, новый ключ создавался при каждом отдельном клике). По RFC – три разных намерения. По жизни – одно намерение, оформленное трижды.

Простейшая наивная реализация без какой-либо идемпотентности:

public async Task<PaymentResult> MakePaymentAsync(
    UserId userId, Money amount, BookingId bookingId)
{
    var payment = new Payment(userId, amount, bookingId);
    await db.Payments.AddAsync(payment);
    await db.SaveChangesAsync();

    var providerResult = await paymentProvider.ChargeAsync(payment);
    payment.Apply(providerResult);
    await db.SaveChangesAsync();

    return payment.Result;
}

Три клика – три отдельных платежа в БД, три похода в провайдера, три списания. Чтобы дедуплицировать, инстинктивно хочется навесить дедуп по содержимому запроса – собрать ключ из его полей и сохранить ответ:

public async Task<PaymentResult> MakePaymentAsync(
    UserId userId, Money amount, BookingId bookingId, DateTime requestTimestamp)
{
    var key = Sha256Hash(userId, amount, bookingId, requestTimestamp); // BAD: timestamp ломает идею
    if (await store.TryGetAsync(key) is { } cached)
        return cached;

    var result = await DoChargeAsync(userId, amount, bookingId);
    await store.SetAsync(key, result, TimeSpan.FromHours(24));
    return result;
}

Беда в том, что три клика приходят с тремя разными requestTimestamp – значит и три разных хеша. Дедуп не сработал. Уберите requestTimestamp – будет другая беда: первый платёж провалился soft decline’ом от банка (перегружен эмитент, временный fraud-флаг, проблема на сети ACQ), пользователь через час пробует ещё раз. Тот же userId, amount, bookingId, currency, тот же хеш. Хранилище вернёт закэшированный fail на новое легитимное намерение – пока ключ не истечёт, оплатить эту бронь нельзя.

Классический компромисс со временем в ключе. С requestTimestamp – защита от серии быстрых нажатий не работает. Без requestTimestamp – серия обработана, но честный повтор через час залипает на старом отказе. И там, и там – часть запросов отрабатывает неверно.

Решение, к которому мы пришли: ключ собирается из бизнес-семантики операции с явным окном намерения, а попадание в хранилище идёт атомарно – чтобы два одновременных повтора не проскочили проверку наперегонки.

public async Task<PaymentResult> MakePaymentAsync(
    UserId userId, Money amount, BookingId bookingId)
{
    var intentKey = BuildIntentKey(userId, bookingId, amount);

    // Атомарно: резерв ключа со статусом InProgress, либо получение существующего слота
    var slot = await store.AcquireOrGetAsync(intentKey, TimeSpan.FromDays(30));
    if (slot.IsCompleted) return slot.CachedResult;
    if (slot.IsInProgress) throw new ConflictException("operation already in flight");

    // slot.IsReserved – мы первые, кто застолбил этот ключ
    try
    {
        var payment = new Payment(userId, amount, bookingId);
        await db.Payments.AddAsync(payment);
        await db.SaveChangesAsync();

        var providerResult = await paymentProvider.ChargeAsync(payment);
        payment.Apply(providerResult);
        await db.SaveChangesAsync();

        await store.CompleteAsync(intentKey, payment.Result);
        return payment.Result;
    }
    catch
    {
        await store.ReleaseReservationAsync(intentKey);
        throw;
    }
}

static string BuildIntentKey(UserId u, BookingId b, Money m) =>
    $"pay:{u}:{b}:{m.Amount}:{m.Currency}:{Window5Min(DateTime.UtcNow):O}";

Ключ собирается из четырёх параметров и временного окна:

  • userId – привязка к субъекту

  • bookingId + amount + currency – содержание намерения

  • Window5Min(now) – окно, в котором повторное нажатие считается тем же намерением

Атомарный AcquireOrGetAsync под капотом – это SETNX с TTL в Redis или INSERT ... ON CONFLICT DO NOTHING RETURNING в Postgres. Возвращает один из трёх статусов: завершено (отдаём кэш), в работе (второй параллельный запрос получает 409 и инструкцию повторить через секунду), зарезервировано нами (делаем операцию). Обвязка вокруг бизнес-логики выглядит шумно – в проде это уходит в декоратор/middleware над платёжным сервисом, чтобы методы не разрастались boilerplate-ом.

Через 2 часа после первого платежа за ту же бронь – это уже новое намерение, новый ключ, новая операция. В 5-минутном окне после первого – повтор. Окно 5 минут – компромисс: достаточно, чтобы вместить серию повторов от мобильного клиента и серию быстрых нажатий в зависший UI; недостаточно, чтобы случайно склеить настоящие повторы. Реальный пользователь, делающий 3 осмысленных оплаты одной и той же брони в течение 5 минут – редкая патология, заслуживающая ручного разбирательства, а не автодедупа.

Урок этой грабли – пожалуй, самый важный в посте. Идемпотентность – свойство бизнес-операции, а не http-запроса. RFC задаёт механику заголовка Idempotency-Key, а вот семантику – что считать одной операцией, а что разными – задаёт ваша бизнес-логика. Дедуп на трансляции http-заголовка работает в одном сценарии из пяти; дедуп на бизнес-намерении накрывает повторы, серии нажатий, переотправку из сети и пользовательскую неуверенность одним способом.

Грабля 4: внешний неидемпотентный вызов

Поправили клиента, переделали хранилище на intent-key с атомарным резервом. Внутри нашей системы идемпотентность теперь устроена нормально. И снова прилетел инцидент с двойным списанием – уже третий, к этому моменту мы перестали удивляться.

На этот раз сценарий сложнее. Пользователь нажал 1 раз. Наш intent-key один. Хранилище сказало “такого ещё не было, резервирую за тобой”. Вызвали paymentProvider.ChargeAsync(payment). Платёжный провайдер провёл операцию, списал деньги, отправил ответ. По дороге балансировщик на стороне провайдера отвалился, и наш сервер получил таймаут. Мы пометили операцию как требующую повтора, фоновый процесс через минуту повторил вызов. Провайдер обработал второй вызов как новый – он не понимал наш intent-key. Списались деньги второй раз.

Граница нашей идемпотентности заканчивалась на границе системы. Дальше за неё отвечает провайдер – и многие провайдеры этого не делают.

Лекарство в общем виде – двухфазная фиксация: сначала зарезервировать на стороне провайдера reference, потом по нему провести commit. Reference создаётся идемпотентно (провайдер гарантирует), commit идемпотентен по тому же reference (провайдер гарантирует), а связку reference↔intentKey хранит наша сторона:

// двухфазная фиксация на провайдере, не понимающем наш intentKey
var providerRef = await provider.AcquireReferenceAsync(intentKey);
await store.SaveProviderRefAsync(intentKey, providerRef);
var charge = await provider.CommitAsync(providerRef);
// AcquireReferenceAsync идемпотентен; CommitAsync идемпотентен по ref

Тут, кстати, спрятан ещё один тонкий момент: между Acquire и Save сервер может упасть. Reference у провайдера уже есть, мы про него ещё не знаем – orphaned reference. Закрывается reconciliation-процессом, который периодически сверяет наши записи с провайдерскими и подтягивает потерявшиеся связки. Это уже соседняя тема, в этом посте просто запишем, что такая проблема в принципе существует, out of scope.

Если провайдер поддерживает собственный Idempotency-Key (Stripe, Adyen, CloudPayments – да; некоторые внутренние банковские шлюзы – нет), вариант проще: транслируем наш intentKey в их заголовок, ответственность лежит на стороне провайдера.

Если провайдер не поддерживает ничего из этого – оборачиваем его в свой адаптер с собственным reference-store и реализуем двухфазную модель на свой страх и риск. Это работает, но добавляет новые точки отказа.

Урок: идемпотентность цепочки – это идемпотентность самого слабого звена. Граница системы – граница ответственности. На каждом стыке между сервисами проверяем, что обе стороны понимают одно и то же слово “повтор”.

Грабля 5: response replay

Последняя грабля – самая невидимая. Допустим, всё предыдущее сделано правильно: ключ привязан к намерению, хранилище с длинным ttl, провайдер обёрнут в двухфазку. Через неделю после успешной операции пользователь – или service worker из background-очереди, или браузер, восстановивший вкладку из фона – повторяет запрос с тем же intent-key. Что вернуть?

Очевидный ответ – закэшированный успех. Храним результат первой операции, отдаём его без повторного похода в шлюз. Идемпотентно, быстро, копий в системе нет.

Проблема в том, что между первой операцией и повтором мир мог измениться. Реальный кейс: клиент оплатил бронь авиа, через два дня позвонил в поддержку и отменил – деньги вернули, бронь сняли. Ещё через неделю в его браузере отработал отложенный sync – service worker допослал тот же запрос из очереди (вкладка с зависшей оплатой долго оставалась открытой в фоне). Запрос приходит на сервер с тем же intent-key, хранилище отдаёт закэшированный “success”. Клиентское приложение показывает в истории: “оплата активна, бронь подтверждена”. В нашей же базе этой брони уже нет. Час разборок с поддержкой, пока юзер не понял, что видит призрак из прошлого. Технически идемпотентность не нарушена – вернули тот же ответ, что и в первый раз. но семантически – соврали о текущем состоянии.

Решение в общем виде: явно отделять “это replay” от “это первая попытка”. Вот 2 варианта, как это сделать:

Можно отдавать оригинальный ответ дословно – тогда клиент должен понимать, что ответ может быть устаревшим, и проверять состояние перед действием. Подходит, когда состояние не успевает уйти в другую сторону за окно retry.

Можно отдавать 200 OK со ссылкой на актуальный статус операции: /payments/{id}. Клиент идёт по ссылке, видит текущее состояние, принимает решение. Чуть дороже по запросам, семантически честнее: мы говорим “операция была, иди посмотри что с ней сейчас”.

В итоге response replay – часть контракта идемпотентности, не побочка. Если о нём не думать заранее, в проде он проявится тихо – через UX-баги, не через 500-е ошибки.

Точки отказа на одной картинке

Точки отказа идемпотентности – где может сломаться каждая из 5 граблей по ходу потока.

Точки отказа идемпотентности – где может сломаться каждая из 5 граблей по ходу потока.

sequenceDiagram
    autonumber
    participant C as Клиент (mobile)
    participant S as Сервер
    participant P as Карточный провайдер

    Note over C: Грабля 1 – ключ внутри метода<br/>→ 3 повтора, 3 разных ключа
    C->>S: POST /payments {amount, idempotencyKey}
    Note over S: Грабля 3 – ключ из тела, не из намерения<br/>3 разных http-key → 3 операции
    S->>S: store.AcquireOrGet(key)
    Note over S: Грабля 2 – TTL 24h, retry клиента до недели<br/>через 3 дня ключ истёк
    S->>P: charge(payment)
    Note over P: Грабля 4 – провайдер не понимает наш ключ<br/>ответ потерян → повтор → двойное списание
    P-->>S: result
    S->>S: store.Complete(key, result)
    S-->>C: 200 {result}
    Note over S,C: Грабля 5 – на повторе вернуть закэш. успех<br/>или статус-ссылку с актуальным состоянием?

Чем кончилось

Платёжный слой в итоге переписали довольно радикально. UUID на клиенте выкинули, intent-key собирали уже на сервере. Связали наш intent-key со ссылкой на операцию у провайдера через двухфазную фиксацию. Закэшированный успех на повтор заменили статус-ссылкой. TTL подняли до 30 дней, хранилище перенесли в надёжную БД. Пара эпизодов после релиза всё-таки была – на тестовом окружении забыли поправить флаг отказного режима, потом нашли. Но инциденты такого класса больше не повторялись.

Если перечитать все 5 граблей подряд, видно одно: это 5 частных случаев одной ошибки. Думали про дедупликацию http-запроса, а надо было – про идентификацию намерения пользователя. На уровне транспорта ловятся технические повторы (сети, флапы прокси, перезаливка из локальной очереди клиента) – а человеческое “я нажал три раза, потому что зависло” транспорт не видит, это уже уровень бизнес-операции. Pat Helland в “Idempotence is Not a Medical Condition” формулирует это как разделение слоёв: транспорт не гарантирует единственность доставки, потому что сеть может дублировать пакет в любой момент; приложение может гарантировать единственность эффекта – именно там и должна жить идемпотентность. Кто прибил её к http-заголовку, защищается от не той категории ошибок.


Что почитать

  • Pat Helland – “Idempotence is Not a Medical Condition” (ACM Queue, 2012). Фундаментальный аргумент против привязки идемпотентности к транспортному уровню. Короткая, читается за вечер.

  • Brandur Leach – “Designing robust and predictable APIs with idempotency” (Stripe blog, 2017). Каноничная инженерная реализация в продакшене: какие гарантии, как организован store, как обрабатывать concurrent requests с одним ключом.

  • IETF draft “The Idempotency http Header Field” (Yasskin, Nottingham). Куда движется стандарт. Знать полезно, верить как контракту – рано: статус draft.