golang

Golang Top 15 ошибок

  • воскресенье, 27 апреля 2025 г. в 00:00:08
https://habr.com/ru/articles/904658/

Go – язык простой, но из-за кажущейся простоты многие разработчики совершают одни и те же ошибки, которые приводят к серьёзным последствиям в production.

Ниже собраны 15 самых распространённых ошибок при разработке на Golang и рекомендации по их исправлению.

1. Игнорирование ошибок

Игнорирование ошибок приводит к скрытым багам, которые сложно найти.

Неправильно:

_, err := ioutil.ReadFile("config.json")

Правильно:

data, err := ioutil.ReadFile("config.json")
if err != nil {
    log.Fatal(err)
}

2. Неправильное управление горутинами

Бесконтрольный запуск горутин приводит к утечкам памяти и проблемам с конкурентностью.

Неправильно:

for i := 0; i < 1000; i++ {
    go doSomething()
}

Правильно:

var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        doSomething()
    }()
}
wg.Wait()
var wg sync.WaitGroup

Создаётся переменная wg типа sync.WaitGroup — это специальная структура из стандартной библиотеки Go, которая позволяет ждать завершения группы горутин.

for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        doSomething()
    }()
}

Цикл запускает 1000 горутин.
Для каждой итерации:

  1. wg.Add(1) — увеличивает счётчик WaitGroup на 1: «Ожидается одна горутина».

  2. go func() { ... }() — запускается анонимная функция в новой горутине.

  3. defer wg.Done() — отложенный вызов, который уменьшит счётчик WaitGroup на 1, когда горутина завершится.

  4. doSomething() — выполняется ваша бизнес-логика в каждой горутине.

wg.Wait()

Этот вызов блокирует выполнение до тех пор, пока все 1000 горутин не вызовут wg.Done(), то есть до их завершения.

Зачем это нужно?

Чтобы дождаться завершения всех асинхронных задач, прежде чем продолжить выполнение основной программы. Иначе main() может завершиться раньше, чем горутины успеют выполниться.

Аналогия:

Представте, что вы поручили 1000 помощникам разложить документы, но хотите убедиться, что все закончили работу, прежде чем закрыть офис. WaitGroup — это как список с галочками: каждый помощник отмечает себя выполненным, и ты ждёшь, пока все поставят галочки.

Без контроля приложение становится нестабильным и непредсказуемым.

3. Неиспользование context

Отсутствие context приводит к сложностям в отмене и таймаутах операций.

Неправильно:

req, _ := http.NewRequest("GET", url, nil)
client.Do(req)

Правильно:

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
client.Do(req)

Context необходим для контроля длительных операций.

Отсутствие context приводит к сложностям в отмене и таймаутах операций.

Использование context особенно важно в сетевых запросах и длительных операциях. Без context вы не сможете прервать запросы при превышении времени ожидания или отменить запросы, когда они больше не нужны. Это может привести к зависаниям, длительному ожиданию ответа от удалённых сервисов и повышению нагрузки на сервер, так как ресурсы остаются занятыми на неопределённое время.

4. Использование interface{} вместо строгой типизации

Использование пустого интерфейса ухудшает читаемость и поддержку.

Неправильно:

func doSomething(data interface{}) {}

Правильно:

func doSomething(data string) {}

Строгая типизация помогает избегать ошибок во время компиляции.

5. Преждевременная оптимизация

Это усложняет код и затрудняет его поддержку без реальной выгоды.

Неправильно:

buf := make([]byte, 0, 1024)

Правильно:

buf := []byte{}

Оптимизируйте только по необходимости и после профилирования.

6. Игнорирование утечек памяти

Утечки памяти незаметно ухудшают производительность приложения.

Что такое утечка памяти в Go?

Хотя Go использует сборщик мусора (GC), это не гарантирует, что память не будет утекать.

Утечка — это не обязательно потерянная память в классическом понимании (как в C), а скорее данные, которые остаются в памяти, но больше не нужны, и GC их не может освободить, потому что на них всё ещё есть ссылки.

Примеры типичных причин утечек

  1. Горутины, которые никогда не завершаются (зависли, ждут по каналу).

  2. Кэш или map, в который пишут, но никогда не очищают.

  3. Срезы или структуры, которые ссылаются на большие блоки данных, даже если используют только их часть.

  4. Открытые файлы или соединения без Close().

    Почему это плохо?

    • Со временем утечки накапливаются.

    • Увеличивается использование памяти и CPU (GC работает чаще).

    • Программа может начать тормозить или крашиться из-за OOM (Out of Memory).

Используйте:

import _ "net/http/pprof"

Регулярно проверяйте утечки через pprof.

Как помогает pprof

pprof — это мощный инструмент профилирования в Go, встроенный в стандартную библиотеку. Он позволяет вам:

  • Снимать heap-профили (использование памяти).

  • Смотреть goroutine dump — какие горутины висят и сколько их.

  • Анализировать CPU, блокировки, аллокации и др.

  • Использовать интерактивные визуализации через go tool pprof.

Как подключить pprof

mport _ "net/http/pprof"
import "net/http"

func main() {
    go func() {
        http.ListenAndServe("localhost:6060", nil)
    }()
    // твой код
}

Теперь вы можете открыть в браузере:

http://localhost:6060/debug/pprof/

Снятие и анализ профиля

go tool pprof http://localhost:6060/debug/pprof/heap

После чего можете в интерактивном режиме:

  • top — показать топ аллокаторов.

  • list SomeFunc — посмотреть, где в SomeFunc утечка.

  • web — открыть SVG-граф утечки в браузере (если установлен Graphviz).

Пример утечки:

func leaky() {
    ch := make(chan int)
    go func() {
        for {
            ch <- 1 // никто не читает — горутина никогда не завершится
        }
    }()
}

Такая горутина зависает навсегда и удерживает память.

  • Всегда закрывай каналы, соединения и файлы.

  • Используй context с таймаутом.

  • Анализируй pprof в stress-тестах или при длительной работе.

7. Неправильная работа с каналами

Неправильное использование каналов вызывает deadlock или panic.

Что такое каналы в Go?

Каналы (chan) в Go — это механизм синхронизации между горутинами. Они позволяют передавать данные от одной горутины к другой без явной блокировки, при этом встроено поведение блокировки/ожидания.

В чём проблема?

Неправильная работа с каналами приводит к:

  • Deadlock (взаимная блокировка).

  • Panic (при отправке в закрытый канал или чтении из него).

  • Утечкам горутин, если канал не обслуживается (горутине некуда писать/читать).

  • Неопределённому поведению, особенно при конкурентной записи без синхронизации.

Неправильно:

func main() {
    ch := make(chan int) // небуферизированный канал

    ch <- 1 // deadlock: main заблокирован, никто не читает
    fmt.Println("unreachable")
}

Здесь main() пытается отправить в канал, но никто не читает, и он навсегда повисает — это и есть deadlock.

Правильно:

func main() {
    ch := make(chan int, 1) // буферизированный канал
    ch <- 1                 // нет блокировки, потому что буфер есть
    fmt.Println("done")
}

Буфер позволяет сделать одну отправку без ожидания читателя.

Или исправление с получателем

func main() {
    ch := make(chan int)

    go func() {
        ch <- 1 // эта горутина отправит, когда main будет читать
    }()

    value := <-ch
    fmt.Println(value)
}

А здесь уже есть полный цикл: одна горутина пишет, другая читает — всё корректно и без deadlock.

Другие типичные ошибки с каналами:

Запись в закрытый канал

close(ch)
ch <- 1 // panic: send on closed channel

Чтение из закрытого канала — норма, но нужно проверить:

value, ok := <-ch
if !ok {
    fmt.Println(\"канал закрыт\")
}

Почему важно?

Каналы — ключевая часть модели конкурентности в Go:

  • Они блокируют по умолчанию, и это фича, а не баг.

  • Они обеспечивают синхронизацию.

  • Их неправильное использование может убить всю систему.

8. Забытые defer-вызовы

Забытые defer приводят к утечкам ресурсов.

Правильно:

f, err := os.Open("file.txt")
if err != nil { log.Fatal(err) }
defer f.Close()

Используйте defer для автоматического освобождения ресурсов.

9. Race conditions

Отсутствие синхронизации вызывает непредсказуемое поведение.

Правильно:

var mu sync.Mutex
mu.Lock()
defer mu.Unlock()

Используйте sync.Mutex для безопасной работы с данными.

10. Игнорирование тестов

Отсутствие тестов ухудшает стабильность и усложняет поддержку.

Правильно:

func TestAdd(t *testing.T) {}

Регулярно пишите тесты для критических функций.

11. Злоупотребление reflection

Reflection замедляет код и усложняет чтение.

Что такое reflection в Go?

reflection — это механизм, позволяющий программе анализировать и изменять свои собственные структуры во время выполнения. В Go это реализуется через пакет reflect.

Пример:

import "reflect"

func printType(x interface{}) {
    v := reflect.ValueOf(x)
    fmt.Println("Type:", v.Type())
}

Почему злоупотребление reflection — это плохо?

  1. Потеря производительности

    • Reflection работает медленнее, чем обычный статический вызов. Всё, что делается через reflect, требует дополнительных проверок, аллокаций и обращений к типовой информации.

    • В критичных по скорости частях кода это может стать узким местом.

  2. Усложнение понимания

    • Код с reflect менее прозрачен. Вместо явных вызовов методов и доступа к полям — приходится разбираться, что делает reflect.ValueOf, Elem(), Field(i) и т.д.

    • Для новичков или команды сопровождения такой код — ночной кошмар.

  3. Потеря типовой безопасности

    • Один из плюсов Go — это строгая типизация. Reflection обходит эту систему, что может привести к runtime-ошибкам вместо compile-time.

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

  1. Фреймворки и библиотеки

    • Например, encoding/json использует reflect, чтобы сериализовать произвольные структуры.

    • ORM-библиотеки вроде gorm — чтобы работать с любыми структурами данных.

  2. Универсальные инструменты

    • В случаях, когда нужно написать универсальную функцию для работы с множеством разных типов — и они неизвестны заранее.

    • Пример: сериализация, логгирование, динамическое создание UI, роутинг HTTP-запросов по методам.

  3. Вспомогательные утилиты для отладки или генерации кода

    • Например, авто-документация API на основе структур.

Избегайте:

func setField(x interface{}, fieldName string, value interface{}) {
    v := reflect.ValueOf(x).Elem()
    f := v.FieldByName(fieldName)
    if f.IsValid() && f.CanSet() {
        f.Set(reflect.ValueOf(value))
    }
}

Этот код трудно отлаживать и сопровождать. Лучше сделать это явно через интерфейсы или использовать generics (с Go 1.18+).

Лучше использовать generics (если можно)

func print[T any](value T) {
    fmt.Printf("%v\n", value)
}

Generics позволяют избежать использования reflect в большинстве случаев, особенно при написании универсальных функций.

12. Неиспользование линтеров и форматтеров

Это приводит к разрозненному стилю и сложности поддержки.

Используйте:

go fmt ./...

Поддерживайте единый стиль кода.

13. Неэффективное использование структур

Передача структур по значению ухудшает производительность.

Неправильно:

type User struct {
    Name  string
    Email string
    Age   int
}

func PrintUser(u User) {
    fmt.Println(u.Name, u.Email, u.Age)
}

func main() {
    user := User{Name: "Alice", Email: "alice@example.com", Age: 30}
    PrintUser(user) // структура копируется
}

Здесь User передаётся по значению — создаётся копия всей структуры при каждом вызове функции.

Правильно:

func PrintUser(u *User) {
    fmt.Println(u.Name, u.Email, u.Age)
}

func main() {
    user := User{Name: "Alice", Email: "alice@example.com", Age: 30}
    PrintUser(&user) // передаётся указатель, копирования нет
}

Передача по указателю *User позволяет избежать копирования и эффективнее использовать память и ресурсы.

14. Отсутствие мониторинга и метрик

Игнорирование мониторинга усложняет выявление проблем.

Используйте логирование, мониторинг. Например Prometheus:

prometheus.MustRegister(myMetric)

Мониторинг необходим для оперативной реакции на проблемы.

15. Неправильное логирование в Go

Проблема

Во многих Go-проектах логирование выглядит примерно так:

goCopyEditlog.Println("Something went wrong")
log.Printf("error: %v", err)

log.Println("Something went wrong") log.Printf("error: %v", err)

На первый взгляд — нормально. Ошибка логируется. Но если система масштабируется, появляются микросервисы, параллельные запросы и DevOps-обвязка, такие логи превращаются в болото:

  • Непонятно, откуда пришла ошибка

  • Не видно, что именно происходило

  • Нет возможности отследить ошибку в системах мониторинга (например, Loki, ELK, Datadog)

  • Нет request_id — ключевого идентификатора запроса

Что такое хорошее логирование

Хорошее логирование — это:

  1. Структурированный вывод (JSON или key-value формат)

  2. Уровни логирования: debug, info, warning, error, fatal

  3. Контекст: модуль, пользователь, ID запроса (request_id), ошибка, действия

  4. Легкая интеграция в Prometheus/Grafana/Cloud Logging

Используйте request_id

request_id — это уникальный ID каждого запроса. Его можно:

  • Получать из заголовка X-Request-ID

  • Генерировать, если отсутствует

  • Прокидывать через context.Context

  • Использовать в каждом логе

Это упрощает трейсинг ошибок, особенно в микросервисной архитектуре.

Пример с logrus

goCopyEditimport (
    log "github.com/sirupsen/logrus"
    "github.com/google/uuid"
)

func logAuthError(userID string, err error, requestID string) {
    log.WithFields(log.Fields{
        "request_id": requestID,
        "module":     "auth",
        "user_id":    userID,
        "action":     "login_attempt",
        "error":      err,
    }).Error("failed to authenticate user")
}

Пример с zap

goCopyEditimport (
    "go.uber.org/zap"
    "github.com/google/uuid"
)

func logAuthError(userID string, err error, requestID string, logger *zap.Logger) {
    logger.Error("failed to authenticate user",
        zap.String("request_id", requestID),
        zap.String("module", "auth"),
        zap.String("user_id", userID),
        zap.String("action", "login_attempt"),
        zap.Error(err),
    )
}

Middleware для request_id (Gin-пример)

goCopyEditfunc RequestIDMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        reqID := c.GetHeader("X-Request-ID")
        if reqID == "" {
            reqID = uuid.New().String()
        }

        c.Set("request_id", reqID)
        c.Writer.Header().Set("X-Request-ID", reqID)
        c.Next()
    }
}

Затем в хендлерах:

goCopyEditfunc LoginHandler(c *gin.Context) {
    requestID, _ := c.Get("request_id")
    userID := "123"
    err := errors.New("invalid password")

    log.WithFields(log.Fields{
        "request_id": requestID,
        "module":     "auth",
        "user_id":    userID,
        "action":     "login_attempt",
        "error":      err,
    }).Error("failed to authenticate user")
}

Результат

Теперь каждый лог содержит структурированную информацию:

jsonCopyEdit{
  "level": "error",
  "msg": "failed to authenticate user",
  "request_id": "abcd-1234-efgh-5678",
  "module": "auth",
  "user_id": "123",
  "action": "login_attempt",
  "error": "invalid password"
}

И вы можете легко:

  • Искать логи по request_id

  • Собирать метрики по модулям

  • Интегрировать с Observability-платформами

Заключение

Плохие логи — это логи без контекста, уровня и ID.

Хорошие логи:

  • Структурированы

  • Содержат request_id

  • Используют уровни (Info, Error, Debug)

  • Помогают тебе и машинам находить проблемы

Избегая этих ошибок и следуя рекомендациям, вы сможете значительно улучшить стабильность, производительность и читаемость ваших Go-приложений, сократить время на отладку и сделать разработку более эффективной.