Как обрабатывать условия гонки при сложной пользовательской проверке в Rails? - PullRequest
0 голосов
/ 31 мая 2018

Люди делают бронирование с помощью нашего веб-приложения Rails.

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

Моя проверка немного сложна, но вот упрощенная версия, которая дает представление о части того, что проверяется:

validate :time_availability

def time_availability
  if Reservation.where(date: date, arrival_time: arrival_time).count >= ReservationMax.for(date, arrival_time)
    errors.add(:arrival_time, "This time is not available")
  end
end

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

Ответы [ 4 ]

0 голосов
/ 02 июня 2018

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

Я добавил столбец в Reservation модель называется seat_number и добавляет уникальный индекс для date, arrival_time и seat_number:

class AddSeatNumberToReservations < ActiveRecord::Migration[5.0]
  def change
    add_column :reservations, :seat_number, :integer
    Reservation.update_all("seat_number=id") // so that existing reservations have a unique seat_number
    add_index :reservations, [:date, :arrival_time, :seat_number], unique: true
  end
end

Затем я использую around_save, чтобы установить seat_number в зависимости от того, какуже существует много резервирований на эту дату и время, и спасите от ActiveRecord::RecordNotUnique и повторите попытку сохранения с новым номером места

class Reservation < ApplicationRecord
  around_save :check_for_race_condition

  def check_for_race_condition
    seat_count = Reservation.where(date: date, arrival_time: arrival_time).count
    begin
      self.seat_number = seat_count + 1
      yield
    rescue ActiveRecord::RecordNotUnique
      if seat_count + 1 >= ReservationMax.for(date, arrival_time)
        errors.add(:arrival_time, "This time is not available")
      else
        seat_count += 1
        retry
      end
    end
  end
end

Это прекрасно работает.

В моем многопоточном тестеЯ установил пул базы данных на 15, максимальное резервирование (для определенной даты и времени) на 9 и запустил 14 потоков одновременно (кроме основного потока), пытаясь сохранить резервирование для этой даты и времени.В результате получается 9 бронирований с номерами мест от 1 до 9 по порядку, а оставшиеся 5 корректно возвращают бронь с правильной ошибкой.

0 голосов
/ 31 мая 2018

Я не уверен, что валидация модели будет работать здесь из-за возможного состояния гонки - вместо этого вам нужно заключить ее в транзакцию и сделать это в обратном направлении:

date, arrival_time = @reservation.date, @reservation.arrival_time
Reservation.transaction do
  @reservation.save!
  unless Reservation.where(date: date, arrival_time:arrival_time).count >= ReservationMax.for(date, arrival_time)
    raise ActiveRecord::Rollback, "This time is not available"
  end
end

if @reservation.persisted?
  redirect_to @reservation
else
  redirect_to :somewhere_else
end

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

0 голосов
/ 31 мая 2018

Еще не тестировал, но у меня есть идея.Он пришел ко мне после того, как он прокомментировал с coorasse его ответ об индексах базы данных.

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

Миграция для новой модели и таблицы:

class CreateFullReservationTimes < ActiveRecord::Migration[5.0]                                                                   
  def change
    create_table :full_reservation_times do |t|
      t.date :date
      t.datetime :arrival_time

      t.index [:date, :arrival_time], unique: true

      t.timestamps
    end
  end
end

Добавьте before_save к модели Reservation:

class Reservation < ApplicationRecord
  before_save :add_full_reservation_time, if: :arrival_time_will_be_full?

  def add_full_reservation_time
    begin
      FullReservationTime.create!(date: date, arrival_time: arrival_time)                                                         
    rescue ActiveRecord::RecordNotUnique
      errors.add(:arrival_time, "This time is not available")
      throw :abort
    end
  end

  def arrival_time_will_be_full?
    Reservation.one_or_none_left_at?(date, arrival_time)                                                                                     
  end
end

Таким образом, если осталось одно (или нет) резервирования before save ing, оно пытается create a FullReservationTime (и если есть условие гонки для двух одновременных «последних резервирований», только одно будет успешным из-за ActiveRecord::RecordNotUniqe


ОБНОВЛЕНИЕ

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

Если я начну с одного оставленного резервирования, тест проходит и сохраняется только одно резервирование!

Если я начну с двух оставленных резервирований и запустю 3 одновременных потока, не пройдёт , потому что все они сохраняются раньшеесть время проверить, есть лиили ничего не осталось.

Я попробовал метод из ответа Макса (завершение транзакции, выполнение проверки после сохранения и повышение ActiveRecord::Rollback, если проверка не пройдена, и тест не прошел.Все темы успешно сохраняют запись ...

0 голосов
/ 31 мая 2018

Вам нужно инкапсулировать все в транзакции, если вам нужно выполнить больше операций:

def create
  Reservation.transaction do
    reservation = Reservation.new(parsed_params)
    if reservation.save
      #do other things within the transaction
    else
      #...
    end
  end
end

Пожалуйста, прочитайте также: http://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html

и для оптимистической блокировки: http://api.rubyonrails.org/classes/ActiveRecord/Validations/ClassMethods.html#method-i-validates_uniqueness_of-label-Concurrency+and+integrity

ваш код должен вызывать исключение StatementInvalid, если у вас есть индекс.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...