Идемпотентность: «ровно один раз» бывает в эффекте, а не в доставке
Повтор запроса — не решение, а способ сделать эффект дважды. Измерено: четыре повтора без ключа дали четыре эффекта, с ключом — один. Между эффектом и ответом есть окно, которое закрыть нельзя: измерено, как клиент видит отказ там, где на сервере уже всё случилось. А порядок работы с ключом решает всё: занять до работы — один эффект, записать после — два.
Полное техническое изложение
TL;DR
Идемпотентной называют операцию, которую можно повторить без дополнительного эффекта после того, как она один раз успешно применилась. Повтор запроса сам по себе таким свойством не обладает: сервер, не различающий повторы, применяет эффект заново. Договорённость «это та же операция» между клиентом и сервером надо создать — её и создаёт ключ, который клиент порождает на операцию и посылает вместе с каждой попыткой.
Главное следствие: клиент не может отличить «запрос не дошёл» от «дошло, а ответ потерялся». Измерено: клиент получил пустой ответ, а эффект на сервере уже применился. Поэтому повторять он обязан — и повтор без ключа стоит дорого: четыре одинаковых запроса дали четыре эффекта, те же четыре с ключом — один.
Дальше — порядок, границы и остальные числа. Ключ сам по себе не гарантирует ничего: измерено на двух одновременных повторах — занят до работы даёт 1 эффект, записан после работы — 2. Новый ключ на каждую попытку не защищает ни от чего: три попытки с одним ключом — 1 эффект, три попытки с тремя ключами — 3. А «ровно один раз» (exactly-once) существует только про эффект: доставку повторить может кто угодно и сколько угодно; единственное, что можно гарантировать, — что повтор не сделает работу второй раз.
- клиент отправляет запрос по сети, и ответ может до него не дойти;
- клиент, не дождавшийся ответа, обычно повторяет запрос — сам или библиотекой;
- есть операции, которые нельзя выполнить дважды: списать деньги, отправить письмо, создать заказ.
- ключ идемпотентности, «exactly-once», условная вставка и гонка между проверкой и записью;
- как устроены повторы с экспоненциальной выдержкой и джиттером.
Что здесь на самом деле спрашивают
Лестница выглядит так:
- «Что такое идемпотентность?» — разминка на определение.
- «Как сделать повтор безопасным?» — здесь начинается содержание.
- «Что если ответ потерялся после того, как эффект применился?» — главный вопрос темы.
- «Возможна ли доставка ровно один раз?» — вопрос-ловушка.
- «Два повтора пришли одновременно — что будет?» — вопрос про порядок работы с ключом.
- «Кто генерирует ключ?» — вопрос, на котором видно, думал ли собеседник про клиента.
Числа получены прогоном bench/idempotency/keys.py и
bench/idempotency/practice.py на loopback. Эффект здесь — одно увеличение
счётчика внутри сервиса: то, что нельзя сделать дважды. Считается именно он, а
не число запросов и не то, что увидел клиент.
База: сервер списал деньги, а ответ не дошёл
Начнём с истории, из которой выросла вся тема.
Клиент просит сервис списать сто рублей. Сервис списывает — деньги ушли, всё получилось, — и отправляет ответ. Ответ до клиента не доходит: оборвалось соединение, кончился таймаут, потерялся пакет. Клиент видит одно: ответа нет.
Что ему делать? Вариантов два, и оба плохие. Не повторять — тогда, если запрос до сервиса вообще не дошёл, списание не случилось и никто об этом не узнает. Повторить — тогда, если списание уже случилось, оно случится второй раз, и сто рублей превратятся в двести.
Отличить один случай от другого клиент не может, и это ключевое место всей темы. «Запрос не дошёл» и «запрос дошёл, эффект применился, потерялся ответ» приходят к нему в одном и том же виде — как отсутствие ответа.
Раз развилку не разрешить, её обходят: делают так, чтобы повторять было безопасно. Операцию называют идемпотентной, если её можно повторить без дополнительного эффекта после того, как она один раз успешно применилась: первое применение делает работу, каждое следующее ничего не делает и сообщает тот же результат. Тогда клиенту и не нужно ничего различать — он просто повторяет, пока не получит ответ.
Некоторые операции идемпотентны сами по себе: «установить статус в готово» можно повторять сколько угодно, результат один и тот же. Списание сотни таким свойством не обладает — два списания это двести рублей. Значит, свойство надо добавить снаружи, и добавляют его ключом идемпотентности: клиент придумывает на операцию уникальное значение и посылает его с каждой попыткой, а сервис по этому значению узнаёт повтор и вместо второй работы возвращает прежний ответ.
И вот главный вопрос урока: что должен делать сервис, чтобы это работало на самом деле? «Сервис хранит ключи» — ещё не ответ: важно, что именно он делает с ключом и в какой момент.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Всё дальнейшее — про то, сколько эффектов получается без ключа и с ним, про окно между эффектом и ответом, которое ключ не закрывает, и про то, кто и когда ключ порождает.
Механизм 1: повтор — это второй эффект
Теперь то же самое в числах, и начать стоит с того, что происходит без всякой защиты. Четыре одинаковых запроса — ровно то, что делает клиент с повторами из урока про ретраи:
1. A RETRY WITHOUT A KEY APPLIES THE EFFECT AGAIN
-------------------------------------------------
requests sent 4
times the effect was applied 4
what the client got each time ok #1 | ok #2 | ok #3 | ok #4
Четыре запроса, четыре эффекта. И обратите внимание на последнюю строку: ответы разные. Это честная картина происходящего — с точки зрения сервера это были четыре отдельные операции, а не одна, повторённая четырежды.
Здесь и лежит корень темы. Клиент, который повторяет запрос, считает, что делает одно дело; сервер, который не различает повторы, делает четыре. Договорённости о том, что это одна операция, между ними нет — её надо создать.
Механизм 2: ключ создаёт эту договорённость
Механизм, которым это лечат, мы уже назвали: это ключ идемпотентности. Стандарта на него нет: ниже — черновик рабочей группы IETF httpapi с истёкшим сроком действия, и приводится он ради формулировки, а не как норма. Формулировка короткая и объясняет всё устройство:
An idempotency key is a unique value generated by the client which the resource
uses to recognize subsequent retries of the same request.
Ключ идемпотентности — это уникальное значение, порождённое клиентом, по которому ресурс распознаёт последующие повторы того же запроса.
Два слова здесь несут всё: порождённое клиентом и распознаёт повторы. Проверим на тех же четырёх запросах:
2. THE SAME RETRIES WITH AN IDEMPOTENCY KEY
-------------------------------------------
requests sent 4
times the effect was applied 1
what the client got each time ok #1 | ok #1 (replayed) | ok #1 (replayed) | ok #1 (replayed)
Четыре запроса, один эффект. И ответ у всех четырёх одинаковый — тот самый, что был выдан в первый раз.
Сервис не стал умнее и ничего не угадывает. Он делает ровно то, что говорит определение: увидев знакомый ключ, возвращает сохранённый ответ вместо того, чтобы выполнить работу второй раз. Черновик формулирует и это:
The resource SHOULD respond with the result of the previously completed
operation, success or an error.
Ресурсу следует ответить результатом ранее завершённой операции — успехом или ошибкой.
Обратите внимание на «или ошибкой». Повтор возвращает тот же результат, а не пытается сделать лучше: если операция в первый раз не удалась, второй раз она не удастся так же. Иначе ключ перестал бы означать «та же операция».
Механизм 3: окно между эффектом и ответом
Теперь главный вопрос темы — тот самый, с которого начиналась «База», только теперь его видно в замере. Эффект применяется на сервере, ответ идёт к клиенту — и между этими двумя событиями есть промежуток. Что если ответ в нём потеряется?
3. THE WINDOW: THE EFFECT HAPPENED, THE ANSWER DID NOT ARRIVE
-------------------------------------------------------------
what the client saw on the first try empty answer
times the effect was applied by then 1
what the client saw on the retry ok #1 (replayed)
times the effect was applied in total 1
Читать надо по строкам. Строка empty answer — это ноль байт: соединение
закрылось, не принеся ничего. Для клиента это неудача. Эффект при этом уже
применился: счётчик показывает единицу.
И клиент был прав, что повторил. Прогон показал вторую половину картины: эффект случился, ответа нет. Первой половины — «запрос вообще не дошёл» — в замере нет, и именно поэтому клиент бессилен: обе половины приходят к нему одним и тем же пустым результатом.
Отсюда главный вывод урока — и это уже рассуждение, а не замер. Сказать его стоит точно: побочный эффект на удалённой стороне и знание клиента о том, что ответ доставлен, нельзя связать атомарно — по крайней мере без дополнительного протокола поверх обычного обмена запросом и ответом. Сколько подтверждений в такой обмен ни добавляй, последнее из них само остаётся сообщением, которое может пропасть, — то есть промежуток между «сделал» и «собеседник знает, что сделано» никуда не девается, он только сдвигается. Ключ этот промежуток не устраняет: он делает безопасным повтор после него. Повтор попадает в тот же ключ и получает тот же ответ, а эффект остаётся один.
Это и есть точный смысл фразы «exactly-once»: гарантировать можно не доставку, а эффект. Доставку повторит кто угодно и сколько угодно раз.
Механизм 4: ключ порождает клиент — и один раз на операцию
Четвёртый уровень — про то, кто отвечает за ключ и когда он рождается. Черновик отвечает на первую половину прямо:
Uniqueness of the key MUST be defined by the resource owner and MUST be
implemented by the clients of the resource.
Уникальность ключа обязана быть определена владельцем ресурса и обязана быть реализована клиентами ресурса.
Ключ порождает клиент — и в этом всё дело. Сервер не может вывести ключ из самого запроса: к моменту, когда запрос у него есть, отличить его от повтора не по чему. Цитата при этом даёт определение, а не запрет: схемы, где сервер заранее выдаёт токен, существуют — но и там значение приходит к серверу до операции, а не вместе с ней.
Что бывает, если клиент делает новый ключ на каждую попытку:
5. A NEW KEY PER ATTEMPT PROTECTS NOTHING
-----------------------------------------
three attempts, one key: effects 1
three attempts, a new key each: effects 3
total effects applied 4
Три попытки с одним ключом — один эффект. Три попытки с тремя ключами — три. Сервис между этими половинами не менялся вовсе; третья строка блока — их сумма, счётчик один на обе половины: 1 + 3.
Отсюда практическое следствие, за которое этот уровень и спрашивают: ключ должен рождаться вместе с намерением, а не вместе с попыткой. Клиент, генерирующий ключ внутри цикла повторов, реализовал механизм и не получил от него ничего.
И отсюда же — почему это не решается на стороне сервера. Что считать «той же операцией», знает только тот, кто её задумал.
Глубже: порядок работы с ключом решает всё
Всё сказанное выше описывает, зачем ключ нужен и откуда он берётся. Последний уровень — про то, как сервис с ним обращается, и именно здесь реализация чаще всего оказывается ненастоящей. Ключ сам по себе ничего не гарантирует — важно, когда он записывается. Проверим на двух повторах, пришедших одновременно:
4. TWO RETRIES AT THE SAME MOMENT, ONE KEY
------------------------------------------
key claimed before the work 1 effect from 2 requests
answers ok #1 | ok #1 (replayed)
key written after the work 2 effects from 2 requests
answers ok #1 | ok #2
Один и тот же ключ, те же два одновременных запроса, то же хранилище. Разница только в моменте записи — и она даёт один эффект против двух.
Почему. Если ключ записывается после работы, между проверкой «видели ли мы этот ключ» и записью есть промежуток. Два запроса успевают пройти проверку оба, пока ни один ещё не записал, — и работа выполняется дважды.
Отсюда правило, которым стоит отвечать: хранилище должно ключ занимать, а не запоминать. Проверка и запись обязаны быть одной неделимой операцией: вставка с условием «если такого ключа ещё нет», а не «прочитали, подумали, записали».
В прогоне неделимость обеспечивает замок внутри процесса — этого хватает, потому что обе стороны живут в одном. Правило про условную вставку — следствие: на нескольких процессах замок бы не помог, и роль замка приходится брать хранилищу.
Это ровно то место, где реализация идемпотентности чаще всего оказывается ненастоящей: ключ есть, хранилище есть, а гонка — тоже есть.
Как отвечать на собеседовании
Короткий ответ: доставку ровно один раз гарантировать нельзя, а эффект — можно. Клиент порождает ключ на операцию, сервер под этим ключом занимает место до работы и хранит ответ, и любой повтор получает тот же ответ вместо второго эффекта. Измерено: четыре повтора без ключа — четыре эффекта, с ключом — один.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
Хороший ответ отличают три вещи. Первая — вы называете окно между эффектом и ответом и говорите точно, чего именно нельзя: атомарно связать эффект на удалённой стороне со знанием клиента о том, что ответ доставлен, — без дополнительного протокола поверх обмена этого не сделать. Идемпотентность не убирает эту неопределённость, а делает безопасным повтор после неё; измерено, как клиент видит отказ там, где эффект уже применился. Вторая — вы говорите про порядок: ключ надо занимать до работы, а не записывать после; измерено, что второе даёт два эффекта на два одновременных повтора. Третья — вы говорите, что ключ порождает клиент, и объясняете почему: сервер к моменту появления запроса уже не может отличить его от повтора.
Чего говорить не стоит: «мы сделали exactly-once». Это обещание про доставку, которого никто не может дать; давать надо про эффект — и тогда оно проверяется.
Дальше спросят
Сколько хранить ключи?
Дольше, чем клиент может повторять. Если у клиента экспоненциальная выдержка с потолком в час, а ключи живут пять минут, то поздний повтор придёт с уже забытым ключом и сделает второй эффект — то есть механизм перестанет работать ровно в том редком случае, ради которого он и нужен.
Отсюда и правильный порядок: сначала выясняется, сколько повторов и как долго делает клиент, и только потом выбирается срок хранения. В замере этого нет — там словарь без срока жизни, — но связь очевидна из того, как ключ работает.
Что если под одним ключом придёт другой запрос?
Это отдельный класс ошибок, и решают его хранением отпечатка тела вместе с ключом. Если ключ тот же, а тело другое, честный ответ — отказ: клиент ошибся, и повторять чужой ответ ему нельзя.
В нашем замере тело не проверяется вовсе: там предмет — число эффектов. Но опасность стоит назвать самому, потому что клиент, переиспользующий ключ по ошибке, получит чужой результат и не заметит этого.
Чем это отличается от идемпотентного метода?
Тем, где живёт гарантия. Идемпотентный метод — свойство самой операции: «установить статус в готово» можно повторять сколько угодно, потому что результат один и тот же. Ключ нужен для операций, у которых такого свойства нет: «списать сто рублей» дважды — это двести.
Поэтому первый вопрос при разборе — не «где взять ключ», а «нельзя ли переформулировать операцию так, чтобы ключ был не нужен». «Установить баланс в X» вместо «вычесть Y» решает задачу без всякого хранилища — если, конечно, такая формулировка допустима.
Где здесь предел?
Там, где эффект уходит за пределы вашей системы. Ключ защищает то, что делает ваш сервис; если он в процессе отправил письмо или дёрнул чужой платёжный шлюз, повтор внутри вашего кода второй раз этого не сделает — но ровно та же задача теперь стоит перед соседом.
Отсюда и то, чего нельзя обещать: цепочка сервисов не становится идемпотентной оттого, что идемпотентен первый. В замере цепочки нет — там один сервис; это следствие определения: ключ распознаёт повтор только там, где его читают. Значит, каждый участок цепи решает задачу сам, и ключ должен доезжать до места, где происходит эффект.
Частые заблуждения
Повтор безопасен, если запрос не изменился
Не изменился запрос, а не эффект. Измерено: четыре одинаковых запроса без ключа дали четыре эффекта, и клиент получил четыре разных ответа. Для сервера, не различающего повторы, это четыре отдельные операции.
Можно добиться доставки ровно один раз
Нельзя: эффект на удалённой стороне и знание клиента о доставленном ответе не связываются атомарно — без дополнительного протокола поверх обмена, — и обе половины картины приходят к клиенту одним и тем же пустым результатом. Измерено: клиент получил ноль байт, а эффект уже применился. Гарантировать можно эффект, а не доставку: повтор после этой неопределённости попадает в тот же ключ и получает тот же ответ.
Достаточно запомнить ключ после обработки
Нет: между проверкой и записью есть промежуток. Измерено на двух одновременных повторах: ключ, занятый до работы, дал 1 эффект, записанный после — 2. Проверка и запись обязаны быть одной неделимой операцией.
Ключ может генерировать сервер
Вывести ключ из самого запроса — не может: к моменту, когда запрос у сервера есть, отличить его от повтора уже не по чему. Черновик даёт определение: An idempotency key is a unique value generated by the client
(ключ идемпотентности — это уникальное значение, порождённое клиентом). Что считать «той же операцией», знает только тот, кто её задумал.
Ключ удобно генерировать в клиентской библиотеке при отправке запроса
Важнее всего. Измерено: три попытки с одним ключом дали 1 эффект, три попытки с новым ключом на каждую — 3, при одном и том же сервисе. Клиент, генерирующий ключ внутри цикла повторов, реализовал механизм и не получил от него ничего.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ печатает сам скрипт замера.
Практика · что напечатает
without_key = retries(use_keys=False) with_key = retries(use_keys=True) late_key = simultaneous(claim_key=False) print(without_key) print(with_key) print(late_key)
Практика · оцените
Проверка знаний
Четыре одинаковых повтора без ключа идемпотентности. Сколько раз применится эффект?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Идемпотентной называют операцию, которую можно повторить без дополнительного эффекта после того, как она один раз успешно применилась. Повтор запроса сам по себе таким свойством не обладает: сервер, не различающий повторы, применяет эффект заново. Договорённость «это та же операция» между клиентом и сервером надо создать — её и создаёт ключ, который клиент порождает на операцию и посылает вместе с каждой попыткой.
- Главное следствие: клиент не может отличить «запрос не дошёл» от «дошло, а ответ потерялся». Измерено: клиент получил пустой ответ, а эффект на сервере уже применился. Поэтому повторять он обязан — и повтор без ключа стоит дорого: четыре одинаковых запроса дали четыре эффекта, те же четыре с ключом — один.
- Дальше — порядок, границы и остальные числа. Ключ сам по себе не гарантирует ничего: измерено на двух одновременных повторах — занят до работы даёт 1 эффект, записан после работы — 2. Новый ключ на каждую попытку не защищает ни от чего: три попытки с одним ключом — 1 эффект, три попытки с тремя ключами — 3. А «ровно один раз» (exactly-once) существует только про эффект: доставку повторить может кто угодно и сколько угодно; единственное, что можно гарантировать, — что повтор не сделает работу второй раз.
На самом деле
- Не изменился запрос, а не эффект. Измерено: четыре одинаковых запроса без ключа дали четыре эффекта, и клиент получил четыре разных ответа. Для сервера, не различающего повторы, это четыре отдельные операции.
- Нельзя: эффект на удалённой стороне и знание клиента о доставленном ответе не связываются атомарно — без дополнительного протокола поверх обмена, — и обе половины картины приходят к клиенту одним и тем же пустым результатом. Измерено: клиент получил ноль байт, а эффект уже применился. Гарантировать можно эффект, а не доставку: повтор после этой неопределённости попадает в тот же ключ и получает тот же ответ.
- Нет: между проверкой и записью есть промежуток. Измерено на двух одновременных повторах: ключ, занятый до работы, дал 1 эффект, записанный после — 2. Проверка и запись обязаны быть одной неделимой операцией.
- Вывести ключ из самого запроса — не может: к моменту, когда запрос у сервера есть, отличить его от повтора уже не по чему. Черновик даёт определение: An idempotency key is a unique value generated by the client. Что считать «той же операцией», знает только тот, кто её задумал.
- Важнее всего. Измерено: три попытки с одним ключом дали 1 эффект, три попытки с новым ключом на каждую — 3, при одном и том же сервисе. Клиент, генерирующий ключ внутри цикла повторов, реализовал механизм и не получил от него ничего.
Что разобрано
- Что здесь на самом деле спрашивают
- База: сервер списал деньги, а ответ не дошёл
- Механизм 1: повтор — это второй эффект
- Механизм 2: ключ создаёт эту договорённость
- Механизм 3: окно между эффектом и ответом
- Механизм 4: ключ порождает клиент — и один раз на операцию
- Глубже: порядок работы с ключом решает всё
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
1 ИСТОЧНИК
- The Idempotency-Key HTTP Header Field (истёкший Internet-Draft IETF)Источник. Стандарта на ключи идемпотентности нет — есть черновик рабочей группы httpapi, и срок его действия истёк. Документ сам это оговаривает обязательной преамбулой IETF: «It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress"» (Неуместно использовать Internet-Drafts как справочный материал или ссылаться на них иначе как на „работу в процессе“). Поэтому урок берёт отсюда определение и не берёт норм. Номера разделов между ревизиями черновика меняются, поэтому ниже они не указаны. Что такое ключ: «An idempotency key is a unique value generated by the client which the resource uses to recognize subsequent retries of the same request» (Ключ идемпотентности — это уникальное значение, порождённое клиентом, по которому ресурс распознаёт последующие повторы того же запроса). Что делать с повтором: «The resource SHOULD respond with the result of the previously completed operation, success or an error» (Ресурсу СЛЕДУЕТ ответить результатом ранее завершённой операции — успехом или ошибкой). Кто отвечает за уникальность: «Uniqueness of the key MUST be defined by the resource owner and MUST be implemented by the clients of the resource» (Уникальность ключа ОБЯЗАНА быть определена владельцем ресурса и ОБЯЗАНА быть реализована клиентами ресурса).https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header