Снаружи касса выглядит простой штукой: выбрать товары, принять оплату, напечатать чек.

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

Я несколько лет проработал с кассовым контуром — облачная фискализация, поток чеков от интернет-магазинов и платёжных агрегаторов. Названия компаний и внутренние детали раскрывать не буду, но сами задачи и принятые архитектурные решения разобрать вполне можно. Это первая часть из шести.

Разбор построен вокруг одной операции — той, которую нельзя переделать. Но сначала минимум контекста, без которого её не понять: как система устроена физически и почему именно так.

Под «печатью» дальше понимается формирование фискального документа накопителем. Бумаги в облачной модели нет.

Комната касс

Первая версия работала прямолинейно. За каждой торговой точкой клиента закреплялась отдельная физическая касса. Кассы стояли у нас в серверной и периодически опрашивали сервер, забирая поставленные в очередь чеки. Программно всё было просто: принять чек, сохранить, поставить статус QUEUED, дождаться, пока устройство заберёт его на печать.

Модель работала. Проблема была в том, что рост клиентов означал линейный рост железа. В какой-то момент серверная превратилась в ферму из касс, проводов и блоков питания. Администраторы шутили, что уровень радиации там уже требует шапочки из фольги.

Следующим этапом взяли стоечные фискальные серверы БФР-112ФС. Внешне — обычный стоечный сервер, внутри до 112 фискальных накопителей, каждый из которых с точки зрения закона остаётся самостоятельной кассой. Один такой сервер заменял целую комнату отдельных устройств и давал HTTP API для управления накопителями.

Фискальный сервер БФР-112ФС со снятой крышкой: фискальные накопители стоят рядами внутри стоечного корпуса.
Фискальный сервер БФР-112ФС со снятой крышкой: фискальные накопители стоят рядами внутри стоечного корпуса.

Тот самый сервер изнутри. Каждая планка в ряду — отдельный фискальный накопитель, то есть отдельная касса с точки зрения закона.

Физически стало проще. Архитектурно появился новый класс проблем.

Почему работу с железом вынесли отдельно

Можно было встроить поддержку нового оборудования прямо в сервис приёма чеков — дальше буду называть его receipts, он владеет чеком и его жизненным циклом. На первый взгляд это выглядело проще всего: получил чек, вызвал API, сохранил результат.

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

Поэтому интеграцию оформили отдельным сервисом. Границу провели жёстко:

device-gateway не решает, что и где печатать. Он получает готовую команду с уже выбранным слотом и отвечает за её корректное исполнение на физическом накопителе.

Маршрутизация и подготовка документа остались выше по тракту. На вход приходит команда с документом и идентификатором целевого слота, на выходе публикуется результат: чек фискализирован либо обработка завершилась ошибкой.

Клиент отправляет чек в receipts, тот передаёт команду в device-gateway, device-gateway печатает на фискальном накопителе и возвращает событие PRINTED или FAILED
Клиент отправляет чек в receipts, тот передаёт команду в device-gateway, device-gateway печатает на фискальном накопителе и возвращает событие PRINTED или FAILED

Слот как единица всего

Внутри одного такого сервера — множество накопителей. В программной модели каждый стал слотом.

Это не абстракция ради удобства. Фискальный накопитель физически последовательный: формирует документы по одному, присваивает им возрастающие номера, не допускает параллельной печати и обладает собственным состоянием и ограниченным ресурсом.

Поэтому модель параллелизма просто повторила физику:

Внутри одного слота документы обрабатываются строго последовательно. Разные слоты обрабатываются параллельно.

Внутри фискального сервера три слота: в каждом чеки идут строго друг за другом, слоты между собой работают независимо
Внутри фискального сервера три слота: в каждом чеки идут строго друг за другом, слоты между собой работают независимо

Слот стал одновременно единицей последовательности, консистентности, блокировки и масштабирования. Если одному крупному клиенту не хватало пропускной способности одного накопителя, ему выделялся пул слотов, а поток распределялся между ними. Сам сервис при этом не менялся — масштабирование уходило в конфигурацию.

Эталонное время фискализации одного чека — около 0,7 секунды. Ускорить его нельзя никаким кодом. Единственный способ обработать больше — добавить накопителей.

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

Два фискальных сервера БФР-112ФС, установленные в стойку рядом с сетевым оборудованием.
Два фискальных сервера БФР-112ФС, установленные в стойку рядом с сетевым оборудованием.

Два таких сервера в стойке — это уже 224 слота вместо комнаты с кассами.

Приём и исполнение — это два разных контура

Команды приходили через RabbitMQ, но печать не выполнялась прямо в обработчике очереди.

В нашей конфигурации RabbitMQ давал доставку как минимум один раз: если обработчик не успевал подтвердить сообщение, брокер присылал его снова. Значит одна и та же команда могла прийти повторно, а для печати фискального чека повторное исполнение недопустимо.

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

Дальше исполнение уже не зависело от повторной доставки: отдельные фоновые обработчики находили в базе незавершённые документы и обрабатывали их в разрезе слотов.

Контур приёма: RabbitMQ, запись в базу с уникальным индексом, подтверждение брокеру. Контур исполнения: фоновый обработчик берёт незавершённые документы и печатает
Контур приёма: RabbitMQ, запись в базу с уникальным индексом, подтверждение брокеру. Контур исполнения: фоновый обработчик берёт незавершённые документы и печатает

RabbitMQ остался транспортом, а собственная база стала персистентной очередью исполнения. Это дало отсечение дублей, группировку по накопителям, строгий порядок внутри слота, видимость накопившейся работы и восстановление после перезапуска.

Пока всё это выглядит как обычная аккуратная работа с очередями. Теперь про место, где обычные приёмы перестают работать.

Главная проблема: непонятно, что произошло

У API фискального накопителя не было идемпотентности.

Представьте сценарий:

  1. сервис отправил команду печати;

  2. накопитель сформировал чек;

  3. соединение оборвалось до получения ответа.

А теперь другой:

  1. сервис отправил команду печати;

  2. накопитель ничего не сформировал;

  3. соединение оборвалось.

Для сервиса эти две ситуации неотличимы. В обоих случаях он видит одно и то же: команда ушла, ответа нет.

Два сценария: в первом чек сформирован и ответ потерян, во втором накопитель ничего не сделал. Для сервиса они выглядят одинаково
Два сценария: в первом чек сформирован и ответ потерян, во втором накопитель ничего не сделал. Для сервиса они выглядят одинаково

Повторить запрос вслепую нельзя: если первый чек всё-таки напечатался, появится дубль — а это лишний юридически значимый документ. Не повторять тоже нельзя: продажа может остаться без фискального чека, что уже проблема с законом.

Обычный ретрай здесь не работает вообще. Не «работает плохо» — именно не работает.

Состояние, которое означает «я не знаю»

Решение начали с того, что перестали притворяться, будто ситуация бинарная.

В модели документа появилось отдельное состояние SENT. Оно означало не «чек отправлен» и тем более не «чек напечатан», а:

Команда могла попасть на устройство, но окончательный результат пока неизвестен.

Документ переводился в SENT и сохранялся до обращения к железу. Если сервис падал или ловил таймаут, после восстановления он видел не просто незаконченный документ, а явно зафиксированную неопределённость.

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

Разрешение через архив накопителя

Выяснить помогала особенность самого устройства: накопитель хранит архив сформированных документов.

При повторной обработке документа в состоянии SENT сервис не отправлял его на печать сразу. Сначала он читал архив и смотрел, какой чек был сформирован последним.

Чтобы сопоставление было надёжным, собственный идентификатор документа заранее помещался в пользовательский реквизит фискального чека. Поэтому свой документ опознавался точно — не по сумме и не по времени, которые могут совпасть, а по точному идентификатору.

Документ в состоянии SENT: читаем архив накопителя. Три исхода — последний чек наш, последний предыдущий, последний неизвестен
Документ в состоянии SENT: читаем архив накопителя. Три исхода — последний чек наш, последний предыдущий, последний неизвестен

Дальше было три исхода. Если последним в архиве оказывался текущий документ — печать уже состоялась, а ответ потерялся: состояние восстанавливалось без повторной печати. Если последним оставался предыдущий известный чек — команда до накопителя не дошла, и её можно было спокойно выполнить. Если же в архиве обнаруживался неизвестный документ — состояние сервиса и устройства разошлись.

Третий случай — самый интересный. Сервис не пытался продолжить наугад: слот блокировался до ручного разбора.

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

Цена

Про это обычно не пишут, но без этого разбор нечестный.

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

Про частоту стоит сказать прямо. В первое время слот блокировался заметно чаще, чем хотелось, и почти всегда по одной причине: гонки при высокой конкурентной нагрузке, из-за которых состояние документа успевало испортиться до записи. Это были наши ошибки, а не свойство схемы. После того как их вычистили, блокировки сошли практически на нет.

И вот что тут важнее самой цифры. Механизм, который выглядит чистой ценой, оказался ещё и детектором. Он не дал системе тихо работать дальше на испорченном состоянии — он останавливал слот и показывал, что что-то не так. Без него та же гонка выдавала бы дубли фискальных чеков, и узнали бы мы об этом сильно позже и сильно дороже.

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

Существует альтернатива: не разрешать неопределённость, а продолжать выполнение и компенсировать лишний документ потом. Это похоже на hedged execution или сагу с компенсацией. Но компенсация фискального чека — не отмена: первый чек остаётся юридически значимым, и исправление требует ещё одной юридически значимой операции. Такое нельзя прятать внутри технического ретрая, это отдельный бизнес-процесс со своим состоянием, аудитом и правилами разбора.

Мы выбрали корректность. Это был выбор, а не единственный возможный путь.

Что в итоге

device-gateway получился маленьким по объёму кода и тяжёлым по гарантиям. Он отвечал за:

  • однократное исполнение команды печати;

  • последовательный порядок документов внутри накопителя;

  • параллельную обработку разных накопителей;

  • восстановление после таймаутов и перезапусков;

  • согласованность своей базы с состоянием оборудования;

  • блокировку слота при необъяснимом расхождении.

И сознательно не отвечал за приём публичных запросов, юридическое обогащение чека, выбор накопителя и бизнес-маршрутизацию.

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


Во второй части разберу диспетчер: как масштабировать принципиально последовательное устройство пулом слотов и почему главная его ценность оказалась не в раскладывании нагрузки по кругу, а в failover. Дальше — сервис, который сознательно сделали вообще без состояния, и событийное ядро, вокруг которого всё это выросло. В финале — почему архитектуру такой системы полезнее описывать не схемой компонентов, а цепочкой обещаний.

Продолжение выходит здесь же, на Хабре. В канале Архитектура и путь — анонсы и то, что не влезло в статьи: схемы, заметки, разборы по ходу дела.