
Формулировка задачи и цель
Заявка представляет собой большой JSON документ с несколькими дочерними документами. В разное время разные участники процесса обработки заявки обновляют этот документ. Например, по инициативе клиента. Клиент может поменять залоговый авто, недвижимость, изменить сумму или срок кредита и т.д. Пользователям отчёта по заявке важно знать, кто и когда поменял значение определённого ключа, например, в котором лежит процентная ставка.
У текущей реализации получения истории изменений в Product Storage есть проблемы:
- исторические данные хранятся в горячей базе, занимают порядка полутора террабайт, а читаются редко (за исключением постоянного читающего коннектора)
- разработанные методы получения истории изменений производят поиск и последующий перебор партиций аудит таблиц вместо поиска через индекс https://gitlab.tcsbank.ru/top-storage/top-product-storage/-/blob/0bbe650a4da0e43940a21fa3fee327014e64b14b/src/main/kotlin/ru/tinkoff/top/productstorage/util/PartitionBinarySearcher.kt
Основная цель RFC - спроектировать решение для хранения и получения истории изменения заявочных данных. Документ не рассматривает изменений метода поставки данных в DWH.
Требования
Я, как top_support хочу получать заявки по клиенту, чтобы видеть:
- Полную хронологию изменений выбранной сущности в порядке от самого нового к старому.
- Кто вносил каждое изменение — ФИО пользователя / имя сервис-аккаунта.
- Точную дату и время (с учётом часового пояса клиента) каждой правки.
- Подробное описание изменения: какие поля изменились, прежние и новые значения.
Требуется хранить версии 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_size | 260 B | Минимальный размер. |
| p25 | 1 356 B | Четверть всех записей ≤ 1,3 КБ. |
| median | 1 647 B | Половина документов ≤ 1,65 КБ. Самый частый размер. |
| p75 | 1 934 B | 75 % документов укладываются в 1,9 КБ. |
| p90 | 2 650 B | Только 10 % крупнее 2,65 КБ |
| p95 | 3 310 B | 5 % тяжёлых JSON больше 3.3 КБ |
| p99 | 6 501 B | Из них лишь 1 % превышает 6,5 КБ. |
| max_size | 36 КБ | Самый крупный документ в выборке из 18 млн записей. |
| avg_size | 1 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) были разработаны эндпоинты:
Эти ручки предполагались для основного потребителя - аудита, чтобы получать историю изменений заявки. Но эпик по истории заявки сдвинулся на 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
Варианты хранения
-
в бакете складываем в формате {id}/{version}.json. В таком случае для получения всех версий одного продукта нужно вызвать s3 ровно столько раз, сколько версий у продукта (см. распределение по версиям). Запросы при этом можно делать параллельно, чтобы ускорить ответ. Учитывая, что целевой пользователь у нас поддержка (rps < 1), считаю, что такой вариант приемлем. Тут мы воспользуемся холодным S3. Но бакетов понадобиться как минимум 100.
-
Сохраняем все версии в единый 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 дней) будут удаляться библиотекой автопартиционирования.
Алгоритм вставки при этом будет такой
- открываем транзакцию
- смотрим последний использованный bucket_id для pid, если его нет - генерим новый bucket_id
- кладем в s3 в бакет bucket_id
- вставляем в индекс данные - pid, updated_at, bucket_id
- закрываем транзакцию
- коммитим офсет на консюмере
Преимещуства:
- легко расширяется, через создание новых бакетов
- легко изменять срок хранения данных
- равномерное распределение нагрузки по бакетам
- ttl объектов из коробки
- всегда есть полная история изменений сущности, без разрывов Недостатки:
- отсутствует экспертиза работы с S3 в команде
- зависимы от двух хранилищ
- нужно соблюдать консистентность данных между хранилищами
Выводы
С учетом всех минусов и плюсов я бы выбирал между:
- Cold Postgres на 7-10-15 тб, откладывая проблему расширения на далекое будущее
- Горячий S3, с записью в один файл с большим количеством заранее созданных бакетов, откладывая проблему расширения на будущее
- Комбинированным решением (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 уже наступил и эпик по истории заявки в работе у команды Миши Копылова, то последовательность переезда такая:
- Делаем аутбокс в топики https://jira.tcsbank.ru/browse/TOPST-1962
- storage-annals читает топики, кладет в свое хранилище
- Делаем ручку в storage-annals которая возвращает снапшоты сначала из своей базы, затем идет в сторедж, берет оттудава, дедуплицирует по version (приоритет отдает своим). Контракт ручки оставляем тем же что в Product Storage. Не имеет смысла делать дедупликацию, если команда Миши не приступила к выше упомянотуму эпику.
- Основного потребителя пересаживаем на ручку нового сервиса.
- Ручку в стородже объевляем deprecated с EOL 3,5 месяца. Упоминаем в доке что возвращает меньше данных. Некоторое время ничего возвращать не будет.
- В новом сервисе с даты EOL перестаем ходить в сторедж. Убираем логику по дедупликации, берем только свои данные.
- DONE
Внедрение
- Подготовить новый сервис, бд, api и авторизацию.
- Написать документацию, как минимум для методов в Swagger, упомянуть про новый сервис в документации Product Storage.
- см. миграция
- Метрики, аллерты и т.д.
- Переключить 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.
Нерешенные вопросы
- все ли изменения нужны техподдержке?