В 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 }
Есть несколько способов вызвать этот метод:
- Используйте
StoreOrder
напрямую. Например, если вы синхронизируете заказы из Stripe - Используйте
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)
отдельных подключений к базе данных, что может очень быстро привести к снижению производительности базы данных, если вы не будете осторожны.
Большое спасибо Энтони Дуонгу и Райану Вангу за то, что они прочитали черновики этой статьи.