Архитектурные паттерны для высокой масштабируемости. Часть 1

от автора


1. Предисловие

Шаблоны такие шаблонные:

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

    Какие недостатки имеет такой подход:

    1. Сложность разработки.

      • Вместо одной монолитной системы вы создаете множество небольших сервисов, каждый из которых нужно проектировать, разрабатывать, тестировать и разворачивать отдельно.

      • Появляется необходимость в четком определении границ ответственности (bounded contexts), что требует глубокого анализа бизнеса. Если границы изменяются, то микросервисы очень сложно переделать.

    2. Усложнение инфраструктуры.

      • Для управления микросервисами требуется развитая инфраструктура: оркестрация контейнеров (например, Kubernetes), системы мониторинга (Prometheus, Grafana), централизованное логирование (ELK-стек или аналог), сервисы API Gateway и балансировки нагрузки.

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

    3. Высокие требования к DevOps и сложности в поддержке.

      • Чтобы микросервисы работали стабильно, нужны автоматизация CI/CD, управление версиями, мониторинг, контейнеризация (Docker и т. д.), а также устойчивость к отказам.

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

      • Трудно отслеживать и исправлять баги в распределенной системе, особенно без инструментов трассировки (например, Jaeger, OpenTelemetry)

    4. Проблемы с производительностью.

      • Высокие сетевые задержки

      • Сериализация/десериализация данных (например, JSON, Protobuf) также добавляет накладные расходы

    5. Безопасность.

      • Микросервисы увеличивают количество точек входа для потенциальных атак, поскольку каждый сервис может иметь собственные уязвимости и требует отдельной защиты.

      • Сложность управления доступом и аутентификации между сервисами

    Вот сколько недостатков!

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

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

Многабукф, разбор частных случаев убрал под кат.

Что бы я ожидал от архитектора на примере магазина уровня М-Видео

Архитектурное решение для онлайн-магазина

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


Разделение доменных областей и баз данных

Домен

Решение

База Данных

Каталог товаров

Модуль в монолите

Общая БД с Складом

Заказы

Выделенный микросервис

Отдельная БД

Платежи

Выделенный микросервис

Отдельная БД

Склад

Модуль в монолите

Общая БД c Каталогом

Пользователи

Модуль в монолите (без репликации)

Общая БД с Уведомлениями

Уведомления

Воркеры в монолите

Общая БД с Пользователями

Аналитика

Выделенный микросервис или модуль в монолите

Отдельная БД (например, специализированная аналитическая), CQRS

Ключевые особенности архитектуры

  1. Два экземпляра Монолита и Балансировщик

    • Повышение отказоустойчивости и распределение нагрузки.

    • Лёгкое горизонтальное масштабирование при росте трафика.

  2. Выделенные Микросервисы (Заказы, Платежи, Аналитика)

    • Независимое развитие, масштабирование по горизонтали.

    • Снижение нагрузки на монолит, упрощение соблюдения норм безопасности для платежей.

  3. Отсутствие Репликации для Пользователей

    • Упрощает архитектуру при невысокой нагрузке на этот домен.

    • Достаточно одного экземпляра базы для сохранения профилей и аутентификации.

  4. Уведомления во Внутренних Воркерах

    • Асинхронная модель обработки событий снижает задержки и повышает отказоустойчивость.

    • Меньше сетевых overhead при доступе к профилям пользователей.

  5. Отдельная Аналитическая БД

    • Позволяет эффективно обрабатывать большие объёмы исторических данных, формировать метрики и отчёты.

    • Отделение от основных транзакционных баз улучшает общую производительность системы.


Вывод

Данная архитектура сочетает простоту управления монолитными модулями (Каталог, Склад, Пользователи, Уведомления) и выигрышные аспекты микросервисного подхода (Заказы, Платежи, Аналитика). Горизонтальное масштабирование достигается запуском нескольких экземпляров монолита и выделением микросервисов с независимыми базами. Такой подход обеспечивает высокую производительность, надёжность и удобство сопровождения онлайн-магазина.

Немножко рефлексии и обоснования выбора

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

  1. Доводы для отрезания домена

  • требования к безопасности (платежи)

  • масштабирование бизнес-логики (не СУБД) выше чем позволяет вертикальное масштабирование и несколько серверов для инстансов монолита, то есть от 200-500 vCPU, но вам это не нужно этот фактор переоценен и решает только для систем очень большого масштаба

  1. Доводы против отрезания домена

  • важна низкая задержка при взаимодействии между доменами

  • имеется большой поток данных между доменами

  • высокая стоимость разработки, сопровождения

  • проблемы при изменении границ домена, особенно в начале жизненного цикла системы, когда бизнес процессы еще не сложились

  1. Ускоряем монолит

  • кеширование, шардирование и репликация БД

  • CQRS

  • no ACID

Плохие подходы:
❌ «Давайте сделаем всё микросервисами, это же модно»
❌ «Разделим по доменам, а там разберемся»
❌ «Начнем резать, а транзакции потом как-нибудь решим»

Хороший подход:
✅ Сначала анализ данных и транзакций
✅ Затем группировка по критичным связям
✅ И только потом решение об отделении сервисов

Масштаб побольше и проблемы побольше

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

  1. Домен: Каталог товаров

    Разделение на поддомены:

  • Основной каталог (товары, категории)

  • Ценообразование (цены, акции, скидки)

  • Контент-менеджмент (описания, фото) БД:

  • Отдельная БД для каждого поддомена

  • Репликация read-only копий

  • Redis для кэширования

  • ElasticSearch для поиска

  1. Домен: Заказы

    Разделение на поддомены:

  • Онлайн-заказы

  • Кассовые операции

  • Возвраты

  • История заказов БД:

  • Шардированная БД по регионам

  • Архивная БД для истории

  • Очереди для обработки

  1. Домен: Платежи

    Разделение на поддомены:

  • Онлайн-платежи

  • Кассовые платежи

  • Корпоративные расчеты

  • Бухгалтерия БД:

  • Отдельные БД для каждого типа платежей

  • Специализированная БД для бухгалтерии

  1. Домен: Склад

    Разделение на поддомены:

  • Центральный склад

  • Региональные склады

  • Магазины

  • Логистика БД:

  • Отдельные БД для каждого региона

  • Time-series БД для движения товаров

  • Graph БД для логистической оптимизации

  1. Домен: Пользователи

    Разделение на поддомены:

  • Клиенты

  • Сотрудники

  • Программа лояльности

  • Права доступа БД:

  • Географически распределенная БД клиентов

  • Отдельная защищенная БД сотрудников

  • БД программы лояльности

  1. Домен: Уведомления

    Разделение на поддомены:

  • Клиентские уведомления

  • Внутренние коммуникации

  • Маркетинговые рассылки БД:

  • Queue-oriented storage

  • Архив коммуникаций

  1. Домен: Аналитика

    Разделение на поддомены:

  • Операционная аналитика

  • Финансовая аналитика

  • Маркетинговая аналитика

  • Прогнозирование БД:

  • Data Warehouse

  • OLAP кубы

  • Data Lake

  1. Новые домены:

  • Поставщики (отдельная БД)

  • Маркетинг и акции (отдельная БД)

  • Производство (для собственных торговых марок)

  • Качество и сертификация

Итого около 20-25 баз данных, сгруппированных по:

  1. Функциональному назначению

  2. Географическому признаку

  3. Требованиям к производительности

  4. Требованиям к безопасности

Особенности:

  • Географическое шардирование

  • Мульти-датацентр развертывание

  • Различные типы БД под разные задачи

  • Сложная система репликации

  • Отказоустойчивые кластеры

  • Системы мониторинга и алертинга

  • Отдельные среды разработки/тестирования

Дополнительные направления, которые я не учел:

  • Разработка кассового ПО

  • Системы видеонаблюдения и безопасности

  • Системы управления персоналом

  • Системы планирования ресурсов

  • Мобильные приложения для разных брендов

  • Системы автоматизации складов

  • Системы прогнозирования спроса

  • Системы контроля качества

  • Системы управления транспортом

  • Системы электронного документооборота

  • IoT решения (холодильники, датчики и т.д.)

  • Системы энергоэффективности

Эволюция архитектуры по мере роста масштаба

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

Начальные условия

  1. Текущая нагрузка: 500 запросов в секунду (QPS) на чтение, 100 QPS на запись.

  2. Ожидаемый рост нагрузки: 20% в месяц.

  3. Текущая база данных: монолитная, с репликацией для чтения.

  4. Операционные затраты на поддержку монолитной БД: 10 часов в неделю.

Эволюционный подход

Шаг 1: Репликация и шардирование (временная экономия 3–6 месяцев)

  1. Репликация: уменьшаем нагрузку на основную БД на 30% (чтение).

  2. Шардирование: уменьшаем нагрузку на основную БД на 20% (запись).

  3. Операционные затраты: +20% (добавляем управление репликацией и шардированием).

Шаг 2: Разделение БД по доменам (через 6–12 месяцев)

  1. Разделение: создаем отдельные БД для каталога, заказов и корзины.

  2. Масштабируемость: каждый домен можно масштабировать независимо.

  3. Операционные затраты: +50% (добавляем управление несколькими БД).

Издержки и преимущества

Издержки

  1. Избыточная работа: при разделении БД по доменам часть работы по настройке репликации и шардирования может оказаться избыточной.

  2. Сложности миграции: потребуется мигрировать данные из монолитной БД в отдельные БД по доменам.

Преимущества

  1. Быстрая оптимизация: репликация и шардирование быстро улучшают производительность.

  2. Гибкость: разделение БД по доменам позволяет масштабировать каждый домен независимо.

  3. Улучшенная поддержка: операционные затраты на поддержку отдельных БД могут быть ниже, чем на поддержку монолитной БД.

Цифровое сравнение

Сценарий

Нагрузка (QPS)

Операцион-ные затраты (часов/неделю)

Издержки

Преиму-щества

Монолитная бизнес логика и данные

500 (чтение), 100 (запись)

10

Репликация + шардирование

350 (чтение), 80 (запись)

12

Избыточная работа

Быстрая оптимизация

Разделение БД по доменам

200 (чтение), 50 (запись)

15

Сложности миграции

Гибкость, улучшенная поддержка

Выводы

Переход на сложные паттерны одним скачком приводят к большим рискам и длительным срокам до появления первых результатов.

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

Чтобы минимизировать издержки, рекомендуется:

  1. Планировать архитектуру: заранее продумать структуру БД и потенциальные точки роста.

  2. Автоматизировать процессы: использовать инструменты для автоматизации управления БД и миграции данных.

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

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

Вот эти инструменты:

  • Шардирование

  • Репликация

  • Кеширование

  • CQRS

  • Event sourcing

  • Ослабление требований ACID

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


2. Распространение данных по распределенной системе

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

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

  • не требовать одновременной консистентности во всей системе (event sourcing, cqrs, no acid)

  • требовать консистентности данных, но лишь в ограниченных областях системы (bounded context, например в микросервисах) и ограничить распространение данных за их пределы (только по API и т.д.)

  • не блокировать данные в ожидании консистентности и проверять их уже после изменений с возможностью обратных действий (паттерн сага)

  • отказ от распределенной архитектуры или объединение неудачно разделенных микросервисов

По существу, все архитектурные паттерны выигрывают в быстродействии за счет допущения неполной консистентности данных. И это основной trade-off.

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

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


3. Шаблоны data-bounded масштабирования

Шардирование (sharding)

Смысл шардирования и как оно повышает производительность

Шардирование — это метод разделения данных на независимые части (шарды), которые хранятся на разных серверах. Это помогает распределить нагрузку, ускорить запросы и избежать узких мест.

Примеры

Шардирование по пользователю

Данные изолируются по идентификатору пользователя (например, user_id).

  • Какие данные изолируются: профиль, заказы, сообщения.

  • Как повышает производительность: равномерное распределение нагрузки между шардами, уменьшение объема данных на каждом сервере, параллельная обработка запросов.

  • Пример: Социальная сеть, где данные каждого пользователя хранятся отдельно.

Шардирование по типу данных

Данные разделяются по их назначению (например, заказы в одном шарде, инвентарь — в другом). Но бизнес-логика еще может работать в монолите

  • Какие данные изолируются: заказы, товары, остатки.

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

  • Пример: Интернет-магазин, где операции с заказами и инвентарем не конфликтуют.

Шардирование по времени

Данные изолируются по временным интервалам (например, логи по месяцам).

  • Какие данные изолируются: журналы, метрики, архивы.

  • Как повышает производительность: свежие данные обрабатываются быстрее, старые шарды можно архивировать, запросы работают с меньшими объемами данных.

  • Пример: Система логирования, где запросы обычно касаются недавних событий.

Как это работает в общем случае

Шардирование снижает нагрузку на отдельные сервера за счет:

  • Увеличения параллелизма.

  • Ускорения запросов за счет работы с меньшими объемами данных.

Когда использовать: когда данных слишком много для одного сервера или нагрузка слишком высока.

CQRS

CQRS — это архитектурный паттерн, который разделяет:

  1. Команды (Commands) — операции изменения состояния (например, добавление товара в корзину, перевод денег).

    • Ответственность: Модификация данных.

    • Модель данных: Оптимизирована для записи.

  2. Запросы (Queries) — операции чтения (например, получение списка товаров, отображение баланса).

    • Ответственность: Извлечение данных.

    • Модель данных: Оптимизирована для чтения.

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

Trade-off: мы жертвуем консистентностью данных, создавая еще одну базу редко изменяемых и, возможно, уже устаревших данных Мы перекидываем нагрузку на чтение на новую БД там, где высокая актуальность не требуется. Например, паттерн активно используется для аналитических систем где устаревание данных на несколько минут или часов некритично.

Event sourcing

Вместо хранения текущего состояния объекта, сохраняются все его изменения в виде последовательности событий. Раз мы не храним данные, то мы можем не заботиться об их консистентности и не ждать пока эти данные распространятся и станут одинаковыми во всей системе. Ну а если понадобится узнать состояние, то мы восстановим его по запросу в любой момент времени.

Trade-off: мы жертвуем консистентностью данных состояния, его не храним совсем, но можем узнать/вычислить состояние когда это понадобится.

Event Sourcing особенно эффективен в сценариях, где:

  1. Чтение текущего состояния происходит реже чем запись (например, логирование)

  2. Важна возможность восстановить состояние системы (например, платежная подсистема)

Event sourcing + CQRS

Как они работают совместно?

  1. Часть записи (Commands):

    • События (events) записываются в журнал событий (event store) как результат обработки команд (например, «Добавить товар в корзину»).

    • Текущее состояние системы (например, корзина) не обновляется напрямую. Вместо этого всё состояние можно восстановить из последовательности событий.

  2. Часть чтения (Queries):

    • Для быстрого чтения (например, отображения корзины) используются проекции.

    • Проекции (read models) строятся асинхронно на основе событий, записанных в event store.

    • Эти проекции представляют агрегированное или предварительно вычисленное состояние, оптимизированное для запросов.

Кэширование

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

Где используется кэширование

  1. На стороне приложения. Локальный кэш в памяти для хранения часто запрашиваемых данных.

    • Пример: Кэширование профиля пользователя или настроек.

  2. На уровне базы данных. Инструменты вроде Redis или Memcached хранят результаты запросов, чтобы избежать повторных обращений к медленной БД.

    • Пример: Список популярных товаров в интернет-магазине.

  3. На уровне сети. CDN (Content Delivery Network) кэширует статический контент (изображения, видео) близко к пользователю.

    • Пример: Быстрая загрузка веб-страниц.

Как повышает производительность

  • Ускоряет доступ к данным. Позволяет обращаться к кэшу вместо медленных операций чтения из базы.

  • Снижает нагрузку на сервер. Уменьшает количество запросов к БД или API.

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

Проблемы

  1. Согласованность данных: Обеспечение однородности данных на всех узлах.

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

  3. Масштабируемость: Координация инвалидации при большом числе узлов усложняется.

  4. Надёжность: Гарантия доставки сообщений об инвалидации даже при отказах.

  5. Оверхед на коммуникацию: Частые обновления приводят к повышенному сетевому трафику.

Стратегии инвалидации кеша

  1. Time-to-Live (TTL): Кеш автоматически истекает через заданное время. Просто, но возможны устаревшие данные.

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

  3. Write-through/Write-back:

    • Write-through: Синхронная запись в кеш и хранилище.

    • Write-back: Асинхронная запись из кеша в хранилище.

  4. Версионность: Использование версий данных для проверки актуальности кеша.

  5. Pub/Sub Модель: Узлы подписываются на события изменений и получают уведомления.

  6. Локальная и глобальная инвалидация: Комбинация локальных очисток и глобальных обновлений.

  7. Самоинвалидация кеша: Кеш самостоятельно отслеживает изменения и обновляется.

Когда использовать

  • Данные часто запрашиваются, но редко изменяются (например, справочники, профили пользователей).

  • Результаты запросов требуют сложных вычислений или их выполнение занимает много времени.

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

Ослабление требований к ACID / no ACID

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

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

Автор теоремы CAP использует акроним BASE для описания не совсем ACID систем

  • Basically Available

  • Soft state

  • Eventual consistency

Варианты отказа от части ACID для обеспечения скорости

  1. Consistency (согласованность):

    • Заменяется на eventual consistency, где данные становятся согласованными со временем.

    • Пример: В распределенных базах данных (DynamoDB, Cassandra) данные между репликами синхронизируются асинхронно.

  2. Isolation (изоляция):

    • Ослабление изоляции позволяет выполнять транзакции параллельно, даже если они временно конфликтуют.

    • Пример: Использование уровней изоляции «Read Committed» или «Read Uncommitted».

  3. Durability (устойчивость):

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

    • Пример: Redis допускает потерю данных при сбое.

No ACID могут быть и традиционные RDBMS системы в определенных сценариях

Примеры «no-ACID» транзакций в PostgreSQL для ускорения работы

PostgreSQL, будучи реляционной СУБД, по умолчанию стремится к соблюдению ACID. Однако, существуют способы ослабить некоторые свойства для повышения производительности в определенных сценариях:

  1. UNLOGGED таблицы:

    • Создание таблиц с ключевым словом UNLOGGED отключает запись изменений в WAL (Write-Ahead Log). Это значительно ускоряет операции записи, так как не требуется накладных расходов на журналирование.

    • Ослабление: Durability. В случае сбоя сервера данные в UNLOGGED таблицах будут потеряны.

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

  2. Асинхронная фиксация (synchronous_commit = off или local):

    • По умолчанию PostgreSQL ожидает записи WAL на диск перед подтверждением транзакции (синхронная фиксация). Установка synchronous_commit в off или local позволяет серверу подтверждать транзакции до фактической записи на диск.

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

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

  3. Уровень изоляции READ UNCOMMITTED:

    • Позволяет транзакции видеть незафиксированные изменения других транзакций («грязное чтение»).

    • Ослабление: Isolation. Может привести к чтению несогласованных данных.

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

Когда использовать

  • Высоконагруженные распределенные системы (NoSQL и SQL базы данных).

  • Сценарии, где скорость важнее строгой согласованности (социальные сети, системы аналитики).

Итог: Ослабление ACID — это компромисс между производительностью и строгими гарантиями консистентности.

Data Denormalization

Нарушение нормальных форм для уменьшения количества соединений (JOIN) в запросах, что может значительно повысить скорость выполнения запросов за счет увеличения объема данных.

В следующей серии (будет опубликовано 12.01.2025 11:00):

  • Последний из data-bounded шаблонов масштабирования: микросервисы

  • О природе ограничений data-bounded масштабируемости. Осторожно, матан!

  • Шаблоны CPU-bounded масштабирования

  • Практические выводы

https://habr.com/ru/articles/871784/

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

Какую статью про архитектурные паттерны написать еще?

75.61% Паттерны борьбы со сложностью, т.е. способности развивать и поддерживать систему при росте объема кода и/или количества разработчиков31
34.15% Паттерны обеспечения безопасности14
21.95% Дописать или детализировать эту статью9
7.32% Креатив Гениальный, Автор Молодец (но больше не пиши)3
9.76% Просто хочу посмотреть ответы4

Проголосовал 41 пользователь. Воздержались 5 пользователей.

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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *