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

У меня есть простая модель Payments, в которой есть два поля amount и running_balance. Когда создается новая payment запись, мы ищем running_balance предыдущего платежа, скажем last_running_balance, и сохраняем last_running_balance+amount как running_balance текущего платежа.

Вот три наших неудачных попытки реализовать модель Payments. Для простоты предположим, что предыдущий платеж всегда существует, а ids увеличиваются по мере создания платежей.

Попытка 1:

class Payments < ActiveRecord::Base
    before_validation :calculate_running_balance
    private
    def calculate_running_balance
        p = Payment.last
        self.running_balance = p.running_balance + amount
    end
end

Попытка 2:

class Payments < ActiveRecord::Base
    after_create :calculate_running_balance
    private
    def calculate_running_balance
        p = Payment.where("id < ?", id).last
        update!(running_balance: p.running_balance + amount)
    end
end

Попытка 3:

class Payments < ActiveRecord::Base
    after_commit :calculate_running_balance
    private
    def calculate_running_balance
        p = Payment.where("id < ?", id).last
        update!(running_balance: p.running_balance + amount)
    end
end

Эти реализации могут вызвать состояние гонки в системе, поскольку мы используем sidekiq для создания платежей в фоновом режиме. Допустим, последний платеж payment 1. Когда два новых платежа, скажем, payment 2 и payment 3 создаются одновременно, их running_balance может быть вычислен на основе текущего баланса payment 1, потому что может случиться так, что когда payment 3 вычисляет его текущий баланс, payment 2 не был сохранен в базы данных пока нет.

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


person Zilin J.    schedule 11.03.2016    source источник
comment
Я бы, наверное, не стал хранить производные данные. Но если бы я это сделал, это был бы всего лишь один простой запрос UPDATE.   -  person Strawberry    schedule 11.03.2016
comment
Это больше похоже на недостаток моделирования. Я бы подумал, стоит ли вам создать что-то вроде модели Account, которая отслеживает баланс, вместо того, чтобы создавать странную взаимозависимость между платежами.   -  person max    schedule 11.03.2016
comment
@max к сожалению, мы должны отображать текущий баланс вместе с каждым платежом. Учитывая тысячи платежей, я не вижу способа обойти это, если текущий баланс для каждого платежа не вычисляется и не сохраняется.   -  person Zilin J.    schedule 11.03.2016
comment
Привет, @ Strawberry, я был бы рад услышать атомарное ОБНОВЛЕНИЕ, которое сделает свою работу.   -  person Zilin J.    schedule 11.03.2016
comment
В этом случае рассмотрите следующий простой двухэтапный курс действий: 1. Если вы еще этого не сделали, предоставьте правильные DDL (и / или sqlfiddle), чтобы мы могли более легко воспроизвести проблему. 2. Если вы еще не сделали этого, предоставьте желаемый набор результатов, который соответствует информации, предоставленной на шаге 1.   -  person Strawberry    schedule 11.03.2016


Ответы (1)


Обновление: это первая версия, о реально работающем подходе см. ниже:

Вы можете избавиться от условий гонки, если заблокируете последний платеж при расчете последнего баланса, используя пессимистическая блокировка. Чтобы это работало, вам всегда нужно обернуть создание платежей блоком транзакции.

class Payments < ActiveRecord::Base
  before_create :calculate_running_balance

  private
  def calculate_running_balance
    last_payment = Payment.lock.last
    self.running_balance = last_payment.running_balance + amount
  end
end

# then, creating a payment must always be done in transaction
Payment.transaction do
  Payment.create!(amount: 100)
end

Первый запрос для получения последнего Payment также заблокирует запись (и отложит дальнейший запрос к ней) на время транзакции, которая ее обертывает, то есть до момента полной фиксации транзакции и создания новой записи. Если другой запрос тем временем также пытается прочитать заблокированный последний платеж, ему придется дождаться завершения первой транзакции. Поэтому, если вы используете транзакцию в своем sidekiq при создании платежа, вы должны быть в безопасности.

См. Указанное выше руководство для получения дополнительной информации.

Обновление: это не так просто, такой подход может привести к тупикам

После тщательного тестирования проблема кажется более сложной. Если мы заблокируем только «последнюю» платежную запись (которую Rails переводит как SELECT * FROM payments ORDER BY id DESC LIMIT 1), мы можем попасть в тупик.

Здесь я представляю тест, который приводит к тупиковой ситуации, фактически рабочий подход ниже.

Во всех приведенных ниже тестах я работаю с простой таблицей InnoDB в MySQL. Я создал простейшую payments таблицу, в которой только столбец amount добавил первую строку и сопутствующую модель в Rails, например:

# sql console
create table payments(id integer primary key auto_increment, amount integer) engine=InnoDB;
insert into payments(amount) values (100);
# app/models/payments.rb
class Payment < ActiveRecord::Base
end

Теперь давайте откроем две консоли Rails, запустим длительную транзакцию с блокировкой последней записи и вставкой новой строки в первую и еще одну блокировку последней строки во втором сеансе консоли:

# rails console 1
>> Payment.transaction { p = Payment.lock.last; sleep(10); Payment.create!(amount: (p.amount + 1));  }
D, [2016-03-11T21:26:36.049822 #5313] DEBUG -- :    (0.2ms)  BEGIN
D, [2016-03-11T21:26:36.051103 #5313] DEBUG -- :   Payment Load (0.4ms)  SELECT  `payments`.* FROM `payments`  ORDER BY `payments`.`id` DESC LIMIT 1 FOR UPDATE
D, [2016-03-11T21:26:46.053693 #5313] DEBUG -- :   SQL (1.0ms)  INSERT INTO `payments` (`amount`) VALUES (101)
D, [2016-03-11T21:26:46.054275 #5313] DEBUG -- :    (0.1ms)  ROLLBACK
ActiveRecord::StatementInvalid: Mysql2::Error: Deadlock found when trying to get lock; try restarting transaction: INSERT INTO `payments` (`amount`) VALUES (101)

# meanwhile in rails console 2
>> Payment.transaction { p = Payment.lock.last; }
D, [2016-03-11T21:26:37.483526 #8083] DEBUG -- :    (0.1ms)  BEGIN
D, [2016-03-11T21:26:46.053303 #8083] DEBUG -- :   Payment Load (8569.0ms)  SELECT  `payments`.* FROM `payments`  ORDER BY `payments`.`id` DESC LIMIT 1 FOR UPDATE
D, [2016-03-11T21:26:46.053887 #8083] DEBUG -- :    (0.1ms)  COMMIT
=> #<Payment id: 1, amount: 100>

Первая транзакция зашла в тупик. Одним из решений было бы использовать код из начала этого ответа, но повторить всю транзакцию при возникновении тупика.

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

Воспользовавшись методом повторной попытки ошибки блокировки @ M.G.Palmer в этом SO-ответе:

retry_lock_error do
  Payment.transaction 
    Payment.create!(amount: 100)
  end
end

При возникновении тупика транзакция повторяется, т. Е. Обнаруживается и используется последняя последняя запись.

Рабочее решение с тестом

Другой подход, с которым я столкнулся - заблокировать все записи таблицы. Это можно сделать, заблокировав предложение COUNT(*), и, похоже, оно работает последовательно:

# rails console 1
>> Payment.transaction { Payment.lock.count; p = Payment.last; sleep(10); Payment.create!(amount: (p.amount + 1));}
D, [2016-03-11T23:36:14.989114 #5313] DEBUG -- :    (0.3ms)  BEGIN
D, [2016-03-11T23:36:14.990391 #5313] DEBUG -- :    (0.4ms)  SELECT COUNT(*) FROM `payments` FOR UPDATE
D, [2016-03-11T23:36:14.991500 #5313] DEBUG -- :   Payment Load (0.3ms)  SELECT  `payments`.* FROM `payments`  ORDER BY `payments`.`id` DESC LIMIT 1
D, [2016-03-11T23:36:24.993285 #5313] DEBUG -- :   SQL (0.6ms)  INSERT INTO `payments` (`amount`) VALUES (101)
D, [2016-03-11T23:36:24.996483 #5313] DEBUG -- :    (2.8ms)  COMMIT
=> #<Payment id: 2, amount: 101>

# meanwhile in rails console 2
>> Payment.transaction { Payment.lock.count; p = Payment.last; Payment.create!(amount: (p.amount + 1));}
D, [2016-03-11T23:36:16.271053 #8083] DEBUG -- :    (0.1ms)  BEGIN
D, [2016-03-11T23:36:24.993933 #8083] DEBUG -- :    (8722.4ms)  SELECT COUNT(*) FROM `payments` FOR UPDATE
D, [2016-03-11T23:36:24.994802 #8083] DEBUG -- :   Payment Load (0.2ms)  SELECT  `payments`.* FROM `payments`  ORDER BY `payments`.`id` DESC LIMIT 1
D, [2016-03-11T23:36:24.995712 #8083] DEBUG -- :   SQL (0.2ms)  INSERT INTO `payments` (`amount`) VALUES (102)
D, [2016-03-11T23:36:25.000668 #8083] DEBUG -- :    (4.3ms)  COMMIT
=> #<Payment id: 3, amount: 102>

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

Итак, окончательное решение, которое я предлагаю, таково:

class Payments < ActiveRecord::Base
  before_create :calculate_running_balance

  private
  def calculate_running_balance
    Payment.lock.count # lock all rows by pessimistic locking
    last_payment = Payment.last # now we can freely select the last record
    self.running_balance = last_payment.running_balance + amount
  end
end

# then, creating a payment must always be done in transaction
Payment.transaction do
  Payment.create!(amount: 100)
end
person BoraMa    schedule 11.03.2016
comment
Привет, BoraMa, спасибо за ответ. У меня вопрос по механизму блокировки, который не упоминался в руководстве. Если другой запрос также пытается прочитать заблокированный последний платеж и ожидает завершения первой транзакции, получает ли он по-прежнему последнюю запись последнего платежа? - person Zilin J.; 11.03.2016
comment
Привет, Зилин, ты очень хорошо подмечаешь этот вопрос. Я провел несколько тестов и нашел несколько проблем и решений, пожалуйста, посмотрите мой обновленный ответ. - person BoraMa; 12.03.2016
comment
BoraMa, спасибо за проработку. Фактически, мы приняли ваше первое решение, и пока оно работает нормально. Тем временем я пытаюсь обдумать ваш 1-й тестовый пример. В этом SO ответ взаимоблокировки возникают, когда две транзакции пытаются заблокировать две блокировки в противоположных ордерах. Мне сложно применить ту же логику к вашему тесту. Я был бы очень признателен, если бы вы научили меня рассуждать о тупиках в этом случае. - person Zilin J.; 12.03.2016
comment
Зилин, честно говоря, мне самому хотелось бы полностью понять, что здесь происходит, потому что я этого не понимаю. Я думаю, что поведение взаимоблокировки связано с тем, что MySQL блокирует не только данную запись, но в сценариях, подобных этому, также другие записи, а также промежутки между ними. См. здесь и здесь для более подробного объяснения. И, возможно, в этом конкретном случае (order by id desc limit 1) может случиться так, что порядок блокировки не полностью согласован. Но нам понадобится какой-нибудь гуру MySQL, чтобы подтвердить это, поскольку это, по сути, дикая догадка с моей стороны :). - person BoraMa; 12.03.2016
comment
Просто продолжение. До сих пор мы сталкивались с небольшим количеством тупиковых ситуаций. Что касается нашего приложения, мы используем sidekiq для заполнения задания при возникновении исключения взаимоблокировки (аналогично retry lock error в сообщении). - person Zilin J.; 12.03.2016