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 时出现错误“无法强制转换为没有时区的时间戳”

如何逃脱? (问号)运算符在 Rails 中查询 Postgresql JSONB 类型

使用 Activerecord、Rails 和 Postgres 查找具有多个重复字段的行