mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-04-17 11:06:25 +02:00
Eventual Consistency
This commit is contained in:
parent
4ec0e03e1c
commit
b01772fc12
BIN
docs/API.en.epub
BIN
docs/API.en.epub
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
docs/API.en.pdf
BIN
docs/API.en.pdf
Binary file not shown.
BIN
docs/API.ru.epub
BIN
docs/API.ru.epub
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
docs/API.ru.pdf
BIN
docs/API.ru.pdf
Binary file not shown.
@ -84,7 +84,7 @@
|
|||||||
<li><a href="API.en.html#api-patterns-context">Chapter 15. On Design Patterns in the API Context</a></li>
|
<li><a href="API.en.html#api-patterns-context">Chapter 15. On Design Patterns in the API Context</a></li>
|
||||||
<li><a href="API.en.html#api-patterns-aa">Chapter 16. Authenticating Partners and Authorizing API Calls</a></li>
|
<li><a href="API.en.html#api-patterns-aa">Chapter 16. Authenticating Partners and Authorizing API Calls</a></li>
|
||||||
<li><a href="API.en.html#api-patterns-sync-strategies">Chapter 17. Synchronization Strategies</a></li>
|
<li><a href="API.en.html#api-patterns-sync-strategies">Chapter 17. Synchronization Strategies</a></li>
|
||||||
<li><a href="API.en.html#chapter-18">Chapter 18. Eventual Consistency</a></li>
|
<li><a href="API.en.html#api-patterns-weak-consistency">Chapter 18. Eventual Consistency</a></li>
|
||||||
<li><a href="API.en.html#chapter-19">Chapter 19. Asynchronicity and Time Management</a></li>
|
<li><a href="API.en.html#chapter-19">Chapter 19. Asynchronicity and Time Management</a></li>
|
||||||
<li><a href="API.en.html#chapter-20">Chapter 20. Lists and Accessing Them</a></li>
|
<li><a href="API.en.html#chapter-20">Chapter 20. Lists and Accessing Them</a></li>
|
||||||
<li><a href="API.en.html#chapter-21">Chapter 21. Bidirectional Data Flows. Push and Poll Models</a></li>
|
<li><a href="API.en.html#chapter-21">Chapter 21. Bidirectional Data Flows. Push and Poll Models</a></li>
|
||||||
|
@ -84,7 +84,7 @@
|
|||||||
<li><a href="API.ru.html#chapter-15">Глава 15. О паттернах проектирования в контексте API</a></li>
|
<li><a href="API.ru.html#chapter-15">Глава 15. О паттернах проектирования в контексте API</a></li>
|
||||||
<li><a href="API.ru.html#api-patterns-aa">Глава 16. Аутентификация партнёров и авторизация вызовов API</a></li>
|
<li><a href="API.ru.html#api-patterns-aa">Глава 16. Аутентификация партнёров и авторизация вызовов API</a></li>
|
||||||
<li><a href="API.ru.html#api-patterns-sync-strategies">Глава 17. Стратегии синхронизации</a></li>
|
<li><a href="API.ru.html#api-patterns-sync-strategies">Глава 17. Стратегии синхронизации</a></li>
|
||||||
<li><a href="API.ru.html#chapter-18">Глава 18. Слабая консистентность</a></li>
|
<li><a href="API.ru.html#api-patterns-weak-consistency">Глава 18. Слабая консистентность</a></li>
|
||||||
<li><a href="API.ru.html#chapter-19">Глава 19. Асинхронность и управление временем</a></li>
|
<li><a href="API.ru.html#chapter-19">Глава 19. Асинхронность и управление временем</a></li>
|
||||||
<li><a href="API.ru.html#chapter-20">Глава 20. Списки и организация доступа к ним</a></li>
|
<li><a href="API.ru.html#chapter-20">Глава 20. Списки и организация доступа к ним</a></li>
|
||||||
<li><a href="API.ru.html#api-patterns-push-vs-poll">Глава 21. Двунаправленные потоки данных. Push и poll-модели</a></li>
|
<li><a href="API.ru.html#api-patterns-push-vs-poll">Глава 21. Двунаправленные потоки данных. Push и poll-модели</a></li>
|
||||||
|
@ -1 +1,101 @@
|
|||||||
### Eventual Consistency
|
### [Eventual Consistency][api-patterns-weak-consistency]
|
||||||
|
|
||||||
|
The approach described in the previous chapter is in fact a trade-off: the API performance issues are traded for “normal” (i.e., expected) background errors that happen while working with the API. This is achieved by isolating the component responsible for controlling concurrency and ensuring strict consistency within the system. Still, the achievable throughput of the API is still limited, and the only way of scaling it up is removing the strict consistency from the external API and thus allowing reading system state from read-only replicas:
|
||||||
|
|
||||||
|
```
|
||||||
|
// Reading the state,
|
||||||
|
// possibly from a replica
|
||||||
|
const orderState =
|
||||||
|
await api.getOrderState();
|
||||||
|
const version =
|
||||||
|
orderState.latestVersion;
|
||||||
|
try {
|
||||||
|
// The request handler will
|
||||||
|
// read the actual version
|
||||||
|
// from the master data
|
||||||
|
const task = await api
|
||||||
|
.createOrder(version, …);
|
||||||
|
} catch (e) {
|
||||||
|
…
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
As orders are created much more rarely than read, we might significantly increase the system performance if we drop the requirement of returning the most recent state of the resource from the state retrieval endpoints. The versioning will help us avoid possible problems: creating an order will still be impossible unless the client has the actual version. In fact, we transited to the [eventual consistency](https://en.wikipedia.org/wiki/Consistency_model#Eventual_consistency) model: the client will be able to fulfill its request *sometime* when it finally gets the actual data. In modern microservice architectures, eventual consistency is rather an industrial standard, and it might be close to impossible to achieve the opposite, i.e., strict consistency.
|
||||||
|
|
||||||
|
**NB**: let us stress that you might choose the approach only in the case of exposing new APIs. If you're already providing an endpoint implementing some consistency model, you can't just lower the consistency level (for instance, introduce eventual consistency instead of the strict one) even if you never documented the behavior. This will be discussed in detail in the [“On the Waterline of the Iceberg”](#back-compat-iceberg-waterline) chapter of “The Backwards Compatibility” section of this book.
|
||||||
|
|
||||||
|
Choosing weak consistency instead of a strict one, however, brings some disadvantages. For instance, we might require partners to wait until they get the actual resource state to make changes — but it is quite unobvious for partners (and actually inconvenient) they must be prepared to wait for changes they made themselves to propagate.
|
||||||
|
|
||||||
|
```
|
||||||
|
// Creates an order
|
||||||
|
const api = await api
|
||||||
|
.createOrder(…)
|
||||||
|
// Returns a list of orders
|
||||||
|
const pendingOrders = await api.
|
||||||
|
getOngoingOrders(); // → []
|
||||||
|
// The list is empty
|
||||||
|
```
|
||||||
|
|
||||||
|
If strict consistency is not guaranteed, the second call might easily return an empty result as it reads data from a replica, and the newest order might not have hit it yet.
|
||||||
|
|
||||||
|
An important pattern that helps in this situation is implementing the [“read-your-writes”](https://en.wikipedia.org/wiki/Consistency_model#Read-your-writes_consistency) model, i.e., guaranteeing that clients observe the changes they have just made. The consistency might be lifted to the read-your-writes level by making clients pass some token that describes the last changes known to the client.
|
||||||
|
|
||||||
|
```
|
||||||
|
const order = await api
|
||||||
|
.createOrder(…);
|
||||||
|
const pendingOrders = await api.
|
||||||
|
getOngoingOrders({
|
||||||
|
…,
|
||||||
|
// Pass the identifier of the
|
||||||
|
// last operation made by
|
||||||
|
// the client
|
||||||
|
last_known_order_id: order.id
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Such a token might be:
|
||||||
|
* an identifier (or identifiers) of the last modifying operations carried out by the client;
|
||||||
|
* the last known resource version (modification date, ETag) known to the client.
|
||||||
|
|
||||||
|
Upon getting the token, the server must check that the response (e.g., the list of ongoing operations it returns) matches the token, i.e., the eventual consistency converged. If it did not (the client passed the modification date / version / last order id newer than the one known to the server), one of the following policies or their combinations might be applied:
|
||||||
|
* the server might repeat the request to the underlying DB or to the other kind of data storage in order to get the newest version (eventually);
|
||||||
|
* the server might return an error that requires the client to try again later;
|
||||||
|
* the server queries the main node of the DB, if such a thing exists, or otherwise initiates retrieving the master data.
|
||||||
|
|
||||||
|
The advantage of this approach is client development convenience (compared to the absence of any guarantees): by preserving the version token, client developers get rid of the possible inconsistency of the data got from API endpoints. There are two disadvantages, however:
|
||||||
|
* it is still a trade-off between system scalability and a constant inflow of background errors;
|
||||||
|
* if you're querying master data or repeating the request upon the version mismatch, the load on the master storage is increased in poorly a predictable manner;
|
||||||
|
* if you return a client error instead, the number of such errors might be considerable, and partners will need to write some additional code to deal with the errors;
|
||||||
|
* this approach is still probabilistic, and will only help in a limited number of use cases (to be discussed in the next chapter).
|
||||||
|
|
||||||
|
There is also an important question regarding the default behavior of the server if no version token was passed. Theoretically, in this case, master data should be returned, as the absence of the token might be the result of an app crash and subsequent restart or corrupted data storage. However, this implies an additional load on the master node.
|
||||||
|
|
||||||
|
#### Evaluating the Risks of Switching to Eventual Consistency
|
||||||
|
|
||||||
|
Let us state an important assertion: the methods of solving architectural problems we're discussing in this section *are probabilistic*. Abolishing strict consistency means that, even if all components of the system work perfectly, client errors will still occur — and we may only try to lessen their numbers for typical usage profiles.
|
||||||
|
|
||||||
|
**NB**: the “typical usage profile” stipulation is important: an API implies the variability of client scenarios, and API usage cases might fall into several groups, each featuring quite different error profiles. The classical example is client APIs (where it's an end user who makes actions and waits for results) versus server APIs (where the execution time is per se not so important — but let's say mass parallel execution might be). If this happens, it's a strong signal to make a family of API products covering different usage scenarios, as we will discuss in [“The API Services Range”](#api-product-range) chapter of “The API Product” section of this book.
|
||||||
|
|
||||||
|
Let's return to the coffee example, and imagine we implemented the following scheme:
|
||||||
|
* optimistic concurrency control (through, let's say, the id of the last user's order)
|
||||||
|
* the “read-your-writes” policy of reading the order list (again with passing the last known order id as a token)
|
||||||
|
* retrieving master data in the case the token is absent.
|
||||||
|
|
||||||
|
In this case, the order creation error might only happen in one of the two cases:
|
||||||
|
* the client works with the data incorrectly (does not preserve the identifier of the last order or the idempotency key while repeating the request)
|
||||||
|
* the client tries to create an order from two different instances of the app that do not share the common state.
|
||||||
|
|
||||||
|
The first case means there is a bug in the partner's code; the second case means that the user is deliberately testing the system's stability — which is hardly a frequent case (or, let's say, the user's phone went off and they quickly switched to a tablet — rather rare case, we must admit).
|
||||||
|
|
||||||
|
Let's now imagine that we dropped the third requirement — i.e., returning the master data if the token was not provided by the client. We would get the third case when the client gets an error:
|
||||||
|
* the client application lost some data (restarted or corrupted), and the user tries to replicate the last request.
|
||||||
|
|
||||||
|
**NB**: the repeated request might happen without any automation involved if, let's say, the user got bored of waiting, killed the app and manually re-orders the coffee again.
|
||||||
|
|
||||||
|
Mathematically, the probability of getting the error is expressed quite simply. It's the ratio between two durations: the time period needed to get the actual state to the time period needed to restart the app and repeat the request. (Keep in mind that the last failed request might be automatically repeated on startup by the client.) The former depends on the technical properties of the system (for instance, on the replication latency, i.e., the lag between the master and its read-only copies) while the latter depends on what client is repeating the call.
|
||||||
|
|
||||||
|
If we talk about applications for end users, the typical restart time there is measured in seconds, which normally should be much less than the overall replication latency. Therefore, client errors will only occur in case of data replication problems / network issues / server overload.
|
||||||
|
|
||||||
|
If, however, we talk about server-to-server applications, the situation is totally different: if a server repeats the request after a restart (let's say because the process was killed by a supervisor), it's typically a millisecond-scale delay. And that means that the number of order creation errors will be significant.
|
||||||
|
|
||||||
|
As a conclusion, returning eventually consistent data by default is only viable if an API vendor is either ready to live with background errors or capable of making the lag of getting the actual state much less than the typical app restart time.
|
@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
Разница между двумя подходами заключается в гранулярности доступа:
|
Разница между двумя подходами заключается в гранулярности доступа:
|
||||||
* если клиент API выполняет запросы от имени пользователя системы, то его доступ к эндпойнту может быть ограничен каким-то конкретным набором данных, к которым имеет доступ пользователь;
|
* если клиент API выполняет запросы от имени пользователя системы, то его доступ к эндпойнту может быть ограничен каким-то конкретным набором данных, к которым имеет доступ пользователь;
|
||||||
* если же авторизуется вызывающая система, то обычно подразумевается, что она имеет полный доступ к эндпойнту, и может передавать любые параметр (т.е. имеет доступ к полному набору данных, предоставляемых через эндпойнт).
|
* если же авторизуется вызывающая система, то обычно подразумевается, что она имеет полный доступ к эндпойнту, и может передавать любые параметры (т.е. имеет доступ к полному набору данных, предоставляемых через эндпойнт).
|
||||||
|
|
||||||
Первый подход, таким образом, является более гранулярным (робот может быть «виртуальным сотрудником» организации, то есть иметь доступ только к ограниченному набору данных) и вообще является естественным выбором для тех API, которые являются дополнением к существующему сервису для конечных пользователей (и, таким образом, иогут использовать уже существующие системы AA). Недостатками же этого подхода являются:
|
Первый подход, таким образом, является более гранулярным (робот может быть «виртуальным сотрудником» организации, то есть иметь доступ только к ограниченному набору данных) и вообще является естественным выбором для тех API, которые являются дополнением к существующему сервису для конечных пользователей (и, таким образом, могут использовать уже существующие системы AA). Недостатками же этого подхода являются:
|
||||||
|
|
||||||
1. Необходимо организовать какой-то процесс безопасного получения токенов авторизации для пользователя-робота (например, через получение для него токенов реальным пользователем из веб-интерфейса), поскольку стандартная логин-парольная схема логина (тем более двухфакторная) слаба применима к клиенту API.
|
1. Необходимо организовать какой-то процесс безопасного получения токенов авторизации для пользователя-робота (например, через получение для него токенов реальным пользователем из веб-интерфейса), поскольку стандартная логин-парольная схема логина (тем более двухфакторная) слаба применима к клиенту API.
|
||||||
2. Необходимо сделать для пользователей-роботов исключения из почти всех систем безопасности:
|
2. Необходимо сделать для пользователей-роботов исключения из почти всех систем безопасности:
|
||||||
|
@ -1 +1,105 @@
|
|||||||
### Слабая консистентность
|
### [Слабая консистентность][api-patterns-weak-consistency]
|
||||||
|
|
||||||
|
Описанный в предыдущей главе подход фактически представляет собой размен производительности API на «нормальный» (т.е. ожидаемый) фон ошибок при работе с ним путём изоляции компонента, отвечающего за строгую консистентность и управление параллелизмом внутри системы. Тем не менее, его пропускная способность всё равно ограничена, и снизить её мы можем единственным образом — убрав строгую консистентность из внешнего API, что даст возможность осуществлять чтение состояния системы из реплик:
|
||||||
|
|
||||||
|
```
|
||||||
|
// Получаем состояние,
|
||||||
|
// возможно, из реплики
|
||||||
|
const orderState =
|
||||||
|
await api.getOrderState();
|
||||||
|
const version =
|
||||||
|
orderState.latestVersion;
|
||||||
|
try {
|
||||||
|
// Обработчик запроса на
|
||||||
|
// создание заказа прочитает
|
||||||
|
// актуальную версию
|
||||||
|
// из мастер-данных
|
||||||
|
const task = await api
|
||||||
|
.createOrder(version, …);
|
||||||
|
} catch (e) {
|
||||||
|
…
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Т.к. заказы создаются намного реже, нежели читаются, мы можем существенно повысить производительность системы, если откажемся от гарантии возврата всегда самого актуального состояния ресурса из операции на чтение. Версионирование же поможет нам избежать проблем: создать заказ, не получив актуальной версии, невозможно. Фактически мы пришли к модели [событийной консистентности](https://en.wikipedia.org/wiki/Consistency_model#Eventual_consistency) (т.н. «согласованность в конечном счёте»): клиент сможет выполнить свой запрос *когда-нибудь*, когда получит, наконец, актуальные данные. В самом деле, согласованность в конечном счёте — скорее норма жизни для современных микросервисных архитектур, в которой может оказаться очень сложно как раз добиться обратного, т.е. строгой консистентности.
|
||||||
|
|
||||||
|
**NB**: на всякий случай уточним, что выбирать подходящий подход вы можете только в случае разработки новых API. Если вы уже предоставляете эндпойнт, реализующий какую-то модель консистентности, вы не можете понизить её уровень (в частности, сменить строгую консистентность на слабую), даже если вы никогда не документировали текущее поведение явно (мы обсудим это требование детальнее в главе [«О ватерлинии айсберга»](#back-compat-iceberg-waterline) раздела «Обратная совместимость»).
|
||||||
|
|
||||||
|
Однако, выбор слабой консистентности вместо сильной влечёт за собой и другие проблемы. Да, мы можем потребовать от партнёров дождаться получения последнего актуального состояния ресурса перед внесением изменений. Но очень неочевидно (и в самом деле неудобно) требовать от партнёров быть готовыми к тому, что они должны дождаться появления в том числе и тех изменений, которые сами же внесли.
|
||||||
|
|
||||||
|
```
|
||||||
|
// Создаёт заказ
|
||||||
|
const api = await api
|
||||||
|
.createOrder(…)
|
||||||
|
// Возвращает список заказов
|
||||||
|
const pendingOrders = await api.
|
||||||
|
getOngoingOrders(); // → []
|
||||||
|
// список пуст
|
||||||
|
```
|
||||||
|
|
||||||
|
Если мы не гарантируем сильную консистентность, то второй вызов может запросто вернуть пустой результат, ведь при чтении из реплики новый заказ мог просто до неё ещё не дойти.
|
||||||
|
|
||||||
|
Важный паттерн, который поможет в этой ситуации — это имплементация модели [«read-your-writes»](https://en.wikipedia.org/wiki/Consistency_model#Read-your-writes_consistency), а именно гарантии, что клиент всегда «видит» те изменения, которые сам же и внёс. Поднять уровень слабой консистентности до 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-product-range) раздела «API как продукт».
|
||||||
|
|
||||||
|
Проиллюстрируем этот принцип на нашем примере с заказом кофе. Предположим, что мы реализуем следующую схему:
|
||||||
|
* оптимистичное управление синхронизацией (скажем, через идентификатор последнего заказа);
|
||||||
|
* «read-your-writes»-политика чтения списка заказов (вновь через отправку последнего идентификатора заказа в качестве токена);
|
||||||
|
* если токен не передан, клиент всегда получает актуальное состояние.
|
||||||
|
|
||||||
|
Тогда получить ошибку создания заказа можно только в одном из двух случаев:
|
||||||
|
* клиент неверно обращается с данными (не сохраняет идентификатор последнего заказа или ключ идемпотентности при перезапросах);
|
||||||
|
* клиент создаёт заказы одновременно с двух разных экземпляров приложения, которые не разделяют между собой состояние.
|
||||||
|
|
||||||
|
В первом случае речь идёт об ошибке имплементации приложения партнёра; второй случай означает, что пользователь намеренно пытается проверить систему на прочность, что вряд ли можно рассматривать как частотный кейс (либо, например, у пользователя сел телефон и он очень быстро продолжает работу с приложением с планшета — согласитесь, маловероятное развитие событий.)
|
||||||
|
|
||||||
|
Всё вышесказанное означает, что возникновение ошибки — исключительная ситуация, которая может действительно требовать расследования на предмет ошибки в коде.
|
||||||
|
|
||||||
|
Теперь посмотрим, что произойдёт, если мы откажемся от третьего требования, т.е. возврата мастер-данных клиенту, не передающему токен. У нас появится третья ситуация, когда клиент получит ошибку, а именно:
|
||||||
|
* клиентское приложение потеряло часть данных (токен синхронизации), и пробует повторить последний запрос.
|
||||||
|
|
||||||
|
**NB**: важно, что перезапрос может случить и по совершенно не техническим причинам: конечному пользователю может просто надоесть ждать, он вручную перезапустит приложение и вручную создаст повторный заказ.
|
||||||
|
|
||||||
|
Математически вероятность получения ошибки выражается довольно просто: она равна отношению периода времени, требуемого для получения актуального состояния к типичному периоду времени, за который пользователь перезапускает приложение и повторяет заказ. (Следует, правда, отметить, что клиентское приложение может быть реализовано так, что даст вам ещё меньше времени, если оно пытается повторить несозданный заказ автоматически при запуске). Если первое зависит от технических характеристик системы (в частности, лага синхронизации, т.е. задержки репликации между мастером и копиями на чтение). А вот второе зависит от того, какого рода клиент выполняет операцию.
|
||||||
|
|
||||||
|
Если мы говорим о приложения для конечного пользователя, то типично время перезапуска измеряется для них в секундах, что в норме не должно превышать суммарного лага синхронизации — таким образом, клиентские ошибки будут возникать только в случае проблем с репликацией данных / ненадежной сети / перегрузки сервера.
|
||||||
|
|
||||||
|
Однако если мы говорим не о клиентских, а о серверных приложениях, здесь ситуация совершенно иная: если сервер решает повторить запрос (например, потому, что процесс был убит супервизором), он сделает это условно моментально — задержка может составлять миллисекунды. И в этом случае фон ошибок создания заказа будет достаточно значительным.
|
||||||
|
|
||||||
|
Таким образом, возвращать по умолчанию событийно-консистентные данные вы можете, если готовы мириться с фоном ошибок или если вы можете обеспечить задержку получения актуального состояния много меньшую, чем время перезапуска приложения на целевой платформе.
|
||||||
|
@ -1,91 +0,0 @@
|
|||||||
### Стратегии синхронизации
|
|
||||||
|
|
||||||
Перейдём теперь к техническим проблемам, стоящим перед разработчикам API, и начнём с последней из описанных во вводной главе — необходимости синхронизировать состояния. Представим, что конечный пользователь размещает заказ на приготовление кофе через наш API. Пока этот запрос путешествует от клиента в кофейню и обратно, многое может произойти. Например, рассмотрим следующую последовательность событий.
|
|
||||||
|
|
||||||
1. Клиент отправляет запрос на создание нового заказа.
|
|
||||||
2. Из-за сетевых проблем запрос идёт до сервера очень долго, а клиент получает таймаут:
|
|
||||||
* клиент, таким образом, не знает, был ли выполнен запрос или нет.
|
|
||||||
3. Клиент запрашивает текущее состояние системы и получает пустой ответ, поскольку таймаут случился раньше, чем запрос на создание заказа дошёл до сервера:
|
|
||||||
```
|
|
||||||
const pendingOrders = await
|
|
||||||
api.getOngoingOrders(); // → []
|
|
||||||
```
|
|
||||||
4. Сервер, наконец, получает запрос на создание заказа и исполняет его.
|
|
||||||
5. Клиент, не зная об этом, создаёт заказ повторно.
|
|
||||||
|
|
||||||
Поскольку действия чтения списка актуальных заказов и создания нового заказа разнесены во времени, мы не можем гарантировать, что между этими запросами состояние системы не изменилось. Если же мы хотим такую гарантию дать, нам нужно обеспечить какую-то из [стратегий синхронизации](https://en.wikipedia.org/wiki/Synchronization_(computer_science)). Если в случае, скажем, API операционных систем или клиентских фреймворков мы можем воспользоваться предоставляемыми платформой примитивами, то в кейсе распределённых сетевых API такой примитив нам придётся разработать самостоятельно.
|
|
||||||
|
|
||||||
Существуют два основных подхода к решению этой проблемы — пессимистичный (программная реализация блокировок) и оптимистичный (версионирование ресурсов).
|
|
||||||
|
|
||||||
**NB**. Вообще, лучший способ избежать проблемы — не иметь её вовсе. Если ваш API идемпотентен, то никакой повторной обработки запроса не будет происходить. Однако не все операции в реальном мире идемпотентны в принципе: например, создание нового заказа такой операцией не является. Мы можем добавлять механики, предотвращающие *автоматические* перезапросы (такие как, например, генерируемый клиентом токен идемпотентности), но не можем запретить пользователю просто взять и повторно создать точно такой же заказ.
|
|
||||||
|
|
||||||
##### Программные блокировки
|
|
||||||
|
|
||||||
Первый подход — очевидным образом перенести стандартные примитивы синхронизации на уровень API. Например,вот так:
|
|
||||||
|
|
||||||
```
|
|
||||||
let lock;
|
|
||||||
try {
|
|
||||||
// Захватываем право
|
|
||||||
// на эксклюзивное исполнение
|
|
||||||
// операции создания заказа
|
|
||||||
lock = await api.
|
|
||||||
acquireLock(ORDER_CREATION);
|
|
||||||
// Получаем текущий список
|
|
||||||
// заказов, известных системе
|
|
||||||
const pendingOrders = await
|
|
||||||
api.getPendingOrders();
|
|
||||||
// Если нашего заказа ещё нет,
|
|
||||||
// создаём его
|
|
||||||
const task = await api
|
|
||||||
.createOrder(…)
|
|
||||||
} catch (e) {
|
|
||||||
// Обработка ошибок
|
|
||||||
} finally {
|
|
||||||
// Разблокировка
|
|
||||||
await lock.release();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
В примере выше предполагается, что чтение состояния ресурса происходит только после получения блокировки. Думаем, излишне уточнять, что подобного рода подход крайне редко реализуется в распределённых сетевых API, из-за комплекса связанных проблем.
|
|
||||||
|
|
||||||
1. Ожидание получения блокировки вносит во взаимодействие дополнительные плохо предсказуемые и, в худшем случае, весьма длительные задержки.
|
|
||||||
2. Сама по себе блокировка — это ещё одна сущность, для работы с которой нужно иметь отдельную весьма производительную подсистему, поскольку для работы блокировок требуется ещё и обеспечить сильную консистентность в API: метод `getPendingOrders` должен вернуть актуальное состояние системы, иначе повторный заказ всё равно будет создан.
|
|
||||||
3. Поскольку клиентская часть разрабатывается сторонними партнёрами, мы не можем гарантировать, что написанный ими код корректно работает с блокировками; неизбежно в системе появятся «висящие» блокировки, а, значит, придётся предоставлять партнёрам инструменты для отслеживания и отладки возникающих проблем.
|
|
||||||
4. Необходимо разработать достаточную гранулярность блокировок, чтобы партнёры не могли влиять на работоспособность друг друга. Хорошо, если мы можем ограничить блокировку, скажем, конкретным конечным пользователем в конкретной системе партнёра; но если этого сделать не получается (например, если система авторизации общая и все партнёры имеют доступ к одному и тому же профилю пользователя), то необходимо разрабатывать ещё более комплексные системы, которые будут исправлять потенциальные ошибки в коде партнёров — например, вводить квоты на блокировки.
|
|
||||||
|
|
||||||
##### Оптимистичное управление параллелизмом
|
|
||||||
|
|
||||||
Более щадящий с точки зрения сложности имплементации вариант — это реализовать [оптимистичное управление параллелизмом](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) и потребовать от клиента передавать признак того, что он располагает актуальным состоянием разделяемого ресурса.
|
|
||||||
|
|
||||||
```
|
|
||||||
// Получаем состояние
|
|
||||||
const orderState =
|
|
||||||
await api.getOrderState();
|
|
||||||
// Частью состояния является
|
|
||||||
// версия ресурса
|
|
||||||
const version =
|
|
||||||
orderState.latestVersion;
|
|
||||||
// Заказ можно создать,
|
|
||||||
// только если версия состояния
|
|
||||||
// не изменилась с момента чтения
|
|
||||||
try {
|
|
||||||
const task = await api
|
|
||||||
.createOrder(version, …);
|
|
||||||
} catch (e) {
|
|
||||||
// Если версия неверна, т.е. состояние
|
|
||||||
// было параллельно изменено
|
|
||||||
// другим клиентом, произойдёт ошибка
|
|
||||||
if (Type(e) == INCORRECT_VERSION) {
|
|
||||||
// Которую нужно как-то обработать…
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**NB**: Внимательный читатель может возразить нам, что необходимость имплементировать стратегии синхронизации и строгую консистентность никуда не пропала, т.к. где-то в системе должен существовать компонент, осуществляющий блокирующее чтение версии с её последующим изменением. Это не совсем так: стратегии синхронизации и строгая консистентность *пропали из публичного API*. Расстояние между клиентом, устанавливающим блокировку, и сервером, её обрабатывающим, стало намного меньше, и всё взаимодействие теперь происходит в контролируемой среде (это вообще может быть одна подсистема, если мы используем [ACID-совместимую базу данных](https://en.wikipedia.org/wiki/ACID) или вовсе держим состояние ресурса в оперативной памяти).
|
|
||||||
|
|
||||||
Вместо версий можно использовать дату последней модификации ресурса (что в целом гораздо менее надёжно ввиду неидеальной синхронизации часов в разных узлах системы; не забывайте, как минимум, сохранять дату с максимально доступной точностью!) либо идентификаторы сущности (ETag).
|
|
||||||
|
|
||||||
Достоинством оптимистичного управления параллелизмом является, таким образом, возможность «спрятать» сложную в имплементации и масштабировании часть «под капотом». Недостаток же состоит в том, что ошибки версионирования теперь являются штатным поведением, и клиентам *придётся* написать правильную работу с ними, иначе их приложение может вообще оказаться неработоспособным — пользователь будет вечно пытаться создать заказ с неактуальной версией.
|
|
||||||
|
|
||||||
**NB**. Выбор ресурса, версию которого мы требуем передать для получения доступа, очень важен. Если в нашем примере мы заведём глобальную версию всей системы, которая изменяется при поступлении любого заказа, то, очевидно, у пользователя будут околонулевые шансы успешно разместить заказ.
|
|
Loading…
x
Reference in New Issue
Block a user