Forwarded from Julia Reznichenko
Внимание, у нас важная новость - мы запустили пилотную программу по системному анализу! 🚀
И это не очередной «курс» на просторах интернета - это безопасное пространство, чтобы попробовать, ошибиться, разобраться и увидеть, как все устроено изнутри.
Мы сделали упор на практику и живую поддержку наставников.
Что будем делать?
🔹 Погрузимся в основы, которые реально работают в проектах.
🔹 Разберем живую систему и создадим настоящую документацию.
🔹 Проведем авторские мастер-классы - на них можно будет потрогать технологии руками и увидеть, как они работают изнутри.
🤗 Учимся без перегруза: в формате индивидуальных разборов, небольших встреч и лёгких игр. И, конечно, даём обратную связь по каждому заданию.
Это пилот, и в будущем мы планируем набирать только маленькие группы до 5 человек. Нам важен результат и реальный опыт каждого 💪
Мы долго готовились, чтобы поделиться тем, что проверили годами практики!
Первый поток ведет лично наш руководитель отдела системного анализа Оксана Соболевская ❤️
У Оксаны за плечами многолетний опыт в анализе и целый отдел аналитиков, который она создала с нуля!
Следите за обновлениями: обязательно расскажем, как все проходит, и о следующих наборах тоже сообщим - у нас уже открыт лист ожидания😉
❗️Если ты тоже хочешь в нем оказаться, пиши нам на почту hr@evapps.ru
И это не очередной «курс» на просторах интернета - это безопасное пространство, чтобы попробовать, ошибиться, разобраться и увидеть, как все устроено изнутри.
Мы сделали упор на практику и живую поддержку наставников.
Что будем делать?
🔹 Погрузимся в основы, которые реально работают в проектах.
🔹 Разберем живую систему и создадим настоящую документацию.
🔹 Проведем авторские мастер-классы - на них можно будет потрогать технологии руками и увидеть, как они работают изнутри.
🤗 Учимся без перегруза: в формате индивидуальных разборов, небольших встреч и лёгких игр. И, конечно, даём обратную связь по каждому заданию.
Это пилот, и в будущем мы планируем набирать только маленькие группы до 5 человек. Нам важен результат и реальный опыт каждого 💪
Мы долго готовились, чтобы поделиться тем, что проверили годами практики!
Первый поток ведет лично наш руководитель отдела системного анализа Оксана Соболевская ❤️
У Оксаны за плечами многолетний опыт в анализе и целый отдел аналитиков, который она создала с нуля!
Следите за обновлениями: обязательно расскажем, как все проходит, и о следующих наборах тоже сообщим - у нас уже открыт лист ожидания😉
❗️Если ты тоже хочешь в нем оказаться, пиши нам на почту hr@evapps.ru
❤1
Сегодня завершим нашу линейку постов про транзакции.
🚨 1) Внешние действия внутри транзакции = проблемы
Любой внешний вызов внутри транзакции может выполниться два и более раз, потому что Laravel автоматически повторяет транзакцию при дедлоках и таймаутах.
DB::transaction($callback, $attempts) сам делает retry, если произошёл:
- дедлок
- lock wait timeout
- потеря блокировки
И важно: При повторе он запускает весь callback с нуля.
Он не понимает, что там было «одноразовым», а что идемпотентным.
Что под раздачу попадает:
- отправка писем
- пуши
- внешние HTTP-запросы
- интеграции (CRM, платёжки)
Если транзакция упала и Laravel её перезапустил → действие повторится, потому что MySQL откатывает только свои изменения, а внешние вызовы уже произошли и не откатываются.
Как избежать:
- всё внешнее — только после commit
- использовать afterCommit() у моделей или dispatch(fn)->afterCommit() — так действие выполняется один раз, после успешной фиксации данных.
🔒 2) Дедлоки из-за порядка блокировок
Дедлок (deadlock) — ситуация, когда две и более транзакций ожидают друг друга бесконечно, и MySQL вынужден прервать одну из них, чтобы система продолжила работать.
Чаще всего дедлоки возникают не из-за MySQL как такового, а из-за разного порядка, в котором код блокирует строки или ресурсы.
Пример типичной ситуации:
- Транзакция A начинает и лочит строки 5 → 10
- Транзакция B начинает и лочит строки 10 → 5
MySQL обнаруживает тупик и прерывает одну транзакцию с ошибкой Deadlock found.
Как снизить риск:
1. Всегда блокировать строки в одном порядке
Обычно по возрастанию ID, чтобы исключить циклические ожидания.
2. Использовать блокировки корректно
- FOR UPDATE — эксклюзивная блокировка для изменения
- sharedLock() — блокировка для чтения без мешающих других читателей
3. Минимизировать время удержания блокировок
Чем быстрее транзакция завершится, тем ниже шанс дедлока.
4. Разделять независимые операции
Операции по разным таблицам или сущностям лучше выполнять в отдельных транзакциях, чтобы не увеличивать зону возможного конфликта.
🌐 3) Когда транзакции не работают вообще
Стандартные транзакции работают только в рамках одной базы данных и одного соединения.
Если операция затрагивает:
- несколько сервисов (например, микросервисы с разной логикой),
- несколько БД (разные инстансы, разные схемы),
- внешние API (CRM, платёжные системы, сторонние интеграции),то гарантировать атомарность стандартной транзакцией невозможно.
Причина: транзакция контролирует только изменения внутри конкретного движка базы данных.
Внешние действия, например HTTP-запросы или вызовы другой БД, не могут быть откатаны автоматически при ошибке — их состояние уже “зафиксировано” в сторонней системе.
🔹4) Стандартный подход — Saga pattern
Saga pattern — это архитектурный паттерн для управления распределёнными транзакциями.
Его идея проста:
1.Каждый шаг — независимый
Каждое действие выполняется как отдельная транзакция, которая гарантированно сохраняет свои изменения в локальной БД.
2. Компенсирующие действия
Для каждого шага создаётся обратная операция, которая может откатить изменения, если последующий шаг не удался.
Например, для биллинга:
Шаг 1: резервируем средства на счёте клиента (локальная транзакция)
Шаг 2: создаём заказ в системе (локальная транзакция)
Шаг 3: уведомляем склад о сборкеЕсли шаг 2 падает, шаг 1 компенсируется: средства возвращаются на счёт клиента.
Преимущества Saga pattern:
- Нет необходимости в глобальной межсервисной транзакции, которая блокировала бы все сервисы.
- Процесс становится устойчивым к сбоям: каждая ошибка обрабатывается локально и компенсируется.
- Подходит для систем с высокой нагрузкой и распределённой архитектурой: очереди, биллинг, бронирования, логистика.
❓ Есть ли у вас интересные кейсы, когда Saga pattern спасала систему от ошибок?
#laravel #mysql #transactions #webdev #db #devops #saga #aftercommit
🚨 1) Внешние действия внутри транзакции = проблемы
Любой внешний вызов внутри транзакции может выполниться два и более раз, потому что Laravel автоматически повторяет транзакцию при дедлоках и таймаутах.
DB::transaction($callback, $attempts) сам делает retry, если произошёл:
- дедлок
- lock wait timeout
- потеря блокировки
И важно: При повторе он запускает весь callback с нуля.
Он не понимает, что там было «одноразовым», а что идемпотентным.
Что под раздачу попадает:
- отправка писем
- пуши
- внешние HTTP-запросы
- интеграции (CRM, платёжки)
Если транзакция упала и Laravel её перезапустил → действие повторится, потому что MySQL откатывает только свои изменения, а внешние вызовы уже произошли и не откатываются.
Как избежать:
- всё внешнее — только после commit
- использовать afterCommit() у моделей или dispatch(fn)->afterCommit() — так действие выполняется один раз, после успешной фиксации данных.
🔒 2) Дедлоки из-за порядка блокировок
Дедлок (deadlock) — ситуация, когда две и более транзакций ожидают друг друга бесконечно, и MySQL вынужден прервать одну из них, чтобы система продолжила работать.
Чаще всего дедлоки возникают не из-за MySQL как такового, а из-за разного порядка, в котором код блокирует строки или ресурсы.
Пример типичной ситуации:
- Транзакция A начинает и лочит строки 5 → 10
- Транзакция B начинает и лочит строки 10 → 5
MySQL обнаруживает тупик и прерывает одну транзакцию с ошибкой Deadlock found.
Как снизить риск:
1. Всегда блокировать строки в одном порядке
Обычно по возрастанию ID, чтобы исключить циклические ожидания.
$rows = DB::table('accounts')
->whereIn('id', [$id1, $id2])
->orderBy('id')
->lockForUpdate()
->get();2. Использовать блокировки корректно
- FOR UPDATE — эксклюзивная блокировка для изменения
- sharedLock() — блокировка для чтения без мешающих других читателей
3. Минимизировать время удержания блокировок
Чем быстрее транзакция завершится, тем ниже шанс дедлока.
4. Разделять независимые операции
Операции по разным таблицам или сущностям лучше выполнять в отдельных транзакциях, чтобы не увеличивать зону возможного конфликта.
🌐 3) Когда транзакции не работают вообще
Стандартные транзакции работают только в рамках одной базы данных и одного соединения.
Если операция затрагивает:
- несколько сервисов (например, микросервисы с разной логикой),
- несколько БД (разные инстансы, разные схемы),
- внешние API (CRM, платёжные системы, сторонние интеграции),то гарантировать атомарность стандартной транзакцией невозможно.
Причина: транзакция контролирует только изменения внутри конкретного движка базы данных.
Внешние действия, например HTTP-запросы или вызовы другой БД, не могут быть откатаны автоматически при ошибке — их состояние уже “зафиксировано” в сторонней системе.
🔹4) Стандартный подход — Saga pattern
Saga pattern — это архитектурный паттерн для управления распределёнными транзакциями.
Его идея проста:
1.Каждый шаг — независимый
Каждое действие выполняется как отдельная транзакция, которая гарантированно сохраняет свои изменения в локальной БД.
2. Компенсирующие действия
Для каждого шага создаётся обратная операция, которая может откатить изменения, если последующий шаг не удался.
Например, для биллинга:
Шаг 1: резервируем средства на счёте клиента (локальная транзакция)
Шаг 2: создаём заказ в системе (локальная транзакция)
Шаг 3: уведомляем склад о сборкеЕсли шаг 2 падает, шаг 1 компенсируется: средства возвращаются на счёт клиента.
Преимущества Saga pattern:
- Нет необходимости в глобальной межсервисной транзакции, которая блокировала бы все сервисы.
- Процесс становится устойчивым к сбоям: каждая ошибка обрабатывается локально и компенсируется.
- Подходит для систем с высокой нагрузкой и распределённой архитектурой: очереди, биллинг, бронирования, логистика.
❓ Есть ли у вас интересные кейсы, когда Saga pattern спасала систему от ошибок?
#laravel #mysql #transactions #webdev #db #devops #saga #aftercommit
#️⃣ClickHouse (Часть 1)
🔥 Запускаем серию постов про ClickHouse — одну из самых быстрых колонночных баз для аналитики.
Её придумали в Яндексе, а сейчас используют Facebook, Uber и многие другие компании, когда нужно крутить миллиарды строк за секунды.
Что такое ClickHouse:
Это open‑source СУБД, заточенная под OLAP.
Работает с SQL‑подобным языком, умеет шардинг, репликацию, распределённые запросы и масштабирование без единой точки отказа.
Чем она крута:
🗂 Колонночное хранение
В отличие от классических СУБД, где данные лежат построчно, ClickHouse хранит их по столбцам.
Это даёт сразу несколько преимуществ:
📑Сжатие: каждый столбец хранится в отдельном файле и может быть отсортирован. Благодаря этому алгоритмы компрессии (zstd, LZ4) работают эффективнее, и таблицы занимают в десятки раз меньше места.
⚡️Быстрые аналитические запросы: для range‑запросов система обращается только к нужным столбцам, а не ко всей таблице. Если столбцы отсортированы (sort keys), поиск и агрегации выполняются значительно быстрее.
🖥 Параллельная обработка: при работе с большими объёмами данных ClickHouse умеет распараллеливать операции на многоядерных процессорах, ускоряя загрузку и вычисления.
📈 Масштабируемость
ClickHouse отлично масштабируется как «вверх», так и «вширь»:
🔀 Горизонтально — добавляем новые шарды и реплики, распределяем нагрузку между узлами.
🏢 Между дата‑центрами — поддерживается асинхронная multi‑master репликация, все узлы равноправны, нет единой точки отказа.
💡 Вертикально — можно увеличивать ресурсы отдельного сервера (CPU, RAM, диски), и ClickHouse будет использовать их максимально эффективно.
Но есть нюансы:
- Нет полноценного UPDATE/DELETE ClickHouse не рассчитан на частые модификации данных.
Такие операции выполняются медленно и неэффективно, поэтому для сценариев с постоянными изменениями таблиц он не лучший выбор.
- OLTP‑запросы не его сильная сторона
Если нужны точечные запросы (например, быстро достать одну строку по ключу), классические реляционные базы вроде MySQL или PostgreSQL справятся заметно лучше.
Аналоги:
- Druid
- ElasticSearch
- SingleStore
- Snowflake
- TimescaleDB
У каждого свои плюсы, но ClickHouse — топ именно для аналитики.
Установка
В этом гайде я показываю только вариант через Docker — самый быстрый способ «поднять» ClickHouse без лишних танцев с бубном.
За другими способами и нюансами - как и всегда, лучше обратиться к официальной доке
Загрузка образа
Запуск:
Подключение к нему из нативного клиента
Первые шаги
➕ Вставка данных
🔍 Чтение данных
📌 Мы разобрали основы ClickHouse и базовый сетап.
Впереди — движки таблиц, ключи, индексы и сравнение с MySQL.
Следите за апдейтами!
#clickhouse #database #tutorial
🔥 Запускаем серию постов про ClickHouse — одну из самых быстрых колонночных баз для аналитики.
Её придумали в Яндексе, а сейчас используют Facebook, Uber и многие другие компании, когда нужно крутить миллиарды строк за секунды.
Что такое ClickHouse:
Это open‑source СУБД, заточенная под OLAP.
Работает с SQL‑подобным языком, умеет шардинг, репликацию, распределённые запросы и масштабирование без единой точки отказа.
Чем она крута:
🗂 Колонночное хранение
В отличие от классических СУБД, где данные лежат построчно, ClickHouse хранит их по столбцам.
Это даёт сразу несколько преимуществ:
📑Сжатие: каждый столбец хранится в отдельном файле и может быть отсортирован. Благодаря этому алгоритмы компрессии (zstd, LZ4) работают эффективнее, и таблицы занимают в десятки раз меньше места.
⚡️Быстрые аналитические запросы: для range‑запросов система обращается только к нужным столбцам, а не ко всей таблице. Если столбцы отсортированы (sort keys), поиск и агрегации выполняются значительно быстрее.
🖥 Параллельная обработка: при работе с большими объёмами данных ClickHouse умеет распараллеливать операции на многоядерных процессорах, ускоряя загрузку и вычисления.
📈 Масштабируемость
ClickHouse отлично масштабируется как «вверх», так и «вширь»:
🔀 Горизонтально — добавляем новые шарды и реплики, распределяем нагрузку между узлами.
🏢 Между дата‑центрами — поддерживается асинхронная multi‑master репликация, все узлы равноправны, нет единой точки отказа.
💡 Вертикально — можно увеличивать ресурсы отдельного сервера (CPU, RAM, диски), и ClickHouse будет использовать их максимально эффективно.
Но есть нюансы:
- Нет полноценного UPDATE/DELETE ClickHouse не рассчитан на частые модификации данных.
Такие операции выполняются медленно и неэффективно, поэтому для сценариев с постоянными изменениями таблиц он не лучший выбор.
- OLTP‑запросы не его сильная сторона
Если нужны точечные запросы (например, быстро достать одну строку по ключу), классические реляционные базы вроде MySQL или PostgreSQL справятся заметно лучше.
Аналоги:
- Druid
- ElasticSearch
- SingleStore
- Snowflake
- TimescaleDB
У каждого свои плюсы, но ClickHouse — топ именно для аналитики.
Установка
В этом гайде я показываю только вариант через Docker — самый быстрый способ «поднять» ClickHouse без лишних танцев с бубном.
За другими способами и нюансами - как и всегда, лучше обратиться к официальной доке
Загрузка образа
docker pull clickhouse/clickhouse-server
Запуск:
docker run -d --name some-clickhouse-server --ulimit nofile=262144:262144 clickhouse/clickhouse-server
Подключение к нему из нативного клиента
docker run -it --rm --network=container:some-clickhouse-server --entrypoint clickhouse-client clickhouse/clickhouse-server
# ИЛИ \{#or}
docker exec -it some-clickhouse-server clickhouse-client
Первые шаги
CREATE DATABASE ecommerce;
CREATE TABLE ecommerce.users
(
UserID UUID,
Username String,
Email String,
RegistrationDate Date,
LastLogin DateTime64(3, 'UTC'),
Age UInt8,
Salary Decimal(10, 2),
IsPremium Bool,
Settings JSON,
Tags Array(String),
Metadata Map(String, String)
)
ENGINE = MergeTree
PRIMARY KEY (UserID, RegistrationDate)
ORDER BY (UserID, RegistrationDate, Username)
PARTITION BY toYYYYMM(RegistrationDate);
➕ Вставка данных
INSERT INTO ecommerce.users VALUES
(
generateUUIDv4(), -- Автоматическая генерация UUID
'anna_sidorova',
'anna.s@company.com',
'2024-02-20',
'2024-03-19 09:15:30.500',
32,
62000.00,
false,
'{"theme": "light", "email_notifications": false}',
['new_user'],
map('city', 'Saint Petersburg', 'department', 'Sales')
);
🔍 Чтение данных
SELECT * FROM ecommerce.users;
📌 Мы разобрали основы ClickHouse и базовый сетап.
Впереди — движки таблиц, ключи, индексы и сравнение с MySQL.
Следите за апдейтами!
#clickhouse #database #tutorial
А есть ли здесь представители компаний-аутстафферов, которые одним глазком наблюдают за нашей бурной деятельностью?😎
На всякий случай делимся новостью - мы запустили конкурс на предоставление услуг IT-разработчиков в аутстафф.
У нас большой штат собственных аналитиков и разработчиков, но иногда клиентам нужна команда еще больше - или требуется специфичный стек. Тогда мы идем к нашим любимым партнерам, список которых все время пополняется надежными поставщиками IT-услуг💪
⚡️ Так что, если здесь среди разрабов затесался поставщик IT-ресурсов, который давно хотел поработать с нами - есть отличная возможность принять участие в открытом отборе партнеров на 2026 год.
📄 Подача заявок, а также подробные условия тендера и требования - на нашей площадке: https://business.roseltorg.ru/lk/orders/all/94251
⏰ Принимаем заявки до 18:00 19 декабря - успевайте, возможно, мы ищем именно вас😉
На всякий случай делимся новостью - мы запустили конкурс на предоставление услуг IT-разработчиков в аутстафф.
У нас большой штат собственных аналитиков и разработчиков, но иногда клиентам нужна команда еще больше - или требуется специфичный стек. Тогда мы идем к нашим любимым партнерам, список которых все время пополняется надежными поставщиками IT-услуг💪
⚡️ Так что, если здесь среди разрабов затесался поставщик IT-ресурсов, который давно хотел поработать с нами - есть отличная возможность принять участие в открытом отборе партнеров на 2026 год.
📄 Подача заявок, а также подробные условия тендера и требования - на нашей площадке: https://business.roseltorg.ru/lk/orders/all/94251
⏰ Принимаем заявки до 18:00 19 декабря - успевайте, возможно, мы ищем именно вас😉
🔥1
В этой части мы поговорим о движке таблиц ClickHouse.
Как и в любой другой базе данных, ClickHouse использует движки для определения методов хранения, репликации и работы с параллельными запросами для таблиц.
У каждого движка есть свои плюсы и минусы, и выбирать их следует исходя из ваших задач.
Более того, движки сгруппированы в семейства, объединённые общими ключевыми характеристиками.
Итак, начнём с первого и самого популярного семейства:
Семейство MergeTree
Это основной и наиболее мощный движок ClickHouse. Если вы создаете таблицу и не знаете, что выбрать — начинайте с MergeTree или его модификаций.
Основная идея — оптимизация для интенсивной записи данных (INSERT).
Под капотом используется структура LSM-дерево (Log-Structured Merge-Tree).
В отличие от B-деревьев в классических базах данных (MySQL, PostgreSQL), LSM сначала буферизирует записи в памяти, а затем крупными, отсортированными "пакетами" записывает на диск.
Это дает огромный выигрыш в скорости вставки и уменьшает фрагментацию.
Теперь рассмотрим ключевых представителей семейства.
1. MergeTree (базовый)Пример создания таблицы:
Как работает?
Данные разбиваются на части (parts) и сортируются по ORDER BY.
Каждая часть делится на гранулы (блоки данных).
Для гранул создаются засечки (marks) — "отметки" по первичному ключу. Это разреженный индекс.
При запросе с условием по первичному ключу ClickHouse быстро находит нужные гранулы через бинарный поиск по засечкам и загружает только их.Правило: Первичный ключ (PRIMARY KEY) должен быть префиксом или совпадать с ключом сортировки (ORDER BY). Если PRIMARY KEY не указан, вместо него используется ORDER BY.
2. ReplacingMergeTree (для дедупликации)
DDL
В этом движке строки с одинаковыми ключами сортировки заменяются последней вставленной строкой.
Рассмотрим пример:
Предположим, вы вставляете строку в эту таблицу:
Теперь вставим другую строку с теми же ключами сортировки:
Теперь последняя строка заменит предыдущую. Обратите внимание: если вы выполните выборку, то можете увидеть обе строки:
Это происходит потому, что ClickHouse выполняет процесс замены во время слияния частей (merge), которое происходит в фоновом режиме асинхронно, а не мгновенно. Чтобы сразу увидеть финальный результат, вы можете использовать модификатор FINAL:
Примечание: Вы можете указать столбец в качестве версии при определении таблицы, чтобы управлять логикой замены строк.
Применение
ReplacingMergeTree широко используется для дедупликации.
Поскольку ClickHouse не очень эффективен при частых обновлениях, вы можете обновить столбец, вставив новую строку с такими же ключами сортировки, и ClickHouse удалит устаревшие строки в фоновом режиме.
Конечно, обновление самих ключей сортировки является проблемой, поскольку в этом случае старые строки не будут удалены.
В следующей части мы продолжим разбор семейства MergeTree и рассмотрим:
CollapsingMergeTree — для контролируемых обновлений и удалений
AggregatingMergeTree — для предварительной агрегации данных
А также затронем более легковесные семейства Log и Integration для работы с внешними системами.
#ClickHouse #БазыДанных #Аналитика #MergeTree #Оптимизация
Как и в любой другой базе данных, ClickHouse использует движки для определения методов хранения, репликации и работы с параллельными запросами для таблиц.
У каждого движка есть свои плюсы и минусы, и выбирать их следует исходя из ваших задач.
Более того, движки сгруппированы в семейства, объединённые общими ключевыми характеристиками.
Итак, начнём с первого и самого популярного семейства:
Семейство MergeTree
Это основной и наиболее мощный движок ClickHouse. Если вы создаете таблицу и не знаете, что выбрать — начинайте с MergeTree или его модификаций.
Основная идея — оптимизация для интенсивной записи данных (INSERT).
Под капотом используется структура LSM-дерево (Log-Structured Merge-Tree).
В отличие от B-деревьев в классических базах данных (MySQL, PostgreSQL), LSM сначала буферизирует записи в памяти, а затем крупными, отсортированными "пакетами" записывает на диск.
Это дает огромный выигрыш в скорости вставки и уменьшает фрагментацию.
Теперь рассмотрим ключевых представителей семейства.
1. MergeTree (базовый)Пример создания таблицы:
CREATE TABLE users
(
`user_id` Int32,
`name` String,
`age` Int32,
`city` String
)
ENGINE = MergeTree
PRIMARY KEY (user_id, city)
ORDER BY (user_id, city, name)
Как работает?
Данные разбиваются на части (parts) и сортируются по ORDER BY.
Каждая часть делится на гранулы (блоки данных).
Для гранул создаются засечки (marks) — "отметки" по первичному ключу. Это разреженный индекс.
При запросе с условием по первичному ключу ClickHouse быстро находит нужные гранулы через бинарный поиск по засечкам и загружает только их.Правило: Первичный ключ (PRIMARY KEY) должен быть префиксом или совпадать с ключом сортировки (ORDER BY). Если PRIMARY KEY не указан, вместо него используется ORDER BY.
2. ReplacingMergeTree (для дедупликации)
DDL
В этом движке строки с одинаковыми ключами сортировки заменяются последней вставленной строкой.
Рассмотрим пример:
CREATE TABLE user_sessions
(
`user_id` Int32,
`session_id` String,
`status` String,
`last_activity` DateTime
)
ENGINE = ReplacingMergeTree
ORDER BY (user_id, session_id);
Предположим, вы вставляете строку в эту таблицу:
INSERT INTO user_sessions VALUES (101, 's1', 'active', '2024-01-15 10:00:00');
Теперь вставим другую строку с теми же ключами сортировки:
INSERT INTO user_sessions VALUES (101, 's1', 'inactive', '2024-01-15 11:30:00');
Теперь последняя строка заменит предыдущую. Обратите внимание: если вы выполните выборку, то можете увидеть обе строки:
Это происходит потому, что ClickHouse выполняет процесс замены во время слияния частей (merge), которое происходит в фоновом режиме асинхронно, а не мгновенно. Чтобы сразу увидеть финальный результат, вы можете использовать модификатор FINAL:
SELECT * from user_sessions FINAL WHERE user_id=101;
Примечание: Вы можете указать столбец в качестве версии при определении таблицы, чтобы управлять логикой замены строк.
Применение
ReplacingMergeTree широко используется для дедупликации.
Поскольку ClickHouse не очень эффективен при частых обновлениях, вы можете обновить столбец, вставив новую строку с такими же ключами сортировки, и ClickHouse удалит устаревшие строки в фоновом режиме.
Конечно, обновление самих ключей сортировки является проблемой, поскольку в этом случае старые строки не будут удалены.
В следующей части мы продолжим разбор семейства MergeTree и рассмотрим:
CollapsingMergeTree — для контролируемых обновлений и удалений
AggregatingMergeTree — для предварительной агрегации данных
А также затронем более легковесные семейства Log и Integration для работы с внешними системами.
#ClickHouse #БазыДанных #Аналитика #MergeTree #Оптимизация
Продолжаем разбор движков ClickHouse!
3. CollapsingMergeTree (для контролируемых изменений)
Этот движок позволяет явно управлять обновлениями и удалениями через специальный столбец-признак (sign):
sign = 1 — добавить/актуальная версия строки
sign = -1 — удалить/старая версия строки
Пример таблицы для отслеживания статусов заказов:
Как работает изменение статуса:
Важно:
Как и в ReplacingMergeTree, схлопывание происходит в фоне. Для немедленного результата используйте FINAL.
Особенности:
CollapsingMergeTree позволяет более контролируемо обрабатывать обновления и удаления.
Например, вы можете обновить ключи сортировки, вставив старую строку с sign=-1 и новую строку с новыми ключами сортировки и sign=1.
4. AggregatingMergeTree
Этот движок автоматически вычисляет агрегаты при вставке данных, значительно ускоряя аналитические запросы.
Пример — дневная статистика пользователей:
Как использовать агрегированные данные:
Этот движок помогает сократить время отклика на сложные, фиксированные аналитические запросы, рассчитывая их во время записи.
Семейство Log: минималистичное хранение
Эти движки максимально просты и быстры для записи, но не имеют индексов.
TinyLog — для временных данных:
Применение: Промежуточные данные ETL, кэши, временные логи.
Семейство Integration: работа с внешними системами
MySQL Engine — доступ:
Теперь можно выполнять запросы к MySQL через ClickHouse:
Очевидно, что ClickHouse предлагает широкий спектр вариантов движков для различных сценариев использования. MergeTree является движком по умолчанию и подходит для большинства ситуаций, но при необходимости его можно заменить другими движками, правильный выбор движка для вашей конкретной задачи может значительно повысить производительность и эффективность работы с данными. Поэтому стоит потратить время на то, чтобы понять сильные и слабые стороны каждого движка и выбрать тот, который лучше всего соответствует вашим потребностям.
Ну и будем продолжать тему Clickhouse в следующих постах, дальше интересней :)
#ClickHouse #БазыДанных #Аналитика #MergeTree #CollapsingMergeTree #AggregatingMergeTree
3. CollapsingMergeTree (для контролируемых изменений)
Этот движок позволяет явно управлять обновлениями и удалениями через специальный столбец-признак (sign):
sign = 1 — добавить/актуальная версия строки
sign = -1 — удалить/старая версия строки
Пример таблицы для отслеживания статусов заказов:
CREATE TABLE order_statuses
(
`order_id` Int32,
`status` String,
`updated_at` DateTime,
`sign` Int8
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY (order_id, updated_at);
Как работает изменение статуса:
-- Первоначальный статус
INSERT INTO order_statuses VALUES (5001, 'pending', '2024-01-15 10:00:00', 1);
-- Обновление статуса: удаляем старый, добавляем новый
INSERT INTO order_statuses VALUES
(5001, 'pending', '2024-01-15 10:00:00', -1),
(5001, 'shipped', '2024-01-15 14:00:00', 1);
Важно:
Как и в ReplacingMergeTree, схлопывание происходит в фоне. Для немедленного результата используйте FINAL.
Особенности:
CollapsingMergeTree позволяет более контролируемо обрабатывать обновления и удаления.
Например, вы можете обновить ключи сортировки, вставив старую строку с sign=-1 и новую строку с новыми ключами сортировки и sign=1.
4. AggregatingMergeTree
Этот движок автоматически вычисляет агрегаты при вставке данных, значительно ускоряя аналитические запросы.
Пример — дневная статистика пользователей:
-- Исходная таблица с активностью
CREATE TABLE user_activity
(
`user_id` Int32,
`action` String,
`duration` UInt32,
`event_date` Date
) ENGINE = MergeTree
ORDER BY (user_id, event_date);
-- Материализованное представление с агрегатами
CREATE MATERIALIZED VIEW user_daily_stats
ENGINE = AggregatingMergeTree()
ORDER BY (user_id, event_date)
AS SELECT
user_id,
event_date,
countState() as action_count,
sumState(duration) as total_duration,
uniqState(action) as unique_actions
FROM user_activity
GROUP BY user_id, event_date;
Как использовать агрегированные данные:
-- Вставка детальных данных
INSERT INTO user_activity VALUES
(1001, 'login', 120, '2024-01-15'),
(1001, 'view', 300, '2024-01-15'),
(1001, 'purchase', 60, '2024-01-15');
-- Получение агрегированных результатов
SELECT
user_id,
event_date,
countMerge(action_count) as actions,
sumMerge(total_duration) as total_time,
uniqMerge(unique_actions) as different_actions
FROM user_daily_stats
WHERE user_id = 1001
GROUP BY user_id, event_date;
Этот движок помогает сократить время отклика на сложные, фиксированные аналитические запросы, рассчитывая их во время записи.
Семейство Log: минималистичное хранение
Эти движки максимально просты и быстры для записи, но не имеют индексов.
TinyLog — для временных данных:
CREATE TABLE temp_metrics
(
`metric_id` UUID,
`value` Float64,
`collected_at` DateTime
) ENGINE = TinyLog;
Применение: Промежуточные данные ETL, кэши, временные логи.
Семейство Integration: работа с внешними системами
MySQL Engine — доступ:
CREATE TABLE remote_products
(
`id` Int32,
`noscript` String,
`category` String
)
ENGINE = MySQL('mysql-server:2206', 'shop', 'products', 'admin', 'password');
Теперь можно выполнять запросы к MySQL через ClickHouse:
SELECT * FROM remote_products WHERE category = 'electronics';
Очевидно, что ClickHouse предлагает широкий спектр вариантов движков для различных сценариев использования. MergeTree является движком по умолчанию и подходит для большинства ситуаций, но при необходимости его можно заменить другими движками, правильный выбор движка для вашей конкретной задачи может значительно повысить производительность и эффективность работы с данными. Поэтому стоит потратить время на то, чтобы понять сильные и слабые стороны каждого движка и выбрать тот, который лучше всего соответствует вашим потребностям.
Ну и будем продолжать тему Clickhouse в следующих постах, дальше интересней :)
#ClickHouse #БазыДанных #Аналитика #MergeTree #CollapsingMergeTree #AggregatingMergeTree
А тем временем в новом эпизоде ITToLкового подкаста наши бессменные ведущие вывели на чистую воду человека, который привык оставаться за кадром (но у истоков💪) всех наших PR-активностей😎
Поговорили с CMO EvApps Юлией Резниченко о том, как продвигать IT-компанию, сколько дают "на маркетинг" и причем здесь тульские пряники😉
🚀Что выяснили?
🔗 Смотри скорее по ссылке: https://vkvideo.ru/video-78780379_456239303
Поговорили с CMO EvApps Юлией Резниченко о том, как продвигать IT-компанию, сколько дают "на маркетинг" и причем здесь тульские пряники😉
🚀Что выяснили?
🔗 Смотри скорее по ссылке: https://vkvideo.ru/video-78780379_456239303
VK Видео
IT ToLк by EvApps. Маркетинг, сексизм и пряники
Расспросили нашего CMO Юлию Резниченко, откуда она берет такие красивые показатели, что работает, а что не работает в маркетинге и PR IT-компаний - и причем здесь, собственно, пряники. "Ссылочки в описании", как и обещали: ✅ Это наш официальный ВК (https…
👍1
Продолжаем серию постов про ClickHouse! Ранее мы разобрали основные движки таблиц.
Сегодня углубимся в сердце производительности ClickHouse — ключи и индексы.
Важное уточнение: всё, о чём поговорим сегодня, работает только для семейства движков MergeTree.
Первичный ключ (Primary Key)
Индексы ClickHouse основаны на разреженном индексировании (Sparse Indexing) — альтернативе B-деревьям, используемым традиционными СУБД.
В B-деревьях индексируется каждая строка, что хорошо подходит для точечных запросов (point queries), характерных для OLTP-задач.
Однако это приводит к низкой скорости вставки больших объемов данных и высокому потреблению памяти и дискового пространства.
Напротив, разреженный индекс разбивает данные на несколько частей, каждая из которых группируется в фиксированные порции — гранулы.
ClickHouse создает индекс для каждой гранулы (группы данных), а не для каждой строки, отсюда и название "разреженный индекс".
При запросе с фильтром по первичным ключам ClickHouse находит соответствующие гранулы и загружает их параллельно в память.
Кроме того, данные хранятся в столбцах в нескольких файлах, что позволяет их сжимать и значительно экономить место на диске.
Для наглядности создадим таблицу пользовательских логов и вставим в нее данные:
Важно: Если отдельно не указать первичные ключи, ClickHouse использует ключи сортировки (ORDER BY) в качестве первичных ключей. В этой таблице user_id и access_time будут первичными ключами.
При каждой вставке данных они будут сортироваться сначала по user_id, затем по access_time.
Фильтрация по первому первичному ключу
Посмотрим, что происходит при фильтрации по user_id (первый ключ):
Результат анализа индексов: Система определила user_id как первичный ключ и исключила большинство гранул с его помощью!
Фильтрация по второму первичному ключу
Теперь попробуем фильтрацию по access_time (второй ключ):
Результат анализа индексов: База данных определила access_time как первичный ключ, но не смогла эффективно исключить гранулы. Почему?
Потому что ClickHouse использует бинарный поиск только для первого ключа, а для остальных ключей — общий исключающий поиск, который гораздо менее эффективен.
Решение: правильный порядок ключей
Если мы поменяем порядок ключей в ORDER BY, поместив access_time на первое место (так как временные метки часто используются для диапазонных запросов), то получим лучшие результаты:
Теперь при фильтрации по user_id (который стал вторым ключом):
Результат: ClickHouse всё равно сможет эффективно фильтровать данные, используя комбинированную стратегию поиска.
Ключевое правило - всегда старайтесь упорядочивать первичные ключи от низкой к высокой кардинальности:
— Сначала ключи с малым количеством уникальных значений
— Затем ключи с большим количеством уникальных значений
Это обеспечит максимальную эффективность индексов для различных типов запросов.
#ClickHouse #БазыДанных #Аналитика #Индексы #Производительность #Оптимизация #OLAP
Сегодня углубимся в сердце производительности ClickHouse — ключи и индексы.
Важное уточнение: всё, о чём поговорим сегодня, работает только для семейства движков MergeTree.
Первичный ключ (Primary Key)
Индексы ClickHouse основаны на разреженном индексировании (Sparse Indexing) — альтернативе B-деревьям, используемым традиционными СУБД.
В B-деревьях индексируется каждая строка, что хорошо подходит для точечных запросов (point queries), характерных для OLTP-задач.
Однако это приводит к низкой скорости вставки больших объемов данных и высокому потреблению памяти и дискового пространства.
Напротив, разреженный индекс разбивает данные на несколько частей, каждая из которых группируется в фиксированные порции — гранулы.
ClickHouse создает индекс для каждой гранулы (группы данных), а не для каждой строки, отсюда и название "разреженный индекс".
При запросе с фильтром по первичным ключам ClickHouse находит соответствующие гранулы и загружает их параллельно в память.
Кроме того, данные хранятся в столбцах в нескольких файлах, что позволяет их сжимать и значительно экономить место на диске.
Для наглядности создадим таблицу пользовательских логов и вставим в нее данные:
CREATE TABLE user_access_logs
(
user_id UInt32,
page_url String,
access_time DateTime,
ip_address String,
session_duration UInt32
)
ENGINE = MergeTree
ORDER BY (user_id, access_time);
INSERT INTO user_access_logs
SELECT
number % 5000 as user_id,
concat('https://site.com/page', toString(rand() % 100)) as page_url,
now() - (rand() % 2592000) as access_time,
concat('192.168.', toString(rand() % 255), '.', toString(rand() % 255)) as ip_address,
rand() % 300 as session_duration
FROM numbers(1000000);
Важно: Если отдельно не указать первичные ключи, ClickHouse использует ключи сортировки (ORDER BY) в качестве первичных ключей. В этой таблице user_id и access_time будут первичными ключами.
При каждой вставке данных они будут сортироваться сначала по user_id, затем по access_time.
Фильтрация по первому первичному ключу
Посмотрим, что происходит при фильтрации по user_id (первый ключ):
EXPLAIN indexes=1
SELECT * FROM user_access_logs WHERE user_id = 100;
Результат анализа индексов: Система определила user_id как первичный ключ и исключила большинство гранул с его помощью!
Фильтрация по второму первичному ключу
Теперь попробуем фильтрацию по access_time (второй ключ):
EXPLAIN indexes=1
SELECT * FROM user_access_logs
WHERE access_time >= '2024-01-15 00:00:00'
AND access_time < '2024-01-16 00:00:00';
Результат анализа индексов: База данных определила access_time как первичный ключ, но не смогла эффективно исключить гранулы. Почему?
Потому что ClickHouse использует бинарный поиск только для первого ключа, а для остальных ключей — общий исключающий поиск, который гораздо менее эффективен.
Решение: правильный порядок ключей
Если мы поменяем порядок ключей в ORDER BY, поместив access_time на первое место (так как временные метки часто используются для диапазонных запросов), то получим лучшие результаты:
CREATE TABLE user_access_logs_optimized
(
`user_id` UInt32,
`page_url` String,
`access_time` DateTime,
`ip_address` String,
`session_duration` UInt32
)
ENGINE = MergeTree
ORDER BY (toStartOfDay(access_time), user_id, access_time);
Теперь при фильтрации по user_id (который стал вторым ключом):
EXPLAIN indexes=1
SELECT * FROM user_access_logs_optimized WHERE user_id = 100;
Результат: ClickHouse всё равно сможет эффективно фильтровать данные, используя комбинированную стратегию поиска.
Ключевое правило - всегда старайтесь упорядочивать первичные ключи от низкой к высокой кардинальности:
— Сначала ключи с малым количеством уникальных значений
— Затем ключи с большим количеством уникальных значений
Это обеспечит максимальную эффективность индексов для различных типов запросов.
#ClickHouse #БазыДанных #Аналитика #Индексы #Производительность #Оптимизация #OLAP
Всем привет!
Сегодня углубимся в детали ключей, партиций и дополнительных индексов — всё, что нужно для максимальной производительности.
Order Key vs Primary Key
Ранее я упоминал: если не указать PRIMARY KEY явно, ClickHouse использует ключи сортировки (ORDER BY) как первичные ключи.
Но вы можете задать PRIMARY KEY отдельно — он должен быть подмножеством ORDER BY.
Что здесь происходит:
— event_date и customer_id — используются и для индекса, и для сортировки
— action_type и product_id — только для сортировки (помогают в ORDER BY запросах)
— session_token — вообще не участвует в сортировке
Зачем это нужно?
Если вы часто используете ORDER BY в запросах, включение этих столбцов в ORDER BY таблицы ускоряет выполнение — ClickHouse не будет тратить время на дополнительную сортировку.
Partition Key — разбиваем данные
Партиционирование в ClickHouse — это логическое разделение данных на части. По умолчанию все данные в одной партиции, но вы можете изменить это:
Что даёт партиционирование:
— Быстрое удаление старых данных — можно удалить целую партицию
— Оптимизация запросов — ClickHouse читает только нужные партиции
— Управление данными — перемещение, копирование партиций
Важно:
Партиционирование — не для ускорения запросов!
Skip Index — когда ORDER BY не помогает:
Что делать, если нужно искать по столбцу, которого нет в ORDER BY? Например, найти все логи с определённым типом ошибки:
Типы Skip Index:
— bloom_filter — для точного совпадения строк
— minmax — для диапазонов (даты, числа)
— ngrambf_v1 — для поиска подстрок
— tokenbf_v1 — для поиска отдельных слов
Case Study: оптимизация метрик IoT-устройств
Допустим, у нас есть данные с 50K IoT-устройств. Мы хотим:
1. Быстро искать метрики по времени и device_id
2. Фильтровать по типу сенсора
3. Искать по статусу ошибки
Результат:
— По времени+устройству — мгновенно (первичный ключ)
— По типу сенсора — быстро (часть ORDER BY)
— По ошибкам — эффективно (skip index)
— Архивация данных — DROP PARTITION для старых месяцев
Главные правила проектирования:
1.ORDER BY — сначала столбцы для самых частых фильтров
2.PRIMARY KEY — подмножество ORDER BY (обычно первые 2-3 столбца)
3.PARTITION BY — только для управления данными, не для скорости
4.Skip Index — для столбцов вне ORDER BY, но с фильтрами
Что дальше?
В следующем посте мы проведём детальное сравнение производительности ClickHouse и MySQL на практических примерах
#ClickHouse #БазыДанных #Производительность #Индексы #Партиционирование #Оптимизация #MySQL
Сегодня углубимся в детали ключей, партиций и дополнительных индексов — всё, что нужно для максимальной производительности.
Order Key vs Primary Key
Ранее я упоминал: если не указать PRIMARY KEY явно, ClickHouse использует ключи сортировки (ORDER BY) как первичные ключи.
Но вы можете задать PRIMARY KEY отдельно — он должен быть подмножеством ORDER BY.
CREATE TABLE ecommerce_events
(
`customer_id` UInt32,
`action_type` String,
`event_date` Date,
`product_id` UInt32,
`session_token` String
)
ENGINE = MergeTree
PRIMARY KEY (event_date, customer_id) -- Для индекса
ORDER BY (event_date, customer_id, action_type, product_id); -- Для сортировки
Что здесь происходит:
— event_date и customer_id — используются и для индекса, и для сортировки
— action_type и product_id — только для сортировки (помогают в ORDER BY запросах)
— session_token — вообще не участвует в сортировке
Зачем это нужно?
Если вы часто используете ORDER BY в запросах, включение этих столбцов в ORDER BY таблицы ускоряет выполнение — ClickHouse не будет тратить время на дополнительную сортировку.
Partition Key — разбиваем данные
Партиционирование в ClickHouse — это логическое разделение данных на части. По умолчанию все данные в одной партиции, но вы можете изменить это:
CREATE TABLE server_logs_partitioned
(
`server_id` UInt16,
`log_message` String,
`log_timestamp` DateTime
)
ENGINE = MergeTree
PARTITION BY toDate(log_timestamp)
ORDER BY (log_timestamp, server_id);
Что даёт партиционирование:
— Быстрое удаление старых данных — можно удалить целую партицию
— Оптимизация запросов — ClickHouse читает только нужные партиции
— Управление данными — перемещение, копирование партиций
Важно:
Партиционирование — не для ускорения запросов!
Skip Index — когда ORDER BY не помогает:
Что делать, если нужно искать по столбцу, которого нет в ORDER BY? Например, найти все логи с определённым типом ошибки:
-- Добавляем ngram bloom filter для поиска подстрок
ALTER TABLE server_logs
ADD INDEX error_idx log_message
TYPE ngrambf_v1(3, 256, 2, 0) GRANULARITY 2;
-- Применяем индекс к существующим данным
ALTER TABLE server_logs
MATERIALIZE INDEX error_idx;
Типы Skip Index:
— bloom_filter — для точного совпадения строк
— minmax — для диапазонов (даты, числа)
— ngrambf_v1 — для поиска подстрок
— tokenbf_v1 — для поиска отдельных слов
Case Study: оптимизация метрик IoT-устройств
Допустим, у нас есть данные с 50K IoT-устройств. Мы хотим:
1. Быстро искать метрики по времени и device_id
2. Фильтровать по типу сенсора
3. Искать по статусу ошибки
CREATE TABLE iot_metrics
(
`device_id` UInt32,
`sensor_type` String,
`metric_time` DateTime,
`value` Float32,
`error_flag` UInt8
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(metric_time) -- Для архивации старых данных
PRIMARY KEY (metric_time, device_id) -- Основные фильтры
ORDER BY (metric_time, device_id, sensor_type) -- Сортировка + фильтры
SETTINGS index_granularity = 8192;
-- Добавляем skip index для поиска ошибок
ALTER TABLE iot_metrics
ADD INDEX error_sk error_flag TYPE minmax GRANULARITY 1;
Результат:
— По времени+устройству — мгновенно (первичный ключ)
— По типу сенсора — быстро (часть ORDER BY)
— По ошибкам — эффективно (skip index)
— Архивация данных — DROP PARTITION для старых месяцев
Главные правила проектирования:
1.ORDER BY — сначала столбцы для самых частых фильтров
2.PRIMARY KEY — подмножество ORDER BY (обычно первые 2-3 столбца)
3.PARTITION BY — только для управления данными, не для скорости
4.Skip Index — для столбцов вне ORDER BY, но с фильтрами
Что дальше?
В следующем посте мы проведём детальное сравнение производительности ClickHouse и MySQL на практических примерах
#ClickHouse #БазыДанных #Производительность #Индексы #Партиционирование #Оптимизация #MySQL
👍3
Друзья, с наступающим 2026 годом! 🚀
Еще один год кода, багов, бессонных деплоев и триумфальных "все работает!" позади. Год, в котором требования менялись быстрее, чем кеш, а в продакшене всегда находился тот самый крайний случай. Но мы выдержали нагрузку, отрефакторили хаос и выкатили фичи - потому что наше дело именно такое.
Так пусть же в новом году:
🔥 Мержи проходят без конфликтов, а код-ревью длится не дольше чашки кофе.
🔥 Продакшен-баги обходят ваши сервисы десятой дорогой, а если и появляются — то в понедельник утром.
🔥 Тесты покрывают все, что нужно, и никогда не падают из-за кривых моков.
🔥 Документация существует (!), будет актуальной и в ней можно будет найти ответы.
🔥 Технический долг будет-таки закрыт, а не станет легаси, о котором все боятся вспоминать.
Желаем вам в 2026-м легких задач, быстрых компиляций, стабильных зависимостей и того чувства, когда после долгой работы код компилируется и запускается с первого раза. Пусть баланс между "багом" и "фичей" всегда будет в вашу пользу!
Отдыхайте, набирайтесь сил и вдохновения. Новый год готовит нам свежие вызовы, крутые технологии и гору интересной работы. И мы с вами точно со всем справимся - потому что лучшая команда разработчиков собрана именно здесь. 💻❤️
С Новым годом! 🎄✨
Еще один год кода, багов, бессонных деплоев и триумфальных "все работает!" позади. Год, в котором требования менялись быстрее, чем кеш, а в продакшене всегда находился тот самый крайний случай. Но мы выдержали нагрузку, отрефакторили хаос и выкатили фичи - потому что наше дело именно такое.
Так пусть же в новом году:
🔥 Мержи проходят без конфликтов, а код-ревью длится не дольше чашки кофе.
🔥 Продакшен-баги обходят ваши сервисы десятой дорогой, а если и появляются — то в понедельник утром.
🔥 Тесты покрывают все, что нужно, и никогда не падают из-за кривых моков.
🔥 Документация существует (!), будет актуальной и в ней можно будет найти ответы.
🔥 Технический долг будет-таки закрыт, а не станет легаси, о котором все боятся вспоминать.
Желаем вам в 2026-м легких задач, быстрых компиляций, стабильных зависимостей и того чувства, когда после долгой работы код компилируется и запускается с первого раза. Пусть баланс между "багом" и "фичей" всегда будет в вашу пользу!
Отдыхайте, набирайтесь сил и вдохновения. Новый год готовит нам свежие вызовы, крутые технологии и гору интересной работы. И мы с вами точно со всем справимся - потому что лучшая команда разработчиков собрана именно здесь. 💻❤️
С Новым годом! 🎄✨
🔥2🎉1