Народная карта наличия бензина: честно про цифры, архитектуру и как PostGIS прод положил

от автора

Коллеги, всем привет, меня зовут Дмитрий Тыльный, и я автор проекта: Где бензин
Народная карта наличия бензина на АЗС, где водители в реальном времени отмечают,
на каких заправках сейчас есть топливо, где очередь, где скоро будет, а где уже все…

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

Мы с «гдебенз» запустились примерно в одно время, если я не путаю, зарегистрировал даже на день раньше домен, но к разработке приступил позже, по личным причинам).

Оба проекта это вайбкодинг с Claude, поэтому визуально и по логике они неизбежно похожи…

В основе у всех одно и то же — карта, на ней заправки, несколько статусов. Утверждать, что кто‑то кого‑то намеренно копирует, я не буду — это некрасиво и, скорее всего, неправда.
Идея простая, время одно, инструмент один.

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

Мои честные цифры

Не буду мериться миллионами, покажу что вижу в своей админке на момент публикации

  • сейчас на сайте: 1 128 человек

  • уникальных за сегодня: 28 764

  • всего АЗС в базе: 27 558 (вся Россия)

  • АЗС со свежими данными прямо сейчас: 3 276

  • отметок за сегодня: 4 382, за трое суток: 12 847

  • добавили на экран: 18 642

Отметки за трое суток по статусам:
есть — 5 142 
скоро будет — 3 287 
очередь — 2 814 
закончился — 1 604

Цифры скромнее, чем 1,8 млн за трое суток у соседей.
Но это ровно то, что я могу показать по факту…
Для нишевого сервиса, результат за три дня — огонь)

Как все работает

Стек намеренно простой и дешёвый в эксплуатации:

  • Бэкенд — FastAPI (async), PostgreSQL + PostGIS для геозапросов, Redis (кэш выдачи по видимой области, rate‑limit, pub/sub → WebSocket).

  • Фронт — статика + Яндекс Карты (JS API), mobile‑first, тёмная тема.
    Работает прямо в браузере: без приложения, регистрации, разрешений.

  • Реалтайм — как только кто‑то ставит отметку, она через WebSocket прилетает всем открытым картам; плюс карта раз в минуту сама подтягивает свежие данные.

  • Всё живёт в Docker Compose, статику отдаёт системный nginx,
    мониторинг — Prometheus + node‑exporter + cAdvisor + Grafana.

Моменты, которые оказались важнее, чем кажется:

Концепция «последний прав» ломается от одного тролля, который натыкает «нет бензина». Поэтому статус АЗС — это большинство среди отметок, где один человек (по анонимному идентификатору) = один голос, а при равенстве побеждает более свежая.

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

Донат‑кластеры. При отдалении заправки схлопываются в кластер, и кластер закрашен кольцом по долям статусов: одна зелёная и одна красная рядом — кольцо ровно пополам. Рисуется как SVG‑иконка на лету, без сторонних библиотек кластеризации.

Только Россия, со всеми регионами. Точки берём из OpenStreetMap, но в выдаче нужна строго РФ. Границу собираем из OSM (osm2geojson + shapely) и режем по ней — включая Крым, Севастополь, ДНР, ЛНР, Херсонскую и Запорожскую области.
И вот на этой самой границе прод упал)

И пара решений под капотом, которые оказались неочевидными…

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

grid      = round(south,2),round(west,2),round(north,2),round(east,2)cache_key = f"bbox:{redis.get('stations:ver')}:{grid}"

Когда прилетает новая отметка, не нужно чистить сотни bbox‑ключей в Redis, только одна операция INCR stations:ver. Версия сменилась → все старые ключи bbox:41 
разом становятся недоступны и просто протухают по TTL сами. Новые запросы идут уже на bbox:42. Глобальная инвалидация всего кэша карты в один атомарный инкремент, без гонок. Плюс округление bbox до сотых долей градуса склеивает близкие вьюпорты в один ключ.

Статус заправки считается на чтении

Крон джоб, которые бы отключали заправки по таймеру, нет, при запросе обращаемся к
now() — last_report_at, если самый свежий отчёт старше окна таймаута (2 часа),
То просто отдается серый цвет заправки, без мутаций бд и блокировок при таком количестве.

Там же на чтении считается freshness_seconds, это сколько минут назад была отметка, expires_in_seconds это обратный отсчет до того как заправка снова станет серой,
и confirmations, сколько разных людей подтвердили статус.
Плюс разбивка по каждому топливу отдельно, 92й может быть, а 95го уже нет.

Rate‑limit и грабли на ровном месте
Спам отметками режется скользящим окном на Redis ZSET, при каждом запросе одним pipeline выкидываю из ZSET все что старше окна, добавляю текущий запрос, считаю ZCARD и сравниваю с лимитом. Четыре команды, атомарно, по IP.

pipe.zremrangebyscore(key, 0, now - window_sec)pipe.zadd(key, {member: now})pipe.zcard(key)pipe.expire(key, window_sec)

И тут я успел наступить на грабли, сначала уникальным членом ZSET был id(now).
А id у флоата в питоне это адрес объекта, который переиспользуется. Два запроса в одну микросекунду получали один member, один перезаписывал другой и счетчик занижался, лимитер пропускал больше чем должен. Заменил на now + token_hex и стало честно.

Реалтайм тонкими событиями, WebSocket часть нарочно тупая.
В приложении один Hub, одна фоновая задача слушает канал Redis pub/sub и рассылает сообщение всем подключенным сокетам, мертвые соединения выкидываются прямо при неудачной отправке. Само событие это просто {«type»: «station_update», «station_id»: N}, без данных. Клиент получив его сам решает интересна ли ему эта станция в текущем вьюпорте и дергает API, а тот уже отвечает из свежего кэша (версия то инкрементнулась). По сокетам летают байты, а не JSONы со статусами, и не надо думать у кого какой вьюпорт открыт.

Как PostGIS ронял прод

Первую версию фильтра по границе я сделал «в лоб»: на каждый запрос выдачи по видимой области — ST_Contains(граница_РФ, точка_АЗС). На практике граница России — это мультиполигон на ~147 тысяч вершин, и PostGIS честно проверял вхождение каждой из 27 тысяч станций в этот монстр‑полигон на каждый скролл карты.
На пике трафика load average сервера ушёл под 47, а postgres ел ~415% CPU.
Посыпались таймауты и недогруз карты.

Чинил так:

  1. Убрал ST_Contains из горячего пути. Граница нужна ровно один раз — при импорте, чтобы выкинуть заграничные АЗС. В рантайме проверять её незачем: в базе и так только Россия.

  2. Добавил кэш выдачи по bbox в Redis с коротким TTL и мгновенной инвалидацией при новой отметке (через инкремент версии).

После этого postgres с 415% CPU вернулся к ~1%, и сайт перестал складываться под нагрузкой. Мораль банальная, но выстраданная руками: тяжёлую геометрию нельзя дёргать на каждый запрос — предрасчитывай и выноси из горячего пути.
По классике мониторинг: Grafana + cAdvisor

SEO

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

Данные для лендингов уже есть в базе, так что по сути это выгрузка из PostgreSQL в HTML плюс sitemap. Пока это самый дешевый канал, страница типа «бензин в Воронеже» индексируется и приводит людей ровно в тот момент, когда им надо.

Про точность

Карта верна и релевантна ровно настолько, насколько активны водители.
Там, где сегодня никто не отметился, заправка серая и ничего не придумывает.
Достоверное «есть бензин» карта показывает там, где только что был живой человек, поэтому в карточке видно, сколько минут назад пришла отметка и сколько ей ещё жить.
Это не замена звонку на АЗС, а способ не объехать три пустые заправки подряд, как это когда‑то сделал я — собственно, поэтому проект и появился.

Безопасность и немного оффтопа

Раз под видом карт бензина набежали мошенники, я сразу зашил в сервис принципы: ничего не скачивать, никакой регистрации, не собираем персональные данные, не просим оплату или данные карт. Геолокация включается только по кнопке и остаётся в браузере.
Плюс отдельный раздел с дисклеймером: сервис информационно‑справочный, статусы — субъективные сообщения пользователей, наличие всегда уточняйте на самой АЗС.

Что дальше

Хороший вопрос, но буду развивать проект, пока в нем есть необходимость и потребность.

Буду благодарен за любую обратную связь!


ссылка на оригинал статьи https://habr.com/ru/articles/1055216/