Мне пришла задача на исследование — выбрать хранилище для промышленной телеметрии. Я раньше с таким вообще не работал. Ни с временными рядами, ни с реактивным программированием, ни с WebSocket. Работал только с Postgres.
Слышал про Redis, но не применял. На выбор — SQLite или Redis, надо исследовать что лучше. Пошёл читать. Нашёл TimescaleDB и ещё кучу вариантов — у каждого свои плюсы.
Основной плюс SQLite — там можно писать запросы, SQL знакомый, Spring интеграция есть. Скорость записи меня вообще удивила — и у Redis, и у SQLite. Я не ожидал таких цифр.
Долго не мог понять зачем вообще Redis, если там нет нормального поиска. Просто хэши ключ-значение. Хочешь найти все датчики по типу объекта — не работает. Я такой: ладно, Redis минус, берём SQLite.
Сделал прототипы. Потестировал один поток — SQLite летит, 370 000 вставок в секунду. Redis ещё быстрее — 650 000. Нашёл RediSearch с поиском по индексам — оч круто, но скорость падает до 150 000. SQLite снова выглядит выигрышнее.
И вот я запускаю 100 потоков на SQLite.
В логах появляется:
org.sqlite.SQLiteException: [SQLITE_BUSY]The database file is locked
Не один раз. Постоянно. Скорость падает с 370 000 до 98 000 вставок в секунду. Данные теряются.
Я потратил несколько дней на исследование, написал два прототипа, нашёл RediSearch — и в итоге выбирал не по тому критерию. Скорость на одном потоке вообще не важна, когда система параллельная.
Вот тогда я и понял что выбирал не по тем критериям. Делюсь цифрами — чтобы вы не повторили тот же путь.
Задача
Промышленный сервис телеметрии. Данные от датчиков летят через WebSocket и REST. Каждое измерение — тип объекта, ID, ID параметра, момент времени и значение (число, строка или дата).
Нужно:
-
быстро писать — десятки и сотни тысяч вставок в секунду
-
держать параллельную запись от множества потоков
-
искать по типу, ID, диапазону значений
-
хранить последнее значение каждого датчика
Стек — Spring WebFlux, реактивный. Это важно для выбора инструмента.
Два прототипа
Сделал два одинаковых прототипа с одинаковыми данными — один на SQLite, второй на Redis. Просто чтобы пощупать.
Скорость записи SQLite на диске с WAL — 370 000 вставок/сек. Я не ожидал такого от файловой базы.
Скорость записи Redis (чистый HSET, без индексов) — 650 000 вставок/сек. Redis работает в памяти, поэтому понятно почему так.
Но поиска в Redis нет. Просто HSET cur:1:100:5 value 42.5 — и всё. Хочешь найти все датчики с typeObj=1 — нет, так не работает. Я такой: ладно, Redis минус, берём SQLite.
Нахожу RediSearch
Продолжаю копать и нахожу модуль — RediSearch. Устанавливаю, настраиваю индексы:
FT.CREATE idx:cur ON HASH PREFIX 1 cur: SCHEMA typeObj NUMERIC objId NUMERIC parId NUMERIC
Теперь поиск по нескольким полям работает:
FT.SEARCH idx:cur "@typeObj:[1 1] @objId:[100 100]" LIMIT 0 1000
Оч круто. Есть поиск, есть скорость. Начинаю тестировать производительность с индексами.
Скорость записи RediSearch — 150 000–170 000 вставок/сек.
Упала в 4 раза по сравнению с чистым Redis. Индексы дорого стоят.
Смотрю на SQLite — 370 000 на диске, 700 000 in-memory. Выглядит выгоднее. Начинаю склоняться к SQLite.
Тестирую многопоточность — и всё ломается
Запускаю 100 потоков. Смотрю в логи.
org.sqlite.SQLiteException: [SQLITE_BUSY]The database file is locked (database is locked)
Не один раз. Каждые несколько секунд.
Скорость падает с 370 000 до 98 000 вставок/сек — в почти 4 раза. А данные при этом теряются.
Почему SQLite теряет данные на многопоточке
SQLite работает с блокировкой на уровне файла. Даже в WAL-режиме — только один писатель в каждый момент. Остальные потоки ждут или получают SQLITE_BUSY и пропускают запись.
Представьте один кассир в супермаркете. Пока покупатель один — быстро. Выстроились 100 человек — кто-то уйдёт без покупок, потому что магазин закроется раньше чем до него дойдёт очередь.
Race Condition — это когда два потока одновременно пытаются изменить одни данные, и результат зависит от того, кто успеет первым. У SQLite в многопоточной записи именно это и происходит.
Можно настроить PRAGMA busy_timeout, выстроить очередь — но это уже костыли. Инструмент не для этого.
SQLite in-memory ещё хуже: каждое соединение получает изолированную базу, при рестарте все данные исчезают, при многопоточной записи теряется до 10% данных без специальной оптимизации.
Почему RediSearch выигрывает несмотря на меньшую скорость
Redis обрабатывает команды в одном потоке. Звучит как ограничение — на деле это гарантия: никаких блокировок, никакого Race Condition.
При росте числа потоков RediSearch не падает. Результаты даже слегка улучшаются — очередь команд выстраивается эффективнее. Никаких SQLITE_BUSY. Данные не теряются.
Redis как очень быстрый и организованный кладовщик — он один, но обрабатывает заказы строго по очереди и никогда не путает. Именно поэтому параллельные клиенты не мешают друг другу.
Важно знать: FT.SEARCH по умолчанию возвращает не больше 10 000 записей. Нужна пагинация через LIMIT offset count. JOIN между разными индексами нет — придётся делать два запроса и связывать на стороне приложения. Стандартный паттерн для NoSQL, просто держите в голове.
Цифры нагрузочного тестирования
|
Инструмент |
Запись зап./сек |
Многопоточность |
|---|---|---|
|
SQLite (диск, WAL, кэш 400 МБ) |
~370 000 |
При 100 потоках падает до ~98 000; |
|
SQLite (in-memory) |
~700 000 |
Теряет до 10% данных без оптимизации |
|
Redis (чистый HSET, без индексов) |
~650 000 |
Нет проблем — однопоточный обработчик |
|
RediSearch (с индексами) |
~150 000–170 000 |
Нет проблем; при росте потоков результат улучшается |
Таблица рисков
|
Риск |
SQLite |
RediSearch |
|---|---|---|
|
Многопоточная запись |
Единственный писатель; |
Нет блокировок; предсказуемая скорость |
|
Объём данных |
Неограничен (диск); деградация после 50–100 ГБ |
Ограничен RAM; индексы занимают в 2–5 раз больше данных |
|
Потеря данных при сбое |
Полный ACID; WAL защищает |
Без AOF/репликации — потеря последних операций |
|
Поиск и JOIN |
Полный SQL + JOIN |
Нет JOIN; нужно два запроса + связка в коде |
|
Масштабирование |
Вертикально, один файл |
Redis Cluster, горизонтально |
|
Eviction |
— |
При переполнении памяти удаляет старые ключи по LRU |
Итог
SQLite хорош. Для одного-двух потоков, небольших объёмов, простой аналитики — отличный выбор. Как только появляется конкурентная запись от сотни потоков — начинаются проблемы.
RediSearch медленнее на бумаге: 150 000 против 700 000. Но это предсказуемые 150 000 без потерь на любом числе потоков. Против непредсказуемого SQLite, который теряет данные и падает до 98 000 под нагрузкой.
Для высоконагруженной системы с множеством параллельных источников — RediSearch надёжнее. Даже с более низкой пиковой скоростью.
Если интересно следить за процессом — канал @java_quant
Пообщаться или задать вопрос — @karim_product, на связи.
ссылка на оригинал статьи https://habr.com/ru/articles/1034824/