Перейти к основному содержимому

Разбираю ТОП-12 вопросов с собеседований на Go-разработчика в Ozon

· 17 мин. чтения

Сегодня мы разберём подборку из 12 наиболее частых вопросов с собеседований в Озон, систематизированных на основе анализа 53 реальных интервью и 491 заданного вопроса. Материал охватывает ключевые темы — от основ языка Go (горутины, каналы, интерфейсы, внутреннее устройство мапы) до системного дизайна (трассировка в микросервисах, HTTP, транзакции в базах данных), и структурирован по уровням сложности: от джуниора до сеньора. Это практический гид для подготовки к собеседованиям в крупные компании, собранный ментором с опытом обучения более 200 разработчиков.

Вопрос 1. Чем отличается горутина от потока операционной системы?

Таймкод: 00:00:43

Ответ собеседника: Правильный. Три ключевых отличия: 1) Размер стека — у гортины при старте 2 КБ, у потока ОС — 8 МБ (разница ~4000 раз), поэтому можно запустить тысяч горутин, но не потоков. 2) Модель M:N — тысяча горутин работает на нескольких потоках ОС, планировщик Go сам распределяет. 3) Переключение горутин происходит в userspace без системных вызовов, а переключение потоков ОС — это context switch, что дорого. Стек горутины растёт динамически, Go использует GMP-модель (G — горутина, M — поток, P — процессор).

Правильный ответ:

Ответ собеседника полный и точный. Дополним деталями.

GMP-модель

Go использует планировщик на основе трёх сущностей:

  • G (Goroutine) — сама горутина: контекст выполнения (счётчик команд, стек, регистры).
  • M (Machine) — поток операционной системы, реальный исполнитель.
  • P (Processor) — логический процессор, который владеет локальной очередью горутин.

Модель M:N означает, что N горутин распределяются между M потоками ОС. По умолчанию количество P равно runtime.GOMAXPROCS() (обычно число CPU).

Стек горутины

Исторически стек горутины начинался с 2 КБ (в ранних версиях — 4 КБ) и рос динамически до ~1 ГБ. Начиная с Go 1.4+, стек использует непрерывные сегменты (contiguous stack), а раньше были сегментированные стеки, что вызывало проблему "hot split".

Переключение контекста

Переключение между горутинами — это cooperative scheduling с точками прерывания (function calls, channel operations, time.Sleep, runtime.Gosched()). Это значительно дешевле, чем context switch потоков ОС, который требует перехода в kernel mode, сохранения/восстановления регистров, обновления таблиц страниц.

Практические следствия

Можно запускать сотни тысяч горутин на одном процессе, тогда как потоки ОС ограничены ресурсами памяти. Однако горутины не дают преимущества для CPU-bound задач — если горутина не передаёт управление (busy loop без channel/IO), она монополизирует поток до тех пор, пока планировщик не вытеснит её (с Go 1.14+ добавлен асинхронный preemption).

Вопрос 2. Как работают каналы в Go: небуферизированные, буферизированные, закрытые и nil-каналы?

Таймкод: 00:01:35

Ответ собеседника: Правильный. Небуферизированный канал блокирует отправителя, пока получатель не прочитает данные. Буферизированный канал блокирует отправителя только когда буфер полон. При записи в закрытый канал — паника, при чтении из закрытого канала — zero value и false. Повторное закрытие тоже вызывает панику. nil-канал всегда блокируется при чтении и записи.

Правильный ответ:

Ответ собеседника корректен. Добавим нюансы.

Небуферизированные каналы (make(chan T)

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

ch := make(chan int)
go func() { ch <- 42 }() // блокируется, пока ниже не прочитают
val := <-ch

Буферизированные каналы (make(chan T, n)

Отправитель блокируется только когда буфер полностью заполнен. Получатель блокируется только когда буфер пуст. Полезны для паттерна worker pool или burst-нагрузки.

Закрытые каналы

  • Запись в закрытый канал → panic: send on closed channel
  • Чтение из закрытого канала: если в буфере остались данные — они читаются нормально; после опустошения — возвращается zero value и ok == false
  • Повторный close()panic: close of closed channel
  • Закрытие nil-канала → panic: close of nil channel

nil-каналы (var ch chan T)

Чтение и запись в nil-канал блокирует горутину навсегда. Это используется в select для отключения case:

// Динамическое отключение case в select
var ch chan int // nil-канал
select {
case v := <-ch: // никогда не сработает
fmt.Println(v)
default:
fmt.Println("канал отключён")
}

Дополнительно: направление каналов

func send(ch chan<- int) { ch <- 1 } // только запись
func recv(ch <-chan int) { <-ch } // только чтение

Это обеспечивает типобезопасность на уровне компилятора.

Вопрос 3. Чем отличается Mutex от RWMutex?

Таймкод: 00:02:03

Ответ собеседника: Правильный. Mutex — эксклюзивная блокировка: один захватил, остальные ждут, неважно читает или пишет. RWMutex разделяет читателей и писателей: несколько горутин могут читать одновременно через RLock, но при вызове Lock для записи все ждут. Если данные читаются в 10 раз чаще — RWMutex, если чтение и запись примерно поровну — лучше обычный Mutex, он проще и быстрее из-за меньшего оверхеда.

Правильный ответ:

Ответ точный. Дополним деталями.

sync.Mutex

Простая эксклюзивная блокировка. Одна горутина владеет блокировкой, остальные блокируются при вызове Lock().

var mu sync.Mutex
var counter int

mu.Lock()
counter++
mu.Unlock()

sync.RWMutex

Разделяет блокировки на два типа:

  • RLock() / RUnlock() — для читателей, несколько горутин могут удерживать одновременно
  • Lock() / Unlock() — для писателя, эксклюзивный доступ
var mu sync.RWMutex
var data map[string]int

// Читатель
mu.RLock()
val := data["key"]
mu.RUnlock()

// Писатель
mu.Lock()
data["key"] = 42
mu.Unlock()

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

  • RWMutex — когда чтение значительно преобладает над записью (типично 10:1 и больше). Пример: кэш, конфигурация, реестр.
  • Mutex — когда операции чтения и записи примерно равны, или когда простота важнее. RWMutex имеет бо́льший оверхед из-за необходимости отслеживать количество читателей.

Проблема writer starvation

В стандартной реализации sync.RWMutex писатель может быть "голодным": если непрерывно приходят новые читатели, писатель может ждать бесконечно. Это особенность дизайна — читатели могут вытеснить писателя. Если это критично, нужна собственная реализация или использование sync.Mutex с условными переменными.

Рекурсивные блокировки

Ни Mutex, ни RWMutex не поддерживают рекурсивную блокировку. Повторный вызов Lock() из той же горутины приведёт к deadlock.

Вопрос 4. Как устроена внутренняя структура map в Go?

Таймкод: 00:02:34

Ответ собеседника: Правильный. Map — это hash-таблица. Внутри массив бакетов, каждый бакет хранит до 8 пар ключ-значение. Когда средняя загрузка больше 6.5 элементов, map увеличивается в два раза. Хеш-код мапы рандомизирован специально. Главное — map не потокобезопасна, конкурентное чтение/запись вызывает фатальную ошибку. Для конкурентного доступа используют sync.Map или обычную map с мьютексами.

Правильный ответ:

Ответ полный. Добавим внутреннюю механику.

Структура hmap

Внутри map[K]V находится указатель на hmap (определён в runtime/map.go):

type hmap struct {
count int // количество элементов
flags uint8
B uint8 // log2 количества бакетов (2^B)
noverflow uint16 // количество overflow-бакетов
hash0 uint32 // seed для рандомизации хеша
buckets unsafe.Pointer // массив 2^B бакетов
oldbuckets unsafe.Pointer // предыдущий массив (при росте)
nevacuate uintptr // прогресс эвакуации
extra *mapextra // overflow-указатели
}

Бакет (bmap)

Каждый бакет хранит до 8 пар ключ-значение. Внутри бакета сначала идут 8 байт tophash (старший бит хеша каждого элемента), затем ключи, затем значения. Такая компоновка оптимизирована для кэш-линии: сначала проверяем tophash (один раз читаем 8 байт), и только при совпадении обращаемся к ключу.

Рост map

Когда count / 2^B > 6.5 (load factor), map удваивает количество бакетов. Рост происходит инкрементально — при каждой вставке эвакуируются 1–2 старых бакета, чтобы избежать долгой паузы. Поля oldbuckets и nevacuate отслеживают этот процесс.

Рандомизация хеша

Поле hash0 инициализируется случайным значением при создании map. Это защита от Hash DoS атак — злоумышленник не может подобрать коллизии, потому что хеш-функция непредсказуема.

Конкурентный доступ

Конкурентное чтение и запись в map вызывает fatal error: concurrent map read and map write (не panic, а fatal — нельзя перехватить recover). Решения:

// Вариант 1: map + RWMutex
var mu sync.RWMutex
var m map[string]int

mu.RLock()
val := m["key"]
mu.RUnlock()

mu.Lock()
m["key"] = 42
mu.Unlock()

// Вариант 2: sync.Map (оптимизирован для чтения)
var m sync.Map
m.Store("key", 42)
val, ok := m.Load("key")

sync.Map оптимизирован для двух сценариев: когда ключи читаются многократно после однократной записи, и когда несколько горутин читают/пишут непересекающиеся наборы ключей.

Вопрос 5. Как правильно склеивать строки в Go?

Таймкод: 00:03:00

Ответ собеседника: Правильный. Если складывать строки через оператор в цикле, сложность O(N²), потому что строки в Go неизменяемые и каждый раз создаётся новая строка. Правильно использовать strings.Builder — он пишет в буфер, и сложность линейная. Если известен финальный размер, вызвать Grow заранее — тогда Builder сделает только одну аллокацию. Также можно упомянуть strings.Join, который делает то же под капотом для готового слайса строк.

Правильный ответ:

Ответ корректен. Добавим детали.

Проблема с + в цикле

// Плохо: O(N²) по памяти и времени
var s string
for _, part := range parts {
s += part // каждый раз новая аллокация + копирование
}

Строка в Go — это struct { ptr *byte; len int }. Оператор + создаёт новый буфер и копирует обе строки. Для N строк суммарно копируется O(N²) байт.

strings.Builder

// Правильно: O(N)
var b strings.Builder
b.Grow(estimatedSize) // опционально, но снижает аллокации
for _, part := range parts {
b.WriteString(part)
}
result := b.String()

strings.Builder накапливает данные в []byte буфере. Метод String() использует unsafe, чтобы преобразовать буфер в строку без копирования (начиная с Go 1.20 — через unsafe.String).

strings.Join

// Для готового слайса строк
result := strings.Join(parts, ", ")

Под капотом strings.Join один раз вычисляет общую длину, выделяет буфер и копирует — это оптимально.

bytes.Buffer vs strings.Builder

bytes.Buffer — более общий, реализует io.Writer и io.Reader. strings.Builder — специализирован только для построения строк и не может использоваться как io.Reader. Для чистой конкатенации строк предпочтительнее strings.Builder.

fmt.Sprintf

Подходит для форматирования с подстановкой значений, но медленнее из-за парсинга форматной строки и рефлексии. Не использовать в горячих циклах.

Вопрос 6. Что такое транзакция и какие уровни изоляции существуют?

Таймкод: 00:03:25

Ответ собеседника: Правильный. Четыре уровня изоляции: 1) Read Uncommitted — видишь незакоммиченные изменения. 2) Read Committed — видишь только закоммиченное (по умолчанию в PostgreSQL). 3) Repeatable Read — данные стабильны в рамках транзакции. 4) Serializable — полная изоляция, транзакции идут синхронно.

Правильный ответ:

Ответ верный, но стоит дополнить описание аномалий и поведения в разных СУБД.

ACID-свойства транзакции

  • Atomicity — всё или ничего: транзакция либо применяется целиком, либо откатывается.
  • Consistency — транзакция переводит базу из одного согласованного состояния в другое.
  • Isolation — параллельные транзакции не влияют друг на друга сверх допустимого уровня.
  • Durability — после коммита изменения сохраняются при сбоях.

Аномалии

Dirty Read — чтение незакоммиченных данных другой транзакции. Если та откатится, прочитанные данные никогда не существовали.

Non-Repeatable Read — при повторном чтении той же строки внутри транзакции получаем другое значение (другая транзакция изменила и закоммитила строку).

Phantom Read — при повторном выполнении запроса с условием WHERE появляются новые строки (другая транзакция вставила строки, удовлетворяющие условию).

Lost Update — две транзакции читают одно значение, обе модифицируют, вторая перезаписывает результат первой.

Уровни изоляции

УровеньDirty ReadNon-Repeatable ReadPhantom Read
Read UncommittedВозможнаВозможнаВозможна
Read CommittedНевозможнаВозможнаВозможна
Repeatable ReadНевозможнаНевозможнаВозможна*
SerializableНевозможнаНевозможнаНевозможна

*В PostgreSQL Repeatable Read предотвращает и phantom read через механизм SSI (Serializable Snapshot Isolation), но в MySQL/InnoDB — нет.

Поведение в СУБД

  • PostgreSQL по умолчанию использует Read Committed. Уровень Repeatable Read в PostgreSQL реализован через MVCC с снимком на момент начала транзакции.
  • MySQL (InnoDB) по умолчанию Repeatable Read. Использует блокировки на следующий ключ (next-key locks) для предотвращения phantom read.
  • Read Uncommitted практически не используется в production, так как данные могут быть некорректными.

Пример в SQL

-- Установка уровня изоляции
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

BEGIN;
SELECT * FROM accounts WHERE user_id = 1 FOR UPDATE; -- блокировка строк
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
COMMIT;

Вопрос 7. Что такое defer и в каком порядке он выполняется?

Таймкод: 00:03:50

Ответ собеседника: Правильный. Defer откладывает вызов функции до выхода из текущей функции. Порядок выполнения — LIFO (как стек): последний вызов defer выполнится первым. Важный нюанс: аргументы defer вычисляются сразу при вызове, а не при выполнении.

Правильный ответ:

Ответ точный. Добавим важные нюансы.

Порядок LIFO

func main() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
// Вывод: 3, 2, 1
}

Вычисление аргументов

func main() {
x := 1
defer fmt.Println(x) // напечатает 1, не 42
x = 42
}

Аргументы функции в defer вычисляются немедленно. Сама функция выполнится при выходе.

Именованные возвращаемые значения

defer имеет доступ к именованным возвращаемым значениям и может их изменять:

func example() (result int) {
defer func() {
result++ // изменит возвращаемое значение
}()
return 1 // result = 1, затем defer делает result = 2
}

Выполнение после return

Порядок при return: 1) вычисляется возвращаемое значение, 2) выполняются defer-ы, 3) функция возвращает управление.

Выполнение при panic

defer выполняется даже при panic, что делает его аналогом finally в других языках. Это основной механизм для recover:

func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
panic("something went wrong")
}

Производительность

defer имеет небольшой оверхед (запись в связный список deferred calls). В горячих циклах это может быть заметно, но в большинстве случаев это пренебрежимо мало. Начиная с Go 1.13, компилятор оптимизирует defer в некоторых случаях, избегая аллокаций.

Вопрос 8. Чем отличается слайс от массива в Go?

Таймкод: 00:04:06

Ответ собеседника: Правильный. Массив — фиксированный размер, создаётся при объявлении. При передаче в функцию копируется целиком. Слайс — обёртка над массивом, динамически изменяемая. Структура из трёх полей: указатель на массив, длина и capacity. Слайс не хранит данные, а ссылается на массив. Два слайса могут указывать на один массив. Когда превышается capacity, Go создаёт новый массив, а старые слайсы продолжают указывать на старый.

Правильный ответ:

Ответ полный. Добавим детали о росте и подводные камни.

Массив

var a [5]int // массив из 5 нулей
b := [3]int{1, 2, 3}
c := [...]int{1, 2, 3} // компилятор вычисляет размер

Массив — это значение. Присваивание a = b копирует все элементы. Размер — часть типа: [3]int и [5]int — разные типы.

Слайс

type SliceHeader struct {
Data uintptr // указатель на базовый массив
Len int // текущая длина
Cap int // вместимость
}
s := make([]int, 3, 5) // len=3, cap=5
s = append(s, 4) // len=4, cap=5
s = append(s, 5) // len=5, cap=5
s = append(s, 6) // len=6, cap=10 (рост)

Стратегия роста

При нехватке capacity Go создаёт новый массив бо́льшего размера и копирует элементы. Стратегия роста: при capacity < 256 — удвоение, при ≥ 256 — рост примерно на 25% (формула зависит от версии Go). Это обеспечивает амортизированную O(1) для append.

Подводный камень: общий базовый массив

original := []int{1, 2, 3, 4, 5}
slice := original[1:3] // [2, 3], cap=4
slice = append(slice, 99)
// original теперь [1, 2, 3, 99, 5] — изменился!

Чтобы избежать этого, используйте полный слайс-выражение original[1:3:3] (ограничение capacity) или copy.

Передача в функцию

Слайс передаётся по значению (копируется заголовок — 24 байта), но указывает на тот же базовый массив. Изменение элементов внутри функции видно снаружи. Изменение длины через append — не видно (заголовок скопирован).

Вопрос 9. Как работает Go-планировщик и GMP-модель?

Таймкод: 00:04:35

Ответ собеседника: Правильный. G — горутина (задача), M — поток ОС (реально выполняет код), P — логический процессор (количество равно числу ядер или GOMAXPROCS). У каждого P есть своя очередь горутин. M берёт P, достаёт горутину из очереди и выполняет. При системном вызове M блокируется, P отсоединяет его и ищет другой свободный M или создаёт новый. Work-stealing: если у P нет работы, он крадёт половину горутин у соседнего P для балансировки нагрузки.

Правильный ответ:

Ответ корректен. Дополним механику работы.

Три сущности GMP

  • G (Goroutine) — контекст выполнения: стек, счётчик команд, состояние (running, runnable, waiting). Хранится в пуле или в локальной/глобальной очереди.
  • M (Machine / OS Thread) — реальный поток ОС. По умолчанию ограничен runtime/debug.SetMaxThreads() (10000).
  • P (Processor) — логический процессор, владелец локальной очереди горутин. Количество P = GOMAXPROCS() (по умолчанию число CPU).

Очереди горутин

Каждый P имеет локальную очередь (runqueue) до 256 горутин. Также существует глобальная очередь для горутин, созданных из системных вызовов или через runtime.Gosched(). M сначала проверяет локальную очередь P, затем глобальную, затем work-stealing.

Work-Stealing

Если у P пустая локальная очередь, он случайным образом выбирает другой P и "крадёт" половину его очереди. Это обеспечивает балансировку нагрузки без глобальной блокировки.

Системные вызовы

Когда горутина выполняет блокирующий системный вызов (например, чтение файла), M блокируется. P отсоединяется от этого M и присоединяется к другому M (существующему или новому). Когда системный вызов завершается, горутина возвращается в очередь, а M идёт в пул.

Для неблокирующих IO (network IO через epoll/kqueue/IOCP) Go использует netpoller — M не блокируется, а горутина паркуется.

Асинхронный preemption

До Go 1.14 горутина с бесконечным циклом без channel/IO могла монополизировать M. С Go 1.14+ планировщик использует сигнал SIGURG для асинхронного вытеснения горутин, выполняющихся дольше ~10 мс.

Вопрос 10. Из чего состоит HTTP-запрос и HTTP-ответ?

Таймкод: 00:05:20

Ответ собеседника: Правильный. HTTP-запрос состоит из: метода (GET, POST, PUT, DELETE и т.д.), пути, заголовков (Host, Content-Type, Authorization и т.д.) и тела (есть у POST/PUT, обычно нет у GET, но технически можно отправить). HTTP-ответ состоит из: статус-кода, заголовков и тела ответа.

Правильный ответ:

Ответ верный. Дополним структуру.

HTTP-запрос

POST /api/users HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: Bearer token123
Accept: application/json
Content-Length: 27

{"name": "John", "age": 30}

Структура:

  • Request Line: METHOD PATH HTTP/VERSION — первая строка
  • Headers: пары Key: Value, разделённые \r\n
  • Пустая строка: отделяет заголовки от тела
  • Body: опционально, присутствует при наличии Content-Length или Transfer-Encoding: chunked

HTTP-ответ

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 15
Date: Mon, 01 Jan 2025 00:00:00 GMT
Server: nginx/1.21

{"status": "ok"}

Структура:

  • Status Line: HTTP/VERSION STATUS_CODE REASON_PHRASE
  • Headers: аналогично запросу
  • Body: опционально

Статус-коды

  • 1xx — Informational
  • 2xx — Success (200 OK, 201 Created, 204 No Content)
  • 3xx — Redirection (301 Moved Permanently, 302 Found, 304 Not Modified)
  • 4xx — Client Error (400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 429 Too Many Requests)
  • 5xx — Server Error (500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable)

Варианты передачи тела

  • Content-Length — фиксированный размер
  • Transfer-Encoding: chunked — потоковая передача, когда размер заранее неизвестен
  • Без тела — для GET (обычно), HEAD, DELETE, 204, 304

Вопрос 11. Как устроены интерфейсы в Go?

Таймкод: 00:05:45

Ответ собеседника: Правильный. Интерфейс — набор методов. Имплементация неявная: если тип имеет все методы, он реализует интерфейс. Пустой интерфейс (interface{} или any) принимает любой тип данных. Под капотом — два указателя: ITAB (таблица методов) и data (данные). Важная ловушка: nil-интерфейс и интерфейс с nil-значением — разные вещи.

Правильный ответ:

Ответ полный. Добавим детали о внутренней структуре и type assertion.

Внутренняя структура

// runtime/iface.go
type iface struct {
tab *itab // таблица методов + информация о типе
data unsafe.Pointer // указатель на данные
}

type itab struct {
inter *interfacetype // тип интерфейса
_type *_type // динамический тип значения
hash uint32 // хеш типа (для type switch)
_ [4]byte
fun [1]uintptr // таблица указателей на методы (вариативный размер)
}

Интерфейс — это пара (тип, значение). nil-интерфейс — оба поля nil. Интерфейс с nil-значением — tab не nil (тип известен), но data == nil.

Ловушка с nil

var p *MyStruct = nil
var i interface{} = p // i != nil! tab != nil, data == nil

if i != nil {
fmt.Println("i is not nil") // это напечатается
}

Type assertion и type switch

// Type assertion
if val, ok := i.(string); ok {
fmt.Println(val)
}

// Type switch
switch v := i.(type) {
case string:
fmt.Println("string:", v)
case int:
fmt.Println("int:", v)
default:
fmt.Printf("unknown: %T\n", v)
}

Пустой интерфейс

interface{} (синоним any с Go 1.18) не имеет методов, поэтому любой тип его реализует. Используется для универсальных контейнеров, но с потерей типобезопасности. С Go 1.18+ предпочтительнее дженерики.

Embedding интерфейсов

type Reader interface {
Read(p []byte) (n int, err error)
}

type Writer interface {
Write(p []byte) (n int, err error)
}

type ReadWriter interface {
Reader
Writer
}

Вопрос 12. 1000 микросервисов — всё упало. Как найти проблему?

Таймкод: 00:06:07

Ответ собеседника: Правильный. Использовать распределённую трассировку. Каждый запрос получает Trace ID, передаётся через заголовки между сервисами. Каждый сервис создаёт спан (отрезок с временем начала и конца). По трассировке видно, какой сервис обрабатывал запрос 2 мс, какой 50 мс, а какой 3000 мс — там и проблема. Также важно логировать Trace ID — из любой ошибки в логах можно перейти к трассе.

Правильный ответ:

Ответ хороший. Дополним стратегию диагностики.

Распределённая трассировка

Инструменты: Jaeger, Zipkin, OpenTelemetry. Каждый запрос получает TraceID, каждый сервис создаёт Span с SpanID и ParentSpanID. Данные отправляются в коллектор.

Стратегия диагностики при массовом падении

1. Метрики (первые 30 секунд)

  • Проверить дашборды Grafana: latency p99, error rate, throughput по каждому сервису
  • Найти сервис, где резко выросла ошибка или задержка
  • Посмотреть на ресурсы: CPU, память, сеть, диск

2. Логи (следующие 2 минуты)

  • Агрегированные логи (ELK, Loki): фильтровать по уровню ERROR, искать паттерны
  • Группировать ошибки по типу: connection refused, timeout, out of memory
  • Искать корреляцию по времени — что изменилось первым?

3. Трассировка

  • Найти конкретный запрос с ошибкой по TraceID
  • Определить, на каком сервисе/спане произошла ошибка или задержка
  • Построить цепочку: какой сервис вызвал каскад

4. События инфраструктуры

  • Было ли развёртывание? (rollback)
  • Были ли изменения в конфигурации?
  • Сетевые проблемы? (проверить service mesh)

5. Паттерны каскадных отказов

  • Circuit breaker: убедиться, что не все запросы идут в упавший сервис
  • Retry storm: ограничить retries с exponential backoff
  • Bulkhead: изолировать пулы соединений

Инструменты в экосистеме Go

// OpenTelemetry пример
import "go.opentelemetry.io/otel"

ctx, span := tracer.Start(ctx, "myHandler")
defer span.End()

span.SetAttributes(attribute.String("user.id", userID))
span.RecordError(err)