Это глава 18 моей книги «API». v2 будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.
Описанный в предыдущей главе подход фактически представляет собой размен производительности API на «нормальный» (т. е. ожидаемый) фон ошибок при работе с ним путём изоляции компонента, отвечающего за строгую консистентность и управление параллелизмом внутри системы. Тем не менее, его пропускная способность всё равно ограничена, и снизить её мы можем единственным образом — убрав строгую консистентность из внешнего API, что даст возможность осуществлять чтение состояния системы из реплик:
// Получаем состояние,
// возможно, из реплики
const orderState =
await api.getOrderState();
const version =
orderState.latestVersion;
try {
// Обработчик запроса на
// создание заказа прочитает
// актуальную версию
// из мастер-данных
const task = await api
.createOrder(version, …);
} catch (e) {
…
}
Т.к. заказы создаются намного реже, нежели читаются, мы можем существенно повысить производительность системы, если откажемся от гарантии возврата всегда самого актуального состояния ресурса из операции на чтение. Версионирование же поможет нам избежать проблем: создать заказ, не получив актуальной версии, невозможно. Фактически мы пришли к модели событийной консистентности (т. н. «согласованность в конечном счёте»): клиент сможет выполнить свой запрос когда‑нибудь, когда получит, наконец, актуальные данные. В самом деле, согласованность в конечном счёте — скорее норма жизни для современных микросервисных архитектур, в которой может оказаться очень сложно как раз добиться обратного, т. е. строгой консистентности.
NB: на всякий случай уточним, что выбирать подходящий подход вы можете только в случае разработки новых API. Если вы уже предоставляете эндпойнт, реализующий какую‑то модель консистентности, вы не можете понизить её уровень (в частности, сменить строгую консистентность на слабую), даже если вы никогда не документировали текущее поведение явно (мы обсудим это требование детальнее в главе «О ватерлинии айсберга» раздела «Обратная совместимость»).
Однако, выбор слабой консистентности вместо сильной влечёт за собой и другие проблемы. Да, мы можем потребовать от партнёров дождаться получения последнего актуального состояния ресурса перед внесением изменений. Но очень неочевидно (и в самом деле неудобно) требовать от партнёров быть готовыми к тому, что они должны дождаться появления в том числе и тех изменений, которые сами же внесли.
// Создаёт заказ
const api = await api
.createOrder(…)
// Возвращает список заказов
const pendingOrders = await api.
getOngoingOrders(); // → []
// список пуст
Если мы не гарантируем сильную консистентность, то второй вызов может запросто вернуть пустой результат, ведь при чтении из реплики новый заказ мог просто до неё ещё не дойти.
Важный паттерн, который поможет в этой ситуации — это имплементация модели «read‑your‑writes», а именно гарантии, что клиент всегда «видит» те изменения, которые сам же и внёс. Поднять уровень слабой консистентности до read‑your‑writes можно, если предложить клиенту самому передать токен, описывающий его последние изменения.
const order = await api
.createOrder(…);
const pendingOrders = await api.
getOngoingOrders({
…,
// Передаём идентификатор
// последней операции
// совершённой клиентом
last_known_order_id: order.id
})
В качестве такого токена может выступать, например:
идентификатор или идентификаторы последних модифицирующих операций, выполненных клиентом;
последняя известная клиенту версия ресурса (дата изменения, ETag).
Получив такой токен, сервер должен проверить, что ответ (список текущих операций, который он возвращает) соответствует токену, т. е. консистентность «в конечном счёте» сошлась. Если же она не сошлась (клиент передал дату модификации / версию / идентификатор последнего заказа новее, чем известна в данном узле сети), то сервер может реализовать одну из трёх стратегий (или их произвольную комбинацию):
запросить данные из нижележащего БД или другого хранилища повторно;
вернуть клиенту ошибку, индицирующую необходимость повторить запрос через некоторое время;
обратиться к основной реплике БД, если таковая имеется, либо иным образом инициировать запрос мастер‑данных из хранилища.
Достоинством этого подхода является удобство разработки клиента (по сравнению с полным отсутствием гарантий): ценой хранения токена версии разработчик клиента избавляется от возможной неконсистентности получаемых из API данных. Недостатков же здесь два:
вам всё ещё нужно выбрать между масштабируемостью системы и постоянным фоном ошибок;
если при несовпадении версий клиента и сервера вы обращаетесь к мастер‑реплике или перезапрашиваете данные, то увеличиваете нагрузку на хранилище сложно прогнозируемым образом;
если же вы генерируете ошибку для клиента, то в вашей системе всегда будет достаточно заметный фон таких ошибок, и, к тому же, партнёрам придётся написать клиентский код для их обработки;
этот подход вероятностный и спасает только в части ситуаций — о чём мы расскажем в следующей главе.
Учитывая, что клиентское приложение может быть перезапущено или просто потерять токен, наиболее правильное (хотя не всегда приемлемое с точки зрения нагрузки) поведение сервера при отсутствии токена в запросе — форсировать возврат актуальных мастер‑данных.
Риски перехода к событийной консистентности
Прежде всего, давайте зафиксируем один важный тезис: все обсуждаемые в настоящем разделе техники решения архитектурных проблем — вероятностные. Отказ от строгой консистентности означает, что даже при идеальной работе компонентов системы клиентские ошибки все равно будут возникать — мы только можем постараться сделать так, чтобы при типичном профиле использования системы ошибок было меньше.
Оговорка про «типичный профиль важна»: API предполагает вариативность сценариев его применения, и вполне может оказаться так, что кейсы использования API делятся на несколько сильно отличающихся с точки зрения толерантности к ошибкам групп (классический пример — это клиентские API, где завершения операций ждёт реальный пользователь, против серверных API, где время исполнения само по себе менее важно, но может оказаться важным, например, массовый параллелизм операций). Если такое происходит — это сильный сигнал для того, чтобы выделить API для различных типовых сценариев в отдельные продукты в семействе API, о чём мы поговорим в главе «Линейка сервисов API» раздела «API как продукт».
Проиллюстрируем этот принцип на нашем примере с заказом кофе. Предположим, что мы реализуем следующую схему:
оптимистичное управление синхронизацией (скажем, через идентификатор последнего заказа);
«read‑your‑writes»‑политика чтения списка заказов (вновь через отправку последнего идентификатора заказа в качестве токена);
если токен не передан, клиент всегда получает актуальное состояние.
Тогда получить ошибку создания заказа можно только в одном из двух случаев:
клиент неверно обращается с данными (не сохраняет идентификатор последнего заказа или ключ идемпотентности при перезапросах);
клиент создаёт заказы одновременно с двух разных экземпляров приложения, которые не разделяют между собой состояние.
В первом случае речь идёт об ошибке имплементации приложения партнёра; второй случай означает, что пользователь намеренно пытается проверить систему на прочность, что вряд ли можно рассматривать как частотный кейс (либо, например, у пользователя сел телефон и он очень быстро продолжает работу с приложением с планшета — согласитесь, маловероятное развитие событий.)
Всё вышесказанное означает, что возникновение ошибки — исключительная ситуация, которая может действительно требовать расследования на предмет ошибки в коде.
Теперь посмотрим, что произойдёт, если мы откажемся от третьего требования, т. е. возврата мастер‑данных клиенту, не передающему токен. У нас появится третья ситуация, когда клиент получит ошибку, а именно:
клиентское приложение потеряло часть данных (токен синхронизации), и пробует повторить последний запрос.
NB: важно, что перезапрос может случить и по совершенно не техническим причинам: конечному пользователю может просто надоесть ждать, он вручную перезапустит приложение и вручную создаст повторный заказ.
Математически вероятность получения ошибки выражается довольно просто: она равна отношению периода времени, требуемого для получения актуального состояния к типичному периоду времени, за который пользователь перезапускает приложение и повторяет заказ. (Следует, правда, отметить, что клиентское приложение может быть реализовано так, что даст вам ещё меньше времени, если оно пытается повторить несозданный заказ автоматически при запуске). Если первое зависит от технических характеристик системы (в частности, лага синхронизации, т. е. задержки репликации между мастером и копиями на чтение). А вот второе зависит от того, какого рода клиент выполняет операцию.
Если мы говорим о приложения для конечного пользователя, то типично время перезапуска измеряется для них в секундах, что в норме не должно превышать суммарного лага синхронизации — таким образом, клиентские ошибки будут возникать только в случае проблем с репликацией данных / ненадежной сети / перегрузки сервера.
Однако если мы говорим не о клиентских, а о серверных приложениях, здесь ситуация совершенно иная: если сервер решает повторить запрос (например, потому, что процесс был убит супервизором), он сделает это условно моментально — задержка может составлять миллисекунды. И в этом случае фон ошибок создания заказа будет достаточно значительным.
Таким образом, возвращать по умолчанию событийно‑консистентные данные вы можете, если готовы мириться с фоном ошибок или если вы можете обеспечить задержку получения актуального состояния много меньшую, чем время перезапуска приложения на целевой платформе.