Deep Engineering
Средний·Опубликовано·25 МИН

Идемпотентность: «ровно один раз» бывает в эффекте, а не в доставке

Повтор запроса — не решение, а способ сделать эффект дважды. Измерено: четыре повтора без ключа дали четыре эффекта, с ключом — один. Между эффектом и ответом есть окно, которое закрыть нельзя: измерено, как клиент видит отказ там, где на сервере уже всё случилось. А порядок работы с ключом решает всё: занять до работы — один эффект, записать после — два.

Полное техническое изложение

TL;DR

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

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

Дальше — порядок, границы и остальные числа. Ключ сам по себе не гарантирует ничего: измерено на двух одновременных повторах — занят до работы даёт 1 эффект, записан после работы — 2. Новый ключ на каждую попытку не защищает ни от чего: три попытки с одним ключом — 1 эффект, три попытки с тремя ключами — 3. А «ровно один раз» (exactly-once) существует только про эффект: доставку повторить может кто угодно и сколько угодно; единственное, что можно гарантировать, — что повтор не сделает работу второй раз.

Порог входа
Перед уроком достаточно понимать
  • клиент отправляет запрос по сети, и ответ может до него не дойти;
  • клиент, не дождавшийся ответа, обычно повторяет запрос — сам или библиотекой;
  • есть операции, которые нельзя выполнить дважды: списать деньги, отправить письмо, создать заказ.
Заранее знать не нужно
  • ключ идемпотентности, «exactly-once», условная вставка и гонка между проверкой и записью;
  • как устроены повторы с экспоненциальной выдержкой и джиттером.

Что здесь на самом деле спрашивают

Лестница выглядит так:

  1. «Что такое идемпотентность?» — разминка на определение.
  2. «Как сделать повтор безопасным?» — здесь начинается содержание.
  3. «Что если ответ потерялся после того, как эффект применился?» — главный вопрос темы.
  4. «Возможна ли доставка ровно один раз?» — вопрос-ловушка.
  5. «Два повтора пришли одновременно — что будет?» — вопрос про порядок работы с ключом.
  6. «Кто генерирует ключ?» — вопрос, на котором видно, думал ли собеседник про клиента.

Числа получены прогоном 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
наблюдение замераbench/idempotency/keys.py. Эффект — одно увеличение счётчика внутри сервиса. Обе стороны живут в одном процессе на loopback: предмет замера — число применений, а не сеть.

Четыре запроса, четыре эффекта. И обратите внимание на последнюю строку: ответы разные. Это честная картина происходящего — с точки зрения сервера это были четыре отдельные операции, а не одна, повторённая четырежды.

Здесь и лежит корень темы. Клиент, который повторяет запрос, считает, что делает одно дело; сервер, который не различает повторы, делает четыре. Договорённости о том, что это одна операция, между ними нет — её надо создать.

Механизм 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.

перевод

Ключ идемпотентности — это уникальное значение, порождённое клиентом, по которому ресурс распознаёт последующие повторы того же запроса.

The Idempotency-Key HTTP Header Field (черновик IETF)

Два слова здесь несут всё: порождённое клиентом и распознаёт повторы. Проверим на тех же четырёх запросах:

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)
наблюдение замераbench/idempotency/keys.py. Тот же сервис и те же четыре запроса, что и в первом блоке. Изменилось одно: запросы несут ключ, а сервис хранит под ним ответ.

Четыре запроса, один эффект. И ответ у всех четырёх одинаковый — тот самый, что был выдан в первый раз.

Сервис не стал умнее и ничего не угадывает. Он делает ровно то, что говорит определение: увидев знакомый ключ, возвращает сохранённый ответ вместо того, чтобы выполнить работу второй раз. Черновик формулирует и это:

The resource SHOULD respond with the result of the previously completed operation, success or an error.

перевод

Ресурсу следует ответить результатом ранее завершённой операции — успехом или ошибкой.

The Idempotency-Key HTTP Header Field (черновик IETF)

Обратите внимание на «или ошибкой». Повтор возвращает тот же результат, а не пытается сделать лучше: если операция в первый раз не удалась, второй раз она не удастся так же. Иначе ключ перестал бы означать «та же операция».

Механизм 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
наблюдение замераbench/idempotency/keys.py. Потеря ответа сделана намеренно: сервис закрывает соединение, не отправив ответ, и клиент получает ноль байт. В сети такой обрыв чаще выглядит как таймаут — предмет замера в том, что эффект уже применился, а не в том, как именно пропал ответ.

Читать надо по строкам. Строка 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.

перевод

Уникальность ключа обязана быть определена владельцем ресурса и обязана быть реализована клиентами ресурса.

The Idempotency-Key HTTP Header Field (черновик IETF)

Ключ порождает клиент — и в этом всё дело. Сервер не может вывести ключ из самого запроса: к моменту, когда запрос у него есть, отличить его от повтора не по чему. Цитата при этом даёт определение, а не запрет: схемы, где сервер заранее выдаёт токен, существуют — но и там значение приходит к серверу до операции, а не вместе с ней.

Что бывает, если клиент делает новый ключ на каждую попытку:

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
наблюдение замераbench/idempotency/keys.py. Сервис в обеих половинах блока один и тот же, и ключи он обрабатывает одинаково. Меняется только то, порождает клиент один ключ на операцию или новый на каждую попытку.

Три попытки с одним ключом — один эффект. Три попытки с тремя ключами — три. Сервис между этими половинами не менялся вовсе; третья строка блока — их сумма, счётчик один на обе половины: 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
наблюдение замераbench/idempotency/keys.py. Оба случая — один и тот же сервис, один и тот же ключ и два одновременных запроса. Отличается только момент записи ключа: под замком до работы или после неё. Работа в обоих случаях занимает 50 мс — этот промежуток и есть то окно, в которое проваливается второй запрос; без него исход был бы случайным.

Один и тот же ключ, те же два одновременных запроса, то же хранилище. Разница только в моменте записи — и она даёт один эффект против двух.

Почему. Если ключ записывается после работы, между проверкой «видели ли мы этот ключ» и записью есть промежуток. Два запроса успевают пройти проверку оба, пока ни один ещё не записал, — и работа выполняется дважды.

Отсюда правило, которым стоит отвечать: хранилище должно ключ занимать, а не запоминать. Проверка и запись обязаны быть одной неделимой операцией: вставка с условием «если такого ключа ещё нет», а не «прочитали, подумали, записали».

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

Это ровно то место, где реализация идемпотентности чаще всего оказывается ненастоящей: ключ есть, хранилище есть, а гонка — тоже есть.

Как отвечать на собеседовании

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

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Хороший ответ отличают три вещи. Первая — вы называете окно между эффектом и ответом и говорите точно, чего именно нельзя: атомарно связать эффект на удалённой стороне со знанием клиента о том, что ответ доставлен, — без дополнительного протокола поверх обмена этого не сделать. Идемпотентность не убирает эту неопределённость, а делает безопасным повтор после неё; измерено, как клиент видит отказ там, где эффект уже применился. Вторая — вы говорите про порядок: ключ надо занимать до работы, а не записывать после; измерено, что второе даёт два эффекта на два одновременных повтора. Третья — вы говорите, что ключ порождает клиент, и объясняете почему: сервер к моменту появления запроса уже не может отличить его от повтора.

Чего говорить не стоит: «мы сделали 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 из 5

Четыре одинаковых повтора без ключа идемпотентности. Сколько раз применится эффект?

Источники и что читать дальше

1 ИСТОЧНИК

  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