Общая архитектура: слои, стили, консистентность и протоколы взаимодействия
Кратко о теме
Заголовок раздела «Кратко о теме»«Общая архитектура» на собеседовании — это не какой-то один раздел знаний, а проверка того, умеете ли вы рассуждать о системе на уровне выше функции и пакета: где проходят границы, кто от кого зависит, что происходит при отказе части системы, чем вы платите за каждое решение. Практически все вопросы этого блока сводятся к трём осям: зависимости внутри процесса (слои, чистая архитектура, DIP, «верхний/нижний уровень»), взаимодействие между процессами (REST/gRPC/HTTP-версии, событийная архитектура, брокеры) и данные и их консистентность (CAP/PACELC, уровни изоляции, репликация, кеши, поисковые индексы).
Модель, которую стоит держать в голове для первой оси, простая: у кода есть «политика» (бизнес-правила, ради которых система существует) и «детали» (СУБД, HTTP-фреймворк, брокер, файловая система, чужой API). Все архитектурные стили — многослойная, луковая, гексагональная, чистая — это разные формулировки одного правила: детали зависят от политики, а не наоборот, и достигается это тем, что политика объявляет интерфейс, а деталь его реализует. В Go это получается особенно естественно, потому что интерфейсы удовлетворяются неявно и объявляются на стороне потребителя — импортировать пакет с реализацией не нужно.
Для второй оси ключевая мысль — синхронный вызов создаёт временну́ю связанность (temporal coupling): если ваш сервис синхронно ходит в пять чужих, ваша доступность есть произведение их доступностей, а ваша латентность — сумма их латентностей плюс хвосты. Асинхронное взаимодействие через брокер разрывает эту связь ценой отказа от немедленной согласованности: вы получаете eventual consistency, дубликаты (at-least-once), необходимость идемпотентности и порядок только в рамках партиции. Выбор протокола (REST против gRPC) — это подвыбор внутри синхронного варианта, и он определяется не «gRPC быстрее», а тем, кто потребитель и как контракт будет эволюционировать.
Для третьей оси важно не путать три разных «консистентности»: C в ACID (транзакция переводит БД из одного корректного по инвариантам состояния в другое), C в CAP (линеаризуемость — все читатели видят единый порядок операций), и согласованность реплик (насколько реплика отстала от primary). Вопрос «что такое консистентность и как она достигается в PostgreSQL» на собеседовании почти всегда требует, чтобы вы сначала развели эти три смысла, а потом уже говорили про MVCC, WAL, уровни изоляции и синхронную репликацию.
Вопросы и ответы
Заголовок раздела «Вопросы и ответы»cap/pacelc теоремы
Заголовок раздела «cap/pacelc теоремы»Коротко. CAP (формализация Гилберта и Линч, 2002) утверждает: распределённая система с сетевыми разделениями не может одновременно обеспечивать линеаризуемость (C) и доступность каждой ноды (A) — при разделении (P) приходится выбирать между C и A. PACELC (Абади, 2010) достраивает картину: if P then A or C, else L or C — то есть даже когда сети всё хорошо, вы всё равно выбираете между низкой латентностью и строгой согласованностью.
Глубже. Ключевая частая ошибка — трактовать CAP как «выбери два из трёх». P не выбирают: сетевые разделения случаются, и отказаться от них можно только в одномашинной системе. Выбор всегда бинарный и только на время разделения: либо продолжать отвечать, отдавая потенциально устаревшие/расходящиеся данные (AP), либо отказывать в обслуживании меньшинству, сохраняя единый порядок операций (CP). Строгий C в CAP — это именно линеаризуемость, а не C из ACID; это две разные буквы с разным смыслом, и путать их — классический провал.
PACELC ценнее на практике, потому что разделения редки, а компромисс «латентность против согласованности» вы платите каждый запрос. Классификация: Cassandra и DynamoDB — PA/EL (кворумы настраиваемые, по умолчанию быстро и слабосогласованно), HBase и классический Bigtable — PC/EC, MongoDB с дефолтным w:1 — PA/EC-подобная (настраивается через writeConcern/readConcern), Google Spanner — PC/EC (жертвует латентностью ради внешней согласованности за счёт TrueTime и Paxos). PostgreSQL с одним primary и асинхронными репликами: при чтении с реплики вы фактически в EL, при synchronous_commit=remote_apply и кворумной синхронной репликации — в EC. Хороший ответ на собеседовании звучит так: «CAP — про поведение в момент разделения, PACELC — про то, что мы платим всегда; выбор делается не для системы целиком, а для каждой операции: баланс счёта читаем строго, ленту рекомендаций — из реплики».
За счет чего на уровне serializable достигается консистентность бд?
Заголовок раздела «За счет чего на уровне serializable достигается консистентность бд?»Коротко. Serializable гарантирует, что результат конкурентного выполнения транзакций эквивалентен какому-то их последовательному выполнению. Достигается это одним из трёх механизмов: строгая двухфазная блокировка с предикатными/gap-локами (S2PL), последовательное исполнение на одном потоке, или — как в PostgreSQL с 9.1 — SSI, Serializable Snapshot Isolation: транзакции работают на снимках как в Repeatable Read, но движок отслеживает конфликты чтения-записи и аварийно завершает транзакции, которые могли бы дать несериализуемый результат.
Глубже. SSI (реализация на основе работы Cahill/Röhm/Fekete) строится вокруг наблюдения: снимковая изоляция нарушает сериализуемость только при появлении в графе конфликтов особой структуры — транзакции-«пивота», у которой есть одновременно входящее и исходящее rw-антизависимое ребро (кто-то прочитал то, что она перезаписала, и она прочитала то, что перезаписал кто-то ещё). PostgreSQL берёт на чтениях так называемые SIREAD-локи — это не блокировки в обычном смысле, они никого не останавливают, а только регистрируют факт «эта транзакция читала вот эти строки/страницы/предикат». Когда обнаруживается опасная структура, одна из транзакций получает ошибку 40001 could not serialize access due to read/write dependencies among transactions.
Отсюда три практических следствия. Первое: приложение обязано уметь ретраить транзакцию на 40001, иначе Serializable в PostgreSQL просто не работает — это не опциональная деталь, а часть контракта. Второе: SIREAD-локи могут эскалироваться со строк на страницы и на отношение при нехватке памяти (max_pred_locks_per_transaction), что увеличивает долю ложных срабатываний. Третье: читающие транзакции стоит помечать SET TRANSACTION READ ONLY DEFERRABLE — тогда PostgreSQL дождётся безопасного снимка и такая транзакция гарантированно не будет ни отменена, ни причиной отмены другой. Serializable в PostgreSQL не берёт лишних блокировок и потому хорошо ведёт себя при низкой конкуренции, но деградирует при высокой — там иногда дешевле явные SELECT ... FOR UPDATE на Read Committed. В MySQL/InnoDB подход другой — там SERIALIZABLE реализован через 2PL: обычные SELECT неявно превращаются в SELECT ... LOCK IN SHARE MODE плюс gap-локи, что даёт настоящие блокировки и дедлоки вместо отмен.
-- типичная обёртка на стороне приложенияBEGIN ISOLATION LEVEL SERIALIZABLE;-- ... работа ...COMMIT; -- при SQLSTATE 40001 -> ROLLBACK и повтор всей транзакции целикомЧто такое событийно-ориентированная архитектура?
Заголовок раздела «Что такое событийно-ориентированная архитектура?»Коротко. Это стиль, в котором компоненты обмениваются не командами «сделай», а фактами «произошло»: продюсер публикует событие в брокер, не зная, кто его прочитает, а потребители подписываются независимо. Результат — слабая связанность и хорошая масштабируемость на запись ценой eventual consistency, дубликатов и сложной отладки.
Глубже. Полезно различать четыре разных вещи, которые часто валят в одну кучу. Event notification — тонкое событие «заказ №42 оплачен», потребитель при необходимости идёт за деталями обратно синхронно; связанность низкая, но появляется обратный вызов. Event-carried state transfer — событие несёт достаточное состояние, чтобы потребитель ничего не переспрашивал; убирает синхронные вызовы, но раздувает сообщения и дублирует данные. Event sourcing — события становятся источником истины, текущее состояние есть свёртка (fold) потока событий; даёт полный аудит и time-travel, но требует версионирования схемы событий, снапшотов и очень аккуратной миграции. CQRS — разделение модели записи и модели чтения, часто, но не обязательно, вместе с событиями.
Инженерные обязательства, без которых EDA превращается в источник инцидентов: доставка почти всегда at-least-once, значит обработчики обязаны быть идемпотентными (таблица обработанных message_id, либо естественная идемпотентность через upsert по бизнес-ключу); порядок гарантирован только в пределах партиции, поэтому ключ партиционирования выбирается по сущности (order_id, user_id); публикация события и запись в БД должны быть атомарны — это transactional outbox (пишем событие в таблицу в той же транзакции, отдельный процесс или CDC вроде Debezium доставляет в брокер), потому что «сохранили в БД, потом упали до publish» — самый частый баг; нужны DLQ и стратегия для poison-сообщений; нужна схема с контролем совместимости (Schema Registry, Avro/Protobuf, buf breaking). Распределённые бизнес-транзакции строятся как saga с компенсациями, а не как 2PC.
Столкнулись ли с чистой/гексогональной архитектурой?
Заголовок раздела «Столкнулись ли с чистой/гексогональной архитектурой?»Коротко. Да — это семейство подходов (Ports & Adapters Кокбёрна, Onion Палермо, Clean Architecture Мартина) с одним общим правилом: зависимости направлены внутрь, к доменной модели, а всё внешнее (HTTP, СУБД, брокер, чужие API) подключается через порты-интерфейсы, объявленные внутри. Домен не импортирует ни драйвер БД, ни веб-фреймворк.
Глубже. Если вопрос задан в формате «сталкивались ли», интервьюер хочет услышать не пересказ книги, а конкретику: как у вас лежали пакеты, что вы получили и чем заплатили. Рабочий каркас ответа: назвать структуру, назвать правило зависимостей, назвать выгоду (подмена инфраструктуры и тестируемость без БД), честно назвать цену (лишние маппинги DTO↔сущность, больше кода на простой CRUD) и сказать, где вы её не платили — потому что грамотный ответ включает «мы применили это к платёжному домену, а сервис-прокси оставили двухслойным».
Типичная раскладка в Go, которую можно нарисовать на доске:
// internal/domain/order.go — политика, ноль внешних зависимостейpackage domain
type Order struct { ID string Total int64 Status Status}
func (o *Order) Pay() error { /* инварианты домена */ return nil }
// internal/usecase/order.go — сценарии; порт объявлен здесь, у потребителяpackage usecase
import ( "context"
"example.com/shop/internal/domain")
type OrderRepo interface { Get(ctx context.Context, id string) (*domain.Order, error) Save(ctx context.Context, o *domain.Order) error}
type PayOrder struct{ repo OrderRepo }
func (uc PayOrder) Handle(ctx context.Context, id string) error { o, err := uc.repo.Get(ctx, id) if err != nil { return err } if err := o.Pay(); err != nil { return err } return uc.repo.Save(ctx, o)}
// internal/adapters/postgres/order.go — деталь, импортирует usecase/domain, но не наоборотКлючевой момент, который стоит проговорить: интерфейс OrderRepo живёт в usecase, а не в postgres. Именно это делает инверсию зависимости реальной и исключает циклический импорт; в Java/C# так не получается без отдельного пакета интерфейсов, а в Go получается благодаря неявной реализации интерфейсов.
Расскажите про многослойную архитектуру;
Заголовок раздела «Расскажите про многослойную архитектуру;»Коротко. Классические слои: presentation (HTTP/gRPC-хендлеры, валидация формата) → application/service (сценарии, транзакционные границы) → domain (правила) → data access (репозитории, драйверы БД). Каждый слой знает только о слое ниже, вызовы идут сверху вниз, доменные сущности не утекают наружу как JSON-модели.
Глубже. Стоит различать strict layering (слой обращается только к соседнему снизу — больше дисциплины, больше проброса) и relaxed layering (можно перепрыгнуть, например хендлер напрямую в репозиторий для простого чтения — меньше кода, но границы размываются). Также важно не путать слои (logical layers) и физические уровни развёртывания (tiers): три слоя могут жить в одном бинарнике.
Главная болезнь многослойки — анемичная доменная модель: сущности превращаются в мешки полей с геттерами, а вся логика уползает в «сервисы», которые становятся процедурными god-объектами на тысячи строк. Второй частый дефект — «сквозные» изменения: добавление одного поля требует правки DTO, модели, сущности, схемы и четырёх маппингов, что провоцирует срезание углов и утечку *sql.Rows наверх. Третий — транзакция, размазанная по репозиториям: правильно, чтобы границу транзакции задавал application-слой (через Unit of Work, передачу pgx.Tx в контексте или функцию WithTx), а не каждый репозиторий сам по себе. Многослойка — разумный дефолт для сервиса средней сложности; когда домена почти нет (прокси, интеграция, CRUD), два слоя честнее, а когда домен богат — переходят к гексагональной с явными портами.
Приходилось принимать участие в выстраивании архитектуры?
Заголовок раздела «Приходилось принимать участие в выстраивании архитектуры?»Коротко. Это поведенческий вопрос: интервьюер проверяет масштаб вашего участия и способность объяснять решения через ограничения и компромиссы, а не через модные слова. Отвечать надо конкретным кейсом по схеме «контекст и ограничения → варианты → выбор и почему → результат в цифрах → что бы сделал иначе».
Глубже. Что именно хотят услышать: (1) какова была ваша реальная роль — «я предложил и защитил», «я участвовал в обсуждении и написал ADR», «я реализовал чужое решение» — врать здесь бессмысленно, честное «я отвечал за архитектуру одного из сервисов, а границы контекстов определял архитектор» звучит сильнее раздутого «я спроектировал платформу»; (2) какие были нефункциональные требования — RPS, p99, объём данных, SLA, бюджет, дедлайн, размер команды; (3) какие варианты вы рассматривали и почему отвергли — ответ без отвергнутых альтернатив выглядит как решение, принятое по инерции; (4) как вы измерили результат — «p99 упал с 800 мс до 120 мс», «расход на БД снизился на треть»; (5) чем вы заплатили — это самый ценный пункт, зрелость видна именно здесь.
Типичные провалы: рассказ на уровне «мы сделали микросервисы на Go с Kafka», без единой цифры; приписывание себе командных заслуг; отсутствие компромиссов («всё получилось идеально»); неспособность ответить на «а что бы сломалось, если бы нагрузка выросла в 10 раз». Хорошая заготовка — 2–3 истории разного масштаба: одна про изменение внутри сервиса, одна про межсервисное взаимодействие/данные, одна про процесс (ADR, схема ревью, контрактные тесты).
Какими инструментами для визуализации архитектуры пользовался?
Заголовок раздела «Какими инструментами для визуализации архитектуры пользовался?»Коротко. Практический стандарт — C4-модель Саймона Брауна (уровни Context / Container / Component / Code) как язык, плюс «диаграммы как код» рядом с исходниками: PlantUML (C4-PlantUML), Mermaid прямо в Markdown, Structurizr DSL. Для набросков — Excalidraw, draw.io, Miro; для решений — ADR в репозитории.
Глубже. Сильный ответ отличается от слабого не списком тулов, а тем, что вы говорите про процесс: диаграммы живут в том же репозитории, что и код, ревьюятся в PR и рендерятся в CI (Kroki/PlantUML-сервер) — иначе через полгода это музей вранья. Полезно назвать разные типы: C4 для структуры, sequence-диаграммы для сценариев и таймаутов, ERD для данных (dbdiagram.io, DBeaver, schemaspy), state-диаграммы для конечных автоматов заказов. Отдельно стоит упомянуть генерируемые артефакты, которые не врут по определению: сервисный граф из трассировки (Jaeger/Tempo, Kiali в Istio), схема из OpenAPI/.proto, каталог сервисов в Backstage. Формально-текстовые нотации — arc42 как шаблон описания, ADR (MADR-шаблон) как журнал решений. Если спросят про UML: скажите, что из UML в реальной жизни выживают sequence, component и state, а полный UML избыточен.
По архитектуре как общались сервисы? Мобилка куда обращается? gRPC отличие от REST? HTTP2 что дает? отличие от HTTP1?
Заголовок раздела «По архитектуре как общались сервисы? Мобилка куда обращается? gRPC отличие от REST? HTTP2 что дает? отличие от HTTP1?»Коротко. Типовая раскладка: мобильное приложение ходит по HTTPS в API Gateway / BFF (REST+JSON или GraphQL), внутри периметра сервисы общаются синхронно по gRPC (HTTP/2 + Protobuf) и асинхронно через брокер для событий. gRPC отличается от REST строгим кодогенерируемым контрактом, бинарной сериализацией и стримингом; HTTP/2 даёт мультиплексирование, бинарные фреймы и сжатие заголовков вместо текстового «одна пара запрос-ответ на соединение» из HTTP/1.1.
Глубже. По частям.
Мобилка. Наружу почти всегда REST/JSON поверх HTTPS через gateway: он терминирует TLS, аутентифицирует, режет по rate limit, агрегирует ответы и скрывает внутреннюю топологию. BFF (отдельный бекенд под мобильный клиент) нужен, когда мобильному экрану требуется агрегация из 5 сервисов — иначе мобильный клиент начинает знать вашу внутреннюю архитектуру и вы не можете её менять. Чистый gRPC наружу возможен, но упирается в прокси, корпоративные middlebox’ы и отсутствие поддержки в браузере (там нужен gRPC-Web + прокси); для мобильных приложений gRPC вполне применим, но выигрыш обычно съедается сложностью раскатки клиентских версий.
gRPC против REST. gRPC — это RPC поверх HTTP/2 с Protobuf: контракт в .proto, из него генерируются клиент и сервер на всех языках, есть четыре режима вызова (unary, server-streaming, client-streaming, bidirectional), встроенные дедлайны (context.WithTimeout уезжает в заголовок grpc-timeout), метаданные, коды статусов, per-RPC балансировка. REST — архитектурный стиль поверх HTTP: ресурсы, методы, коды, человекочитаемый JSON, кешируемость на уровне HTTP, тривиальная отладка через curl, слабая связанность и терпимость к отсутствию кодогенерации. Производительность: Protobuf компактнее и быстрее парсится, чем JSON, а HTTP/2 экономит на соединениях — на внутреннем трафике это заметно; но если у вас 200 RPS и большие БД-запросы, разница в сериализации в шуме.
HTTP/2 против HTTP/1.1. HTTP/1.1: текстовый протокол, одно соединение обслуживает один запрос в момент времени, pipelining на практике не работает, браузеры компенсируют 6 параллельными соединениями на хост, заголовки отправляются целиком в каждом запросе. HTTP/2: бинарное фреймирование, мультиплексирование множества потоков в одном TCP-соединении (устраняет HTTP-уровневый head-of-line blocking), HPACK — сжатие и инкрементальное кодирование заголовков, приоритизация потоков и оконное управление потоком (flow control), server push (де-факто отменён и удалён из браузеров). Важная оговорка, которую любят: HTTP/2 не устраняет TCP-уровневый head-of-line blocking — потеря одного сегмента тормозит все потоки соединения; это чинит уже HTTP/3 поверх QUIC/UDP. Второй нюанс: одно долгоживущее HTTP/2-соединение плохо балансируется L4-балансировщиком — для gRPC нужен L7-балансировщик или клиентская балансировка / service mesh.
Что такое верхний уровень и что такое нижний уровень?
Заголовок раздела «Что такое верхний уровень и что такое нижний уровень?»Коротко. В контексте архитектуры это термины принципа инверсии зависимостей: верхний уровень — модули бизнес-политики (сценарии, доменные правила), нижний уровень — модули-детали (доступ к БД, HTTP-клиенты, файлы, брокер). DIP говорит: верхний уровень не должен зависеть от нижнего, оба зависят от абстракции, и абстракцию задаёт верхний уровень.
Глубже. Практический критерий «кто выше»: чем ближе модуль к причине, по которой система вообще существует, и чем реже он меняется из-за смены технологий, тем он выше. Расчёт цены заказа — верх; pgx-репозиторий, кладущий заказ в таблицу, — низ. Проверить направление зависимостей в Go можно буквально по импортам: если пакет domain импортирует github.com/jackc/pgx/v5, инверсии нет. При правильной инверсии интерфейс OrderRepo объявлен в usecase, а postgres его реализует и импортирует usecase — стрелка на графе компиляции идёт снизу вверх, против направления вызова во время выполнения. Это и называется «инверсия».
Стоит держать в голове, что вопрос могут задавать и в других смыслах: «верхний/нижний уровень» сетевого стека (прикладной против канального в OSI/TCP-IP), «высокоуровневый/низкоуровневый язык», «верхний уровень API» (публичный фасад) против «нижнего» (внутренние примитивы). Правильная тактика — уточнить контекст одной фразой и дальше отвечать про DIP, если разговор шёл про архитектуру.
Является ли использование заглушек в тестах признаком плохой архитектуры?
Заголовок раздела «Является ли использование заглушек в тестах признаком плохой архитектуры?»Коротко. Само по себе — нет: заглушка на границе с внешним миром (чужой HTTP-API, платёжный шлюз, отправка SMS) это нормально и обязательно. Плохой архитектурой пахнет не факт использования моков, а их количество и глубина: если для одного unit-теста нужно поднять шесть моков или замокать внутренние детали собственного класса, это сигнал о слишком большом числе зависимостей и связанности с реализацией.
Глубже. Полезные различения. Первое — тип дублёра: stub возвращает заготовленные данные, mock ещё и проверяет факт/порядок вызовов, fake — работающая упрощённая реализация (in-memory репозиторий). Fake обычно лучше mock: тест проверяет поведение, а не то, «какой метод был вызван», и не ломается при рефакторинге. Второе — правило «не мокай то, чем не владеешь»: заворачивать чужой SDK нужно в свой узкий порт и мокать порт, потому что мок чужой библиотеки кодирует ваши предположения о ней, которые могут быть неверны. Третье — уровень: моки уместны в unit-тестах, но интеграцию с вашей же БД честнее проверять на реальном PostgreSQL через testcontainers, а не через sqlmock, который проверяет строки SQL, а не работоспособность запроса.
Есть и обратный аргумент: чрезмерное количество моков часто вызвано не архитектурой, а тем, что тестируют не тот уровень. Часто вместо десяти unit-тестов с моками правильнее написать несколько тестов на уровне use case с in-memory адаптерами плюс контрактные тесты (Pact/buf-совместимость) на границах. Хорошая фраза для собеседования: «Заглушки — инструмент изоляции от недетерминированного и медленного. Если их становится больно писать, я воспринимаю это как обратную связь от дизайна: чаще всего значит, что у компонента слишком много обязанностей или что интерфейс слишком широкий».
Какие можете перечислить архитектурные принципы, которые используете в разработке?
Заголовок раздела «Какие можете перечислить архитектурные принципы, которые используете в разработке?»Коротко. Базовый набор: высокая связность и низкая связанность, разделение ответственностей, SOLID (особенно SRP, ISP и DIP), KISS и YAGNI, композиция вместо наследования, явные границы и контракты, проектирование с расчётом на отказ (таймауты, ретраи, идемпотентность), обратная совместимость контрактов, наблюдаемость как требование, а не довесок.
Глубже. Важно не выпалить список, а показать, как принцип превращается в решение. Пример по каждому: low coupling / high cohesion — модуль меняется по одной причине и не тянет за собой соседей; практическая проверка — сколько пакетов приходится трогать ради типичной задачи. ISP в Go — интерфейс из одного-двух методов на стороне потребителя (io.Reader как эталон), а не «репозиторий на 30 методов». DIP — интерфейсы объявляет политика. YAGNI — не выносить второй сервис, пока нет ни отдельного цикла релизов, ни отдельной нагрузки. Design for failure — каждый исходящий вызов имеет дедлайн из context, лимит конкурентности и деградацию по умолчанию. Идемпотентность — все внешние мутации принимают ключ идемпотентности; это принцип, а не «фича Stripe». Обратная совместимость — expand/contract при миграциях схемы, buf breaking в CI для .proto, отсутствие удаления полей в один релиз. Из более широких наборов уместно упомянуть 12-factor для конфигурации и состояния, GRASP/CUPID как альтернативную оптику и «правило наименьшего удивления». Финальный штрих, который отличает сильного кандидата: сказать, что принципы конфликтуют между собой (DRY против низкой связанности, SRP против простоты), и что архитектура — это выбор, какой принцип в этом месте важнее.
У нас сотни тысяч запросов на чтение в базу данных. На два порядка меньше операций записи. Как правильно организовать архитектуру такого регионального хранилища, чтобы обеспечить нормальную скорость для всех?
Заголовок раздела «У нас сотни тысяч запросов на чтение в базу данных. На два порядка меньше операций записи. Как правильно организовать архитектуру такого регионального хранилища, чтобы обеспечить нормальную скорость для всех?»Коротко. Профиль 100k+ RPS чтения при ~1k RPS записи — это классический read-heavy сценарий: один primary на запись, набор read-реплик рядом с пользователями по регионам, агрессивное кеширование (локальный in-process кеш + Redis + CDN для того, что можно), денормализованные read-модели/материализованные представления и явно заданный допустимый лаг чтения для каждой ручки. Основная работа — не «поставить больше реплик», а снять с БД 90–99% чтений кешем и правильно обработать read-your-writes.
Глубже. Разложу по слоям, с арифметикой, потому что интервьюер обычно ждёт именно её.
Кеш. 100k RPS чтения при 95% попаданий в кеш оставляют 5k RPS на БД, при 99% — 1k RPS, что уже спокойно разбирается 3–5 репликами. Значит первое решение — многоуровневый кеш: L1 в процессе (sync.Map/LRU с TTL 1–5 с, снимает горячие ключи и сетевые round-trip’ы), L2 в Redis рядом в регионе, для статичного и полустатичного контента — CDN и HTTP-кеширование (Cache-Control, ETag). Обязательные защиты: stampede/dogpile — при протухании популярного ключа тысячи горутин ломятся в БД; лечится singleflight и TTL с джиттером; negative caching для несуществующих ключей, иначе кеш пробивается мусорными id.
import "golang.org/x/sync/singleflight"
var g singleflight.Group
func (s *Service) GetCard(ctx context.Context, id string) (*Card, error) { if c, ok := s.local.Get(id); ok { return c, nil } v, err, _ := g.Do(id, func() (any, error) { return s.loadFromRedisOrDB(ctx, id) }) if err != nil { return nil, err } return v.(*Card), nil}Репликация и регионы. Один регион-владелец записи (primary) + потоковые реплики в остальных регионах; чтения маршрутизируются в ближайшую реплику, записи — всегда в primary (кросс-регионально, но их мало: 1k RPS записи вытерпит и 100 мс RTT). Мультимастер (BDR, Citus multi-writer, CRDT-хранилища) стоит рассматривать только если запись реально нельзя централизовать — иначе вы покупаете конфликты и их разрешение задорого. Обязательно решить read-your-own-writes: после записи пользователь не должен увидеть старое. Механики: sticky-чтение с primary в течение N секунд после записи для этого пользователя; либо отдавать клиенту LSN коммита (pg_current_wal_insert_lsn()) и на реплике ждать pg_last_wal_replay_lsn() >= lsn; на реплике полезен hot_standby_feedback, но помните, что он тормозит vacuum на primary.
Пул и топология. PgBouncer (transaction pooling) перед каждой репликой — иначе 100k RPS превратятся в десятки тысяч backend-процессов; в приложении — разделение readPool/writePool. Балансировка чтений — через отдельный DNS/сервис-дискавери на реплики, а не через «случайный выбор в коде без health-check».
Модель данных. Ускорять чтения индексами под конкретные запросы (в т.ч. покрывающие с INCLUDE), убирать N+1, использовать материализованные представления или отдельные read-модели (CQRS), обновляемые из событий; при больших объёмах — партиционирование по времени/тенанту. Если запросы аналитические — вынести их в отдельное хранилище (ClickHouse), а не мучить OLTP.
Дисциплина. Для каждого эндпоинта явно записать допустимую устарелость: «баланс — только primary», «карточка товара — до 60 с устаревания», «счётчик просмотров — до 5 минут». Без этого спор «а можно ли с реплики» будет вечным. И измерять: p99 по каждому слою, cache hit ratio, replication lag как SLI с алертом.
Кто отвечал за проектирование архитектуры и технических решений?
Заголовок раздела «Кто отвечал за проектирование архитектуры и технических решений?»Коротко. Поведенческий вопрос про модель принятия решений в команде и вашу роль в ней. Отвечать надо описанием процесса, а не именем: кто предлагал, как обсуждали, кто имел право вето, как фиксировали (RFC/ADR), и что конкретно решали именно вы.
Глубже. Интервьюер обычно проверяет две вещи: (1) не приписываете ли вы себе чужое и (2) умеете ли работать в процессе, а не только «делать как сказали». Хорошие формулировки под разные реальности: «решения внутри сервиса принимала команда, я как техлид владел итоговым выбором и писал ADR; кросс-сервисные вещи выносились на архитектурный комитет раз в две недели»; «у нас был выделенный архитектор, он задавал границы контекстов и общие стандарты, а внутри сервиса свободу имели мы — например, выбор между Kafka и NATS для нашего потока я обосновывал сам»; «формального архитектора не было, работала схема RFC: автор пишет документ, две недели на комментарии, решает тимлид с учётом возражений».
Что усиливает ответ: пример реального разногласия и как оно разрешилось («я был за монолитный модуль, коллега за отдельный сервис; договорились о критерии — отдельный сервис заводим, когда появится независимый цикл релизов; через квартал он появился, вынесли»). Что ослабляет: «архитектуру придумывал я один», «решал начальник, мне не говорили почему», отсутствие любого артефакта решения.
Представьте ситуацию: к вам обращается аналитик, чтобы обосновать заказчику выбор архитектурного подхода. Какие критерии вы назовете для выбора между gRPC и REST?
Заголовок раздела «Представьте ситуацию: к вам обращается аналитик, чтобы обосновать заказчику выбор архитектурного подхода. Какие критерии вы назовете для выбора между gRPC и REST?»Коротко. Критерии по убыванию веса: кто потребитель (браузер/внешний партнёр → REST, внутренний сервис → gRPC); нужен ли строгий кодогенерируемый контракт и полиглотные клиенты; нужен ли стриминг и двунаправленность; требования к латентности и объёму трафика; кешируемость на уровне HTTP; готовность инфраструктуры (L7-балансировка, mesh, наблюдаемость) и квалификация команды; стоимость эволюции контракта и отладки.
Глубже. Формулировка «обосновать заказчику» означает, что ответ должен быть на языке рисков и денег, а не «protobuf быстрее». Разложу критерии в форме, пригодной для документа:
- Аудитория API. Публичный/партнёрский API → REST + OpenAPI: партнёру не нужен ваш toolchain, документация читается человеком, интеграция делается за час. Внутренний трафик между вашими сервисами → gRPC.
- Контракт и совместимость. gRPC даёт машинно-проверяемый контракт:
.protoв отдельном репозитории,buf lintиbuf breakingв CI ломают сборку при несовместимом изменении. С REST того же уровня добиваются через OpenAPI spec-first плюс генерация (oapi-codegen) и линтер (Spectral), но дисциплины требуется больше. - Стриминг и долгие взаимодействия. Нужны серверные потоки, подписки, двунаправленный обмен — gRPC делает это штатно; в REST придётся выбирать SSE, WebSocket или long-polling, то есть городить второй протокол.
- Производительность и стоимость трафика. Protobuf даёт компактнее payload и дешевле парсинг, HTTP/2 — меньше соединений. Реальная выгода видна на высоком RPS и мелких сообщениях; при 100 RPS с тяжёлой БД разница нерелевантна, и это надо честно сказать заказчику, а не продавать gRPC.
- Кеширование. REST кешируется на CDN/прокси стандартными средствами (
GET,ETag,Cache-Control). gRPC — нет; кеш придётся делать внутри приложения. - Эксплуатация и отладка. REST — curl, Postman, логи в браузере, любой WAF/прокси понимает. gRPC —
grpcurl, reflection, требует L7-балансировки (иначе одно долгоживущее соединение прилипнет к одному поду) и корректной поддержки в mesh/ingress. - Ошибки и семантика. REST использует коды HTTP, gRPC — свой набор
codesплюсgoogle.rpc.Status; для внешнего потребителя привычнее первое. - Команда и сроки. Если команда никогда не работала с protobuf, стоимость входа реальна — это тоже критерий.
Итоговая рекомендация, которую обычно и ждут: гибрид — gRPC внутри периметра, REST/JSON наружу, при этом внешний REST генерируется из тех же .proto через grpc-gateway, что убирает дублирование контракта. Заказчику это продаётся как «одна точка правды для контракта, при этом партнёры интегрируются привычным способом».
Приходилось ли вам менять архитектуру сервиса, потому что старая переставала масштабироваться? Что меняли?
Заголовок раздела «Приходилось ли вам менять архитектуру сервиса, потому что старая переставала масштабироваться? Что меняли?»Коротко. Ожидаемая структура ответа: какой именно ресурс упёрся (CPU, соединения к БД, блокировки, сеть, память, latency внешнего вызова), как вы это доказали измерением, какое изменение сделали, какой получили эффект и что при этом ухудшилось. Ответ без метрик до/после считается слабым.
Глубже. Каркас, в который стоит уложить свою историю: симптом (p99 вырос до 2 с при нагрузке X, ошибок 5%) → диагностика (pprof показал, что 60% CPU уходит на JSON-маршалинг; или pg_stat_activity показал 400 активных backend’ов и LWLock contention; или трассировка показала, что 80% времени — ожидание партнёрского API) → гипотеза и минимальное изменение → проверка нагрузочным тестом → раскатка с флагом и откатом.
Типовые изменения, каждое из которых — законный ответ, если у вас такое было: перевод синхронной цепочки вызовов в асинхронную через outbox + Kafka (снимает temporal coupling и пики); добавление read-реплик и кеша с singleflight; введение PgBouncer, потому что упёрлись не в CPU базы, а в число соединений; шардирование/партиционирование горячей таблицы по тенанту или по времени; вынос тяжёлого модуля из монолита в отдельный сервис ради независимого масштабирования (а не ради моды); замена «горутина на запрос без ограничений» на воркер-пул с ограниченной очередью и backpressure; переход с pull-модели «каждый инстанс опрашивает БД» на распределённые задачи с лидером/партициями; денормализация и материализованные проекции вместо джойнов на 5 таблиц; вынос аналитики из OLTP в ClickHouse; замена ретраев без ограничения на ретраи с экспоненциальной задержкой, джиттером и circuit breaker’ом — потому что «ретраи усилили лавину» это очень частая настоящая причина деградации.
Обязательно назовите цену: асинхронность принесла eventual consistency и необходимость идемпотентности; шардирование сломало кросс-шардовые запросы и отчёты; выделение сервиса добавило сетевой хоп и распределённую отладку. Кандидат, который признаёт цену, звучит убедительнее, чем тот, у кого «стало лучше по всем осям».
Как вы документировали API и архитектуру?
Заголовок раздела «Как вы документировали API и архитектуру?»Коротко. API — спецификацией как единственным источником правды: OpenAPI для REST (spec-first + генерация кода), .proto для gRPC с buf в CI, AsyncAPI или Schema Registry для событий. Архитектуру — «docs as code» в репозитории: C4-диаграммы в PlantUML/Mermaid, ADR на каждое значимое решение, README и runbook у каждого сервиса.
Глубже. Основной тезис, который стоит проговорить: документация ценна ровно настолько, насколько она проверяется автоматически. Что можно проверять: сгенерирован ли серверный интерфейс из OpenAPI (oapi-codegen) — тогда код физически не разъедется со спекой; проходит ли buf breaking против main — тогда несовместимое изменение .proto не попадёт в релиз; валидны ли примеры в спеке (Spectral, схема-валидация в тестах); отрендерились ли диаграммы в CI. Комментарии-аннотации в коде (swaggo для Go) — рабочий, но более слабый вариант: спека следует за кодом, а не наоборот, и контракт легче сломать.
Для архитектурного уровня: ADR (Architecture Decision Record, короткий файл docs/adr/0007-kafka-vs-nats.md со структурой контекст/решение/статус/последствия) — самый высокоокупаемый артефакт, потому что через год отвечает на вопрос «почему так, а не иначе». C4 даёт три-четыре уровня масштаба и не превращается в UML-простыню. arc42 — если нужен полноформатный документ по сервису. Для событий — реестр схем с политикой совместимости (BACKWARD/FULL). Для эксплуатации — runbook: как деплоить, что делать по каждому алерту, где дашборды, кто владелец. Для организации в целом — каталог сервисов (Backstage) с владельцами и ссылками. И правило: документ, который не ревьюят в PR вместе с кодом, устареет за квартал — поэтому всё лежит в репозитории сервиса, а не в отдельной вики.
Что такое консистентность, как она достигается на postgresql?
Заголовок раздела «Что такое консистентность, как она достигается на postgresql?»Коротко. Термин перегружен: C в ACID — транзакция переводит БД из одного корректного состояния в другое, не нарушая инвариантов (ограничения, внешние ключи, уникальность, триггеры, бизнес-правила); C в CAP — линеаризуемость чтений в распределённой системе; согласованность реплик — насколько реплика отстала. В PostgreSQL первое обеспечивается сочетанием ограничений схемы, атомарности транзакций (WAL + MVCC) и уровней изоляции; третье — настройками репликации.
Глубже. Механизмы PostgreSQL по слоям.
Атомарность и долговечность как фундамент. Все изменения сначала пишутся в WAL (write-ahead log) и фиксируются fsync на коммите (synchronous_commit=on), затем страницы попадают в файлы данных на checkpoint. Крэш восстанавливается проигрыванием WAL. Без атомарности не бывает консистентности: половина транзакции нарушила бы инвариант.
MVCC. Каждая строка хранит xmin/xmax; читатель работает со снимком (snapshot) и не блокирует писателя, писатель не блокирует читателя. Именно поэтому в PostgreSQL «грязного чтения» нет вовсе — READ UNCOMMITTED ведёт себя как READ COMMITTED.
Уровни изоляции. READ COMMITTED (дефолт) — снимок берётся на каждый оператор, возможны неповторяющиеся чтения и фантомы, а также lost update при read-modify-write без блокировки. REPEATABLE READ — снимок на всю транзакцию, реализован как snapshot isolation: фантомов нет, но возможен write skew (две транзакции читают одно и то же и записывают непересекающиеся строки, ломая совместный инвариант), при конфликте обновлений транзакция получает 40001 could not serialize access due to concurrent update. SERIALIZABLE — SSI (см. вопрос выше), убирает write skew ценой отмен и обязательного ретрая.
Инварианты явно. Значительная часть «консистентности» — это то, что вы объявили схемой: NOT NULL, CHECK, UNIQUE, FOREIGN KEY (в том числе DEFERRABLE INITIALLY DEFERRED для циклических зависимостей), EXCLUDE USING gist для непересечения интервалов, триггеры для сложных правил. Проверка инварианта только в коде приложения при нескольких инстансах — это не консистентность, это гонка.
Явные блокировки. Когда логика типа «прочитать баланс, проверить, списать» не укладывается в один UPDATE ... SET balance = balance - x WHERE balance >= x, используют SELECT ... FOR UPDATE (или FOR NO KEY UPDATE), advisory-локи для координации процессов, SKIP LOCKED для очередей на таблице.
Распределённый уровень. Между primary и репликами: асинхронная репликация даёт лаг (чтение с реплики — eventual), synchronous_standby_names + synchronous_commit = on|remote_write|remote_apply дают разные степени гарантии (только remote_apply гарантирует, что чтение с этой реплики увидит закоммиченное). Кворумная синхронная репликация (ANY 1 (s1,s2)) — компромисс между надёжностью и латентностью. Между разными сервисами со своими БД консистентности через PREPARE TRANSACTION (2PC) обычно избегают, вместо этого — transactional outbox и saga с компенсациями.
Архитектурная задача: Реализовать префиксный поиск для карточек товаров
Заголовок раздела «Архитектурная задача: Реализовать префиксный поиск для карточек товаров»Коротко. Сначала уточнить требования (объём каталога, RPS автодополнения, целевой p99, нужна ли опечаточная устойчивость и морфология, ранжирование по популярности, префикс по всей строке или по любому слову). Дальше — три уровня решений по возрастанию сложности: B-tree индекс с text_pattern_ops для якорного LIKE 'abc%'; полнотекстовый поиск PostgreSQL с to_tsquery('abc:*') на GIN; выделенный поисковый индекс (Elasticsearch/OpenSearch с edge_ngram, либо собственный trie/FST в памяти) с наполнением из Postgres через outbox/CDC.
Глубже. Разбор по вариантам.
Вариант 1 — только PostgreSQL, префикс с начала строки. Работает LIKE 'sam%' по B-tree индексу, но в локали, отличной от C, обычный индекс для этого не годится — нужен класс операторов text_pattern_ops (или индекс по lower(name)), иначе планировщик не сделает range scan.
CREATE INDEX idx_products_name_prefix ON products (lower(name) text_pattern_ops);-- запросSELECT id, name FROM productsWHERE lower(name) LIKE lower($1) || '%'ORDER BY popularity DESCLIMIT 10;Ограничение существенное: это префикс только всей строки. «пыле» не найдёт «Робот-пылесос».
Вариант 2 — префикс по любому слову внутри PostgreSQL. Полнотекстовый поиск умеет префиксный матч токена:
ALTER TABLE products ADD COLUMN tsv tsvector GENERATED ALWAYS AS (to_tsvector('russian', coalesce(name,'') || ' ' || coalesce(brand,''))) STORED;CREATE INDEX idx_products_tsv ON products USING gin (tsv);
SELECT id, name FROM productsWHERE tsv @@ to_tsquery('russian', 'пылесо:*')ORDER BY popularity DESCLIMIT 10;Для опечаток и подстрок — расширение pg_trgm (CREATE EXTENSION pg_trgm; + GIN/GiST индекс по gin_trgm_ops, оператор % и similarity()); оно же спасает LIKE '%abc%'. Этого варианта хватает каталогам до сотен тысяч — единиц миллионов позиций при умеренном RPS.
Вариант 3 — выделенный поисковый слой. При десятках миллионов карточек, требовании p99 < 30 мс на автодополнение и сложном ранжировании берут Elasticsearch/OpenSearch: поле с анализатором edge_ngram (индексируем «пыл», «пыле», «пылес»… на этапе индексации, а не поиска), либо тип search_as_you_type, либо completion suggester на FST. Альтернативы полегче — Typesense, Meilisearch, Manticore. Собственное решение оправдано, когда нужен минимальный latency и небольшой словарь: trie/radix-дерево в памяти сервиса (github.com/armon/go-radix) или FST, где в узле префикса лежит уже посчитанный top-K по популярности — тогда запрос это спуск по дереву на длину префикса плюс отдача готового списка, то есть единицы микросекунд.
Архитектура целиком. Источник истины — PostgreSQL. Изменения карточек публикуются через transactional outbox (или CDC-коннектор Debezium) в Kafka; индексатор потребляет поток и обновляет поисковый индекс; сервис автодополнения читает только индекс и не ходит в основную БД. Перестроение индекса — в новый индекс с последующим атомарным переключением алиаса. Сверху: кеш горячих префиксов (первые 1–3 символа дают большую часть трафика) в Redis и локально, дебаунс 100–150 мс на клиенте, ограничение результата 10 позициями, нормализация запроса (lower, trim, схлопывание пробелов, транслитерация раскладки «ghtktr» → «прелек»). Метрики: p99 по длине префикса, доля пустых выдач, CTR подсказок.
Что назвать как компромиссы. Отдельный индекс = eventual consistency (новая карточка появляется в поиске через секунды) и дополнительная инфраструктура; edge_ngram раздувает размер индекса; trie в памяти ограничен объёмом и требует репликации состояния на каждый под.
Как бы вы организовали архитектуру интеграции с партнерским сервисом для получения рекламного контента? Какие слои будут задействованы?
Заголовок раздела «Как бы вы организовали архитектуру интеграции с партнерским сервисом для получения рекламного контента? Какие слои будут задействованы?»Коротко. Партнёр — это внешняя ненадёжная зависимость, поэтому он прячется за anti-corruption layer: домен работает с нашим типом AdSlot/Ad и портом AdProvider, а адаптер переводит наш вызов в HTTP-запрос партнёра и его DTO — в наши сущности. Слои: транспорт (наш API) → use case → домен и порт → адаптер партнёра → инфраструктура (HTTP-клиент, кеш, метрики). Обязательные атрибуты адаптера: дедлайн, ограничение конкурентности, ретраи с backoff, circuit breaker и graceful degradation — реклама никогда не должна ронять страницу.
Глубже. Порт и адаптер:
// internal/usecase/ports.go — порт объявлен потребителемpackage usecase
import "context"
type Ad struct { ID string ImageURL string Title string ClickURL string}
type AdProvider interface { Fetch(ctx context.Context, slot string, userSegment string) ([]Ad, error)}
// internal/adapters/partner/client.go — адаптер: ACL + устойчивостьpackage partner
import ( "context" "time"
"example.com/ads/internal/usecase")
func (c *Client) Fetch(ctx context.Context, slot, seg string) ([]usecase.Ad, error) { ctx, cancel := context.WithTimeout(ctx, 150*time.Millisecond) defer cancel() if !c.breaker.Allow() { return nil, usecase.ErrAdsUnavailable } resp, err := c.do(ctx, slot, seg) // ретраи с джиттером только на таймаут/5xx if err != nil { c.breaker.Fail() return nil, err } c.breaker.Success() return toDomain(resp), nil // маппинг чужого DTO в наши типы}Что обязательно проговорить помимо кода. Бюджет времени: у рекламы должен быть жёсткий дедлайн (100–200 мс), меньший, чем бюджет всей страницы; истёк — отдаём дефолтный/собственный креатив, а не 500. Кеш: креативы по слоту и сегменту кешируются на десятки секунд, плюс фоновая префетч-подгрузка, чтобы пользовательский запрос почти никогда не ходил к партнёру синхронно; это же решает пики. Ограничение нагрузки: rate limiter под квоту партнёра и bulkhead (отдельный пул соединений/семафор), чтобы залипший партнёр не съел все горутины и коннекты сервиса. Идемпотентность и учёт: показы и клики фиксируем у себя (события в Kafka), сверку с партнёром делаем отдельным батчем — деньги нельзя считать по синхронному ответу. Контракт и тесты: описываем ожидаемый формат в своей спецификации, держим контрактные тесты против sandbox партнёра и записанные фикстуры (httptest + golden files) для юнит-тестов. Безопасность и право: ключи в секрет-хранилище, валидация и санитизация приходящих URL/HTML, модерация креативов, учёт согласий пользователя (GDPR/152-ФЗ) при передаче сегментов — сегмент пользователя это персональные данные в широком смысле. Наблюдаемость: отдельные метрики по партнёру (latency, error rate, fill rate, доля деградаций), трассировка с span’ом на внешний вызов, алерт на рост доли фолбэков. Смена партнёра: наличие порта означает, что второй провайдер добавляется как второй адаптер, а роутинг/аукцион между ними живёт в use case.
В чем ключевая разница в подходе к ООП между C++ и Go? Как отсутствие классического наследования влияет на архитектуру?
Заголовок раздела «В чем ключевая разница в подходе к ООП между C++ и Go? Как отсутствие классического наследования влияет на архитектуру?»Коротко. C++ строит полиморфизм на классах, наследовании и виртуальных функциях: тип обязан явно объявить, чей он наследник, диспетчеризация идёт через vtable, есть множественное наследование, шаблоны и RAII. В Go классов и наследования нет вовсе: есть структуры с методами, встраивание (композиция с продвижением методов) и неявно удовлетворяемые интерфейсы. Следствие для архитектуры — вместо иерархий типов проектируют маленькие интерфейсы у потребителя, что делает инверсию зависимостей естественной, а иерархические паттерны (Template Method, «фреймворк-базовый класс») — нерабочими.
Глубже. Разница по механизмам. В C++ полиморфное поведение фиксируется на стороне определения типа: class Dog : public Animal — связь заявлена навсегда, и добавить новую абстракцию над существующим чужим классом можно только адаптером. В Go интерфейс удовлетворяется структурно: если у типа есть метод Read([]byte) (int, error), он уже io.Reader, даже если автор типа про io.Reader не знал. Интерфейс объявляется там, где он нужен — в пакете-потребителе, а не рядом с реализацией. Именно это делает DIP в Go бесплатным: пакет usecase объявляет OrderRepo, пакет postgres его реализует, импорт идёт только в одну сторону и цикла не возникает.
Встраивание (type Server struct { *BaseServer }) выглядит как наследование, но это не оно: методы «продвигаются» во внешний тип, однако обратной диспетчеризации нет — метод внутреннего типа, вызывая другой свой метод, вызовет именно внутренний, а не переопределённый снаружи. То есть Template Method через встраивание не собрать; вместо него используют поля-функции, стратегии-интерфейсы или встраивание интерфейса (type Handler struct { Validator }), чтобы поведение подставлялось извне. Из-за отсутствия иерархий в Go нет проблемы ромба, хрупкого базового класса и «наследования ради переиспользования кода» — переиспользование делают композицией и обычными функциями.
Технические детали, которые полезно назвать: значение интерфейса в Go — пара (itab с дескриптором типа и таблицей методов, указатель на данные), вызов через интерфейс — косвенный, компилятор умеет девиртуализировать при известном конкретном типе и инлайнить; сравнение с виртуальным вызовом C++ примерно паритетное. Дженерики (Go 1.18+) дают параметрический полиморфизм с ограничениями-интерфейсами, но это не шаблоны C++: нет специализации, нет вычислений на этапе компиляции, реализация через GC-shape stenciling с dictionary. Ещё две архитектурные разницы: единица инкапсуляции в Go — пакет, а не класс (экспорт по заглавной букве, internal/ для запрета импорта извне модуля), поэтому границы проводят по пакетам; и отсутствие конструкторов/деструкторов и исключений — вместо RAII явные New* + Close() с defer, вместо try/catch возвращаемые error, что делает пути ошибок частью сигнатуры и заметно влияет на дизайн API.
Какие программные архитектуры знаешь и по каким работал?
Заголовок раздела «Какие программные архитектуры знаешь и по каким работал?»Коротко. Стоит перечислить по группам: монолит и модульный монолит, многослойная/n-tier, ports & adapters (гексагональная), clean/onion, DDD-ориентированная, микросервисы и SOA, событийно-ориентированная (pub/sub, event sourcing, CQRS), pipeline/pipes-and-filters, serverless/FaaS, микроядро (plugin-based), а из клиентских — MVC/MVP/MVVM. И честно отметить, с чем работали лично.
Глубже. Короткие характеристики, чтобы ответ не выглядел зазубренным списком. Модульный монолит — один процесс, жёсткие внутренние границы модулей с явными контрактами; лучший дефолт для новой системы, потому что даёт границы без сетевого налога и позволяет позже вынести модуль. Многослойная — простая и понятная, риск анемичной модели. Гексагональная/чистая — про направление зависимостей, окупается при богатом домене. Микросервисы — не про размер, а про независимый цикл релиза и владение данными; берутся ради организационной и нагрузочной независимости, платятся распределёнными транзакциями, latency и эксплуатацией. SOA — предшественник с ESB и централизованной оркестрацией. EDA — слабая связанность и eventual consistency. CQRS — разные модели для чтения и записи, окупается при перекосе нагрузки. Event sourcing — события как источник истины, дорогая эволюция схемы. Pipeline — ETL/стриминг, стадии соединены очередями; в Go это идиоматичный паттерн с каналами. Serverless — экономия на простое и автоскейл, цена — холодные старты и vendor lock-in. Микроядро — ядро плюс плагины, применимо для расширяемых продуктов. Для данных отдельно упоминают lambda/kappa-архитектуры (batch+stream против только stream) и medallion в озёрах данных. Инфраструктурно — sidecar / service mesh и cell-based для изоляции отказов.
Про личный опыт отвечайте прямо и с деталями: «работал с модульным монолитом на Go, который постепенно разбивали на 6 сервисов; в трёх из них применяли ports & adapters, потому что там был реальный домен; межсервисно — gRPC плюс Kafka с outbox; в одном сервисе делали CQRS с отдельной read-моделью в ClickHouse». Не заявляйте event sourcing и DDD, если не готовы к уточняющим вопросам про версионирование событий и агрегаты — проверка последует немедленно.
Частые ошибки на собесе
Заголовок раздела «Частые ошибки на собесе»- Трактовать CAP как «выберите два из трёх» и не понимать, что P не является предметом выбора; путать C из CAP (линеаризуемость) с C из ACID (инварианты).
- Говорить, что в PostgreSQL Serializable «просто блокирует всё», не зная про SSI, rw-конфликты и обязательный ретрай по ошибке 40001 на стороне приложения.
- Считать, что REPEATABLE READ в PostgreSQL защищает от всего: write skew остаётся, и именно на нём ловят кандидатов задачей про «двух дежурных врачей».
- Продавать gRPC аргументом «он быстрее», не упоминая ни кешируемость REST, ни проблему балансировки долгоживущих HTTP/2-соединений, ни отсутствие поддержки в браузере.
- Утверждать, что HTTP/2 полностью убирает head-of-line blocking: на уровне TCP он остаётся, это решает только HTTP/3 поверх QUIC.
- Называть встраивание в Go наследованием и обещать переопределение методов «как виртуальных» — обратной диспетчеризации в Go нет.
- Объявлять интерфейсы рядом с реализацией (в пакете
postgres), а потом говорить про инверсию зависимостей — инверсии в этом случае нет. - В read-heavy задаче сразу предлагать шардирование, пропуская кеш, реплики и пул соединений, и не проговаривать read-your-own-writes и лаг репликации.
- Рассказывать про событийную архитектуру без outbox, идемпотентности и ключа партиционирования — то есть без всего, что делает её работоспособной.
- На поведенческих вопросах («участвовали ли в проектировании», «кто отвечал») давать ответ без цифр, без отвергнутых альтернатив и без честно названной цены решения.
Что почитать
Заголовок раздела «Что почитать»- Daniel Abadi. «Consistency Tradeoffs in Modern Distributed Database System Design» (PACELC) и Gilbert & Lynch, «Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services» — первоисточники по CAP/PACELC.
- PostgreSQL Documentation: «Transaction Isolation» (https://www.postgresql.org/docs/current/transaction-iso.html) и
src/backend/storage/lmgr/predicate.cс комментарием-описанием реализации SSI. - Martin Kleppmann. «Designing Data-Intensive Applications» — главы 5–9 (репликация, партиционирование, транзакции, консистентность и консенсус).
- Alistair Cockburn. «Hexagonal Architecture» (https://alistair.cockburn.us/hexagonal-architecture/) и Simon Brown, C4 model (https://c4model.com/).
- gRPC docs и RFC 9113 (HTTP/2) / RFC 9114 (HTTP/3) — для точных формулировок про мультиплексирование, HPACK и HOL blocking.
- Effective Go и Go Blog «Go Proverbs» / «Codelab: Interfaces» — про композицию, встраивание и интерфейсы у потребителя.