May 13, 2024

Конкурентный доступ к записи в базе данных

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

Для написания кода возьмём язык Golang

Моделирование use case

Для начала давайте введём 3 сущности:

  • User
  • Product
  • Order

На основе этих данных мы можем схематично набросать 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

Подписывайся на мой телеграмм