Есть класс ошибок, которые невозможно найти простой сверкой. Не потому, что данные хорошо спрятаны, а потому, что искомого состояния нет ни в одной системе. Оно собирается на лету из двух правильных половинок, каждая из которых по отдельности абсолютно валидна.

Хочу рассказать про случай, где искали причину две недели, но при этом мониторинг всё это время показывал, что всё в порядке, и формально он был прав.

Оговорюсь сразу: случай собирательный. Механика ошибки из моей практики, но домен и декорации изменены. Пересказывать реальный контекст я не могу, а без конкретики история теряет всё, ради чего её стоит рассказывать)

Что за система

B2B‑магазин. У каждого клиента своя цена: базовый прайс, скидки по договору, действующие акции. За расчёт всего этого отвечал отдельный сервис.

Базовые цены сервис брал из общего хранилища, а условия конкретного клиента (проценты скидки, список активных акций) держал у себя в памяти.

Причина простая. Клиент открывает каталог, на экране тридцать позиций, для каждой нужен свой расчёт. Тридцать расчётов, и в каждом участвуют условия договора клиента. Ходить за ними наружу на каждую позицию не релевантно, с учетом, что данные меняются раз в квартал. Держать в памяти выглядело очевидным решением. Так и сделали 🤷‍♀️

Очевидный вопрос: почему не вынести условия в общее хранилище и не иметь одну копию на всех?

Это правильное решение, и в конечном счёте система пришла примерно к нему. В тот момент против были две вещи. Первая, добавлять новую внешнюю зависимость в горячий путь расчёта цены означало новую точку отказа. Вторая, сроки и размер команды, вариант в памяти выглядел решением на день.

Задним числом я не считаю этот выбор правильным. Но ошибка была не в том, что состояние держали в памяти. Ошибка была в том, что мы обновляли его так, будто оно одно.

Естественно появился вопрос, как эту память обновлять.

Три способа обновления и почему мы выбрали худший

Мы рассматривали три варианта.

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

Перечитывать при каждом обращении. Всегда актуально и полностью бессмысленно: выходит шторм чтений ради данных, которые статичны девяносто девять процентов времени. Ровно то, от чего мы уходили, когда клали условия в память.

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

Выбрали третье) И вот здесь была ошибка, которую я тогда не увидела вообще.

Обращение к сервису — это обращение к одному экземпляру

Мы обновляли состояние HTTP‑запросом. А запрос приходит не в сервис. Запрос приходит в один под...

Сервис расчёта цен работал в шести репликах. Балансировщик делает ровно то, для чего он существует: берёт один экземпляр из шести и отдаёт запрос ему. Этот под честно идёт в базу, перечитывает условия и обновляет свою память.

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

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

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

Почему это не взрывалось несколько месяцев

Баг был в системе с самого начала. Просто он не доживал до последствий.

Мы катились раз в два‑три дня. Каждый деплой поднимал поды заново, на старте каждый под читал условия, и состояние выравнивалось само.

Получилось так: у нас была неисправная система обновления и был деплой, который её чинил. Никто не связывал эти две вещи, потому что связь работала в одну сторону и незаметно. Регулярный релизный цикл лечил ошибку, о существовании которой мы не знали.

Это отдельный неприятный класс проблем. Ошибка, замаскированная побочным эффектом здоровой практики. Пока практика соблюдается, ошибки как будто нет. Стоит практике прерваться и мы получили не постепенную деградацию, а резкую несогласованность, причём в момент, когда меньше всего готовы разбираться.

В декабре у нас встал стоп на релизы. Три недели без деплоя.

Договор крупному клиенту переподписали в первую же неделю. Обновление ушло на один под. Пять остались жить прошлым месяцем.

Почему поиск занял две недели

Если бы пять подов просто отдавали старую цену, мы бы нашли это за час. Взяли бы условия из договорного сервиса, сравнили с тем, что отдаёт расчёт, увидели расхождение.

Но отдавали они не старую цену...

Базовые цены сервис по‑прежнему брал из общего хранилища. А в декабре прошла переиндексация базового прайса, и там всё было свежее и корректное. Скидку же эти поды накидывали по договору, который уже не действовал)

На выходе получался гибрид: новые базовые цены и старые условия.

Такой цены не существовало ни в один момент времени. Ни до переподписания договора, ни после. Это не было «состоянием системы на прошлой неделе» — это была комбинация, которой не было никогда.

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

Отдельно добавила веселья природа воспроизведения. Привязки к сессии на балансировщике не было. Клиент открывает карточку товара и попадает на обновлённый под, видит правильную цену. Кладёт в корзину и попадает на старый, цена другая. Обновляет страницу, а там снова правильная.

Воспроизводится в пяти случаях из шести. Почти всегда. Но «почти» здесь ключевое: поддержка пару раз попала на здоровый под и закрыла тикеты как неподтверждённые.

Клиент видел мистику. Поддержка видела человека, который не может показать проблему. Мы видели зелёный мониторинг.

Почему мониторинг молчал

Вот это, на мой взгляд, самая интересная часть истории.

На кэше висела метрика hit rate. Главная метрика кэша во всех руководствах 🫠

Она была сто процентов. На всех шести подах. Условия загружаются в память при старте и лежат там. Вымывать их нечем, это не LRU‑кэш с ограниченным размером, это загруженное состояние. Промахов нет ни у одного пода физически, ни в исправном состоянии, ни в сломанном.

Метрика не различала два этих состояния вообще. Ни по значению, ни по динамике, ни на графике за месяц. Она отвечала на вопрос «часто ли мы попадаем в память», а сломался у нас ответ на вопрос «то ли лежит в памяти».

Дальше по списку было не лучше. Ошибок нет, в целом их действительно не было, под честно отдавал результат расчёта. Память в норме. Времена ответов отличные, даже лучше обычного. В логах чисто.

Мониторинг не врал. Он добросовестно отвечал на вопросы, которые мы ему задали. Просто ни один из этих вопросов не был про консистентность состояния между репликами)

Что надо было мерить

Две метрики, обе дешёвые.

Версия состояния, которое сейчас в памяти, с меткой пода.

И тогда наша декабрьская история выглядит на графике так: пять подов на ревизии 412, один на 413, расхождение держится дольше минуты. Видно за секунду. Причём видно было бы и в сентябре, когда баг только появился, просто расхождение схлопывалось бы через пару часов очередным деплоем, и это само по себе было бы отличной подсказкой.

Ни один алерт на ошибках такого не поймает, потому что ошибки нет.

Возраст последнего успешного обновления, тоже по каждому поду.

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

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

Что я изменила бы в архитектуре

Метрики ловят проблему. Но правильнее было не создавать её 😁

Обновление нужно не проталкивать, а забирать.

Push‑модель «источник данных сообщает потребителю об изменении» требует, чтобы источник знал про всех потребителей. В мире, где потребитель это шесть подов, которые пересоздаются при каждом деплое, масштабируются по нагрузке и могут упасть в любой момент, источник этого знания не имеет и иметь не может.

Pull‑модель лечит такую проблему. Каждый под сам, по таймеру, спрашивает источник: какая сейчас ревизия условий? Если та же, то просто ничего не делает, запрос дешёвый. Если новая, то перечитывает данные.

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

Да, появляется окно рассинхронизации до какого‑то количестве секунд, но для условий, которые меняются раз в квартал, это несопоставимо с тремя неделями.

Потолок на срок жизни ставится всегда.

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

Без него пропущенное обновление живёт до ближайшего деплоя. А деплоя, как выяснилось, может не быть три недели.

Три вещи, которые я забрала себе

Обращение к сервису — это обращение к одному его экземпляру. Всё, что вы делаете HTTP‑запросом, вы делаете с одним подом. Если состояние живёт в памяти, оно живёт в каждом поде отдельно, и «сообщить сервису» через ручку невозможно в принципе.

Метрика, которая физически не может измениться, не является мониторингом. Стопроцентный hit rate выглядел как показатель здоровья, а был константой. Полезная проверка: спросить себя, при каком сбое эта метрика изменится. Если ответа нет, то на графике просто украшение.

Практика, которая маскирует ошибку, однажды прервётся. Регулярные деплои чинили нам консистентность бесплатно и молча. Про такие подпорки узнаёшь только в момент, когда они исчезают, и обычно это происходит в самый неудачный момент)


После этой истории я села и выписала себе список: что нужно повесить на любое состояние, которое живёт в памяти процесса. Пять пунктов, каждый занимает пару строк кода и закрывает один способ, которым такое состояние тихо разъезжается.

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

А пока мне интересно собрать чужие случаи. Какой у вас был самый долгоживущий кусок протухших данных и кто его в итоге нашёл?