Rails Postgres 查询无法返回新记录
Posted
技术标签:
【中文标题】Rails Postgres 查询无法返回新记录【英文标题】:Rails Postgres Query Fails to Return New Records 【发布时间】:2019-01-30 23:37:38 【问题描述】:这个 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.1 Dyno 处理,而我的 Puma.rb 设置为 4 个 worker 和 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)
【问题讨论】:
你可能需要展示@transit
是如何初始化的
添加了@transit 初始化
有一个错字。哪里不应该有)
你能显示日志中的2个查询和db中的2个记录吗?
我已将日志和数据库记录添加为 Rails 控制台的输出。我还纠正了@LeninRajRajasekaran 提到的问题中的错字(这不在我的实际代码中)
【参考方案1】:
在 Rails 中防止重复的一种方法是使用验证: Correct way of prevent duplicate records in Rails
但是,您的条件更复杂,因为它涉及跨越多行。 我相信您的标准是,如果最近的过境记录是在不到 20 秒前创建的,则不允许输入过境记录。对吗?
这里提到了试图强制执行涉及查看多行数据的约束是不可取的: SQL Sub queries in check constraint
触发器可用于在数据库级别强制执行您的约束。 可以在异常中捕获触发器。 有一个名为 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(现在)和带有我们标签的最新过境条目之间的差异。这种差异是 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)
添加 Extract(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 秒的延迟运行 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.95695\n"
rails 外的短期测试:
#!/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 之类的东西 - 性能可能更好
【讨论】:
【参考方案2】:我相信这是一个并发问题。
在 ActiveRecord 返回后,Rails 事务会异步继续。任何时候提交需要 15 秒来应用它都会导致这个问题。这很长而且不太可能,但有可能。
我无法证明这就是发生的事情,但它似乎是唯一的解释。防止它需要一个dB存储过程或@PhilipWright建议的或像你和@kwerle建议的分布式锁。
【讨论】:
【参考方案3】:这就是测试的目的。
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
等
附言考虑使用 redis 或类似的东西。否则,您会想要做一些类似桌子锁的事情,以确保您不会踩到自己。而且你可能不想做表锁。
【讨论】:
我确实有一个控制器规范,它通过了,所以我认为这不太可能是逻辑问题。以上是关于Rails Postgres 查询无法返回新记录的主要内容,如果未能解决你的问题,请参考以下文章
如何使用 Postgres 在 Rails 中按天对记录进行分组
Postgres / Rails Active Record - 查询四舍五入的浮点值
在 Postgres 迁移后,Rails 写入时间增加了 100%
Rails 和 Postgres:迁移到 change_colomn 时出现错误“无法强制转换为没有时区的时间戳”