суббота, 9 июня 2012 г.

[prog.thoughts] Несколько слов о самодельном прикладном пакетном протоколе и заточенной под него логике

Предположим, что есть компонент A, который должен делать асинхронные запросы к компоненту B посредством пары сообщений request/response. Т.е. A отсылает request, B его обрабатывает, а затем отсылает response. Каждая пара request/response идентифицируется уникальным номером. Поскольку взаимодействие асинхронное, то A может отослать B сразу несколько request-ов (например, с номерами 1, 2, 3, 4 и 5). Компонент B будет отвечать по мере готовности и его ответы могут идти в совсем другом порядке. Например, сначала response с номером 4, затем 5, затем 2 и 1, и только затем 3. Компонент A на каждый запрос ждет ответа в течении определенного тайм-аута. Если ответ не пришел, то A повторяет запрос, но обязательно с тем же самым номером, что и в первый раз.

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

Проблема в возможности перепосылок сообщений request. Из-за этого компонент B получив очередной request должен проверить его уникальность. Если же запрос получен повторно, то он не должен обрабатываться. Тут все зависит от того, в какой стадии находится выполнение запроса – если запрос обрабатывается в данный момент, то повторный request игнорируется. А если запрос уже был обработан, то на повторный request нужно повторить ранее отосланный response.

Но и на стороне A нужно проверять уникальность response. Вполне может произойти ситуация, когда A может получить два повторных response с одинаковым идентификатором.

Т.е. и A, и B при получении очередной порции сообщений от удаленной стороны, должны сначала проверить уникальность полученных в сообщениях идентификаторов. Пока речь идет о сотнях сообщений в секунду, это не проблема. Но когда счет начинает идти о тысяче в секунду и выше, тут уже приходится выкручиваться.

И вот в качестве одного из решений было предложено объединять request и response в пакеты. Т.е. компоненты A и B переходят на взаимодействие посредством сообщений request_pkg и response_pkg, внутри которых уже собраны request-ы и respons-ы.

Например, компонент A отсылает request_pkg с идентификатором 1, в котором находятся request-ы с номерами 1, 2, 3, 4, 5. Компонент B сначала присылает request_pkg_ack с идентификатором 1 – это есть подтверждение того, что запросы дошли до компонента B. Если request_pkg_ack до A не дойдет за положенное время, то A вновь отсылает request_pkg с идентификатором 1 с точно таким же содержимым внутри.

По мере обработки запросов компонент B будет формировать response_pkg, в которые будет собирать готовые ответы. При этом размер и идентификатор response_pkg никак не связан с request_pkg. Так, компонент B может сначала отослать response_pkg с идентификатором 2, в котором будут response с номерами 3 и 5. А затем – response_pkg с идентификатором 3, в котором будут response с номерами 1, 2 и 4.

В ответ на response_pkg компонент A должен выслать response_pkg_ack. Если он этого не сделает, то после некоторого тайм-аута компонент B перепошлет response_pkg.

Краеугольными принципами этого пакетного протокола являются:

  • если сообщение с идентификатором X включено в пакет с идентификатором Y, то оно может присутствовать только в пакете с идентификатором Y и ни в каком другом;
  • состав конкретного пакета должен быть постоянным. Т.е. если в пакет с идентификатором Y входили сообщения с идентификаторами X, Z и W, то только эти сообщения и только в этом количестве должны присутствовать в пакете Y при всех его перепосылках.

Благодаря таким принципам, объем работы по проверке уникальности получаемых от удаленного компонента сообщений уменьшается в разы (пропорционально размеру пакета). Ведь сейчас нужно проверять уникальность не индивидуальных request/response, а уникальность пакетов.

Вот такая в итоге сложилась схема. В соответствии с ней для компонента A создана табличка responses в БД, первичным ключем в которой является уникальный номер request/response. Вставки строк в эту таблицу происходят при обработке response_pkg_ack.

Все вроде бы нормально. Но, допустим, что компонент B вдруг по какой-то причине нарушает принципы пакетного протокола. Скажем, из-за не выявленной вовремя ошибки в коде включает response с некоторым номером в два разных пакета.

Соответственно, при получении второго пакета с тем же самым response внутри, компонент A попытается добавить в табличку responses строку с неуникальным значением первичного ключа. И получит ошибку при работе с БД. Причем, поскольку компонент B будет перепосылать проблемный пакет (не получая из-за сбоев компонента A подтверждения), то компонент A будет “падать” снова и снова (не обязательно падать в прямом смысле, достаточно будет того, что он начнет тратить время на устранение этой ситуации).

Вот, собственно, хорошая ситуация для размышлений. Должны ли компоненты A и B защищаться от возможных ошибок в реализации друг друга? Особенно принимая во внимание, что компоненты разработаны одной командой, предназначены для работы в рамках одной задачи. Просто отвечают за разные куски функциональности.

PS. Это вопрос риторический. Я его озвучил не для того, чтобы посоветоваться с общественностью. Просто выступаю в роли акына – что вижу вокруг себя, то и пою :)

6 комментариев:

TheIvan комментирует...

Спасибо большое за ёмкую и интересную статью. Акын вышел неплохой.

имя комментирует...

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

eao197 комментирует...

@имя:

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

Для некоторых задач обеспечение даже 1K запросов в секунду -- уже запредельным оказывается. Конечно, с учетом того факта, что есть ограничения на ресурсы (как человеческие, так и финансовые).

если номера идут последовательно

Номера только в таком поверхностном описании сделаны целочисленными. Вообще-то они строковые и "непрозрачные" для принимающей стороны. Т.е. принимающая стороне лучше не иметь знаний о том, каким образом строится идентификатор сообщения.

имя комментирует...

> Для некоторых задач обеспечение даже 1K запросов в секунду -- уже запредельным оказывается. Конечно, с учетом того факта, что есть ограничения на ресурсы (как человеческие, так и финансовые).

ммм... не понял ответа

мой вопрос следует читать не "ха-ха-ха, у вас всего тысячи запросов в секунду" (так как очевидно, что запросы могут быть тяжелые), а так: "неужели при тысячах запросов в секунду простое обслуживание уникальности запросов забирает столь много ресурсов?"

именно, прикидка выглядит так:

чтение из памяти в худшем случае (кэш мисс) = 300 тактов

уровней у дерева std::map = 20 штук

скорость = 10К сообщ/сек

перемножаем, и получаем 60М тактов в секунду для постоянных проверок уникальности, что вроде пока не жмет? т.е. я где-то сильно ошибся в прикидке, или вам 60М тактов/сек уже внапряг?

тут я предполагаю, что нужен фиксированный порядок обхода, а hash_map его не предоставляет

дальше можно использовать В-дерево вместо обычного дерева (где-то я вроде видел такой map, полностью совместимый с std::map) и уменьшить это время до 10М тактов

имя комментирует...

уточнение:

ну да, там кроме поиска в дереве будет еще и вставка, которая займет примерно столько же (умножаем время на 2), но и из 20 уровней дерева 10 уровней все же попадут в кэш -- их всего-то 1024 штуки (время делим на 2)

eao197 комментирует...

@имя:

С кэшами проблема в том, насколько просядет производительность, если будут случаться промахи мимо кэша. Допустим, в кэше сидят данные за последние 20 минут. И вдруг приходит серия повторных запросов, которые обрабатывались 21 минуту назад. Нужно будет поднимать старые данные из БД. Насколько упадет скорость обработки запросов, как быстро она потом вернется к нормальной.

Плюс к тому, если есть только один тип запросов, то можно отдать несколько сотен метров памяти под кэш. Если типов запросов несколько, то расход памяти растет, повышаются требования к оборудованию, растет стоимость обслуживания решения...