Конкурентный доступ к записи в базе данных
Допустим у нас есть интернет магазин. У пользователя есть возможность оформить заказ. Задача в том, чтобы избежать двойного создания заказа и лишнего списания денежных средств. Человек оформляет заказ, но наш сайт/приложение подтупливает и на backend уходит параллельно два одинаковых запроса. Данную задачу иногда дают на собеседованиях.
Для написания кода возьмём язык Golang
Моделирование use case
Для начала давайте введём 3 сущности:
На основе этих данных мы можем схематично набросать use case создания заказа.
Описание сущностей и репозиториев
На основе описанного выше use case мы можем написать сущности и репозитории
package entity
// User
type User struct {
id int
name string
balance int
versionUpdate int
}
func (u *User) GetBalance() int {
return u.balance
}
func (u *User) GetId() int {
return u.id
}
func (u *User) GetVersionUpdate() int {
return u.versionUpdate
}
func (u *User) SubtractAmount(sum int) error {
if u.balance < sum {
return errors.New("not enough money")
}
u.balance -= sum
return nil
}
// Product
type Product struct {
id int
name string
price int
}
func (p *Product) GetId() int {
return p.id
}
func (p *Product) GetName() string {
return p.name
}
func (p *Product) GetPrice() int {
return p.price
}
// Order
type Order struct {
id int
userId int
productId int
}
func (o *Order) GetId() int {
return o.id
}
func (o *Order) GetUserId() int {
return o.userId
}
func (o *Order) GetProductId() int {
return o.productId
}
Внимательный читатель заметил странное поле versionUpdate в сущности User. Забегая немного вперед, вокруг этого поля будет крутиться весь подход. Об этом подробней ниже.
package repo
type UserRepo struct {}
func (r *UserRepo) FindUser(userId int) (*entity.User, error) {}
func (r *UserRepo) Save(user *entity.User) error {}
type ProductRepo struct {}
func (r *ProductRepo) FindProduct(productId int) (*entity.Product, error){}
type OrderRepo struct {}
func (r *OrderRepo) Save(order entity.Order) error {}
func (r *OrderRepo) GetNextId() (int, error) {}
Описание use case
Теперь на основе сущностей и репозиториев мы можем набросать черновик use case
package handler
type OrderUsecase struct {
productRepo *repo.ProductRepo
userRepo *repo.UserRepo
orderRepo *repo.OrderRepo
}
func (u *OrderUsecase) MakeOrder(userId int, productId int) error {
// Ищем пользователя
user, err := u.userRepo.FindUser(userId)
if err != nil {
return err
}
// Ищем продукт
product, err := u.productRepo.FindProduct(productId)
if err != nil {
return err
}
// Создаём заказ
newOrderId, err := u.orderRepo.GetNextId()
if err != nil {
return err
}
order := entity.NewOrder(newOrderId, productId, userId)
err = u.orderRepo.Save(order)
if err != nil {
return err
}
// Списываем с баланса деньги
err = user.SubtractAmount(product.GetPrice())
if err != nil {
return err
}
// Сохраняем пользователя
err = u.userRepo.Save(user)
if err != nil {
return err
}
return nil
}
Моделирование базы данных
На основе кода выше мы можем спроектировать базу данных
Тут в общем-то всё достаточно просто, 3 таблицы:
- users - таблица с пользователями
- products - таблица с товарами
- orders - таблица с заказами и она имеет 2 внешних ключа на таблицы users и products
Репозиторий UserRepository
Все репозитории сущностей описывать нет смысла. Их можно посмотреть в на Github. Возьмём только метод Save у UserRepository
func (r *UserRepo) Save(user *entity.User) error {
expectedVersion := user.GetVersionUpdate()
updatedVersion := expectedVersion + 1
result, err := r.db.Exec(
"UPDATE users SET balance = $1, version_update = $2 WHERE id = $3 AND version_update = $4",
user.GetBalance(), updatedVersion, user.GetId(), expectedVersion,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.New("Ошибка при обновлении пользователя")
}
return nil
}
Теперь раскрою идею вокруг поля version_update. При получении пользователя из БД у нас в сущности User будет определенная версия. Например, 10.
При сохранении пользователя в SQL запрос мы добавляем эту версию в WHERE и если другая транзакция не перетёрла данную версию, то мы обновим баланс и сохраним все данные одной транзакцией. Если версия уже изменилась, тогда мы откатываем транзакцию и бросаем ошибку. При этом при сохранении мы увеличиваем версию на 1.
Таким образом мы сможем избежать двойного списывания / дублирования заказа.
Для понимания, данная схема очень сильно упрощена и в вашем коде может быть всё гораздо сложней.
Полный код можно посмотреть на Github