В этом посте я собираюсь пройти процесс построения рабочего процесса на примере платежей. По мере того, как вы будете больше работать с Redis, вы скоро начнете создавать рабочие процессы, то есть небольшие фрагменты кода, которые общаются друг с другом через Redis. Для тех, кто знаком с сервис-ориентированным подходом к построению систем, это должно быть похоже на дежа-вю. За исключением того, что вместо использования протокола (HTTP, TCP, UDP, AMQP, ZeroMQ) мы возвращаемся к CS101 с использованием старой доброй структуры данных очереди.
Многим это может показаться сумасшедшим: создание рабочего процесса обработки платежей с использованием чего-либо, кроме традиционной СУБД. Я хотел бы возразить, однако, что многие из предполагаемых преимуществ, которые, по вашему мнению, традиционные СУБД обеспечивают с точки зрения транзакционности, могут быть очень легко созданы с помощью Redis.
Существует проблема, связанная с тем, чтобы Redis сохранял свои данные в энергозависимой памяти, и если компьютер, на котором работает сервер Redis, дает сбой, то все, что было с момента последнего bgsave, потеряно. Но те же самые правила применимы и к обычной базе данных. Вы можете увеличить частоту, с которой redis выполняет fsync и записывать ее на диск при каждой записи, как в СУБД. Что делать, если диск стирается? В этом случае вы можете принять те же меры предосторожности, что и при работе с СУБД, т.е. при репликации. Лично я попадаю в лагерь одобрения репликации и думаю, что это делает недействительной необходимость иметь параноидальные записи на диск (которые убивают производительность, которую дает redis). Избавившись от этого, давайте приступим к созданию быстрого, правильного, надежного и надежного платежного процессора с использованием атомарных инструментов, которые дает вам Redis.
Модус операнди
Мы хотим, чтобы каждый из игроков был в нашем рабочем процессе легким, простым и желательно сфокусированным. Это поможет нам масштабировать нашу систему, легче выявлять проблемы и, что важнее всего, будет гораздо проще поддерживать, чем монолитное животное. Имея это в виду, наш рабочий процесс можно представить следующим образом:
- A HTTP API allowing users to submit payments into our workflow.
This generates a unit of work that get's handled by the workers.
- Workers that act on the work generated from our API
Наш план будет:
- Expose a HTTP API that let's users submit payments
- Keep our controller minimal, have it just create a model and call it a day
- Have the model process the payment asynchronously
- Think about handling cases for when things don't go according to plan
- Have a way to figure which payments are taking unduly long to process etc.
HTTP API
Для простоты предположим, что у нас есть следующий код, запущенный в Ruby-on-Rails, который представляет наш платежный процессор через HTTP:
class PaymentController < ApplicationController
def create
Payment.create! params[:payment]
render :json => {:status => "OK"}
end
end
Модельный слой
В соответствии с совершенством MVC, вот наш уровень модели:
class Payment
def self.create!
raise ArgumentError unless params[:payer_id], params[:recipient_id],
params[:amount]
redis.rpush 'payments_to_be_processed',
params[:payment].merge(:tracking_id => redis.incr('tracking_ids')).to_json
end
end
Оглядываясь назад на список пунктов, можно сказать, что мы выполнили первые три пункта: у нас есть супер простой HTTP API, код нашего контроллера и модели минимален, и мы проложили дорогу для асинхронной обработки платежей с помощью списка payment_to_be_processed. , Довольно классная штука, теперь все, что осталось сделать, — это разобраться в бэк-энде. Как мы отслеживаем платежи, когда они проходят через различные состояния обработки , какие метрики, по нашему мнению, нам понадобятся, и как нам их хранить, каковы условия гонки, от которых мы должны защищаться, и т. Д. .?
Оформление платежного квитанции
В целях этого обсуждения давайте не будем беспокоиться о фактической обработке платежа (есть несколько хорошо документированных сервисов, таких как braintree , recurly и т. Д. , Которые делают его простым). Чтобы добиться успеха, я буду предполагать, что у нас есть следующий фрагмент кода, который мы будем вызывать для обработки нашего платежа:
def process_payment(payer_id, recipient_id, amount)
rand_val = (rand * 10).to_i
if rand_val > 3
return :status => :success,
:txn_id => redis.incr("txn_ids"), :processed_on => Time.new.getutc.to_i
elsif rand_val < 7
return :status => :insufficient_funds,
:txn_id => redis.incr("txn_ids"), :processed_on => Time.new.getutc.to_i
else
return :status => :api_error, :processed_on => Time.new.getutc.to_i
end
end
Как видите, этот метод может иметь один из трех возможных результатов:
- success
- insufficient funds
- api error (we were not able to connect with our payment service)
Это может показаться многим наивным, но это ни в коем случае не является исчерпывающей монографией о том, что может пойти не так при обработке платежей. Вместо этого, на чем я хотел бы сосредоточиться, учитывая конечный список возможных результатов при обработке платежа, как я могу использовать redis для точной обработки платежа и изящного восстановления после того, как дела пойдут плохо.
(Примечание. В этом методе я работаю со временем в целочисленном формате UTC. Очень рекомендую это при работе с Redis.)
Работники по обработке платежей
Учитывая список из трех возможных результатов, нетрудно понять, что нашим сотрудникам, занимающимся обработкой платежей, нам необходимо выполнить эти три условия. Имея это в виду, вот первый удар по этому:
loop do
payment = JSON.parse(redis.brpop("payments_to_be_processed")[1])
tracking_id = payment['tracking_id']
payer_id = payment["payer_id"]
recipient_id = payment["recipient_id"]
amount = payment["amount"]
results = process_payment payer_id, recipient_id, amount
if results[:status] == :success
redis.zadd "successful_txns", results[:processed_on], results[:txn_id]
redis.hmset "txns", results[:txn_id], payment.merge(:tracking_id => tracking_id).to_json
redis.zadd "payments_made_by|#{payer_id}", results[:processed_on], results[:txn_id]
redis.zadd "payments_received_by|#{recipient_id}", results[:processed_on], results[:txn_id]
elsif results[:status] == :insufficient_funds
redis.zadd "insufficient_funds_txns", results[:processed_on], results[:txn_id]
redis.hmset "txns", results[:txn_id], payment.merge(:tracking_id => tracking_id).to_json
redis.zadd "insufficient_funds_for|#{payer_id}", results[:processed_on], results[:txn_id]
redis.zadd "insufficient_funds_to|#{recipient_id}", results[:processed_on], results[:txn_id]
else
redis.zadd "api_errors", results[:processed_on], {:payment_id => payment_id}.to_json
end
end
Это выглядит довольно впечатляющим первым ударом по проблеме. У нас есть:
1. Handled (maybe not completely) for our three cases when processing a payment.
2. A way to figure out the status of a payment by looking in the lists:
- successful_txns
- insufficient_funds_txns
- api_errors
Каждый работник извлекает хэш-код JSON, содержащий подробности о том, кто кому платит, и о сумме. Затем работник пытается обработать платеж и, в зависимости от того, был он успешным или неудачным, добавляет его к дальнейшим структурам повторного предоставления данных. Здесь я хотел бы отметить одну вещь: когда бы ни было возможно, я склоняюсь к использованию отсортированного набора вместо набора с меткой времени UTC в качестве результата. Это позволяет мне выполнять диапазонные запросы, такие как общее количество успешных транзакций, выполненных сегодня, сколько платежей совершил или получил данный пользователь за определенный период времени и т. Д. В любое время вы можете увидеть, что вам нужен набор, подумайте немного глубже, чтобы посмотрите, может ли отсортированный набор лучше подойти Возвращаясь к приведенному выше коду,Единственное, от чего мы хотели бы защититься, — это убедиться, что независимо от результата платежа, когда мы записываем статус в некоторых из наших структур данных, это действительно хорошая идея сделать это одним махом. Чтобы быть немного понятнее, если транзакция была успешной, мы хотим убедиться, что она либо добавленаsuccessful_txns , txns , payments_made_by и payments_received_by или ничего.
Транзакционность с использованием multi-exec
Для этого мы используем встроенные в Redis примитивы транзакций multi и exec. Обновленный код выглядит следующим образом:
loop do
...
if results[:status] == :success
redis.multi
redis.zadd "successful_txns", results[:processed_on], results[:txn_id]
...
redis.exec
elsif results[:status] == :insufficient_funds
redis.multi
...
redis.exec
else
redis.zadd "api_errors", results[:processed_on], {:payment_id => payment_id}.to_json
end
end
Отслеживание размера очереди
Вот очень простой трекер размера очереди:
loop do
if redis.llen("payments_to_be_processed") > 100_000
send_pager(:to => "ops", :msg => "queue is getting backed up")
end
sleep 1*60 #for a minute
end
Здесь 100_000 — это число, которое я вытащил из воздуха. Вы можете / должны иметь возможность настройки. Вам также не нужно беспокоиться о том, что этот трекер отключит ваш сервер Redis Поверьте, Redis может выполнять операцию llen O (1) каждые 60 секунд! ?
пересчет
Допустим, ваш HTTP API перекачивает больше платежей, чем вы способны обработать, и вы хотели бы обрабатывать их немного быстрее. Просто — просто увеличьте количество работающих у вас работников, и вы будете масштабироваться по горизонтали.
Вывод
Я хотел бы, чтобы после прочтения этого поста вы получили большой опыт работы с Redis. Этот пост не о построении платежной системы (хотя заголовок говорит, что это так). Речь идет о создании крошечных сервисов, которые имеют единственное назначение и общаются друг с другом с помощью Redis. Часть кода в этом посте может быть неправильной, и некоторые из моих предположений могут быть неверными. Но основной смысл построения рабочего процесса, состоящего из небольших сервисов, которые общаются друг с другом через Redis, верен.