Архитектура и системный дизайн
Единственная секция, где нет одного правильного ответа — и именно поэтому она самая показательная. Здесь не проверяют, знаешь ли ты слово «сага». Проверяют, умеешь ли ты назвать цену решения, признать компромисс и сказать «здесь я бы этого не делал, потому что…». Кандидат, который на всё отвечает «микросервисы, Kafka, CQRS», проваливает эту секцию быстрее, чем тот, кто не помнит букв в PACELC.
Три вещи. Первое — умеешь ли ты рассуждать в терминах компромиссов: любой архитектурный выбор что-то даёт и что-то отнимает, и сильный ответ всегда двусторонний. Второе — держишь ли ты в голове отказы: сеть рвётся, узлы падают, сообщения дублируются, и вопрос «а что если ответ потерялся после того, как деньги списали» должен быть у тебя рефлексом. Третье — можешь ли ты вести диалог: уточнять требования, считать нагрузку вслух, предлагать простое решение и усложнять его только под давлением конкретных цифр.
11.1Организация кода и принципы
Самая «инженерная» глава темы: масштаб тут ни при чём, речь о том, как выглядит репозиторий, который через два года всё ещё можно менять. Собеседующий проверяет не знание слов «SOLID» и «гексагональная», а способность объяснить, какую конкретную боль лечит каждое из этих слов и что оно стоит.
Сначала — четыре слова, без которых дальше будет каша
Практический вопрос тут один: если я поменяю одну часть программы, сколько других частей придётся чинить следом? Ответ зависит от того, кто про кого знает. Договоримся о четырёх словах, дальше всё будет читаться само.
1. Зависимость — это «кто про кого знает»
Пакет A зависит от пакета B, если в A написан import B: A знает имена
типов и функций из B, а значит, правка B может сломать A. Зависимости рисуют стрелками:
стрелка идёт от того, кто знает, к тому, кого знают. Остальное в главе сводится к тому,
куда эти стрелки должны смотреть.
2. Слой — группа пакетов, у которых одинаковый уровень абстракции
Слой означает не папку и не пакет, а роль: набор кода, который решает задачи одного рода. Транспортный слой разбирает HTTP-запросы, слой сценариев выполняет бизнес-операции, слой хранения ходит в базу. Слоем эту группу делает не имя каталога, а правило: слой может зависеть только от соседа с одной стороны и никогда — с другой. Три папки без такого правила остаются просто тремя папками.
3. Инверсия зависимостей — развернуть стрелку, не меняя вызовов
Обычно тот, кто вызывает, зависит от того, кого вызывает: сервису нужен репозиторий, значит, сервис импортирует пакет репозитория. Инверсия зависимостей (dependency inversion) оставляет вызов прежним, но разворачивает стрелку импорта: сервис объявляет у себя интерфейс «мне нужен вот такой репозиторий», а пакет с реальной реализацией импортирует сервис и подписывается под этим интерфейсом.
Похоже на наём сотрудника. В обычной схеме ты идёшь к конкретному Петрову и просишь его о помощи: ты зависишь от Петрова, ушёл Петров — переписывать всё. В инвертированной ты публикуешь вакансию с требованиями, и уже кандидаты приходят под неё подстраиваться. Требования принадлежат тебе, а кто им соответствует, тебя не касается. Инверсию инверсией делает вот что: интерфейс принадлежит тому, кто им пользуется, а не тому, кто его реализует.
4. Внедрение зависимостей (DI) — передать готовое, а не создавать внутри
DI расшифровывается как dependency injection, «внедрение зависимостей». Звучит громко, означает
до скуки простую вещь: объект не создаёт то, что ему нужно, сам, а получает это
снаружи — обычно аргументом конструктора. Не repo := postgres.New(...)
внутри сервиса, а NewService(repo Repository). Всё. Никакого фреймворка
для этого не нужно, и в Go его обычно и не берут.
Зачем: пока сервис создаёт зависимость сам, он намертво привязан к конкретной реализации. В тесте вместо базы не подставишь заглушку, вторую реализацию не добавишь. DI и есть та механика, которой пользуются инверсия зависимостей и всё, что из неё следует.
Слои: это не про папки, это про направление стрелок
Классическая тройка handler → service → repository существует ради одного
правила: зависимости идут в одну сторону и никогда обратно.
Смысл появляется, когда ты можешь удалить весь HTTP-слой,
заменить его на gRPC-сервер или на консольную команду, и не тронуть ни строчки в service.
Каждый слой отвечает за свой уровень абстракции, и у каждого есть список того, чего он не должен знать. Этот список важнее списка обязанностей: нарушают именно его.
| Слой | За что отвечает | Чего знать не должен | Как ломают на практике |
|---|---|---|---|
| transport handler |
Разбор запроса, валидация формата, коды ответа, сериализация, маппинг ошибок домена в HTTP-статусы | Про SQL, транзакции, структуру таблиц | Бизнес-правило «скидка не больше 30 %» прописано прямо в хендлере |
| service usecase |
Сценарий целиком, инварианты, оркестрация репозиториев и внешних клиентов, границы транзакции | Про http.Request, JSON-теги, номера статусов |
В сервис прилетает *gin.Context, и он же пишет ответ |
| repository storage |
Доступ к данным, SQL, маппинг строк в доменные типы, ретраи драйвера | Про то, кто и зачем его вызвал | Репозиторий сам решает, слать ли письмо; наружу торчит *sql.Rows |
| domain core |
Типы предметной области и правила, которые верны всегда | Вообще ни о чём внешнем | domain импортирует gorm.io/gorm ради тегов |
go list -deps у сервиса покажет драйвер БД, справа — ничего лишнего.// internal/order/service.go: потребитель объявляет контракт, узкий и в своих терминах
package order
type Repository interface {
ByID(ctx context.Context, id ID) (*Order, error)
Save(ctx context.Context, o *Order) error
}
type Notifier interface {
OrderPaid(ctx context.Context, o *Order) error
}
type Service struct {
repo Repository
notif Notifier
now func() time.Time // время тоже зависимость: иначе тесты будут флакать
}
// принимаем интерфейсы, возвращаем конкретный тип
func NewService(r Repository, n Notifier) *Service {
return &Service{repo: r, notif: n, now: time.Now}
}
// internal/storage/postgres/order.go: реализация ничего не знает про order.Service
package postgres
type OrderRepo struct {
db *sql.DB
}
func NewOrderRepo(db *sql.DB) *OrderRepo { return &OrderRepo{db: db} }
func (r *OrderRepo) ByID(ctx context.Context, id order.ID) (*order.Order, error) {
var o order.Order
err := r.db.QueryRowContext(ctx, `SELECT id, status, total FROM orders WHERE id = $1`, id).
Scan(&o.ID, &o.Status, &o.Total)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, fmt.Errorf("order %s: %w", id, order.ErrNotFound) // деталь БД наружу не течёт
case err != nil:
return nil, fmt.Errorf("select order: %w", err)
}
return &o, nil
}
Запусти go list -deps ./internal/order. Если в выводе есть net/http,
database/sql или имя драйвера — слоёв нет, есть папки. Эту же проверку вешают
в CI линтерами depguard (в составе golangci-lint) или
go-arch-lint. Архитектура, которую не проверяет машина, разрушается за квартал.
Чистая и гексагональная: чем они отличаются от «просто слоёв»
Гексагональная архитектура (она же ports & adapters, Алистер Кокбёрн, 2005) и «чистая» архитектура (Роберт Мартин, 2012) описывают одну идею в разной упаковке. Разница со слоёнкой ровно одна, но принципиальная:
- В слоёнке стрелки идут сверху вниз. Верхний слой зависит от нижнего.
Репозиторий считается «низом», и сервис зависит от него; ничто не мешает протащить
*sql.DBпрямо в сервис, и код будет компилироваться. - В гексагоне стрелки идут снаружи внутрь, всегда. Внешнего мира нет вообще: есть порты (интерфейсы, объявленные ядром) и адаптеры (реализации снаружи). HTTP-сервер и Postgres оказываются на одной и той же «наружной» окружности — и то и другое всего лишь способ поговорить с ядром.
Откуда слова. Порт описывает форму разъёма, которую задаёт ядро: «мне нужен кто-то, умеющий сохранить заказ и найти его по id». Как розетка в стене — она описывает форму отверстий, но ничего не знает о том, что в неё воткнут. Адаптер работает вилкой конкретного прибора: реализация подогнана под эту форму, будь то Postgres, файл или заглушка в тесте. Аналогия держится ровно до одного места: розетка отдаёт ток одинаково всем, а порт бывает двух видов, и они противоположны по направлению.
Порты делятся на два вида, и это единственная терминология, которую стоит запомнить. Через driving (primary, ведущие) внешний мир дёргает приложение: интерфейс use-case реализует сам сервис, а вызывают его HTTP-хендлер, gRPC, консьюмер Kafka или cron. Driven (secondary, ведомые) смотрят в другую сторону, через них уже приложение дёргает мир: репозиторий, кэш, шину, почту; их объявляет ядро, а реализуют адаптеры.
«Чистая архитектура» не сводится к папкам entity/ usecase/ adapter/. Если внутри
entity лежит gorm.Model, а usecase принимает
*gin.Context — у тебя ровно та же слоёнка, только с модными именами и лишними
маппингами. Критерий один: направление импортов, и проверяется он механически.
Когда это оверинжиниринг
Гексагон стоит денег: каждый переход через границу требует типа DTO и функции маппинга.
Три уровня моделей (OrderRequest → domain.Order → orderRow)
и две функции перекладывания полей на каждый сценарий. Если у сервиса 12 CRUD-ручек и ноль
бизнес-правил, ты получишь +300 % кода и ноль пользы: менять всё равно придётся все три модели
сразу, они меняются по одной и той же причине.
Число слоёв должно быть пропорционально количеству бизнес-правил, а не размеру команды и не моде. Тонкому CRUD хватит handler + storage. Появились инварианты, которые нужно проверять в нескольких местах, появились сценарии длиннее одного запроса к БД: вот тогда выделяется service. Появилось два транспорта или два хранилища, пора заводить порты. Обратный ход (схлопнуть слои) делать тяжело психологически, но технически он проще, чем добавление.
DI: конструкторы, composition root и надо ли wire
Про DI (dependency injection, внедрение зависимостей) уже говорили: зависимости получают снаружи, а не создают внутри. Composition root («корень сборки») остаётся единственным местом в программе, где все конкретные реализации создаются и соединяются друг с другом. Знание «какая именно база, какой именно почтовик» должно сидеть в программе ровно в одной точке, остальные файлы про это не знают.
Go-идиома проста до скуки: зависимости приходят аргументами конструктора, а граф
собирается ровно в одном месте — в main. Никаких глобальных синглтонов,
никакого service locator, никакой «магии» через теги и рефлексию. Плюс: граф зависимостей
виден глазами, компилятор проверяет его целиком, а go build падает, если ты
что-то забыл подключить.
// cmd/api/main.go: composition root, единственное место со всеми конкретными типами
func main() {
cfg := config.MustLoad()
db, err := sql.Open("pgx", cfg.DSN)
must(err)
db.SetMaxOpenConns(cfg.DBMaxConns)
// снизу вверх: инфраструктура → репозитории → сервисы → транспорт
orderRepo := postgres.NewOrderRepo(db)
mailer := smtp.NewMailer(cfg.SMTP)
bus := kafka.NewPublisher(cfg.Brokers)
orderSvc := order.NewService(orderRepo, notify.New(mailer, bus))
h := httpapi.NewHandler(orderSvc, logger)
srv := &http.Server{Addr: cfg.Addr, Handler: h, ReadHeaderTimeout: 5 * time.Second}
run(srv)
}
Когда граф вырастает до 40–60 узлов, main на 200 строк начинает раздражать, и тут
появляются генераторы. Их два, и они принципиально разные.
| google/wire | uber-go/fx (и dig) | |
|---|---|---|
| Когда собирается граф | На этапе компиляции — генерирует wire_gen.go | В рантайме, через рефлексию |
| Где падает ошибка | go generate / компиляция | При старте процесса |
| Что читаешь при отладке | Обычный сгенерированный Go-код | Стек рефлексии и лог fx |
| Что даёт сверх сборки | Ничего — только сборку | Lifecycle (OnStart/OnStop), модули, graceful shutdown из коробки |
| Стоимость входа | Низкая, но нужен build-тег и генерация в CI | Средняя: команда должна знать fx, иначе «где это создалось?» |
«По умолчанию собираю руками в main: это самый читаемый вариант и он ничего не стоит.
wire беру, когда сборка перестала помещаться на экран. Он генерирует ровно тот
код, который я написал бы сам, и ошибка ловится компилятором. fx оправдан там,
где важен не столько граф, сколько lifecycle: много фоновых воркеров, которые надо
согласованно поднимать и гасить. А вот DI-контейнер в сервис на десять файлов
ради красоты не тащу.»
Интерфейсы как контракты: объявлять у потребителя
Go отличается от Java/C# одним: интерфейсы реализуются неявно. Типу не нужно
объявлять implements, достаточно иметь нужные методы. Следствие: интерфейс можно
объявить после реализации и в другом пакете — том, который его использует.
Это и есть DIP в практическом виде.
// плохо: интерфейс живёт у реализации
package storage
type UserRepository interface {
Create(ctx context.Context, u *User) error
Update(ctx context.Context, u *User) error
Delete(ctx context.Context, id ID) error
ByID(ctx context.Context, id ID) (*User, error)
ByEmail(ctx context.Context, e string) (*User, error)
List(ctx context.Context, f Filter) ([]*User, error)
}
// сервису нужен один метод —
// а мокать придётся шесть
// хорошо: контракт у потребителя, ровно по нужде
package auth
type UserByEmail interface {
ByEmail(ctx context.Context, e string) (*domain.User, error)
}
type Service struct {
users UserByEmail
}
// storage.UserRepo подходит автоматически,
// он ничего не знает про пакет auth
Есть законные исключения, и их полезно назвать. Первое: интерфейс и есть
публичный контракт библиотеки, у которой несколько реализаций (io.Reader,
driver.Conn, hash.Hash). Второе: множество потребителей ждут
один и тот же набор методов — дублировать одинаковое объявление в шести пакетах хуже, чем
завести один. Третье: интерфейс нужен, чтобы разорвать цикл импортов, и по смыслу он
принадлежит нижнему общему пакету. Формулировка: «объявляю у потребителя по умолчанию,
у поставщика — когда это осознанный публичный контракт».
Структура проекта: cmd, internal, pkg и нарезка по домену
| Путь | Что там | Правило |
|---|---|---|
cmd/<bin>/main.go | По одной папке на каждый бинарь: api, worker, migrator | Только флаги, конфиг и сборка графа. Логики — ноль |
internal/ | Всё приложение | Команда go запрещает импорт извне дерева, в котором лежит эта папка. Единственный настоящий модификатор доступа в Go на уровне пакетов |
pkg/ | То, что ты обещаешь поддерживать для чужих | Не обещаешь совместимость — не заводи pkg/. Пустая папка-ритуал |
api/, proto/ | OpenAPI/proto-схемы и сгенерированный код | Схема — источник правды, код генерируется в CI |
migrations/ | SQL-миграции | Только вперёд, только через инструмент (goose/migrate), никаких ручных ALTER в проде |
Внутри internal/ есть развилка, на которой валятся чаще всего.
Нарезка по типам (models/, services/, handlers/,
repositories/) выглядит аккуратно, но одна правка фичи задевает четыре пакета,
и любой пакет знает про все домены сразу. Package-oriented design режет по
предметным областям (order/, billing/, user/): внутри
пакета лежат и модель, и логика, и интерфейсы хранилища. Правка фичи остаётся в одной
папке, а циклы почти не возникают, потому что домены слабо связаны по смыслу.
# по типам: «горизонтальная» нарезка # по домену: «вертикальная»
internal/ internal/
models/ user.go order.go payment.go user/ user.go service.go store.go
services/ user.go order.go payment.go order/ order.go service.go store.go
handlers/ user.go order.go payment.go billing/ payment.go service.go store.go
repository/ user.go order.go payment.go platform/ postgres/ kafka/ httpx/
Про монорепу спрашивают реже, но ответ короткий: она оправдана, когда общие контракты меняются часто и менять их нужно атомарно — один PR правит proto, сервер и всех клиентов. Платишь за это тулингом (нужен селективный CI, иначе каждый PR гоняет всё), правами доступа и дисциплиной: без границ монорепа за год превращается в клубок, где всё импортирует всё.
SOLID на Go: по одному честному примеру
SOLID складывается из первых букв пяти принципов проектирования (Роберт Мартин, начало 2000-х). Все пять отвечают на один вопрос: как написать код, который потом можно менять, не разбирая половину системы. SOLID придуман для языков с наследованием, и часть букв в Go звучит иначе. Каждую лучше показывать кодом, а не определением, но сначала сказать своими словами, о чём она.
S — Single Responsibility: одна причина для изменения
Принцип единственной ответственности. Формулируют его обычно как «класс должен делать одно дело», и это неточно: дел может быть много, важно другое. У куска кода должна быть одна причина меняться. За причиной стоит человек или отдел, который приходит с просьбой. Если один и тот же файл правят и когда маркетинг меняет текст письма, и когда бухгалтерия меняет правила скидок, и когда DBA переименовывает колонку — причин три, а значит, три разных повода сломать чужую работу в одном файле. Ровно этот случай в примере ниже.
// было: три причины меняться, правила заказа, формат письма, схема БД
func (s *OrderService) Place(ctx context.Context, req PlaceReq) error {
if req.Total < 0 { return errors.New("bad total") }
_, err := s.db.ExecContext(ctx, `INSERT INTO orders ...`)
if err != nil { return err }
body := "<h1>Заказ " + req.ID + "</h1>" // вёрстка письма в сервисе
return smtp.SendMail(s.addr, nil, "no-reply@x", []string{req.Email}, []byte(body))
}
// стало: каждая причина изменения живёт отдельно
func (s *OrderService) Place(ctx context.Context, o *domain.Order) error {
if err := o.Validate(); err != nil { return err } // правила проверяет домен
if err := s.repo.Save(ctx, o); err != nil { return err } // схему знает репозиторий
return s.notifier.OrderPlaced(ctx, o) // письмо собирает notifier
}
O — Open/Closed: расширяем добавлением, а не правкой switch
Принцип открытости/закрытости: код должен быть открыт для расширения и закрыт
для изменения. По-человечески — новую возможность добавляют, дописав новый код,
а не отредактировав старый, работающий и уже протестированный. Признак нарушения виден
глазом: каждый новый случай требует дописать ветку в тот же самый switch,
который трогали уже двадцать раз. Каждая такая правка даёт шанс сломать девятнадцать
предыдущих веток.
// было: каждый новый провайдер = правка функции, которую все уже протестировали
switch method {
case "card": return chargeCard(amount)
case "sbp": return chargeSBP(amount)
case "wallet": return chargeWallet(amount)
}
// стало: стратегия + реестр. Новый провайдер = новый файл, старый код цел
type Provider interface {
Name() string
Charge(ctx context.Context, a Money) (TxID, error)
}
type Registry struct {
m map[string]Provider
}
func (r *Registry) Register(p Provider) { r.m[p.Name()] = p }
func (r *Registry) Charge(ctx context.Context, name string, a Money) (TxID, error) {
p, ok := r.m[name]
if !ok { return "", fmt.Errorf("provider %q: %w", name, ErrUnsupported) }
return p.Charge(ctx, a)
}
L — Liskov: реализация не имеет права ужесточать контракт
Принцип подстановки Барбары Лисков: если код работает с интерфейсом, то любая реализация этого интерфейса должна в нём работать — без «а вот с этой надо осторожнее». Бытовой пример: розетка обещает 220 вольт. Прибор, который в неё воткнули, вправе на это рассчитывать. Реализация, которая иногда выдаёт 380, формально «тоже розетка», но пользоваться ей нельзя — и не потому, что она хуже, а потому что она нарушила обещание, на которое все уже полагаются. В коде такие нарушения выглядят так.
// Нарушение LSP, которое реально встречается:
// реализация io.Writer паникует вместо ошибки
func (w *badWriter) Write(p []byte) (int, error) {
if len(p) == 0 { panic("empty write") } // контракт io.Writer этого не разрешает
...
}
// Второе нарушение, частое в репозиториях:
func (r *cachedRepo) ByID(ctx context.Context, id ID) (*User, error) {
if u, ok := r.cache[id]; ok { return u, nil }
return nil, nil // вместо ErrNotFound: вызывающий получит nil-указатель и панику
}
Формулировка для собеса: подстановка реализации не должна ломать вызывающего. На практике это значит не добавлять предусловий, которых нет в контракте, не менять набор возвращаемых ошибок и не менять смысл «нулевого» результата.
I — Interface Segregation: маленькие интерфейсы
Принцип разделения интерфейсов: никого нельзя заставлять зависеть от методов, которыми он не пользуется. Чем больше методов требует интерфейс, тем меньше типов ему соответствует и тем больше лишнего приходится писать в заглушках для тестов. Просить надо ровно то, что реально вызываешь: функции, которой нужно только писать байты, незачем требовать объект, умеющий ещё и закрываться, перематываться и отдавать имя файла.
Формулировка Роба Пайка: the bigger the interface, the weaker the abstraction.
Стандартная библиотека держит планку: io.Reader и io.Writer по одному
методу, а всё остальное собирается композицией (io.ReadWriteCloser).
Функция должна просить минимум:
func Backup(dst io.Writer, src io.Reader) error // подойдёт файл, сокет, буфер, gzip-писатель
func Backup(dst *os.File, src *os.File) error // подойдёт только файл, тест потребует диска
D — Dependency Inversion: оба зависят от абстракции
Принцип инверсии зависимостей, тот самый, с которого начиналась глава: модуль высокого уровня не должен зависеть от модуля низкого уровня; оба зависят от абстракции. «Высокий уровень» описывает то, ради чего программа написана (сценарии, правила), «низкий» — как именно она это делает (Postgres, SMTP, файлы). Правило спасает от ситуации, когда бизнес-логику переписывают из-за смены библиотеки драйвера.
Выше это уже было: order.Service зависит от order.Repository,
postgres.OrderRepo — тоже от него. Ни один из них не зависит от другого напрямую.
Уточнение, которое любят слышать: абстракция принадлежит верхнему уровню,
а не нижнему, иначе инверсии не произошло.
Паттерны GoF в Go-идиомах
| Паттерн GoF | Как выглядит в Go | Где в stdlib / экосистеме |
|---|---|---|
| Strategy | Интерфейс с одним методом + поле в структуре. Часто — просто func-тип | sort.Interface, http.RoundTripper |
| Decorator | Middleware: func(http.Handler) http.Handler, обёртка вокруг интерфейса | http.StripPrefix, bufio.NewReader, интерсепторы gRPC |
| Adapter | Тип-переходник, приводящий один интерфейс к другому | http.HandlerFunc, sort.Reverse, io.NopCloser |
| Builder | Чаще заменён на functional options; классический builder — там, где нужен пошаговый неизменяемый объект | strings.Builder, net/url.URL + Query() |
| Factory | Функция NewX(...); фабричный метод — реестр map[string]func() T | sql.Register + sql.Open, image.RegisterFormat |
| Singleton | sync.Once / sync.OnceValue (Go 1.21). Обычно вообще не нужен — есть DI | rand глобальный источник, пулы соединений |
| Observer | Каналы + горутина-диспетчер, либо колбэки, либо внешний pub/sub | signal.Notify, context.Done(), fsnotify |
| Template Method | Функция высшего порядка вместо абстрактного класса: передаём шаг как аргумент | sort.Slice(s, less), filepath.WalkDir(fn) |
| Command | Структура-задача + очередь; для отложенных задач — сообщение в брокере | context.CancelFunc, задачи в worker pool |
// Functional options: конструктор с обязательными аргументами и расширяемым хвостом
type Client struct {
addr string
timeout time.Duration
retries int
log Logger
}
type Option func(*Client)
func WithTimeout(d time.Duration) Option { return func(c *Client) { c.timeout = d } }
func WithRetries(n int) Option { return func(c *Client) { c.retries = n } }
func WithLogger(l Logger) Option { return func(c *Client) { c.log = l } }
func New(addr string, opts ...Option) *Client {
c := &Client{addr: addr, timeout: 3 * time.Second, retries: 2, log: nopLogger{}}
for _, o := range opts { o(c) } // умолчания разумные, опции их переопределяют
return c
}
c := New("10.0.0.1:9000", WithTimeout(time.Second), WithRetries(5))
// Decorator == middleware. Цепочка разворачивается справа налево от вызова
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mw ...Middleware) http.Handler {
for i := len(mw) - 1; i >= 0; i-- { h = mw[i](h) }
return h
}
func WithLogging(l Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusRecorder{ResponseWriter: w, code: 200}
next.ServeHTTP(rw, r)
l.Info("req", "path", r.URL.Path, "code", rw.code, "dur", time.Since(start))
})
}
}
// Тот же приём годится для любого интерфейса, не одного лишь http.Handler.
type cachedRepo struct {
next order.Repository
c *lru.Cache
}
func (r *cachedRepo) ByID(ctx context.Context, id order.ID) (*order.Order, error) { ... }
// Singleton по-современному: sync.OnceValue (Go 1.21) вместо ручного sync.Once и глобальной переменной
var loadRates = sync.OnceValue(func() *RateTable {
return mustParse(embeddedRates) // выполнится ровно один раз, потокобезопасно
})
// и вариант с ошибкой:
var conn = sync.OnceValues(func() (*grpc.ClientConn, error) {
return grpc.NewClient(addr)
})
Репозиторий: что возвращать при «не найдено»
Репозиторий прячет способ хранения за интерфейсом в терминах домена. Он даёт три вещи: тестируемость сервиса без БД, единое место маппинга «строка → доменный тип» и запрет на протекание SQL по всему коду. А вот чего он, вопреки легенде, не даёт, так это «легко сменить Postgres на Mongo»: реальные запросы, транзакции и модель консистентности так не переносятся.
package user
// Sentinel-ошибки домена: не привязаны ни к SQL, ни к HTTP
var (
ErrNotFound = errors.New("user not found")
ErrAlreadyExists = errors.New("user already exists")
)
type Repository interface {
ByID(ctx context.Context, id ID) (*User, error)
ByEmail(ctx context.Context, email string) (*User, error)
Create(ctx context.Context, u *User) error
Update(ctx context.Context, u *User) error
}
- Не
return nil, nil: вызывающий обязан помнить о проверке на nil, забудет — поймает панику. - Не
(*User, bool, error). Три возвращаемых значения превращают каждый вызов в лапшу. - Не
sql.ErrNoRowsнаружу: деталь хранилища утекает в бизнес-слой, а при переходе на другой драйвер сломается всё. - Да: доменный sentinel
ErrNotFound, обёрнутый через%w, иerrors.Isна верхних уровнях. Транспорт мапит его в 404 в одном месте.
// Транспорт: единственное место, где домен встречается с HTTP-кодами
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
u, err := h.svc.Get(r.Context(), user.ID(r.PathValue("id"))) // Go 1.22 routing
switch {
case errors.Is(err, user.ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "timeout", http.StatusGatewayTimeout)
case err != nil:
h.log.Error("get user", "err", err)
http.Error(w, "internal", http.StatusInternalServerError) // деталь наружу не отдаём
default:
writeJSON(w, u)
}
}
Как только сценарий трогает два репозитория, всплывает «где начинается транзакция».
Плохо, если *sql.Tx протащили через интерфейс: домен узнал про SQL.
Рабочие варианты: (1) метод-обёртка WithTx(ctx, func(ctx) error) у
отдельного типа Transactor, который кладёт транзакцию в context,
а репозитории достают её оттуда (querier(ctx) возвращает либо *sql.Tx,
либо *sql.DB); (2) Unit of Work — объект, отдающий согласованный набор
репозиториев внутри одной транзакции; (3) просто вынести весь сценарий в один метод
репозитория, если он всё равно про одну агрегатную сущность. Чаще всего в Go берут первый.
Рефакторинг god-object на 1000+ строк
God-object («божественный объект», божок) знает и умеет всё: тысячи строк, три десятка полей, методы про базу, про HTTP, про почту и про бизнес-правила одновременно. Признак не в размере, а в том, что его правят по любому поводу: какое требование ни принеси, менять придётся этот файл. Это прямое нарушение S из SOLID в самой запущенной форме, а вредит он конфликтами в git и страхом что-либо трогать.
Правило простое: сначала сеть безопасности, потом движение. Переписать «начисто» почти всегда проигрывает, потому что в старом коде зашиты годы багфиксов, о которых никто не помнит. Порядок, который стоит проговорить на собесе:
- Характеристические тесты (термин Майкла Физерса). Не «правильные» тесты, а фиксирующие текущее поведение, включая странности. Проще всего через golden-файлы: прогнать реальные входы, записать выходы, закоммитить, сравнивать. Цель в том, чтобы любое изменение поведения было видно как diff.
- Найти швы (seams) — места, где можно подменить зависимость. В Go это обычно
значит: вынести глобалы и прямые вызовы
time.Now,http.Get,os.Getenv,db.Queryв поля структуры. - Выделить чистые функции. Куски без ввода-вывода и без состояния вытаскиваются первыми: их легко покрыть тестами и невозможно сломать незаметно.
- Разрезать по данным. Смотришь, какие поля используются какими методами вместе — это и есть будущие типы. Метод, который трогает только три поля из двадцати, просится наружу.
- Переносить по одному методу, оставляя в старом объекте делегирующий вызов. Компилятор Go тут помогает: он найдёт все места использования.
- Удалить делегаты и старый тип, когда все вызовы переехали.
Big bang rewrite: ветка на три месяца, которая не мержится, потому что главная ветка ушла вперёд. Смешивание переименования и переноса в одном коммите делает diff нечитаемым, и ревью превращается в формальность. Рефакторинг без метрики не кончается никогда: заранее договорись, что считается успехом (покрытие, цикломатика, число зависимостей пакета).
Циклические зависимости пакетов
Go запрещает циклы импортов на уровне компилятора: import cycle not allowed.
Это не каприз — так держится корректный порядок инициализации пакетов и возможность
компилировать пакеты независимо. Причина цикла почти всегда одна: пакеты нарезаны
по типам данных, а не по зонам ответственности, и два «домена» ссылаются друг на друга.
Конфигурация и 12-factor
Практический канон: флаги перекрывают переменные окружения, те перекрывают файл, файл
перекрывает умолчания. Порядок можно выбрать и другой, лишь бы он был один,
задокументирован и не менялся от сервиса к сервису. Конфиг разбираем один раз на старте,
валидируем целиком, дальше раздаём кусками в конструкторы. os.Getenv
посреди бизнес-логики работает как скрытый глобал: его не протестируешь и не увидишь в графе.
type Config struct {
Addr string `env:"ADDR" envDefault:":8080"`
DSN string `env:"DATABASE_URL,required"`
DBMaxConns int `env:"DB_MAX_CONNS" envDefault:"20"`
ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"5s"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
}
func (c Config) Validate() error {
if c.DSN == "" { return errors.New("DATABASE_URL is required") }
if c.DBMaxConns <= 0 { return fmt.Errorf("DB_MAX_CONNS=%d must be > 0", c.DBMaxConns) }
if !slices.Contains([]string{"debug", "info", "warn", "error"}, c.LogLevel) {
return fmt.Errorf("LOG_LEVEL=%q unknown", c.LogLevel)
}
return nil
}
// fail fast: неправильный конфиг убивает процесс на старте,
// а не через два часа на первом обращении к необязательной ручке.
- III. Config в окружении, потому что он меняется между стендами, а код нет. В репозитории не должно быть конфигов стендов; внутренний конфиг приложения, вроде таблицы маршрутов, манифест конфигом не считает.
- VI. Stateless-процессы. Никакого состояния в памяти между запросами: сессии в Redis, файлы в S3. Иначе не масштабируется горизонтально и ломается при рестарте.
- VII. Port binding. Приложение само слушает порт, а не живёт внутри сервера приложений. Для Go это данность.
- IX. Disposability: быстрый старт и корректный
SIGTERM. Перестать принимать новое, дожать текущее, закрыть коннекты. Прямая связка с graceful shutdown и k8s. - XI. Логи в stdout: ротацией и файлами занимается окружение, а не приложение.
- Секреты устроены иначе. Env-переменная видна в
/proc/<pid>/environ, попадает в дампы и в описание пода. Под секреты берут Vault/KMS/k8s Secret, монтируют файлом и ротируют.
Вопросы
11
Формулировка, которую ждут: каждый слой знает только про слой ниже, и никогда наоборот.
Транспорт знает про сервис, сервис — про интерфейсы репозиториев, репозиторий — про SQL.
Обратных импортов нет вообще: service не должен уметь собрать HTTP-ответ,
а repository не должен знать, кто его вызвал и зачем.
Что это покупает
- Транспорт меняется, логика остаётся. Появился gRPC или консольная команда, дописываешь ещё один адаптер поверх того же сервиса. Если бизнес-правила размазаны по хендлерам, второй транспорт означает копипасту правил.
- Тестируемость. Сервис тестируется без БД и без HTTP: подставил фейковый репозиторий, проверил сценарий. Это самые дешёвые и самые полезные тесты в проекте.
- Изменения не расползаются. Поменялась схема таблицы, правишь репозиторий. Поменялся формат ответа API, правишь транспорт. Поменялось правило скидки, правишь сервис. Если правка одного требования трогает все три слоя, границы проведены неправильно.
Как это ломают
Три классических нарушения. Первое, «анемичный сервис»: хендлер сам ходит в репозиторий,
сам считает, сам решает; сервис остаётся тонкой прослойкой-делегатом и никакой ценности не несёт.
Второе: транспорт протекает вниз, в сервис прилетает *gin.Context или
http.ResponseWriter, и тестировать его теперь нельзя без поднятия сервера.
Третье: хранилище протекает вверх, наружу возвращается *sql.Rows или
структура с gorm-тегами, и домен оказывается формой таблицы.
Тест на собесе: «могу ли я собрать бинарь, где вместо HTTP — CLI, и не тронуть
ни строки в service?» и «сколько файлов надо открыть, чтобы понять сценарий целиком?».
В Go есть и механическая проверка — правило импортов легко закрепить линтером
(depguard, go-arch-lint) или просто структурой пакетов.
Если internal/storage физически не импортируется из internal/domain,
компилятор сам не даст нарушить.
«А DTO между слоями отдельные или одна структура на всё?» Честный ответ: по умолчанию одна доменная структура, отдельные DTO появляются, когда транспортное и доменное представления начинают расходиться (скрытые поля, версии API, вычисляемые поля). Три зеркальные структуры на CRUD из пяти полей выглядят как ритуал, а не как архитектура, и грамотный интервьюер это ценит.
В наивной трёхслойке цепочка импортов такая: handler → service → postgres.
Домен в самом низу зависит от драйвера БД. Гексагональная (она же «порты и адаптеры»,
она же ядро Clean Architecture) разворачивает это: service объявляет интерфейс
UserRepo у себя, а пакет postgres импортирует service,
чтобы этот интерфейс реализовать. Домен теперь не импортирует ничего, кроме стандартной
библиотеки. Это и есть Dependency Inversion из SOLID, применённый к границе процесса.
Ports and adapters, конкретно
- Driving-порты (входящие, «слева») описывают, что домен предлагает миру: интерфейс юзкейса. Адаптеры: HTTP-хендлер, gRPC-сервер, consumer Kafka, cron-команда.
- Driven-порты (исходящие, «справа») говорят, что домену нужно от мира:
UserRepo,Mailer,PaymentGateway,Clock. Адаптеры: Postgres, SMTP, HTTP-клиент,time.Now. - Composition root остаётся единственным местом (
main), где конкретные адаптеры встречаются с портами.
Практический выигрыш измеряется не красотой схемы, а тем, сколько тестов работают без Docker.
Когда все внешние эффекты спрятаны за портами, весь домен покрывается быстрыми юнит-тестами,
а тестконтейнеры нужны только адаптерам. Второй выигрыш: время больше не просачивается
в код случайно, Clock как порт снимает флаки в тестах на таймауты и TTL.
Когда это оверинжиниринг
Когда домена нет. Если сервис просто проксирует БД (CRUD, фильтры, пагинация), то «домен»
состоит из структуры и валидации, и три слоя абстракций вокруг него означают, что новое поле
придётся дописывать в шести файлах. Признаки лишнего: интерфейс ровно с одной реализацией,
которая никогда не менялась; маппинг структуры в структуру без единого изменения полей;
папка usecase, где в каждом файле один метод, дёргающий один метод репозитория.
Честная позиция на собесе: «начинаю с плоской структуры, инвертирую зависимость там, где
реально появилась вторая реализация или где мне мешает тестировать».
Термины лучше развести. Hexagonal (Кокбёрн, 2005) говорит только про симметрию портов и адаптеров, никаких обязательных четырёх колец. Onion (Палермо) добавляет кольца и правило зависимостей внутрь. Clean (Мартин) сводит это вместе и дописывает сущности, юзкейсы и явные Boundary-интерфейсы. В Go 90 % пользы даёт одна идея из всех трёх: интерфейс объявляет тот, кто его вызывает. Остальное зависит от вкуса и размера команды, и на собесе полезно так и сказать.
wire и fx решают не проблему DI,
а проблему размера composition root.
Канон: у каждого компонента есть New…, принимающий интерфейсы своих зависимостей
и возвращающий готовый к работе объект. Всё связывание происходит в одном месте, в
main (composition root). Граф зависимостей виден глазами, порядок инициализации
и закрытия ресурсов явный, компилятор проверяет типы. Это и есть «DI без магии».
func main() {
cfg := config.MustLoad()
db, err := pgxpool.New(ctx, cfg.DSN) // ресурсы создаются сверху вниз,
if err != nil { log.Fatal(err) }
defer db.Close() // закрываются снизу вверх
repo := postgres.NewUserRepo(db) // адаптер реализует порт
mailer := smtp.NewMailer(cfg.SMTP)
svc := user.NewService(repo, mailer, clock.System{})
h := httpapi.NewHandler(svc, logger)
srv := &http.Server{Addr: cfg.Addr, Handler: h.Routes()}
// ... graceful shutdown
}
Когда становится больно
Когда в main набирается 300 строк проводки и новую зависимость приходится
протаскивать через пять конструкторов. Тогда есть два ответа.
wire (Google) генерирует код: ты описываешь провайдеры, go generate
выдаёт обычный Go-файл с той же ручной проводкой. Ошибки вылезают на компиляции, рантайм
не платит ничего, в отладке видно нормальный код. fx (Uber) собирает граф в рантайме
через рефлексию: удобные жизненные циклы (OnStart/OnStop), группы,
декораторы, но ошибки графа всплывают при старте, а стектрейсы становятся неприятными.
| Ручные конструкторы | wire | fx | |
|---|---|---|---|
| Когда падает при ошибке графа | компиляция | кодогенерация/компиляция | старт процесса |
| Рефлексия в рантайме | нет | нет | да |
| Читаемость графа | полная | полная (сгенерённый код) | надо доверять контейнеру |
| Жизненный цикл ресурсов | руками, defer | руками + cleanup-функции | встроен |
| Порог входа для нового человека | нулевой | низкий | заметный |
«По умолчанию — руками. Это идиоматично, читается без документации и не стоит ничего
в рантайме. Если проводка разрастается и по ней начинают ошибаться, беру wire: он даёт
тот же самый код, только сгенерированный. fx оправдан там, где много одинаковых
сервисов и нужна единая платформенная обвязка с жизненным циклом; в одном сервисе он
добавляет магии больше, чем убирает бойлерплейта.» Отдельно назови, чего в Go делать
не надо: сервис-локатор, глобальный контейнер, зависимости через init()
и пакетные переменные. Всё это скрытый граф, который нельзя ни увидеть, ни подменить в тесте.
В Java или C# интерфейс объявляется вместе с реализацией, потому что класс обязан явно сказать
implements. В Go этого нет: любой тип с подходящим набором методов автоматически
удовлетворяет интерфейсу. Значит, интерфейс можно объявить где угодно, и правильное место
там, где он используется. Пакет-потребитель описывает минимальный контракт, который ему нужен,
а пакет-реализация о нём даже не знает.
// Плохо: пакет-производитель объявляет широкий интерфейс "на всякий случай"
package postgres
type Storage interface { // 20 методов; потребителю нужен один
GetUser(...) ; SaveUser(...) ; ListUsers(...)
GetOrder(...) ; SaveOrder(...) ; /* ... */
}
// Хорошо: потребитель просит ровно то, что использует
package notify
type userGetter interface { // с маленькой буквы: деталь пакета
ByID(ctx context.Context, id user.ID) (*user.User, error)
}
type Notifier struct {
users userGetter
}
func New(u userGetter) *Notifier { return &Notifier{users: u} }
Что это даёт по шагам
- Мок пишется в две строки и лежит рядом с тестом: ни gomock, ни правка чужого пакета не нужны.
- Циклы импорта рвутся. Стрелка разворачивается: раньше
notifyимпортировалpostgres, теперь никто никого. - Честный ISP. Интерфейс с одним методом не получится «раздуть на будущее» — он ровно такой, каким его требует код.
- Устойчивость к изменениям. Добавили метод в конкретный тип — ни один потребитель не сломался, потому что их контракты уже, чем реализация.
Обратная сторона правила: возвращать надо конкретный тип. Если конструктор отдаёт
интерфейс, потребитель теряет доступ к новым методам, доку становится читать сложнее,
и появляется классическая ловушка Go — «типизированный nil в интерфейсе»: функция вернула
(*MyErr)(nil), а сравнение err != nil дало true.
Есть законные исключения, и их стоит назвать: (1) интерфейсы стандартной библиотеки
(io.Reader, error, fmt.Stringer) задают общий словарь
и объявлены «у производителя» специально; (2) плагинная точка расширения, когда
реализаций заведомо много и они внешние (драйверы database/sql);
(3) контракт, который и есть публичный API библиотеки. Во всех этих случаях
интерфейс входит в продукт, а не остаётся деталью потребителя.
internal/ (импорт разрешён только из поддерева родителя этой папки). Всё остальное — соглашение,
и pkg/ чаще вреден, чем полезен.Что означает каждая папка
cmd/<binary>/main.goдаёт по папке на бинарь. Внутри только разбор конфига, сборка графа зависимостей и запуск. Логики здесь нет: всё, что вmain, невозможно импортировать и тяжело тестировать.internal/остаётся единственной папкой со смыслом на уровне языка. Пакет внутриinternalимпортируется только из поддерева его родителя. Это защита от того, что чужой сервис начнёт зависеть от твоих внутренностей и ты никогда не сможешь их поменять. По умолчанию весь код сервиса живёт здесь.pkg/означает «код, который мы разрешаем импортировать снаружи». В проекте с одним сервисом это лишний уровень вложенности. Заводить его стоит, только когда у тебя есть внешние потребители и ты готов держать обратную совместимость.- В
api/лежат proto/OpenAPI-схемы, вmigrations/SQL, вdeploy/манифесты. Это уже вкусовщина, но она предсказуемая.
Package-oriented design
Идея Билла Кеннеди: пакет задаёт зону ответственности, а не категорию типов.
Нарезка models/, services/, handlers/, utils/
группирует по «что это за штука», из-за чего одна фича размазывается по четырём папкам,
а импорты неизбежно закольцовываются. Нарезка user/, order/,
billing/ группирует по «про что это», и тогда фича живёт в одном месте,
а имя пакета работает на читаемость: user.Service, а не services.UserService
(заикание user.UserService выдаёт ту же неправильную нарезку).
Отдельно под запретом пакет utils/common/helpers:
склад без границы, который со временем импортирует всё и становится источником циклов.
Монорепа
Монорепа выигрывает, когда сервисы делает одна команда и они меняются вместе: атомарный
коммит через границу сервиса, единые версии зависимостей, общий тулинг и линтеры, простой
shared-код без публикации библиотек, честный «сломал контракт — увидел сразу». Проигрывает,
когда репозиторий вырастает настолько, что CI начинает собирать всё на любое изменение
(лечится go work, тегами и селективной сборкой по изменённым путям), и когда
разные команды хотят разный релизный цикл и разные права доступа. Практичный ответ:
«пока команда одна и сервисов меньше десятка, монорепа почти всегда дешевле;
боль начинается на масштабе CI, и лечится она тулингом, а не разделением репозиториев».
Этот репозиторий часто выдают за официальный стандарт. Стандартом он не стал, и авторы Go
от него открыто дистанцируются. Рабочая позиция на собесе: «начинаю плоско, с main.go
и нескольких пакетов по доменам; cmd/ появляется, когда бинарей становится больше
одного; internal/ завожу сразу, потому что это бесплатная защита границы;
pkg/ только под реальных внешних потребителей».
S — Single Responsibility
«У модуля должна быть одна причина для изменения» — причина, а не действие. Классический
антипример: OrderService, который валидирует, считает налог, пишет в БД, шлёт письмо
и отдаёт PDF. Его меняют бухгалтерия, маркетинг и DBA — три причины, три источника конфликтов.
Разбиваешь на Validator, TaxCalculator, Repository,
Notifier, а сам сервис становится сценарием, который их вызывает.
O — Open/Closed
Расширять поведение без правки существующего кода. В Go это делается не наследованием, а функциями и интерфейсами: реестр стратегий, куда регистрируются новые обработчики, или functional options, куда добавляется новая опция.
// Было: switch, который правят при каждом новом типе оплаты
switch method {
case "card": ...
case "sbp": ...
}
// Стало: новая оплата = новый файл, старый код не трогаем
type PaymentMethod interface {
Charge(ctx context.Context, amount Money) (TxID, error)
}
type Registry struct {
m map[string]PaymentMethod
}
func (r *Registry) Register(name string, p PaymentMethod) { r.m[name] = p }
L — Liskov Substitution
Наследования в Go нет, но LSP никуда не делся: любая реализация интерфейса обязана вести себя
так, как обещает контракт. Контракт нарушает реализация, которая на половине методов возвращает
errors.New("not supported"), или Read, который не возвращает
io.EOF, или репозиторий, который вместо ErrNotFound отдаёт
nil, nil. Код, написанный против интерфейса, ломается при подстановке, и это
нарушение LSP. В контракт входят сигнатуры, а вместе с ними ошибки, блокирующее поведение
и потокобезопасность.
I — Interface Segregation
Много маленьких интерфейсов лучше одного большого. Эталон держит стандартная библиотека:
io.Reader и io.Writer по одному методу, а io.ReadWriteCloser
собирается композицией. Практическое правило: если реализациям приходится писать заглушки —
интерфейс слишком широкий.
D — Dependency Inversion
Оба уровня зависят от абстракции, и объявляет её верхний уровень. Именно поэтому в Go
интерфейс живёт у потребителя: service объявляет UserRepo,
postgres его реализует, и стрелка импорта идёт снизу вверх, к домену.
Пересказать принципы «как в книжке» и не сказать про цену. Сильный ответ добавляет:
«SRP, доведённый до предела, даёт сотню типов по одному методу, где сценарий невозможно
прочитать целиком; OCP через реестр стратегий делает поток управления неявным —
по коду больше не видно, кто сработает. Я применяю их как направление, а не как проверку:
если правка одного требования трогает пять пакетов — нарушен SRP; если новая фича заставляет
править switch в трёх местах — просится OCP.»
| Паттерн | Идиома в Go | Где живёт в стдлибе / экосистеме |
|---|---|---|
| Builder | Functional options: New(addr, WithTimeout(5*time.Second)) | grpc.NewClient, zap.New |
| Strategy | Поле-интерфейс или просто поле-функция | sort.Slice(less func) |
| Decorator | Обёртка, реализующая тот же интерфейс; middleware-цепочка | http.Handler, io.LimitReader |
| Adapter | Тип-функция с методом: http.HandlerFunc | http.HandlerFunc, sort.Reverse |
| Factory | Обычная функция New…; реестр для выбора по строке | sql.Open по имени драйвера |
| Singleton | sync.Once / sync.OnceValue (Go 1.21) | ленивые клиенты, компиляция регэкспов |
| Observer | Каналы, список подписчиков, context.Done() | шины событий, signal.NotifyContext |
| Iterator | Функции-итераторы iter.Seq (Go 1.23), range over func | maps.Keys, slices.Values |
| Template Method | Не наследование, а функция-параметр или встраивание интерфейса | — |
Functional options — почему именно они
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithLogger(l *slog.Logger) Option { return func(s *Server) { s.log = l } }
func New(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second, log: slog.Default()} // умолчания
for _, o := range opts { o(s) }
return s
}
Плюсы: обратная совместимость (новая опция не ломает старые вызовы), разумные умолчания, самодокументируемость на месте вызова, невозможность перепутать порядок аргументов. Минусы: многословно, и валидация уезжает в рантайм; для двух-трёх параметров честнее просто структура конфига.
Decorator = middleware
type Middleware func(http.Handler) http.Handler
func WithLogging(log *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Info("req", "path", r.URL.Path, "dur", time.Since(start))
})
}
}
// Порядок важен: первый в списке становится самым внешним
func Chain(h http.Handler, ms ...Middleware) http.Handler {
for i := len(ms) - 1; i >= 0; i-- { h = ms[i](h) }
return h
}
Abstract Factory с иерархией фабрик в языке без наследования вырождается в чистый бойлерплейт.
Singleton через пакетную переменную и init() прячет зависимость:
в тесте её не подменить, порядок инициализации между пакетами непредсказуем;
если состояние правда одно на процесс, оно всё равно должно создаваться в main
и передаваться явно. Visitor почти всегда проще выражается через
switch v := x.(type). Хороший ответ на собесе заканчивается фразой:
«паттерн это имя для решения, а не цель; в Go половина GoF растворяется в языке».
ErrNotFound, обёрнутая через
%w; ни nil, nil, ни sql.ErrNoRows наружу отдавать нельзя.Три реальные причины заводить репозиторий: сервис тестируется без базы; маппинг «строка → доменный тип» лежит в одном месте; SQL не растекается по хендлерам. Причина, которую называют чаще всего и которая почти всегда ложная, звучит так: «сможем сменить Postgres на Mongo». Не сможем. Транзакции, блокировки, уникальные индексы и модель консистентности интерфейсом не абстрагируются.
// domain/user: интерфейс объявлен там, где используется
type Repository interface {
ByID(ctx context.Context, id ID) (*User, error)
ByEmail(ctx context.Context, email string) (*User, error)
Create(ctx context.Context, u *User) error
Update(ctx context.Context, u *User) error
}
var (
ErrNotFound = errors.New("user not found")
ErrAlreadyExists = errors.New("user already exists")
)
// storage/postgres: адаптер переводит ошибки драйвера в доменные
func (r *UserRepo) ByID(ctx context.Context, id user.ID) (*user.User, error) {
const q = `SELECT id, email, name, created_at FROM users WHERE id = $1`
var u user.User
err := r.db.QueryRow(ctx, q, id).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
switch {
case errors.Is(err, pgx.ErrNoRows):
return nil, fmt.Errorf("user %s: %w", id, user.ErrNotFound) // %w сохраняет sentinel
case err != nil:
return nil, fmt.Errorf("select user %s: %w", id, err)
}
return &u, nil
}
func (r *UserRepo) Create(ctx context.Context, u *user.User) error {
_, err := r.db.Exec(ctx, `INSERT INTO users (...) VALUES (...)`, ...)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation
return fmt.Errorf("email %s: %w", u.Email, user.ErrAlreadyExists)
}
return err
}
Почему именно так
nil, nilперекладывает на вызывающего обязанность помнить про проверку; рано или поздно кто-то забудет и получит панику вместо 404.(*User, bool, error)возвращает три значения на каждый вызов, конструкцияif !okплодится по всему коду и путается сerr != nil.sql.ErrNoRowsнаружу тащит деталь драйвера в бизнес-логику; при переходе сdatabase/sqlнаpgx(там свойpgx.ErrNoRows) сломается весь сервис.- Sentinel +
errors.Isпозволяют транспорту в одном месте отобразить домен в HTTP:ErrNotFound→ 404,ErrAlreadyExists→ 409.
Транзакции: не тащить *sql.Tx в интерфейс. Либо Transactor
с методом WithTx(ctx, fn), кладущий транзакцию в context, либо
Unit of Work. Списки: метод List с произвольными фильтрами быстро
превращается в конструктор SQL внутри домена, и это признак, что нужен либо явный
типизированный Filter-объект, либо отдельный read-модуль с честными запросами
(шаг в сторону CQRS). Для пустого списка ошибка не нужна: пустой срез — валидный результат.
Порядок действий
- Характеристические тесты (Майкл Физерс). Не «правильные», а фиксирующие текущее поведение, включая странности. Golden-файлы: прогнал реальные входы, записал выходы, закоммитил. Теперь любое изменение поведения видно как diff.
- Найти швы (seams). В Go это значит вытащить наружу
time.Now,http.Get,os.Getenv, глобальные переменные, прямыеdb.Query, и сделать их полями структуры или параметрами. - Вынести чистые функции. Куски без ввода-вывода покрываются тестами первыми и ломаются заметнее всего.
- Разрезать по данным. Смотришь, какие поля используются какими методами вместе. Метод, трогающий три поля из двадцати, просится в отдельный тип. Это самый надёжный критерий границы — куда надёжнее, чем «по смыслу».
- Переносить по одному методу, оставляя в старом объекте делегирующий вызов. Каждый шаг компилируется, каждый шаг деплоится.
- Удалить делегаты, когда все вызовы переехали. Компилятор Go найдёт остатки.
Что говорить про процесс
Рефакторинг идёт в основной ветке маленькими коммитами, а не в трёхмесячной ветке. Переименование и перенос разводи по разным коммитам, иначе diff нечитаем и ревью формально. Если поведение обязано измениться, ставь feature flag, чтобы старый и новый путь жили рядом и можно было сравнить результаты на проде (shadow-запуск). И заранее договорись о метрике успеха: размер файла, число зависимостей пакета, время сборки, покрытие. Иначе процесс не заканчивается.
«Я не рефакторю ради красоты. Триггером работает конкретная боль: в этот файл упираются все задачи спринта, любая правка ломает соседнее, тесты идут десять минут. Тогда я делаю ровно столько, сколько нужно, чтобы текущая задача стала простой. Правило Кента Бека: make the change easy, then make the easy change. Большой рефакторинг „потому что некрасиво“ никогда не окупается и не проходит приоритизацию.»
import cycle not allowed). Цикл почти всегда означает, что пакеты нарезаны
по типам данных, а не по зонам ответственности.
Запрет не каприз: без циклов у компилятора есть однозначный топологический порядок
инициализации пакетов (переменные уровня пакета, затем init()), и каждый пакет
можно компилировать независимо и кэшировать. В языках, где циклы разрешены, порядок
инициализации становится источником трудноуловимых багов.
Четыре способа разорвать
- Интерфейс у потребителя. Работает почти всегда:
orderобъявляет у себяtype userGetter interface{ ByID(...) }, и импортorder → userисчезает, аuserо нём и не знал. - Общий нижний пакет. Типы, нужные обоим, уезжают в
internal/domain(илиentity), от которого зависят оба, а он — ни от кого. - Слить пакеты. Если два пакета взаимно нужны друг другу постоянно, это чаще всего одна зона ответственности, разрезанная искусственно.
- Инвертировать через события. Вместо прямого вызова
user → notifyпакетuserпубликует событие в шину, аnotifyподписывается; оба зависят от пакета с типами событий.
Разрывать цикл через any и reflect, через глобальную мапу
обработчиков или через регистрацию в init(). Компилятор замолчит, но цикл
никуда не делся — он просто переехал в рантайм, где вместо ошибки сборки ты получишь
панику при старте или в проде. Особняком идёт тестовый цикл: пакет a
тестируется с помощью b, а b импортирует a.
Лечится внешним тестовым пакетом package a_test, которому разрешено
импортировать и a, и b.
Практический канон
- Приоритет один на все сервисы и задокументирован. Какой именно, дело вкуса; важно, чтобы он не менялся от проекта к проекту.
- Fail fast. Невалидный конфиг убивает процесс на старте, а не через два часа на первом обращении к редкой ручке. В k8s это правильно: под не пройдёт readiness и старая версия останется жить.
- Конфиг не глобал. Разобрали структуру в
main, дальше передаём куски в конструкторы.os.Getenvпосреди бизнес-логики прячет зависимость: её не видно в графе и не подменить в тесте. - Типы, а не строки.
time.Duration,int,url.URLразбираются и проверяются на старте, чтобы потом не гадать: «таймаут тут в миллисекундах или в секундах?».
12-factor: что реально спрашивают
- III. Config в окружении. В репозитории конфигов стендов быть не должно, а новый стенд не должен требовать релиза.
- VI. Stateless-процессы: никакого состояния в памяти между запросами. Сессии в Redis, файлы в объектном хранилище. Иначе не масштабируется горизонтально и теряется при рестарте.
- IX. Disposability требует быстрого старта и корректной реакции на
SIGTERM: перестать принимать новое, дожать текущее, закрыть коннекты. Прямая связка с graceful shutdown и с тем, как k8s выкатывает поды. - XI. Логи в stdout. Приложение не занимается ротацией и файлами, это работа окружения.
- X. Dev/prod parity: одинаковые версии зависимостей и одинаковый способ запуска, отсюда популярность docker-compose для локальной разработки.
Этот вопрос любят задавать следом. Переменная окружения видна в
/proc/<pid>/environ, попадает в core-дампы, в kubectl describe pod
и в логи упавшего процесса, а ротировать её можно только рестартом. Поэтому секреты
хранят в Vault/KMS/k8s Secret, монтируют файлом, ротируют и держат короткий TTL
(в идеале — динамические креды к БД). И главное: секрет не коммитят никогда, даже
в приватный репозиторий, потому что вычистить его из истории получится только переписыванием
всех коммитов с force-push, а утёкший ключ всё равно придётся считать
скомпрометированным.
11.2Монолит vs микросервисы
Тема, где кандидата чаще всего ловят на моде. Правильный ответ никогда не звучит как «микросервисы лучше»: он звучит как «микросервисы решают организационную проблему ценой технической сложности, и если у тебя нет первой, ты просто покупаешь вторую».
Честное сравнение по осям
Сравнивать «монолит vs микросервисы» вообще бессмысленно. Сравнивать надо по конкретным осям, и почти на каждой оси ответ меняется от размера команды. Ниже таблица, которую полезно уметь развернуть вслух: в ней нет строки, где один вариант выигрывает всухую.
| Ось | Монолит | Микросервисы | Где перелом |
|---|---|---|---|
| Скорость разработки фичи | Высокая: один репозиторий, рефакторинг через границы одним коммитом, компилятор проверяет всё | Низкая на старте: одна фича = правки в 2–4 сервисах, согласование контрактов, порядок выката | Когда в один код лезут 5+ команд и merge-конфликты съедают больше, чем сеть |
| Деплой | Один артефакт. Но: любая правка выкатывает всё, откат — тоже всё. Релиз становится событием | Независимые релизы — главный и часто единственный настоящий выигрыш | Когда очередь на релиз мешает командам работать |
| Масштабирование | Только целиком: чтобы вытянуть тяжёлый отчёт, копируешь весь процесс со всей его памятью | Точечное: 40 подов на поиск, 3 на биллинг. Разные профили CPU/RAM/GPU | Когда 5 % кода потребляет 80 % ресурсов |
| Отказоустойчивость | Всё падает вместе; зато нет частичных отказов и «половина системы работает, половина нет» | Изоляция отказов — если есть таймауты, circuit breaker и деградация. Без них хуже монолита | Изоляция не бесплатна: её надо явно построить |
| Отладка | Стектрейс, дебаггер, go test ./.... Всё воспроизводится локально |
Нужны distributed tracing, корреляционные id, агрегация логов; локально система не поднимается | Всегда в пользу монолита. Это чистая цена |
| Найм и онбординг | Новичок должен осилить всё; чем больше монолит, тем дольше вход | Новичок входит в один сервис за неделю, но не понимает системы целиком | Зависит от того, есть ли документация границ |
| Инфраструктура | Бинарь + БД. Хватит systemd и одной машины | Оркестратор, реестр образов, service discovery, брокер, трейсинг, CI на N пайплайнов, дежурства | Это тот самый «налог», который платится до первой пользы |
| Консистентность данных | ACID-транзакция покрывает весь сценарий. Foreign keys работают | Транзакций между сервисами нет: саги, компенсации, eventual consistency, дубликаты | Самая недооценённая статья расходов |
| Технологическая свобода | Один стек на всё | Свобода выбора языка — и зоопарк, который потом некому поддерживать | Чаще ловушка, чем польза |
«Микросервисы не про производительность и не про чистоту кода. Они про то, чтобы N команд могли релизиться независимо друг от друга. Остальное идёт побочными эффектами: что-то приятное, что-то приходит счётом к оплате. Если у меня одна команда из шести человек, я покупаю сетевые вызовы, eventual consistency и дежурства, не получая взамен главного.»
Стоит ли новый продукт делать монолитом
Почти всегда да, и у подхода есть имя: monolith first (Мартин Фаулер, 2015). Логика простая и почти не оспаривается на практике.
- Границы неизвестны. В новом продукте предметная область меняется каждую неделю. Граница внутри монолита переносится рефакторингом за час; между сервисами она обойдётся в миграцию данных, версионирование API и несколько недель координации релизов. Ошибиться в границах на старте нормально.
- Продукт может не выжить. Инфраструктурный налог платится сразу, а окупается через год-два. Тратить первые три месяца на Kubernetes и трейсинг вместо проверки гипотезы не стоит.
- Обратный путь дороже прямого. Разрезать монолит по живой границе неприятно, но реально. Склеивать обратно десять сервисов, у каждого из которых своя БД и свой контракт, мучительно. Segment схлопнул полторы сотни таких сервисов в один, Istio слил управляющие компоненты в istiod, Prime Video собрал свой конвейер анализа видео обратно в один процесс — и каждый раз это стоило дорого.
Обратный аргумент существует, и назвать его стоит самому: если компания заранее знает предметную область (второй похожий продукт), уже имеет платформу и опыт эксплуатации, а команд сразу несколько, то начинать с нескольких сервисов разумно. Это исключение, и оно про зрелую организацию, а не про стартап.
Модульный монолит: как его строить в Go
У модульного монолита один процесс и одна база, но внутренние границы соблюдаются так, как будто это уже сервисы. Смысл: получить дешёвый рефакторинг границ и ACID-транзакции сейчас, а возможность распила оставить на потом, без переписывания.
Практические правила, которые делают монолит модульным (а не «просто монолитом с папками»):
- У модуля публичный API и приватные потроха.
internal/order/экспортируетorder.Serviceи доменные типы, всё остальное пишется с маленькой буквы или прячется в подпакетinternal/order/internal/…, куда компилятор просто не пустит чужих. - Модули общаются только через публичные интерфейсы модулей, а не через чужие
репозитории и таблицы. Хочешь пользователя — зови
user.Service, а неSELECT * FROM users. - Схема БД разделена по модулям. Отдельные схемы Postgres (
orders.*,users.*) или хотя бы явное соглашение о префиксах. Запрет на JOIN между схемами разных модулей соблюдать тяжелее всего, и он же ценнее всего: именно совместные JOIN-ы потом делают распил невозможным. - Внутримодульные события вместо прямых вызовов там, где связь «уведомить». Простая шина в памяти сегодня превращается в Kafka завтра, а код модулей не меняется.
- Границы проверяются автоматически. Линтер импортов (
depguard,go-arch-lint) в CI: правило «никто, кромеorder, не импортируетorder/internal». Без такой проверки границы разъезжаются за квартал.
// internal/order/service.go — публичный контракт модуля
package order
type Service struct {
repo repository // приватный интерфейс модуля
users UserProvider // порт к соседнему модулю: только то, что нужно
events EventPublisher // сегодня шина в памяти, завтра Kafka
}
// Порт: order не знает про пакет user, только про свой узкий интерфейс.
type UserProvider interface {
ByID(ctx context.Context, id string) (Customer, error)
}
// main.go — единственное место, где модули встречаются
orderSvc := order.NewService(orderRepo, userAdapter{userSvc}, bus)
Задай себе один вопрос: сколько времени займёт вынести модуль billing в отдельный
процесс? Если ответ «заменить вызов метода на HTTP-клиент и перенести таблицы», монолит
модульный. Если «непонятно, потому что половина запросов джойнит billing.invoices
с orders.items, а user.Service напрямую пишет в чужую таблицу»,
то это обычный монолит, просто разложенный по папкам.
Когда монолит действительно упирается в потолок
«Стало много кода» ни о чём не говорит. Есть монолиты на миллионы строк, которые прекрасно живут. Настоящие признаки касаются людей и эксплуатации, и все они измеримы:
| Признак | Как измерить | Что это значит |
|---|---|---|
| Очередь на релиз | Время от «готово» до «на проде» > нескольких дней; релизный поезд раз в неделю | Команды блокируют друг друга — главный аргумент за распил |
| Один упавший модуль роняет всё | Утечка в отчётах кладёт приём платежей | Нет изоляции ресурсов внутри процесса |
| Профиль нагрузки разошёлся | Поиск требует 32 ГБ RAM, всё остальное — 2 ГБ; платим за максимум ×N подов | Масштабировать целиком стало дорого буквально в деньгах |
| Конфликты в общих файлах | Каждый мерж — ручное разрешение в одних и тех же местах | Границы модулей не соблюдаются |
| CI перестал помещаться | Сборка и тесты > 30–40 минут, флаки, «прогоню ещё раз» | Обратная связь порвалась; лечится в том числе кэшем и селективными тестами |
| Разные требования к доступности | Приём платежей — 99.99 %, админка — 99 %, а деплоятся вместе | Разные SLA просят разных жизненных циклов |
| Технологический тупик | Нужен ML-инференс на Python или библиотека, которой нет в Go | Отдельный сервис оправдан точечно, без общего распила |
«Код грязный» лечится рефакторингом, сеть его не почистит; грязный код, размазанный по сети, становится грязным распределённым кодом. «Хотим микросервисы в резюме» причина честная, но не архитектурная. «Медленно работает» разбирают профилированием: сетевой вызов дороже вызова функции на пять порядков, и распил почти всегда делает латентность хуже. «У нас 30 разработчиков» само по себе ничего не значит; значит только то, мешают ли они друг другу релизиться.
Стратегии распила
Strangler fig по шагам
- Поставить фасад. Reverse proxy (nginx/Envoy/API Gateway) перед монолитом, который сначала проксирует 100 % трафика без изменений. Шаг важнее всех остальных: появляется точка, где можно переключать маршруты, не трогая клиентов.
- Выбрать первый кусок. Не самый важный, а самый изолированный: мало данных, мало связей, понятный контракт. Обычно это уведомления, экспорт отчётов, загрузка файлов. Первый распил тренирует инфраструктуру, а не приносит бизнес-ценность.
- Разделить данные до кода. Таблицы будущего сервиса перестают участвовать в JOIN-ах монолита; вместо прямых запросов появляются вызовы модуля. Это самая долгая часть, и её делают внутри монолита, пока откат бесплатный.
- Написать сервис и включить двойную запись/чтение. Запросы идут в оба места, результаты сравниваются в фоне (shadow / dark launch). Расхождения это найденные баги, а не повод останавливаться.
- Переключить маршрут на фасаде. Сначала 1 % трафика, потом 10 %, потом 100 %. Откат мгновенный.
- Удалить код из монолита. Шаг, который пропускают чаще всего. Если старый код остался «на всякий случай», через полгода у тебя два источника правды и никто не помнит, какой живой.
Branch by abstraction
Альтернатива фасаду для случая, когда заменяемый кусок сидит внутри кода и наружу не торчит (нельзя переключить по URL). Порядок: (1) вводим абстракцию — интерфейс над старой реализацией, все вызовы идут через него; (2) пишем вторую реализацию, которая ходит в новый сервис; (3) переключаем по фиче-флагу, можно по проценту пользователей; (4) удаляем старую реализацию и, при желании, саму абстракцию. Всё это живёт в главной ветке, ничего не висит в долгоживущем бранче, отсюда и название.
type PricingEngine interface {
Price(ctx context.Context, cart Cart) (Money, error)
}
// Флаг-переключатель: обе реализации живут одновременно
type switching struct {
old, new PricingEngine
flags Flags
log *slog.Logger
}
func (s *switching) Price(ctx context.Context, c Cart) (Money, error) {
if !s.flags.Enabled(ctx, "pricing_v2") {
return s.old.Price(ctx, c)
}
// «тёмный» прогон: считаем новым, но отдаём старое, пока не совпадёт
got, err := s.new.Price(ctx, c)
want, errOld := s.old.Price(ctx, c)
if err == nil && errOld == nil && got != want {
s.log.Warn("pricing mismatch", "new", got, "old", want, "cart", c.ID)
}
return want, errOld
}
Антипаттерн: распределённый монолит
Диагностические признаки, которые полезно перечислить на собесе списком:
- Общая база данных. Два сервиса пишут в одну таблицу, значит это один сервис, разложенный по двум процессам. Миграция схемы становится общесистемным событием.
- Релиз «поездом». Чтобы выкатить фичу, нужно выкатить три сервиса в правильном порядке, и откат тоже по порядку.
- Длинные синхронные цепочки. Один клиентский запрос порождает 5–10 последовательных вызовов; латентность складывается, доступности перемножаются.
- Общая библиотека с доменными типами. Изменил структуру в shared-пакете — обязан пересобрать и выкатить всех потребителей. Это компиляционная связность, просто спрятанная в go.mod.
- Нельзя протестировать сервис в одиночку. Для локального запуска нужен docker-compose на восемь контейнеров.
Монолит даёт транзакции, дешёвый рефакторинг и простую отладку, а распределённый монолит всё это теряет. Микросервисы дают независимый релиз и изоляцию отказов, а он не получает и этого. Остаётся чистый минус: сетевые ошибки, сериализация, версии контрактов, десять пайплайнов, трейсинг ради того, чтобы понять, где упало. Именно поэтому фраза «мы распилили монолит, но релизимся всё равно всем вместе» звучит как диагноз, а не как этап.
Как проводить границы сервисов
Рабочие критерии границы, по убыванию надёжности:
- Bounded context из DDD. Граница проходит там, где меняется смысл слова. «Заказ» в корзине это черновик с товарами; в доставке — адрес, вес и маршрут; в бухгалтерии — сумма и НДС. Три разных модели одного слова дают три кандидата в сервисы. Общая «универсальная» модель заказа, которая устраивает всех, не устраивает никого и намертво связывает команды.
- По данным, а не по действиям. Сервис эксклюзивно владеет своими таблицами и остаётся для них единственным источником правды. Если для одной операции нужна транзакция по таблицам двух сервисов, значит граница проведена неверно, и это самый сильный сигнал.
- По причине изменения. Смотришь историю git: какие файлы меняются вместе. То, что всегда правится одним коммитом, должно жить в одном сервисе. Это буквально SRP, поднятый на уровень системы.
- По команде (закон Конвея). Система копирует структуру коммуникаций организации. Если границы сервисов не совпадают с границами команд, побеждают команды: код всё равно расползётся по чужим сервисам. При «обратном манёвре Конвея» сначала строят команды так, как хотят видеть архитектуру.
- По требованиям к нагрузке и SLA. Кусок с другим профилем трафика или другой требуемой доступностью тянет на отдельный сервис, даже если доменно он невелик.
- Ubiquitous language — единый язык кода и бизнеса внутри контекста. Если аналитик
говорит «заявка», а в коде
Request,TicketиApplication, то это будущие баги перевода. - Bounded context задаёт границу, внутри которой термин значит одно и то же. Между контекстами термины переводятся явно.
- Aggregate собирает объекты вокруг одного корня и меняется атомарно. Практическое правило: агрегат задаёт границу транзакции, и он никогда не должен пересекать границу сервиса.
- Context map описывает отношения между контекстами: shared kernel (общий код, опасно), customer/supplier (поставщик учитывает нужды потребителя), conformist (принимаем чужую модель как есть), anticorruption layer (слой перевода, который защищает твою модель от чужой); им обычно и оборачивают легаси-монолит при распиле.
Полная цена микросервисов
Список, который стоит держать в голове целиком: на собесе именно он отличает человека, который эксплуатировал микросервисы, от человека, который про них читал.
- Сеть вместо вызова функции. Вызов метода занимает единицы наносекунд, вызов по сети внутри датацентра сотни микросекунд плюс сериализация. Это пять-шесть порядков. Плюс сеть ненадёжна: таймауты, ретраи, дубликаты, частичные отказы становятся частью бизнес-логики.
- Консистентность. Транзакции больше нет. Всё, что раньше было одним
BEGIN … COMMIT, превращается в сагу с компенсациями, outbox и идемпотентными обработчиками. Это не «немного кода», а отдельная подсистема со своим состоянием и багами. - Наблюдаемость обязательна, а не желательна. Без distributed tracing, единого сбора логов с trace_id и метрик по каждому вызову отладка превращается в гадание. Это отдельный стек (OpenTelemetry, Jaeger/Tempo, Prometheus, Loki) со своей стоимостью хранения.
- Инфраструктура и эксплуатация. Оркестратор, реестр образов, service discovery, секреты, N пайплайнов CI, N дашбордов, N алертов, дежурства и рантбуки. Плюс средства локальной разработки, чтобы человек мог поднять кусок системы на ноутбуке.
- Версионирование контрактов. Ни один сервис нельзя выкатить, сломав контракт: нужны обратная совместимость, дедлайны миграции и contract-тесты.
- Дублирование данных и кода. Копии справочников в нескольких сервисах, их синхронизация, повторяющаяся обвязка (аутентификация, логирование, конфиг). Дальше или копипаста, или общая платформенная библиотека со своей проблемой версий.
- Когнитивная нагрузка. Понять один сервис легко, понять систему тяжело. Нужна карта сервисов и владельцев, иначе появляются «сервисы-сироты», которые все боятся трогать.
Вопросы
6Разложить по осям, а не «лучше/хуже»
Монолит выигрывает в скорости разработки, отладке, консистентности и стоимости инфраструктуры. Микросервисы выигрывают в независимости деплоя, точечном масштабировании и изоляции отказов, причём изоляция не появляется сама, её надо построить таймаутами, circuit breaker и деградацией. Есть оси, где выигрыш неоднозначен: онбординг (в монолит вход дольше, но система понятна целиком), технологическая свобода (чаще зоопарк, чем польза).
Почему monolith first
- Границы неизвестны. В новом продукте домен переопределяют каждую неделю. Ошибка в границе внутри монолита стоит рефакторинга; между сервисами миграции данных, версий API и координации релизов.
- Налог платится сразу, польза приходит потом. Оркестратор, трейсинг, брокер, пайплайны съедают месяцы, которые продукт может не пережить.
- Склеивать обратно дороже, чем резать. Публичных историй возврата к монолиту (Segment, Amazon Prime Video) больше, чем историй «мы жалеем, что начали с монолита».
Когда исключение оправдано
Если организация уже знает домен (делает второй похожий продукт), у неё есть платформа и опыт эксплуатации, и сразу несколько команд, то стартовать несколькими сервисами разумно. Законен и отдельный сервис под чужой стек (ML на Python) или под другой профиль нагрузки: это точечное решение, а не общий распил.
«Микросервисы лечат организационную боль, а не техническую. Если команды не мешают друг другу релизиться, я плачу за микросервисы, не получая главного, что они дают.» Дальше полезно добавить компромисс: модульный монолит, один процесс с жёсткими внутренними границами, из которого распил делается механически, когда боль появится.
Пять правил
- Модуль = пакет с узким публичным API. В Go за это отвечает сам язык:
internal/order/internal/…компилятор не даст импортировать никому, кроме поддереваorder. Наружу торчатorder.Serviceи доменные типы, остальное закрыто. - Общение через порты, а не через чужие репозитории.
orderобъявляет у себяUserProviderс одним методом; адаптер кuser.Serviceсобирается вmain. - Данные разделены. Отдельные схемы Postgres на модуль и запрет на JOIN между ними. Это самое неудобное и самое ценное правило: именно совместные JOIN-ы делают распил невозможным через два года.
- События внутри процесса. Там, где связь по смыслу «уведомить, а не запросить», модуль публикует событие в шину в памяти. При распиле шину меняют на Kafka, а модули править не приходится.
- Границы проверяются в CI.
go-arch-lintилиdepguardс правилами импортов. Без автоматической проверки границы разъезжаются за квартал.
Что это даёт при распиле
Вынос модуля превращается в механическую операцию: реализация порта заменяется на HTTP/gRPC-клиент, таблицы модуля переезжают в свою базу, шина в памяти — в брокер. Никакого «сначала полгода распутываем» не требуется. Плюс можно резать по одному модулю за раз и останавливаться в любой момент, многие команды так и живут: два-три вынесенных сервиса и большое модульное ядро.
«Сколько времени займёт вынести billing в отдельный процесс?» Если ответ
измеряется днями, монолит модульный. Если «непонятно, там всё джойнится», то это обычный
монолит с красивыми папками, и его модульность существует только в презентации.
Признаки, которые считаются
- Организационный. Время от «готово» до «на проде» измеряется днями; команды ждут релизного поезда и друг друга. Это единственный признак, который прямо указывает на микросервисы.
- Ресурсный. Один модуль требует 32 ГБ, остальным нужно 2, и платим за максимум, умноженный на число подов. Или один модуль на GPU, а остальные нет.
- Надёжностный. Утечка памяти в отчётах кладёт приём платежей; нет способа изолировать ресурсы внутри одного процесса.
- SLA. Платежи хотят 99.99 %, админка — 99 %, а деплоятся вместе, значит, по факту у обоих SLA админки.
- Инженерный. Сборка и тесты дольше получаса, флаки, «прогоню ещё раз». Обратная связь порвалась.
Что делать, и не всегда это распил
- Сначала дешёвое. Вертикальное масштабирование, кэш, индексы, профилирование. Часто «потолок» оказывается одним неудачным запросом. Распил медленного кода делает его медленным и распределённым.
- Разделить деплой одного бинаря по ролям. Один и тот же артефакт запускается
с разными флагами:
--role=api,--role=worker,--role=cron. Это даёт независимое масштабирование и изоляцию отказов почти бесплатно, без распила кода и без сети между модулями. - Навести модульность внутри. Разделить схемы БД, убрать межмодульные JOIN-ы, поставить линтер импортов. Это подготовка к распилу, которая полезна, даже если распила не будет.
- Вынести первый сервис strangler-фигом. Самый изолированный кусок, ради тренировки инфраструктуры.
- Резать дальше только под конкретную боль. Каждый следующий сервис должен закрывать названный признак из списка выше, а не «продолжать начатое».
Интервьюер ждёт, что ты назовёшь «много кода» или «стало сложно». Ответ «сложно» не инженерный: сложность лечится модульностью, а не сетью. Сильнее сработает встречный вопрос: «какая боль? релизы, ресурсы или отказы?», и дальше отвечаешь под неё. Именно это поведение и проверяют вопросом.
Strangler fig
Метафора фикуса-душителя: новое обвивает старое и постепенно его вытесняет. Шаги: ставим reverse proxy перед монолитом (сначала он просто проксирует всё) → выбираем самый изолированный кусок → разделяем данные внутри монолита, убирая JOIN-ы → пишем сервис и гоняем его в тени, сравнивая результаты → переключаем маршрут на фасаде по проценту трафика → удаляем старый код. Последний шаг пропускают чаще всего, и тогда получаются два источника правды.
Выделение по bounded context
Это ответ на вопрос «что резать», а strangler fig — на вопрос «как резать». Ищем места, где меняется смысл терминов и где данные не пересекаются; первым выносим кусок с минимальным числом связей, а не самый бизнес-важный. Порядок «данные → код → трафик» здесь обязателен: пока таблицы общие, вынесенный сервис не самостоятелен.
Branch by abstraction
Нужен, когда заменяемый кусок не торчит наружу и переключить его по URL нельзя. Вводим интерфейс над старой реализацией → пишем вторую реализацию, ходящую в новый сервис → переключаем фиче-флагом, можно по проценту пользователей и с параллельным сравнением результатов → удаляем старую. Всё в главной ветке: успешных долгоживущих веток рефакторинга не бывает.
Распределённый монолит: признаки
- Общая база: два сервиса пишут в одну таблицу.
- Релиз только «поездом», в определённом порядке, и откатывать надо по порядку.
- Длинные синхронные цепочки: один запрос порождает 5–10 последовательных вызовов.
- Общая библиотека доменных типов: правка в ней требует пересборки всех.
- Сервис нельзя поднять и протестировать в одиночку.
Почему это хуже обоих вариантов: от монолита потеряли транзакции, дешёвый рефакторинг и простую отладку; от микросервисов не получили независимый релиз и изоляцию отказов. Остаются только издержки. Выход не в том, чтобы «доводить распил»: сначала надо разделить данные и убрать синхронные цепочки, заменить часть вызовов событиями, добавить кэш/реплику данных вместо запроса к соседу, ввести версионирование контрактов, чтобы релизы расцепились.
Bounded context
Идея DDD в том, что универсальной модели предметной области не существует, существуют
контексты, внутри каждого из которых термин значит ровно одно. В корзине «заказ» это черновик
с позициями, в доставке — адрес, вес, маршрут, в бухгалтерии — сумма, НДС, документ.
Попытка сделать одну структуру Order на всех даёт объект с сорока полями,
половина которых всегда пустые, и намертво связывает три команды. Между контекстами модели
переводятся явно, этим занимается anticorruption layer, слой, который защищает твою модель
от чужой (и от легаси-монолита при распиле).
Практические критерии, по убыванию надёжности
- По данным. Сервис эксклюзивно владеет своими таблицами и остаётся единственным источником правды. Если сценарий требует транзакции по таблицам двух сервисов, граница неверна. Это самый сильный и самый проверяемый сигнал.
- По причине изменения. Смотри историю git: что всегда меняется вместе, должно жить вместе. SRP на уровне системы.
- По языку. Меняется словарь бизнеса — меняется контекст. Если аналитики двух отделов под одним словом понимают разное, это готовая граница.
- По команде (закон Конвея). Архитектура повторит структуру коммуникаций. Если границы сервисов не совпали с границами команд, победят команды. При «обратном манёвре Конвея» команды строят сначала, под желаемую архитектуру.
- По нагрузке и SLA. Другой профиль трафика или другая требуемая доступность дают законный повод, даже для небольшого куска домена.
Ubiquitous language — общий язык кода и бизнеса внутри контекста. Aggregate собирает объекты вокруг корня и меняется атомарно; практическое правило: агрегат равен границе транзакции и никогда не пересекает границу сервиса. Context map описывает отношения: shared kernel (общий код связывает релизы, опасно), customer/supplier, conformist, anticorruption layer. Если добавить, что размер сервиса не критерий («микро» про границу, а не про строки кода), ответ звучит как опыт, а не как пересказ книги Эванса.
Сеть
Вызов метода занимает единицы наносекунд. RPC внутри датацентра сотни микросекунд плюс сериализация: разница в пять-шесть порядков. Но главное не скорость, а то, что сеть ненадёжна: появляются таймауты, ретраи, дубликаты и частичные отказы, и всё это просачивается в бизнес-логику. Отдельно бьёт умножение доступностей: цепочка из пяти сервисов по 99.9 % даёт 99.5 %, и это без учёта латентности хвостов.
Консистентность
Транзакции между сервисами нет. Каждый сценарий, который раньше был одним
BEGIN … COMMIT, становится сагой с компенсирующими действиями, transactional
outbox для надёжной публикации событий и идемпотентными обработчиками для защиты от дублей.
Это отдельная подсистема с собственным состоянием, мониторингом и багами, самая
недооценённая статья расходов.
Наблюдаемость
Без distributed tracing, единого сбора логов с общим trace_id и метрик по каждому
исходящему вызову отладка становится гаданием: «медленно» может быть в любом из десяти мест.
Это отдельный стек (OpenTelemetry + Jaeger/Tempo + Prometheus + Loki) со своей стоимостью
хранения и своей эксплуатацией. В монолите то же самое даёт обычный стектрейс.
Инфраструктура и люди
- Оркестратор, реестр образов, service discovery, управление секретами, брокер.
- N пайплайнов CI/CD, N наборов дашбордов и алертов, дежурства и рантбуки.
- Средства локальной разработки, иначе человек не может воспроизвести баг.
- Версионирование контрактов и contract-тесты, чтобы релизы не ломали соседей.
- Дублирование справочных данных между сервисами и их синхронизация.
- Когнитивная нагрузка и владение: без карты сервисов появляются «сироты», которые все боятся трогать.
«Всё это оплачивается независимостью релизов. Если независимость релизов не нужна, счёт приходит, а товар не поставляется.» И полезно добавить, что цена не разовая: она платится каждый спринт, каждым дежурством и каждым новым человеком в команде.
11.3Межсервисное взаимодействие
Самая «инженерная» глава темы. Здесь проверяют не знание слов «сага» и «outbox», а понимание, что происходит с системой, когда вызов функции превращается в сетевой запрос: связность, доступность, консистентность и порядок. Почти каждый вопрос сводится к одному: что будет, если этот вызов не вернётся?
Синхронное или асинхронное: не вкус, а свойство сценария
Синхронное взаимодействие (HTTP, gRPC) устроено как запрос-ответ: вызывающий блокируется и ждёт. В асинхронном сервис публикует сообщение в брокер и идёт дальше, а получатель обработает его когда-нибудь потом. Разница выглядит как разница транспортов, но на деле это разница в том, кто кому обязан быть живым.
При синхронном вызове сервис A не может выполнить свою работу, если сервис B недоступен. Это временная связность (temporal coupling): A и B обязаны быть живыми одновременно. При асинхронном A обязан быть живым только вместе с брокером; B может лежать час, и это задержит обработку, но не сломает запрос клиента. Ровно поэтому вопрос «синхронно или асинхронно» на самом деле звучит так: «нужен ли клиенту результат B прямо сейчас».
Арифметика доступности: почему цепочки убивают
Если запрос клиента проходит через N сервисов последовательно и любой из них может ответить ошибкой, доступности перемножаются. Это самая полезная формула на собесе, потому что она превращает архитектурный спор в числа.
| Цепочка | Расчёт | Итог | Простой в месяц |
|---|---|---|---|
| 1 сервис 99.9 % | 0.999 | 99.90 % | ~43 мин |
| 3 сервиса по 99.9 % | 0.9993 = 0.9970 | 99.70 % | ~2 ч 10 мин |
| 5 сервисов по 99.9 % | 0.9995 = 0.9950 | 99.50 % | ~3 ч 36 мин |
| 10 сервисов по 99.9 % | 0.99910 = 0.9900 | 99.00 % | ~7 ч 12 мин |
| 10 сервисов по 99.99 % | 0.999910 = 0.9990 | 99.90 % | ~43 мин |
Читается это так: чтобы система из десяти звеньев держала три девятки, каждое звено должно
держать четыре. Один порядок надёжности «съедается» самим фактом цепочки. И это ещё
оптимистичный расчёт: он не учитывает латентность. Если у каждого сервиса p99 = 50 мс,
у цепочки из пяти p99 окажется втрое-вчетверо хуже одного звена, около 130 мс: хвосты редко
совпадают, поэтому сумма p99 остаётся верхней оценкой, а не результатом. Зато вероятность
зацепить чей-нибудь хвост растёт с длиной цепочки: 1 - 0.99^5 = 4.9 %
запросов вместо 1 %.
Критерии выбора
| Признак сценария | Синхронно | Асинхронно |
|---|---|---|
| Клиенту нужен результат в этом же HTTP-ответе | Да | Нет |
| Ответ влияет на дальнейшее ветвление логики прямо сейчас | Да | Нет |
| Операция — «сообщить, что факт произошёл» | Нет | Да |
| Получателей несколько и список меняется | Нет (N вызовов) | Да (одно событие) |
| Работа тяжёлая: отчёт, письмо, конвертация видео | Нет | Да |
| Нужен строгий порядок и немедленная консистентность | Да | Нет (eventual) |
| Пик нагрузки надо сгладить | Нет | Да (очередь как буфер) |
| Нужна простая отладка и понятный стектрейс | Да | Нет |
Читаем синхронно, побочные эффекты пишем асинхронно. Проверить остаток на счёте перед оплатой надо синхронно, иначе решение принимать не на чем. Отправить письмо, обновить поисковый индекс, начислить бонусы, уведомить склад можно асинхронно: клиенту эти результаты в ответе не нужны, а связывать с ними доступность оформления заказа уже вредительство.
Часто считают, что переход на брокер решает проблему надёжности. Он её перемещает: теперь надо гарантировать, что событие вообще опубликовано (запись в БД и публикация живут в двух разных системах, общей транзакции между ними нет — отсюда outbox), что оно обработано хотя бы раз (ack после обработки), что повтор не сломает данные (идемпотентность), и что порядок там, где он важен, сохранён (ключ партиции). Асинхронность меняет отказы на сложность и задержку, а не даёт выигрыш даром.
Между «ждать» и «выстрелить и забыть» есть асинхронный запрос с колбэком/поллингом:
клиент делает POST /reports, мгновенно получает 202 Accepted и
Location: /reports/{id}, дальше опрашивает статус или получает webhook. Это
синхронный по смыслу сценарий («мне нужен результат»), реализованный без временной связности.
Этот паттерн на собесе почти всегда добавляет очков.
Событийная архитектура: три разных вида «события»
Слово «событие» на собесе используют минимум в трёх разных смыслах, и путаница между ними даёт половину неудачных ответов. Разница в том, сколько состояния несёт сообщение и кто здесь источник истины.
1. Event notification — уведомление
Сообщение говорит «произошёл факт» и почти ничего не несёт: идентификатор сущности, тип события, время. Потребитель, если ему нужны детали, идёт обратно в сервис-издатель синхронным запросом.
// Event notification: только сам факт и ссылка
type OrderPlaced struct {
EventID string `json:"event_id"` // для дедупликации у потребителя
OrderID string `json:"order_id"`
OccurredAt time.Time `json:"occurred_at"`
Version int `json:"version"` // версия схемы события
}
// За подробностями потребитель идёт в GET /orders/{id}
- Плюс: сообщения крошечные, схема почти не меняется, нет дублирования данных.
- Минус: обратный синхронный вызов возвращает временную связность и нагрузку на издателя: 1 событие × 5 потребителей = 5 запросов «дай подробности».
- Минус: гонки. Потребитель может прочитать состояние новее, чем то, что было в момент события, или, наоборот, попасть в реплику с отставанием и увидеть старое.
2. Event-carried state transfer — событие с данными
Событие несёт всё нужное потребителям состояние. Потребитель складывает у себя локальную копию (кэш, денормализованную таблицу) и живёт полностью автономно: издатель может лежать, а потребитель продолжает отвечать на запросы.
// ECST: потребителю не нужно ходить обратно
type OrderPlaced struct {
EventID string `json:"event_id"`
OrderID string `json:"order_id"`
UserID string `json:"user_id"`
UserEmail string `json:"user_email"` // копия данных пользователя
Items []OrderItem `json:"items"`
TotalCents int64 `json:"total_cents"`
Currency string `json:"currency"`
OccurredAt time.Time `json:"occurred_at"`
Version int `json:"version"`
}
- Плюс: полная развязка во времени, нет обратных вызовов, потребитель переживает падение издателя.
- Минус: данные дублируются и устаревают; на такую eventual consistency идут осознанно.
- Минус: схема события становится публичным контрактом и её больно менять; сообщения растут, брокер и сеть нагружаются сильнее.
- Минус: поменяв логику, нельзя «перечитать историю», потому что старые события несут старый набор полей.
3. Event sourcing — событие как источник истины
Здесь события и есть единственное хранимое состояние. Текущее
состояние сущности не хранится вовсе: его собирают заново, проигрывая (replay) все её события
от начала. Таблица accounts с колонкой balance исчезает, остаётся
журнал Deposited(100), Withdrawn(30), Deposited(50), а баланс 120 получается
свёрткой (fold) журнала.
Проще всего думать про банковскую выписку. Банк не хранит просто число «на счету 120 рублей», он хранит список операций, а число получается их сложением. Выписку нельзя «поправить»: ошибочный платёж отменяют не стиранием строки, а новой строкой «возврат». Так же устроена запись шахматной партии — хранится список ходов, а не расстановка фигур, и позицию на любой момент всегда можно получить, проиграв ходы с начала.
Отсюда два слова, которые дальше встречаются постоянно. Replay («проигрывание», «переигрывание») значит прочитать события с начала и заново собрать из них состояние. Проекция хранит заранее посчитанную таблицу под конкретный запрос: её строят, проигрывая события, и дополняют по мере поступления новых. Проекцию можно в любой момент выбросить и построить заново — журнал никуда не делся. Поэтому она не «вторая база с теми же данными», а производная от журнала, вроде кэша.
// Сворачиваем журнал событий в текущее состояние
func (a *Account) Apply(e Event) {
switch ev := e.(type) {
case Deposited:
a.Balance += ev.Amount
case Withdrawn:
a.Balance -= ev.Amount
case Frozen:
a.Frozen = true
}
a.Version++
}
func Load(events []Event) *Account {
a := &Account{}
for _, e := range events { // длинную историю читаем от снапшота
a.Apply(e)
}
return a
}
Цена event sourcing высока, и складывается она так: миграция схемы старых событий (их
нельзя «поправить ALTER-ом», они уже случились), снапшоты для сущностей с тысячами событий,
обязательный CQRS для любых нетривиальных выборок. CQRS (Command Query
Responsibility Segregation, «разделение ответственности за команды и запросы») вводит
правило «пишем в одну модель, читаем из другой»: изменения уходят в журнал, а запросы
обслуживают те самые проекции, построенные под конкретные экраны. Нужен он не из красоты,
а по нужде: по журналу событий нельзя сделать
WHERE status = 'paid' ORDER BY total — там нет ни колонки status, ни строк,
по которым можно отфильтровать. Дальше идёт непривычная отладка, а людей с опытом
эксплуатации на рынке почти нет. И сверху GDPR: любое удаление данных
ломается о неизменяемость журнала, приходится городить крипто-удаление (выбросить ключ,
которым зашифровано поле).
Audit log — почему его путают с event sourcing
Аудит-лог пишет дополнительную запись «кто, что, когда изменил» рядом с обычной таблицей состояния. При event sourcing таблицы состояния нет вообще. Внешне похоже, разница принципиальная: аудит-лог можно потерять, и система продолжит работать; журнал событий потерять нельзя — вместе с ним теряются данные. Если на собесе спрашивают «нам нужна история изменений — брать event sourcing?», правильный ответ почти всегда: «нет, возьмём аудит-лог или temporal-таблицы, это на два порядка дешевле».
| Event notification | Event-carried state | Event sourcing | |
|---|---|---|---|
| Размер сообщения | Десятки байт | Килобайты | Килобайты |
| Обратный вызов к издателю | Нужен | Не нужен | Не нужен |
| Источник истины | БД издателя | БД издателя | Сам журнал |
| Дублирование данных | Нет | Да, осознанно | Да (проекции) |
| Хрупкость контракта | Низкая | Высокая | Максимальная |
| Хранение событий | Можно чистить | Можно чистить | Вечно |
| Когда брать | Много потребителей, детали редко нужны | Потребителю нужна автономность | История — часть домена (финансы, аудит, версии) |
Событие называют в прошедшем времени и адресуют «всем, кого касается»: OrderPlaced,
PaymentCaptured. Команду пишут в повелительном наклонении и шлют конкретному
получателю: ReserveSeat, ChargeCard. Издатель события не знает
своих подписчиков и не отвечает за их работу; отправитель команды знает адресата и ждёт,
что тот её выполнит. Если сообщение называется SendEmailEvent, перед тобой команда,
переодетая событием, и связность в системе на самом деле осталась. Умение вслух развести
event и command на собесе считывается сразу.
Почему «просто транзакция» между сервисами не работает
В монолите сценарий «списать деньги + занять место + забронировать отель» укладывается в один
BEGIN … COMMIT. Атомарность обеспечивает СУБД: она держит блокировки, пишет
WAL и умеет откатить всё разом. Как только эти три шага живут в трёх сервисах с тремя
базами, механизма, который дал бы то же самое, просто нет:
- Нет общего менеджера транзакций. Транзакция живёт как состояние внутри одного экземпляра СУБД: список блокировок, undo/redo, снапшот MVCC. Postgres сервиса A физически не знает, что MySQL сервиса B вообще существует, и не может держать его строки заблокированными.
- Границы транзакции нельзя протянуть через сеть. HTTP-вызов в транзакции
не участвует. Вызвали
POST /payments, он вернул 200 — деньги уже списаны и закоммичены на той стороне. СвоимROLLBACKты это не отменишь. - Сеть даёт третий исход. В локальной транзакции результат бинарный: закоммитилось или нет. В сети есть неизвестность: таймаут не отвечает на вопрос, выполнилась операция или нет. Именно это ломает всю логику «если ошибка — откатываем».
- Блокировки становятся распределёнными. Даже если бы протокол существовал, он держал бы строки заблокированными на всё время сетевых раундтрипов, а это на три-четыре порядка дольше локальной транзакции. Пропускная способность обрушится.
- Часть участников вообще не базы. Платёжный шлюз, отправка SMS, вызов внешнего API партнёра не умеют ни prepare, ни rollback. Списанные деньги нельзя «откатить», их можно только вернуть новой операцией.
«ACID между сервисами недостижим не потому, что мы поленились, а потому что атомарность требует общего менеджера блокировок, а он несовместим с автономностью сервисов и с сетью. Поэтому вместо атомарности мы берём атомарность на шаг плюс механизм, который доводит сценарий до одного из двух конечных состояний. Так появляется сага.»
Сага: оркестрация против хореографии
В быту механика ровно та же. Ты собираешь поездку через три независимые конторы: покупаешь билет на самолёт, бронируешь отель, берёшь машину напрокат. Общей кнопки «отменить всё» не существует — у каждой конторы своя касса и свои правила. Если на третьем шаге машин не оказалось, ты не «откатываешь» первые два шага, а делаешь ещё два действия: сдаёшь билет и снимаешь бронь. Каждое из них проходит отдельной операцией и оставляет след: в выписке будет и списание, и возврат, а не пустота.
Это и есть сага. Сага склеивает несколько шагов в разных сервисах: каждый шаг фиксируется сразу и насовсем, а на случай неудачи для него заранее написано компенсирующее действие, обратная операция, которая отменяет эффект по смыслу, а не стирает факт. Слово взято из статьи 1987 года Гарсиа-Молины и Салема про длинные транзакции в БД; к микросервисам приём приспособили позже.
Формально сага складывается из локальных транзакций, каждая атомарна в своём сервисе, плюс компенсация для каждой. Сага не даёт изоляции: промежуточные состояния видны наружу. Она гарантирует только одно: сценарий не застрянет посередине навсегда — либо все шаги выполнены, либо все выполненные скомпенсированы.
Склеить шаги между собой можно двумя способами, и разница между ними такая же, как между руководителем проекта и эффектом домино.
- Оркестрация. Есть отдельный сервис-дирижёр, оркестратор. Он держит у себя весь сценарий целиком, по очереди говорит участникам «сделай это», ждёт ответа и решает, что дальше. Участники друг о друге не знают вообще: каждый выполняет команду и отчитывается дирижёру. Слово пришло от «оркестра»: музыканты вступают по взмаху палочки.
- Хореография. Дирижёра нет. Каждый сервис просто публикует событие «я своё сделал», а следующий подписан на это событие и включается сам. Команд никто не отдаёт — все знают свою партию и момент вступления. Отсюда и слово: танцоры на сцене двигаются без указаний, потому что роли разучены заранее.
Главное различие видно сразу: при оркестрации сценарий записан в одном месте и читается сверху вниз; при хореографии он не записан нигде — чтобы узнать, что произойдёт после оплаты, надо обойти все сервисы и посмотреть, кто на что подписан.
| Критерий | Оркестрация | Хореография |
|---|---|---|
| Где живёт сценарий | В одном сервисе-оркестраторе, читается сверху вниз | Размазан по подписчикам, целиком нигде не записан |
| Связность | Оркестратор знает всех участников (но участники не знают друг друга) | Никто не знает никого — только имена событий |
| Добавить шаг | Правка одного сервиса | Новый подписчик, издателя не трогаем |
| Понять «где застряло» | Один SELECT по таблице саг | Собирать по логам и трейсам всех сервисов |
| Компенсации | Явный обратный проход, порядок под контролем | Каскад «failed»-событий, порядок надо проектировать |
| Риск | Оркестратор превращается в божественный объект и SPOF-логики | Циклы событий, «а кто это вызвал?», непредсказуемые каскады |
| Кол-во участников | Хорошо до 8–10 шагов | Хорошо на 2–3 шагах, дальше не читается |
| Когда брать | Бизнес-процесс с ветвлениями, таймаутами, ручным разбором | Простая линейная реакция: «заказ оформлен — начисли бонусы» |
«Хореографию беру по умолчанию для двух-трёх шагов без ветвлений: она дешевле и не создаёт центральной зависимости. Как только появляются условия, таймауты, ручное вмешательство оператора или больше трёх участников — перехожу на оркестрацию, потому что процесс становится бизнес-сущностью, у которой должно быть имя, состояние и владелец. Гибрид тоже нормален: оркестратор ведёт основной сценарий, а побочные эффекты вроде уведомлений живут на хореографии.»
Пример: «оплата + бронь билета + отель»
Дальше сценарий целиком: на собесе почти всегда просят «расскажи на примере».
| Шаг | Локальная транзакция | Компенсация | Почему это не откат |
|---|---|---|---|
| T1 | Списать 12 400 руб с карты | C1: вернуть 12 400 руб (refund) | Списание уже прошло через банк и видно в выписке; refund — новая проводка, а не исчезновение старой. Комиссия эквайринга не возвращается |
| T2 | Занять место 14C, статус RESERVED | C2: освободить место, статус AVAILABLE | Пока место было занято, другой пассажир его не увидел и купил соседнее — этот эффект не отменяется |
| T3 | Забронировать номер в отеле | — | Упал: компенсировать нечего, шаг не выполнился |
Порядок тут важен: компенсации идут в обратном порядке шагов, сначала освобождаем место, потом возвращаем деньги. Это не формальность: если вернуть деньги первым, а освободить место не получится, система останется с занятым местом без оплаты — то есть с реальной потерей выручки. Обратный порядок оставляет систему в состоянии «оплачено, но не выдано»: неприятно, но восстановимо, и такое обычно уже покрыто процессом ручного разбора.
Rollback стирает следы: после отката никто никогда не узнает, что транзакция была.
Компенсация запускает новую бизнес-операцию, которая семантически отменяет предыдущую,
и обе остаются в истории. Письмо «ваш заказ принят» уже улетело, и компенсация не выдернет его
из почтового ящика: она пошлёт второе, «заказ отменён». Отсюда следствие:
сага не даёт изоляции (I в ACID). Между T1 и C1 наружу видно промежуточное состояние,
и другие процессы могут на нём принять решения. Это называется грязным чтением саги,
и лечится оно не технически, а доменно: статусами (PENDING,
CONFIRMED), семантическими блокировками (место RESERVED на 10 минут)
и правилом «не показывать наружу то, что ещё не подтверждено».
Персистентное состояние саги: конечный автомат
Оркестратор без состояния бессмыслен: если он упадёт между шагами, никто не узнает, что деньги списаны, а место не занято. Поэтому сага живёт как строка в базе данных оркестратора, которая обновляется в той же локальной транзакции, что и решение о следующем шаге. Формально это конечный автомат.
CREATE TABLE saga_instances (
saga_id uuid PRIMARY KEY,
saga_type text NOT NULL, -- 'book_trip'
state text NOT NULL, -- см. автомат ниже
step smallint NOT NULL DEFAULT 0,
payload jsonb NOT NULL, -- вход + накопленные id операций
attempts smallint NOT NULL DEFAULT 0,
last_error text,
next_retry_at timestamptz, -- для повторов и таймаутов
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- воркеры разбирают «зависшие» саги
CREATE INDEX ON saga_instances (state, next_retry_at)
WHERE state NOT IN ('COMPLETED', 'FAILED_COMPENSATED', 'NEEDS_ATTENTION');
type State string
const (
Started State = "STARTED"
Charging State = "CHARGING" // ждём ответа платёжного
Charged State = "CHARGED"
ReservingSeat State = "RESERVING_SEAT"
SeatReserved State = "SEAT_RESERVED"
BookingHotel State = "BOOKING_HOTEL"
Completed State = "COMPLETED" // терминальное: успех
Compensating State = "COMPENSATING"
Compensated State = "FAILED_COMPENSATED" // терминальное: чисто откатились
NeedsAttention State = "NEEDS_ATTENTION" // терминальное: зовём человека
)
// Шаг саги = ровно одна локальная транзакция оркестратора:
// прочитали состояние -> решили -> записали новое состояние + команду в outbox.
func (o *Orchestrator) advance(ctx context.Context, id uuid.UUID) error {
return o.db.InTx(ctx, func(tx *sql.Tx) error {
s, err := loadForUpdate(tx, id) // SELECT ... FOR UPDATE: один воркер на сагу
if err != nil {
return err
}
switch s.State {
case Charged:
s.State = ReservingSeat
enqueueCommand(tx, s.ID, "tickets.ReserveSeat", s.Payload)
case SeatReserved:
s.State = BookingHotel
enqueueCommand(tx, s.ID, "hotels.BookRoom", s.Payload)
// ... остальные переходы
}
return save(tx, s)
})
}
Три свойства этого автомата стоит назвать вслух: переходы идемпотентны (повторный
ответ от сервиса на тот же шаг не двигает сагу дважды, потому что мы сравниваем текущее состояние
с ожидаемым), у каждого «ожидающего» состояния есть таймаут (next_retry_at:
если ответа нет 30 секунд, шаг повторяется, а после N попыток сага уходит в компенсацию),
и есть терминальные состояния, из которых нет выхода — иначе сага будет крутиться вечно.
Что делать, если упала сама компенсация
Это любимый добивающий вопрос, потому что он проверяет, думал ли кандидат дальше картинки. Компенсация идёт обычным сетевым вызовом и падает так же, как основной шаг. Ответ складывается из четырёх уровней:
- Компенсации обязаны быть ретраебельными и идемпотентными. «Вернуть деньги по
операции X» можно вызывать сто раз — возврат произойдёт один. Делается это ключом
идемпотентности
saga_id + step. - Ретраи с экспоненциальной задержкой и без верхнего предела по количеству, но с ограничением по времени. В отличие от основного шага, компенсацию нельзя «просто бросить»: система уже в несогласованном состоянии, и выйти из него можно только одним способом — довести компенсацию до конца.
- Проектировать компенсации так, чтобы они не могли упасть по бизнес-причине. «Освободить место» не может вернуть «нельзя» — это действие возможно всегда. Если компенсация способна получить логический отказ (например, «нельзя отменить: билет уже использован»), значит, шаги стоят в неправильном порядке: необратимые действия ставят последними. Правило дизайна саг звучит так: сначала всё, что можно откатить, в конце то, что нельзя.
- Терминальное состояние
NEEDS_ATTENTION+ алерт. Когда ретраи исчерпаны, сага не «теряется», а попадает в очередь ручного разбора: дашборд, алерт дежурному, ручные действия оператора. Кандидат, который признаёт, что часть случаев разбирает человек, показывает эксплуатационный опыт. В финтехе это отдельный процесс со своим SLA.
Три классических провала. Первый: «сага откатит всё как транзакция». Нет, изоляции нет
и промежуточные состояния видны. Второй: сага без персистентного состояния, «оркестратор
просто вызывает по очереди», а при рестарте пода такая сага исчезает вместе с памятью.
Третий: забыли про таймауты. Если сервис не ответил ни успехом, ни ошибкой, сага должна
сама проснуться и решить, что делать, иначе она зависнет навсегда в состоянии
CHARGING, а деньги у клиента уже списаны.
Вопросы
10Тут два разных измерения, которые постоянно смешивают. Первое отвечает за синхронность вызова: блокируется ли вызывающий до ответа. Второе смотрит на направление знания: знает ли отправитель, кто его слушает. Можно сделать асинхронный запрос-ответ через очередь (вызывающий всё равно логически ждёт ответ) и можно сделать синхронную публикацию через HTTP в шину. Настоящая развязка появляется, только когда выполняются оба: вызывающий не ждёт и не знает получателей.
Критерии, которые стоит проговорить вслух
| Критерий | Синхронно (HTTP/gRPC) | Асинхронно (брокер) |
|---|---|---|
| Нужен ответ для продолжения | да — цена товара, доступность остатка, результат авторизации | нет — «уведоми», «пересчитай», «проиндексируй» |
| Доступность | перемножается по цепочке | ограничена своей БД и брокером |
| Латентность для клиента | сумма всех хопов | только свой хоп |
| Пики нагрузки | прокатываются насквозь до самого слабого | гасятся очередью, растёт лаг, а не 5xx |
| Консистентность | ближе к «сразу» | eventual, окно рассинхрона видно наружу |
| Отладка | один трейс, понятная причина | нужен distributed tracing и correlation id |
| Обратное давление | таймаут и ошибка | лаг консьюмера, метрика вместо инцидента |
Как это связано со связностью
Синхронный вызов даёт временную связность (temporal coupling): чтобы сервис A работал, сервис B должен быть жив прямо в эту миллисекунду. Плюс связность по адресу: A знает, что B существует и как его найти. Событие снимает временную связность полностью — потребитель может лежать час, события подождут в топике. Но взамен приходит связность по схеме: все потребители завязаны на формат события, и его изменение задевает всех сразу. Поэтому формулировка «асинхронно = развязано» неполная: связность не исчезает, она переезжает из времени в контракт.
Запросы шлём синхронно, факты публикуем асинхронно. Если A нужно узнать что-то, чем владеет B, чтобы принять решение прямо сейчас, это запрос: синхронный вызов с таймаутом, ретраем и деградацией. Если A совершил у себя факт и другим стоит об этом знать, это событие: публикуем и забываем. Ошибка проектирования почти всегда в том, что командой («сделай мне X») называют то, что должно быть фактом («у меня произошёл Y»), и тогда в шине оказываются приказы, а не события, и получается распределённый монолит с брокером вместо HTTP.
Гибриды, которые полезно назвать
- Синхронно для чтения, асинхронно для записи. Заказ создаётся синхронно (клиенту нужен номер), а бонусы, кэшбэк и письмо уходят событиями.
- Локальный кэш чужих данных. Вместо синхронного вызова «дай мне тариф» подписываемся на события изменения тарифов и держим локальную копию. Убирает хоп и временную связность ценой окна устаревания. Это event-carried state transfer.
- Асинхронный запрос-ответ (очередь запросов + очередь ответов + correlation id): нужен, когда операция длинная (генерация отчёта, конвертация видео), а клиент опрашивает статус или получает вебхук.
«Давайте всё через Kafka, будет надёжнее» — а на вопрос «а как клиент узнает цену заказа» ответа нет. Второй капкан: асинхронность выбрана, но клиенту всё равно нужен результат, и поверх очереди городится поллинг статуса каждые 200 мс — получилась синхронность с худшей латентностью и лишней инфраструктурой. Третий: забыть, что у асинхронности есть наблюдаемая наружу цена. Пользователь видит «заказ создан», но баланс обновится через две секунды, и это надо либо объяснить в UI, либо не делать асинхронным.
Event notification
Событие содержит минимум: тип, идентификатор сущности, время, версию. Потребитель, которому нужны подробности, делает синхронный вызов обратно к владельцу.
// notification: только факт и ссылка
type OrderPaid struct {
EventID string `json:"event_id"` // для дедупликации
OrderID string `json:"order_id"`
OccurredAt time.Time `json:"occurred_at"`
Version int `json:"version"` // версия агрегата, не схемы
}
- Плюс: событие крошечное, схема почти не меняется, нет дублирования данных, нет вопроса «а что если в событии устаревшие данные».
- Минус: временная связность вернулась через заднюю дверь — потребитель обязан сходить к продюсеру, и если тот лежит, обработка встала. Плюс N потребителей дают N обратных запросов на каждое событие (эффект «громового стада» по владельцу).
- Ещё минус: гонка. Событие «заказ оплачен» пришло, потребитель пошёл читать заказ и увидел уже отменённый заказ — состояние ушло вперёд относительно события. Лечится так: в событии едет версия, а потребитель проверяет, не старее ли то, что он прочитал.
Event-carried state transfer
Событие несёт всё, что нужно типичному потребителю, и потребитель строит у себя локальную денормализованную копию.
type OrderPaid struct {
EventID string `json:"event_id"`
OrderID string `json:"order_id"`
UserID string `json:"user_id"`
Amount Money `json:"amount"`
Items []OrderItem `json:"items"`
Address Address `json:"address"`
OccurredAt time.Time `json:"occurred_at"`
Version int `json:"version"`
}
- Плюс: полная временная развязка — потребитель работает, даже если владелец лежит сутки. Нет обратного трафика. Потребитель может отвечать на свои запросы из своей копии, и его латентность не зависит от чужой.
- Минус: событие большое, схема меняется чаще, данные продублированы в N местах, и любое изменение смысла поля надо согласовывать со всеми потребителями.
- Минус, о котором забывают: в каждой локальной копии свой лаг, и наружу пользователь видит расхождение между экранами. Нужен способ пересобрать копию с нуля (replay топика или отдельный «полный снимок»).
По частоте чтения к частоте изменения и по требованию к свежести. Если данные читаются на каждый запрос, а меняются раз в день (тарифы, справочники, настройки мерчанта), держи локальную копию через carried state, без синхронных хопов. Если данные нужны редко, они объёмные и должны быть максимально свежими (полный профиль клиента для одного отчёта), хватит notification и запроса по требованию. Промежуточный вариант, который на собесе звучит зрело: notification с «толстым» полем-подсказкой, где лежит минимум полей для 90 % потребителей, а остальные ходят за деталями.
Audit log — и чем он не является
Audit log хранит журнал того, что произошло, для людей и регуляторов: кто, когда, что изменил, с какого IP, по какому запросу. Его пишут рядом с основным состоянием, и нужен он для расследований, комплаенса, разбора инцидентов.
Его постоянно путают с event sourcing, и разница принципиальная: в event sourcing истина лежит в событиях, а состояние выводится из них; в audit log истину хранит состояние, а журнал вторичен. Если удалить audit log, система продолжит работать корректно и никто не потеряет данные — пропадёт только история. Если удалить события в event sourcing, система перестанет существовать. Отсюда практическое следствие: audit log вводится дёшево и почти всегда окупается (отдельная таблица, отдельный топик, запись через триггер или в той же транзакции), а event sourcing остаётся тяжёлым архитектурным решением, которое меняет всё.
«У нас event-driven, мы шлём события», а в событии лежит {"user_id": 42}
и все потребители синхронно ходят обратно; фактически это RPC с лишним звеном
и худшей отладкой. Второй капкан: carried state без версии и без порядка. Два события
по одному заказу обработаны в обратном порядке, и локальная копия навсегда застряла
в старом состоянии. Ответ: ключ партиционирования по идентификатору агрегата
(порядок гарантирован в пределах партиции) плюс проверка версии перед применением.
Что даёт локальная транзакция и что из этого исчезает
- Атомарность держится на WAL: до
COMMITничего не видно, после видно сразу всё. Между сервисами общего журнала нет: сервис A уже закоммитил, сервис B вернул ошибку, и никто уже не «развидит» коммит A. - Изоляция держится на блокировках и снимках внутри одного движка. Через сеть изоляция означала бы держать блокировку в чужой БД на время сетевого раунд-трипа, а это убивает пропускную способность и делает систему заложником чужой латентности.
- Долговечность у каждого своя, и они не синхронизированы: A мог сфсинкать на диск, а B в этот момент потерял питание до fsync.
Три конкретные причины, которые звучат убедительно
- Неотличимость отказа от задержки. Ты послал «коммить» и не получил ответ. Он не дошёл? Дошёл, но ответ потерялся? Узел жив и коммитит прямо сейчас? С точки зрения отправителя эти три случая идентичны, а действий требуют противоположных. Это не инженерная недоработка, а свойство асинхронной сети.
- Блокировки через сеть. Любой протокол атомарного коммита обязан держать ресурсы «замороженными» до общего решения. Время заморозки = сетевая задержка + время на согласование, то есть в тысячи раз больше локального коммита. Пропускная способность падает на порядок, а горячие строки становятся глобальным узким местом.
- Автономность сервисов. Микросервисы вводились именно ради того, чтобы команда владела своей схемой и релизила независимо. Общая транзакция возвращает общую блокировочную судьбу: деплой одного сервиса вешает транзакции другого. Технически это ещё и требует, чтобы у всех участников был XA-совместимый ресурс — а у Kafka, Redis, S3 и большинства внешних API его нет в принципе.
«Между сервисами нельзя получить атомарность без потери доступности, поэтому
на практике отказываются не от корректности, а от изоляции и мгновенности:
операция становится многошаговой, промежуточные состояния становятся легальными
и получают имена в домене (PENDING, RESERVED,
AWAITING_CONFIRMATION), а откат становится компенсацией.»
Дальше сам собой открывается разговор про сагу и outbox.
Что делают вместо
- Переопределить границу. С этого и стоит начинать: если две операции обязаны быть атомарными по бизнесу, скорее всего они принадлежат одному bounded context и не должны быть в разных сервисах. Списание с баланса и запись в историю баланса живут в одном сервисе, в одной БД, в одной транзакции.
- Сага выстраивает цепочку локальных транзакций с компенсациями и меняет изоляцию на доступность.
- Transactional outbox запирает «изменил состояние + отправил сообщение» в одну локальную транзакцию, дальше идёт at-least-once доставка.
- Идемпотентность + повтор до успеха. Если операция идемпотентна, «не знаю, прошло ли» лечится повтором, и половина проблемы исчезает.
- Сверка (reconciliation). Фоновый процесс, который сравнивает состояния двух сервисов и чинит расхождения. В финтехе это обязательный элемент, а не костыль: любая распределённая система рано или поздно расходится, и вопрос только в том, заметишь ты это сам или тебе скажет клиент.
Сравнение по существу
| Хореография | Оркестрация | |
|---|---|---|
| Где живёт процесс | нигде — размазан по подпискам | в одном сервисе-оркестраторе, в таблице |
| Связность | ниже: все знают только события | выше: оркестратор знает всех участников |
| Понятность | падает нелинейно с числом шагов | процесс читается как код |
| Отладка «где застряло» | надо собирать трейс по топикам | SELECT state FROM saga_instances |
| Циклы и ветвления | почти невозможно контролировать | естественны |
| Таймауты шагов | каждый реализует сам | централизованы в автомате |
| Риск | «никто не отвечает за целое» | оркестратор превращается в god-service |
Сильный ответ добавляет границу: оркестратор допустим, пока он координирует, а не решает. Как только в него переезжают бизнес-правила участников («если сумма больше 100 000, то проверить лимит и позвать риск-скоринг»), сервисы становятся анемичными CRUD-обёртками, и получается распределённый монолит с единой точкой изменений. Признак болезни: каждая новая фича в любом сервисе требует правки оркестратора.
Компенсации: правила, которые отличают знающего
- Компенсация не откатывает, а делает обратную бизнес-операцию. «Отменить бронь», «сделать возврат», «послать письмо об отмене». Обе операции остаются в истории.
- Необратимые шаги ставь в конец. Отправка письма, выдача физического товара, списание во внешней системе без возврата. Всё, что можно откатить, идёт раньше.
- Компенсации идемпотентны и ретраебельны без ограничения по числу попыток —
система уже в промежуточном состоянии, «сдаться» нельзя. Ключ идемпотентности
складывается из
saga_id + step. - Компенсация не должна уметь отказать по бизнес-причине. Если может — порядок шагов выбран неверно.
- Семантические блокировки вместо изоляции. Статус
RESERVEDс TTL вместо реального удержания строки. Наружу промежуточное состояние показывается как «в обработке», а не как результат.
Персистентное состояние — обязательное, не опциональное
Оркестратор без таблицы саг не переживает рестарт пода. Схема, конечный автомат
и код перехода приведены в теории выше; на собесе достаточно назвать четыре свойства:
состояние в БД, один воркер на сагу (SELECT ... FOR UPDATE
или партиционирование по saga_id), таймаут у каждого ожидающего
состояния (next_retry_at + фоновый сканер зависших),
терминальные состояния, включая NEEDS_ATTENTION для ручного разбора.
«Сага гарантирует консистентность?»
Нет, она гарантирует атомарность в терминах бизнеса (либо все шаги, либо все
компенсации) и не даёт изоляции; итоговая консистентность остаётся eventual. «Что если
компенсация упала?» Ретраи без предела по числу, идемпотентность,
а в пределе NEEDS_ATTENTION и человек. «Как тестировать?»
Тесты автомата как чистой функции переходов плюс сценарные тесты с инъекцией отказа
на каждом шаге: на N шагов должно быть N тестов «упало здесь — компенсации прошли».
Классика: сказать «сага = распределённая транзакция» и на вопрос про грязное чтение
не иметь ответа. Вторая ловушка — хореография на семь шагов: рисуешь схему, и она
превращается в клубок; правильно тут самому сказать, что нужен оркестратор.
Третья: компенсация как DELETE строки. В реальности удалять нельзя,
нужен статус и след в истории, иначе теряется аудит и ломается идемпотентность
(повторное событие создаст запись заново).
2PC (two-phase commit, «двухфазный коммит») позволяет нескольким независимым базам закоммитить одну общую транзакцию по принципу «либо все, либо никто». Работает это как сделка через нотариуса. Сначала нотариус обходит участников и спрашивает каждого: «ты готов подписать?». Тот, кто ответил «да», уже не может передумать и обязан ждать. Когда «да» сказали все, нотариус объявляет «подписываем» — и только в этот момент сделка состоялась. Нотариус здесь называется координатором, остальные участниками, а два обхода и есть две фазы.
Аналогия ломается ровно в одном месте: нотариус может выйти из комнаты между вопросом и объявлением. Участники, уже сказавшие «да», останутся сидеть связанными — отказаться нельзя, подписать без объявления нельзя, разойтись нельзя. Именно это и называют блокирующим окном.
Как работает
- Фаза 1, prepare. Координатор рассылает
PREPARE. Каждый участник выполняет работу, записывает всё необходимое в свой журнал, берёт блокировки и отвечаетYES(обещание: «я гарантированно смогу закоммитить, что бы дальше ни случилось») илиNO. ПослеYESучастник теряет право решать самостоятельно — он в неопределённости. - Фаза 2, commit/abort. Если все ответили
YES, координатор пишет решениеCOMMITв свой журнал (эта запись и есть точка невозврата) и рассылает её. Иначе рассылаетABORT. Участники применяют решение и отпускают блокировки.
YES, обязан ждать решения сколь угодно долго: отпустить
блокировки самому означает нарушить обещание и потенциально разъехаться с соседом.Почему избегают: пять причин по убыванию важности
- Блокирующий протокол. Доказано, что атомарный коммит без блокировки при отказе координатора невозможен в асинхронной сети. Падение координатора в окне между фазами замораживает данные у всех участников. Восстановиться можно только по журналу координатора — то есть координатор становится критической единой точкой отказа, которую надо реплицировать (и тогда ты уже строишь консенсус).
- Доступность перемножается и падает. Транзакция успешна, только если живы все участники плюс координатор. Четыре компонента по 99.9 % дают 99.6 %.
- Латентность и пропускная способность. Два сетевых раунда плюс fsync журнала у каждого; блокировки держатся всё это время. Горячая строка (баланс, склад) превращается в глобальный сериализатор.
- Не все ресурсы поддерживают XA. Kafka, Redis, S3, внешние платёжные API — фазы prepare у них нет. Двухфазный коммит работает только в мире «несколько реляционных БД под одним менеджером транзакций», а это ровно та монолитная картина, от которой уходили.
- Связывает автономию. Общая транзакция означает общие релизы, общие окна обслуживания и общий инцидент.
Внутри одной системы, где координатор реплицирован консенсусом и участники под контролем: распределённые БД (Spanner, CockroachDB, YDB, TiDB) используют 2PC поверх Raft/Paxos — консенсус чинит именно ту проблему, из-за которой 2PC плох, и делает координатора отказоустойчивым. Ещё жив в enterprise-интеграции через XA (сервер приложений + пара Oracle) и в связке «одна БД + брокер с XA» в старых стеках. Правильная формулировка на собесе: «2PC избегают не потому, что он неправильный, а потому что его цена (блокировка при отказе) в межсервисном контуре неприемлема; там, где координатор надёжен, 2PC используют вовсю».
Вопрос «а 3PC решает проблему?» — формально 3PC добавляет фазу pre-commit и снимает блокировку при отказе координатора, но только в синхронной модели сети: при сетевом разделении он может нарушить атомарность (разные части кластера примут разные решения). Поэтому на практике его не используют, а берут консенсус. Второй капкан: путать 2PC с 2-phase locking (2PL). Первое про атомарный коммит, второе про изоляцию внутри одной БД, общего у них только число «два».
message_id для дедупликации.Проблема dual write
// Так нельзя: два разных хранилища, атомарности нет
func (s *Service) PayOrder(ctx context.Context, id string) error {
if err := s.db.MarkPaid(ctx, id); err != nil { // (1)
return err
}
return s.broker.Publish(ctx, OrderPaid{OrderID: id}) // (2)
}
Между (1) и (2) четыре разных плохих исхода. Процесс упал после коммита — событие потеряно навсегда, а данные изменены. Брокер недоступен, и результат тот же. Publish прошёл, но ответ не дошёл и мы вернули ошибку: клиент повторит, событие уедет дважды. Publish поставили перед коммитом, событие ушло, а транзакция откатилась; сообщение о том, чего не было, чинить уже нечем, и это худший из вариантов. Ретраи внутри функции не спасают: нет способа откатить уже опубликованное сообщение.
Outbox: реализация
CREATE TABLE outbox (
id bigserial PRIMARY KEY, -- монотонность = порядок публикации
aggregate_type text NOT NULL, -- 'order'
aggregate_id text NOT NULL, -- ключ партиционирования в брокере
event_type text NOT NULL, -- 'order.paid.v1'
payload jsonb NOT NULL,
headers jsonb NOT NULL DEFAULT '{}', -- trace_id, correlation_id
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz -- NULL = ещё не отправлено
);
CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;
func (s *Service) PayOrder(ctx context.Context, id string) error {
return s.db.InTx(ctx, func(tx *sql.Tx) error {
if err := markPaid(tx, id); err != nil { // бизнес-изменение
return err
}
return insertOutbox(tx, Event{ // сообщение в той же транзакции
AggregateID: id,
Type: "order.paid.v1",
Payload: mustJSON(OrderPaid{OrderID: id}),
})
})
}
// Отдельный релей: читает пачками, публикует, помечает.
func (r *Relay) tick(ctx context.Context) error {
rows := r.db.Query(ctx, `SELECT id, aggregate_id, event_type, payload
FROM outbox WHERE published_at IS NULL
ORDER BY id LIMIT 200
FOR UPDATE SKIP LOCKED`) // несколько релеев не мешают друг другу
for _, e := range rows {
if err := r.broker.Publish(ctx, e.Key(), e.Payload); err != nil {
return err // не помечаем, повторим
}
r.db.Exec(ctx, `UPDATE outbox SET published_at = now() WHERE id = $1`, e.ID)
}
return nil
}
Вычитывать outbox можно двумя способами. Polling publisher работает как в коде выше: просто, заводится с любой БД, а платишь задержкой на интервал опроса и лишней нагрузкой на БД. CDC / log tailing читает WAL напрямую (Debezium + Postgres logical replication): нулевая нагрузка на таблицы, минимальная задержка, честный порядок, но плюс один серьёзный компонент в инфраструктуре и своя эксплуатация.
Между удачным Publish и UPDATE published_at процесс может
упасть — сообщение уедет второй раз. Это неустранимо, и именно поэтому outbox
всегда идёт в паре с идемпотентностью потребителя. Ответ «мы сделали outbox,
значит, ровно один раз» ловят немедленно. Правильная формула:
«outbox гарантирует, что событие не потеряется и не появится без изменения данных;
уникальность обеспечивает потребитель».
Inbox: зеркало на стороне потребителя
CREATE TABLE inbox (
message_id text PRIMARY KEY, -- event_id продюсера
consumer text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now()
);
func (c *Consumer) Handle(ctx context.Context, m Message) error {
return c.db.InTx(ctx, func(tx *sql.Tx) error {
ok, err := claim(tx, m.ID, c.name) // INSERT ... ON CONFLICT DO NOTHING
if err != nil {
return err
}
if !ok {
return nil // уже обрабатывали, тихо подтверждаем
}
return c.apply(tx, m) // эффект и отметка в одной транзакции
})
}
Отметка об обработке и сам эффект должны лежать в одной локальной
транзакции. Если пометить в Redis, а изменение сделать в Postgres, вернулся тот же
dual write, только на стороне потребителя. Inbox нужен не всегда: если сам эффект
естественно идемпотентен (UPSERT по ключу, SET статуса,
«положить файл по имени»), отдельная таблица избыточна.
Подводные камни, которые стоит назвать самому
- Порядок. Публикация по
ORDER BY idплюс ключ партиционирования =aggregate_idсохраняет порядок в пределах агрегата. С параллельными релеями иSKIP LOCKEDглобального порядка нет — и он обычно не нужен, но это надо сказать вслух. - Раздувание таблицы. Outbox становится самой горячей таблицей на запись. Нужен
DELETE/партиционирование по дням с отрезанием старых партиций, иначе автовакуум и индексы деградируют. - Разрыв «изменил — забыл записать в outbox». Лечится тем, что публикацию прячут в репозиторий/UoW, а не оставляют на дисциплину разработчика.
- Транзакция стала длиннее на одну вставку — обычно незаметно, но на очень горячих путях это лишний WAL-трафик, и его надо учитывать.
- Не путать с Kafka transactions. Транзакции Kafka дают атомарность «прочитал из топика — записал в топик» внутри Kafka, но не связывают Kafka с твоей БД. Мост «БД ↔ брокер» всё равно строится через outbox или CDC.
CQRS по уровням, от дешёвого к дорогому
- Разные модели в коде.
CreateOrderCommandиOrderListItemустроены по-разному, потому что у записи и чтения разные инварианты. Стоит почти ничего, полезно почти всегда. - Разные пути доступа. Запись через доменную модель с валидацией и агрегатами, чтение — прямым SQL с джойнами в плоский DTO, минуя домен. Убирает половину «мапперов ради мапперов».
- Реплика для чтения. Та же схема, но чтение с read-replica. Появляется репликационный лаг и вопрос read-your-writes.
- Отдельное хранилище для чтения. Проекция в Elasticsearch/Redis/денормализованную таблицу, обновляемая событиями. Настоящая цена начинается тут: eventual consistency, пересборка проекций, мониторинг лага, обработка «проекция сломалась».
Оправдан, когда профили чтения и записи разошлись объективно: соотношение чтений к записям 100:1 и больше; чтения требуют агрегатов и полнотекстового поиска, которые ломают нормализованную схему записи; масштабировать надо только одну сторону; разным потребителям нужны принципиально разные представления одних данных. Не оправдан на CRUD с равномерной нагрузкой — там он просто удваивает код и добавляет окно рассинхрона на ровном месте.
Event sourcing: механика
Источником истины служит append-only лог событий агрегата. Текущее состояние получается
свёрткой: state = fold(apply, events). Запись добавляет событие
и проверяет оптимистичную блокировку по номеру версии.
// Событие неизменяемо и в прошедшем времени: оно уже случилось.
type Event interface{ isEvent() }
type AccountOpened struct {
ID, Owner string
}
type Deposited struct {
Amount Money
}
type Withdrawn struct {
Amount Money
}
// метод-маркер: запечатывает интерфейс, чужой тип событием не притворится
func (AccountOpened) isEvent() {}
func (Deposited) isEvent() {}
func (Withdrawn) isEvent() {}
type Account struct {
ID string
Balance Money
Version int
}
func (a *Account) Apply(e Event) { // чистая функция без побочных эффектов
switch v := e.(type) {
case AccountOpened:
a.ID = v.ID
case Deposited:
a.Balance = a.Balance.Add(v.Amount)
case Withdrawn:
a.Balance = a.Balance.Sub(v.Amount)
}
a.Version++
}
// Команда: загрузили состояние, проверили инвариант, вернули новые события.
func (a *Account) Withdraw(amount Money) ([]Event, error) {
if a.Balance.Less(amount) {
return nil, ErrInsufficientFunds
}
return []Event{Withdrawn{Amount: amount}}, nil
}
CREATE TABLE events (
stream_id text NOT NULL, -- agg id
version int NOT NULL, -- 1,2,3... внутри стрима
type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (stream_id, version) -- защита от конкурентной записи
);
Первичный ключ (stream_id, version) и есть оптимистичная блокировка:
два конкурентных процесса, прочитавших версию 7, попытаются вставить версию 8 —
один получит конфликт и перечитает.
Что покупает и сколько стоит
| Покупает | Стоит |
|---|---|
| Полная история: не «баланс 100», а как он таким стал | нельзя просто изменить данные — правка = компенсирующее событие |
| Аудит и комплаенс встроены by design | любой запрос «покажи все с балансом > X» требует проекции |
| Time travel: состояние на любой момент | версионирование схемы событий навсегда — старые события нельзя переписать, нужен upcasting |
| Новую проекцию можно построить задним числом по всей истории | снапшоты, иначе агрегат с 100 000 событий грузится вечно |
| Естественная интеграция: события уже есть | GDPR/«право на забвение» против immutable-лога — отдельная головная боль (крипто-шреддинг) |
| Отладка багов «как получилось такое состояние» | порог входа команды: мышление событиями, а не строками |
«CQRS и event sourcing ортогональны: CQRS без ES встречается сплошь и рядом и почти бесплатен, ES без CQRS практически невозможен (по логу событий нельзя делать произвольные выборки, нужна проекция для чтения). Event sourcing я бы взял для одного агрегата, где история входит в домен: счёт, платёж, движение товара, жизненный цикл заявки. А переводить на ES весь сервис ради моды почти всегда ошибка: цена платится ежедневно, а выгода нужна раз в квартал.»
Первое: «CQRS это когда две базы» (нет, речь про модели, а две базы уже крайний случай). Второе: не иметь ответа на «как читать данные в ES». Отвечать надо так: «проекции, и они eventual». Третье: «мы удалим неправильное событие» — в event sourcing лог неизменяем, ошибку исправляют новым событием, а не правкой старого. Четвёртое: забыть про снапшоты и версионирование схемы событий. Это первые две вещи, которые больно кусают в проде на горизонте года.
Что решает API Gateway
- Единая точка входа и роутинг. Клиент знает один домен, а внутренняя топология его не касается. Сервисы можно делить и переименовывать, не трогая клиентов.
- Терминация TLS и mTLS внутрь. Сертификаты в одном месте.
- Аутентификация. Проверка подписи JWT / интроспекция токена делается один раз, дальше внутрь идёт проверенный контекст (заголовки с subject, scope, tenant). Аутентификацию gateway берёт на себя, авторизацию нет (об этом ниже).
- Rate limiting и квоты по клиенту/ключу/IP, защита от всплесков и абьюза.
- Сквозная наблюдаемость. Генерация
trace_id/request_id, единые метрики и логи по всему внешнему трафику: на «сколько 5xx у нас снаружи» отвечает один дашборд. - Развязка версий и протоколов. Снаружи REST/JSON, внутри gRPC; наружу
/v1, внутри ужеv3. - Инфраструктурные мелочи, которых иначе N копий: CORS, сжатие, размер тела, таймауты, канареечный роутинг по заголовку, allowlist эндпоинтов.
Что в gateway класть нельзя
Правило простое: gateway не должен знать домен. Как только в конфигурации
появляется «если order.total > 10000, отправить в другой сервис» или
«склеить ответы и посчитать скидку», начинается ESB-паттерн из 2005-го: центральный
узел с бизнес-правилами, который релизится отдельно, ломает всех и становится узким
местом организационно (очередь на изменение конфига) и технически. Проверить просто:
если для новой фичи нужен релиз gateway — граница проведена неверно.
Проверить, что токен подлинный и не истёк, можно централизованно — это доменно-нейтральная
задача. Решить, что этот пользователь может редактировать этот заказ,
нельзя: правило зависит от состояния данных, которые знает только владелец.
Второе следствие: сервис не должен слепо верить заголовкам. Если во внутреннюю
сеть можно попасть мимо gateway, любой сможет прислать X-User-Id: 1.
Отсюда либо mTLS и сетевые политики, либо проброс подписанного токена внутрь
с повторной проверкой подписи. Это мягкое подбрюшье пентестеры находят регулярно.
BFF: backend for frontend
Проблема, которую он решает: у мобильного приложения, веба и партнёрского API
разные потребности. Мобильному нужен один запрос с минимумом байт (батарея, сеть),
вебу богатый ответ, партнёру стабильный контракт на годы. Общий API, который
пытается устроить всех, устраивает никого: он либо переусложнён параметрами
(?include=a,b,c&fields=...), либо заставляет клиента делать 8 запросов
на один экран.
BFF работает как слой на клиента: агрегирует вызовы к нескольким сервисам, обрезает лишние поля, приводит формат и принадлежит команде этого клиента. Отличие от gateway: gateway один и доменно-нейтрален, BFF-ов несколько и они знают про конкретный UI. Логика в BFF допустима, но только презентационная: «на экране заказа показываем адрес, статус и три последних события» — это про экран, а не про домен.
«Gateway это единая точка отказа». Да, поэтому его держат в нескольких репликах за L4-балансировщиком, делают stateless и готовят деградацию (если сервис интроспекции токенов лёг — кэш решений с коротким TTL, а не отказ всем). Второй вопрос: «а не добавляет ли BFF латентность». Добавляет один хоп, но убирает несколько клиентских раунд-трипов по мобильной сети, где RTT 100+ мс, так что обычно выигрывает. Третий: «сколько BFF». По одному на тип клиента, а не на команду и не на экран, иначе получается зоопарк с общим кодом, скопированным N раз.
Service discovery
Поды переезжают, инстансы автоскейлятся, и статический список адресов в конфиге не живёт. Нужен реестр: инстанс при старте регистрируется и шлёт heartbeat, при остановке снимается, а клиент получает актуальный список.
- Client-side discovery. Клиент сам запрашивает реестр (Consul, etcd, Eureka) и сам балансирует. Плюсы: умная балансировка (least-request, learning от латентности) и нет лишнего хопа. Минус: логика едет в каждый клиент на каждом языке.
- Server-side discovery. Клиент бьёт в стабильный адрес (k8s Service, ELB),
балансировкой занимается инфраструктура. Клиент от этого тупеет, и это плюс, но
добавляется лишний хоп и слабый контроль стратегии. В Kubernetes это по умолчанию: DNS-имя
svc.ns.svc.cluster.localплюс kube-proxy/iptables. - Health checks. Реестр обязан быстро выкидывать мёртвые инстансы, иначе клиенты будут упорно бить в труп. Разделяй liveness («перезапусти меня») и readiness («не шли мне трафик») — путаница между ними даёт либо рестарт-луп, либо 502 при деплое.
- Залипание на старом адресе, классические грабли: под переехал, а клиент
продолжает бить в прежний IP. Сам резолвер Go ответы не кэширует — держат адрес кэш ОС
и живые соединения в пуле, для которых адрес спрашивали один раз. В gRPC лечится
резолвером с подпиской, в HTTP — временем жизни соединения:
IdleConnTimeoutиCloseIdleConnections.
Конфигурация
- Разделение по природе. Код в образе, конфиг в окружении, секреты — в хранилище
секретов (Vault, KMS, k8s Secret с монтированием файлом и ротацией). Env-переменная
для секрета плоха тем, что видна в
/proc/<pid>/environ, вkubectl describeи в дампах, и ротируется только рестартом. - Статическая vs динамическая. Статическая читается на старте (адрес БД, размер пула). Динамическая должна меняться без рестарта: фича-флаги, лимиты, семплирование логов, аварийные переключатели. Для второй нужен watch/pull с валидацией и безопасным дефолтом при недоступности источника.
- Валидация на старте, fail fast. Отсутствующий или бессмысленный параметр
роняет приложение при запуске с внятным сообщением, а не
nil-паникой через час под нагрузкой. - Конфиг-сервис становится единой точкой отказа, если приложение не стартует без него. Лечится кэшем последней валидной конфигурации на диске.
Service mesh
Идея: вынести всю сетевую обвязку из библиотек приложения в sidecar-прокси (Envoy рядом с каждым подом) или в ядро (eBPF/ambient-режим). Приложение думает, что делает простой HTTP-запрос на localhost, а прокси делает всё остальное.
| Что даёт mesh | Что это заменяет |
|---|---|
| mTLS между всеми сервисами и ротация сертификатов | ручную настройку TLS в каждом сервисе |
| Ретраи, таймауты, circuit breaker, outlier detection | библиотеки устойчивости на каждом языке |
| Трафик-менеджмент: канарейка по весам, зеркалирование, fault injection | самописные переключалки и отдельные деплойменты |
| Единая телеметрия: RED-метрики и трейсинг без правки кода | инструментирование в каждом сервисе |
| Авторизация L7 между сервисами (кто кого может звать) | сетевые политики руками |
Цена: +1 контейнер и +2 сетевых хопа на каждый запрос (обычно единицы миллисекунд, но на внутренних вызовах в 1 мс это удвоение), заметный расход CPU/памяти на sidecar-ы, сложная плоскость управления, которую надо уметь чинить в 3 часа ночи, и новый класс инцидентов («запросы не идут, потому что sidecar стартовал позже приложения»). На собесе это звучит так: «mesh окупается примерно от нескольких десятков сервисов и/или при жёстком требовании mTLS и zero-trust; на пяти сервисах это дороже, чем библиотека с ретраями и circuit breaker».
Спросят «что делает mesh с ретраями, если приложение тоже ретраит» — умножение
попыток: 3 ретрая в приложении × 3 в прокси = 9 запросов, готовый retry storm.
Правило: ретраи живут в одном месте, и обычно это mesh; в приложении
отключаются. Второй добивающий: «что не может mesh». Понять бизнес-семантику.
Он не знает, идемпотентен ли твой POST, и потому ретрай на уровне прокси
по умолчанию безопасен только для методов, которые ты сам пометил идемпотентными.
Что считается ломающим изменением
| Безопасно (аддитивно) | Ломает |
|---|---|
| Добавить необязательное поле в ответ | Удалить или переименовать поле |
| Добавить необязательное поле в запрос с дефолтом | Сделать необязательное поле обязательным |
| Добавить новый эндпоинт/метод | Изменить тип поля (int → string) |
| Добавить значение в enum, если потребители толерантны | Сузить допустимый диапазон, ужесточить валидацию |
| Ослабить валидацию входа | Изменить смысл поля при том же типе — худшее, компилятор не поймает |
Отдельная ловушка: enum. Новый статус добавить безопасно, только если потребители
написаны толерантно; если у кого-то switch с default: panic,
аддитивное изменение уронит его в проде. Отсюда правило: «новое значение enum ломает
совместимость, пока не доказано обратное».
Стратегии версионирования
- Аддитивность + tolerant reader. Основной механизм: не удалять, не менять типы,
игнорировать незнакомые поля при разборе. В protobuf это встроено: номера полей
неизменны, неизвестные поля сохраняются, удалённые номера помечаются
reserved, чтобы их никто не переиспользовал. - Версия в URL (
/v1/orders) — просто, видно в логах, легко роутить на gateway; минус в дублировании кода при живых v1 и v2. - Версия в медиа-типе (
Accept: application/vnd.acme.v2+json): чище с точки зрения REST, хуже для отладки и кэширования. - Версия в имени топика или типа события (
order.paid.v2). Для брокеров это самый практичный вариант: старые потребители продолжают читать старый топик, пока не мигрируют. Плюс schema registry с проверкой совместимости (BACKWARD/FORWARD/FULL) на этапе публикации схемы — то есть ломающее изменение не проходит в CI. - Обязательное перекрытие. Порядок выкатки: сначала расширить (сервер поддерживает и старое, и новое), затем мигрировать потребителей, затем удалить старое. Это тот же expand–migrate–contract, что и для схемы БД, и точно так же нельзя делать шаги одновременно.
- Политика вывода версии из эксплуатации: объявленный срок, метрика «кто ещё ходит в v1» с разбивкой по клиентам, письма владельцам, только потом удаление. Без метрики использования удаление превращается в русскую рулетку.
Consumer-driven contracts
Интеграционные тесты «поднимем всё вместе» медленные и хрупкие, да ещё требуют собрать всю систему на каждый коммит. CDC переворачивает подход. Каждый потребитель описывает, что именно ему нужно от провайдера: конкретные запросы и ожидаемые куски ответов. Этот пакт публикуется в общий брокер контрактов (Pact Broker и аналоги).
- Тест на стороне потребителя гоняется против мока, сгенерированного из пакта, так что живой провайдер ему не нужен.
- Пакт публикуется в брокер с версией потребителя.
- В CI провайдера запускается верификация: реальный провайдер проверяется против всех пактов всех потребителей. Красный билд означает «твоё изменение сломает вот этих».
- Перед деплоем спрашиваем брокер: «совместима ли эта версия провайдера
со всем, что сейчас в проде?» (
can-i-deploy).
Главная ценность в том, что провайдер узнаёт о поломке в своём CI за минуты,
а не от соседней команды через неделю. Побочная: пакты показывают, какие
поля реально кем-то используются, и неиспользуемое можно спокойно удалять.
Ограничение: CDC проверяет форму и совместимость, но не бизнес-смысл — если ты изменил
значение поля amount с копеек на рубли, все пакты останутся зелёными.
Контракт события ломать опаснее, чем контракт HTTP: потребителей может быть больше,
чем ты знаешь, они могут читать топик с недельным лагом, а в брокере лежат старые
сообщения в старой схеме. Отсюда три правила: schema registry с проверкой
совместимости в CI, обязательные event_id/version/
occurred_at в конверте события, и двойная публикация на время
миграции, пока живут обе версии. И правило гигиены: событие это часть
публичного API, а не дамп внутренней структуры; сериализовать доменную модель
напрямую в топик — значит намертво связать свою схему БД со всеми потребителями.
«У нас в конфлюенсе описан контракт» — документ не проверяется на каждом коммите и расходится с реальностью на второй неделе. Второе: «выкатим v2 и попросим всех перейти». Без перекрытия версий это скоординированный релиз, ровно то, ради отсутствия чего заводили микросервисы. Третье: не подумать про откат. Если новая версия сервиса уже пишет данные в новом формате, откат бинаря назад приведёт к тому, что старый код не прочитает свои же свежие данные. Совместимость нужна в обе стороны, а не только вперёд.
11.4Распределённые системы: теория
«Просто вызов» иногда не возвращается. Go-специфики тут почти нет, и почти всё держится на трёх фактах: сеть ненадёжна, часы врут, узлы падают в самый неудачный момент. Остальное про то, как жить с этими фактами, не притворяясь, что их нет.
Сначала — четыре понятия, без которых дальше будет каша
Практический вопрос тут один: что делать, когда ответ не пришёл, а узнать причину невозможно? Дальше пойдут CAP, кворумы, консенсус и векторные часы, но все они вырастают из четырёх простых понятий. Договоримся о них сразу.
1. Узел и реплика
Узел (node) означает одну работающую машину или процесс внутри кластера: конкретный сервер Postgres, конкретный брокер Kafka, конкретный экземпляр etcd. Реплика хранит копию тех же данных, что и другой узел. Копий держат несколько ровно по одной причине: любая машина рано или поздно выключится, и весь вопрос в том, продолжит ли система работать без неё и что при этом увидит клиент.
2. Разделение сети (partition) — это не «данные разбиты на части»
Разделение (network partition, «сетевой разрыв») случается, когда узлы живы, но не могут разговаривать друг с другом: пакеты между ними не доходят. Кластер распадается на группы; внутри группы связь есть, между группами её нет. Причины прозаические: оборванный линк, перегруженный свитч, ошибка в сетевой политике, пауза сборщика мусора на 30 секунд.
Отсюда вырастает всё остальное: изнутри разрыв неотличим от падения. Узел A послал запрос узлу B и не получил ответа. Что случилось: B умер? B жив, но ответ потерялся по дороге? B жив, получил запрос, выполнил его и просто отвечает медленно? Различить эти три случая нельзя в принципе — не «инструмент пока не придумали», а математически невозможно: все три выглядят для A одинаково, как тишина. Всё дальнейшее сводится к тому, как с этой тишиной жить.
Слово partition в русском IT-жаргоне живёт в трёх разных смыслах: партиция Kafka (кусок топика), партиция таблицы в БД (кусок таблицы) и разделение сети. Здесь и в CAP имеется в виду только третье, и с нарезкой данных оно не связано никак.
3. Кворум — сколько голосов нужно, чтобы решение считалось принятым
Кворум задаёт минимальное число узлов, которые должны согласиться, чтобы решение
вступило в силу. Обычно это большинство, N/2+1. Слово взято из парламентской практики
в том же самом значении: заседание правомочно, только если пришло больше половины.
Почему именно большинство, а не «хотя бы двое»: любые два большинства одного и того же множества обязательно пересекаются. Из трёх узлов нельзя набрать две непересекающиеся двойки, как ни старайся. А раз пересекаются, двух противоречащих решений не выйдет: узел из пересечения участвовал в обоих и на второе просто не согласился бы. Подробный разбор ниже, в разделе про лидера.
4. Консенсус — договориться об одном значении и больше его не менять
В задаче о консенсусе группа узлов должна выбрать одно значение (кто теперь лидер; какая запись идёт под номером 42) так, чтобы выполнились три условия: выбрано ровно одно значение, выбранное больше никогда не меняется, и все живые узлы в итоге узнают одно и то же. Это умеют Paxos и Raft; на них стоят etcd, ZooKeeper, Consul и выборы лидера партиции в Kafka.
Разницу с кворумом полезно уметь проговорить: кворум задаёт правило подсчёта («больше половины»), а консенсус описывает протокол, который поверх этого правила решает, что делать с отказами, повторными голосованиями и вернувшимися после разрыва «зомби»-узлами. Кворум считает, консенсус договаривается.
CAP: что теорема на самом деле утверждает
Сначала о чём вообще речь, одной фразой и без терминов. Когда сеть порвалась, узел на одной стороне разрыва обязан выбрать одно из двух — ответить клиенту возможно устаревшими данными или не ответить вовсе. Третьего варианта нет физически: свежие данные лежат по ту сторону разрыва, дотянуться до них нельзя, а притвориться, что дотянулся, значит соврать. Всё прочее в CAP лишь аккуратно формулирует этот выбор и доказывает, что он неизбежен.
Точная формулировка (Gilbert & Lynch в 2002 году доказали гипотезу Брюера): распределённая система не может одновременно гарантировать consistency, availability и partition tolerance. Дальше начинается место, где ошибаются почти все: каждое из трёх слов означает не то, что кажется.
- Consistency здесь означает линеаризуемость, а не букву C из ACID. Требование: система ведёт себя так, будто существует одна копия данных, и любое чтение возвращает результат последней завершённой записи по реальному времени. Свойство очень сильное — сильнее, чем «данные не противоречат ограничениям».
- Availability: каждый запрос к любому живому узлу получает непустой корректный ответ за конечное время. Не «99.99 % аптайма», а именно «ни один живой узел не имеет права ответить ошибкой или молчать».
- С partition tolerance система продолжает работать, когда сеть произвольно теряет сообщения между группами узлов.
CAP не говорит «выбери два из трёх на всё время жизни системы». Это условное утверждение: если случилось сетевое разделение, то придётся отказаться либо от C, либо от A. Пока сеть цела, система спокойно даёт и консистентность, и доступность — выбора делать не нужно и не из чего. Именно поэтому вопрос «ваша система CP или AP?» осмыслен только как «что вы делаете в момент разрыва».
QUORUM против ONE).Почему «CA-система» — некорректная категория
Отказаться от P означает утверждать, что сетевого разделения не бывает. В любой системе, где узлы общаются по сети, разделение не гипотеза, а наблюдаемый факт: кабель, свитч, перегруженный линк, GC-пауза на 30 секунд (снаружи неотличима от разрыва), неправильно применённая сетевая политика. Ты не выбираешь, случится ли partition; ты выбираешь только, как система себя поведёт.
Поэтому P не опция, а условие задачи, и реальный выбор всегда «C или A при P». Честной CA-системой остаётся только одноузловая: там нет сети между узлами, а значит, нет и разделения (но есть отказ, при котором система просто недоступна). Одиночный Postgres формально CA — и именно поэтому его аптайм ограничен аптаймом одной машины. Как только к нему добавили синхронную реплику, появился вопрос «что делать, если реплика недоступна: ждать (CP, встанет запись) или коммитить без неё (AP, риск потерять данные при failover)».
Плохой ответ: «выбираем два из трёх». Хороший: «P выбрать нельзя, он дан. Вопрос в том, что делает узел, который не может подтвердить свою актуальность: отвечает ошибкой (CP) или отвечает возможно устаревшими данными (AP). Для денег я выберу CP и объясню клиенту, что операция временно недоступна; для ленты рекомендаций возьму AP, потому что устаревшая лента лучше пустого экрана». Отдельный балл дадут за то, что ты заметишь: CAP описывает крайности и почти ничего не говорит о нормальной работе. Для этого есть PACELC.
PACELC: то, чего не хватает в CAP
Формулировка Дэниела Абади: if P then A or C, else L or C. Читается так: «при разделении (P) выбирай между доступностью (A) и консистентностью (C), а в остальное время (Else) выбирай между латентностью (L) и консистентностью (C)».
Вторая половина важнее первой, потому что описывает 99.99 % времени жизни системы. Разделения случаются редко; а вот выбор «ждать подтверждения от кворума реплик (медленнее, но консистентно) или ответить сразу с локальной реплики (быстро, но возможно устарело)» делается на каждом запросе. Синхронная репликация через дата-центры стоит десятки миллисекунд на каждый коммит, всегда, независимо от аварий.
| Система | При partition | В обычном режиме | Класс |
|---|---|---|---|
| Cassandra / DynamoDB (дефолт) | отвечает всегда | жертвует консистентностью ради скорости | PA/EL |
| etcd / ZooKeeper / Consul | меньшинство отказывает | ждёт кворум, латентность выше | PC/EC |
| Google Spanner | меньшинство отказывает | ждёт TrueTime-неопределённость | PC/EC |
| MongoDB (writeConcern majority) | меньшинство отказывает | ждёт большинство | PC/EC |
| MySQL/Postgres с асинхронной репликой | реплика отдаёт старое | чтение с реплики быстрое и устаревшее | PA/EL |
Практический смысл PACELC для собеса: он даёт язык, чтобы обсуждать реплики для чтения. За «читаем с асинхронной реплики» стоит осознанный выбор L над C в нормальном режиме, со всеми последствиями вроде «пользователь сохранил профиль и не увидел изменений».
Strict и eventual consistency, и что между ними
Между «всегда свежие данные» и «когда-нибудь сойдётся» лежит целая шкала моделей. Подготовленный кандидат отличается тем, что знает её середину: на практике почти всегда нужна именно она.
| Модель | Гарантия | Цена |
|---|---|---|
| Linearizability (strict/strong) | система выглядит как одна копия; чтение видит последнюю завершённую запись по реальному времени | кворумы или лидер + синхронная репликация; латентность и недоступность меньшинства |
| Sequential | все видят один и тот же порядок операций, но он может отставать от реального времени | дешевле линеаризуемости, но всё ещё требует согласования порядка |
| Causal | причинно-связанные операции видны всем в одном порядке; независимые — в любом | метаданные о причинности (векторные часы) — но без глобального согласования |
| Read-your-writes | ты видишь свои собственные записи | липкая сессия или чтение с лидера/по версии |
| Monotonic reads | прочитав новое значение, ты не увидишь старое при следующем чтении | привязка сессии к реплике или отслеживание версии |
| Eventual | если записи прекратятся, все реплики когда-нибудь сойдутся | почти ничего — и почти никаких обещаний пользователю |
Три «клиентские» гарантии, за которые платят пользователи
Eventual consistency без дополнительных гарантий выдаёт аномалии, которые пользователь замечает. У каждой есть имя и лечение.
- Read-your-writes (read-after-write). Пользователь сменил аватарку, обновил
страницу и видит старую — запись ушла на лидера, чтение попало на отстающую реплику.
Лечения: (1) читать с лидера в течение N секунд после записи этого пользователя;
(2) липкая сессия, когда все запросы сессии идут на одну реплику; (3) клиент запоминает
версию/LSN своей записи и требует реплику не старее (
read your writesчерез токен консистентности — так работаетconsistent readв некоторых БД). - Monotonic reads. Пользователь видит комментарий, обновляет страницу — комментарий
исчез: два чтения попали на реплики с разным лагом. Лечение: закрепить сессию
за репликой (по
user_idв consistent hashing, а не случайно), либо передавать наблюдённую версию и не читать более старую. - Monotonic writes / causal. Классика: ответ на комментарий появился раньше самого комментария, потому что записи распространялись независимо. Лечение: причинная согласованность, то есть вместе с операцией передают её зависимости (версии тех записей, которые операция «видела») и не применяют операцию, пока не применены её причины.
«Eventual consistency — это когда данные сойдутся за пару секунд». Нет: eventual не даёт никакой границы по времени, только обещание «сойдутся, если записи прекратятся». Граница по времени уже другое свойство: его надо мерить (лаг репликации как метрика с алертом) и обеспечивать. Во второй капкан попадают те, кто считает, что «сильная консистентность» решает всё: линеаризуемость не даёт атомарности нескольких объектов, это отдельное свойство (транзакции), и в распределённой системе оно ещё дороже.
Идемпотентность: главный инструмент выживания в ненадёжной сети
Определение: операция идемпотентна, если её повторное применение с теми же
аргументами не меняет состояние системы по сравнению с однократным применением.
Формально f(f(x)) = f(x). Важно, что речь про состояние, а не про
ответ: повторный DELETE может вернуть 404 вместо 204 — состояние всё равно
одинаковое, операция идемпотентна.
Смысл ровно в одном: в сети нельзя отличить «запрос не дошёл» от «ответ не вернулся», поэтому на неизвестность остаётся один безопасный ответ: повторить, а повторять можно только идемпотентное. Ретраи, at-least-once доставка, outbox, саги: всё это держится на идемпотентности.
| Идемпотентно | Неидемпотентно |
|---|---|
SET balance = 100 (присвоение) | balance = balance + 100 (инкремент) |
GET, PUT, DELETE, HEAD, OPTIONS | POST, PATCH (в общем случае) |
INSERT ... ON CONFLICT DO NOTHING | INSERT без уникального ключа |
UPDATE ... SET status='paid' WHERE id=$1 | «отправить письмо», «списать деньги», «напечатать чек» |
Redis SET k v, SADD | Redis INCR, LPUSH, APPEND |
| Загрузка файла по фиксированному ключу в S3 | Добавление строки в лог, публикация события |
Три способа сделать неидемпотентную операцию идемпотентной
-
Ключ идемпотентности (idempotency key). Клиент генерирует уникальный
идентификатор намерения (не запроса!) и присылает его в заголовке. Сервер
сохраняет ключ вместе с результатом; повторный запрос с тем же ключом возвращает
сохранённый результат, не выполняя работу заново. Так устроены Stripe, платёжные шлюзы
и любой нормальный финансовый API.
CREATE TABLE idempotency ( key text PRIMARY KEY, -- Idempotency-Key от клиента request_hash text NOT NULL, -- защита от «тот же ключ, другое тело» state text NOT NULL, -- IN_PROGRESS | DONE response jsonb, -- сохранённый ответ created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL -- ключи не хранят вечно: 24ч–7 суток );func (s *Service) Charge(ctx context.Context, key string, req ChargeReq) (Resp, error) { h := hashOf(req) // 1. Столбим ключ: тут и происходит сериализация. claimed, prev, err := s.claim(ctx, key, h) // INSERT ... ON CONFLICT DO NOTHING + SELECT if err != nil { return Resp{}, err } if !claimed { if prev.RequestHash != h { return Resp{}, ErrKeyReused // 422: тот же ключ с другим телом } if prev.State == "IN_PROGRESS" { return Resp{}, ErrInFlight // 409: клиент должен повторить позже } return prev.Response, nil // 200 с сохранённым ответом } // 2. Делаем работу и сохраняем результат в одной транзакции с ключом. return s.doChargeAndStore(ctx, key, req) }Три детали, которые отличают знающего: (а) ключ придумывает клиент, и он обязан переиспользовать его при ретрае — иначе смысла нет; (б) хранится хеш запроса, чтобы поймать «тот же ключ, другое тело» и вернуть ошибку вместо чужого ответа; (в) есть состояние
IN_PROGRESS, иначе два параллельных ретрая начнут работу одновременно. -
Уникальное ограничение в БД (естественная дедупликация). Если у операции есть
естественный уникальный ключ, СУБД сделает работу за тебя:
UNIQUE (order_id, operation_type), и второйINSERTпросто провалится или ничего не сделает. Самый дешёвый и самый надёжный вариант, потому что гарантию держит само хранилище, а не дисциплина кода. Работает и на потребителе брокера:INSERT INTO processed(message_id) ON CONFLICT DO NOTHINGв одной транзакции с эффектом. -
Условное изменение состояния (CAS / переход конечного автомата). Вместо «списать»
писать «перевести из
PENDINGвPAID»:UPDATE orders SET status='PAID' WHERE id=$1 AND status='PENDING'. Повтор увидит 0 затронутых строк и поймёт, что работа уже сделана. Тем же приёмом работает оптимистическая блокировка по версии:... WHERE id=$1 AND version=$2. Это превращает любое изменение в идемпотентное на уровне семантики домена и заодно защищает от конкурентных обновлений.
Сделать идемпотентной отправку письма или SMS внутри своей БД можно (запомнить, что отправляли), а вот у внешнего провайдера гарантию придётся получать его средствами — и обычно он тоже предлагает ключ идемпотентности. Если не предлагает, остаётся своя таблица «отправлено» с записью до вызова и подтверждением после, плюс осознанный выбор: «лучше не отправить, чем отправить дважды» или наоборот. Это доменное решение, и на собесе полезно сказать вслух, что оно доменное.
Таймауты, ретраи, jitter — и как не устроить retry storm
Таймаут: обязателен всегда
Запрос без таймаута оставляет утечку, которая ждёт своего часа: горутина, соединение из пула и память живут ровно столько, сколько решит удалённая сторона. Правила:
- Таймаут ставится на каждый внешний вызов и выбирается по перцентилю, а не наугад: обычно p99 нормального ответа × 1.5–2. Таймаут 30 секунд при p99 = 80 мс бесполезен — он не защищает, а только продлевает агонию.
- Бюджет таймаута (deadline propagation). Дедлайн задаёт входящий запрос,
и он уменьшается по цепочке: если клиенту обещано 2 секунды, а на первый хоп
ушло 700 мс, второму хопу остаётся 1.3 с, а не снова 2. В Go это буквально
context.WithTimeout, который пробрасывают вниз, а в gRPC он передаётся по сети автоматически. Без этого суммарное время ответа = сумма таймаутов, и клиент отваливается раньше, чем система успевает ему ответить. - Отменять работу по дедлайну. Если клиент уже ушёл, считать дальше значит
жечь ресурсы впустую.
ctx.Done()должен реально доходить до запроса в БД.
Ретраи: только на то, что можно повторять
- Повторять можно таймауты, ошибки соединения, 502/503/504, gRPC
UNAVAILABLE/DEADLINE_EXCEEDED— и только для идемпотентных операций. - Повторять нельзя 400/401/403/404/422 (отказ детерминированный — повтор даст то же самое) и любые неидемпотентные операции без ключа идемпотентности.
- Уважать
Retry-After, если сервер его прислал: он знает лучше. - Ограничивать число попыток: 2–3 на уровень, не больше. И помнить про умножение по цепочке: 3 уровня по 3 ретрая = 27 запросов на один клиентский.
Экспоненциальная задержка и jitter
Просто «повторить через секунду» плохо тем, что тысяча клиентов, получивших ошибку одновременно, повторят тоже одновременно — и добьют сервис ровно в момент, когда он пытается встать. Экспонента разносит попытки во времени, jitter разносит клиентов между собой.
// Full jitter: задержка равномерно из [0, cap), где cap растёт экспоненциально.
// В замерах AWS он даёт наименьшую нагрузку и наименьшее время до успеха,
// лучше, чем «экспонента ± небольшой шум».
func backoff(attempt int) time.Duration {
const base, max = 100 * time.Millisecond, 10 * time.Second
exp := base << attempt // 100ms, 200ms, 400ms, 800ms...
if exp > max {
exp = max
}
return time.Duration(rand.Int63n(int64(exp))) // [0, exp)
}
func doWithRetry(ctx context.Context, f func(context.Context) error) error {
var err error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-time.After(backoff(attempt)):
case <-ctx.Done(): // дедлайн важнее ретраев
return ctx.Err()
}
}
if err = f(ctx); err == nil || !retriable(err) {
return err
}
}
return err
}
Сценарий: сервис D деградировал и отвечает медленно. C ретраит 3 раза, B ретраит C 3 раза, A ретраит B 3 раза — на один пользовательский запрос приходит 27 запросов к D. Нагрузка на упавший сервис выросла на порядок ровно тогда, когда ему нужно было разгрузиться, и сам он больше не встанет: это metastable failure — состояние, из которого система не выходит даже после снятия исходной причины. Четыре средства: (1) ретраи только на одном уровне — обычно на самом внешнем или в mesh, остальные выключены; (2) retry budget: не более ~10 % ретраев от общего числа запросов, сверх бюджета ретраи запрещены; (3) circuit breaker: перестать долбить того, кто явно лежит; (4) jitter, чтобы попытки не выстраивались в синхронную волну.
Тот же механизм синхронизации проявляется и без ретраев: одновременно истёкший TTL
у горячего ключа кэша отправляет тысячу запросов в БД (cache stampede), перезапуск
кластера отправляет все поды в БД за прогревом одновременно, cron в
0 * * * * у ста инстансов бьёт в один момент. Лечения те же по духу:
jitter в TTL и в расписании, single-flight (один поход за значением на все ожидающие
горутины — в Go это golang.org/x/sync/singleflight), вероятностное раннее
обновление кэша.
Circuit breaker, bulkhead, backpressure, load shedding
Четыре разных ответа на вопрос «что делать, когда мощности не хватает». Их часто перечисляют списком, не различая, хотя различие простое: breaker защищает от чужого отказа, bulkhead ограничивает распространение своего, backpressure тормозит источник, load shedding выбрасывает лишнее.
Circuit breaker — не долби мёртвого
Конечный автомат из трёх состояний вокруг вызова к внешней зависимости.
- Closed: запросы идут, считаем долю ошибок и медленных ответов в скользящем окне.
- В состоянии Open порог уже превышен, и все вызовы мгновенно падают с ошибкой, не трогая зависимость. Главный выигрыш тут в том, что клиент получает быстрый отказ вместо таймаута в 3 секунды, освобождаются горутины и соединения, а упавший сервис получает шанс встать.
- Half-open пропускает несколько пробных запросов, когда пауза вышла. Успех возвращает в closed, неуспех отправляет обратно в open с увеличенной паузой.
Тонкости: считать надо долю ошибок при достаточном объёме (минимум N запросов в окне, иначе один неудачный запрос при низком трафике откроет breaker), считать таймауты как ошибки, а бизнес-ошибки (404, 422) не считать, и обязательно иметь осмысленный fallback: закэшированное значение, значение по умолчанию, деградированный ответ. Breaker без fallback просто меняет медленный отказ на быстрый — это полезно, но только половина ценности.
Bulkhead — переборка
Название из судостроения: корпус делят на отсеки, чтобы пробоина не затопила весь корабль. В сервисе это отдельные пулы ресурсов на каждую зависимость или класс трафика: свой пул соединений, свой семафор на число одновременных вызовов, отдельный пул воркеров. Без переборок один медленный внешний API съедает все горутины и соединения, и падает весь сервис, включая эндпоинты, которые к этому API вообще не обращаются.
// Простейший bulkhead в Go: буферизованный канал как семафор на зависимость.
type Bulkhead struct{ sem chan struct{} }
func NewBulkhead(n int) *Bulkhead { return &Bulkhead{sem: make(chan struct{}, n)} }
func (b *Bulkhead) Do(ctx context.Context, f func() error) error {
select {
case b.sem <- struct{}{}: // взяли слот
defer func() { <-b.sem }()
return f()
case <-ctx.Done(): // слотов нет, бесконечно не ждём
return ErrBulkheadFull // быстрый отказ, а не медленная очередь
}
}
Backpressure — обратное давление
Сигнал «мне плохо, притормози», который идёт вверх по потоку к источнику нагрузки.
Это не отказ, а замедление: TCP-окно, небуферизованный канал в Go, ограниченная очередь,
ответ 429 с Retry-After, остановка чтения из брокера, пока не разобрана
текущая пачка. Очередь без границы не буфер, а отложенный
отказ: неограниченный канал или неограниченная очередь превращают перегрузку
в рост латентности и OOM вместо честного «не могу».
Load shedding — сбрасывание нагрузки
Когда притормозить источник невозможно (это внешние пользователи), остаётся отказывать части запросов, чтобы обслужить остальные. Отличие от rate limiting: rate limit задаёт политику («этому клиенту не больше 100 rps»), а shedding реагирует на состояние («у меня очередь длиннее 200, отвечаю 503»). Правильно делать это по приоритету: сначала отбрасывать фоновые задачи и повторные запросы, потом аналитику, в последнюю очередь платежи. И отбрасывать как можно раньше и дешевле: проверка на входе, до похода в БД.
Если запрос пролежал в очереди дольше, чем клиентский таймаут, обрабатывать его
бессмысленно — ответ уже никому не нужен, а ресурсы уйдут впустую. Проверка
«дедлайн истёк, сразу выбрасываем» на входе в обработчик (ctx.Err() != nil)
даёт системе шанс выбраться из перегрузки самостоятельно, вместо того чтобы
бесконечно работать над устаревшими запросами. В SRE это один
из описанных способов выйти из метастабильного отказа.
| Приём | От чего защищает | Когда применять |
|---|---|---|
| Circuit breaker | от чужой деградации, от собственных зависаний на таймаутах | любой внешний вызов с ненулевой вероятностью отказа |
| Bulkhead | от того, что одна зависимость съест все ресурсы | несколько зависимостей или классов трафика в одном процессе |
| Backpressure | от неограниченного роста очередей и OOM | есть управляемый источник: брокер, пайплайн, свои клиенты |
| Load shedding | от полного коллапса при перегрузке | источник неуправляем: публичное API, пик трафика |
Консистентное хеширование
Название сбивает с толку, поэтому сразу о нём. «Консистентное» здесь не имеет отношения к консистентности данных из CAP. Английское consistent hashing в этом контексте значит «устойчивое»: такое распределение ключей по узлам, которое почти не меняется, когда узлов становится больше или меньше. Речь всё время идёт про шардирование: ключи раскладывают по нескольким узлам так, чтобы любой участник системы, зная только ключ, вычислил один и тот же адрес.
Какую проблему решает
Ключи по N узлам обычно размазывают так: node = hash(key) % N.
Работает ровно до момента, когда N меняется. Пусть в кэше 100 узлов и 100 млн ключей.
Добавили один узел: % 100 превратилось в % 101, и почти
каждый ключ теперь отображается на другой узел — переезжает около
99 % ключей (точнее, доля N/(N+1) при добавлении). Для кэша это
означает почти полный промах на всём кластере одновременно: кэш пустой, весь трафик
ушёл в базу, база легла. Не «деградация», а инцидент — вызванный не аварией,
а плановым добавлением мощности.
Консистентное хеширование делает так, что при изменении числа узлов переезжает только
1/N ключей. Те же 100 узлов, добавили 101-й: переехало примерно 1 %
ключей, остальные 99 % остались на своих местах и продолжают попадать в кэш.
Кольцо
Пространство хешей (скажем, 0…232−1) замыкается в кольцо. На кольцо отображаются и узлы (по хешу их имени или адреса), и ключи (по хешу ключа). Правило владения одно: ключ принадлежит первому узлу, встреченному при движении по кольцу по часовой стрелке. Ищут бинарным поиском по отсортированному массиву позиций узлов, O(log N).
Виртуальные узлы: зачем они обязательны
У наивного кольца с N физическими точками две болезни. Первая в неравномерности: случайные позиции трёх узлов почти никогда не делят кольцо на три равные дуги, и разброс нагрузки в 2–3 раза считается нормой. Вторая: при удалении узла вся его нагрузка падает на одного соседа, который тут же ложится следом (каскад).
Решение: каждый физический узел кладут на кольцо V раз под разными метками
(hash("nodeA#0"), hash("nodeA#1"), …). При V = 100–200 разброс
нагрузки падает до единиц процентов, а при удалении узла его дуги расходятся
между всеми остальными, а не сваливаются на одного. Платишь памятью под V×N точек
(для 100 узлов по 150 реплик это 15 000 записей, копейки) и чуть более долгим поиском.
Бонус: V можно делать пропорциональным мощности узла — тогда получается взвешенное
распределение для разнородного железа.
type Ring struct {
points []uint32 // отсортированные позиции виртуальных узлов
owner map[uint32]string // позиция -> имя физического узла
vnodes int
}
func (r *Ring) Add(node string) {
for i := 0; i < r.vnodes; i++ {
p := crc32.ChecksumIEEE([]byte(node + "#" + strconv.Itoa(i)))
r.points = append(r.points, p)
r.owner[p] = node
}
sort.Slice(r.points, func(i, j int) bool { return r.points[i] < r.points[j] })
}
func (r *Ring) Get(key string) string {
h := crc32.ChecksumIEEE([]byte(key))
// первый узел по часовой стрелке; ушли за конец, замыкаем на начало
i := sort.Search(len(r.points), func(i int) bool { return r.points[i] >= h })
if i == len(r.points) {
i = 0
}
return r.owner[r.points[i]]
}
Memcached-клиенты (ketama), Cassandra и DynamoDB (партиционирование + репликация
на следующие R узлов по кольцу), шардирование Redis Cluster (там 16 384 слота,
по сути фиксированные виртуальные узлы), балансировка sticky-сессий, шардирование
очередей. Полезно знать, что существуют альтернативы: rendezvous hashing
(HRW): берём узел с максимальным hash(key, node), получаем то же свойство
минимального переноса без кольца и без виртуальных узлов, но поиск O(N).
И jump consistent hash с O(1) памяти и идеальной равномерностью, но узлы можно
только добавлять и убирать с конца, что не подходит для произвольного набора адресов.
Распределённые блокировки и fencing token
Задача: гарантировать, что критическую секцию (обработку файла, запуск джобы, изменение ресурса) в кластере выполняет ровно один процесс. Наивный вариант на Redis:
SET lock:job42 <random-uuid> NX PX 30000 # взять, только если свободен, TTL 30 c
# ... работа ...
# снять только свою блокировку: сравнить значение и удалить атомарно (Lua):
# if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) end
Уже здесь две обязательные детали: TTL (иначе упавший держатель заблокирует ресурс навсегда) и уникальное значение с проверкой при снятии (иначе процесс, чей TTL истёк, снимет чужую блокировку). Но главная проблема остаётся.
Фундаментальная проблема: TTL не останавливает процесс
Сценарий, который надо уметь рассказать: процесс P1 взял блокировку с TTL 30 секунд, начал работу и застрял — stop-the-world пауза GC, своп, вытеснение планировщиком, сетевая задержка. Прошло 40 секунд. Блокировка истекла, P2 её взял и начал работу. Тут P1 просыпается: с его точки зрения ничего не произошло, он уверен, что владеет блокировкой, и делает запись в хранилище. Два процесса одновременно в критической секции, и никакой TTL, никакой алгоритм согласования этого не предотвратит — потому что проблема не в блокировке, а в том, что нельзя остановить процесс, который считает себя владельцем.
Fencing token («ограждающий номер», «номер-пропуск») лечит эту болезнь не со стороны блокировки, а со стороны ресурса. Аналогия простая: талон электронной очереди. На табло горит номер, и окно обслуживает только того, чей талон не меньше текущего. Пришёл с талоном 33, когда на табло уже 34, и тебя развернут: неважно, насколько ты был уверен, что твоя очередь ещё не прошла.
Механика: сервис блокировок выдаёт вместе с блокировкой монотонно возрастающий номер (token). Клиент передаёт этот номер в защищаемый ресурс при каждой операции, а ресурс запоминает максимальный виденный номер и отвергает всё, что меньше. P1 получил токен 33, P2 получил 34. P2 успел записать с 34; когда проснувшийся P1 приходит с 33, хранилище отвечает отказом. Проверяют обычно ровно это: корректность обеспечивает не блокировка, а ресурс. Если ресурс не умеет проверять токен (обычный S3-бакет, внешнее API), полной защиты нет в принципе — и это надо честно сказать.
// Токен ложится в условие записи: ресурс сам отсекает устаревшего владельца.
// UPDATE resource SET data = $1, fence = $2 WHERE id = $3 AND fence < $2
res, err := db.ExecContext(ctx,
`UPDATE jobs SET result = $1, fence = $2 WHERE id = $3 AND fence < $2`,
data, token, jobID)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return ErrStaleLockHolder // нас уже вытеснили, тихо выходим без паники
}
Redlock и почему вокруг него спор
Redlock работает на N независимых Redis-мастерах: берём блокировку на большинстве за время, много меньшее TTL. Критика Мартина Клеппманна: алгоритм опирается на ограниченность задержек и на синхронность часов, а при паузе GC или скачке часов ломается ровно так же, как одиночный Redis — то есть даёт иллюзию строгости без фактической гарантии. Ответ на собесе, который закрывает вопрос: «Для эффективности (не запускать одну и ту же работу дважды без большой беды) хватит блокировки на одном Redis с TTL и уникальным значением. Для корректности (двойной запуск недопустим) нужен либо fencing token, либо перенос гарантии в сам ресурс — уникальный индекс, CAS по версии, условная запись».
В большинстве реальных задач блокировка не нужна вовсе. Уникальное ограничение в БД
(«эту джобу уже взяли») делает работу надёжнее и дешевле. SELECT ... FOR UPDATE
SKIP LOCKED раздаёт задачи воркерам без внешнего сервиса. Партиционирование
по ключу (все операции по счёту X идут в один воркер через ключ партиции Kafka)
убирает конкуренцию физически, а не логически. Оптимистичная блокировка по версии
решает конфликт в момент записи. Хороший ответ звучит так: «сначала я попробую сделать
так, чтобы блокировка не понадобилась; распределённый лок оставлю на крайний случай,
потому что он даёт меньше гарантий, чем от него ждут».
Лидер, кворум, консенсус
Зачем вообще лидер
Лидер сводит распределённую задачу к локальной: если все записи проходят через один узел, порядок операций определён тривиально, конфликтов нет, а остальные узлы просто повторяют журнал. Лидер нужен далеко не одним БД: единственный запускающий cron, единственный владелец шарда, единственный компактор, единственный ребалансировщик. Цена известна: нового лидера при отказе надо надёжно выбрать и гарантировать, что он ровно один.
Кворум и почему N/2+1
Кворумом называют минимальное число узлов, которое должно согласиться, чтобы решение
считалось принятым. Большинство (N/2+1) выбрано не из эстетики: любые два
большинства из одного и того же множества обязательно пересекаются минимум в одном узле.
Из этого следует главное свойство: два разных решения (два лидера, две записи одного
индекса лога) невозможны, потому что узел из пересечения не проголосует дважды в одном
терме. Если бы кворумом была половина, при разрыве 2/2 обе половины набрали бы кворум
и выбрали бы разных лидеров — классический split brain.
| N узлов | Кворум | Переживает отказов | Комментарий |
|---|---|---|---|
| 1 | 1 | 0 | не отказоустойчиво |
| 2 | 2 | 0 | хуже одного: отказ любого ломает кворум |
| 3 | 2 | 1 | рабочий минимум |
| 4 | 3 | 1 | толку как от 3, а латентность выше |
| 5 | 3 | 2 | стандарт для важных кластеров |
| 7 | 4 | 3 | редко: каждый лишний узел замедляет запись |
Отсюда правило «нечётное число узлов»: чётность не добавляет живучести, но добавляет латентности и стоимости. И отсюда же понятно, почему кластер из 3 узлов, потерявший 2, перестаёт обслуживать запись, хотя один узел жив: он не может доказать, что он не в меньшинстве, а значит, не имеет права принимать решения. Это буквально CP-выбор в действии.
В системах с настраиваемым кворумом (Cassandra, Dynamo-подобные) то же самое выражается
через W + R > N: если число узлов для подтверждения записи плюс число
узлов для чтения больше общего числа реплик, множества гарантированно пересекаются
и чтение увидит последнюю запись. Типовой выбор N=3, W=2, R=2;
а W=1, R=1 даёт максимальную скорость и никаких гарантий.
Raft: термы, выборы, репликация лога
Raft придумали как «понятную альтернативу Paxos», и держится он на трёх идеях: сильный лидер, разделение времени на термы и репликация журнала.
- Терм (term) задаёт номер эпохи, монотонно растущее целое. В каждом терме не более одного лидера. Он же служит логическими часами: узел, увидевший больший терм, немедленно становится follower. Так система сама вытесняет «зомби-лидера», вернувшегося после разрыва.
- Три роли: follower (пассивно принимает записи и heartbeat), candidate (пытается стать лидером), leader (единственный, кто принимает клиентские записи).
- Выборы. У каждого follower идёт случайный таймаут выборов (обычно 150–300 мс).
Heartbeat не пришёл — follower увеличивает терм, становится candidate, голосует
за себя и рассылает
RequestVote. Узел отдаёт голос, если ещё не голосовал в этом терме и лог кандидата не отстаёт от его собственного (это условие гарантирует, что лидером не станет узел, потерявший закоммиченные записи). Набрал большинство — стал лидером и сразу начинает слать heartbeat. - Split vote случается, когда несколько кандидатов стартовали одновременно: голоса делятся, и никто не набирает большинство. Терм проходит впустую, таймауты истекают, начинается новый терм. Именно поэтому таймаут случайный: рандомизация делает одновременный старт маловероятным, и обычно достаточно одного лишнего раунда.
- Репликация лога. Клиентская запись попадает в лог лидера, лидер рассылает
AppendEntries. Реплицировав запись своего терма на большинство, лидер считает её committed, применяет к машине состояний и сообщает индекс коммита followers. Записи прошлых термов так коммитить нельзя, даже если они лежат на большинстве: их новый лидер ещё может затереть, и они становятся закоммиченными заодно с первой записью текущего терма. Клиенту, соответственно, нельзя отвечать «ок» до коммита. - Свойство безопасности: если запись закоммичена в терме T, она присутствует в логах всех будущих лидеров. Это обеспечивают правило голосования «не голосуй за отстающий лог» и пересечение кворумов.
term 3 в первом же ответе, поймёт,
что отстал, и станет follower, не успев ничего испортить.
Свой Raft писать не надо — надо уметь пользоваться чужим. Практический рецепт
выбора лидера для «одного cron на кластер»: аренда (lease) в etcd/Consul/Kubernetes
(coordination.k8s.io/Lease, тот же механизм, что у контроллеров k8s)
или строка в Postgres с SELECT ... FOR UPDATE и TTL. Обязательные детали:
аренда продлевается в фоне, при неудачном продлении процесс сам прекращает
работу лидера (иначе будет два), и вся защищаемая работа всё равно должна быть
идемпотентной, потому что перекрытие лидеров возможно по причинам из раздела
про блокировки.
Дедупликация и «exactly-once end-to-end»
Три семантики доставки и один жестокий факт.
- At-most-once. Отправили и забыли: потери возможны, дублей нет. Годится для метрик и телеметрии, где потеря одного значения безразлична.
- At-least-once. Повторяем до подтверждения: потерь нет, дубли гарантированы. Так и работает любая надёжная система доставки.
- Exactly-once, ровно один раз. Как свойство доставки по сети недостижимо: потерю запроса нельзя отличить от потери ответа (в теории это задача о двух генералах).
«Exactly-once доставки не существует. Существует exactly-once обработка (effectively-once): at-least-once доставка плюс дедупликация или идемпотентность на стороне получателя. Kafka-транзакции дают exactly-once внутри Kafka для схемы consume–process–produce, но как только эффект уходит наружу (в твою БД, в письмо, во внешнее API), гарантия заканчивается и восстанавливать её приходится самому.» По этому ответу и видно, кто читал маркетинговую страницу, а кто разбирался.
Как делают дедупликацию на практике
- Ключ дедупликации в сообщении. Продюсер кладёт
event_id(детерминированный, а неuuid.New()на каждой попытке отправки), потребитель хранит виденные идентификаторы. Под хранилище идёт таблица inbox (транзакционно с эффектом) или Redis-множество с TTL, если допустимо «почти точно». - Окно дедупликации конечно. Хранить все идентификаторы вечно нельзя. Окно выбирают (час, сутки, неделя) по максимально возможной задержке повтора; за пределами окна дубль пройдёт. Компромисс придётся назвать вслух: «у нас дедупликация на 24 часа, сообщение старше этого срока считается новым».
- Экономия памяти. Для больших потоков ставят Bloom-фильтр предфильтром: «точно не видел» он отвечает бесплатно, «возможно видел» отправляет в БД. Ложноположительные срабатывания не страшны, за ними идёт точная проверка.
- Лучше не дедуплицировать, а быть идемпотентным.
UPSERTпо естественному ключу, переход состояния с условием, запись в S3 по детерминированному имени. Тогда дубли безвредны by design и никакого хранилища идентификаторов не нужно.
Отдельно про то, в каком порядке потребитель брокера коммитит смещение: commit offset после обработки даёт at-least-once (упали между обработкой и коммитом — будет повтор), commit до обработки даёт at-most-once (упали после коммита — сообщение потеряно). Почти всегда выбирают первое плюс идемпотентную обработку.
Часы и порядок событий
Физические часы врут — и это архитектурная проблема
- Дрейф. Кварцевый генератор уходит на 10–100 ppm, то есть на секунды в сутки. NTP подтягивает часы обратно, и время в этот момент может прыгнуть назад. Два события, случившиеся друг за другом, получат метки в обратном порядке.
- Точность синхронизации в дата-центре измеряется единицами миллисекунд, между дата-центрами десятками. Это больше, чем длительность многих операций, поэтому сравнивать метки с разных машин с точностью до миллисекунды нельзя.
- Практические правила Go: чтобы померить длительность, берут
монотонные часы (в Go
time.Now()хранит монотонную составляющую, и разность двухtime.Timeкорректна даже при переводе часов), а метки времени в данных читают как «примерно» и корректность на них не строят. - Опасный антипаттерн: last-write-wins по wall clock. Часы одного узла спешат на 200 мс — и его записи всегда «выигрывают», а данные, записанные позже, молча теряются. Так теряют данные в Cassandra при кривой синхронизации времени.
Google Spanner использует TrueTime: атомные часы и GPS дают не точку, а интервал
[earliest, latest] с гарантированной границей ошибки (единицы миллисекунд).
Чтобы транзакции были линеаризуемыми, коммит ждёт, пока этот интервал пройдёт
(«commit wait»). Система буквально платит латентностью за то, чтобы порядок по времени
был настоящим. Отсюда общее правило: сильные гарантии времени покупаются либо
специальным железом, либо ожиданием.
Happened-before и логические часы
Лэмпорт заметил: чтобы упорядочивать события, физическое время не нужно, хватает отношения
«произошло раньше» (→), которое задают три правила:
(1) в пределах одного процесса события упорядочены; (2) отправка сообщения предшествует
его получению; (3) отношение транзитивно. События, не связанные этим отношением,
называются конкурентными. Про них нельзя сказать, какое было раньше,
и это не незнание, а объективное свойство.
Оба вида логических часов удобно представить как бумажную переписку. Часы Лэмпорта нумеруют письма подряд: по номерам видно, что письмо №7 написано после №5, но если два человека писали одновременно, номера всё равно выстроятся в один ряд и создадут ложное впечатление очерёдности. Векторные часы добавляют приписку «твои письма прочитал по 4-е включительно, своих написал 6»: по ней сразу видно, знал ли автор о твоём последнем письме, когда писал своё. Если он не знал о твоём, а ты о его, письма разошлись в пути; события конкурентны, и это видно явно, а не угадывается по времени на штемпеле.
- Часы Лэмпорта. У каждого процесса счётчик. Перед локальным событием
C++. При отправке сообщения к нему прикладываетсяC. При полученииC = max(C, C_msg) + 1. Свойство: еслиa → b, тоC(a) < C(b). Обратное неверно: изC(a) < C(b)не следует, чтоaбыло раньше, события вполне могут оказаться конкурентными. То есть часы Лэмпорта дают полный порядок (ничьи разрешают по id процесса), но обнаружить конкурентность не дают. - Векторные часы. Вектор из N счётчиков, по одному на процесс; процесс
инкрементирует свою позицию, при получении берёт поэлементный максимум.
Теперь
a → bтогда и только тогда, когда векторaпокомпонентно меньше или равен векторуbи где-то строго меньше; если ни один не меньше другого, события конкурентны, и видно это сразу. Цена: размер метаданных растёт с числом узлов, а узлы бывают эфемерными. Именно так Dynamo и Riak находили конфликтующие версии и отдавали их разрешать приложению (siblings).
| Часы | Что дают | Чего не дают | Где встречается |
|---|---|---|---|
| Физические (wall clock) | человекочитаемые метки, сравнение с внешним миром | корректный порядок между узлами | логи, аудит, TTL |
| Монотонные | корректные длительности внутри процесса | смысл между процессами | таймауты, метрики латентности |
| Лэмпорта | полный порядок, согласованный с причинностью | обнаружение конкурентности | термы Raft, версии в БД |
| Векторные | обнаружение конкурентных версий | компактность при большом N | Dynamo, Riak, CRDT |
| Гибридные (HLC) | близость к реальному времени + причинность | магии — ошибка часов всё равно учитывается | CockroachDB, YugabyteDB |
Вопрос-детектор: «у тебя два сервиса пишут в общий лог с метками времени; можешь ли
ты по этим меткам восстановить порядок событий?» Правильный ответ: нет, не можешь,
если разница меньше погрешности синхронизации. Чтобы восстановить порядок, нужен
trace_id с причинными связями (span parent), логическая версия или
последовательный номер от общего источника. Второй вопрос-детектор: «как ты
разрешаешь конфликт двух одновременных записей?» Ответ «по времени» плохой;
«по версии/вектору», «CRDT», «отдать конфликт домену на разрешение» хорошие.
Вопросы
10Скелет ответа за минуту
- Дать точные определения: C значит линеаризуемость (система выглядит как одна копия); A требует, чтобы каждый живой узел отвечал непустым корректным ответом за конечное время; P описывает работу при потере сообщений между группами узлов.
- Сказать, что теорема условная. Пока сеть цела, есть и C, и A. Выбор возникает ровно в момент разрыва.
- Разобрать сценарий на двух узлах: клиент записал
x=2в A, сеть порвалась, другой клиент читает у B. У B два варианта: ответить ошибкой (CP) или ответитьx=1(AP). Третьего нет. - Привести примеры: etcd/ZooKeeper/Consul/Spanner работают как CP (меньшинство отключает себя), Cassandra/DynamoDB/Riak как AP (отвечают всегда, конфликты чинят потом).
- Добавить, что это настройка, а не природа: в Cassandra
QUORUMпротивONEбуквально переключает CP/AP на уровне отдельного запроса.
Почему CA — некорректная категория
Чтобы отказаться от P, надо утверждать, что сетевого разделения не бывает. Но разрыв не сводится к перерубленному кабелю: 30-секундная пауза GC, перегруженный линк, кривая сетевая политика, перезагрузка свитча выглядят снаружи идентично разрыву. Ты не выбираешь, случится ли partition. Выбираешь только реакцию. Формально под CA подходит одиночный узел (сети между узлами нет, разделения не бывает), но у него нет и отказоустойчивости: он просто недоступен, когда падает. Как только появляется вторая реплика, сразу встаёт вопрос «коммитить, если реплика не ответила?» — и это уже C против A.
PACELC
If P then A or C, else L or C. Вторая половина описывает 99.99 % времени: ждать, пока подтвердит кворум (медленнее, консистентно), или ответить с локальной реплики (быстро, возможно устарело). Синхронная кросс-ДЦ репликация стоит десятки миллисекунд на каждый коммит всегда, а не в одной лишь аварии. Классификация: Cassandra/DynamoDB идут как PA/EL, etcd/ZooKeeper/Spanner/MongoDB(majority) как PC/EC, Postgres с асинхронной репликой как PA/EL по пути чтения с реплики.
Хороший ход: сказать самому, что CAP переоценён как инструмент проектирования.
Он говорит о крайностях (полная линеаризуемость против полной доступности)
и молчит про промежуточные модели, а на практике нужны именно они: причинная
согласованность, read-your-writes, кворумы W+R>N.
И ещё, что выбор делается не для системы целиком, а для конкретной операции:
деньги списывают по CP, ленту показывают по AP, и внутри одного продукта это нормально.
За «выбираем два из трёх» без оговорки про условность сразу минус. Фраза
«MongoDB это CP, а MySQL это CA» вешает ярлык на СУБД целиком, тогда как поведение
зависит от настроек (writeConcern, синхронная или асинхронная реплика,
readPreference). И третье: путать C из CAP с C из ACID. Вещи разные:
первое про линеаризуемость, второе про то, что инварианты схемы соблюдены.
Крайности
Strict (линеаризуемость): система ведёт себя как одна копия, любое чтение видит последнюю завершённую запись по реальному времени. Добиваются этого лидером плюс синхронной репликацией на кворум или консенсусом. Цена: латентность каждой операции и отказ меньшинства при разделении.
Eventual: если записи прекратятся, реплики когда-нибудь сойдутся. Никакой границы по времени, никаких гарантий на порядок. Дёшево, масштабируемо и очень неприятно для пользователя, если не добавить клиентских гарантий сверху.
Три аномалии, у которых есть имена
| Что видит пользователь | Название | Как чинят |
|---|---|---|
| Сменил аватар, обновил страницу — старый аватар | нет read-your-writes | читать с лидера N секунд после записи этого пользователя; липкая сессия; чтение по версии/LSN своей записи |
| Видел комментарий, обновил — исчез | нет monotonic reads | закрепить сессию за конкретной репликой (хеш от user_id, а не round-robin); передавать наблюдённую версию и не читать реплику старее |
| Ответ на комментарий появился раньше комментария | нет causal consistency | передавать причинные зависимости (версии прочитанного) и не применять операцию раньше её причин; в брокере — один ключ партиционирования на причинно связанные события |
Практический рецепт «read-your-writes» для веб-сервиса
// После записи кладём в сессию/куку момент (или LSN) и N секунд читаем с мастера.
const stickyWindow = 3 * time.Second
func (r *Repo) pickConn(ctx context.Context) *sql.DB {
if t, ok := LastWriteAt(ctx); ok && time.Since(t) < stickyWindow {
return r.primary // свои свежие данные только с мастера
}
return r.replica // всё остальное с реплики
}
Точнее работает вариант не по времени, а по позиции: после записи запомнить LSN
(pg_current_wal_lsn()), при чтении выбрать реплику, у которой
pg_last_wal_replay_lsn() >= запомненного, иначе идти на мастер.
В других системах это называют «токен консистентности» или
ReadYourWrites-сессия.
Во-первых, причинная согласованность остаётся сильнейшей моделью, совместимой с доступностью при разделении (доказано): всё, что сильнее, требует отказа от A. Во-вторых, линеаризуемость относится к одному объекту; атомарность нескольких объектов даёт уже транзакция, свойство отдельное и более дорогое. В-третьих, у eventual consistency обязана быть метрика: лаг репликации с алертом, иначе «когда-нибудь» превращается в «через сорок минут» и никто не узнает.
f(f(x)) = f(x). Это про состояние, а не про ответ.
Нужна потому, что в сети «запрос не дошёл» неотличимо от «ответ не вернулся»,
и единственная реакция на неизвестность — повтор.Примеры
- Идемпотентно:
SET balance = 100;PUT /users/42;DELETE(второй раз просто нечего удалять);INSERT ... ON CONFLICT DO NOTHING;UPDATE ... SET status='paid' WHERE id=$1; загрузка объекта в S3 по фиксированному ключу; RedisSET,SADD. - Неидемпотентно:
balance = balance + 100;POST /ordersбез ключа;INSERTбез уникального ограничения; RedisINCR,LPUSH; отправка письма, SMS, push; печать чека; вызов внешнего платёжного API без idempotency key.
Уточнение, которое любят слышать: идемпотентность и безопасность (safety)
различаются. GET и безопасен, и идемпотентен;
DELETE идемпотентен, но не безопасен; POST ни то, ни другое.
И ещё: ответ может отличаться (204 в первый раз, 404 во второй),
а идемпотентность от этого не ломается.
Три способа сделать неидемпотентную идемпотентной
- Ключ идемпотентности. Клиент генерирует уникальный id намерения
и присылает в заголовке
Idempotency-Key, переиспользуя его при всех ретраях. Сервер хранитkey → (хеш запроса, состояние, сохранённый ответ)и на повтор возвращает сохранённый результат, не выполняя работу. Три детали: хеш тела (поймать «тот же ключ, другое тело» → 422), состояниеIN_PROGRESS(два параллельных ретрая не должны стартовать оба → 409), TTL ключей (сутки-неделя, не вечно). - Уникальное ограничение в БД. Естественный ключ операции
(
UNIQUE (order_id, op_type),message_id PRIMARY KEY) иON CONFLICT DO NOTHING. Самый надёжный вариант, потому что гарантию даёт хранилище, а не дисциплина кода. - Условный переход состояния (CAS). Не «списать», а «перевести из PENDING
в PAID»:
UPDATE ... WHERE id=$1 AND status='PENDING'. Ноль затронутых строк = работа уже сделана. То же самое с версией:WHERE id=$1 AND version=$2. Заодно решает конкурентные обновления.
«Сгенерируем UUID на сервере» ключом идемпотентности не работает: при ретрае клиент придёт с новым UUID, и будет два заказа. Ключ придумывает клиент и обязан его переиспользовать. Второй капкан ловит тех, кто хранит ключ в Redis, а эффект пишет в Postgres: между двумя хранилищами нет атомарности, и на падении между операциями получишь либо потерянный ключ, либо потерянный эффект. Ключ и эффект пишутся одной транзакцией. Третий капкан прячется на границе системы: письмо, отправленное внешним провайдером, твоей таблицей не отменяется.
Таймауты
- Ставятся всегда: вызов без таймаута превращается в утечку горутин, соединений и памяти, которой управляет чужая сторона.
- Значение выбирают из наблюдаемого p99, а не из головы. Таймаут 30 секунд при p99 = 80 мс не защищает ни от чего.
- Бюджет убывает. Клиенту обещано 2 с, первый хоп съел 700 мс — второму
остаётся 1.3 с. В Go это проброшенный
context, в gRPC дедлайн передаётся по сети автоматически. Без бюджета суммарный таймаут = сумма таймаутов, и клиент отваливается раньше, чем система отвечает. - Дедлайн должен реально доходить до запроса в БД и отменять работу, иначе процесс продолжает считать ответ, который уже никому не нужен.
Ретраи
| Ретраить | Не ретраить |
|---|---|
| таймаут, обрыв соединения, 502/503/504 | 400, 401, 403, 404, 422 — детерминированный отказ |
gRPC UNAVAILABLE, RESOURCE_EXHAUSTED (с уважением к Retry-After) | gRPC INVALID_ARGUMENT, PERMISSION_DENIED |
| идемпотентные операции | неидемпотентные без ключа идемпотентности |
Jitter: почему без него плохо
Тысяча клиентов получила ошибку в один и тот же момент. Без jitter все повторят через
ровно 1 секунду — и синхронная волна ударит в сервис, который как раз пытается встать.
Full jitter (задержка равномерно из [0, cap), где cap растёт
экспоненциально) в замерах даёт наименьшую нагрузку, а по времени до успеха идёт
вровень с decorrelated jitter, чуть уступая ему. «Экспонента плюс небольшой шум»
проигрывает обоим и по работе, и по времени. Тот же приём нужен в TTL кэша
и в расписании cron, иначе получишь thundering herd без всяких ретраев.
Retry storm
Сервис D деградировал. C ретраит 3 раза, B ретраит C 3 раза, A ретраит B 3 раза — и до D доходит 27 запросов на один пользовательский. Нагрузка выросла на порядок ровно в момент, когда нужна была разгрузка, и система входит в метастабильный отказ: она не восстанавливается, даже когда исходную причину убрали, потому что причина теперь в самих ретраях.
- Ретраи на одном уровне. Обычно на самом внешнем или в service mesh; на промежуточных выключены. Ретраит mesh — приложение не ретраит.
- Retry budget: ретраи не более ~10 % от объёма основных запросов; превысили порог — ретраи временно запрещены.
- Circuit breaker поверх: перестать долбить того, кто явно лежит.
- Jitter обязательно.
- Отбрасывать просроченное: если запрос пролежал дольше клиентского дедлайна, не обрабатывать его вообще — мощность освободится для актуальных.
Спросят: «а как понять, что таймаут выставлен правильно?» Смотреть надо на долю
запросов, отваливающихся по таймауту, и на сравнение с p99.9: если таймаут
режет заметную долю успешных ответов, он мал; если ошибки по таймауту приходят
через 30 секунд — он велик, и ты просто дольше держишь ресурсы. И второй:
«а ретраить запросы в базу можно?» Можно на ошибках соединения и deadlock/serialization
failure (в Postgres на 40001/40P01 повторять транзакцию
прямо предписано), нельзя на нарушениях ограничений.
Circuit breaker
Автомат из трёх состояний вокруг внешнего вызова: closed (считаем долю ошибок в скользящем окне), open (мгновенный отказ без обращения к зависимости), half-open (несколько пробных запросов; успех закрывает автомат, отказ снова открывает с увеличенной паузой). Ценен он быстрым отказом вместо медленного: освобождаются горутины и соединения, а упавшая зависимость получает шанс встать.
Тонкости, которые отличают знающего: считать долю, а не абсолютное число, и только при минимальном объёме в окне (иначе на низком трафике один сбой откроет breaker); таймауты считать ошибками, а бизнес-ошибки (404, 422) не считать; иметь fallback (кэш, дефолт, деградированный ответ), иначе выигрыш только в скорости отказа; отдельный breaker на каждую зависимость, а не один на сервис.
Bulkhead
Переборки из судостроения. На каждую зависимость или класс трафика заводят
отдельный пул ресурсов: свой пул соединений, свой семафор на число одновременных
вызовов, свой набор воркеров. Без них один медленный внешний API съедает все горутины — и падает
весь сервис, включая ручки, которые к этому API не обращаются. В Go это чаще
всего буферизованный канал как семафор плюс select с
ctx.Done(), чтобы не ждать слот бесконечно.
Backpressure
Сигнал «притормози» вверх по потоку к источнику: TCP-окно, небуферизованный канал,
ограниченная очередь, 429 с Retry-After, пауза в чтении из брокера.
Очередь без границы работает не буфером, а отложенным отказом.
Неограниченный канал превращает перегрузку в рост латентности и OOM вместо
честного «не могу прямо сейчас».
Load shedding
Когда источник неуправляем (внешние пользователи), остаётся отбрасывать часть запросов, чтобы обслужить остальные. Отличие от rate limiting: rate limit задаёт политику по клиенту, shedding реагирует на своё состояние (длина очереди, использование CPU, возраст запросов в очереди). Отбрасывать правильно по приоритету (сначала фон и аналитика, платежи в последнюю очередь) и как можно раньше и дешевле, до похода в БД.
Один вопрос: кто виноват в перегрузке? Деградировала внешняя зависимость, значит breaker и таймауты. Одна часть системы мешает другим, значит bulkhead. Потребляешь из управляемого источника (брокер, пайплайн, свои клиенты), тут поможет backpressure. Источник неуправляем и топит тебя — остаётся load shedding. В реальном сервисе обычно нужны все четыре, и они не заменяют друг друга.
«Поставим circuit breaker» — а зависимость отвечает 200 OK за 5 секунд, breaker закрыт, сервис лежит. Медленные ответы должны считаться отказами, иначе breaker бесполезен ровно в самом частом сценарии. Во втором капкане один breaker стоит на все зависимости: отказ второстепенного сервиса отрубает вызовы к главному. Третий: увеличить очередь, чтобы «не терять запросы». Время ожидания уходит за клиентский таймаут, и ты работаешь над ответами, которых уже никто не ждёт.
hash(key) % N при изменении N переносит почти
все ключи — для кэша это мгновенный полный промах и падение базы. Кольцо переносит
только 1/N. Виртуальные узлы нужны, чтобы нагрузка была ровной и чтобы
при отказе узла его ключи не свалились целиком на одного соседа.Проблема с числами
100 узлов кэша, 100 млн ключей. Добавляем 101-й: % 100 стало
% 101, и на новое место переезжает доля N/(N+1) —
около 99 % ключей. Весь кластер одновременно промахивается, трафик уходит
в базу — база ложится. Инцидент устроила не авария, а плановое добавление мощности.
С консистентным хешированием переезжает 1/(N+1), примерно 1 %,
остальные 99 % ключей остаются на своих узлах.
Кольцо
Пространство хешей (0…232−1) замкнуто в кольцо. На него отображаются и узлы (по хешу имени), и ключи. Правило: ключ принадлежит первому узлу по часовой стрелке. Ищут бинарным поиском по отсортированному массиву позиций, O(log N).
- Добавление узла: он вклинивается между двумя существующими и забирает ключи только у одного соседа, той дуги, которая теперь заканчивается на нём.
- Удаление узла: вся его дуга целиком достаётся следующему по часовой стрелке. Именно здесь наивное кольцо опасно: сосед получает удвоенную нагрузку, падает следом — начинается каскад.
Виртуальные узлы
Каждый физический узел сажают на кольцо V раз под разными метками
(hash("A#0"), hash("A#1"), …). Что это даёт:
- Равномерность. Три случайные точки почти никогда не делят кольцо на три равные дуги, разброс в 2–3 раза тут обычное дело. При V = 100–200 разброс падает до единиц процентов.
- Каскада не будет. При отказе узла его 150 дуг распределяются между всеми оставшимися, а не сваливаются на одного.
- Взвешенность. Узлу с вдвое большей памятью даём вдвое больше виртуальных точек — разнородное железо загружается пропорционально.
Цена: V×N записей в памяти (100 узлов × 150 = 15 000, копейки) и чуть более долгий поиск.
«А если распределение всё равно перекошено, горячий ключ?» Консистентное
хеширование распределяет ключи, а не нагрузку: один суперпопулярный
ключ живёт на одном узле, и никакие виртуальные узлы не помогут. Лечится репликацией
горячего ключа на несколько узлов с суффиксом (key#1..key#K), локальным
кэшем перед распределённым или отдельной обработкой известных горячих ключей.
«А альтернативы?» Rendezvous hashing (HRW): берём узел с максимальным
hash(key, node). Перенос такой же минимальный, кольца
и виртуальных узлов нет, но поиск O(N). Jump consistent hash: O(1) памяти, идеальная
равномерность, но узлы можно менять только «с конца». Redis Cluster идёт третьим
путём: 16 384 фиксированных слота, которые вручную переназначают между узлами.
Те же виртуальные узлы, только управляют ими явно.
Забыть, что при перебалансировке кэша данные не переезжают физически: ключ просто начинает искаться на новом узле — а там его нет. Даже 1 % переезда даёт 1 % промахов, и об этом надо предупредить. Для хранилища (не кэша) всё серьёзнее: там переезд означает реальное копирование данных, и его надо делать фоново и с ограничением скорости, иначе ребаланс сам станет инцидентом. И ещё: консистентное хеширование не даёт репликации, за отказоустойчивость отвечает отдельный механизм (например, «ключ живёт на следующих R узлах по кольцу»).
Базовая реализация и её обязательные детали
SET lock:job42 <uuid> NX PX 30000 # NX = только если свободен, PX = TTL
# снимаем только свою блокировку, атомарно через Lua:
# if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) end
- TTL обязателен, иначе упавший держатель заблокирует ресурс навсегда.
- Уникальное значение обязательно, иначе процесс, чей TTL уже истёк, снимет блокировку, взятую другим.
- Продление (watchdog). Если работа может занять дольше TTL, фоновая горутина продлевает аренду; при неудачном продлении процесс обязан сам прекратить работу.
Фундаментальная проблема
P1 взял блокировку с TTL 30 с и застрял на 40 с — stop-the-world GC, своп, вытеснение, сетевая задержка. Блокировка истекла, P2 её взял и работает. P1 просыпается: с его точки зрения ничего не произошло, он «владеет» блокировкой и пишет в хранилище. Два процесса в критической секции. Никакой алгоритм блокировки этого не предотвратит, потому что нельзя остановить процесс, который считает себя владельцем, и нельзя узнать снаружи, что он застрял, а не умер.
Fencing token
Сервис блокировок выдаёт вместе с блокировкой монотонно возрастающий номер. Клиент передаёт его в защищаемый ресурс при каждой операции; ресурс помнит максимальный виденный номер и отвергает всё меньшее. P1 получил 33, P2 получил 34; после записи P2 запись P1 с номером 33 отвергается.
// UPDATE ... WHERE fence < $token: ресурс сам отсекает устаревшего владельца
res, _ := db.Exec(ctx,
`UPDATE jobs SET result = $1, fence = $2 WHERE id = $3 AND fence < $2`,
data, token, jobID)
if n, _ := res.RowsAffected(); n == 0 {
return ErrStaleLockHolder
}
Отсюда вывод: если ресурс не умеет проверять токен (обычный S3-бакет, сторонний API, отправка письма), полной защиты не существует, и это надо честно признать, а не делать вид, что «Redlock всё решает».
Redlock берёт блокировку на большинстве из N независимых Redis-мастеров. Критика Клеппманна: алгоритм опирается на ограниченность задержек и корректность часов, поэтому при паузе GC или скачке времени ломается так же, как одиночный Redis, давая ложное ощущение строгости. Формулировка, закрывающая вопрос: «Для эффективности (не делать одну работу дважды без большой беды) хватит одного Redis с TTL и уникальным значением. Для корректности (двойной запуск недопустим) нужен fencing token или перенос гарантии в сам ресурс: уникальный индекс, CAS по версии».
В большинстве задач она не нужна: уникальное ограничение в БД («эту джобу уже взяли»),
SELECT ... FOR UPDATE SKIP LOCKED для раздачи задач воркерам,
партиционирование по ключу (все операции по счёту X идут в один воркер через ключ
партиции), оптимистичная блокировка по версии. Фраза «сначала я попробую
спроектировать так, чтобы лок не понадобился» работает сильно: видно,
что кандидат понимает, насколько мало гарантий даёт распределённая блокировка.
Почему N/2+1
Два любых подмножества размера N/2+1 из одного множества обязательно
имеют общий элемент. Этот общий узел не проголосует дважды в одном терме и не примет
две разные записи на один индекс лога, поэтому split brain невозможен. Если бы
кворумом была ровно половина, при разрыве 2/2 обе половины «набрали бы кворум»
и выбрали разных лидеров.
| N | Кворум | Переживает отказов |
|---|---|---|
| 3 | 2 | 1 |
| 4 | 3 | 1 — толку как от трёх, латентность выше |
| 5 | 3 | 2 |
Отсюда правило нечётного числа узлов. И отсюда понятно, почему кластер из трёх,
потерявший два, перестаёт принимать запись, хотя один узел жив: он не может
доказать, что он не в меньшинстве. Это и есть CP-выбор на практике. В Dynamo-подобных
системах ту же идею записывают как W + R > N.
Raft по частям
- Терм нумерует эпохи монотонно; в терме не более одного лидера. Узел, увидевший больший терм, немедленно становится follower — так система вытесняет «зомби-лидера», вернувшегося после разрыва, без всякой ручной работы.
- Роли: follower, candidate, leader. Клиентские записи принимает только лидер.
- Выборы: у follower истёк случайный таймаут (150–300 мс) без heartbeat →
term++, стал candidate, голосует за себя, рассылаетRequestVote. Узел отдаёт голос, если ещё не голосовал в этом терме и лог кандидата не отстаёт от его собственного. Второе условие гарантирует, что лидером не станет тот, кто потерял закоммиченные записи. - Split vote: несколько кандидатов стартовали одновременно, голоса разделились, большинства ни у кого нет. Терм проходит впустую, таймауты истекают, начинается следующий. Именно из-за этого таймаут случайный: рандомизация делает повторное совпадение маловероятным.
- Репликация лога: запись попадает в лог лидера →
AppendEntriesна followers → реплицирована на большинство → committed → применена к машине состояний → ответ клиенту. Отвечать клиенту до коммита нельзя: незакоммиченные записи при смене лидера могут быть отброшены. - Свойство безопасности: закоммиченная запись присутствует в логах всех будущих лидеров. Следует это из правила голосования плюс пересечения кворумов.
Что из этого нужно прикладному разработчику
Не писать свой Raft, а уметь пользоваться готовым. Лидера для «одного cron
на кластер» выбирают так: аренда в etcd/Consul, Lease в Kubernetes (тот же механизм,
что у контроллеров), или строка в Postgres с TTL и FOR UPDATE.
Обязательные детали: аренда продлевается в фоне; при неудачном продлении процесс
сам складывает полномочия; вся защищаемая работа всё равно идемпотентна, потому что
кратковременное перекрытие двух лидеров возможно по тем же причинам, что и нарушение
распределённой блокировки.
«Возьмём кластер из двух узлов для надёжности» — два узла хуже одного: кворум 2, отказ любого останавливает всё. «Лидер выбран, значит всё консистентно»: нет, нужен ещё запрет отвечать на чтение с устаревшего лидера (в Raft это lease read или подтверждение лидерства кворумом, иначе старый лидер отдаст устаревшие данные). И третье: путать кворум записи с кворумом выборов. Это разные кворумы одного и того же множества, и безопасность даёт именно их пересечение.
Три семантики
- At-most-once: отправил и забыл, потери есть, дублей нет. Метрики, телеметрия.
- At-least-once: повторяем до подтверждения, потерь нет, дубли гарантированы. Это и дают на практике надёжные системы.
- Exactly-once как свойство сетевой доставки недостижимо (задача о двух генералах). Достижима effectively-once обработка.
Как делают на практике
- Идемпотентный эффект бьёт всё остальное.
UPSERTпо естественному ключу, переход состояния с условием, запись в S3 по детерминированному имени. Дубли безвредны by design, хранить идентификаторы не нужно вообще. - Таблица inbox / processed.
message_id PRIMARY KEY,INSERT ... ON CONFLICT DO NOTHINGв одной транзакции с эффектом. Если отметку писать в Redis, а эффект в Postgres — вернулся dual write, и гарантия исчезла. - Окно дедупликации. Хранить идентификаторы вечно нельзя: окно выбирают (сутки, неделя) по максимально возможной задержке повтора; за его пределами дубль пройдёт. Это ограничение надо называть вслух.
- Bloom-фильтр работает дешёвым предфильтром для больших потоков: «точно не видел» он отвечает мгновенно, «возможно видел» отправляет на точную проверку в БД. Ложноположительные срабатывания безопасны, ложноотрицательных не бывает.
Детали, которые проверяют
event_idдолжен быть детерминированным. Если продюсер при каждой попытке отправки генерирует новыйuuid.New(), дедупликация у потребителя не сработает никогда. Идентификатор рождается вместе с событием и не меняется при ретраях (в outbox это первичный ключ строки).- Порядок коммита смещения. Коммит после обработки = at-least-once (упали до коммита — будет повтор); коммит до обработки = at-most-once (упали после — будет потеря). Правильный выбор почти всегда первый + идемпотентная обработка.
- Kafka EOS (
enable.idempotence, транзакции,read_committed) закрывает схему consume–process–produce внутри Kafka: нет дублей при ретраях продюсера и атомарность «прочитал и записал». Но как только эффект уходит в твою БД, в письмо или во внешнее API, гарантия заканчивается и восстанавливается своими средствами.
«У нас exactly-once, мы включили транзакции в Kafka» — уточняющий вопрос «а письмо клиенту отправляется внутри транзакции Kafka?» ставит всё на места. Второй капкан ждёт тех, кто дедуплицирует в памяти процесса: при рестарте и при нескольких репликах это не работает. Третий: «мы дедуплицируем по содержимому». Два легитимных одинаковых события (два одинаковых платежа по 100 рублей) неотличимы от дубля, поэтому нужен явный идентификатор, а не хеш тела.
Почему физическому времени нельзя верить
- Дрейф. Кварц уходит на 10–100 ppm, то есть на секунды в сутки.
- Скачки при коррекции. NTP может перевести часы назад — и два последовательных события получат метки в обратном порядке. Отдельно стоит високосная секунда — она исторически роняла целые кластеры.
- Погрешность синхронизации держится в единицах миллисекунд внутри ДЦ и в десятках между ДЦ. Это больше длительности многих операций, поэтому разрешение «до миллисекунды» между узлами остаётся иллюзией.
- В Go: для длительностей использовать монотонную составляющую
(разность двух
time.Now()корректна даже при переводе часов), а метки в данных читать как «примерно» и корректность на них не строить.
Часы одного узла спешат на 200 мс, и его записи всегда «побеждают», а данные, записанные позже, молча пропадают. Это не гипотетика: так теряют данные в Cassandra при разъехавшемся NTP. Если конфликт разрешается по времени, корректность системы завязана на то, как администрируют время, а это плохое место для корректности.
Happened-before и логические часы
Отношение → задают три правила: события внутри процесса
упорядочены; отправка предшествует получению; отношение транзитивно. События,
не связанные им, конкурентны, и это объективное свойство, а не незнание.
- Часы Лэмпорта. Счётчик на процесс; перед событием
C++, при полученииC = max(C, C_msg) + 1. Свойство:a → bвлечётC(a) < C(b). Обратное неверно: по номерам нельзя понять, были ли события конкурентными. Даёт полный порядок (ничьи разрешаются по id узла), но не обнаруживает конфликты. - Векторные часы. Вектор из N счётчиков; при получении берут поэлементный максимум. Теперь конкурентность обнаруживается явно: если ни один вектор не меньше другого, это конфликт, и его можно отдать приложению (siblings в Dynamo/Riak) или разрешить структурой данных (CRDT). Платить приходится метаданными, они растут с числом узлов.
- Гибридные (HLC) комбинируют физическое время с логическим счётчиком: метки близки к реальному времени и при этом согласованы с причинностью. Используются в CockroachDB, YugabyteDB.
- В TrueTime у Spanner часы возвращают интервал с гарантированной границей ошибки, и коммит ждёт, пока интервал пройдёт. Настоящий порядок по времени покупается либо специальным железом, либо ожиданием.
Вопрос-детектор: «два сервиса пишут в общий лог с метками времени, восстановишь
порядок?» Правильный ответ: нет, если разница меньше погрешности синхронизации;
для порядка нужен trace_id со связями span→parent, логическая версия
или последовательный номер от общего источника (например, bigserial
одной БД или смещение в партиции Kafka). Второй: «как разрешаешь конфликт двух
одновременных записей?» Тут «по времени» плохо, а «по версии/векторным часам»,
«CRDT», «отдать конфликт в домен» хорошо. Третий, любимый в финтехе:
«клиент прислал операцию с меткой времени, верить?» Нет: время клиента говорит
не о порядке событий, а о том, что показывали часы клиента.
11.5Секция системного дизайна
Единственная секция, где проверяют не знания, а процесс мышления. Задача сформулирована нарочно расплывчато («спроектируй Twitter»): интервьюер смотрит, начнёшь ли ты рисовать кубики сразу или сначала выяснишь, что вообще надо построить. «Правильного» ответа тут нет, есть обоснованный: к каждому решению ты называешь цену и альтернативу.
Фреймворк: как вести диалог 45 минут
Дальше идут восемь шагов, и это инструкция, а не список тем. У каждого шага есть вопрос, на который он отвечает, и причина, по которой без ответа дальше идти нельзя. Шаг, который ни на какой вопрос не отвечает, пропусти и скажи об этом вслух; это сильнее, чем добросовестно проговорить пункт ради пункта.
Держи в голове примерный бюджет: 5–8 минут на требования, 5 на оценку нагрузки, 5 на API и модель данных, 15–20 на архитектуру и потоки, 10 на узкие места, отказоустойчивость и добивающие вопросы. Хуже всего провалить первые десять минут: неверно понятую задачу не спасут остальные тридцать пять.
Шаг 1. Уточнение требований (5–8 минут, не пропускать никогда)
Вопрос этого шага: что мы вообще строим и чего в этой версии точно не делаем? Интервьюер нарочно даёт неполное условие. Не додумывай за него молча, спрашивай. Заодно раздели вопросы на функциональные и нефункциональные и проговори, что именно ты исключаешь из скоупа.
| Функциональные | Нефункциональные |
|---|---|
| Кто пользователи и какие сценарии главные? Какие два-три сценария мы точно поддерживаем? | Сколько DAU/MAU? Какой рост ожидается за год? |
| Что не делаем в этой версии (модерация, аналитика, биллинг, мобильные пуши)? | Соотношение чтение/запись? Есть ли пики (вечер, чёрная пятница, матч)? |
| Нужна ли авторизация, роли, мультиарендность? | Целевая латентность p99 для чтения и записи? |
| Есть ли редактирование и удаление, или только создание? | Целевая доступность: 99.9 % (43 мин/мес) или 99.99 % (4.3 мин/мес)? |
| Нужна ли история/версионирование? | Насколько допустима eventual consistency? Где именно недопустима? |
| Каков жизненный цикл данных: хранить вечно или N месяцев? | Есть ли требования по географии (один регион или несколько), по закону (ПДн, аудит)? |
«Что здесь абсолютно недопустимо потерять или показать неверно?» Ответ на него задаёт всю архитектуру. Нельзя терять деньги: значит, транзакции, идемпотентность, сверка, CP-хранилище. Нельзя показывать устаревшую ленту: значит, кэш и AP. Ничего критичного нет? Строй в разы проще и скажи это вслух. Это сильный ход, а не слабость.
Шаг 2. Оценка нагрузки — вслух и с округлением
Вопрос этого шага: сколько это будет запросов, байтов и гигабайтов? Без чисел любое дальнейшее решение остаётся вкусовщиной: обосновать кэш, реплики или шардинг нечем. Считай на бумаге и грубо: цель не точность, а порядок величины, который определит, нужен ли шардинг и какого класса хранилище. Полезные округления: в сутках 86 400 ≈ 105 секунд, в году ≈ 3·107 секунд, 1 млн запросов в день ≈ 12 rps.
| Величина | Как считать | Пример: соцсеть, 10 млн DAU |
|---|---|---|
| Средний RPS | DAU × действий в день ÷ 86 400 | 107 × 20 ÷ 105 = 2 000 rps |
| Пиковый RPS | средний × 3…5 (суточный профиль), × 10 при событиях | 2 000 × 4 = 8 000 rps |
| Чтение/запись | спросить; для соцсетей 100:1, для логов 1:100 | записей ~80 rps, чтений ~7 900 rps |
| Размер записи | сложить поля честно, добавить 30–50 % на индексы и накладные | пост: 300 Б текст + 200 Б метаданных ≈ 0.5 КБ |
| Объём в день | записей в день × размер | 7·106 постов × 0.5 КБ ≈ 3.5 ГБ/день |
| Объём за год | ×365, плюс репликация ×3 | ≈ 1.3 ТБ/год, с репликами ≈ 4 ТБ |
| Трафик | RPS × размер ответа | 8 000 × 20 КБ ≈ 160 МБ/с ≈ 1.3 Гбит/с |
| Память под кэш | горячие 20 % данных, правило Парето | активные ленты: 106 × 50 КБ ≈ 50 ГБ |
Ради этих трёх выводов всё и считалось, и произнести их надо вслух: (1) влезают ли данные на один узел (до нескольких ТБ влезают, дальше шардинг); (2) справится ли одна БД с записью (тысячи rps записи уже граница, за которой нужен шардинг или другое хранилище); (3) нужен ли кэш (при чтении 100:1 — да, всегда). Если цифры показали, что хватает одной реплики Postgres, так и скажи: простое решение, обоснованное расчётом, ценится выше, чем Kafka «на всякий случай».
Шаг 3. Дизайн API
Вопрос этого шага: что именно клиент присылает и что получает в ответ? Именно
в API расплывчатые требования впервые превращаются в конкретику.
Пока не написан POST /v1/posts, слова «пользователь публикует пост» ничего
не значат; как только написан, сразу видно, чего не хватает во входных данных и какие
сценарии остались непокрытыми. Хватит 3–5 методов по главным сценариям — остальное
отнимет время у архитектуры.
Что проговорить вслух на этом шаге:
- Ресурсы и глаголы, коды ответов, формат ошибки. Единый конверт ошибки
(код, сообщение,
request_id) стоит дёшево, а замечают его всегда. - Пагинация курсором, а не offset:
?cursor=&limit=.OFFSET 100000на большой таблице гарантированно тормозит, а курсор ещё и устойчив к вставкам между страницами. - Идемпотентность там, где есть деньги или побочные эффекты: заголовок
Idempotency-KeyнаPOST. - Версионирование и совместимость (
/v1, аддитивные изменения). - Лимиты: максимальный
limit, ограничение размера тела, rate limit по ключу — если их нет, интервьюер обязательно заметит.
POST /v1/posts Idempotency-Key: <uuid> -> 201 {id, created_at}
GET /v1/feed?cursor=<c>&limit=20 -> 200 {items[], next_cursor}
GET /v1/posts/{id} -> 200 | 404
DELETE /v1/posts/{id} -> 204 | 404
Шаг 4. Модель данных и выбор хранилища
Вопрос этого шага: как эти данные будут читать и писать чаще всего? Спрашивать надо именно так, потому что ответ и определяет технологию. Отсюда и порядок работы: сначала сущности и связи, потом ключи доступа (по какому полю ищем, что сортируем, что нужно отдавать вместе) — и только потом выбор хранилища. Обратный порядок, когда сначала выбрали базу, а потом подгоняли под неё модель, чаще всего и приводит к тому, что через год всё переписывают.
- Вопросы к модели: какой ключ шардирования (и не создаст ли он горячие партиции), какие запросы должны быть быстрыми, где нужны транзакции, что можно денормализовать.
- Реляционная БД идёт по умолчанию: транзакции, джойны, ограничения целостности, понятная эксплуатация. Ответ «Начну с Postgres, потому что нагрузка это позволяет» звучит сильно, а не наивно.
- Key-value / wide-column (Redis, Cassandra, DynamoDB) берут, когда доступ строго по ключу, запись идёт потоком, а горизонтальный рост важнее джойнов.
- Поиск (Elasticsearch, OpenSearch) закрывает полнотекст и фасеты. Всегда как вторичный индекс, никогда как источник истины.
- Объектное хранилище (S3) держит файлы, картинки, видео, бэкапы. Метаданные в БД, байты в S3, и это почти всегда правильный ответ.
- Колоночное / OLAP (ClickHouse) считает аналитику и агрегаты по большим объёмам, отдельно от оперативного контура.
- Брокер (Kafka, RabbitMQ) — не хранилище, а транспорт и буфер; но Kafka с длинным retention заодно позволяет переиграть историю.
Шаг 5. Компоненты и потоки
Вопрос этого шага: что происходит с одним конкретным запросом от нажатия кнопки до ответа? Схема из кубиков сама по себе не доказывает ничего. Доказывает запрос, который ты по ней провёл: на этом проходе и вылезают недостающие компоненты. Поэтому рисуй не «кубики», а путь запроса. Пройди вслух два-три сценария от клиента до хранилища и обратно: что происходит при записи, что при чтении, что при отказе на каждом шаге. Стандартный набор слоёв: клиент → CDN → балансировщик → gateway (authn, rate limit) → сервисы приложения → кэш → хранилище, плюс сбоку брокер и асинхронные воркеры.
Сразу раздели синхронный путь (то, без чего клиенту нельзя ответить) и асинхронный хвост (всё остальное: индексация, уведомления, счётчики, аналитика). Приём простой, а половину вопросов про латентность снимает сразу.
Шаг 6. Узкие места и масштабирование
Вопрос этого шага: что сломается первым, если нагрузка вырастет в десять раз? Отвечать надо цифрами из шага 2, а не ощущениями, и на каждое лекарство сразу называть цену — интервьюер ждёт именно этого, а не перечня технологий.
- Найди узкое место по расчёту, а не по интуиции. Обычно это запись в БД, горячий ключ или один сервис на пути всех запросов.
- Порядок действий по стоимости: сначала кэш и индексы, потом реплики для чтения, потом вынос тяжёлого в асинхронный контур, и только потом шардинг. Шардинг стоит дороже всего, поэтому его называют последним и с обоснованием.
- Ключ шардирования разбирают по вариантам: по
user_id(равномерно, но кросс-пользовательские запросы дороги), по времени (удобно удалять старое, но последняя партиция горячая), по хешу (равномерно, но диапазонные запросы невозможны). - Горячие ключи стоят особняком: знаменитость с 50 млн подписчиков, товар дня, «магазин №1». Лечится репликацией ключа, локальным кэшем, отдельным путём обработки.
Шаг 7. Отказоустойчивость
Этот вопрос ты задаёшь к каждому нарисованному компоненту по очереди: «а что, если он сейчас упадёт?» Любой компонент на схеме однажды окажется недоступен, и схема из презентации отличается от схемы из эксплуатации ровно наличием ответа, а не красотой картинки. Минимальный набор тем, по которым ответ готов заранее: репликация и failover БД (и потеря данных при асинхронной репликации), несколько зон доступности, таймауты и ретраи с jitter, circuit breaker, деградация функциональности (лента без рекомендаций лучше, чем пустой экран), бэкапы и проверка восстановления, graceful shutdown и rolling update без потери запросов.
Шаг 8. Мониторинг
Вопрос этого шага: как ты узнаешь, что система сломалась, раньше пользователей?
Двух-трёх предложений достаточно, но они должны быть конкретными. RED для сервисов
(Rate, Errors, Duration), USE для ресурсов (Utilization, Saturation, Errors),
плюс бизнес-метрики, которые ловят то, чего не видят технические: «заказов
в минуту упало вдвое, а 5xx нет» и есть самый частый признак настоящего инцидента.
Дальше: распределённый трейсинг с trace_id сквозь все сервисы,
структурные логи, алерты на симптомы (SLO нарушается), а не на причины (CPU 80 %),
дашборд лага очередей и лага репликации.
- Начать с решения. Первые же слова: «поставим Kafka и Kubernetes», а что строим, ещё не выяснено. Мгновенный минус.
- Не задать ни одного вопроса. Молча проектировать по своей фантазии — значит решить не ту задачу.
- Не считать. Без цифр невозможно обосновать ни шардинг, ни кэш, ни выбор хранилища; всё превращается в перечисление модных слов.
- Оверинжиниринг. Микросервисы, CQRS, event sourcing и три вида кэша для сервиса на 100 rps. Интервьюер проверяет чувство меры не меньше, чем знания.
- Молчать и думать про себя. Оценивают ход рассуждения; неозвученные мысли не существуют. Проговаривай варианты, даже отвергаемые.
- Не называть цену решений. Каждое «добавим кэш» закрывай фразой «ценой будет инвалидация и окно устаревания».
- Спорить с интервьюером или упираться в первую идею. На «а если так?» надо обдумать и, если аргумент верный, вслух согласиться и поменять решение. Это плюс, а не минус.
- Игнорировать отказы. Схема без ответа на «что упадёт и что тогда» остаётся схемой из презентации, а не из эксплуатации.
- Уйти в детали слишком рано. Полчаса про формат JSON, когда не нарисована архитектура. Держи уровень абстракции и опускайся по запросу.
- Забыть про эксплуатацию: миграции, деплой, мониторинг, стоимость железа. Одно предложение про каждое сильно поднимает впечатление.
Вопросы
12Требования и расчёт
- Функционально: создать короткую ссылку (опционально свой алиас и TTL), перейти по ней, посмотреть статистику. Не делаем: аналитику в реальном времени, A/B, биллинг.
- Нефункционально: для чтения латентность p99 меньше 50 мс и доступность 99.99 % (редирект лежит на критичном пути), запись может быть медленнее.
- Цифры: 100 млн новых ссылок в месяц ≈ 40 записей/с; чтение к записи 100:1 → 4 000 чтений/с, пик ×4 ≈ 16 000 rps. Хранение: 500 Б на запись × 100 млн × 12 мес ≈ 600 ГБ/год — влезает в один сервер, шардинг пока не нужен. Горячие 10 % ссылок дают ~90 % трафика → кэш на 20–50 ГБ закрывает почти всё.
Генерация ключа: счётчик + base62 против хеша
Длина ключа: base62 (a–z, A–Z, 0–9) даёт 627 ≈ 3.5·1012 — семи символов хватит на десятилетия.
| Счётчик + base62 | Хеш от URL (MD5/SHA, первые N символов) | |
|---|---|---|
| Коллизии | невозможны — счётчик уникален по построению | парадокс дней рождения: при 109 ссылок и 7 символах коллизии неизбежны, нужна проверка и повтор |
| Одинаковый URL дважды | две разные короткие ссылки (обычно это и нужно — своя статистика) | одна и та же ссылка (иногда плюс, иногда утечка приватности) |
| Предсказуемость | ключи идут подряд — можно перебрать чужие ссылки | непредсказуемо |
| Узкое место | сам счётчик | нет, но есть проверка коллизий = лишнее чтение |
На практике берут счётчик, а его узкое место снимают так: не ходить в общий счётчик
на каждую ссылку, а выдавать инстансам диапазоны (range allocator: сервис берёт
блок из 10 000 идентификаторов одним INCRBY в Redis или
UPDATE ... RETURNING в БД и раздаёт локально). Уходит и сетевой хоп,
и единая точка отказа на горячем пути. Предсказуемость лечим перемешиванием
битов перед кодированием (обратимая перестановка, Feistel-шифр на 41 бите: 2 в сорок второй уже больше, чем 62 в седьмой) —
ключи остаются уникальными, но перестают идти подряд. Без общего
счётчика можно обойтись и вовсе, взяв Snowflake-подобный id (время + id инстанса + последовательность).
Хранилище и кэш
- Модель тривиальна:
short_key PRIMARY KEY, long_url, user_id, created_at, expires_at. Доступ строго по первичному ключу, идеальный кандидат для key-value, но Postgres на 600 ГБ и 40 записях/с справится без вопросов, и начинать стоит с него. - Кэш: Redis, ключ → URL, TTL несколько часов, LRU. Хитрейт 90+ % за счёт распределения Ципфа. При промахе идём в БД и прогреваем кэш (single-flight, чтобы 1000 параллельных запросов на один холодный ключ не устроили stampede).
- Записи иммутабельны, и это сильно упрощает жизнь: нет инвалидации кэша, можно кэшировать сколь угодно агрессивно, можно раздавать через CDN.
301 или 302 — вопрос с подвохом
301 (Moved Permanently) кэшируется браузером надолго: следующий переход пойдёт мимо твоего сервиса. Меньше нагрузки, но ты теряешь статистику кликов и не можешь изменить или отозвать ссылку — браузер уже никогда не спросит. 302 (Found) не кэшируется: каждый переход приходит к тебе, статистика полная, ссылку можно отключить. Правильный ответ: 302 по умолчанию, потому что бизнес-ценность продукта держится на статистике и управлении ссылками; 301 остаётся опцией для тех, кому нужна максимальная скорость и не нужна аналитика. Отдельная тонкость: для редиректа корректнее 307/308, если важно сохранить метод и тело запроса, но для ссылок это несущественно.
Счётчики кликов
Инкремент строки в БД на каждый клик убивает сервис на первой же горячей ссылке. Правильный путь: редирект отвечает сразу, а событие клика уходит в брокер; отдельный потребитель агрегирует и пишет в аналитическое хранилище (ClickHouse) или инкрементит счётчик в Redis с периодическим сбросом в БД. Точность «примерно» здесь допустима, и это надо проговорить как осознанный выбор.
«Кастомный алиас?» — уникальность проверяем отдельно, через
INSERT ... ON CONFLICT, и алиасы живут в том же пространстве ключей,
чтобы не пересечься со сгенерированными (например, генерируемые всегда длиной 7,
кастомные от 8, или отдельный префикс). «Удаление и TTL?» — поле
expires_at, фоновая чистка партициями по дате, кэш с тем же TTL.
«Защита от злоупотреблений?» — rate limit на создание, проверка URL
по спискам вредоносных, запрет рекурсии (короткая ссылка на короткую ссылку).
«Мультирегион?» — ссылки иммутабельны, поэтому чтение
реплицируется в каждый регион и раздаётся локально; запись оставляем
в одном регионе, диапазоны id при этом расходятся по регионам без конфликтов.
Требования
- Ограничивать по разным ключам: API-ключ,
user_id, IP, эндпоинт, и по комбинациям. Разные лимиты для разных тарифов. - Проверка должна укладываться в единицы миллисекунд, иначе лимитер сам станет узким местом.
- Точность: «примерно» приемлемо; распределённый лимитер, который гарантирует строгое «не более 100», требует согласования и стоит дороже, чем польза.
- Ответ при превышении: 429 плюс заголовки
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-ResetиRetry-After— без них клиенты будут долбить в цикле.
Алгоритмы
| Алгоритм | Как работает | Плюсы / минусы |
|---|---|---|
| Fixed window | счётчик на окно (минуту), сброс по границе | проще всего; всплеск на границе: 100 в конце минуты + 100 в начале = 200 за секунду |
| Sliding window log | хранить метки времени всех запросов, считать те, что в окне | точно; память O(запросов) — дорого на больших лимитах |
| Sliding window counter | текущее окно + взвешенный остаток предыдущего | дефолтный выбор: почти точно, память O(1), нет проблемы границы |
| Token bucket | токены капают с постоянной скоростью, запрос забирает токен, ёмкость ограничена | разрешает контролируемый всплеск (накопленные токены) — идеален для API |
| Leaky bucket | очередь с постоянной скоростью вытекания | сглаживает трафик до ровного; всплески не пропускает вовсе |
Практический выбор: token bucket, если хочется разрешать короткие всплески (нормальное поведение реальных клиентов), sliding window counter, если важна предсказуемость. Оба делаются в Redis двумя-тремя полями и Lua-скриптом.
# token bucket в Redis: атомарно через Lua (иначе гонка между GET и SET)
# KEYS[1] = bucket:user:42 ; ARGV = rate, capacity, now, cost
# HMGET tokens last_refill -> долить (now-last)*rate, обрезать по capacity
# если tokens >= cost: списать, вернуть 1 ; иначе вернуть 0 и время до пополнения
EVALSHA <sha> 1 bucket:user:42 10 100 1730000000 1
Архитектура
- Где принимается решение. На gateway/edge — как можно раньше, чтобы отброшенный запрос не тратил ресурсы. Отдельный «сервис лимитера» добавляет сетевой хоп и на горячем пути только вредит; лучше библиотека внутри gateway плюс общий Redis.
- Локальный слой + глобальный. Каждый инстанс держит локальный счётчик и синхронизируется с Redis не на каждом запросе, а пачками или по «квоте» (инстанс берёт из общего лимита долю и расходует локально). Сетевой вызов уходит из горячего пути ценой небольшой неточности.
- Хранилище. Redis (в идеале рядом, в том же регионе). Для мультирегиона держат лимиты по регионам, а не глобальный счётчик: кросс-региональная синхронизация на каждый запрос невозможна по латентности.
- Конфигурация лимитов меняется динамически, без релиза: правила в конфиг-сервисе с горячей перезагрузкой, потому что лимиты меняют чаще, чем код.
Fail open, то есть пропускать запросы. Лимитер прикрывает сервис от злоупотреблений, в функциональность продукта он не входит; отказ лимитера не должен превращаться в отказ сервиса. При этом переход в fail open должен быть заметным (метрика + алерт) и, желательно, деградированным: локальный счётчик на инстансе с грубым лимитом продолжает работать и защищает от совсем уж дикого трафика. Fail closed оправдан только там, где превышение лимита само по себе опасно: попытки ввода пароля, дорогая внешняя операция. Здесь и проверяют, назовёшь ли ты оба варианта и обоснуешь ли выбор.
Неатомарная проверка: GET, потом INCR — при конкуренции
лимит протекает; нужен Lua или INCR с EXPIRE в одной транзакции.
Лимит только по IP — за NAT сидит целый офис, а злоумышленник меняет адреса;
нужен многоуровневый ключ. Без Retry-After клиенты начинают
ретраить синхронно и устраивают storm. И забыть, что 429 тоже стоит денег:
если каждый отброшенный запрос проходит authn и три сетевых хопа, злоумышленник
всё равно тебя утопит, так что отбрасывать надо как можно раньше и дешевле.
Расчёт, который определяет выбор
- 10 млн DAU, каждый заходит 10 раз в день → ~1 200 чтений ленты/с, пик ×4 ≈ 5 000.
- Постов: 1 млн в день → ~12 записей/с. Соотношение чтение/запись ≈ 100:1, значит, оптимизировать надо чтение.
- Средний подписчик: 200 фолловеров. При fanout on write один пост порождает 200 вставок → 12 × 200 = 2 400 вставок/с — вполне подъёмно.
- А теперь знаменитость с 50 млн подписчиков: один твит = 50 млн вставок. При 10 000 вставок/с это полтора часа на один пост, и всё это время часть подписчиков его не видит. Вот здесь чистый push и ломается.
- Хранение лент: 10 млн активных × 500 записей × 100 Б ≈ 500 ГБ в Redis — дорого, поэтому материализуют ленту только для активных пользователей (заходил за последние N дней) и ограничивают глубину.
Гибрид в деталях
- Порог, скажем, 10 000 подписчиков. Ниже него работает fanout on write в Redis-список
feed:{user_id}(обрезанный до 500–1000 записей, хранятся только id постов). - Выше порога не разносим вообще. При чтении: берём готовую ленту, отдельно читаем свежие посты тех знаменитостей, на кого подписан читатель (их обычно единицы-десятки и они прекрасно кэшируются), сливаем по времени/ранжированию.
- Fanout выполняется асинхронно воркерами из очереди, публикация поста не ждёт разноса. Для активных подписчиков приоритет выше: те, кто онлайн, получают пост за секунды, остальные — за минуты.
- Материализуем ленты только активных пользователей. Для вернувшегося через полгода лента собирается на лету и потом материализуется.
Дополнительные вопросы, которые почти всегда задают
- Удаление поста. Из миллионов лент его не вычищают, а фильтруют при чтении по «удалённым» (проверка id по множеству удалённых или по флагу в кэше постов).
- Ранжирование вместо хронологии. Тогда лента собирается из кандидатов и скоринга на чтении, и push вырождается в «список кандидатов», а не в готовую ленту.
- Согласованность. Лента eventual по определению: подписался — новые посты появятся, старые подтянутся отдельным процессом. Это нормально, и это надо назвать компромиссом, а не багом.
- Хранилище постов берут key-value или шардированное по
post_id; ленты живут в Redis; связи подписок лежат отдельно, шардированные поfollower_idдля «на кого подписан» и поfollowee_idдля «кто подписан» (две денормализованные таблицы, потому что нужны оба направления).
Транспорт
- WebSocket берут по умолчанию: двунаправленно, низкие накладные, работает через браузер. Альтернативы: SSE (только сервер→клиент, зато проще и переживает прокси), long polling (fallback), для мобильных — пуш-каналы (APNs/FCM), когда приложение в фоне и соединение держать нельзя.
- Слой соединений отдельный от бизнес-логики. Gateway держит сокеты и не знает домена; логика — в сервисах. Так логику можно деплоить, не рвя миллионы соединений.
- Реестр присутствия:
user_id → gateway_idв Redis с TTL, обновляется heartbeat. Чтобы доставить сообщение, сервис смотрит реестр и шлёт на нужный gateway (напрямую или через топик/канал поgateway_id). - Масштаб: одна Go-машина спокойно держит сотни тысяч соединений (~2–10 КБ на соединение при аккуратных буферах), так что 10 млн онлайна закрываются десятками узлов, а не тысячами. Узкое место обычно не CPU, а память под буферы и количество файловых дескрипторов.
Путь сообщения и подтверждения
- Клиент отправляет сообщение с собственным
client_msg_id(UUID), и это ключ идемпотентности: повтор после обрыва не создаст дубль. - Сервис сначала персистит сообщение и присваивает ему серверный
порядковый номер
seqв пределах диалога, потом отвечает отправителюack: sent. Порядок «сохранить → подтвердить» принципиален: подтверждать до записи — значит терять сообщения при падении. - Дальше доставка получателям. Онлайн идёт через gateway, а для оффлайна сообщение
просто лежит в истории, и клиент заберёт его при следующем подключении по своему
last_seen_seq. - Получатель присылает
delivered, при открытии чата —read. Оба статуса тоже сообщения, и они тоже могут потеряться, поэтому хранятся как «максимальный подтверждённый seq на пользователя», а не как флаг на каждом сообщении: так они идемпотентны и компактны.
Часы клиентов расходятся на минуты, и сортировка по ним даёт перепутанный диалог.
Порядок задаёт сервер: монотонный seq в пределах чата
(счётчик на диалог: INCR в Redis или
bigserial/последовательность на шарде чата). Тогда клиент всегда может
сказать «у меня есть до 417, дай дальше», а дырка в нумерации сразу сигналит о потере.
Глобальный порядок между разными чатами не нужен, и это очень удобно: шардирование
по chat_id становится естественным.
Хранение истории
- Паттерн доступа один: «последние N сообщений чата» и «сообщения чата после seq».
Это идеальный кандидат для wide-column хранилища: ключ партиции
chat_id, кластеризация поseq DESC(Cassandra/ScyllaDB) или Postgres, шардированный поchat_id, с партиционированием по времени. - Расчёт: 10 млн DAU × 50 сообщений/день = 500 млн сообщений/день × 300 Б ≈
150 ГБ/день. Значит, нужны политика хранения (горячие последние месяцы,
холодное — в дешёвое хранилище) и обязательное партиционирование по времени,
чтобы удалять срезанием партиций, а не
DELETE. - Медиа не хранятся в БД: файл в S3, в сообщении — ссылка и метаданные.
- Групповые чаты: не копировать сообщение каждому участнику (fanout on write), а хранить один раз в чате и держать на пользователя только курсор прочитанного. Копирование оправдано только при малом размере групп и жёстком требовании к скорости чтения.
Онлайн-статусы
Самая недооценённая часть: наивное «рассылать всем контактам при каждом изменении» даёт квадратичный трафик. На практике: статус в Redis с коротким TTL, обновляемый heartbeat; рассылка изменений только тем, кто сейчас смотрит на этого пользователя (открыт чат или список); агрегация и throttling («не чаще раза в 10–30 секунд»); «был в сети недавно» с огрублением вместо точной секунды, что и дешевле, и приватнее.
«Переподключение?» Клиент хранит last_seq по каждому чату,
при коннекте синхронизируется запросом «дай всё после N»; экспоненциальный backoff
с jitter, иначе после перезапуска gateway миллион клиентов вернётся одновременно
и положит его снова. «Сквозное шифрование?» Тогда сервер не может ни
искать, ни модерировать, а история восстанавливается только на устройствах;
выбор архитектурный, с большими последствиями, и назвать его стоит.
«Как деплоить gateway, не рвя соединения?» Drain: перестать принимать
новые, попросить клиентов переподключиться порциями, дать время на миграцию.
Разорвёшь все сразу, получишь thundering herd.
Компоненты
- Приём. API или подписка на события домена. Вход всегда несёт
event_idдля дедупликации иuser_id. - Движок правил / preferences. Решает, каким каналам слать (push, email, SMS, in-app, webhook), и смотрит на подписки пользователя, отписки, язык, часовой пояс, тихие часы и глобальные настройки типа уведомления. Здесь же живут отписка и юридические требования: для email ссылка отписки обязательна.
- Шаблонизатор. Отдельно от логики: текст, локализация, версии шаблонов. Шаблон в коде обернётся болью при первой же правке маркетологом.
- Очереди по каналу и приоритету. Транзакционные («код подтверждения», «платёж не прошёл») идут в отдельную очередь с высоким приоритетом; маркетинговые в свою, и её можно тормозить или дропать при перегрузке.
- Воркеры-адаптеры к провайдерам (APNs, FCM, SMTP/SES, SMS-агрегатор). У каждого свой пул (bulkhead), свой rate limit под квоту провайдера, свой circuit breaker.
- Трекинг. Статусы:
queued → sent → delivered → opened → failed, вебхуки от провайдеров, отдельная обработка «жёстких» отказов: по невалидному токену удаляем устройство, по bounce email помечаем адрес.
Надёжность и дедупликация
- Ретраи с экспонентой и jitter, разные политики по каналам: push дешёвый — можно повторять активно; SMS стоит денег, поэтому число попыток ограничено, а лимит расходов обязателен.
- Ключ дедупликации =
(user_id, event_id, channel)в Redis/БД с TTL. Без него at-least-once доставка событий превращается в «пять одинаковых пушей подряд», и это самая заметная пользователю поломка. - Дедупликация по смыслу, не только по id: «10 человек лайкнули ваш пост» сворачивается в одно уведомление в окне (например, 15 минут) вместо десяти пушей. Требование продуктовое, но живёт в этой же системе.
- Rate limit на пользователя: не больше N уведомлений в час независимо от того, сколько событий произошло. Защищает и пользователя, и репутацию отправителя.
- DLQ для окончательно неудавшихся: оттуда их можно разобрать и отправить заново.
Тихие часы и планирование
Тихие часы считаются в часовом поясе пользователя, а не сервера. На этом ошибаются постоянно. Внутри окна тихих часов уведомление не выбрасывается, а откладывается до открытия окна (это ровно задача планировщика отложенных задач из вопроса 9). Транзакционные и security-уведомления («вход с нового устройства») проходят всегда, и список исключений задаётся типом уведомления, а не отдельным флагом на каждом сообщении.
Отдельная тонкость масштаба: массовая рассылка на 10 млн пользователей не уходит одним залпом. Её разбивают на пачки и притормаживают (и по квоте провайдера, и по нагрузке на свои сервисы, потому что после пуша все эти люди одновременно откроют приложение, а вторичный пик на бэкенде часто больше, чем сама рассылка).
«Отправим прямо из сервиса заказов» — упал SMTP, и заказы перестают создаваться. Уведомления всегда за очередью. Второе: не разделили транзакционные и маркетинговые, маркетинговая рассылка забивает очередь, и код подтверждения приходит через двадцать минут, когда он уже протух. И третье: забыть про обратную связь от провайдера. Никто не разбирает bounce и невалидные токены — и система годами долбит мёртвые адреса и попадает в спам-листы.
Загрузка
- Клиент запрашивает
POST /uploadsс именем, размером, типом и (желательно) хешем содержимого. - Бэкенд проверяет права, квоту, лимит размера и допустимый тип, создаёт запись
метаданных со статусом
PENDINGи выдаёт presigned URL с коротким TTL (минуты) и жёсткими условиями (максимальный размер,Content-Type, конкретный ключ объекта). - Клиент заливает напрямую в S3. Твои серверы не видят ни байта — это экономит трафик, память и снимает проблему «файл на 5 ГБ через Go-хендлер».
- Подтверждают двумя способами: либо клиент дергает
POST /uploads/{id}/complete, либо (надёжнее) бэкенд слушает событие от хранилища о появлении объекта. Второе устойчиво к тому, что клиент закрыл вкладку. Статус переходит вREADY. - Фоном: антивирус, извлечение метаданных, превью и транскодирование. Всё через очередь, не в момент загрузки.
- Уборка мусора: объекты, оставшиеся в
PENDINGдольше суток, удаляются (lifecycle policy в бакете + сверка с метаданными). Без этого хранилище копит недозагруженные файлы, за которые ты платишь.
Большие файлы: чанки
- Multipart upload: файл режется на части (обычно 5–100 МБ), каждая
заливается по своему presigned URL, в конце вызывается
completeсо списком частей и их ETag. - Что это даёт: возобновление после обрыва (перезалить одну часть, а не весь файл), параллельность (несколько частей одновременно, на длинных RTT это заметно быстрее), обход лимита на размер одного запроса.
- Клиент хранит состояние загрузки (какие части уже приняты), чтобы после перезапуска продолжить. Незавершённые multipart-загрузки тоже надо чистить политикой жизненного цикла, иначе за них капает счёт.
Дедупликация по хешу
Считаем SHA-256 содержимого (на клиенте, тогда можно вообще не заливать, или на сервере при обработке). Ключ объекта = хеш, метаданные ссылаются на него. Если файл с таким хешем уже есть, загрузка проходит мгновенно, место не тратится. Три обязательные оговорки: (1) нужен счётчик ссылок или отложенное удаление, иначе один пользователь удалит файл и сломает его у другого; (2) «мгновенная загрузка» по хешу без проверки владения открывает уязвимость: зная хеш, можно получить чужой файл, поэтому доступ проверяется по метаданным, а не по факту совпадения хеша; (3) для шифрованного на клиенте контента дедупликация не работает в принципе.
Раздача
- CDN перед хранилищем: файлы иммутабельны (ключ содержит хеш или версию),
поэтому кэшируются вечно с
Cache-Control: public, max-age=31536000, immutable. Обновление файла = новый ключ, а не инвалидация. - Приватный контент раздаётся подписанными URL от CDN с коротким TTL и (для важного) привязкой к IP или подписью в куке. Не «секретная неугадываемая ссылка», если контент действительно приватный: такие ссылки утекают через реферер, мессенджеры и историю.
- Range-запросы для видео и докачки,
ETag/If-None-Matchдля условных запросов. - Стоимость: исходящий трафик обычно самая дорогая строка счёта. CDN берут ради скорости, но окупается он тем, что кэш-хит дешевле обращения к хранилищу.
Проксировать файлы через свой сервис («так удобнее проверять права») — и упереться
в трафик и память на первом же росте. Права проверяются при выдаче ссылки,
а не при передаче байтов. Второе: presigned URL без ограничения размера
и Content-Type, клиент зальёт что угодно и сколько угодно.
Третье — доверять расширению файла и Content-Type от клиента:
тип проверяют по сигнатуре содержимого на стороне сервера, иначе получишь
HTML с JS, отданный с твоего домена. И четвёртое: забыть про уборку недозагруженных
объектов и незавершённых multipart.
Требования и расчёт
- p99 меньше 50–100 мс, иначе подсказки не успевают за печатью и бесполезны.
- Нагрузка обманчиво большая: 10 млн поисков в день × ~5 нажатий = 50 млн запросов в день ≈ 600 rps средних, пик ×5 ≈ 3 000. Debounce 100–150 мс на клиенте режет это в 2–3 раза бесплатно.
- Свежесть: для большинства запросов обновление раз в час-сутки нормально; для трендов («новость дня») нужен отдельный быстрый путь.
Структура данных
- Trie (префиксное дерево). Путь по дереву соответствует префиксу. Наивный вариант «дойти до узла и обойти всё поддерево» слишком медленный: у популярного префикса «а» миллионы листьев.
- Трюк, который и есть ответ: в каждом узле заранее хранится готовый топ-K (K = 5–10) завершений с их весами. Тогда запрос сводится к спуску по префиксу за O(len(prefix)) и возврату готового списка. Память растёт (K строк в каждом узле), зато чтение становится тривиальным.
- Сжатие: radix tree (объединение цепочек из одного потомка) уменьшает число узлов в разы; для очень больших словарей берут succinct-структуры или FST (так устроен Lucene).
- Где живёт: целиком в памяти сервиса, реплики читают одну и ту же
иммутабельную версию. Альтернатива без trie: Redis sorted set на префикс
(
ZREVRANGE pfx:{prefix} 0 9) — проще в эксплуатации, но памяти больше и обновлять тяжелее.
Обновление
- Логи запросов → агрегация за окно (час/сутки) в частоты → фильтрация (стоп-слова, мат, персональные данные, редкие «хвосты», запросы с нулевым результатом) → пересборка структуры.
- Пересобирать целиком и подменять атомарно, а не мутировать на месте: новая версия trie строится офлайн, выкатывается на реплики, переключается указателем. Это снимает все проблемы блокировок и даёт мгновенный откат.
- Веса считаются не по одной частоте: свежесть (экспоненциальное затухание старых периодов), персонализация и география ложатся отдельными слоями поверх базового топа, чтобы не пересобирать всё под каждого пользователя.
- Тренды держат в отдельном маленьком «горячем» слое в Redis, который сливается с основным результатом на чтении. Свежесть появляется без пересборки основной структуры.
Кэш и клиент
- Кэш префиксов на стороне сервиса: короткие префиксы (1–3 символа) дают подавляющую долю запросов и почти не меняются, поэтому их ответы кэшируются с большим TTL. Распределение резко Ципфовское, и небольшой кэш даёт очень высокий хитрейт.
- На клиенте: debounce (не слать на каждый символ), отмена предыдущего запроса, локальный кэш уже полученных префиксов (пользователь набрал «моск», а ответ для «мос» у клиента уже есть, и он отфильтрует локально), плюс подстраховка от гонки: ответы приходят не в том порядке, и показывать надо только ответ на актуальный префикс.
«Опечатки?» Расстояние Дамерау—Левенштейна на уровне 1–2: обход trie
с допуском ошибок или отдельный индекс n-грамм.
Полноценный нечёткий поиск в реальном времени дорог, поэтому обычно ограничиваются
известным списком популярных опечаток. «Многоязычность и раскладка?» —
нормализация (регистр, диакритика), транслитерация и «неправильная раскладка»
(ghbdtn → привет) отдельными путями поиска. «Персонализация?»
Личная история идёт небольшим отдельным источником и сливается с общим топом
на чтении; персональный trie на каждого пользователя не строят.
Модель: платёж — конечный автомат
CREATE TABLE payments (
id uuid PRIMARY KEY,
idempotency_key text NOT NULL,
user_id bigint NOT NULL,
amount bigint NOT NULL, -- только целые: копейки/центы
currency char(3) NOT NULL,
state text NOT NULL, -- CREATED|AUTHORIZED|CAPTURED|FAILED|REFUNDED
provider_ref text, -- id операции на стороне провайдера
version int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, idempotency_key) -- защита от двойного создания
);
Три вещи, которые надо сказать про модель. Деньги считаем целым числом
в минимальных единицах: float для денег сразу поднимает красный
флаг. Переходы только вперёд и только условные:
UPDATE ... WHERE id=$1 AND state='CREATED' делает повторы
идемпотентными. Двойная запись для баланса:
серьёзные системы хранят не «баланс», а неизменяемый журнал проводок
(ledger), где на каждую операцию приходится пара дебет/кредит,
а баланс выводится из них. Тогда история восстановима и расхождения ловятся арифметикой.
Поток
- Вход:
POST /paymentsсIdempotency-Key. Повтор с тем же ключом возвращает тот же платёж, а не создаёт новый. Ключ и платёж создаются одной транзакцией. - Только вызов провайдера неидемпотентен изначально.
Передаём провайдеру свой ключ идемпотентности (нормальные провайдеры
это поддерживают), сохраняем
provider_ref. - Неопределённость. Если ответ не пришёл, нельзя считать платёж неуспешным: он мог пройти. Состояние остаётся «в процессе», и стартует опрос статуса у провайдера по своему ключу, пока тот не ответит определённо. Здесь и видно разницу между продуманным дизайном и наивным.
- Вебхуки от провайдера подтверждают операцию асинхронно. Обязательно: проверить подпись, дедуплицировать по id события, обработать идемпотентно и выдержать любой порядок (вебхук «captured» может прийти раньше, чем ответ на исходный запрос).
- Outbox: смена состояния платежа и событие для остальной системы
(
payment.captured) пишутся одной транзакцией; релей публикует в брокер. Никаких прямых вызовов «сообщить сервису заказов» из транзакции. - Сага для составных сценариев («оплата + резерв товара + бонусы»): оркестратор с персистентным состоянием, компенсации (возврат оформляется новой операцией, а не откатом), необратимые шаги в конце.
Сверка (reconciliation) — обязательная часть, а не опция
Любая распределённая система рано или поздно расходится с внешним контуром. Ежедневный (а для крупных — ежечасный) процесс выгружает реестр операций провайдера и сравнивает с собственной базой по трём признакам: есть у нас — нет у них (мы считаем платёж успешным, а денег нет), есть у них — нет у нас (деньги списаны, а заказ не создан — самое опасное), суммы или статусы различаются. Расхождения попадают в отдельную очередь ручного разбора со своим SLA. Кто называет сверку без подсказки, тот с деньгами работал, а не читал про них.
Аудит: неизменяемый журнал всех изменений состояния — кто, когда, по какому запросу; требование регуляторов и главный инструмент разбора инцидентов. Хранение реквизитов: не хранить вовсе — токенизация на стороне провайдера (PCI DSS); фраза «номер карты у нас не появляется» закрывает половину вопросов. Тайм-ауты и статусы: у каждого «ожидающего» состояния свой предельный срок, после которого платёж принудительно проверяется или отменяется. Мониторинг бизнес-метрик: конверсия авторизаций по провайдеру и по банку. Её падение видно раньше, чем ошибки в логах.
Считать деньги в float. Использовать таймаут как признак отказа
(«не ответил — значит, не прошло»). Не завести ключ идемпотентности на входе:
клиент нажал «оплатить» дважды, и списалось дважды. Обрабатывать вебхук
неидемпотентно — провайдер повторит его, и возврат уйдёт дважды. И самое частое:
не подумать, что делать, если деньги списались, а бизнес-операция не выполнилась.
Правильный ответ включает и автоматическую компенсацию, и ручной разбор
как терминальный путь.
FOR UPDATE SKIP LOCKED
(надёжно, транзакционно, миллиарды), timing wheel в памяти (точность до миллисекунд,
но только для ближнего горизонта). Промышленный ответ — двухуровневая схема:
БД как долговременное хранилище, память как точный таймер на ближайшую минуту.Требования, от которых зависит выбор
- Точность: «примерно через час» (планировщик раз в минуту) или «ровно в 12:00:00.000» (таймер в памяти). Разница в стоимости на порядок.
- Горизонт: минуты, дни или годы вперёд (напоминания, подписки).
- Объём: тысячи задач или сотни миллионов «висящих» одновременно.
- Гарантии: at-least-once (норма) или дубли недопустимы; переживает ли задача рестарт; можно ли её отменить и перепланировать.
- Профиль: равномерно или пиками (всё на «ровно 9:00» случается очень часто и ломает наивные реализации).
Три подхода
| Подход | Как | Плюсы | Минусы |
|---|---|---|---|
| Redis ZSET | score = время запуска; воркер делает ZRANGE due -inf <now> BYSCORE и атомарно забирает | просто, быстро, точность до секунды | надёжность = надёжность Redis; память под все задачи; забор должен быть атомарным (Lua), иначе дубли |
| Таблица в БД | SELECT ... WHERE run_at <= now() AND state='SCHEDULED' ORDER BY run_at LIMIT 100 FOR UPDATE SKIP LOCKED | транзакционно с бизнес-данными, переживает всё, отмена и перепланирование тривиальны, миллиарды строк на партициях | точность = интервал опроса; нагрузка на БД; нужен вакуум/партиционирование |
| Timing wheel | кольцевой массив слотов, тик двигает указатель; задача кладётся в слот по остатку от деления | O(1) на добавление и на срабатывание, миллионы таймеров, точность до тика | в памяти → теряется при рестарте; горизонт ограничен размером колеса (иерархические колёса снимают это частично) |
Есть ещё готовые механизмы брокеров: delayed exchange в RabbitMQ,
DLX с TTL, SQS DelaySeconds (до 15 минут), scheduled messages в Pulsar.
Они удобны для коротких задержек и плохи для длинных горизонтов и для отмены —
сообщение, уже лежащее в очереди с задержкой, обычно нельзя ни отменить,
ни перепланировать.
Детали, за которые ставят плюс
- Claim с TTL, а не просто статус. Забирая задачу, воркер пишет
claimed_until = now() + interval '5 min'; фоновый процесс возвращает вSCHEDULEDвсё, у чего срок истёк. Иначе упавший воркер забирает задачу с собой навсегда. - Идемпотентность выполнения обязательна: гарантия at-least-once, и при неудачном подтверждении задача выполнится дважды.
- Ретраи с экспонентой и DLQ: неуспешная задача перепланируется
на
now() + backoff, после N попыток уходит в DLQ с алертом. - Пик на круглое время. Все задачи на «ровно 9:00» создают всплеск, который положит и БД, и потребителей. Лечат это jitter при планировании (размазать по окну ±N минут, если бизнес позволяет) и ограничением скорости выдачи задач воркерам.
- Партиционирование по
run_at: старые выполненные партиции отрезаются мгновенно вместо тяжёлогоDELETE; индекс поrun_atделают частичным, только поstate='SCHEDULED', чтобы он оставался маленьким при миллиардах выполненных задач. - Точность против масштаба, названная явно: опрос БД раз в секунду даёт точность ±1 с и нагрузку 1 запрос/с — для 99 % задач этого достаточно. Точность в миллисекунды требует таймеров в памяти и, значит, механизма восстановления после рестарта. Требовать точность выше необходимой значит заниматься оверинжинирингом, и про это стоит сказать вслух.
Правильная структура ответа
- Сначала уточнить, x10 чего. Чтений, записей, объёма данных или числа пользователей? Это совершенно разные проблемы: x10 чтений лечится кэшем и репликами почти бесплатно, x10 записи упирается в шардинг или смену хранилища, x10 объёма — в партиционирование и архивацию.
- Посмотреть на измерения, а не гадать. «Открою дашборд и посмотрю, где насыщение: CPU, диск, пул соединений, лаг очереди, лаг репликации». Ответ «сначала измерю» на этом вопросе всегда правильный.
- Дальше по возрастанию стоимости:
- Кэш и индексы обходятся дешевле всего. Один пропущенный индекс часто даёт те самые 10× без единой новой машины.
- Вертикально: просто увеличить машину. Скучно, но до определённого размера дешевле любой переработки архитектуры, и зрелость как раз в том, чтобы это признать.
- Горизонтально stateless-слой, то есть добавить реплик приложения за балансировщиком. Работает, только если приложение действительно stateless.
- Реплики для чтения, обязательно с оговоркой про лаг и read-your-writes.
- Вынести тяжёлое в асинхронный контур: всё, что не нужно клиенту для ответа, уезжает в очередь. Часто это даёт больше, чем новое железо.
- Шардинг идёт последним и стоит дороже всего: выбор ключа, ребалансировка, кросс-шардовые запросы, распределённые транзакции. Назвать ключ и его недостатки обязательно.
- Назвать, что не масштабируется: единственный лидер БД на запись; горячий ключ или горячая партиция; глобальный счётчик/последовательность; блокировки на популярной строке; внешний провайдер с жёсткой квотой; кросс-шардовые транзакции и джойны; всё, что требует глобального порядка.
- Не забыть про деньги и эксплуатацию. x10 нагрузки превращается в x10 счёта, если ничего не оптимизировать. Иногда правильный ответ звучит как «сжать данные, удалить старое, поднять хитрейт кэша», а не «купить в 10 раз больше серверов».
«x10 по чтению я закрою кэшем и репликами почти без изменений архитектуры.
x10 по записи упрётся в мастер БД. Здесь я бы сначала вынес всё некритичное
в асинхронный контур и посмотрел, хватит ли этого, а если нет — шардировал бы
по user_id, потому что 95 % запросов идут в пределах одного
пользователя; ценой станут кросс-пользовательские отчёты, которые придётся
делать в отдельном аналитическом хранилище.» Здесь есть и порядок действий,
и обоснование ключа, и названная цена — ровно то, что оценивают.
Список кандидатов по частоте
- База данных. Сначала не «падает», а деградирует: растёт время запросов, копятся блокировки, забивается пул соединений, растёт лаг репликации. Подкатегории: одна горячая строка (счётчик, баланс), длинные транзакции, отсутствующий индекс, автовакуум не успевает.
- Пул соединений. Очень частый и неочевидный первый рубеж: приложение масштабируется горизонтально, каждый инстанс держит 50 соединений, на 20 инстансов выходит 1000, а БД держит 500. Дальше все ждут в очереди за соединением, и латентность взрывается при свободных CPU.
- Кэш. Не сам Redis, а хитрейт: когда объём данных растёт, горячее множество перестаёт помещаться, хитрейт падает с 95 % до 80 %, и нагрузка на БД учетверяется при том же трафике. Самая коварная нелинейность в системе.
- Внешние зависимости с квотой: платёжный провайдер, SMS-шлюз, геокодер. Их лимит не растёт вместе с тобой.
- Диск. IOPS и место: логи, WAL, временные файлы сортировок. Кончилось место под WAL — БД встала целиком: классическая причина полной остановки.
- Единственные по природе компоненты: лидер кластера, шедулер, воркер, который «почему-то один», глобальный счётчик.
- Сеть и балансировщик: лимит соединений, полоса, число портов при NAT.
Как отвечать, чтобы это звучало как опыт
Не перечислять список, а разобрать свою схему: «в этой системе первым
сдастся мастер БД: при 5 000 rps записи по 3 индексам на таблицу
мы упрёмся в IOPS; увижу я это по росту p99 времени коммита и по
длине очереди в пуле соединений; следом деградирует всё, что делит с ней пул,
потому что таймауты начнут копить горутины». То есть: компонент → метрика →
следствие.
Настоящие аварии почти никогда не выглядят как «упал один компонент». Выглядят так: БД замедлилась → запросы висят на таймаутах → горутины и соединения не освобождаются → сервис перестаёт отвечать на healthcheck → оркестратор убивает поды → оставшиеся получают ещё больше нагрузки → клиенты ретраят → нагрузка растёт ещё. Этот сценарий и средства против него (быстрые таймауты, bulkhead, circuit breaker, load shedding, отдельный порт для healthcheck, ретраи на одном уровне) тянут на сильнейшую часть ответа.
Чек-лист по схеме
| Компонент | Почему SPOF | Что делают |
|---|---|---|
| Мастер БД | запись идёт только туда | реплика + автоматический failover; принять, что при асинхронной репликации возможна потеря последних транзакций |
| Балансировщик | весь вход через него | пара с VRRP / managed LB / DNS с несколькими адресами и health checks |
| API Gateway | единая точка входа by design | несколько stateless-реплик, деградация при недоступности зависимостей (кэш решений authn) |
| Redis без кластера | падение = потеря кэша и всплеск на БД одновременно | кластер/sentinel; и обязательно — способность работать при пустом кэше (single-flight, ограничение параллелизма к БД) |
| Брокер | без него встают все асинхронные потоки | кластер с репликацией; outbox на стороне продюсера, чтобы события не терялись, пока брокер недоступен |
| Сервис аутентификации | его отказ = отказ всего продукта | кэш валидации токенов с TTL, локальная проверка подписи JWT без похода в сервис |
| Конфиг-сервис / discovery | без него не стартуют новые поды | кэш последней валидной конфигурации на диске, работа на старых значениях |
| DNS | «интернет сломался» почти всегда начинается здесь | несколько провайдеров, разумные TTL, устойчивость клиентов к смене адреса |
| Один регион / одна зона | авария ЦОД кладёт всё | несколько AZ обязательно; мультирегион — дорого, обсуждается отдельно |
| Внешний провайдер | единственный платёжный шлюз | второй провайдер и переключение — или честно признать риск и заложить деградацию |
- Общая зона отказа. Три реплики БД — но все в одной стойке, на одном гипервизоре или в одной AZ. Формально резерв есть, фактически нет. Тот же вопрос про общий блок питания, общий сетевой аплинк, общего провайдера.
- Общая судьба по конфигурации. Плохой конфиг или битый релиз выкатывается одновременно на все реплики — резервирование не спасает. Лечится канареечными выкатками и поэтапным раскатом конфигурации.
- Схема БД и миграции. Одна ломающая миграция кладёт все инстансы сразу; отсюда правило expand–migrate–contract и обратная совместимость на два релиза.
- Сертификаты и секреты. Истёкший TLS-сертификат кладёт систему целиком, как бы плотно её ни зарезервировали по остальным осям.
- Люди и процессы. Единственный человек, который знает, как разворачивать систему; единственный CI, без которого нельзя выкатить хотфикс; бэкап, из которого ни разу не восстанавливались (такой бэкап и не бэкап вовсе).
Не «уберём все SPOF», а «уберу те, что не соответствуют целевой доступности, и явно приму остальные». Убрать очередную точку отказа стоит денег и сложности, а сложность сама по себе порождает аварии. Для 99.9 % достаточно резервирования внутри региона; мультирегион с активной записью стоит совершенно других денег, и предлагать его без требования значит заниматься тем же оверинжинирингом. Осознанно принять риск нормально для инженера, и интервьюеры ценят это выше списка из десяти кластеров.