Rails Postgres查询无法返回新记录



这个Rails代码应该防止服务器在20秒内记录重复的记录:

@transit = Transit.new(tag: params[:tag])
if Transit.where(tag: @transit.tag).where("created_at > ?", 20.seconds.ago).first
logger.warn "Duplicate tag"
else
@transit.save!
end

然而,这并不奏效。我可以在我的生产数据库(托管在Heroku上)中看到,用相同的标签创建了两个不同的记录,间隔10秒。

日志显示在第二个请求上执行了正确的查询,但它没有返回任何结果,而是保存了一条新记录。

为什么会发生这种情况?我认为Postgres默认的read_committed隔离级别可以防止这种情况的发生。不返回任何记录的查询应该错过Rails的SQL缓存。日志显示,这两个请求都是由Heroku上的同一WEB.1Dyno处理的,我的Puma.rb是为4个工人和5个线程设置的。

我错过了什么?

这是数据库中的两条记录:

=> #<Transit id: 1080116, tag: 33504, 
created_at: "2019-01-30 12:36:11", 
updated_at: "2019-01-30 12:41:23">
=> #<Transit id: 1080115, tag: 33504, 
created_at: "2019-01-30 12:35:56", 
updated_at: "2019-01-30 12:35:56">

第一次插入的日志:

30 Jan 2019 07:35:56.203132 <190>1 2019-01-30T12:35:56.050681+00:00 app web.1 - - [1m [36m (0.8ms) [0m [1mBEGIN [0m
30 Jan 2019 07:35:56.203396 <190>1 2019-01-30T12:35:56.055097+00:00 app web.1 - - [1m [35mSQL (1.0ms) [0m INSERT INTO "transits" ("tag", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id"
30 Jan 2019 07:35:56.269133 <190>1 2019-01-30T12:35:56.114572+00:00 app web.1 - - [1m [36m (2.0ms) [0m [1mCOMMIT [0m

插入副本之前的查询日志:

30 Jan 2019 07:36:12.160359 <190>1 2019-01-30T12:36:11.863973+00:00 app web.1 - - [1m [35mTransit Load (5.1ms) [0m SELECT "transits".* FROM "transits" WHERE "transits"."tag" = 33504 AND created_at > '2019-01-30 12:35:51.846431' ORDER BY "transits"."id" ASC LIMIT 1

这里是postgres事务隔离级别,需要明确的是,这是针对这个问题出现后打开的不同连接:

SHOW default_transaction_isolation;
default_transaction_isolation 
-------------------------------
read committed
(1 row)

防止Rails中重复的一种方法是验证:防止Rails 中重复记录的正确方法

然而,您的标准更为复杂,因为它涉及跨越多行。我相信你的标准是,如果最近的过境记录是在不到20秒前创建的,就不允许输入过境记录。是这样吗?

这里提到,试图强制执行涉及查看多行数据的约束是不可取的:检查约束中的SQL子查询

触发器可以用于在数据库级别强制执行约束。可以在异常中捕获触发器。有一个名为HairTrigger的宝石可能有用,但不确定。

从这里获取想法:https://karolgalanciak.com/blog/2016/05/06/when-validation-is-not-enough-postgresql-triggers-for-data-integrity/

Postgresql触发器示例:

bin/rails generate model transit tag:text
rails generate migration add_validation_trigger_for_transit_creation
class AddValidationTriggerForTransitCreation < ActiveRecord::Migration[5.2]
def up
execute <<-CODE
CREATE FUNCTION validate_transit_create_time() returns trigger as $$
DECLARE
age int;
BEGIN
age := (select extract(epoch from current_timestamp - t.created_at)
from transits t
where t.tag = NEW.tag
and t.id in (select id from transits u
where u.id = t.id
and u.tag = t.tag
and u.created_at = (select max(v.created_at) from transits v where v.tag = u.tag)
));
IF (age < 20) THEN
RAISE EXCEPTION 'created_at too early: %', NEW.created_at;
END IF;
RETURN NEW;
END;
$$ language plpgsql;
CREATE TRIGGER validate_transit_create_trigger BEFORE INSERT OR UPDATE ON transits
FOR EACH ROW EXECUTE PROCEDURE validate_transit_create_time();
CODE
end
def down
execute <<-CODE
drop function validate_transit_create_time() cascade;
CODE
end
end

user1@debian8 /home/user1/rails/dup_test > ../transit_test.rb ; sleep 20; ../transit_test.rb 
dup_test_development=> select * from transits;
id  |   tag    |         created_at         |         updated_at         
-----+----------+----------------------------+----------------------------
158 | test_tag | 2019-01-31 18:38:10.115891 | 2019-01-31 18:38:10.115891
159 | test_tag | 2019-01-31 18:38:30.609125 | 2019-01-31 18:38:30.609125
(2 rows)

这是我们查询的部分,它提供了带有我们标签的最新过境条目

dup_test_development=> select * from transits t
where t.tag = 'test_tag' and t.id in
(select id from transits u where u.id = t.id and u.tag = t.tag and u.created_at =
(select max(v.created_at) from transits v where v.tag = u.tag));
id  |   tag    |         created_at         |         updated_at         
-----+----------+----------------------------+----------------------------
159 | test_tag | 2019-01-31 18:38:30.609125 | 2019-01-31 18:38:30.609125
(1 row)

修改以给出current_timestamp(now)和带有我们标签的最新中转条目之间的差异。这种差异是postgresql中的一个间隔。使用UTC匹配Rails:

dup_test_development=> select current_timestamp at time zone 'utc' - created_at
from transits t  where t.tag = 'test_tag' and t.id in
(select id from transits u where u.id = t.id and u.tag = t.tag and u.created_at =
(select max(v.created_at) from transits v where v.tag = u.tag));
?column?     
-----------------
00:12:34.146536
(1 row)

添加提取(epoch)将其转换为秒:

dup_test_development=> select extract(epoch from current_timestamp at time zone 'utc' - created_at)
from transits t  where t.tag = 'test_tag' and t.id in
(select id from transits u where u.id = t.id and u.tag = t.tag and u.created_at =
(select max(v.created_at) from transits v where v.tag = u.tag));
date_part  
------------
868.783503
(1 row)

我们将秒存储为年龄,如果年龄<20,我们提出了一个数据库异常

以小于20:的第二延迟运行2次插入

user1@debian8 /home/user1/rails/dup_test > ../transit_test.rb ; sleep 5; ../transit_test.rb 
#<ActiveRecord::StatementInvalid: PG::RaiseException: ERROR:  created_at too early: 2019-01-31 18:54:48.95695
: INSERT INTO "transits" ("tag", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id">
"ERROR:  created_at too early: 2019-01-31 18:54:48.95695n"

轨外短测试:

#!/usr/bin/env ruby
require 'active_record'
require 'action_view'
path = "/home/user1/rails/dup_test/app/models"
require "#{path}/application_record.rb"
Dir.glob(path + "/*.rb").sort.each do | file |
require file
end
ActiveRecord::Base.establish_connection(
:adapter => "postgresql",
:database  => 'dup_test_development',
encoding: "unicode",
username: "user1",
password: nil
)
class Test
def initialize()
end
def go()
begin
t = Transit.new(tag: 'test_tag')
t.save
rescue ActiveRecord::StatementInvalid => e
p e
p e.cause.message
end
end
end
def main
begin
t = Test.new()
t.go()
rescue Exception => e
puts e.message
end
end
main

使用Redis这样的东西可能对的性能更好

我认为这是一个并发问题。

Rails事务在ActiveRecord返回后异步继续。只要提交需要15秒才能应用,就会导致此问题。这是漫长的,不太可能,但有可能。

我无法证明这就是发生的事情,但这似乎是唯一的解释。防止它需要一个dB存储过程,或者像@PhilipWright建议的那样,或者像你和@kwerle建议的那样使用分布式锁。

这就是测试的目的。

class Transit <  ActiveRecord::Base
def new_transit(tag: tag)
<your code>
end
end

您的测试代码:

test 'it saves once' do
<save it once.  check the count, etc>
end
test 'it does not save within 10 seconds' do
<save it once.  Set the created at to 10 seconds ago.  try to save again.  check the count, etc>
end

etc

p.s.考虑使用redis或类似的东西。否则,你想做一些事情,比如锁桌子,以确保你不会踩到自己。而且你可能不想做表锁。

最新更新