Разбираю ТОП-12 вопросов с собеседований на Go-разработчика в Ozon
Сегодня мы разберём подборку из 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 Read | Non-Repeatable Read | Phantom 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)
