Race Condition убил SQLite в нашем проекте: как мы пришли к RediSearch

от автора

Мне пришла задача на исследование — выбрать хранилище для промышленной телеметрии. Я раньше с таким вообще не работал. Ни с временными рядами, ни с реактивным программированием, ни с 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_BUSY в логах

SQLite (in-memory)

~700 000

Теряет до 10% данных без оптимизации

Redis (чистый HSET, без индексов)

~650 000

Нет проблем — однопоточный обработчик

RediSearch (с индексами)

~150 000–170 000

Нет проблем; при росте потоков результат улучшается

Таблица рисков

Риск

SQLite

RediSearch

Многопоточная запись

Единственный писатель; SQLITE_BUSY; потери данных

Нет блокировок; предсказуемая скорость

Объём данных

Неограничен (диск); деградация после 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/