Избежание двойных платежей в системе распределенных платежей

Kate

Administrator
Команда форума
Как мы создали общую структуру идемпотентности для достижения в конечном итоге согласованности и корректности нашей архитектуры микросервисов платежей.

Background​

Airbnb переводит свою инфраструктуру на сервис-ориентированную архитектуру («SOA»). SOA предлагает множество преимуществ, таких как возможность специализации разработчика и возможность более быстрого выполнения итераций. Однако это также создает проблемы для приложений биллинга и платежей, поскольку затрудняет поддержание целостности данных. Вызов API к службе, которая выполняет дальнейшие вызовы API к нижележащим службам, где каждая служба меняет состояние и потенциально имеет побочные эффекты, эквивалентен выполнению сложной распределенной транзакции.
Чтобы обеспечить согласованность между всеми службами, можно использовать такие протоколы, как двухфазная фиксация. Без такого протокола распределенные транзакции создают проблемы для поддержания целостности данных, обеспечения плавной деградации и достижения согласованности. Запросы также неизбежно завершаются сбоем в распределенных системах - в какой-то момент соединения прерываются и истекает время ожидания, особенно для транзакций, состоящих из нескольких сетевых запросов.
В распределенных системах используются три различных общих метода для достижения конечной согласованности: восстановление при чтении, восстановление при записи и асинхронное восстановление. У каждого подхода есть свои преимущества и недостатки. Наша платежная система использует все три в различных функциях.
При асинхронном восстановлении сервер отвечает за выполнение проверок целостности данных, таких как сканирование таблиц, лямбда-функции и задания cron. Кроме того, асинхронные уведомления от сервера к клиенту широко используются в индустрии платежей для обеспечения согласованности на стороне клиента. Асинхронное восстановление, наряду с уведомлениями, может использоваться в сочетании с методами восстановления чтения и записи, предлагая вторую линию защиты с компромиссами в сложности решения.
Наше решение в этом конкретном посте использует исправление записи, когда каждый вызов записи от клиента к серверу пытается исправить несогласованное, сломанное состояние. Восстановление записи требует, чтобы клиенты были умнее (мы расширим это позже), и позволяет им многократно запускать один и тот же запрос и никогда не поддерживать состояние (кроме повторных попыток). Таким образом, клиенты могут запросить конечную согласованность по требованию, давая им контроль над пользовательским интерфейсом. Идемпотентность - чрезвычайно важное свойство при реализации исправления записи.

Что такое идемпотентность?

Чтобы запрос API был идемпотентным, клиенты могут выполнять один и тот же вызов несколько раз, и результат будет тем же. Другими словами, выполнение нескольких одинаковых запросов должно иметь тот же эффект, что и выполнение одного запроса.
Этот метод обычно используется в биллинговых и платежных системах, связанных с движением денег - очень важно, чтобы запрос на платеж был полностью обработан ровно один раз (также известный как «доставка точно один раз»). Важно отметить, что если одна операция по перемещению денег вызывается несколько раз, основная система должна перемещать деньги не более одного раза. Это критично для API-интерфейсов Airbnb Payments, чтобы избежать множественных выплат хосту и, что еще хуже, множественных платежей гостю.
По замыслу идемпотентность позволяет безопасно выполнять множественные идентичные вызовы от клиентов с использованием механизма автоповтора для API для достижения конечной согласованности. Этот метод распространен среди клиент-серверных отношений с идемпотентностью и кое-что, что мы используем сегодня в наших распределенных системах.
На верхнем уровне приведенная ниже диаграмма иллюстрирует несколько простых примеров сценариев с повторяющимися запросами и идеальным идемпотентным поведением. Независимо от того, сколько запросов на оплату было сделано, с гостя всегда будет взиматься оплата не более одного раза.

1*bft4XyiErJha_uhbGLL6-w.png


Идемпотентный запрос - это запрос, который выполняется с идентичными параметрами, и результат всегда будет одинаковым, согласованно (с гостя взимается не более одного раза).

Постановка проблемы

Гарантия согласованности нашей платежной системы в конечном итоге имеет первостепенное значение. Идемпотентность - желательный механизм для достижения этого в распределенной системе. В мире SOA мы неизбежно столкнемся с проблемами. Например, как восстановятся клиенты, если они не получат ответ? Что делать, если ответ был потерян или время ожидания клиента истекло? Как насчет условий гонки, в результате которых пользователь дважды нажимает «Забронировать»? Наши требования включали следующее:
Вместо того, чтобы внедрять единое настраиваемое решение для конкретного случая использования, нам нужно было универсальное, но настраиваемое решение для идемпотентности, которое можно было бы использовать в различных сервисах SOA для платежей Airbnb.
Пока разрабатывались платежные продукты на основе SOA, мы не могли пойти на компромисс в отношении согласованности данных, поскольку это напрямую повлияло бы на наше сообщество.
Нам требовалась сверхнизкая задержка, поэтому создания отдельной автономной службы идемпотентности было бы недостаточно. Самое главное, что служба будет страдать от тех же проблем, для решения которых она изначально была предназначена.
Поскольку Airbnb масштабирует свою инженерную организацию с помощью SOA, было бы крайне неэффективно, чтобы каждый разработчик специализировался на целостности данных и возможных проблемах согласованности. Мы хотели оградить разработчиков продукта от этих неприятностей, чтобы они могли сосредоточиться на разработке продукта и быстрее выполнять итерацию.
Кроме того, значительные компромиссы с удобочитаемостью кода, тестируемостью и способностью устранять неполадки считались неприемлемыми.

Объяснение решения

Мы хотели иметь возможность однозначно идентифицировать каждый входящий запрос. Кроме того, нам нужно было точно отслеживать и управлять тем, где конкретный запрос находился в своем жизненном цикле.
Мы внедрили и использовали «Orpheus», библиотеку идемпотентности общего назначения, в нескольких платежных сервисах. Орфей - легендарный греческий мифологический герой, который умел управлять и очаровывать все живые существа.
Мы выбрали библиотеку в качестве решения, потому что она предлагает низкую задержку, при этом обеспечивая четкое разделение между высокоскоростным кодом продукта и низкоскоростным кодом управления системой. На высоком уровне он состоит из следующих простых концепций:
Ключ идемпотентности передается в структуру, представляя один идемпотентный запрос.
Таблицы информации об идемпотентности, всегда считываемые и записываемые из сегментированной основной базы данных (для согласованности)
Транзакции базы данных объединяются в разных частях кодовой базы для обеспечения атомарности с использованием лямбда-выражений Java.
Ответы с ошибками классифицируются как «с возможностью повторения» или «без повторения».
Мы подробно расскажем, как сложная распределенная система с гарантиями идемпотентности может стать самовосстанавливающейся и в конечном итоге последовательной. Мы также рассмотрим некоторые компромиссы и дополнительные сложности нашего решения, о которых следует помнить.

Сведите количество коммитов к базе данных до минимума

Одно из ключевых требований идемпотентной системы - обеспечить согласованность только двух результатов, успеха или неудачи. В противном случае отклонения в данных могут привести к часам расследования и неправильным платежам. Поскольку базы данных предлагают свойства ACID, транзакции базы данных могут эффективно использоваться для атомарной записи данных, обеспечивая при этом согласованность. Фиксация базы данных может быть гарантирована успешной или неудачной как единое целое.
В основе Orpheus лежит предположение, что почти каждый стандартный запрос API можно разделить на три отдельных этапа: Pre-RPC, RPC и Post-RPC.
«RPC» или удаленный процедурный вызов (ы) - это когда клиент делает запрос к удаленному серверу и ожидает, пока этот сервер завершит запрошенную процедуру (ы), прежде чем возобновить свой процесс. В контексте API-интерфейсов платежей мы называем RPC запросом к нижестоящему сервису по сети, который может включать в себя внешние платежные системы и банки-эквайеры. Вкратце, вот что происходит на каждой фазе:
Pre-RPC: Подробная информация о платежном запросе записывается в базу данных.
RPC: запрос передается внешней службе по сети, и ответ получен. Это место для выполнения одного или нескольких идемпотентных вычислений или RPC (например, сначала служба запроса состояния транзакции, если это повторная попытка).
Post-RPC: Подробная информация об ответе от внешней службы записывается в базе данных, включая его успешность и возможность повторения неверного запроса.
Чтобы сохранить целостность данных, мы придерживаемся двух простых правил:
Отсутствие сервисного взаимодействия по сети на этапах до и после RPC
Никаких взаимодействий с базой данных на этапах RPC
По сути, мы не хотим смешивать сетевое взаимодействие с работой с базой данных. Мы на собственном опыте убедились, что сетевые вызовы (RPC) на этапах Pre и Post-RPC уязвимы и могут привести к плохим вещам, таким как быстрое исчерпание пула соединений и снижение производительности. Проще говоря, сетевые вызовы по своей сути ненадежны. Из-за этого мы заключили фазы Pre и Post-RPC в транзакции базы данных, инициированные самой библиотекой.
Мы также хотим указать, что один запрос API может состоять из нескольких RPC. Orpheus действительно поддерживает запросы с несколькими RPC, но в этом посте мы хотели проиллюстрировать наш мыслительный процесс только на простом случае с одним RPC.

Как показано на диаграмме ниже, каждая фиксация базы данных на каждой из фаз Pre-RPC и Post-RPC объединяется в одну транзакцию базы данных. Это гарантирует атомарность - целые единицы работы (здесь этапы Pre-RPC и Post-RPC) могут терпеть неудачу или успешно работать как единое целое. Мотив в том, что система должна выйти из строя таким образом, чтобы она могла восстановиться. Например, если несколько запросов API завершились неудачно в середине длинной последовательности коммитов базы данных, будет чрезвычайно сложно систематически отслеживать, где произошел каждый сбой. Обратите внимание, что все сетевое взаимодействие, RPC, явно отделено от всех транзакций базы данных.

1*R3-18TCwdVBAUy_E5yuRvw.png



Сетевое взаимодействие строго отделено от транзакций базы данных

При этом фиксация базы данных включает фиксацию библиотеки идемпотентности и фиксацию базы данных прикладного уровня, объединенные в одном блоке кода. Если не проявлять осторожность, в реальном коде это может выглядеть действительно беспорядочно (спагетти, кто-нибудь?). Мы также считали, что разработчик продукта не должен вызывать определенные процедуры идемпотентности.

Java-лямбды спешат на помощь

К счастью, лямбда-выражения Java можно использовать для беспрепятственного объединения нескольких предложений в одну транзакцию базы данных, не влияя на тестируемость и читаемость кода.
Вот пример упрощенного использования Orpheus с лямбдами Java в действии:

public Response processPayment(InitiatePaymentRequest request, UriInfo uriInfo)
throws YourCustomException {
return orpheusManager.process(
request.getIdempotencyKey(),
uriInfo,
// 1. Pre-RPC
() -> {
// Record payment request information from the request object
PaymentRequestResource paymentRequestResource = recordPaymentRequest(request);
return Optional.of(paymentRequestResource);
},
// 2. RPC
(isRetry, paymentRequest) -> {
return executePayment(paymentRequest, isRetry);
},
// 3. Post RPC - record response information to database
(isRetry, paymentResponse) -> {
return recordPaymentResponse(paymentResponse);
});
}


orpheus_simplified_usage.java hosted with ❤ by GitHub

На более глубоком уровне вот упрощенный отрывок из исходного кода:

orpheus_example_implementation.java hosted with ❤ by GitHub

Мы не реализовали вложенные транзакции базы данных, а вместо этого объединили инструкции базы данных от Orpheus и приложения в одну фиксацию базы данных, строго передавая функциональные интерфейсы Java (лямбда-выражения).

Разделение этих проблем предлагает некоторые компромиссы. Разработчики должны проявлять предусмотрительность, чтобы обеспечить читаемость кода и ремонтопригодность, поскольку другие новые разработки постоянно вносят свой вклад. Им также необходимо последовательно оценивать передачу правильных зависимостей и данных. Теперь вызовы API необходимо преобразовать в три небольших фрагмента, что, возможно, может ограничивать способ написания кода разработчиками. На самом деле может быть действительно сложно разделить некоторые сложные вызовы API на трехэтапный подход. В одной из наших служб реализован конечный автомат с каждым переходом как идемпотентным шагом с использованием StatefulJ, где вы можете безопасно мультиплексировать идемпотентные вызовы в вызове API.

Обработка исключений - повторять или не повторять?

В такой среде, как Orpheus, сервер должен знать, когда повторная попытка запроса безопасна, а когда нет. Чтобы это произошло, исключения должны обрабатываться с особой тщательностью - они должны быть разделены на категории «с возможностью повторения» или «без возможности повторения». Это, несомненно, добавляет уровень сложности для разработчиков и может создать плохие побочные эффекты, если они не будут разумными и осмотрительными.
Например, предположим, что нисходящая служба временно отключена, но возникшее исключение было ошибочно помечено как «неповторяемое», хотя на самом деле оно должно было быть «повторяемым». Запрос будет «неуспешным» на неопределенный срок, и последующие запросы повтора будут постоянно возвращать неверную неповторяемую ошибку. И наоборот, двойные платежи могли произойти, если исключение было помечено как «повторяемое», хотя на самом деле оно должно было быть «неповторяемым» и требовало ручного вмешательства.
В целом мы считаем, что неожиданные исключения времени выполнения из-за проблем с сетью и инфраструктурой (статусы HTTP 5XX) можно повторить. Мы ожидаем, что эти ошибки будут временными, и ожидаем, что более поздняя повторная попытка того же запроса в конечном итоге может быть успешной.
Мы классифицируем ошибки проверки, такие как недопустимый ввод и состояния (например, вы не можете вернуть возмещение), как неповторяемые (4XX состояния HTTP) - мы ожидаем, что все последующие повторные попытки одного и того же запроса не удастся таким же образом. Мы создали настраиваемый общий класс исключений, который обрабатывал эти случаи, по умолчанию он был «без повторной попытки», а для некоторых других случаев был отнесен к категории «с возможностью повторной попытки».
Важно, чтобы полезные данные запроса для каждого запроса оставались неизменными и никогда не изменялись, иначе это нарушило бы определение идемпотентного запроса.

1*_q2kiqlR69N_Tybu37Px2Q.png

Категоризация исключений с возможностью повторения и без возможности повторения

Конечно, существуют более расплывчатые крайние случаи, с которыми нужно обращаться с осторожностью, например, обработка NullPointerException надлежащим образом в разных контекстах. Например, нулевое значение, возвращаемое из базы данных из-за сообщения о подключении, отличается от ошибочного нулевого поля в запросе от клиента или в ответе третьей стороны.

Клиенты играют жизненно важную роль

Как упоминалось в начале этого поста, клиент должен быть умнее в системе восстановления записи. Он должен нести несколько ключевых обязанностей при взаимодействии со службой, использующей библиотеку идемпотентности, такую как Orpheus:
Передавать уникальный ключ идемпотентности для каждого нового запроса; повторно использовать тот же ключ идемпотентности для повторных попыток.
Сохраните эти ключи идемпотентности в базе данных перед вызовом службы (для последующего использования для повторных попыток).
Правильно принимайте успешные ответы и впоследствии отменяйте (или аннулируйте) ключи идемпотентности.
Убедитесь, что изменение полезной нагрузки запроса между попытками повторения не разрешено.
Тщательно разрабатывайте и настраивайте стратегии автоповтора, исходя из потребностей бизнеса (с использованием экспоненциального отката или случайного времени ожидания («джиттер»), чтобы избежать проблемы «грохочущего стада»).

Как выбрать ключ идемпотентности?

Выбор ключа идемпотентности имеет решающее значение - клиент может выбрать либо идемпотентность на уровне запроса, либо идемпотентность на уровне сущности в зависимости от того, какой ключ использовать. Решение об использовании одного над другим будет зависеть от различных бизнес-сценариев использования, но идемпотентность на уровне запроса является наиболее простой и распространенной.
Для идемпотентности на уровне запроса у клиента должен быть выбран случайный и уникальный ключ, чтобы гарантировать идемпотентность для всего уровня коллекции сущностей. Например, если мы хотим разрешить несколько разных платежей за бронирование (например, Pay Less Upfront), нам просто нужно убедиться, что ключи идемпотентности разные. UUID - хороший пример формата для этого.
Идемпотентность на уровне сущности гораздо более строгая и ограничительная, чем идемпотентность на уровне запроса. Допустим, мы хотим гарантировать, что для данного платежа в размере 10 долларов с идентификатором 1234 будет возвращено 5 долларов только один раз, поскольку технически мы можем дважды запросить возврат 5 долларов. Затем мы хотели бы использовать детерминированный ключ идемпотентности, основанный на модели сущности, чтобы гарантировать идемпотентность на уровне сущности. Примерный формат - «платеж-1234-возврат». Следовательно, каждый запрос на возврат для уникального платежа будет идемпотентным на уровне объекта (Платеж 1234).

У каждого запроса API истекает срок аренды

Несколько идентичных запросов могут быть запущены из-за нескольких щелчков пользователем или если у клиента есть агрессивная политика повторных попыток. Это может потенциально создать условия гонки на сервере или двойную оплату для нашего сообщества. Чтобы избежать этого, каждый вызов API с помощью платформы должен получить блокировку на уровне строки базы данных для ключа идемпотентности. Это дает аренду или разрешение на дальнейшую обработку данного запроса.
Срок аренды истекает, чтобы покрыть сценарий, когда на стороне сервера есть тайм-ауты. Если ответа нет, то запрос API можно повторить только после истечения срока текущей аренды. Приложение может настроить истечение срока аренды и таймауты RPC в соответствии со своими потребностями. Хорошее практическое правило - срок действия аренды должен быть больше, чем таймаут RPC.
Orpheus дополнительно предлагает максимальное окно с возможностью повторной попытки для ключа идемпотентности, чтобы обеспечить подстраховку, чтобы избежать ложных повторных попыток из-за неожиданного поведения системы.


Запись ответа

Мы также записываем ответы, чтобы поддерживать и контролировать идемпотентное поведение. Когда клиент делает тот же запрос для транзакции, которая достигла детерминированного конечного состояния, такого как неповторяемая ошибка (например, ошибки проверки) или успешный ответ, ответ записывается в базу данных.
У постоянных ответов действительно есть компромисс с производительностью - клиенты могут получать быстрые ответы при последующих повторных попытках, но эта таблица будет расти пропорционально росту пропускной способности приложения. Этот стол может быстро стать раздутым, если мы не будем осторожны. Одно из возможных решений - периодически удалять строки старше определенного периода времени, но слишком раннее удаление идемпотентного ответа также имеет негативные последствия. Разработчикам также следует опасаться внесения обратно несовместимых изменений в сущности и структуру ответа.

Избегайте баз данных-реплик - придерживайтесь мастера

При чтении и записи информации об идемпотентности с помощью Orpheus мы решили делать это непосредственно из основной базы данных. В системе распределенных баз данных существует компромисс между согласованностью и задержкой. Поскольку мы не могли мириться с большими задержками или чтением незафиксированных данных, использование master для этих таблиц имело для нас наибольший смысл. При этом нет необходимости использовать кэш или реплику базы данных. Если система базы данных не настроена на строгую согласованность чтения (наши системы поддерживаются MySQL), использование реплик для этих операций может фактически создать неблагоприятные эффекты с точки зрения идемпотентности.
Например, предположим, что платежный сервис сохранил информацию об идемпотентности в реплике базы данных. Клиент отправляет в службу запрос на оплату, который в конечном итоге оказывается успешным в нисходящем направлении, но клиент не получает ответа из-за проблем с сетью. Ответ, который в настоящее время хранится в главной базе данных службы, в конечном итоге будет записан в реплику. Однако в случае задержки реплики клиент может правильно запустить идемпотентную повторную попытку к службе, и ответ еще не будет записан в реплику. Поскольку ответ «не существует» (на реплике), служба может ошибочно выполнить платеж еще раз, что приведет к дублированию платежей. В приведенном ниже примере показано, как всего несколько секунд задержки реплики может оказать значительное финансовое влияние на сообщество Airbnb.

Дублирующий платеж, созданный из-за задержки реплики

1*ASFZE0BWYFYxokg1WUgnZw.png


Ответ 1 не найден на реплике при повторной попытке (запрос 2)

Избежание дублирования платежей за счет хранения информации об идемпотентности только на главном устройстве

1*FBLUYnSjxQ15uSdAisvriA.png


Ответ 1 немедленно обнаружен на главном сервере и возвращен при повторной попытке (запрос 2)

При использовании единой главной базы данных для идемпотентности было довольно ясно, что масштабирование, несомненно, быстро станет проблемой. Мы облегчили это, сегментируя базу данных по ключу идемпотентности. Ключи идемпотентности, которые мы используем, имеют высокую мощность и равномерное распределение, что делает их эффективными ключами сегментов.

Последние мысли

Существует множество различных решений для устранения проблем согласованности в распределенных системах. Orpheus - один из нескольких, которые нам хорошо подходят, потому что он универсален и легковесен. Разработчик может просто импортировать библиотеку при работе с новым сервисом, а логика идемпотентности хранится на отдельном, абстрактном уровне над концепциями и моделями, специфичными для приложения.
Однако достижение конечной согласованности не обходится без некоторой сложности. Клиентам необходимо хранить и обрабатывать ключи идемпотентности и внедрять автоматические механизмы повтора. Разработчикам требуется дополнительный контекст, и они должны быть хирургически точными при реализации и устранении неполадок лямбда-выражений Java. Они должны быть преднамеренными при обработке исключений. Кроме того, поскольку текущая версия Orpheus проходит боевые испытания, мы постоянно ищем вещи, которые можно улучшить: сопоставление запросов и полезных данных для повторных попыток, улучшенную поддержку изменений схемы и вложенных миграций, активное ограничение доступа к базе данных на этапах RPC и т. Д.
Хотя это главные соображения, откуда Орфей до сих пор получал платежи Airbnb? С момента запуска платформы мы достигли пяти девяток в согласованности наших платежей, в то время как наш годовой объем платежей одновременно удвоился (прочтите это, если вы хотите узнать больше о том, как мы измеряем целостность данных в масштабе).

Если вы хотите поработать над тонкостями платформы распределенных платежей и помочь путешественникам по всему миру попасть куда угодно, команда Airbnb Payments нанимает!

Благодарим Мишеля Векслера и Дерека Ванга за их интеллектуальное лидерство и архитектурную философию в этом проекте!

Перевод статьи с сайта https://medium.com/
 
Сверху