我正在尝试使用悲观锁定来避免竞争条件。我期待在一个线程通过 SELECT FOR UPDATE
获取一行后,另一个寻找同一行的线程将被阻止,直到释放锁。 但是,经过测试,似乎锁不成立,第二个线程可以获取该行并更新它,即使第一个线程尚未保存(更新)该行。
以下是相关代码:
数据库架构
class CreateMytables < ActiveRecord::Migration
def change
create_table :mytables do |t|
t.integer :myID
t.integer :attribute1
t.timestamps
end
add_index :mytables, :myID, :unique => true
end
end
mytables_controller.rb
class MytablessController < ApplicationController
require 'timeout'
def create
myID = Integer(params[:myID])
begin
mytable = nil
Timeout.timeout(25) do
p "waiting for lock"
mytable = Mytables.find(:first, :conditions => ['"myID" = ?', myID], :lock => true ) #'FOR UPDATE NOWAIT') #true)
#mytable.lock!
p "acquired lock"
end
if mytable.nil?
mytable = Mytables.new
mytable.myID = myID
else
if mytable.attribute1 > Integer(params[:attribute1])
respond_to do |format|
format.json{
render :json => "{"Error": "Update failed, a higher attribute1 value already exist!",
"Error Code": "C"
}"
}
end
return
end
end
mytable.attribute1 = Integer(params[:attribute1])
sleep 15 #1
p "woke up from sleep"
mytable.save!
p "done saving"
respond_to do |format|
format.json{
render :json => "{"Success": "Update successful!",
"Error Code": "A"
}"
}
end
rescue ActiveRecord::RecordNotUnique #=> e
respond_to do |format|
format.json{
render :json => "{"Error": "Update Contention, please retry in a moment!",
"Error Code": "B"
}"
}
end
rescue Timeout::Error
p "Time out error!!!"
respond_to do |format|
format.json{
render :json => "{"Error": "Update Contention, please retry in a moment!",
"Error Code": "B"
}"
}
end
end
end
end
我已经在两个设置中对其进行了测试,一个是在 Heroku 上运行带有 worker_processes 4
的独角兽应用程序,另一个是在我的机器上本地运行并设置 PostgreSQL 9.1,运行该应用程序的两个单线程实例,一个是rails server -p 3001
,另一个是thin start
(出于某种原因,如果我只是单独运行rails server
或thin start
, 他们只会按顺序处理传入呼叫)。
设置 1:数据库中感兴趣的 myID 的原始属性 1 值为 3302。我向 Heroku 应用程序发起了一个更新调用(将属性 1 更新为值 3303),然后等待大约 5 秒并向 Heroku 应用程序触发了另一个更新调用(将属性 1 更新为值 3304)。 我预计第二个调用大约需要 25 秒才能完成,因为第一个调用需要 15 秒才能完成,因为我在 mytable.save!
之前在代码中引入了sleep 15
命令,第二个调用应该在线路mytable = Mytables.find(:first, :conditions => ['"myID" = ?', myID], :lock => true )
处被阻止大约 10 秒,然后它获得锁然后休眠 15 秒。但事实证明,第二次调用仅比第一次调用晚了大约 5 秒。
如果我反转请求顺序,即第一次调用是将属性 1 更新为 3304,而 5 秒延迟的第二次调用是将属性 1 更新为 3303,则最终值为 3303。查看 Heroku 上的登录,第二个调用没有等待时间获取锁,而理论上第一个调用处于睡眠状态,因此仍然持有锁。
设置 2:运行同一应用程序的两个 Thin rails 服务器,一个在端口 3000 上,一个在端口 3001 上。 我的理解是它们连接到同一个数据库,因此如果服务器的一个实例通过SELECT FOR UPDATE
获取锁,另一个实例应该无法获取锁并将被阻止。 但是,锁定行为与 Heroku 上的行为相同(没有按我的预期工作)。 而且由于服务器在本地运行,我设法执行了额外的调整测试,以便在第一个调用休眠 15 秒时,我在启动第二个调用之前更改了代码,以便 5 秒后的第二个调用在获得锁后仅休眠 1 秒,并且第二个调用确实比第一个调用早得多完成......
我还尝试使用SELECT FOR UPDATE NOWAIT
并在SELECT FOR UPDATE
行之后立即引入额外的行mytable.lock!
,但结果是相同的。
所以在我看来,虽然 SELECT FOR UPDATE
命令已成功发送到 PostgreSQL 表,但其他线程/进程仍然可以SELECT FOR UPDATE
同一行,甚至UPDATE
同一行而不会阻塞......
我完全感到困惑,欢迎任何建议。 谢谢!
P.S.1 我在行上使用锁的原因是,我的代码应该能够确保只有将行更新为更高 attribute1 值的调用才会成功。
P.S.2 本地日志中的示例 SQL 输出
"waiting for lock"
Mytables Load (4.6ms) SELECT "mytables".* FROM "mytables" WHERE ("myID" = 1935701094) LIMIT 1 FOR UPDATE
"acquired lock"
"woke up from sleep"
(0.3ms) BEGIN
(1.5ms) UPDATE "mytables" SET "attribute1" = 3304, "updated_at" = '2013-02-02 13:37:04.425577' WHERE "mytables"."id" = 40
(0.4ms) COMMIT
"done saving"
事实证明,因为PostGreSQL已经自动提交默认为打开,行
Mytables Load (4.6ms) SELECT "mytables".* FROM "mytables" WHERE ("myID" = 1935701094) LIMIT 1 FOR UPDATE
实际上后跟一个自动提交,因此释放锁。
从此页面阅读时我弄错了http://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html那
.find(____, :lock => true)
方法自动打开交易,类似于
.with_lock(lock = true)
在同一页的末尾覆盖...
所以要修复我的 Rails 代码,我只需要通过添加
Mytables.transaction do
下
begin
并在"救援"行之前添加一个额外的"结束"。
生成的 SQL 输出将更像:
(0.3ms) BEGIN
Mytables Load (4.6ms) SELECT "mytables".* FROM "mytables" WHERE ("myID" = 1935701094) LIMIT 1 FOR UPDATE
(1.5ms) UPDATE "mytables" SET "attribute1" = 3304, "updated_at" = '2013-02-02 13:37:04.425577' WHERE "mytables"."id" = 40
(0.4ms) COMMIT