В Assembled мы используем Golang в качестве нашего эксклюзивного внутреннего языка с момента нашего основания в 2018 году. Мы запускаем довольно стандартное веб-приложение, но обнаружили, что доступ к базе данных сопряжен с особым набором проблем, которые еще не были решены. полностью решается стандартной библиотекой Go или пакетами сообщества.

В этой статье мы поговорим о трех абстракциях, которые мы создали в Assembled и которые упрощают доступ к базе данных в Golang:

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

Задача 1: Написание производительных повторно используемых SQL-запросов

Проблема: совместное использование кода между однострочными и многострочными геттерами.

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

type Order struct {
  ID     string
  ItemID string
  Price  int
}

func GetOrder(id string) (*Order, error) {
  var order Order
  
  row := db.QueryRow("SELECT id, item_id, price FROM orders WHERE id = $1;", id)
  err := row.Scan(&order.ID, &order.ItemID, &order.Price)
  if err != nil {
    return nil, err
  }
  return &order, nil
}

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

func GetAllOrders() ([]Order, error) {
  // Pull out the ids of all of the orders
  rows, err := db.QueryRows("SELECT id FROM orders;")
  if err != nil {
    return nil, err
  }
  defer rows.Close()

  var ids []string
  for rows.Next() {
    var id string
    if err := rows.Scan(&id); err != nil {
      return nil, err
    }
    ids = append(ids, id)
  }

  // For each id, reuse the GetOrder code. Notice that we must run a
  // separate database query for every order
  var orders []Order
  for _, id := range ids {
    order, err := GetOrder(id)
    if err != nil {
      return nil, err
    }
    orders = append(orders, order)
  }
  return orders, nil
}

Проблема в том, что вы делаете O(# of orders) запросов. Это дорого и неэффективно, потому что:

  • Postgres должен анализировать и генерировать план запроса для каждого запроса.
  • Вы добавите время прохождения пакета от вашего веб-сервера к базе данных к каждому запросу, что может очень быстро взорваться, если у вас много запросов [0].

Решение: создать абстракцию для сканирования строки базы данных

Чтобы решить эту проблему, в Assembled мы ввели абстракцию для сканов. Важно понять, что независимо от того, сканируете ли вы одну строку базы данных или несколько строк базы данных, вы должны выполнять одни и те же операции. Вы всегда хотите заполнять одни и те же поля в Order каждый раз, когда извлекаете один из базы данных (независимо от того, получаете ли вы один заказ или несколько). Поэтому мы создали интерфейс Scannable, который скрывает способ получения строки из базы данных.

type Scannable interface {
  Scan(dest ...interface{}) error
}

Теперь вы можете передать sql.Row или sql.Rows в один метод и выполнить ту же операцию. Вот пример того, как вы можете использовать интерфейс Scannable для повторного использования кода:

var orderAttributes = []string{
  "id",
  "item_id",
  "price",
}

func ScanOrder(row Scannable) (*Order, error) {
  var order Order

  err := row.Scan(&order.ID, &order.ItemID, &order.Price)
  if err != nil {
    return nil, err
  }
  return &order, nil
}

func GetOrder(id string) (*Order, error) {
  query := fmt.Sprintf("SELECT %s FROM orders WHERE id = $1;", 
    strings.Join(orderAttributes, ","))

  row := db.QueryRow(query, id)
  return ScanOrder(row)
}

func GetOrders(ids []string) ([]Order, error) {
  query := fmt.Sprintf("SELECT %s FROM orders WHERE id = ANY($1);", 
    strings.Join(orderAttributes, ","))

  rows, err := db.Query(query, pq.Array(ids))
  if err != nil {
     return nil, err
  }
  defer rows.Close()

  var orders []Order
  for rows.Next() {
    order, err := ScanOrder(rows)
    if err != nil {
      return nil, err
    }
    orders = append(orders, *order)
  }
  return orders, nil
}

Теперь общее время выполнения GetOrders равно времени одного обращения к вашей базе данных плюс время, необходимое для выбора совпадающих заказов и возврата их из Postgres. Помимо улучшения скорости запросов, вы сократили количество запросов к базе данных до постоянного числа для каждого вызова GetOrders и значительно снизили нагрузку на базу данных. Наконец, вы также упростили анализ и рефакторинг кода, потому что существует только одна точка входа при обновлении атрибута в структуре Order.

Задача 2: не забыть закрыть набор строк

Проблема: в какой-то момент вы забудете закрыть строки

Нет ничего определенного, кроме смерти, налогов и забывания закрыть ряды.
— Бенджамин Франклин (вероятно)

Одной из неприятных вещей в SQL-драйвере Golang является обязательный вызов torows.Close() после завершения, который освобождает ваше соединение обратно в ваш пул. Если не вызвать этот метод, это приведет к увеличению задержки, увеличению размера пула подключений и, в худшем случае, к сбоям в работе во время праздников, когда никто не выполняет развертывание.

К сожалению, это одна из самых сложных проблем для отладки, если вы не знаете, что ищете. Вам предстоит пройтись по гигантской кодовой базе в поисках тех мест, где кто-то забыл позвонить rows.Close(). Скажу сразу — найти эти экземпляры непросто.

Решение: вспомогательный метод, с которым вы не сможете забыть

Как мы решили эту проблему в Assembled? У нас были еженедельные тренинги, чтобы напомнить всем никогда не забывать звонить defer rows.Close() и публично пристыдить инженеров, которые все еще забывают.

Шучу — мы создали лучшую абстракцию с помощью вспомогательного метода ScanRows:

type Rows interface {
  Close() error
  Err() error
  Next() bool
  Scan(dest ...interface{}) error
}

func ScanRows(r Rows, scanFunc func(row Scannable) error) error {
  var closeErr error
  defer func() {
    if err := r.Close(); err != nil {
      closeErr = err
    }
  }()

  var scanErr error
  for r.Next() {
    err := scanFunc(r)
    if err != nil {
      scanErr = err
      break
    }
  }
  if r.Err() != nil {
   return r.Err()
  }
  if scanErr != nil {
   return scanErr
  }

  return closeErr
}

Обратите внимание, что ScanRows всегда будет закрывать строки после того, как закончит с ними. Функция также имеет дополнительное удобство: она содержит обработку ошибок, которая ранее копировалась снова и снова каждым инженером.

Вот как это будет работать в нашей функции GetOrders:

func GetOrders(ids []string) ([]Order, error) {
  query := fmt.Sprintf("SELECT %s FROM orders WHERE id = ANY($1);", 
    strings.Join(orderAttributes, ","))

  rows, err := db.Query(query, pq.Array(ids))
  if err != nil {
     return nil, err
  }

  var orders []Order
  err := models.ScanRows(rows, func(row Scannable) error) error {
    order, err := ScanOrder(rows)
    if err != nil {
       return err
    }
    orders = append(orders, *order)
    return nil
  })
  if err != nil {
    return nil, err
  }

  return orders, nil
}

Задача 3: повторное использование запросов внутри транзакций

Проблема: совместное использование SQL между транзакциями и не-транзакциями

Допустим, вы только что написали метод для сохранения Order в вашей базе данных:

func StoreOrder(db *sql.DB, order Order) error {
  _, err := db.Exec("INSERT INTO orders (item_id, price) VALUES ($1, $2)", 
    order.ItemID,
    order.Price,
  ) 
  if err != nil {
    return err
  }

  return nil
}

Есть несколько способов вызвать этот метод:

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

В случае 1 вы не хотите хранить заказы в транзакции — длительные транзакции могут плохо сказаться на производительности базы данных, поэтому вы можете просто использовать свой метод StoreOrder, который вы уже написали. Но в случае 2 вы хотите сохранить свой заказ в транзакции, поэтому вам нужно добавить дополнительный код. Вот как это выглядит в итоге:

func StoreOrder(db *sql.DB, order Order) error {
  _, err := db.Exec("INSERT INTO orders (item_id, price) VALUES ($1, $2)", 
    order.ItemID,
    order.Price,
  ) 
  if err != nil {
    return err
  }

  return nil
}

func StoreOrderTx(tx sql.Tx, order Order) (*Order, error) {
  _, err := tx.Exec("INSERT INTO orders (item_id, price) VALUES ($1, $2)", 
    order.ItemID,
    order.Price,
  ) 
  if err != nil {
    return err
  }

  return nil
}

// SyncOrderFromStripe takes an order from Stripe and syncs it into your database without a transaction
func SyncOrderFromStripe(db *sql.DB, stripeID string) (*Order, error) {
  stripeOrder, err := stripeClient.Get(stripeID)
  if err != nil {
    return err
  }
  order := Order{ItemID: stripeOrder.Items[0].ID, Price: stripeOrder.Amount}
  return StoreOrder(db, order)
}

// StoreOrderAndPayment stores the order and payment information in a single transaction
func StoreOrderAndPayment(db *sql.DB, order Order, payment Payment) (*Order, *Payment, error) {
  tx, err := db.Begin()
  if err != nil {
    return nil, nil, err
  }

  storedOrder, err := StoreOrderTx(tx, order)
  if err != nil {
    return nil, nil, err
  }
  storedPayment, err := StorePaymentTx(tx, payment)
  if err != nil {
    return nil, nil, err
  } 

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

Обратите внимание, что вы должны в основном скопировать все внутри StoreOrder в StoreOrderTx с той лишь разницей, что в первом вы запускаете метод на sql.DB, а во втором вы запускаете его на sql.Tx.

Это много неудачного копирования кода, и если вы измените какой-либо атрибут в Order, вы должны помнить об обновлении как StoreOrder, так и StoreOrderTx. И давайте смотреть правде в глаза, в какой-то момент кто-то забудет и вызовет ошибку.

Решение: интерфейс для объектов, подобных базе данных, и помощник для транзакций.

Обратите внимание, что вместо копирования кода метод StoreOrder на самом деле не заботится о том, работает ли он с sql.DB или sql.Tx, он просто заботится о том, чтобы он мог записывать в базу данных. Это идеальное время, чтобы ввести абстракцию Database, чтобы скрыть это:

type Database interface {
  Query(query string, args ...interface{}) (*sql.Rows, error)
  QueryRow(query string, args ...interface{}) *sql.Row
  Exec(query string, args ...interface{}) (sql.Result, error)
}

Теперь вы можете удалить свой метод StoreOrderTx, потому что и sql.DB, и sql.Tx будут реализовывать интерфейс Database, который может значительно упростить ваш код:

func StoreOrder(db Database, order Order) error {
  _, err := db.Exec("INSERT INTO orders (item_id, price) VALUES ($1, $2)", 
    order.ItemID,
    order.Price,
  ) 
  if err != nil {
    return err
  }

  return nil
}

// SyncOrderFromStripe takes an order from Stripe and syncs it into your database without a transaction
func SyncOrderFromStripe(db *sql.DB, stripeID string) (*Order, error) {
  stripeOrder, err := stripeClient.Get(stripeID)
  if err != nil {
    return err
  }
  order := Order{ItemID: stripeOrder.Items[0].ID, Price: stripeOrder.Amount}
  return StoreOrder(db, order)
}

// StoreOrderAndPayment stores the order and payment information in a single transaction
func StoreOrderAndPayment(db *sql.DB, order Order, payment Payment) (*Order, *Payment, error) {
  tx, err := db.Begin()
  if err != nil {
    return nil, nil, err
  }

  storedOrder, err := StoreOrder(tx, order)
  if err != nil {
    return nil, nil, err
  }
  storedPayment, err := StorePayment(tx, payment)
  if err != nil {
    return nil, nil, err
  } 

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

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

Заключение

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

Если вы заинтересованы в том, чтобы опираться на эти абстракции (или создавать их больше), свяжитесь со мной по адресу [email protected] или посетите нашу страницу вакансий: https://www.assembled.com/careers- в сборе.

[0]: Это особенно дорого обходится, если ваш веб-сервер и ваша база данных не расположены вместе — в некоторых случаях это имело место для Assembled, когда мы начали создавать более глобальную инфраструктуру. Если запрос для выбора подходящих идентификаторов заказов занимает 100 мс, и если требуется 30 мс, чтобы пройти туда и обратно от вашего веб-сервера до вашей базы данных, и, скажем, требуется 10 мс, чтобы получить один заказ из Postgres, то всего 30 заказов займет (30ms + 100ms) + (30ms + 10ms)*30 = 1.3s. Это неприемлемая производительность для большинства уважающих себя приложений электронной коммерции. Конечно, вы всегда можете попытаться разместить свой веб-сервер и базу данных в одном и том же центре обработки данных, но у этого есть свои проблемы. Кроме того, вы по-прежнему будете выполнять O(# of orders)отдельных подключений к базе данных, что может очень быстро привести к снижению производительности базы данных, если вы не будете осторожны.

Большое спасибо Энтони Дуонгу и Райану Вангу за то, что они прочитали черновики этой статьи.