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

Я фронтенд-разработчик и CTO, с командой мы делаем «Систему Анализа Закупок», сервис для поиска и анализа тендеров по 44-ФЗ и 223-ФЗ. Про сам продукт здесь почти не будет речи: расскажу про три случая, когда что-то в проде повело себя не так, как задумывалось, и что мы в итоге поменяли в коде. Все три взяты из истории коммитов за последние месяцы, без прикрас и без «мы всё предвидели заранее». Почти всегда не предвидели.

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

Когда FINAL читает всю таблицу вместо избранных строк

Аналитика по реестру контрактов у нас лежит в ClickHouse, там больше 10 миллионов строк. Таблица версионированная: при обновлении контракта дописывается новая строка, а не правится старая. Чтобы получить актуальную версию, к запросу логично добавить FINAL: ClickHouse сам схлопнет версии и отдаст только последнюю. Мы так и делали почти везде.

Проблема в том, что FINAL меняет план выполнения запроса. Чтобы схлопнуть версии на лету, движку нужно физически прочитать все части таблицы, где может встретиться нужный ключ, и он перестаёт пользоваться индексами пропуска: bloom_filter и minmax просто не участвуют в отборе гранул. На практике это означало полное сканирование таблицы там, где индекс должен был отсечь девяносто с лишним процентов данных.

Один расчёт на фронте состоял из 11 последовательных запросов. На прогретом кэше страниц он укладывался примерно в 15 секунд, что уже на грани терпения пользователя. На холодном кэше, а после деплоя или в начале дня кэш почти всегда холодный, время уходило к 27–31 секунде, запрос упирался в таймаут прокси, фронт получал 502 и по логике ретраев отправлял его заново, добавляя нагрузку туда, где её и так хватало.

Чинили, не отказываясь от логики версионирования как таковой: FINAL убрали, а схлопывание строк стали делать вручную, через `argMax` по ключу сортировки внутри подзапроса. Фильтры перенесли туда же, внутрь подзапроса: на верхнем уровне, после агрегации, индексы их уже не видят. Отдельно поменяли одну конструкцию: `arrayExists(x -> has(array, x))` заменили на `hasAny(array, [...])`, потому что вторая умеет читать bloom_filter по массиву значений, а первая нет, хотя по смыслу они эквивалентны.

SELECT contract_id, argMax(status, version) AS status, argMax(amount, version) AS amount FROM contract_versions WHERE region_code = {region:String} AND hasAny(okpd_codes, {codes:Array(String)}) GROUP BY contract_id
До и после: время расчёта из 11 запросов в ClickHouse
До и после: время расчёта из 11 запросов в ClickHouse

Тот же расчёт стал укладываться примерно в 1,2 секунды. Результаты сверили построчно со старой версией на FINAL: совпали до строки, и проверять это нужно было именно построчно, а не только по контрольным суммам. На форму сгенерированного SQL теперь есть тесты, и не про бизнес-логику, а про то, чтобы через полгода кто-то второпях не дописал FINAL обратно, потому что «так же правильнее».

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

Синхронизация, которая шла 216 раз и ни разу не закончилась

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

Изъян был в одном допущении. При непрерывной записи в источник пустой батч почти никогда не наступает: за то время, пока обрабатывается текущий батч, успевают появиться новые строки. А крон запускался каждый час, не проверяя, закончился ли предыдущий прогон.

За девять дней синхронизация запускалась 216 раз и не завершилась ни разу. Прогоны копились один на другой, пул соединений с Postgres оказался забит открытыми, но бесполезными подключениями, загрузка процессора на базе доходила почти до 457%, а поиск по сервису от этого заметно тормозил. Неприятная часть истории в том, что баг жил с весны: его маскировали частые деплои, потому что каждый рестарт процесса сбрасывал накопленные прогоны и на время снимал симптом. Когда деплои стали реже, накопление перестало прятаться само.

До и после: синхронизация контрактов, которая не могла закончиться
До и после: синхронизация контрактов, которая не могла закончиться

Исправление получилось без сложной архитектуры. Добавили single-flight: если прогон ещё идёт, следующий тик крона просто пропускается, а не запускает вторую копию поверх первой. Отдельно поставили сторож от залипания: если прогон висит дольше разумного времени без прогресса, он считается мёртвым и не блокирует следующий тик. Между батчами добавили дедлайн с чекпоинтом, чтобы прогон не мог растянуться бесконечно и чтобы при перезапуске он продолжал с сохранённой точки, а не с нуля. И в лог добавили строку «sync finished» на каждый час работы: раньше по логам было видно, что что-то происходит, но не было понятно, закончилось оно нормально или нет.

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

Одна цифра вместо двух, которые гасили друг друга

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

Заменили оба фактора одним: эффективным числом конкурентов N_eff = 1 / HHI. HHI, индекс Херфиндаля-Хиршмана, это сумма квадратов долей побед каждого поставщика в выборке похожих закупок. Если рынок держит один игрок, N_eff стремится к единице. Если победы распределены поровну между пятью, N_eff близок к пяти. Метрика одна, а ведёт себя ровно так, как нужно и для числа игроков, и для концентрации, потому что оба смысла в ней уже заложены математически.

Пороги, по которым N_eff превращается в понятную для человека оценку («высокая конкуренция», «низкая» и промежуточные ступени), откалибровали на выборке из примерно 1200 реальных тендеров. Отдельно пришлось разбираться с 223-ФЗ: там реестр исполнения контрактов почти пустой, по нему нельзя честно посчитать статистику побед. Для таких закупок оценка приблизительная: по победителям в похожих закупках, без опоры на полный реестр исполнения. В интерфейсе такая оценка помечена как оценка с низкой уверенностью, а не выдаётся наравне с расчётом по 44-ФЗ.

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

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

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