Формулировка задачи и цель

Заявка представляет собой большой JSON документ с несколькими дочерними документами. В разное время разные участники процесса обработки заявки обновляют этот документ. Например, по инициативе клиента. Клиент может поменять залоговый авто, недвижимость, изменить сумму или срок кредита и т.д. Пользователям отчёта по заявке важно знать, кто и когда поменял значение определённого ключа, например, в котором лежит процентная ставка.

У текущей реализации получения истории изменений в Product Storage есть проблемы:

Основная цель RFC - спроектировать решение для хранения и получения истории изменения заявочных данных. Документ не рассматривает изменений метода поставки данных в DWH.

Требования

Я, как top_support хочу получать заявки по клиенту, чтобы видеть:

  1. Полную хронологию изменений выбранной сущности в порядке от самого нового к старому.
  2. Кто вносил каждое изменение — ФИО пользователя / имя сервис-аккаунта.
  3. Точную дату и время (с учётом часового пояса клиента) каждой правки.
  4. Подробное описание изменения: какие поля изменились, прежние и новые значения.

Требуется хранить версии JSON-документов с высоким потоком записи (в среднем 200 записей в секунду) и сравнительно редкими чтениями (меньше 1 rps). Данные актуальны лишь 90 дней, после чего могут быть удалены. Поиск нужен только по идентификатору сущностей (product_instanct, participant_role и тд), с сортировкой по дате обновления. SLA не критичен – система предназначена для внутренних запросов техподдержки.

Нефункциональные требования

Подсчитано за несколько дней 2025Q2, дни без сбоев. Все оценки производятся по верхним границам значений.

В среднем в будни до 200 RPS на патч, создание, закрытие всех сущностей. При этом в ~160 RPS на продукты в частности (patchEntire в том числе) и 40 RPS на другие сущности.

200 ops на 2 кб =400 кб/c

product_instance

Одна строка продукта занимает до 2 кб в аудит-таблице (сам продукт медиана - 1 647 КБ, 99 перцентиль 6.5 КБ), сам продукт в среднем 1785 bytes. Более подробные данные в таблице ниже.

ПоказательЗначениеИнтерпретация
min_size260 BМинимальный размер.
p251 356 BЧетверть всех записей ≤ 1,3 КБ.
median1 647 BПоловина документов ≤ 1,65 КБ. Самый частый размер.
p751 934 B75 % документов укладываются в 1,9 КБ.
p902 650 BТолько 10 % крупнее 2,65 КБ
p953 310 B5 % тяжёлых JSON больше 3.3 КБ
p996 501 BИз них лишь 1 % превышает 6,5 КБ.
max_size36 КБСамый крупный документ в выборке из 18 млн записей.
avg_size1 785 BСреднее практически совпадает с медианой, значит распределение компактное, без экстремальных выбросов.
TOAST затрагивает ~10–15 % строк, все что больше ~2 КБ будет вынесено в тост таблицы.

Сейчас одна партиция на 1000000 (миллион) записей продуктов в среднем весит 1551-1752 MB, темп появления записей ~8-9 млн записей в сутки, это 14 гб/сутки 14 гб * 90 дней = 1386 гб “сырых” данных нужно хранить за 90 дней

при этом в аудит таблицах хранится по медиане 4 версии продукта, по p95 12, а по p99 22 версии (за 24 часа). Соответственно по одному id-шнку в истории хранится около 4 версий.

participant_role, realty_info, product_task, product_car

сложнее посчитать темпы поступления, данных меньше, значения взяты приблизительно 520 мб + 640 мб + 3 мб + 3 мб = 1166 мб в сутки все остальные сущности Итого 104 гб за 90 дней

Оценка 1 490 ГБ данные 373 ГБ индекс PK (25% от изначальных) 300 ГБ WAL-резерв () 220 ГБ рабочий запас ≈ 2 383 ГБ (~2,4 ТБ)

С учетом некоторого запаса, роста 25% до конца года и всплесков RPS считаю необходимым иметь минимум 3 тб хранилища с возможностью последующего расширения.

Нужно иметь возможность отсортировать по updated_at или version, пагинация по необходимости.


Как сейчас

Product Storage при создании и изменении продукта пишет аудит-записи для всех сущностей в отдельные партиционированные по rev таблицы _aud. Эти таблицы читает strp коннектор и выгружает в топики:

  • top-storage.export.product-instance
  • top-storage.export.participant-role
  • top-storage.export.product-task
  • top-storage.export.realty-info
  • top-storage.export.product-car
  • top-storage.export.migration.product-instance
  • top-storage.export.migration.participant-role
  • top-storage.export.migration.product-task
  • top-storage.export.migration.realty-info
  • top-storage.export.migration.product-car

которые читает экспортер, экспортер в свою очередь маппит содержимое событий в avro и выгружает записи в DWH.

Записи в аудит таблицах хранятся три месяца и удаляются через библиотеку автопартиционирования. https://gitlab.tcsbank.ru/top-storage/autopartitioning-lib https://gitlab.tcsbank.ru/top-storage/top-product-storage/blob/a0f15c1c6bb6d50d5477f42e53164bf78e9c58ae/src/main/resources/application-dev.yaml#L80

Для эпика (история изменения продукта аудит https://jira.tcsbank.ru/browse/TOPIP-1644) были разработаны эндпоинты:

https://top-storage.pages.devplatform.tcsbank.ru/top-product-storage/rest-method/maintenance-changelog-controller-v2/

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

здорово бы по итогу оценить, сколько данных пропускали чтобы гарантировать надежность поставки


Выбор хранилища

Вариант 1 - Cold Postgres

Наиболее простой подход, почти то же самое что сейчас, только в холодном хранилище.

Максимальный уровень критичности для Cold Postgres это Office Productivity -  инстанс с двумя хостами (мастер + синхронная реплика). Если хост-мастер выйдет из строя, инстанс автоматически переключится на синхронную реплику. Также существуют ограничения на тенант:

  • 15 тб
  • на чтение 20 мб/c
  • на запись 29 мб/c

Партиционирование в таком случае необходимо сделать по дням, для простоты последующего удаления, т.е. отказаться от rev. Именно по дням, чтобы в одну партицию складывалось порядка 8-9 млн записей. Создание и удаление партиций в этом случае будет реализована через библиотеку автопартиционирования, как в product-storage.

DDL таблиц product_instance_aud, participant_role_aud, product_task_aud, realty_info_aud, product_car_aud таким образом будет следующим:

CREATE TABLE <table_name>_aud  
(  
    id VARCHAR(32) NOT NULL, -- ID  
    version INTEGER NOT NULL, -- Версия сущности
    data JSONB NOT NULL, -- JSON с данными    
    updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, -- Дата обновления  
    client_id VARCHAR(64) NOT NULL, -- имя сервиса, который совершил операцию
    author  VARCHAR(64), -- автор
    type INTEGER NOT NULL, -- тип операции, аналогичен rev_type
    CONSTRAINT <table_name>_aud_pkey PRIMARY KEY (id, product_instance_id, updated_at)  
) PARTITION BY RANGE (updated_at)  
;

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

Преимущества:

  • простая реализация
  • в команде есть экспертиза работы с postgres Недостатки:
  • нет встроенного ttl объектов
  • невозможность легкого расширения, необходимо заказывать совершенно новый инстанс, это грозит усложнением и потенциальными миграциями в будущем

Нужно ли шардирование БД или одной будет достаточно?

  • в квоты умещаемся более чем, считаю что одной будет достаточно

Какие индексы нужны?

  • хватит одного первичного составного ключа, упомянутого в DDL для поиска всех версий одного продукта

Как мы будем очищать БД от устаревших данных?

  • DML не поддерживаются, update вызывать нельзя. Это значит что у нас физически не будет права обновить данные. Поэтому предлагаю оставить текущий механизм - drop партиций, которые созданы более чем 3 месяца назад.

Нужно ли сжимать данные?

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

Вариант 2 - Cold Postgres - сохранение diff, а не снапшота

Для экономии места на диске существует возможность сохранять не полные снапшоты json, а только разницу между версиями (diff, patch)

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

  • один повреждённый патч делает недоступными все следующие версии. Без дополнительных снапшотов потеряется весь исторический диапазон.
  • Чтобы восстановить старую версию, придётся последовательно применить N патчей. Чем длиннее цепочка, тем дольше ответ - нужно будет калькулировать патчи.
  • больше логики, сложнее в реализации
  • проверить данные вручную уже не выйдет простым SELECT *; придётся запускать сборку версии через сервис.

Вариант 3 - S3aaS

Также как и Cold Postgres простой вариант. Существует ограничение 20 млн объектов на бакет, а версионирование объектов не поддерживается. А объектов в худшем случае будет 2 млрд (первый вариант хранения), и до 0.5 млрд в лучшем (второй вариант хранения). Следовательно понадобится как минимум 32 бакета заранее только для продуктов. Распределять сущности по бакетам можно следующим образом: bucketId = hash(id) % 32.

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

Пользователя делаем без права удаления. Только - создание бакетов, чтение и вставка. Удаление будет работать через встроенный механизм. https://infra.pages.devplatform.tcsbank.ru/docs/docs/s3aas/operations/auto_delete_objects

Варианты хранения

  1. в бакете складываем в формате {id}/{version}.json. В таком случае для получения всех версий одного продукта нужно вызвать s3 ровно столько раз, сколько версий у продукта (см. распределение по версиям). Запросы при этом можно делать параллельно, чтобы ускорить ответ. Учитывая, что целевой пользователь у нас поддержка (rps < 1), считаю, что такой вариант приемлем. Тут мы воспользуемся холодным S3. Но бакетов понадобиться как минимум 100.

  2. Сохраняем все версии в единый jsonl файл. Каждая новая строка при этом содержит {json} целевой сущности. Проблему гонок решаем через http заголовок if-unmodified-since с ретраями. Перед сохранением сортируем строки по updated_at. Не баг, а фича: При перезаписи объекта его TTL начинает отсчет сначала. Это означает, что вновь появившейся патч обновит всю историю и она будет хранится дольше. Версии не будут отмирать друг за другом, а всегда хранится вместе и будут подлежать удалению тоже вместе, если с сущностью не проходило никаких изменений в течение трех месяцев. Тут нам понадобится горячий S3, так как перед записью нужно будет прочитать содержимое файла. А медленное чтение ударит по скорости обработки вновь поступающих данных.

Преимещуства:

  • выше уровень обслуживания – Business Critical Plus (BC+)
  • легко изменять срок хранения данных
  • равномерное распределение нагрузки по бакетам
  • ttl объектов из коробки Недостатки:
  • отсутствует экспертиза работы с S3 в команде
  • ограничение 20кк объектов на бакет
  • есть особенности по неймингу и формату хранения - отдельные объекты на версии или один объект на продукт? (маловероятно во втором случае может возникнуть гонка, есть способы чтобы решить)
  • нужно думать как поступать в случае расширения кол-ва бакетов. При динамическом увеличении кол-ва бакетов bucketId сдвинется также уже для сохраненных данных.

Нужно ли сжимать данные?

  • учитывая, что S3 хранилище довольно легко расширяется и места там в избытке (до 100 бакетов на тенант и до 2 тб на бакет - это софт лимит, хард лимит при этом 1 пб), не вижу смысла сжимать данные, чтобы сэкономить место. Хранение несжатых данных также позволит с легкостью проверять данные вручную - например через S3 Browser.

Таким образом остается вопрос про возможность расширения, но есть вариант - создать не 32, а N бакетов, чего хватит до YYYY года с учетом роста 25% каждый год. (через WarpChat можно увеличить количество бакетов до более чем 100)


Вариант 4 - Комбинированный вариант Cold Postgres + S3aaS

Существует возможность реализовать совмещенное хранилище. S3aaS будет при этом выступать хранилищем самих сущностей (второй вариант хранения), а Cold Postgres будет содержать индекс. Индекс может состоять из pid, updated_at и bucket_id. Он будет партиционирован по дням, а старые записи (старше 90 дней) будут удаляться библиотекой автопартиционирования.

Алгоритм вставки при этом будет такой

  1. открываем транзакцию
  2. смотрим последний использованный bucket_id для pid, если его нет - генерим новый bucket_id
  3. кладем в s3 в бакет bucket_id
  4. вставляем в индекс данные - pid, updated_at, bucket_id
  5. закрываем транзакцию
  6. коммитим офсет на консюмере

Преимещуства:

  • легко расширяется, через создание новых бакетов
  • легко изменять срок хранения данных
  • равномерное распределение нагрузки по бакетам
  • ttl объектов из коробки
  • всегда есть полная история изменений сущности, без разрывов Недостатки:
  • отсутствует экспертиза работы с S3 в команде
  • зависимы от двух хранилищ
  • нужно соблюдать консистентность данных между хранилищами

Выводы

С учетом всех минусов и плюсов я бы выбирал между:

  1. Cold Postgres на 7-10-15 тб, откладывая проблему расширения на далекое будущее
  2. Горячий S3, с записью в один файл с большим количеством заранее созданных бакетов, откладывая проблему расширения на будущее
  3. Комбинированным решением (3 тб cold postgres + горячий S3 3 тб) с сохранением в единый файл сущности.

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


Стэк

Все сервисы команды Storage написаны с использованием фреймфорка Spring, поэтому вижу логичным новый сервис тоже иметь на Spring Boot 3 с использованием корутин/реактивного подхода. Это позволит нескольким инстансам справлятся с большой нагрузкой от стореджа.

Целевое решение для деплоя в банке Spirit Deploy, будем использовать его - https://devplatform.pages.devplatform.tcsbank.ru/spirit-user-docs/docs/deploy/spirit-deploy/#примеры-использования Базовые пайплайны предлагаю взять из sme-ci-cd https://gitlab.tcsbank.ru/sme-ci-cd/java Top-CI не рассматриваем в связи с его грядущим EOL.

API

Предполагается, что после задачи https://jira.tcsbank.ru/browse/TOPST-1962 аутбокс будет писать в топики, в которые сейчас пишет strp-коннектор:

  • top-storage.export.product-instance
  • top-storage.export.participant-role
  • top-storage.export.product-task
  • top-storage.export.realty-info
  • top-storage.export.product-car
  • top-storage.export.migration.product-instance
  • top-storage.export.migration.participant-role
  • top-storage.export.migration.product-task
  • top-storage.export.migration.realty-info
  • top-storage.export.migration.product-car

Эти топики будет слушать новый сервис top-storage-annals. Формат сообщений при этом останется прежним и изменений не требует. В случае возникновения любой ошибки при чтении топика (за исключением конфликта на вставке), нужно увеличить метрику об неудачных чтениях, записать данные в аудит. На такую метрику должен быть настроен алерт.

Мы можем сохранить текущий API поиска который был реализован в сервисе top-product-storage, но все еще имеем возможность его изменять, так как на текущий API еще никто не завязан.

Предлагаю чуть его изменить: rev подлежит удалению dataHistory history добавить новое поле “type”, с возможными значениями CREATE, UPDATE, DELETE чтобы хранить информацию об удалениях.

подумать про формат

{
  "status": "success",
  "data": {
    "history": [
      {
        "entity": { // сама сущность 
          "pid": "02xdf2pQLJL5mLfJZodiHa",
          "type": "Квартира",
          "realtyPid": "bda29dc575264e4699ae7c86f293880f",
          "realtyCrmId": "3-35Y291SFG",
          "isActive": true,
          "encumbrance": {
            "pid": "02xdf2pQLJL5mLfJZodiHa",
            "newEncumbranceNumber": "4534543543534543",
            "newEncumbranceDate": "2023-08-18",
            "isOldEncumbranceRemoved": true,
            "submissionId": "993ceaf2824545c78c275c599d096bf9",
            "dateOfSendingReg": "2023-11-03"
          },
          "assessedAmount": "1000000.0",
          "pledgeOwnerFlag": true
        },
        "clientId": "top-data-client-actualizator",
        "diff": [
          {
            "op": "add",
            "path": "/path",
            "value": "value"
          },
          {
            "op": "copy",
            "path": "/from",
            "from": "/path"
          }
        ],
        "type": "UPDATE",
        "createdAt": "2025-07-03T11:42:48.510186035",
        "updatedAt": "2025-07-03T11:42:48.510186035",
		"closedAt": "2025-07-03T11:42:48.510186035", // только у product-instance
        "version": 0,
        "author": "m.prygunov"
      }
    ],
    "nextPageToken": "v1:CgkKBy1pZC01NDg="
  }
}

Пагинация будет работать как раньше и основана на индексе в Cold Postgres.

Также как и в Storage, для доступа к вызову методов контроллера необходимо будет иметь учетную запись со скоупом topst-product-maintenance-changelog.

Доступ до ресурсного сервера (нового сервиса) будет реализован через Siam V2, с поддержкой провайдера от Siam V1 (keycloak).

Поведение при сбое

не доступен S3 / Cold Postgres:

  • не будет работать сохранение истории событий
  • не будут работать методы получения истории изменений
  • копится очередь кафки, живем без потери данных порядка 8 часов при retention в 5 гб. Дальше теряем данные, их будет сложно восстановить. не доступен Kafka
  • работают методы получения истории изменений
  • данные скапливаются в аутбокс очередь в сторедже, новые события не поступают

Миграция

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

  • время жизни записей в аудит таблицах три месяца
  • эпик по истории заявки сдвинулся на Q4 https://time.tbank.ru/tinkoff/pl/sgr63tqzupd5xbyax36tsi6jqr таким образом, после реализации базового функционала нового сервиса - чтения топиков и записи в хранилище, записи сохраняемые в аудит таблицы перестанут быть актуальными через 90 дней.

В случае, если Q4 уже наступил и эпик по истории заявки в работе у команды Миши Копылова, то последовательность переезда такая:

  1. Делаем аутбокс в топики https://jira.tcsbank.ru/browse/TOPST-1962
  2. storage-annals читает топики, кладет в свое хранилище
  3. Делаем ручку в storage-annals которая возвращает снапшоты сначала из своей базы, затем идет в сторедж, берет оттудава, дедуплицирует по version (приоритет отдает своим). Контракт ручки оставляем тем же что в Product Storage. Не имеет смысла делать дедупликацию, если команда Миши не приступила к выше упомянотуму эпику.
  4. Основного потребителя пересаживаем на ручку нового сервиса.
  5. Ручку в стородже объевляем deprecated с EOL 3,5 месяца. Упоминаем в доке что возвращает меньше данных. Некоторое время ничего возвращать не будет.
  6. В новом сервисе с даты EOL перестаем ходить в сторедж. Убираем логику по дедупликации, берем только свои данные.
  7. DONE

Внедрение

  1. Подготовить новый сервис, бд, api и авторизацию.
  2. Написать документацию, как минимум для методов в Swagger, упомянуть про новый сервис в документации Product Storage.
  3. см. миграция
  4. Метрики, аллерты и т.д.
  5. Переключить Audit на новые методы.

Предположения по дальнейшему развитию

  • можно будет подумать использовать новый сервис в рамках Storage Archive ADR, так как и там и тут хранение исторических данных, в случае архива нужно всего лишь складывать в бакет без ttl https://wiki.tcsbank.ru/display/TOP/2024-09-13+TOP+Storage+Archive+ADR?src=contextnavpagetreemode
  • сервис можно будет использовать для перевыгрузки данных в DWH, если это будет все еще необходимо, так как после аутбокса надежность поставки значительно увеличиться. Для этого можно будет предусмотреть отдельный индекс в cold postgres по updated_at.

Нерешенные вопросы

  • все ли изменения нужны техподдержке?