Исходный код биндинга MinIO можно посмотреть по ссылке

У акции нет точной цены, так как для каждого покупателя должен найтись продавец. Биржы хранят цены акций как японские свечи за минуту: самая большая цена за минуту, самая малая цена за минуту, цена открытия первый момент открытия торгового периода, цена закрытия в последний момент торгового периода. Итого: 25 лет истории × 20 тикеров × 525 600 минут в году ≈ 262,8 млн записей
[ { "time": 1720958400, "open": 450.25, "high": 455.50, "low": 448.10, "close": 453.80, "volume": 12500.5 }, ...]
Чтобы проверить автоматизированную торговую стратегию, нужно сделать виртуальное время, которое итерацией пройдет весь этот массив данных по одной свече. Если усреднить и взять свечи за один час, получится close-to-close, не точный бектест. Я попробовал несколько вариантов баз данных и хочу рассказать об этом
Профиль нагрузки
class CandleAdapter implements IPersistCandleInstance { constructor( readonly symbol: string, readonly interval: CandleInterval, readonly exchangeName: string, ) {} async waitForInit(initial: boolean) { /* поднять инфраструктуру */ } async readCandlesData(limit: number, sinceTimestamp: number) { /* ... */ } async writeCandlesData(candles: CandleData[]) { /* ... */ }}
-
Записи иммутабельны. Свеча за конкретную минуту не меняется никогда. Значит, нужен только идемпотентный upsert, который применит только недостающие записи.
-
Чтение доминирует. Прогон стратегии — это на 99% чтение: точечные выборки одной свечи по ключу
(биржа, символ, интервал, timestamp), много и параллельно. -
Нужен строгий контракт видимости (о нём ниже) — иначе конкурентная запись рассыпается. Мы не хотим сразу скачивать все 525 миллионов записей, нужен lazy loading
Контракт видимости записи
У движка жёсткое требование к долговечности записи: после того как writeCandlesData вернул управление, следующий же readCandlesData обязан увидеть записанное. Файловый адаптер выполняет его тривиально (fs.writeFile → fs.readFile увидят друг друга). А вот наивная реализация поверх любой БД — «прочитай, если нет — вставь» — контракт нарушает на конкурентной записи

Оба писателя видят «записи нет», оба вставляют, второй ловит duplicate key, движок уходит в ретрай. Именно то, как каждая база закрывает эту гонку без потери контракта, и определяет её место в обзоре.
Попытка 1. MinIO: объектное хранилище, отказ от файлов
Попытка себя не оправдала: MinIO создает по новому отдельному файлу на жестком диске с метаданными для каждого объекта свечи
Базовой реализацией было сохранение файлов на жесткий диск. Это тормозит. Я предположил что можно не менять модель данных вообще: JSON-документ остаётся JSON-документом, просто переезжает с локального диска в S3-совместимое объектное хранилище. Один бакет, ключ объекта — чистая функция контекста:
backtest-kit/candle-items/ccxt_binance/TRXUSDT/1m/1767251220000 → { open, high, low, close, volume, ... }
Детерминированный ключ бесплатно решает контракт долговечности: upsert — это один идемпотентный PUT, гонок нет по построению, а S3 гарантирует read-after-write для одиночных объектов. Свечи иммутабельны, поэтому запись — stat + PUT без скачивания тела:
export class CandleDataService extends BaseMinioStorage("backtest-kit/candle-items") { readonly loggerService = inject<LoggerService>(TYPES.loggerService); public create = async (dto: ICandleDto): Promise<ICandleRow> => { this.loggerService.log("candleDataService create", { dto }); const key = GET_STORAGE_KEY_FN(dto.symbol, dto.interval, dto.timestamp); const now = new Date(); const row: ICandleRow = { id: key, exchangeName: EXCHANGE_NAME, symbol: dto.symbol, interval: dto.interval, timestamp: dto.timestamp, open: dto.open, high: dto.high, low: dto.low, close: dto.close, volume: dto.volume, createDate: now, updatedDate: now, };
S3 хранилища не умеют в диапазонные запросы по ключам (нет BETWEEN по timestamp). Для этого, был применен внешний индекс в Redis
export class LogConnectionService extends BaseRedisMap(REDIS_KEY, -1) { readonly loggerService = inject<LoggerService>(TYPES.loggerService); public register = async (objectName: string): Promise<void> => { this.loggerService.log("logConnectionService register", { objectName }); const redis = await getRedis(); // One Redis SET per minute: SADD deduplicates repeated names, the floor // marker (first-ever minute) bounds the backwards walk in listNewest const minute = alignToInterval(new Date(), "1m").getTime(); await redis .pipeline() .sadd(GET_MINUTE_KEY_FN(this.connectionKey, minute), objectName) .setnx(GET_FLOOR_KEY_FN(this.connectionKey), String(minute)) .exec(); }; public listNewest = async (limit: number, prefix = ""): Promise<string[]> => { this.loggerService.log("logConnectionService listNewest", { limit, prefix }); const redis = await getRedis(); const floorRaw = await redis.get(GET_FLOOR_KEY_FN(this.connectionKey)); if (!floorRaw) { return []; } ...
Попытка 2. MongoDB: документная модель без смены схемы
Работать можно, но чтение одной свечи занимает 40мс. Итого: 31 день × 1440 минут в дне × 40 мс за виртуальную минуту ≈ 30 минут. Бизнес процесс ломается так как люди уходят в курилку и возвращаются через час
Я знаю что MongoDB это vendor lock на их облачную базу Atlas, так как в СНГ мало специалистов по ее масштабированию. Но у вендора Mongo Inc есть экосистема: если для Postgres нужно ставить докер образ pgAdmin 4 который вообще веб-сайт (а у кого-то Mac), то тут можно просто скачать программу MongoDB Compass
export class CandleDbService extends BaseMongoCRUD(CandleModel) { readonly loggerService = inject<LoggerService>(TYPES.loggerService); readonly candleCacheService = inject<CandleCacheService>(TYPES.candleCacheService); public upsert = async (dto: ICandleDto): Promise<ICandleRow> => { this.loggerService.log("candleDbService upsert", { dto }); const filter = { exchangeName: dto.exchangeName, symbol: dto.symbol, interval: dto.interval, timestamp: dto.timestamp, }; const insertOnly = { open: dto.open, high: dto.high, low: dto.low, close: dto.close, volume: dto.volume, }; const document = await CandleModel.findOneAndUpdate( filter, { $setOnInsert: insertOnly }, { upsert: true, new: true, setDefaultsOnInsert: true }, ); ...
Чтобы снизить сложность с логарифмической до O(1) можно сделать Redis Lookup, стоит 6гб оперативы, снижает нагрузку на процессор так как нет трафика памяти по шине через пагинацию
export class CandleCacheService extends BaseRedisMap(REDIS_KEY, -1) { readonly loggerService = inject<LoggerService>(TYPES.loggerService); private _cacheKey(symbol: string, interval: CandleInterval, exchangeName: string, timestamp: number): string { return `${exchangeName}:${symbol}:${interval}:${timestamp}`; } public async getCandleId(symbol: string, interval: CandleInterval, exchangeName: string, timestamp: number): Promise<string | null> { this.loggerService.log("candleCacheService getCandleId", { symbol, interval, exchangeName, timestamp, }); const key = this._cacheKey(symbol, interval, exchangeName, timestamp); const id = <string>await super.get(key); return id ?? null; } public async setCandleId(row: ICandleRow): Promise<string> { this.loggerService.log(`candleCacheService setCandleId`, { symbol: row.symbol, interval: row.interval, timestamp: row.timestamp }); const key = this._cacheKey(row.symbol, row.interval, row.exchangeName, row.timestamp); await super.set(key, row.id); return row.id; }}
Попытка 3. PostgreSQL + Pgpool-II: финал
В итоге удалось сделать сборку PostgreSQL + Pgpool-II с четырьмя read репликами и одной write. Скорость чтения выросла с 40мс до 10мс, месяц бектеста виртуального времени стоит 7 минут реального. Бизнес процесс не ломается, можно подождать не покидая рабочее место
Ради чего писалась статья — топология кластера. Нагрузка на 99% состоит из чтения, поэтому перед базой ставится pgpool-II: одна пишущая нода и три читающие реплики. pgpool раскладывает поток точечных SELECT по репликам, а весь DML прозрачно маршрутизирует на праймари

По ёмкости одна таблица с такими индексами держит миллиарды строк: 130 млн свечей — это ~260 байт на свечу со всеми индексами, около 34 ГБ, то есть даже не повод для партиционирования. Максимальная емкость Postgres для одной таблицы это 32 ТБ
export class CandleDbService extends BasePostgresCRUD(CandleModel) { readonly loggerService = inject<LoggerService>(TYPES.loggerService); readonly candleCacheService = inject<CandleCacheService>(TYPES.candleCacheService); public create = async (dto: ICandleDto): Promise<ICandleRow> => { this.loggerService.log("candleDbService create", { dto }); const repo = await super.repo<ICandleRow>(); const { raw } = await repo .createQueryBuilder() .insert() .values({ symbol: dto.symbol, interval: dto.interval, timestamp: dto.timestamp, exchangeName: dto.exchangeName, open: dto.open, high: dto.high, low: dto.low, close: dto.close, volume: dto.volume, }) .orUpdate(["symbol"], ["exchangeName", "symbol", "interval", "timestamp"]) .returning("*") .execute(); const result = raw[0] as ICandleRow; await this.candleCacheService.setCandleId(result); return result; };
Во всех трёх вариантах перед хранилищем стоит Redis. Популярное объяснение «O(1) вместо O(log n)» на практике не работает: обход b-tree на миллиарде строк — это 4–5 страниц, которые давно сидят в кэше, то есть микросекунды. Сетевой раунд-трип на порядок дороже самого обхода. Если бы дело было в асимптотике, кэш можно было бы выбросить.
Пару слов о Redis Lookup
Настоящий выигрыш Redis дает в снижении трафика памяти на один lookup, то есть в разгрузке CPU базы. Схема двухходовая: Redis по контекстному ключу отдаёт id, база добирает строку по PK — самому дешёвому из возможных обходов; при промахе идём в индекс и заполняем кэш.
Почему это разгружает CPU по каждой базе:
-
PostgreSQL. Точечный
SELECT— это парсер, планировщик, executor, пины буферов, обход 4–5 страниц по 8 КБ со случайными обращениями внутри каждой: десятки промахов L1/L2 и заметная полоса памяти на запрос. RedisGETпо короткому ключу — это хеш-бакет и короткая строка, одна-две кэш-линии. На то же железо Redis выдаёт 100–200kGET/с на ядро против 20–50k точечныхSELECT/с. Кэш не ускоряет отдельный запрос — он утраивает-ушестеряет пропускную способность и оставляет CPU праймари и реплик на реальную работу. -
MongoDB. Паттерн ценнее вдвойне: промах по составному индексу тянет за собой декомпрессию блока WiredTiger — чистый CPU, помноженный на полосу памяти. Redis-кэш
ctx → _idпревращает горячий путь вGET+findById, снимая и декомпрессию, и обход индекса. -
MinIO. Здесь per-object кэш не нужен: детерминированный ключ и есть «O(1)-адресация». Redis работает иначе — минутный индекс подменяет S3
LIST(который на сервере читает метакаталоги с диска), а дедуп-набор записанных ключей убирает лавинуstat-запросов.
Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/articles/1059062/