Ошибка, которая может стоить вам миллионов: что это такое и как ее исправить

Ошибка, которая может стоить вам миллионов: что это такое и как ее исправить

11 марта 2023 г.

<цитата>

Это сообщение о реальной проблеме, с которой я столкнулся в своем проекте. Я объединил два сообщения в блоге в одно, так что это немного длиннее, пожалуйста, дочитайте до конца. Спасибо!

Надеюсь, прочитав этот пост, вы сможете избежать той же ошибки, что и мы в нашем проекте 😅. Эта маленькая ошибка не стоила нам миллиона долларов. Но это стоило нам несколько тысяч, и оповещения Prometheus спасли нас.

Но это может стоить вам миллиона, если вы не будете осторожны 💸. Я усвоил уроки на своих ошибках; это то, что я никогда не забуду в будущем.

Сценарий представляет собой простую транзакцию базы данных. Все мы хотя бы раз в жизни программировали транзакции баз данных. Если вы не знаете, как работают транзакции, вы можете прочитать документацию здесь для Postgres.< /p>

Наш сервис занимается управлением некоторыми подписками в базе данных. Все изменения строк происходят внутри транзакции. Сервис находится на Golang, и процесс выглядит следующим образом:

* Начать транзакцию. * Получить запись по идентификатору. * Подтвердите, возможна ли операция по изменению. * Если да, то обновить запись. * Зафиксировать транзакцию. * В случае любой ошибки отмените транзакцию.

Чтобы получить запись и заблокировать запись, чтобы другие потоки ждали обновления, мы используем запрос SELECT ... FOR UPDATE, чтобы получить запись из Postgres и заблокировать ее.

<цитата>

Примечание. Мы используем библиотеку sqlx для базы данных. доступ.

Код репозитория выглядит следующим образом:

func (r *fetcher) GetSubscription(tx *sqlx.Tx, id uuid.UUID) (*model.Subscription, error) {
    var subscription model.Subscription
    err := tx.Get(&subscription, `
        SELECT * FROM subscriptions
        WHERE id = $1
        FOR UPDATE
    `, id)
    if err != nil {
        return nil, err
    }

    return &subscription, nil
}

Код услуги выглядит следующим образом:

func (s *service) CancelSubscription(ctx context.Context, id uuid.UUID) (*model.Subscription, error) {
    tx, err := s.db.BeginTxx(ctx, nil)
    if err != nil {
        return nil, err
    }

    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    subscription, err := s.fetcher.GetSubscription(tx, id)
    if err != nil {
        return nil, err
    }

    if subscription.CancelledAt != nil {
        return subscription, nil
    }

    subscription.CancelledAt = time.Now()

    err = s.updater.UpdateSubscription(tx, subscription)
    if err != nil {
        return nil, err
    }

    err = tx.Commit()
    if err != nil {
        return nil, err
    }

    return subscription, nil
}

Проблема

Все клиентские запросы истекли по тайм-ауту, а количество подключений к БД превышало 1,2 тыс. 😅. Ни один запрос не смог завершить операцию. Это было событие остановки мира 🤣

This is me when this happened and I missed one case

Почему?

Проблема возникает, когда мы пытаемся отменить уже отмененную подписку. Если подписка уже отменена, возвращаем подписку, не внося никаких изменений, но блокировку записи не снимаем.

Причина в том, что функция defer вызывает tx.Rollback() только при возникновении ошибки. Это приведет к тому, что блокировка будет активной до тех пор, пока транзакция не будет зафиксирована или не будет отменена. Но поскольку мы не делаем ни одной из этих двух вещей, блокировка удерживается до тех пор, пока не истечет время транзакции.

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

Исправить

  1. Снимайте блокировку в каждом условии if.
// Error handling is omitted for brevity
if subscription.CancelledAt != nil {
    _ = tx.Rollback() // release the lock

    return subscription, nil
}

Это самый простой способ решить проблему. Но для этого вам потребуется выполнить откат в каждом условии if. И если вы забудете выполнить откат в любом из условий if, возникнет та же проблема.

2. Откатите транзакцию в функции defer для каждого случая.

// Error handling is omitted for brevity
defer func() {
   _ = tx.Rollback()
}()

Это освободит блокировку в любом случае. Для зафиксированных транзакций и согласно трассировке мы видим несколько миллисекунд, которые код тратит на откат транзакций. В случае совершенных транзакций откат ничего не делает. Но это лучший способ решить проблему.

3. Подтвердите транзакцию в функции defer для каждого случая.

defer func() {
  if err != nil {
    _ = tx.Rollback()
  }

  commitErr := tx.Commit()
  // handle commitErr
}

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

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

Сервисный уровень — Возврат ошибки 👾

Проблема со службой заключается в том, что при проверке она не возвращает никаких ошибок. Другой способ решить эту проблему — вернуть ошибку из условия if. Это вызовет откат в функции defer.

Это очень чистый способ. Служба должна иметь только одну обязанность. Либо завершает операцию, либо возвращает ошибку. Он должен возвращать ошибку в случае сбоя проверки. Это хорошая практика, которую мы упустили в нашем проекте.

Позже мы исправили проблему, вернув ошибку из условия if.

Это изменение также помогает обработчику решить, какой код состояния HTTP следует возвращать. Это действительно полезно, так как мы можем вернуть 400 Bad Request для ошибок проверки.

Вот как будет выглядеть код после рефакторинга:

var ErrSubscriptionAlreadyCancelled = errors.New("subscription already cancelled")

func (s *service) CancelSubscription(ctx context.Context, id uuid.UUID) (*model.Subscription, error) {
    tx, err := s.db.BeginTxx(ctx, nil)
    if err != nil {
        return nil, err
    }

    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    subscription, err := s.fetcher.GetSubscription(tx, id)
    if err != nil {
        return nil, err
    }
    if subscription.CancelledAt != nil {
        return subscription, ErrSubscriptionAlreadyCancelled
    }

    subscription.CancelledAt = time.Now()
    err = s.updater.UpdateSubscription(tx, subscription)
    if err != nil {
        return nil, err
    }
    err = tx.Commit()
    if err != nil {
        return nil, err
    }
    return subscription, nil
}

Мое мнение

Мое личное предпочтение — второй вариант. Но вы можете выбрать любой из трех вариантов в зависимости от ваших предпочтений. Я выбираю второй вариант, потому что уверен, что что бы ни случилось в потоке в конце, произойдет откат транзакции.

Да, это стоило бы мне нескольких миллисекунд в случае совершения транзакции, но по сравнению с потерями для бизнеса это того стоит.

Имейте в виду, что сервисный уровень несет только одну ответственность. Либо он завершает операцию, либо возвращает ошибку.

Вы можете подумать, что лучший подход — это иметь абстракцию, которая заботится о транзакциях. Согласен, и об этом тоже был бы отдельный пост. Но я также думаю, что мы можем избежать сложности абстракции, если код будет стабильным и не будет меняться слишком часто.

Официальный пост golang для транзакций также поддерживает аргументацию. Посмотрите фрагмент кода здесь. Офисная почта также использует второй вариант.

// code snippet from the golang official post
func CreateOrder(ctx context.Context, albumID, quantity, custID int) (orderID int64, err error) {
// Create a helper function for preparing failure results.
  fail := func (err error) (int64, error) {
    return fmt.Errorf("CreateOrder: %v", err)
  }
  // Get a Tx for making transaction requests.
  tx, err := db.BeginTx(ctx, nil)
  if err != nil {
    return fail(err)
  }
  // Defer a rollback in case anything fails.
  defer tx.Rollback()

  ... // other code is omitted for brevity

  // Commit the transaction.
  if err = tx.Commit(); err != nil {
    return fail(err)
  }

  // Return the order ID.
  return orderID, nil
}

Подождите, подождите... Есть еще два способа решить проблему. Это также обеспечит еще один уровень безопасности. На мой взгляд, иметь несколько ворот безопасности всегда лучше.

Если один выйдет из строя, есть еще один, который нужно защитить от любых проблем. Критически важные для бизнеса службы с дополнительным уровнем защиты защитят от любых денежных потерь.

Хотите узнать больше?

This is how I react when I get multiple ways to solve a problem

Использование контекста

Мы могли бы использовать контекст для отмены транзакции БД, если она занимает слишком много времени. Это хороший подход, однако вам потребуется точно настроить значение тайм-аута для контекста.

Если время ожидания слишком мало, вы можете слишком рано отменить транзакцию БД. Если время ожидания слишком велико, вы можете долго ждать завершения транзакции БД.

Трассировки приложений могут помочь вам определить оптимальное значение тайм-аута. Кроме того, нагрузочное тестирование приложения может помочь определить оптимальное значение тайм-аута.

Найти правильные значения непросто. Вот почему эта стратегия занимает свое место после предыдущих

func (s *service) CancelSubscription(ctx context.Context, id uuid.UUID) (*model.Subscription, error) {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    tx, err := s.db.BeginTxx(ctx, nil)
    if err != nil {
        return nil, err
    }

    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    subscription, err := s.fetcher.GetSubscription(ctx, tx, id)
    if err != nil {
        return nil, err
    }

    if subscription.CancelledAt != nil {
        return subscription, nil
    }

    subscription.CancelledAt = time.Now()

    err = s.updater.UpdateSubscription(ctx, tx, subscription)
    if err != nil {
        return nil, err
    }

    err = tx.Commit()
    if err != nil {
        return nil, err
    }

    return subscription, nil
}

В приведенном выше коде мы используем контекст с таймаутом 2 секунды. Если транзакция БД не завершена в течение 2 секунд, мы отменяем операцию и возвращаем ошибку.

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

Вызов defer cancel() важен. Это гарантирует, что контекст будет отменен, если функция вернется раньше времени.

Время ожидания на стороне сервера

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

server := &http.Server{
    Addr:         ":8080",
    Handler:      router,
    WriteTimeout: 2 * time.Second,
}

В приведенном выше коде мы устанавливаем тайм-аут на стороне сервера в 2 секунды. Если вся обработка запроса не завершается в течение 2 секунд, мы отменяем операцию и возвращаем клиенту ошибку. При таком подходе вам не нужно создавать контекст тайм-аута для каждого запроса.

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

Заключение

Надеюсь, этот пост был вам полезен. Для служб, ориентированных на клиентов, наличие крошечной ошибки может привести к большим финансовым последствиям. Защита кода с помощью достаточного количества страховочных сеток — это то, что мы должны сделать.

И я надеюсь, что с помощью этого поста я познакомил многих людей с тем, какие худшие вещи могут произойти, если транзакции утекают, и как легко это исправить. 😊

В заключение я рекомендую использовать откат транзакции в откладывать всегда. Трудно найти разумное время ожидания для контекста и время ожидания на стороне сервера. Таким образом, не имеет значения, есть ошибка или нет defer с откатом.

Кроме того, я бы порекомендовал сохранить тайм-аут на стороне сервера для служб HTTP в качестве дополнительного уровня.

Не все приложения основаны на HTTP. Таким образом, тайм-аут контекста также может быть хорошим подходом для других типов приложений.

Контекст в целом имеет множество вариантов использования, и я рекомендую вам больше узнать о нем.

Вы также подумали бы о тайм-ауте на стороне клиента. Они недостаточно надежны. Например, клиент мог держать тайм-аут в течение длительного времени, а при высокой нагрузке сервер мог держать соединение открытым в течение длительного времени.

Это может снова привести к той же проблеме, которую мы пытаемся решить.

Ссылки:

I wanna learn this dance 😅


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE