Реальное Собеседование Golang на ВАЛЮТНУЮ УДАЛЕНКУ USDT
Сегодня мы разберём реальное собеседование на позицию Go-разработчика, в ходе которого кандидат демонстрирует уверенное владение темами каналов в Go, индексации и оптимизации запросов в PostgreSQL, уровней изоляции транзакций, а также архитектурных паттернов — в частности, сравнения монолитной и микросервисной архитектур. Особое внимание уделяется практическому опыту кандидата: он не только теоретически обосновывает свои ответы, но и приводит примеры из реальных проектов, включая работу с банковскими приложениями и высоконагруженными системами. Также из беседы видно, как кандидат пришёл в Go из C/C++, что даёт ему глубокое понимание системного программирования и помогает аргументировать выбор технологий.
Вопрос 1. Есть ли разница между небуферизированным каналом и буферизированным каналом с размером буфера 1 в Go?
Таймкод: 00:00:22
Ответ собеседника: Правильный. Небуферизированный канал блокирует отправителя сразу, пока получатель не заберёт значение. Буферизированный канал с размером 1 позволяет отправить первое значение без блокировки, и блокировка наступает только при попытке отправить второе значение, если первое ещё не прочитано.
Правильный ответ:
Ответ собеседника корректен и хорошо передаёт ключевое различие. Рассмотрим тему глубже.
Семантика небуферизированного канала (make(chan T))
Небуферизированный канал обеспечивает синхронную передачу данных — операция отправки блокируется до тех пор, пока кто-то не выполнит операцию получения, и наоборот. Это гарантия синхронизации момента передачи: отправитель и получатель должны быть готовы одновременно (rendezvous). Небуферизированный канал по сути является примитивом синхронизации, а не очередью.
ch := make(chan int) // небуферизированный
go func() {
ch <- 42 // заблокируется, пока кто-не прочитает
}()
val := <-ch // разблокирует отправителя
Семантика буферизированного канала с размером 1 (make(chan T, 1))
Буферизированный канал с ёмкостью 1 ведёт себя как очередь на одно значение. Отправитель может поместить одно значение в буфер и продолжить работу без блокировки. Блокировка наступает только когда буфер полон и отправитель пытается добавить ещё одно значение.
ch := make(chan int, 1)
ch <- 42 // НЕ блокируется — значение помещается в буфер
ch <- 43 // Заблокируется, пока не прочитают 42
Ключевые различия, которые важно понимать
1. Гарантия синхронизации (happens-before)
Небуферизированный канал создаёт строгую гарантию happens-before: отправитель точно знает, что получатель забрал значение, только когда его собственная операция отправки завершилась. С буферизированным каналом ёмкостью 1 отправитель не получает этой гарантии — он лишь кладёт значение в буфер и идёт дальше.
// Небуферизированный — строгая синхронизация
ch0 := make(chan int)
ch0 <- 42 // блокируется до момента чтения
// Когда эта строка завершилась, значение уже получено
// Буферизированный — ослабленная синхронизация
ch1 := make(chan int, 1)
ch1 <- 42 // завершилась мгновенно, значение в буфере
// Получатель может ещё не запускаться
2. Поведение при отсутствии получателя
Если отправитель пишет в небуферизированный канал, а получателя нет — горутина отправителя заблокируется навсегда (deadlock, если это единственная горутина). С буферизированным каналом ёмкостью 1 отправитель успешно запишет значение и завершится, а значение останется в буфере.
3. Поведение при закрытии канала
Если буферизированный канал закрыт, но в буфере ещё есть значения, получатель сможет прочитать оставшиеся значения. Только после опустошения буфера чтение будет возвращать zero value. С небуферизированным каналом это различие не актуально — буфера нет.
ch := make(chan int, 1)
ch <- 42
close(ch)
val, ok := <-ch // val=42, ok=true — прочитали из буфера
val, ok = <-ch // val=0, ok=false — буфер пуст, канал закрыт
4. Использование как сигнальный канал
Небуферизированный канал часто используется для сигнализации (например, завершения работы). Буферизированный канал ёмкостью 1 подходит, когда нужно «отправить и забыть» — например, одноразовый сигнал о готовности.
// Сигнал завершения — небуферизированный
done := make(chan struct{})
go func() {
defer close(done)
// работа...
}()
<-done // ждём завершения
// Одноразовый сигнал — буферизированный 1
ready := make(chan struct{}, 1)
ready <- struct{}{} // не блокируется
Вывод: Разница между небуферизированным каналом и буферизированным с размером 1 — не только в наличии одного слота буфера, но и в семантике синхронизации. Небуферизированный канал гарантирует синхронную передачу между отправителем и получателем, а буферизированный с ёмкостью 1 допускает асинхронную отправку одного значения.
Вопрос 2. Какой составной (композитный) индекс лучше создать для таблицы с полями имя, фамилия, пол, по которым постоянно идёт поиск?
Таймкод: 00:01:31
Ответ собеседника: Правильный. Нужно в начале индекса ставить поля с наибольшей селективностью (больше уникальных значений). Предложен порядок: фамилия, имя, пол — так как фамилий больше, чем имён, а пол имеет минимальную селективность. Также предложено добавить возраст для повышения селективности индекса.
Правильный ответ:
Ответ собеседника в целом верен, но есть важные нюансы, которые стоит раскрыть глубже.
Принцип селективности в композитных индексах
Селективность — это количество уникальных значений в столбце, делённое на общее количество строк. Чем выше селективность, тем эффективнее столбец в начале индекса. Пол обычно имеет 2–3 значения (самая низкая селективность), имя — сотни или тысячи вариантов, фамилия — десятки тысяч.
Порядок столбцов: фамилия, имя, пол
Этот порядок логичен с точки зрения убывающей селективности:
CREATE INDEX idx_person_search ON users (last_name, first_name, gender);
Почему именно так работает композитный индекс
Композитный индекс работает по принципу «префикса» — он эффективен для запросов, которые используют столбцы индекса слева направо, без пропусков.
-- ✅ Использует весь индекс (last_name, first_name, gender)
SELECT * FROM users WHERE last_name = 'Иванов' AND first_name = 'Иван' AND gender = 'M';
-- ✅ Использует только last_name и first_name (prefix индекса)
SELECT * FROM users WHERE last_name = 'Иванов' AND first_name = 'Иван';
-- ✅ Использует только last_name (первый столбец индекса)
SELECT * FROM users WHERE last_name = 'Иванов';
-- ❌ НЕ использует индекс для first_name (пропуск last_name)
SELECT * FROM users WHERE first_name = 'Иван';
-- ❌ НЕ использует индекс для gender (пропуск last_name и first_name)
SELECT * FROM users WHERE gender = 'M';
Важный нюанс: паттерны запросов важнее селективности
Порядок столбцов в индексе должен определяться не только селективностью, но и реальными паттернами запросов. Если в 90% запросов фильтрация идёт только по last_name, то индекс (last_name, first_name, gender) идеален. Но если часто бывают запросы только по first_name или по gender, то одного индекса недостаточно.
-- Если часто ищут только по имени, нужен отдельный индекс
CREATE INDEX idx_first_name ON users (first_name);
-- Или покрывающий индекс для конкретного запроса
CREATE INDEX idx_gender_last_first ON users (gender, last_name, first_name);
Покрывающие индексы (Covering Indexes)
Если запрос выбирает небольшой набор столбцов, можно добавить их в индекс с помощью INCLUDE (PostgreSQL) или просто включить в индекс (MySQL InnoDB автоматически включает primary key). Это позволяет базе данных отвечать на запрос целиком из индекса без обращения к таблице.
-- PostgreSQL: покрывающий индекс
CREATE INDEX idx_person_covering ON users (last_name, first_name, gender) INCLUDE (age);
-- Запрос выполнится целиком из индекса (Index Only Scan)
SELECT last_name, first_name, gender, age FROM users WHERE last_name = 'Иванов';
Добавление возраста в индекс
Идея собеседника добавить возраст для повышения селективности имеет смысл, но зависит от типа запросов. Если часто используются диапазонные запросы по возрасту (например, WHERE last_name = 'Иванов' AND age BETWEEN 20 AND 30), то возраст стоит добавить в конец индекса:
CREATE INDEX idx_person_age ON users (last_name, first_name, gender, age);
Однако нужно помнить: диапазонное условие по age «блокирует» использование индекса для столбцов, стоящих после него.
Практическая рекомендация
Для общего случая, когда поиск идёт по всем трём полям:
CREATE INDEX idx_person_search ON users (last_name, first_name, gender);
Если известны конкретные частые запросы — индексы нужно подстраивать под них, анализируя планы выполнения через EXPLAIN / EXPLAIN ANALYZE.
Вопрос 3. Правильно ли создавать индексы на все поля подряд в большой базе данных с множеством таблиц?
Таймкод: 00:03:02
Ответ собеседния: Правильный. Индексы замедляют запись, поэтому вешать их на всё подряд не стоит. Нужно анализировать план запросов (EXPLAIN ANALYZE), смотреть, какие индексы используются, и удалявать неиспользуемые. На чтение индексы влияют положительно — они ускоряют поиск. В PostgreSQL по умолчанию используется B-tree индекс, который хорошо работает для операций сравнения (больше, меньше, равно), а hash-индекс подходит для точного поиска конкретного значения.
Правильный ответ:
Ответ собеседника корректен. Разберём тему системнее.
Почему «индексы на всё подряд» — антипаттерн
Каждый индекс — это дополнительная структура данных, которая требует ресурсов на поддержание.
1. Замедление операций записи (INSERT, UPDATE, DELETE)
При каждой вставке строки СУБД должна обновить все индексы таблицы. При обновлении индексированного столбца — перестроить соответствующие записи в индексах. При удалении — пометить записи как невалидные. В таблице с 20 индексами каждая INSERT-операция вызывает 20+ дополнительных операций записи на диск.
-- Представим таблицу с 10 индексами
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
amount DECIMAL(10,2),
-- ... ещё столбцы
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
INDEX idx_amount (amount),
-- ... ещё индексы
);
-- Каждый INSERT обновляет все 10 индексов
INSERT INTO orders (user_id, status, created_at, amount)
VALUES (123, 'new', NOW(), 99.99);
2. Потребление дискового пространства
Индексы занимают место на диске и в оперативной памяти. В больших таблицах (миллиарды строк) суммарный размер индексов может превышать размер самих данных.
-- PostgreSQL: посмотреть размер индексов
SELECT
indexrelname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY pg_relation_size(indexrelid) DESC;
3. Замедление планировщика запросов
Планировщик запросов должен выбрать оптимальный план выполнения. Чем больше индексов, тем больше вариантов нужно оценить, что увеличивает время планирования.
4. Фрагментация и обслуживание
Индексы требуют периодического обслуживания — REINDEX, VACUUM (в PostgreSQL), OPTIMIZE TABLE (в MySQL). Чем больше индексов, тем больше работы по обслуживанию.
Как правильно подходить к созданию индексов
А. Анализ реальных запросов
Индексы создаются под конкретные запросы, а не «на всякий случай».
-- PostgreSQL: найти медленные запросы
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Б. Анализ планов выполнения
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 123 AND status = 'completed';
Обращайте внимание на Seq Scan (полное сканирование таблицы) — это сигнал, что нужен индекс. Но если таблица маленькая (тысячи строк), Seq Scan может быть быстрее индексного поиска.
В. Мониторинг использования индексов
Неиспользуемые индексы — чистый минус: они замедляют запись, не давая выигрыша на чтение.
-- PostgreSQL: найти неиспользуемые индексы
SELECT
schemaname || '.' || relname AS table_name,
indexrelname AS index_name,
idx_scan AS times_used,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND NOT indisunique -- исключаем уникальные ограничения
AND NOT indisprimary -- исключаем первичные ключи
ORDER BY pg_relation_size(indexrelid) DESC;
Г. Типы индексов и их применимость
- B-tree (по умолчанию в PostgreSQL) — универсальный, поддерживает
=,<,>,BETWEEN,IN,LIKE 'prefix%'. Подходит для большинства случаев. - Hash — только для
=. В PostgreSQL до версии 10 не поддерживал WAL, поэтому не рекомендовался для production. - GiST — для геопространственных данных, полнотекстового поиска.
- GIN — для массивов, JSONB, полнотекстового поиска.
- BRIN — для больших таблиц с физически упорядоченными данными (например, логи с timestamp).
-- GIN для JSONB
CREATE INDEX idx_data ON events USING GIN (data);
-- BRIN для timestamp в таблице логов
CREATE INDEX idx_log_time ON logs USING BRIN (created_at);
Правило создания индексов
- Начинайте без индексов (кроме PRIMARY KEY и внешних ключей).
- Найдите медленные запросы через
pg_stat_statements/ slow query log. - Проанализируйте планы через
EXPLAIN ANALYZE. - Создайте индекс под конкретный запрос.
- Убедитесь, что индекс используется.
- Периодически проверяйте и удаляйте неиспользуемые индексы.
Вопрос 4. Какой уровень изоляции транзакций выбрать для банковского приложения с операциями пополнения и снятия средств?
Таймкод: 00:04:26
Ответ собеседника: Правильный. Предложено практическое решение — использовать SELECT ... FOR UPDATE для блокировки конкретных строк при операциях с балансом. Из теоретических уровней изоляции предложен REPEATABLE READ. По умолчанию PostgreSQL использует READ COMMITTED.
Правильный ответ:
Ответ собеседника практичен и в целом верен. Раскроем тему подробнее.
Уровни изоляции в PostgreSQL
PostgreSQL поддерживает четыре уровня изоляции, но фактически реализует три (READ UNCOMMITTED ведёт себя как READ COMMITTED).
READ COMMITTED (по умолчанию)
Каждая инструкция внутри транзакции видит только данные, зафиксированные до начала этой инструкции. Другие транзакции могут изменять данные между инструкциями внутри текущей транзакции.
REPEATABLE READ
Гарантирует, что все чтения внутри транзакции видят снимок данных на момент первого запроса. Другие транзакции не могут изменить данные, уже прочитанные текущей транзакцией (при попытке возникает ошибка serialization failure).
SERIALIZABLE
Самый строгий уровень. Гарантирует, что параллельное выполнение транзакций эквивалентно некоторому последовательному порядку их выполнения.
Проблемы, которые нужно предотвратить в банковском приложении
А. Потерянное обновление (Lost Update)
Две транзакции читают баланс (1000), обе вычитают 100, обе записывают 900. Правильный результат — 800.
-- Транзакция A -- Транзакция B
BEGIN; BEGIN;
SELECT balance FROM accounts SELECT balance FROM accounts
WHERE id = 1; -- 1000 WHERE id = 1; -- 1000
-- Вычисляем 1000 - 100 = 900 -- Вычисляем 1000 - 100 = 900
UPDATE accounts SET balance = 900 UPDATE accounts SET balance = 900
WHERE id = 1; WHERE id = 1;
COMMIT; COMMIT;
-- Результат: 900 (потеряно 100!)
Б. Неповторяющееся чтение (Non-Repeatable Read)
Транзакция дважды читает баланс, но получает разные значения, потому что другая транзакция изменила данные между чтениями.
В. Фантомное чтение (Phantom Read)
Транзакция дважды выполняет запрос с условием, но получает разные наборы строк.
Практическое решение: SELECT ... FOR UPDATE
Это наиболее распространённый и эффективный подход для банковских операций. Явная блокировка строки на уровне приложения.
-- Операция снятия средств
BEGIN;
SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE; -- Блокирует строку до конца транзакции
-- Проверяем достаточность средств
-- Если balance >= суммы_снятия:
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
COMMIT; -- Снимает блокировку
-- Операция пополнения
BEGIN;
SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;
UPDATE accounts
SET balance = balance + 500
WHERE id = 1;
COMMIT;
Почему FOR UPDATE предпочтительнее повышения уровня изоляции
1. Гранулярность блокировки
FOR UPDATE блокирует только конкретные строки, с которыми работает транзакция. Другие транзакции свободно работают с остальными строками. REPEATABLE READ или SERIALIZABLE создают более широкие блокировки и чаще приводят к конфликтам.
2. Производительность
READ COMMITTED + FOR UPDATE обеспечивает лучшую пропускную способность, чем REPEATABLE READ или SERIALIZABLE, потому что блокировки точечные и короткие.
3. Предсказуемость
С FOR UPDATE разработчик явно контролирует, какие данные блокируются и когда. С более высокими уровнями изоляции поведение менее очевидно, и ошибки serialization failure могут возникать неожиданно.
Когда REPEATABLE READ или SERIALIZABLE оправданы
- REPEATABLE READ — когда нужно гарантировать консистентность нескольких чтений внутри одной транзакции (например, формирование отчёта, где данные не должны «прыгать»).
- SERIALIZABLE — для сложных бизнес-операций, где нельзя допустить никаких аномалий, и готовность к retry-логике.
-- Пример с SERIALIZABLE и retry-логикой
-- При возникновении ошибки serialization failure (SQLSTATE 40001)
-- приложение должно повторить транзакцию целиком
Рекомендуемый подход для банковского приложения
-- Уровень по умолчанию: READ COMMITTED
-- Для операций с балансом: SELECT ... FOR UPDATE
-- Для критических отчётов: REPEATABLE READ
-- Пример полной операции перевода
BEGIN;
SELECT balance
FROM accounts
WHERE id = $1
FOR UPDATE; -- блокируем счёт отправителя
-- Проверка достаточности средств на уровне приложения
-- Если недостаточно — ROLLBACK
UPDATE accounts SET balance = balance - $3 WHERE id = $1;
UPDATE accounts SET balance = balance + $3 WHERE id = $2;
-- Запись в историю операций
INSERT INTO transactions (from_account, to_account, amount, created_at)
VALUES ($1, $2, $3, NOW());
COMMIT;
Дополнительные меры безопасности
- Ограничения на уровне БД —
CHECK (balance >= 0)для предотвращения отрицательного баланса. - Оптимистичные блокировки — поле
versionдля обнаружения конфликтов. - Идемпотентность операций — уникальный идентификатор операции для предотвращения двойного списания.
-- Ограничение на уровне БД
ALTER TABLE accounts ADD CONSTRAINT positive_balance CHECK (balance >= 0);
-- Оптимистичная блокировка
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;
-- Если affected rows = 0, значит кто-то изменил запись — retry
Итог: Для банковского приложения оптимальная комбинация — READ COMMITTED (уровень по умолчанию) + SELECT ... FOR UPDATE для операций с балансом + ограничения на уровне БД. Это даёт правильную семантику, хорошую производительность и предсказуемое поведение.
Вопрос 5. В чём разница между EXPLAIN и EXPLAIN ANALYZE?
Таймкод: 00:06:15
Ответ собеседника: Правильный. EXPLAIN только показывает план выполнения запроса (прикидывает), а EXPLAIN ANALYZE фактически выполняет запрос и показывает реальное время выполнения и другие метрики.
Правильный ответ:
Ответ собеседника точен. Дополним деталями.
EXPLAIN — план без выполнения
Команда EXPLAIN показывает, какой план выполнения запроса выбрал планировщик. Запрос не выполняется, данные не читаются, побочных эффектов не происходит. Оценки стоимости и количества строк рассчитываются на основе статистики таблиц (которая может быть устаревшей).
EXPLAIN
SELECT * FROM orders WHERE user_id = 123;
Пример вывода:
Seq Scan on orders (cost=0.00..155.00 rows=10 width=72)
Filter: (user_id = 123)
Rows Removed by Filter: 9990
Здесь rows=10 — это оценка планировщика, а cost=0.00..155.00 — абстрактные единицы стоимости.
EXPLAIN ANALYZE — план с реальным выполнением
Команда EXPLAIN ANALYZE выполняет запрос и показывает реальные метрики: фактическое время выполнения, реальное количество строк, использование памяти, попадания в кэш и т.д.
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 123;
Пример вывода:
Seq Scan on orders (cost=0.00..155.00 rows=10 width=72)
(actual time=0.015..5.230 rows=47 loops=1)
Filter: (user_id = 123)
Rows Removed by Filter: 9953
Planning Time: 0.085 ms
Execution Time: 5.312 ms
Ключевые различия в выводе
| Метрика | EXPLAIN | EXPLAIN ANALYZE |
|---|---|---|
| Запрос выполняется | Нет | Да |
| Оценка строк (rows) | Да (оценка) | Да (оценка + фактическое) |
| Время (time) | Нет | Да (actual time) |
| Использование памяти | Нет | Да (Buffers) |
| Побочные эффекты | Нет | Да (данные изменяются) |
Важные нюансы
1. EXPLAIN ANALYZE изменяет данные
Для INSERT, UPDATE, DELETE запрос EXPLAIN ANALYZE реально выполняет операцию. Для безопасного анализа оборачивайте в транзакцию с откатом:
BEGIN;
EXPLAIN ANALYZE
UPDATE orders SET status = 'cancelled' WHERE created_at < NOW() - INTERVAL '1 year';
ROLLBACK;
2. Разница между оценкой и реальностью
Большое расхождение между оценочным rows и фактическим rows — сигнал того, что статистика таблицы устарела или планировщик принял неверное решение.
-- Планировщик ожидал 10 строк, а получил 47
-- Это может привести к выбору неоптимального плана
-- Решение: обновить статистику
ANALYZE orders;
3. Дополнительные опции
-- Подробный вывод с информацией о буферах
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 123;
-- Вывод в формате JSON
EXPLAIN (ANALYZE, FORMAT JSON)
SELECT * FROM orders WHERE user_id = 123;
-- Включить вывод времени инициализации узлов плана
EXPLAIN (ANALYZE, TIMING)
SELECT * FROM orders WHERE user_id = 123;
Когда использовать что
- EXPLAIN — для быстрой проверки плана без побочных эффектов, для SELECT-запросов в production.
- EXPLAIN ANALYZE — для детального анализа производительности, для поиска узких мест, для проверки гипотез об оптимизации. В production — с осторожностью (выполняет запрос).
Практический пример анализа
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.amount, u.name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.created_at > '2024-01-01'
ORDER BY o.amount DESC
LIMIT 10;
В выводе обратите внимание на:
- Nested Loop vs Hash Join — какой тип соединения выбран
- Index Scan vs Seq Scan — используется ли индекс
- Sort Method — сортировка в памяти или на диске
- Buffers: shared hit/read — сколько блоков взято из кэша и с диска
- actual rows vs rows — насколько точны оценки планировщика
Вопрос 6. Что такое CTE (Common Table Expression) в SQL?
Таймкод: 00:06:22
Ответ собеседника: Правильный. CTE — это виртуальные таблицы, определяемые с помощью конструкции WITH. Они существуют только в рамках одного запроса, могут использоваться несколько раз в одном запросе, а также поддерживают рекурсивные запросы.
Правильный ответ:
Ответ собеседника корректен. Раскроем тему глубже.
Базовый синтаксис CTE
CTE (Common Table Expression) — это именованный временный результирующий набор, который существует только во время выполнения одного запроса. Определяется с помощью ключевого слова WITH.
WITH active_users AS (
SELECT id, name, email
FROM users
WHERE last_login > NOW() - INTERVAL '30 days'
)
SELECT * FROM active_users WHERE name LIKE 'A%';
Множественные CTE
Можно определить несколько CTE через запятую, и каждый последующий может ссылаться на предыдущие.
WITH
active_users AS (
SELECT id, name
FROM users
WHERE last_login > NOW() - INTERVAL '30 days'
),
recent_orders AS (
SELECT user_id, SUM(amount) AS total_spent
FROM orders
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY user_id
)
SELECT au.name, ro.total_spent
FROM active_users au
JOIN recent_orders ro ON ro.user_id = au.id
ORDER BY ro.total_spent DESC;
Преимущества CTE перед подзапросами
1. Читаемость
CTE делают сложные запросы более структурированными и понятными. Логику можно разбить на именованные блоки.
-- С подзапросом — сложно читать
SELECT * FROM (
SELECT user_id, SUM(amount) AS total
FROM orders
GROUP BY user_id
) t WHERE total > 1000;
-- С CTE — понятно
WITH user_totals AS (
SELECT user_id, SUM(amount) AS total
FROM orders
GROUP BY user_id
)
SELECT * FROM user_totals WHERE total > 1000;
2. Мнократное использование
CTE можно reference несколько раз в одном запросе, в отличие от подзапросов, которые пришлось бы дублировать.
WITH monthly_revenue AS (
SELECT DATE_TRUNC('month', created_at) AS month,
SUM(amount) AS revenue
FROM orders
GROUP BY DATE_TRUNC('month', created_at)
)
SELECT
curr.month,
curr.revenue,
prev.revenue AS prev_month_revenue,
ROUND((curr.revenue - prev.revenue) / prev.revenue * 100, 2) AS growth_pct
FROM monthly_revenue curr
LEFT JOIN monthly_revenue prev
ON prev.month = curr.month - INTERVAL '1 month'
ORDER BY curr.month;
Рекурсивные CTE
Мощная возможность для работы с иерархическими или графовыми данными.
-- Получить всех подчинённых сотрудника с id = 1 (иерархия)
WITH RECURSIVE subordinates AS (
-- Базовый случай: начальный сотрудник
SELECT id, name, manager_id, 1 AS depth
FROM employees
WHERE id = 1
UNION ALL
-- Рекурсивный случай: подчинённые
SELECT e.id, e.name, e.manager_id, s.depth + 1
FROM employees e
JOIN subordinates s ON e.manager_id = s.id
)
SELECT * FROM subordinates ORDER BY depth, name;
-- Построить числа Фибоначчи
WITH RECURSIVE fibonacci(n, a, b) AS (
SELECT 1, 0, 1
UNION ALL
SELECT n + 1, b, a + b
FROM fibonacci
WHERE n < 20
)
SELECT n, a AS fib_number FROM fibonacci;
Материализация CTE в PostgreSQL
До версии 12 PostgreSQL всегда материализировал CTE (создавал временную таблицу). Начиная с версии 12, планировщик может встраивать CTE в основной запрос (inline), если это выгоднее.
-- Принудительная материализация (PostgreSQL)
WITH cte_name AS MATERIALIZED (
SELECT * FROM large_table WHERE condition = true
)
SELECT * FROM cte_name;
-- Принудительное встраивание (PostgreSQL 12+)
WITH cte_name AS NOT MATERIALIZED (
SELECT * FROM large_table WHERE condition = true
)
SELECT * FROM cte_name;
Когда использовать CTE, а когда подзапросы
- CTE — когда нужна читаемость, рекурсия, или результат используется многократно.
- Подзапросы — для простых одноразовых вычислений.
- Временные таблицы — когда результат нужен в нескольких запросах или когда планировщик не может выбрать хороший план с CTE.
Практический пример: аналитика
WITH
daily_stats AS (
SELECT
DATE(created_at) AS day,
COUNT(*) AS orders_count,
SUM(amount) AS revenue,
COUNT(DISTINCT user_id) AS unique_customers
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY DATE(created_at)
),
stats_with_avg AS (
SELECT
*,
AVG(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS rolling_7d_avg
FROM daily_stats
)
SELECT *
FROM stats_with_avg
ORDER BY day;
Вопрос 7. Чем отличается фильтрация строк до агрегации и после агрегации в SQL?
Таймкод: 00:07:00
Ответ собеседника: Правильный. Для фильтрации строк до агрегации используется WHERE, а для фильтрации результатов агрегации (групп) используется HAVING.
Правильный ответ:
Ответ собеседника точен. Раскроем тему подробнее с примерами.
Порядок выполнения SQL-запроса
Понимание порядка выполнения операторов критически важно для правильного использования WHERE и HAVING:
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
WHERE — фильтрация строк ДО агрегации
WHERE фильтрует отдельные строки таблицы до того, как они будут сгруппированы и агрегированы. В условии WHERE нельзя использовать агрегатные функции.
-- Найти общую выручку по заказам за 2024 год
SELECT user_id, SUM(amount) AS total_spent
FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' -- фильтр строк
GROUP BY user_id;
Здесь сначала отбираются только строки за 2024 год, а потом происходит группировка и суммирование.
HAVING — фильтрация групп ПОСЛЕ агрегации
HAVING фильтрует уже сгруппированные результаты. В условии HAVING можно использовать агрегатные функции.
-- Найти пользователей, потративших более 10000
SELECT user_id, SUM(amount) AS total_spent
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 10000; -- фильтр после агрегации
Здесь сначала происходит группировка и суммирование, а потом отбираются только те группы, где сумма превышает 10000.
Комбинирование WHERE и HAVING
Можно использовать оба условия одновременно — WHERE отфильтрует строки, HAVING отфильтрует группы.
-- Найти пользователей, потративших более 10000 в 2024 году
SELECT user_id, SUM(amount) AS total_spent, COUNT(*) AS orders_count
FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' -- строки за 2024
GROUP BY user_id
HAVING SUM(amount) > 10000 -- потратили больше 10000
AND COUNT(*) >= 5; -- и сделали минимум 5 заказов
Типичные ошибки
-- ❌ Ошибка: агрегатная функция в WHERE
SELECT user_id, SUM(amount) AS total_spent
FROM orders
WHERE SUM(amount) > 10000 -- ОШИБКА!
GROUP BY user_id;
-- ✅ Правильно: агрегатная функция в HAVING
SELECT user_id, SUM(amount) AS total_spent
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 10000;
-- ❌ Бессмысленно: неагрегатное условие в HAVING (технически возможно, но неэффективно)
SELECT user_id, SUM(amount) AS total_spent
FROM orders
GROUP BY user_id
HAVING created_at >= '2024-01-01' -- неэффективно, строки не отфильтрованы до группировки
-- ✅ Правильно: неагрегатное условие в WHERE
SELECT user_id, SUM(amount) AS total_spent
FROM orders
WHERE created_at >= '2024-01-01' -- строки отфильтрованы до группировки
GROUP BY user_id;
Почему важно использовать WHERE вместо HAVING для неагрегатных условий
WHERE фильтрует строки до группировки, что уменьшает объём данных, которые нужно группировать. HAVING с неагрегатным условием сначала выполнит группировку по всем строкам, а потом отфильтрует — это менее эффективно.
Практический пример: аналитика продаж
-- Отделы, в которых средняя зарплата сотрудников с опытом более 2 лет
-- превышает 5000, и при этом в отделе минимум 3 таких сотрудника
SELECT
department,
COUNT(*) AS employee_count,
ROUND(AVG(salary), 2) AS avg_salary,
MIN(salary) AS min_salary,
MAX(salary) AS max_salary
FROM employees
WHERE experience_years > 2 -- фильтр строк: только опытные
GROUP BY department
HAVING AVG(salary) > 5000 -- фильтр групп: средняя зарплата > 5000
AND COUNT(*) >= 3 -- фильтр групп: минимум 3 человека
ORDER BY avg_salary DESC;
Итог: WHERE работает с отдельными строками до группировки, HAVING — с группами после агрегации. Правильное использование обоих условий обеспечивает и корректность результата, и производительность запроса.
Вопрос 8. Что такое паттерн CQRS (Command Query Responsibility Segregation) и зачем его применяют?
Таймкод: 00:07:18
Ответ собеседника: Правильный. CQRS — это архитектурный паттерн, при котором разделяются модели для чтения (query) и для записи (command). Это удобно, когда нужно масштабировать чтение независимо от записи.
Правильный ответ:
Ответ собеседника верен. Раскроем тему глубже.
Суть CQRS
CQRS (Command Query Responsibility Segregation) — это паттерн, при котором операции чтения (Query) и операции записи (Command) используют разные модели данных. Это развитие принципа CQS (Command Query Separation) Бертрада Мейера, применённое на уровне архитектуры системы.
Типичная архитектура без CQRS
В классическом подходе используется одна модель и одна база данных для чтения и записи:
[Client] → [Service] → [Модель] → [База данных]
Одна и та же структура данных обслуживает как сложные запросы на чтение (с JOIN, агрегациями), так и операции записи (с валидацией, бизнес-правилами, транзакциями).
Архитектура с CQRS
[Client]
│
├── Command → [Command Handler] → [Write Model] → [Write DB]
│ │
│ (события)
│ │
└── Query → [Query Handler] → [Read Model] → [Read DB]
Команды (Commands) — это намерение изменить состояние системы. Они не возвращают данные (или возвращают только статус/ID).
// Пример команды на Go
type CreateOrderCommand struct {
UserID int64
Items []OrderItem
}
type CreateOrderHandler struct {
db *sql.DB
}
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (int64, error) {
// Валидация, бизнес-логика, запись в write модель
tx, err := h.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
// Создание заказа, проверка наличия товара, списание и т.д.
orderID, err := h.createOrder(tx, cmd)
if err != nil {
return 0, err
}
return orderID, tx.Commit()
}
Запросы (Queries) — это запросы на получение данных. Они не изменяют состояние системы.
type GetOrderQuery struct {
OrderID int64
}
type OrderView struct {
OrderID int64
UserName string
TotalAmount float64
Status string
Items []OrderItemView
CreatedAt time.Time
}
type GetOrderHandler struct {
db *sql.DB // может быть отдельная read база
}
func (h *GetOrderHandler) Handle(ctx context.Context, q GetOrderQuery) (*OrderView, error) {
// Чтение из оптимизированной read модели
// Могут быть денормализованные таблицы, материализованные представления и т.д.
return h.getOrderFromReadModel(ctx, q.OrderID)
}
Преимущества CQRS
1. Независимое масштабирование
Чтение и запись масштабируются отдельно. Если 90% трафика — это чтение, можно масштабировать только read-часть.
┌─── Read Replica 1
[Write DB] → 同步 ──┼─── Read Replica 2
└─── Read Replica 3
2. Оптимизация моделей под свою задачу
Write-модель оптимизирована для целостности данных (нормализованная, с ограничениями). Read-модель оптимизирована для быстрого чтения (денормализованная, с предвычисленными полями).
-- Write модель: нормализованная, для транзакций
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
status VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL,
price DECIMAL(10,2) NOT NULL
);
-- Read модель: денормализованная, для быстрых запросов
CREATE TABLE order_views (
order_id BIGINT PRIMARY KEY,
user_name VARCHAR(200) NOT NULL,
user_email VARCHAR(200) NOT NULL,
total_amount DECIMAL(12,2) NOT NULL,
items_count INT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- Запрос к read модели: один SELECT без JOIN
SELECT * FROM order_views WHERE order_id = 123;
3. Разделение ответственности
Команда может работать над логикой записи, не затрагивая логику чтения, и наоборот.
4. Упрощение сложных запросов
Read-модель может быть спроектирована под конкретные запросы UI, без необходимости усложнять write-модель.
Недостатки и сложности CQRS
1. Сложность архитектуры
Два набора моделей, синхронизация данных, обработка событий — всё это увеличивает сложность системы.
2. Конечная согласованность (Eventual Consistency)
Данные в read-модели обновляются с задержкой относительно write-модели. Пользователь может создать заказ и не увидеть его сразу в списке.
Write DB → Event → Read DB
(задержка: мс — с)
3. Синхронизация данных
Нужен механизм доставки событий из write-модели в read-модель: брокеры сообщений (Kafka, RabbitMQ), Change Data Capture (Debezium), триггеры в БД.
Когда применять CQRS
- Высокая нагрузка на чтение, значительно превышающая нагрузку на запись
- Сложные запросы на чтение, которые плохо ложатся на write-модель
- Необходимость независимого масштабирования чтения и записи
- Сложная бизнес-логика записи, которую нужно изолировать от логики чтения
- Командная работа: разные команды отвечают за чтение и запись
Когда НЕ применять CQRS
- Простые CRUD-приложения
- Небольшие проекты с низкой нагрузкой
- Когда нет ресурсов на поддержание сложной архитектуры
- Когда требуется строгая консистентность между чтением и записью
Связь с Event Sourcing
CQRS часто используется вместе с Event Sourcing, но это не обязательно. Event Sourcing — это способ хранения состояния в виде последовательности событий. CQRS — это разделение моделей чтения и записи. Они дополняют друг друга, но могут применяться независимо.
Вопрос 9. Каковы плюсы и минусы монолитной архитектуры и микросервисов?
Таймкод: 00:08:00
Ответ собеседника: Правильный. Монолит проще для старта: легче держать в голове, выше скорость взаимодействия, проще тестировать. Микросервисы оправданы при определённом размере компании и нагрузки, позволяют независимо деплоиться, но усложняют тестирование и требуют зрелости инфраструктуры.
Правильный ответ:
Ответ собеседника полный и сбалансированный. Систематизируем и дополним.
Монолитная архитектура
Весь код приложения находится в одном развёртываемом артефакте.
Плюсы монолита
- Простота разработки на старте — один репозиторий, один процесс, простой деплой
- Высокая производительность взаимодействия — вызовы между модулями — это вызовы функций в рамках одного процесса, без сетевой задержки
- Простота тестирования — можно поднять всё приложение целиком в тестовом окружении
- Простота отладки — один процесс, один лог, сквозной трейсинг без дополнительных инструментов
- Атомарность деплоя — одна версия кода, нет проблем совместимости между сервисами
- Транзакционная целостность — одна база данных, ACID-транзакции покрывают все изменения
// В монолите вызов между модулями — это просто вызов функции
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
// Проверяем пользователя — прямой вызов, без HTTP/gRPC
user, err := s.userService.GetUser(ctx, req.UserID)
if err != nil {
return nil, err
}
// Проверяем наличие товара — прямой вызов
available, err := s.inventoryService.CheckAvailability(ctx, req.Items)
if err != nil {
return nil, err
}
// Всё в одной транзакции
return s.orderRepo.Create(ctx, req, user)
}
Минусы монолита
- Сложность масштабирования — нельзя масштабировать отдельные части, только всё приложение целиком
- Зависимость от одного стека технологий — сложно использовать разные языки или фреймворки
- Рост кодовой базы — со временем монолит становится трудно поддерживать
- Долгий деплой — даже маленькое изменение требует пересборки и перезапуска всего приложения
- Блокировка команд — все команды работают с одной кодовой базой, конфликты неизбежны
- Единая точка отказа — баг в одном модуле может уронить всё приложение
Микросервисная архитектура
Приложение разбито на независимые сервисы, каждый со своей базой данных, развёртываемые и масштабируемые отдельно.
Плюсы микросервисов
- Независимое развёртывание — каждый сервис деплоится отдельно, не затрагивая другие
- Независимое масштабирование — можно масштабировать только те сервисы, которые под нагрузкой
- Технологическая гибкость — каждый сервис может использовать свой стек технологий
- Автономия команд — каждая команда владеет своими сервисами, принимает решения независимо
- Устойчивость к отказам — падение одного сервиса не обязательно приводит к падению всей системы
- Изоляция доменов — чёткие границы между бизнес-доменами
// В микросервисах вызов между сервисами — сетевой запрос
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
// Проверяем пользователя — HTTP/gRPC вызов к user-service
user, err := s.userClient.GetUser(ctx, req.UserID)
if err != nil {
return nil, fmt.Errorf("user service unavailable: %w", err)
}
// Проверяем наличие товара — HTTP/gRPC вызов к inventory-service
available, err := s.inventoryClient.CheckAvailability(ctx, req.Items)
if err != nil {
return nil, fmt.Errorf("inventory service unavailable: %w", err)
}
// Транзакция только в рамках своего сервиса
return s.orderRepo.Create(ctx, req, user)
}
Минусы микросервисов
- Сетевое взаимодействие — каждый вызов между сервисами проходит через сеть, что добавляет задержку, точки отказа, необходимость обработки таймаутов и retry
- Распределённые транзакции — нет ACID между сервисами, нужны паттерны Saga, eventual consistency
- Сложность тестирования — интеграционные тесты требуют поднятия нескольких сервисов или использования моков/стабов
- Операционная сложность — нужен оркестратор (Kubernetes), service mesh, централизованный мониторинг, логирование, трейсинг
- Дублирование данных — каждый сервис хранит свою копию нужных данных, что создаёт проблемы с консистентностью
- Сложность отладки — запрос проходит через множество сервисов, нужен распределённый трейсинг (Jaeger, Zipkin)
- Высокая стоимость — больше инфраструктуры, больше DevOps-инженеров, больше мониторинга
Сравнительная таблица
| Критерий | Монолит | Микросервисы |
|---|---|---|
| Сложность старта | Низкая | Высокая |
| Производительность вызовов | Высокая (in-process) | Ниже (сеть) |
| Масштабирование | Только всё целиком | Каждый сервис отдельно |
| Деплой | Один артефакт | Независимый деплой |
| Тестирование | Простое | Сложное |
| Транзакционная целостность | ACID | Eventual consistency |
| Отказоустойчивость | Низкая | Высокая |
| Стоимость инфраструктуры | Низкая | Высокая |
| Размер команды | Маленькая–средняя | Большая |
Рекомендации по выбору
- Начинайте с монолита — если проект новый, команда небольшая, нагрузка неизвестна
- Модульный монолит — разделяйте код на чёткие модули внутри монолита, чтобы при необходимости выделить их в сервисы
- Переходите к микросервисам — когда монолит становится узким местом, команды растут, нужна независимая масштабируемость
- Не разбивайте слишком рано — преждевременное разделение на микросервисы — одна из самых распространённых ошибок
Модульный монолит как компромисс
// Внутри монолита — чёткие границы модулей
package order
type Service interface {
CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error)
GetOrder(ctx context.Context, orderID int64) (*Order, error)
}
// Зависимости через интерфейсы, а не прямые вызовы
type userService interface {
GetUser(ctx context.Context, userID int64) (*User, error)
}
type inventoryService interface {
CheckAvailability(ctx context.Context, items []Item) (bool, error)
}
Такой подход позволяет при необходимости легко выделить модуль в отдельный сервис — интерфейсы уже определены, границы чёткие.
Вопрос 10. Почему в монолите проще тестировать взаимодействие компонентов, чем в микросервисах?
Таймкод: 00:10:01
Ответ собеседника: Правильный. В монолите компоненты взаимодействуют в рамках одного процесса, можно легко замокать один сервис и протестировать другой. В микросервисах нужно тестировать взаимодействие через сеть, что значительно трудозатратнее.
Правильный ответ:
Ответ собеседника верен. Раскроем тему детальнее.
Тестирование в монолите
В монолите все компоненты работают в рамках одного процесса. Это даёт несколько ключевых преимуществ для тестирования.
1. Мокирование через интерфейсы
Зависимости передаются через интерфейсы, и в тестах их можно легко заменить на моки.
// Интерфейс зависимости
type UserRepository interface {
GetUser(ctx context.Context, id int64) (*User, error)
}
// Реальный сервис зависит от интерфейса
type OrderService struct {
userRepo UserRepository
orderRepo OrderRepository
}
func (s *OrderService) CreateOrder(ctx context.Context, userID int64) (*Order, error) {
user, err := s.userRepo.GetUser(ctx, userID)
if err != nil {
return nil, err
}
// бизнес-логика...
return s.orderRepo.Create(ctx, user)
}
// Тест с моком
func TestCreateOrder(t *testing.T) {
mockUserRepo := &mockUserRepository{
users: map[int64]*User{
1: {ID: 1, Name: "Иван"},
},
}
mockOrderRepo := &mockOrderRepository{}
svc := &OrderService{
userRepo: mockUserRepo,
orderRepo: mockOrderRepo,
}
order, err := svc.CreateOrder(context.Background(), 1)
require.NoError(t, err)
assert.Equal(t, "Иван", order.UserName)
}
2. Интеграционные тесты с реальной базой данных
Можно поднять всё приложение с тестовой базой данных и выполнить сквозной тест.
func TestCreateOrderIntegration(t *testing.T) {
// Поднимаем контейнер с PostgreSQL (testcontainers)
db := setupTestDB(t)
defer db.Close()
// Инициализируем все репозитории и сервисы
userRepo := postgres.NewUserRepository(db)
orderRepo := postgres.NewOrderRepository(db)
svc := NewOrderService(userRepo, orderRepo)
// Создаём пользователя
user, _ := userRepo.Create(context.Background(), &User{Name: "Иван"})
// Выполняем операцию
order, err := svc.CreateOrder(context.Background(), user.ID)
// Проверяем результат
require.NoError(t, err)
assert.NotZero(t, order.ID)
// Проверяем, что данные действительно записались в БД
savedOrder, _ := orderRepo.GetByID(context.Background(), order.ID)
assert.Equal(t, "Иван", savedOrder.UserName)
}
3. Атомарность транзакций в тестах
Можно обернуть тест в транзакцию и откатить её после завершения теста — тесты не загрязняют базу данных.
func TestWithTransaction(t *testing.T) {
db := setupTestDB(t)
tx, _ := db.Begin()
defer tx.Rollback() // откатываем после теста
repo := postgres.NewOrderRepository(tx)
// тестируем...
}
Тестирование в микросервисах
В микросервисной архитектуре каждый сервис — отдельный процесс, часто на отдельном сервере. Это создаёт дополнительные сложности.
1. Сетевое взаимодействия
Нужно тестировать не только логику, но и корректность сетевого взаимодействия: сериализацию/десериализацию, обработку таймаутов, retry-логику, circuit breaker.
// В микросервисах вызов — это HTTP/gRPC запрос
func (s *OrderService) CreateOrder(ctx context.Context, userID int64) (*Order, error) {
// Сетевой вызов к user-service
resp, err := s.httpClient.Get(fmt.Sprintf("http://user-service/users/%d", userID))
if err != nil {
return nil, fmt.Errorf("user service unavailable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrUserNotFound
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decode user response: %w", err)
}
// ...
}
2. Необходимость поднятия зависимых сервисов
Для интеграционных тестов нужно поднять все сервисы, от которых зависит тестируемый. Это делается через Docker Compose, Kubernetes или специализированные инструменты.
# docker-compose.test.yml
version: '3'
services:
order-service:
build: ./order-service
depends_on:
- user-service
- inventory-service
- postgres
user-service:
build: ./user-service
depends_on:
- postgres
inventory-service:
build: ./inventory-service
depends_on:
- postgres
postgres:
image: postgres:16
3. Контрактное тестирование (Contract Testing)
Поскольку сервисы разрабатываются независимо, нужно проверять, что API-контракты между ними соблюдаются. Для этого используются инструменты вроде Pact.
// Потребитель (order-service) определяет ожидания от провайдера (user-service)
func TestUserServiceProvider(t *testing.T) {
pact := dsl.Pact{
Consumer: "order-service",
Provider: "user-service",
}
pact.AddInteraction().
Given("user with ID 1 exists").
UponReceiving("a request for user 1").
WithRequest(dsl.Request{
Method: "GET",
Path: dsl.String("/users/1"),
}).
WillRespondWith(dsl.Response{
Status: 200,
})
// Проверяем, что мок-сервер отвечает как ожидается
}
4. Тестовые дублёры (Test Doubles)
Вместо реальных сервисов используются стабы или моки-серверы.
// Используем httptest для создания мок-сервера user-service
func TestOrderServiceWithMockServer(t *testing.T) {
// Мок-сервер user-service
mockUserServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := User{ID: 1, Name: "Иван"}
json.NewEncoder(w).Encode(user)
}))
defer mockUserServer.Close()
// Настраиваем клиент на мок-сервер
userClient := NewUserClient(mockUserServer.URL)
svc := NewOrderService(userClient)
order, err := svc.CreateOrder(context.Background(), 1)
require.NoError(t, err)
assert.Equal(t, "Иван", order.UserName)
}
5. Проблемы с консистентностью данных
В микросервисах каждый сервис имеет свою базу данных. В тестах нужно синхронизировать состояние нескольких баз, что значительно сложнее, чем работа с одной базой в монолите.
Сравнение подходов к тестированию
| Аспект | Монолит | Микросервисы |
|---|---|---|
| Unit-тесты | Просто (моки через интерфейсы) | Просто (моки через интерфейсы) |
| Интеграционные тесты | Один процесс, одна БД | Несколько процессов, несколько БД |
| Сквозные тесты (E2E) | Один процесс | Все сервисы + сеть + оркестратор |
| Контрактные тесты | Не нужны | Обязательны |
| Время выполнения тестов | Быстрое | Медленное |
| Стабильность тестов | Высокая | Низкая (flaky tests) |
| Настройка окружения | Простая | Сложная (Docker Compose, Kubernetes) |
Итог: Монолит проще тестировать, потому что всё взаимодействие происходит в рамках одного процесса. В микросервисах добавляется сетевой слой, необходимость координации нескольких сервисов, контрактное тестирование и сложная инфраструктура для тестового окружения. Именно поэтому тестирование — одна из главных причин, по которой микросервисы считаются «дорогой» архитектурой.
Вопрос 11. Из какого языка пришёл разработчик в Go и рассматривает ли он возврат обратно на C++ или переход на Rust?
Таймкод: 00:13:08
Ответ собеседния: Правильный. Разработчик пришёл из C и C++, работал с микроконтроллерами и высокопроизводительными веб-сервисами на C++ с Boost.Asio. Перешёл на Go из-за стартапов и современной экосистемы. Возвращаться на C++ не планирует — много legacy, мало новых проектов. Между C++ и Rust выбрал бы Rust, но Go пока не надоел.
Правильный ответ:
Это вопрос о профессиональном пути и предпочтениях разработчика, поэтому правильный ответ — это скорее анализ типичных причин перехода между языками и их обоснованность.
Почему разработчики переходят с C++ на Go
Ответ собеседника типичен для разработчиков, пришедших из C/C++ в Go. Основные причины таких переходов:
1. Скорость разработки
Go значительно ускоряет разработку по сравнению с C++. Компиляция за секунды вместо минут, отсутствие ручного управления памятью, встроенный сборщик мусора, простой синтаксис.
// Go: простой HTTP-сервер за 10 строк
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
})
http.ListenAndServe(":8080", nil)
}
// C++: аналогичный сервер с Boost.Asio — значительно больше кода
#include <boost/asio.hpp>
#include <boost/beast.hpp>
// ... десятки строк boilerplate-кода
2. Конкурентность из коробки
Горутины и каналы делают конкурентное программирование значительно проще, чем потоки и мьютексы в C++.
// Go: запуск 10000 конкурентных задач
func processItems(items []Item) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(i Item) {
defer wg.Done()
process(i)
}(item)
}
wg.Wait()
}
3. Современная экосистема
Go создан для облачных приложений, микросервисов и DevOps-инструментов. Docker, Kubernetes, Terraform, Prometheus — всё написано на Go.
Почему C++ не возвращается
Аргумент собеседника о legacy-проектах справедлив. Новые проекты на C++ — это обычно:
- Системное программирование (драйверы, ОС)
- Высокопроизводительные вычисления (HPC, игры, финансы)
- Встраиваемые системы
- Работа с железом
Для веб-сервисов, микросервисов и облачных приложений C++ используется редко из-за сложности разработки и длительных циклов итерации.
C++ vs Rust: почему Rust привлекает бывших C++ разработчиков
Rust занимает нишу C++ (безопасность памяти без сборщика мусора), но предлагает современную систему типов и гарантии безопасности на этапе компиляции.
// Rust: безопасность памяти без GC
fn process_items(items: Vec<Item>) {
items.par_iter().for_each(|item| {
process(item);
});
}
Преимущества Rust для C++ разработчиков:
- Ownership и borrowing — безопасность памяти без сборщика мусора
- Zero-cost abstractions — как и в C++, абстракции не дают оверхеда в runtime
- Современная экосистема — Cargo, crates.io, активное сообщество
- Интересные задачи — WebAssembly, блокчейн, системное программирование
Однако у Rust есть и минусы:
- Крутая кривая обучения — borrow checker требует переосмысления подхода к управлению памятью
- Медленная компиляция — сопоставима с C++
- Меньше вакансий — рынок Rust пока значительно меньше, чем Go или C++
Почему Go остаётся популярным выбором
Go занимает «золотую середину» между производительностью C++/Rust и простотой Python/JavaScript. Для бэкенд-разработки, микросервисов и инфраструктурных инструментов он остаётся одним из лучших компромиссов:
- Быстрая компиляция
- Простой синтаксис
- Отличная поддержка конкурентности
- Зрелая экосистема
- Высокий спрос на рынке труда
