Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Масштабируемость
scalability_review

Масштабируемость

Кэширование, очереди, rate limiting, горизонтальное масштабирование

Масштабируемость в Code Review

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

#Результат урока

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

#1. Состояние в процессе

Самая частая проблема масштабируемости в диффе — данные, живущие в памяти приложения.

# ❌ работает на одном экземпляре, ломается на двух SESSIONS: dict[str, int] = {} RATE_LIMITS: dict[str, int] = defaultdict(int) UPLOAD_PROGRESS: dict[str, float] = {}

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

То же относится к файлам на локальном диске: аватар, сохранённый в /var/www/uploads, виден только тому экземпляру, который его записал.

Признаки в диффе: модульная переменная-словарь, @lru_cache на функции, зависящей от пользовательских данных, global, запись в локальную файловую систему, планировщик внутри приложения (APScheduler, setInterval) — он выполнится столько раз, сколько экземпляров запущено.

# ✅ состояние во внешнем хранилище await redis.setex(f"session:{sid}", 3600, user_id) await redis.incr(f"rate:{ip}:{minute}") await s3.put_object(Bucket=BUCKET, Key=key, Body=data)

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

Проверьте себя. Найдите в проекте модульные переменные, которые изменяются во время работы. Что случится со вторым экземпляром?

Частая ошибка. Считать, что «мы всё равно запускаем один экземпляр». Второй появится при первой же выкатке без простоя — во время неё старый и новый работают одновременно.

#2. Кэш: вопрос не «есть», а «как инвалидируется»

Добавление кэша в PR почти всегда выглядит улучшением. Работа ревьюера — задать три вопроса, на которые часто нет ответа.

Что делает кэш неверным? Каждый кэш — обещание, что данные не изменились. Нужно назвать все места, где они меняются, и убедиться, что каждое сбрасывает кэш. Пропущенное место означает, что пользователь будет видеть устаревшие данные, и починить это перезапуском нельзя.

# ❌ кэш заполняется, но нигде не сбрасывается: смена имени # не будет видна в течение часа def get_user_profile(user_id: int) -> dict: key = f"profile:{user_id}" if cached := redis.get(key): return json.loads(cached) profile = build_profile(user_id) redis.setex(key, 3600, json.dumps(profile)) return profile

Что происходит при промахе у всех одновременно? Ключ истёк, тысяча запросов увидела промах, тысяча пошла в базу. Это лавина запросов, и она обычно случается ровно в момент пиковой нагрузки, потому что кэш заполнялся тоже в пик.

# ✅ загружает только один, остальные ждут или отдают устаревшее def get_user_profile(user_id: int) -> dict: key = f"profile:{user_id}" if cached := redis.get(key): return json.loads(cached) lock = redis.lock(f"lock:{key}", timeout=10, blocking_timeout=3) if lock.acquire(blocking=True): try: if cached := redis.get(key): # повторная проверка return json.loads(cached) profile = build_profile(user_id) redis.setex(key, 3600, json.dumps(profile)) return profile finally: lock.release() return build_profile(user_id) # не дождались — считаем сами

Альтернатива проще и часто лучше: хранить с двумя сроками — отдавать слегка устаревшее значение сразу и обновлять его в фоне. Пользователь никогда не ждёт.

Что происходит, если кэш недоступен? Redis упал — приложение продолжает работать медленнее или падает целиком? Второе означает, что вы добавили кэш и одновременно новую точку отказа.

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

Частая ошибка. Полагаться только на срок жизни вместо явного сброса. Это работает, но означает, что пользователь видит устаревшие данные всё это время, — иногда допустимо, но должно быть решением, а не побочным эффектом.

#3. Очереди: доставка, порядок, повторы

Вынос долгой работы в очередь — правильное решение, порождающее четыре вопроса.

Сколько раз задача выполнится? Практически все брокеры дают «минимум один раз». Значит, обработчик обязан быть идемпотентным. Задача «списать деньги», выполненная дважды из-за перезапуска воркера, — инцидент.

Что попадает в задачу? Передавать объект целиком — ошибка: пока задача ждёт в очереди, данные изменятся, и обработчик применит устаревшие. Передавайте идентификатор и читайте актуальное состояние.

# ❌ снимок данных на момент постановки send_email_task.delay(user=user.to_dict(), order=order.to_dict()) # ✅ идентификаторы send_email_task.delay(user_id=user.id, order_id=order.id)

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

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

Проверьте себя. Найдите в проекте задачу, которая переводит деньги или отправляет сообщение. Что будет при повторном выполнении?

Частая ошибка. Считать очередь способом «сделать быстрее». Она делает ответ быстрее, а систему — сложнее: добавляется отложенность, повторы и новые состояния.

#4. Пределы и поведение при отказе

Масштабируемость — это в первую очередь предсказуемое поведение при исчерпании ресурса.

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

Ограничение частоты. Проверять надо не только наличие, но и место хранения счётчика (внешнее, а не в памяти) и то, что возвращается при превышении: 429 с заголовком Retry-After, а не 500 и не тихое отбрасывание.

Поведение при отказе зависимости. На каждый внешний вызов в диффе — вопрос: что произойдёт, если он не ответит. Ответ «упадём» иногда правильный, но должен быть выбран, а не получен по умолчанию.

# ❌ рекомендательный сервис недоступен → страница товара не открывается recommendations = recommender.get(product_id) # без таймаута return render(product=product, recommendations=recommendations) # ✅ второстепенное отключается, главное работает try: recommendations = await recommender.get(product_id, timeout=0.3) except (TimeoutError, ServiceUnavailable): recommendations = []

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

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

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

Частая ошибка. Ставить retries=3 без задержки и разброса. Это не отказоустойчивость, а усилитель перегрузки.

#5. Разбор: PR «Лента рекомендаций на главной»

Дифф на 50 строк, локально страница открывается за 80 мс.

+FEED_CACHE: dict[int, list[dict]] = {} +VIEWS_COUNTER: dict[int, int] = defaultdict(int) + + +@app.get("/feed") +def get_feed(user=Depends(current_user)): + if user.id in FEED_CACHE: + return FEED_CACHE[user.id] + + profile = requests.get(f"{ML_SERVICE}/profile/{user.id}").json() + products = Product.objects.filter(category__in=profile["categories"])[:100] + + feed = [] + for product in products: + VIEWS_COUNTER[product.id] += 1 + feed.append({ + "id": product.id, + "title": product.title, + "price": product.price, + "rating": requests.get(f"{REVIEWS}/rating/{product.id}").json()["avg"], + }) + + FEED_CACHE[user.id] = feed + return feed + + +@app.post("/internal/flush-views") +def flush_views(): + for product_id, count in VIEWS_COUNTER.items(): + Product.objects.filter(id=product_id).update(views=F("views") + count) + VIEWS_COUNTER.clear() + return {"flushed": len(VIEWS_COUNTER)}

Что видит ревьюер. Проверим на двадцати экземплярах и на реальной нагрузке.

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

VIEWS_COUNTER — та же проблема плюс потеря данных: при перезапуске экземпляра накопленные просмотры исчезают. А flush_views — публично доступный эндпоинт (префикс /internal/ ничего не запрещает), который вызывается на одном экземпляре и сбрасывает счётчики только этого экземпляра.

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

И return {"flushed": len(VIEWS_COUNTER)} после .clear() всегда вернёт ноль — мелочь, но показательная.

Комментарии в PR:

feed.py:1 · blocker FEED_CACHE и VIEWS_COUNTER — состояние в памяти процесса. На нескольких экземплярах: у пользователя разная лента в зависимости от того, куда попал запрос, а просмотры теряются при каждой выкатке. Плюс FEED_CACHE никогда не очищается — это утечка памяти на десятках тысяч пользователей. Нужен Redis: лента с TTL, счётчик через INCR.

feed.py:18 · blocker Запрос рейтинга внутри цикла по ста товарам — сто последовательных вызовов сервиса отзывов на одну загрузку ленты, без таймаута. При замедлении сервиса до 200 мс лента собирается 20 секунд; при его недоступности главная страница не открывается. Нужен один пакетный запрос с таймаутом и деградацией: нет рейтинга — показываем ленту без него.

feed.py:26 · blocker /internal/flush-views доступен извне: префикс в пути ничего не ограничивает. Любой может сбросить счётчики. Нужна аутентификация, а лучше — вообще не эндпоинт, а периодическая задача. С переходом на INCR в Redis этот механизм не нужен совсем.

feed.py:11 · major Запрос к ML-сервису без таймаута. Он единственный обязательный в этом сценарии, поэтому таймаут нужен вместе с решением, что показывать при отказе: популярные товары как запасной вариант или явное сообщение.

feed.py:8 · major Кэш не инвалидируется ничем, кроме перезапуска. Даже после переезда в Redis нужно решить: TTL в пять минут или явный сброс при изменении цены и остатка. Для ленты рекомендаций TTL, вероятно, достаточно — но это должно быть записанным решением.

feed.py:30 · minor len(VIEWS_COUNTER) считается после .clear() — всегда вернёт ноль.

feed.py:12 · question Что при промахе кэша у всех сразу — например, после выкатки? Сейчас каждый запрос пойдёт в ML-сервис и сделает сто запросов к отзывам. Стоит прогревать ленту заранее или ограничить одновременные сборки.

Чем закончилось.

@app.get("/feed") async def get_feed(user=Depends(current_user)): key = f"feed:{user.id}" if cached := await redis.get(key): return json.loads(cached) try: profile = await ml.get_profile(user.id, timeout=0.5) categories = profile["categories"] except (TimeoutError, ServiceUnavailable): categories = user.recent_categories or DEFAULT_CATEGORIES # деградация products = list( Product.objects.filter(category__in=categories, in_stock=True)[:100] ) ratings = await reviews.get_ratings_batch( # один запрос вместо ста [p.id for p in products], timeout=0.3, default={} ) feed = [ {"id": p.id, "title": p.title, "price": p.price, "rating": ratings.get(p.id)} for p in products ] await redis.setex(key, 300, json.dumps(feed)) await redis.execute_command("INCRBY", *view_counter_args(products)) return feed

Эндпоинт сброса удалён: INCR в Redis не требует накопления в памяти, а перенос в базу делает периодическая задача. Прогрев ленты для активных пользователей добавили отдельным тикетом — вопрос про одновременный промах оказался обоснованным: после первой выкатки без прогрева ML-сервис получил пик в тридцать раз выше обычного.

Главное: ни одна из проблем не была видна локально. Все они — ответы на вопрос «а если экземпляров двадцать и зависимость отвечает медленно».

#6. Чек-лист

СОСТОЯНИЕ ничего изменяемого в памяти процесса и на локальном диске ПЛАНИРОВЩИК не внутри приложения — иначе выполнится N раз КЭШ назван каждый источник изменения; продуман сброс и лавина промахов ОТКАЗ КЭША приложение работает медленнее, а не падает ОЧЕРЕДИ обработчик идемпотентен; передаются идентификаторы; есть предел попыток ПРЕДЕЛЫ заданы явно: размер тела, число элементов, одновременность, время ЧАСТОТА ограничитель во внешнем хранилище; при превышении 429 и Retry-After ТАЙМАУТЫ на каждом внешнем вызове, с решением о поведении при отказе ДЕГРАДАЦИЯ второстепенное отключается, не роняя главное ПОВТОРЫ с экспоненциальной задержкой и разбросом

#Что дальше

Гонки и идемпотентность подробнее — Асинхронность и конкурентность. Индексы и транзакции под нагрузкой — Работа с БД. Базовые проблемы производительности — Рецензирование производительности.


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

Далее: Возможности рефакторинга