vegman.dev
Все статьи

Два процесса и одна база: гонка, которую решили не кодом

2026-03-19кейс

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

Выкладка шла по обычному сценарию: скрипт собирает образы, поднимает контейнеры, следом гоняет тесты. И вот в этот момент тесты открывают векторную базу - ту самую, которую в эту же секунду уже держит открытой только что стартовавший сервис. Дальше как повезет: иногда падает тест, иногда - не тест.

Я смотрела на симптом и видела «архитектуру»: два процесса делят одно хранилище напрямую - значит, хранилище надо выносить. Правильный (и дорогой) ответ из учебника. И, как выяснилось, ответ не на тот вопрос.

Ниже - разбор: как все было устроено, почему «вынести базу в отдельный сервис» оказалось оверкиллом, что мы поменяли вместо этого и как отличить случай, где хватает смены порядка операций, от случая, где без отдельного сервиса действительно не обойтись.

Ситуация: embedded-база и два процесса

Система - два контейнера:

  • веб-сервис, который отвечает на запросы
  • бот с планировщиком: он и общается с людьми, и по расписанию выполняет фоновые задачи.

Оба работают с одной векторной базой знаний.

Важно не то, что база «векторная», а то, как она подключена.

Она работает во встроенном режиме (embedded). То есть база - не отдельная программа, к которой ходят по сети, а каталог с файлами на диске. Библиотека внутри процесса открывает этот каталог и работает с ним напрямую - как приложение с файлами. Никакого сервера посередине нет.

И да: оба процесса открывают один и тот же каталог. В любом архитектурном обзоре это подсвечивают красным.

flowchart LR
  P1["Процесс: веб-сервис"] --> D[("Каталог с файлами базы")]
  P2["Процесс: бот"] --> D
  P3["Тесты при выкладке"] -.->|"третий, посторонний"| D
  N["Сетевого сервиса базы нет"] -.- D
Встроенная база - это каталог на диске: сервера посередине нет, оба процесса открывают файлы напрямую

Почему напрашивалось «правильное» архитектурное решение

Учебник отвечает без запинки: поднимите базу отдельным сервисом и ходите в нее по сети. Один владелец данных, остальные - клиенты, конкурентный доступ разруливает сама база.

Возразить нечего. Но у «правильного решения» был ценник:

  • отдельная работа: переписать подключение, перенести данные, убедиться, что поиск отвечает так же
  • отдельный контейнер: его надо собирать, обновлять, наблюдать
  • отдельная точка отказа: лег сервис базы - легли оба процесса разом (хотя раньше они могли падать независимо).

И вот здесь стоит задать себе неприятный вопрос: какую проблему я закрываю этими тремя пунктами?

Ответ оказался таким же неприятным: гипотетическую.

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

А ломалось другое: во время деплоя тесты открывали базу поверх живого процесса. Не «два сервиса конкурируют под нагрузкой», а «во время выкладки в каталог заходит третий, посторонний». Сетевой сервис, конечно, вылечил бы и это - просто как побочный эффект, ценой всего списка выше.

flowchart TB
  subgraph WAS["Было"]
    W1["Поднять сервис и бота"] --> W2["Прогнать тесты"] --> W3["Тесты открывают базу<br/>поверх живых процессов"]
  end
  subgraph NOW["Стало"]
    N1["Тесты в одноразовом<br/>контейнере"] --> N2["Контейнер погашен"] --> N3["Поднять сервис и бота"]
  end
  WAS ==> NOW
Починили не конструкцию, а порядок шагов - гонка исчезла без отдельного сервиса базы

Что сделали вместо этого

Мы поменяли порядок шагов в скрипте выкладки.

Было: поднять рабочие процессы, потом запустить тесты. Тесты стартовали, когда сервис и бот уже держали базу открытой.

Стало: тесты гоняются в отдельном одноразовом контейнере до подъема рабочих процессов. Контейнер живет ровно столько, сколько идут тесты, и умирает. Если тесты не прошли - рабочие процессы просто не поднимаются.

Результат: базу больше никто не открывает поверх живого процесса. Не потому что мы запретили это «механизмом», а потому что момент, в который это было возможно, больше не наступает.

Embedded-режим остался как был. Мы не трогали подключение - ни строчки. Изменился только деплой.

И мне кажется важным это фиксировать словами: симптом выглядел как «архитектура хранилища», а причина сидела в «порядке шагов деплоя».

Когда так можно - и когда нельзя

Чтобы этот прием не превратился во вредный совет, нужна честная оговорка.

Это работает, потому что:

  • нагрузка небольшая - процессы не толкаются в базе постоянно
  • записи редкие: переиндексация идет по расписанию, а не непрерывно.

Читать одновременно обычно не страшно. Опасно - когда кто-то пишет, пока другой читает или пишет.

Как только запись становится интенсивной и параллельной, фокус перестает работать. Никакой порядок шагов в скрипте не спасет от двух процессов, которые одновременно перестраивают индекс: они делят каталог на диске, и договориться им негде.

Тогда сетевой сервис становится не «правильным по учебнику», а обязательным - потому что нужен один владелец данных, который сериализует записи.

Признаки, что вы еще в безопасной зоне:

  • писатель ровно один, пишет по расписанию, и вы это расписание знаете
  • все остальные только читают
  • вы можете назвать поименно каждый процесс, который открывает каталог.

Признаки, что пора выносить базу в отдельный сервис:

  • писателей больше одного или запись запускается событием от пользователя
  • вы собираетесь масштабировать процессы копиями (вторая копия сервиса = второй держатель каталога)
  • вы уже ловили битый индекс и лечили его переиндексацией.

Дешевое операционное решение - это не «навсегда». Это «пока выполняются условия». И эти условия стоит прямо записать рядом со скриптом деплоя, чтобы через год кто-нибудь (возможно, вы сами) понимал, на чем держится конструкция.

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

Пока я копалась, всплыло забавное: в описании системы одно время значился отдельный контейнер базы. Его никогда не существовало. Не «был и убрали, а в документации забыли поправить» - его не было изначально.

Вывод скучный, но полезный: проверять надо код и список реально запущенных процессов, а не описание. Описание - это чья-то версия намерений. Оно не участвует в деплое, ничего не открывает и ничего не блокирует.

Применить у себя

Что проверить на своей системе (практически)

По порядку:

  • Убедитесь, какой режим подключения: в коде клиента смотрите, что передают - путь к каталогу на диске или хост+порт. Путь = embedded.
  • Выпишите всех, кто открывает каталог. Не по документации - по списку контейнеров и volume-mount'ов. Одна и та же папка, примонтированная в два контейнера, - это два держателя.
  • Не забудьте «гостей»: тесты, миграции, разовые скрипты обслуживания, ручные запуски «посмотреть, что там в базе». Они часто ломают систему внезапно - просто потому что никто не считает их частью архитектуры.
  • Прочитайте деплой-скрипт сверху вниз и отметьте моменты, когда одновременно живы два (или больше) потребителя базы. Обычно это узкое окно в несколько секунд - и именно там все происходит.
  • Отдельно ответьте на вопрос «кто и когда пишет». Один писатель по расписанию - у вас есть право на дешевое решение. Несколько писателей - возвращайтесь к пунктам про отдельный сервис.

Как понять, что получилось

Критерий в одной фразе:
в каждый момент времени каталог базы открыт ровно теми процессами, что и в обычной работе, и во время выкладки нет ни одной секунды, когда их становится больше.

Проверять так:

  • прогоните выкладку несколько раз подряд (если раньше падало «иногда», один успешный прогон ничего не доказывает)
  • убедитесь, что провал тестов больше не оставляет систему в странном состоянии: тесты не прошли, рабочие процессы не поднялись, работает старая версия
  • проверьте, что одноразовый контейнер с тестами действительно исчезает после прогона, а не остается висеть и держать каталог
  • через месяц вернитесь и перечитайте условия «когда так можно». Если хотя бы одно перестало выполняться - вы снова в архитектурной задаче, и теперь она настоящая.

И последнее: дешевое решение - не повод не понимать дорогое. Я по-прежнему знаю, как вынести базу в отдельный сервис, и знаю, при каком условии это придется сделать. Разница лишь в том, что делать это сейчас означало бы заплатить за проблему, которой у нас нет.