АйТи Фреш
Главная / Статьи / DevOps и автоматизация
DevOps и автоматизация

Может ли nginx повторно отправить POST и создать второй заказ

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~22 мин чтения
Может ли nginx повторно отправить POST и создать второй заказ
Иллюстрация к статье «Может ли nginx повторно отправить POST и создать второй заказ».

Звонок от клиента: «оплатили один раз, а заказа в базе два». В access.log nginx при этом одна строка и код 200, а в логе приложения — два одинаковых POST с разницей в минуту. Разбираю механику proxy_next_upstream: в каких случаях nginx действительно отправляет уже ушедший на бэкенд POST следующему серверу, что поменялось в версии 1.9.13, зачем люди сами включают non_idempotent и почему настройками фронта проблема дублей не закрывается — закрывается она в приложении.

Заказ задвоился, а в access.log одна строка

Типовая картина, с которой ко мне приходят. Интернет-магазин или личный кабинет, оформление заказа через POST. Пользователь нажал кнопку один раз, у него на экране крутился спиннер, потом всё прошло. А в базе два заказа с разницей в 60–70 секунд, оба с одинаковым составом корзины. Разработчик открывает access.log nginx и не находит там второго запроса: одна строка, статус 200, время обработки 62 секунды. Логично делается вывод «это фронт двойной клик пропустил» или «пользователь F5 нажал». А потом выясняется, что пользователь ничего не нажимал.

Второй запрос сделал сам nginx. И это не баг, а документированное поведение балансировки. Когда вы пишете proxy_pass на upstream-группу, nginx получает право при неудаче с одним сервером повторить запрос на следующем. В документации ngx_http_upstream_module это сформулировано прямо: «If an error occurs during communication with a server, the request will be passed to the next server, and so on until all of the functioning servers will be tried». Управляет этим директива proxy_next_upstream, и её значение по умолчанию — не «off».

Значение по умолчанию: proxy_next_upstream error timeout;. То есть у всех, кто вообще не трогал эту директиву, повторы включены на ошибку соединения и на тайм-аут. При этом в access.log остаётся одна строка на клиентский запрос — nginx пишет лог по факту завершения обработки запроса клиента, а не по числу походов на бэкенд. Именно поэтому проблему так тяжело поймать: следов в дефолтном log_format нет.

Что нужно держать в голове с самого начала — ограничение из той же документации: «passing a request to the next server is only possible if nothing has been sent to a client yet». Если nginx уже начал отдавать клиенту тело ответа и в середине передачи упал бэкенд, никакого повтора не будет, клиент получит обрыв. Повторы живут только в фазе «соединились с бэкендом и ждём заголовки ответа».

Дефолт — не «выключено». Если вы ни разу не писали proxy_next_upstream в своём конфиге, у вас работает `error timeout`, и повторы уже происходят. Вопрос только в том, попадают ли под них ваши POST.

Что поменялось в 1.9.13 и почему это защита не от всего

До марта 2016 года nginx повторял всё подряд. В релизе 1.9.13 от 29 марта 2016 появилась запись, которую стоит процитировать дословно, потому что вокруг неё много путаницы: «Change: non-idempotent requests (POST, LOCK, PATCH) are no longer passed to the next server by default if a request has been sent to a backend; the "non_idempotent" parameter of the "proxy_next_upstream" directive explicitly allows retrying such requests».

Ключевые слова тут — «if a request has been sent to a backend». Защита срабатывает не по методу как таковому, а по факту отправки. Разложим по стадиям. Соединение не установилось (connection refused, connect timeout, бэкенд перезапускается) — тело ещё никуда не ушло, nginx спокойно уходит на второй сервер, и это абсолютно безопасно, даже для POST. Соединение установилось, запрос ушёл, а заголовков ответа нет дольше proxy_read_timeout — вот тут по умолчанию, начиная с 1.9.13, повтора для POST не будет: nginx отдаст клиенту 504.

Теперь ловушка, о которой почти никто не помнит. В списке защищённых методов ровно три: POST, LOCK, PATCH. PUT и DELETE туда не входят, потому что RFC 9110 (раздел 9.2.2, «Idempotent Methods») относит их к идемпотентным. Формально всё верно. Практически — если у вас REST-API, где PUT /api/v1/invoices создаёт новый документ с автоинкрементным номером, а DELETE /api/v1/holds/{id} снимает резерв склада и пишет движение, то nginx повторит их без всяких non_idempotent и без предупреждений. Идемпотентность метода — это ваше обещание, а не свойство протокола, и nginx верит вам на слово.

И третье: параметр non_idempotent возвращает старое поведение целиком. Его массово растаскивают по конфигам из старых howto в духе «как убрать 502 при деплое» — строка выглядит безобидно, стоит в одном ряду с http_502 и http_504, и на код-ревью её пропускают. Вот такой конфиг я вижу у клиентов регулярно:

```nginx # так делать не надо: последняя строка разрешает nginx повторить # уже отправленный на бэкенд POST proxy_next_upstream error timeout http_502 http_504 non_idempotent; ``` non_idempotent — единственный параметр, который осознанно разрешает nginx создать второй заказ. Если вы не можете объяснить, зачем он у вас стоит, его там быть не должно.
Может ли nginx повторно отправить POST и создать второй заказ — схема
Схема к статье. Открыть схему в полном размере

Разбор: «Ягдташ и ружьё», дубли заказов раз в две недели

Охотничий магазин «Ягдташ и ружьё»: 21 рабочее место, розница плюс интернет-витрина снаряжения, одежды и оптики на PHP 8.3. Схема — два сервера приложения (на каждом свой nginx с php-fpm, слушают HTTP на 8080) за общим фронтом nginx 1.31.4. Жалоба: примерно раз в две-три недели в CRM прилетает задвоенный заказ, всегда в вечернее окно, когда идёт выгрузка прайсов поставщиков. Внутренний разработчик к этому моменту уже полгода — в свободное от 1С время — искал двойной submit в JavaScript и даже навесил блокировку кнопки — не помогло, что логично.

Конфиг фронта на момент разбора выглядел так (сокращаю до сути):

upstream app {
    server 10.20.0.11:8080 max_fails=2 fail_timeout=15s;
    server 10.20.0.12:8080 max_fails=2 fail_timeout=15s;
}

location / {
    proxy_pass http://app;
    proxy_next_upstream error timeout http_502 http_504 non_idempotent;
    proxy_connect_timeout 60s;
    proxy_read_timeout 60s;
}

non_idempotent приехал сюда в 2021 году вместе с чужим сниппетом «нулевой даунтайм при деплое». Автора конфига в компании давно нет.

Первым делом я переписал log_format и добавил в него $upstream_addr, $upstream_status и $upstream_response_time — три переменные, одна перезагрузка, ноль риска. Через сутки картина стала видна. За 24 часа 58 строк, где в $upstream_addr два адреса через запятую, то есть запрос ходил на два сервера. Из них 4 — метод POST на /checkout/confirm. Ещё 3 — PUT на внутренний API остатков. Сверка с логом приложения по времени и телу запроса: из этих четырёх POST три создали по два заказа, а один упал на уникальном индексе, который разработчик поставил год назад «на всякий случай» и уже забыл про него. Тот индекс всё это время был единственной защитой.

Дальше — почему вообще возникал тайм-аут. Бэкенд не падал. Выгрузка прайсов шла одной большой транзакцией и держала блокировку на таблице товаров по 40–70 секунд. Оформление заказа упиралось в эту блокировку, вылезало за proxy_read_timeout 60s, nginx фиксировал timeout и — благодаря non_idempotent — уходил с тем же POST на второй сервер приложения. Второй ждал ту же блокировку, но она к тому моменту уже снималась, и он отрабатывал за пару секунд. При этом первый бэкенд никто не останавливал: он дожидался блокировки, спокойно дописывал заказ в базу и отдавал ответ в сокет, который nginx уже не слушал. Отсюда и характерная сигнатура в логе: urt="60.003, 2.187" и ust="504, 200".

Что сделали и в каком порядке. Первым же reload убрали non_idempotent — три минуты работы, дубли прекратились в тот же вечер. Затем для /checkout/ и /api/payments/ выставили proxy_next_upstream off;, чтобы туда не залезли ни PUT, ни будущие правки. Третьим шагом разбили выгрузку прайсов на батчи по 500 строк — блокировки упали с 40–70 секунд до 1–3, то есть исчезла первопричина, а не только симптом. И последним, уже спокойно, добавили в приложение обработку заголовка Idempotency-Key поверх существующего уникального индекса. Четыре месяца наблюдения: дублей ноль. Строки с двумя адресами в $upstream_addr остались — 5–15 в сутки, но это GET и HEAD во время ночных релизов, ровно то, ради чего механизм и придуман.

Первое, что я делаю на чужом фронте, — `grep -rn non_idempotent /etc/nginx/`. В половине случаев эта строка там есть, и никто в компании не может объяснить, откуда она взялась.
Цифры и версии: Разбор: «Ягдташ и ружьё», дубли заказов раз в две недели — схема
Цифры и версии: Разбор: «Ягдташ и ружьё», дубли заказов раз в две недели. Открыть схему в полном размере

Как за десять минут доказать, что повтор был

Пока в логе нет $upstream_addr, любой разговор о повторах — гадание. Добавляется одной строкой, применяется через nginx -t && nginx -s reload, ничего не ломает, объём лога растёт процентов на пятнадцать. Мой рабочий формат:

log_format ups '$time_iso8601 $remote_addr "$request" $status '
               'ups="$upstream_addr" ust="$upstream_status" '
               'urt="$upstream_response_time" uct="$upstream_connect_time" '
               'rt=$request_time bs=$body_bytes_sent';

access_log /var/log/nginx/access.log ups;

Правило чтения простое, и оно прямо описано в документации переменных. Если в $upstream_addr несколько адресов через запятую — nginx обращался к нескольким серверам в рамках одной группы, то есть был переход на следующий сервер. Если адреса разделены двоеточием — это внутренний редирект между разными группами, инициированный X-Accel-Redirect или error_page, и это совсем другая история. Переменные $upstream_status, $upstream_response_time, $upstream_connect_time и $upstream_bytes_sent разделяются точно так же и читаются попарно с адресами.

Дальше вычленяем интересное. Меня интересуют только небезопасные методы с несколькими адресами:

# сколько всего запросов ушло больше чем на один бэкенд
grep -cE 'ups="[^"]+,' /var/log/nginx/access.log

# из них — только изменяющие данные методы
grep -E 'ups="[^"]+,' /var/log/nginx/access.log \
  | grep -E '"(POST|PUT|PATCH|DELETE|LOCK) ' \
  | awk '{print $2, $3, $4}' | sort | uniq -c | sort -rn | head -20

Что считать доказательством. Сигнатура ust="504, 200" вместе с urt="60.001, 1.9" означает: первый бэкенд не ответил ровно за тайм-аут, второй ответил успешно. Это худший случай — первый почти наверняка операцию выполнил, просто его ответ уже некому было принять. Сигнатура uct="0.001, 0.001" при ust="502, 200" чаще всего безобидна: 502 на мгновенном коннекте — это обычно бэкенд, который перезапускается, и запрос до него не дошёл. Окончательный вердикт всё равно даёт сверка с логом приложения: берёте метку времени из nginx и ищете в приложении два вызова обработчика с одинаковым телом внутри окна тайм-аута.

Не пытайтесь ловить повторы на бэкенде по X-Request-Id: nginx при переходе на следующий сервер отправляет тот же самый запрос с теми же заголовками. Различить попытки можно только по логу фронта.

Как я настраиваю фронт перед приложением, где есть деньги

Сразу обозначу позицию, потому что советы «выключите ретраи везде» встречаются часто и они плохие. Повторы — полезный механизм. Именно они дают выкатывать релиз без 502 в лицо пользователю: пока один сервер приложения перезапускается, запросы уходят на второй. Выключать их глобально означает менять редкую проблему дублей на ежедневную проблему ошибок при деплое. Я разделяю поведение по локейшенам: там, где читают, — повторы разрешены; там, где меняют состояние и деньги, — запрещены.

Базовый скелет, который я раскатываю:

upstream app {
    server 10.20.0.11:8080 max_fails=2 fail_timeout=15s;
    server 10.20.0.12:8080 max_fails=2 fail_timeout=15s;
    keepalive 32;
}

# общий дефолт: повторяем только на error и timeout, без non_idempotent
proxy_next_upstream         error timeout;
proxy_next_upstream_tries   2;
proxy_next_upstream_timeout 10s;
proxy_connect_timeout       3s;
proxy_read_timeout          60s;

location /api/payments/ {
    proxy_pass http://app;
    proxy_next_upstream off;
    proxy_read_timeout  120s;
}

location /checkout/ {
    proxy_pass http://app;
    proxy_next_upstream off;
}

Почему proxy_connect_timeout 3s, а не дефолтные 60. Установка соединения — единственная стадия, на которой повтор гарантированно безопасен: запрос ещё не ушёл. Значит, я хочу узнавать о неудаче на этой стадии быстро, за секунды, а не через минуту. Во внутренней сети три секунды на TCP-коннект — это очень много, и если не уложились, сервер точно нездоров. А proxy_next_upstream_tries 2 (директива есть с 1.7.5) ограничивает общее число попыток. Без неё nginx честно обойдёт всю группу: при восьми бэкендах один клиентский запрос превращается в восемь запросов к приложению, и во время массовой деградации это добивает то, что ещё живо. Дефолт 0 означает «без ограничения».

Два нюанса, на которых спотыкаются. Первый: что считается неуспешной попыткой для max_fails, определяется той же директивой proxy_next_upstream, но с оговоркой из документации: случаи error, timeout, denied и invalid_header считаются неудачными попытками всегда, даже если в директиве не указаны, а http_500, http_502, http_503, http_504 и http_429 — только если перечислены. Поэтому off в платёжном локейшене не ломает пассивную проверку здоровья по обрывам и тайм-аутам: сервер, который перестал отвечать, всё равно выпадет из ротации. Перестанут учитываться только ответы 5xx, если раньше они были в списке. Второй нюанс свежий: начиная с версии 1.29.7 кэш keepalive-соединений к бэкендам включён по умолчанию (keepalive 32 local;), ngx_http_proxy_module по умолчанию работает по HTTP/1.1 (proxy_http_version 1.1) и не отправляет заголовок Connection; в документации отдельно сказано, что закэшированные по умолчанию соединения не используются совместно разными location. После обновления с более старых веток поведение конфига меняется само собой — стоит перечитать раздел про keepalive перед апгрейдом, а не после.

И ловушка, которая ломает логику «у меня один бэкенд, ретраев быть не может». Если в proxy_pass указано доменное имя, документация говорит: «If a domain name resolves to several addresses, all of them will be used in a round-robin fashion». Две A-записи на внутреннем DNS или на сервисе в Kubernetes — и у вас появилась upstream-группа, которую вы не описывали, со всеми вытекающими повторами.

`proxy_next_upstream off` в location выключает переходы на следующий сервер, но не учёт отказов целиком: error, timeout и invalid_header по документации всегда засчитываются в max_fails. Теряется только учёт кодов http_5xx, если вы на него рассчитывали.
Памятка: Как я настраиваю фронт перед приложением, где есть деньги — схема
Памятка: Как я настраиваю фронт перед приложением, где есть деньги. Открыть схему в полном размере

Конфиг nginx — это страховка, а защита живёт в приложении

Даже если вы выключите повторы полностью, второй одинаковый POST всё равно прилетит — просто из другого источника. Пользователь нажмёт F5 на зависшей странице. Мобильное приложение с политикой ретраев по умолчанию перепошлёт запрос при обрыве сети. Ваш собственный фронтенд с axios-retry сделает то же самое. CDN или WAF перед nginx повторит по своим правилам. Платёжный шлюз будет долбить вебхук, пока не получит 200, — это вообще норма индустрии. RFC 9110 в разделе 9.2.2 прямо описывает автоматический повтор при обрыве соединения до получения ответа как штатное поведение клиента. Спорить с этим бессмысленно, надо просто быть к этому готовым.

Правильный ответ — сделать обработчик идемпотентным. Практическая схема, которую я советую и которая нигде не требует героизма: клиент генерирует UUID в момент открытия формы (не в момент отправки!), кладёт его в заголовок Idempotency-Key, сервер в той же транзакции, что и сам заказ, вставляет ключ в отдельную таблицу с UNIQUE-индексом. Пришёл повтор с тем же ключом — отдаём сохранённый ответ первой попытки и не создаём ничего нового. Хранить ключи вечно не нужно, 24 часа с запасом перекрывают любые ретраи.

Здесь надо быть честным: единого стандарта на это до сих пор нет. Черновик IETF draft-ietf-httpapi-idempotency-key-header дошёл до седьмой версии (октябрь 2025), в RFC на сентябрь 2026 не превратился, срок действия черновика истёк. Заголовок называется одинаково у Stripe, PayPal и десятка других платёжных провайдеров, но детали — время жизни ключа, что возвращать при конфликте, что делать, если пришёл тот же ключ с другим телом, — каждый решает сам. Это не повод не делать. Это повод описать поведение в собственном API-контракте и не рассчитывать, что чужой клиент угадает вашу семантику.

Если до полноценных ключей идемпотентности руки не дойдут в ближайший квартал — сделайте дешёвый вариант прямо сегодня. Уникальный индекс на естественный бизнес-ключ: пользователь плюс хэш состава корзины плюс округлённая до минуты метка времени. Ловите ошибку нарушения уникальности и возвращайте существующий заказ вместо ошибки. Полчаса работы, закрывает подавляющее большинство реальных случаев. Ровно эта конструкция в «Ягдташе и ружье» гасила четверть дублей ещё до того, как кто-то вообще заподозрил nginx. А вот на чём экономить точно можно: не надо строить распределённую дедупликацию с блокировками в Redis и хитрым TTL, если у вас триста заказов в сутки. Уникальный индекс в вашей же СУБД надёжнее и понятнее.

Проверьте свои входящие вебхуки отдельно. Платёжные шлюзы повторяют доставку до получения 200, и там ваш nginx вообще ни при чём — дубли придут снаружи.

Порядок действий: что сделать сегодня, а что можно отложить

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

Отдельно про то, чего делать не надо. Не выключайте повторы глобально одной строкой proxy_next_upstream off; в http-секции — потеряете бесшовный деплой и получите 502 на каждом релизе. Не накручивайте proxy_read_timeout до 300–600 секунд в надежде, что тайм-аут перестанет наступать: вы просто удлините окно, в котором висят соединения, и в час пик упрётесь в worker_connections. Тайм-аут — это симптом; если бэкенд отвечает дольше минуты, чинить надо бэкенд.

И последнее наблюдение из практики, которое экономит много времени. Почти во всех разобранных мной случаях дублей корень был не в nginx: длинная блокировка в СУБД, синхронный поход во внешний API без тайм-аута, отчёт, который строится в том же процессе, что и оформление заказа. nginx лишь превращал редкую медленную операцию в удвоенную. Убрав non_idempotent, вы перестанете плодить дубли — но исходная проблема «оформление заказа иногда занимает больше минуты» останется, и её тоже надо закрывать.

Пункты 1–3 занимают меньше часа и не требуют участия разработчиков. Если у вас прямо сейчас идут дубли — начните с них, а не с обсуждения архитектуры идемпотентности.
Порядок действий: Порядок действий: что сделать сегодня, а что можно отложить — схема
Порядок действий: Порядок действий: что сделать сегодня, а что можно отложить. Открыть схему в полном размере

Частые вопросы

Может ли nginx с настройками по умолчанию выполнить мой POST дважды?

Может, но не в том сценарии, о котором обычно думают. С версии 1.9.13 уже отправленный на бэкенд POST по умолчанию следующему серверу не передаётся. Остаются три пути: запрос не успел уйти (не установилось соединение) — тогда повтор безопасен; в конфиге явно стоит non_idempotent; метод не POST/LOCK/PATCH, а PUT или DELETE — их nginx считает идемпотентными и повторяет даже после отправки, если сработало условие из proxy_next_upstream.

Как по логу понять, что запрос уходил на два бэкенда?

Добавьте в log_format переменную $upstream_addr. Если в ней несколько адресов через запятую — nginx обращался к нескольким серверам одной группы, то есть был переход на следующий сервер. Двоеточие вместо запятой означает другое: внутренний редирект между разными upstream-группами через X-Accel-Redirect или error_page. Одновременно смотрите $upstream_status и $upstream_response_time — они разбиваются теми же разделителями.

Не проще ли выключить повторы глобально через proxy_next_upstream off?

Не проще. Вы потеряете бесшовный деплой: пока один бэкенд перезапускается, клиенты будут получать 502 вместо тихого ухода на второй сервер. Учёт отказов для max_fails при этом не пропадает: error, timeout и invalid_header засчитываются всегда, а вот коды 5xx — только если перечислены в директиве. Правильнее выключать точечно — на локейшенах с заказами, платежами и созданием документов.

Что делает proxy_next_upstream_tries 1?

Ограничивает общее число попыток одной, то есть повторов не будет вовсе. Директива появилась в 1.7.5, значение по умолчанию 0 — без ограничения, и тогда nginx обойдёт все живые серверы группы. Я обычно ставлю 2: этого достаточно, чтобы пережить перезапуск одного бэкенда, и при этом один клиентский запрос не превращается в восемь запросов к приложению во время массовой деградации.

Влияет ли proxy_request_buffering off на повторы?

Влияет, и в безопасную сторону. В документации сказано прямо: при выключенной буферизации тела запрос нельзя передать следующему серверу, если nginx уже начал отправлять тело бэкенду. То есть отключение буферизации само по себе гасит часть повторов — но полагаться на это как на защиту не стоит, у этого режима свои последствия для загрузок и тайм-аутов.

У меня один бэкенд в proxy_pass, повторы мне не грозят?

Грозят, если в proxy_pass указано доменное имя. Документация nginx говорит: если имя резолвится в несколько адресов, все они используются по кругу. Две A-записи во внутреннем DNS или сервис в Kubernetes с несколькими подами — и у вас фактически есть upstream-группа, которую вы не описывали, со всеми правилами перехода на следующий сервер.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи