Капстоун: конкурентный краулер

Перед началом: модули 1–17. Проект собирается из всех паттернов курса, поэтому пропущенный модуль будет заметен. Инструменты: Go 1.22+, go test -race, docker для лимита памяти. Трудоёмкость: ~8–10 ч (проект 6–8 ч · защита 1 ч).

🔥 Инцидент: обход «в лоб»: бан по IP, OOM и 40 тысяч URL без отчёта

Команде нужен был краулер для аудита собственного сайта. Написали «в лоб»: скользящая очередь ссылок, и на каждую — горутина. Через две минуты сайт отдал 429 по всему диапазону: краулер выжимал сотни запросов в секунду, не ограничивая скорость. Параллельно процесс упёрся в память: 40 тысяч горутин одновременно держали ответы и дублировали обход — одна и та же страница встречалась в графе десятки раз.

Переписали: пул воркеров с лимитом, token bucket на хоста, map посещённых под мьютексом, отмена по сигналу и отчёт в конце. Результат: те же 40 тысяч страниц за 12 минут, ровно по лимиту запросов, ноль дублей, память — десятки мегабайт.

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

📖 Теория

18.1 Требования: что значит «краулер готов»

Функциональные:

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

Нефункциональные — те, из-за которых «в лоб» и упало:

  • лимит скорости на хост (иначе бан по IP);
  • ограничение параллелизма (пул воркеров, а не горутина на страницу);
  • отмена и graceful stop по сигналу (обход не должен терять отчёт);
  • детерминизм в тестах: граф-заглушка вместо сети.

18.2 Архитектура: четыре уже знакомых кирпича

Краулер — это конвейер из модуля 6, внутри которого:

  1. Frontier — очередь URL, которые надо обойти. Bounded-канал (модуль 13): переполнение — сигнал, что лимит скорости не успевает, а не повод съесть память.
  2. Пул воркеров (модуль 7) — 3–8 воркеров, каждый забирает URL, делает fetch, отдаёт найденные ссылки.
  3. Visited — map[string]bool под sync.Mutex (модуль 5): проверка и вставка — одна критическая секция, иначе два воркера поставят в очередь один и тот же URL.
  4. Rate limiter (модуль 8) — token bucket на хост: 2 страницы за раунд (в бою — по времени, x/time/rate).

Сверху — отмена: ctx/done, который воркеры проверяют перед fetch (модуль 3), и метрики: страниц обойдено, дублей отброшено, ошибок.

Таблица сборки — какой модуль за что отвечает:

БлокМодульЧто берём
Пул воркеров7jobs/results, лимит, отмена
Очередь frontier13bounded-канал, политика переполнения
Дедупликация5map + mutex, проверка и вставка за один захват
Лимит скорости8token bucket на хост
Ретраи/таймауты9повтор только идемпотентного GET
Отмена и останов3, 11done-канал, drain, отчёт после остановки
Метрики17счётчики вместо гаданий по логам
Race-тесты2go test -race, leak-тест

18.3 Ловушки, на которых спотыкаются все

  • Дедупликация «не в той точке». Проверять visited при извлечении из очереди поздно: дубли уже занимают место в frontier. Проверяйте при вставке — под тем же мьютексом, что и вставка.
  • Отчёт после отмены. Если main вышел, не дождавшись воркеров, часть страниц «обойдена» без учёта. WaitGroup и defer на закрытие отчёта — обязательны.
  • Лимит на весь краулер вместо хоста. Общий bucket защищает вас, но не партнёрские сайты; per-host — правило вежливости и защиты от бана.
  • Ретраи без лимита. Страница, отдающая 500, при ретраях в цикле даёт вечный обход; повторы — только с бюджетом (модуль 9) и только для идемпотентных GET.

18.4 Тесты: сеть заменяем графом

Тестируемость краулера — свойство архитектуры: fetch — это функция func(url string) ([]string, error), поэтому в тестах она подменяется графом-заглушкой (как в сниппете). Проверяем:

  1. Полноту: обойдены все достижимые узлы, ни одного лишнего.
  2. Отсутствие дублей: каждая страница в отчёте один раз.
  3. Отмену: после close(done) новые fetch не начинаются, отчёт содержит то, что успели.
  4. Гонки и утечки: go test -race; после завершения runtime.NumGoroutine() возвращается к базовому уровню (модуль 17).

18.5 Компромиссы

  • Frontier в памяти vs внешняя очередь: в памяти — просто и быстро, но не переживает рестарт; внешняя (Redis/Kafka) — переживает, но добавляет зависимость и задержку.
  • BFS vs произвольный порядок: BFS даёт предсказуемый отчёт («глубина N»), но держит широкий фронт; обход «как получится» дешевле по памяти, но отчёт хуже.
  • Глобальный лимит vs per-host: глобальный проще, но не защищает конкретный сайт; per-host вежливее, но требует состояния на каждый хост.
  • Точность vs скорость: больше воркеров — быстрее, но выше риск бана и больше нагрузки на чужой сервер.

💻 Практика

Проект собирается этапами; каждый этап — проверяемый.

  1. Минимум. Frontier (канал), пул из 3 воркеров, visited под мьютексом, fetch по графу-заглушке. Критерий: все узлы обойдены ровно один раз.
  2. Лимит скорости. Token bucket на хост: не больше 2 страниц за раунд. Критерий: в логе раундов видно темп; при лимите 1 обход занимает вдвое больше раундов, но всё ещё завершается.
  3. Надёжность. Таймаут на fetch (context), retry с бюджетом для временных ошибок, счётчик ошибок в отчёте. Критерий: страница, «падающая» первые два раза, обходится с третьей попытки, а «вечно падающая» — попадает в ошибки и не зацикливает обход.
  4. Останов и отчёт. По сигналу (в тесте — close(done)) воркеры завершают текущие fetch, frontier закрывается, отчёт печатается. Критерий: ни одной горутины после завершения (leak-тест), отчёт содержит visited на момент остановки.
  5. Защита. Покажите: go test -race чист; лимит памяти контейнера не превышен на 40k-графе; отчёт воспроизводим (детерминированный граф → одинаковые цифры).
  6. Запустите сниппет модуля: мини-краулер на 8 страницах — 5 раундов по лимиту 2; проверьте, что в отчёте ровно 8 страниц и ни одной дважды.

🧩 Quiz

1. Где проверять, посещали ли страницу, чтобы не задублировать frontier?
2. Что должен сделать краулер по сигналу остановки?
3. Зачем ограничивать скорость на хост, а не только общий лимит?
4. Почему fetch в тестах заменяется графом-заглушкой?
5. Какой тест ловит горутину, пережившую завершение краулера?

✅ Итоги модуля

Студент должен:

  • Собрать краулер из пула, frontier, visited и лимитера
  • Сделать дедупликацию на вставке, а не на извлечении
  • Ограничить скорость на хост и объяснить выбор лимита
  • Реализовать останов по сигналу с честным отчётом
  • Писать тесты с графом-заглушкой без реальной сети
  • Прогонять -race и leak-тест как часть приёмки
  • Объяснить, какой модуль курса за какой блок отвечает
  • Ответить на все вопросы Quiz

🔗 Мостик: дальше по платформе

Курс закончился, навык — нет. Куда идти дальше:

  • mq-track — брокеры всерьёз: гарантии доставки, Outbox, Saga, consumer groups. Конкурентные консьюмеры отсюда станут распределёнными, а идемпотентность — обязательной.
  • pg-clickhouse — конкурентность встречается с данными: пулы, параллельные запросы, OLTP → OLAP.
  • go-advanced m3 — распределённые паттерны: там ваши circuit breaker и retry поднимутся с уровня вызова на уровень системы.

И привычка, которую стоит забрать из курса: любой конкурентный код проверяется -race, любой «фоновый» — на утечки, любой внешний вызов — таймаутом, лимитом и отменой.