기술나눔

[ruby on Rails] 배포 중 ActiveRecord::PreparedStatementCacheExpired 오류에 대한 이유 및 해결 방법

2024-07-12

한어Русский языкEnglishFrançaisIndonesianSanskrit日本語DeutschPortuguêsΕλληνικάespañolItalianoSuomalainenLatina

1. 질문:

  • 때때로 Postgres에 Rails 애플리케이션을 배포할 때 ActiveRecord::PreparedStatementCacheExpired 오류가 나타날 수 있습니다. 이는 배포에서 마이그레이션을 실행할 때만 발생합니다.
  • 이는 Rails가 Postgres의 준비된 명령문 캐시(PreparedStatementCache) 기능을 활용하여 성능을 향상시키기 때문에 발생합니다. 이 기능은 레일에서 기본적으로 활성화되어 있습니다.

2. 문제 재발:

  • rspec을 사용하여 이 오류를 재현할 수 있습니다.
 it 'not raise ActiveRecord::PreparedStatementCacheExpired' do
    create(:user)
    User.first
    User.find_by_sql('ALTER TABLE users ADD new_metric_column integer;')
    ActiveRecord::Base.transaction { User.first }
  end
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

여기에 이미지 설명을 삽입하세요.

3. 생산 원리:

  • 다음과 같은 Rails 쿼리문User.all작성자: active_record SQL 문으로 구문 분석그런 다음 데이터베이스로 보내십시오.먼저 PREPARE를 실행하세요. 준비된 문, SQL 문은 구문 분석, 분석, 최적화 및 다시 작성됩니다.후속 조치를 취할 때EXECUTE 명령 실행, 준비된 진술이 계획되고 실행됩니다.
  • Rails는 쿼리 문을 다음 위치에 저장합니다.pg_prepared_statements다음에 유사한 명령문을 호출할 때 용이하게 하기 위해.직접 실행 문을 구문 분석, 분석, 최적화하는 대신 작업 중복을 피하고 효율성을 향상시킵니다.
User.first
User.all
# 执行上面的2个查询后,用connection.instance_variable_get(:@statements)就可以看到缓存的准备语句
ActiveRecord::Base.connection.instance_variable_get(:@statements)
==> <ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::StatementPool:0x00000001086b13c8 
@cache={78368=>{""$user", public-SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 
$1"=>"a7", ""$user", public-SELECT "users".* FROM "users" /* loading for inspect */ LIMIT $1"=>"a8"}},
@statement_limit=1000, @connection=#<PG::Connection:0x00000001086b31a0>, @counter=8>

# 这个也可以看到,会在数据库中去查询
ActiveRecord::Base.connection.execute('select * from pg_prepared_statements').values
(0.5ms) select * from pg_prepared_statements
==> [["a7", "SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1", "2024-07-
11T07:03:06.891+00:00", "{bigint}", false], ["a8", "SELECT "users".* FROM "users" /* loading for inspect 
*/ LIMIT $1", "2024-07-11T07:04:47.772+00:00", "{bigint}", false]]
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • Postgres에서는 테이블의 스키마 변경이 반환된 결과에 영향을 미치는 경우 준비된 명령문 캐시가 무효화됩니다. 구체적으로는 테이블에 필드를 추가 및 삭제하거나 필드 유형 및 길이를 수정하는 등의 DDL 작업입니다.

다음 예와 같이 필드를 추가하거나 삭제한 후 SELECT를 실행하면,PG 데이터베이스던질 것이다cached plan must not change result type, 레일에서활성 레코드이 오류가 발생한 다음 던집니다.ActiveRecord::PreparedStatementCacheExpired

ALTER TABLE users ADD COLUMN new_column integer;
ALTER TABLE users DROP COLUMN old_column;
添加或删除列,然后执行 SELECT *
删除 old_column 列然后执行 SELECT users.old_column
  • 1
  • 2
  • 3
  • 4
  • 배포 서비스에서 필드를 추가, 빼기, 수정하는 마이그레이션을 실행할 때 사용자가 실행한 쿼리문은 준비된 명령문 캐시에서 SQL을 직접 가져와서 직접 실행하게 되는데, 이때 테이블 구조가 변경되기 때문에, 준비된 명령문 캐시가 유효하지 않게 됩니다.PG 데이터베이스던질 것이다cached plan must not change result type실수
  • active_record 소스 코드 보기exec_cache방법, pg에 대한 레일의 오류 처리 방법은 다음과 같습니다.
    1. 거래에서 직접 던져집니다. raise ActiveRecord::PreparedStatementCacheExpired.new(e.cause.message)
    2. 트랜잭션 외부의 사람들은 @statements를 캐시합니다.이 문장을 삭제하고 시도해 보세요, 재시도 후 SQL 문이 다시 구문 분석, 분석, 최적화 및 실행됩니다.prepare_statement준비된 명령문 캐시에 대한 메소드
module ActiveRecord
  module ConnectionHandling
    def exec_cache(sql, name, binds)
      materialize_transactions
      mark_transaction_written_if_write(sql)
      update_typemap_for_default_timezone

      stmt_key = prepare_statement(sql, binds)
      type_casted_binds = type_casted_binds(binds)

      log(sql, name, binds, type_casted_binds, stmt_key) do
        ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
          @connection.exec_prepared(stmt_key, type_casted_binds)
        end
      end
    rescue ActiveRecord::StatementInvalid => e
      raise unless is_cached_plan_failure?(e)

      # Nothing we can do if we are in a transaction because all commands
      # will raise InFailedSQLTransaction
      if in_transaction?
        raise ActiveRecord::PreparedStatementCacheExpired.new(e.cause.message)
      else
        @lock.synchronize do
          # outside of transactions we can simply flush this query and retry
          @statements.delete sql_key(sql)
        end
        retry
      end
    end
  end
end
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 따라서 트랜잭션에 나타나는 이 오류로 인해 트랜잭션이 롤백됩니다. 비즈니스의 경우 이는 요청이 실패했으며 직접 처리해야 함을 의미합니다.

4. 해결책:

1. 캐시 준비 명령문 기능을 비활성화합니다(권장하지 않음)

Rails6 이상에서는 데이터베이스의 prepare_statements를 false로 설정하여 이 기능을 비활성화할 수 있습니다.

default: &default
  adapter: postgresql
  encoding: unicode
  prepared_statements: false
  • 1
  • 2
  • 3
  • 4

Rails6 이하 버전은 테스트되지 않았습니다. 위의 방법이 작동하지 않으면 새 초기화 파일을 생성해 보세요.

# config/initializers/disable_prepared_statements.rb:
db_configuration = ActiveRecord::Base.configurations[Rails.env]
db_configuration.merge!('prepared_statements' => false)
ActiveRecord::Base.establish_connection(db_configuration)
  • 1
  • 2
  • 3
  • 4

확인하다:

User.all
ActiveRecord::Base.connection.execute('select * from pg_prepared_statements').values
==> []
  • 1
  • 2
  • 3

결론: 소규모 프로젝트에서는 이 기능을 비활성화해도 문제가 없으며 성능에 거의 영향을 미치지 않습니다. 그러나 대규모 프로젝트에서는 사용자가 많을수록, 쿼리문이 복잡할수록 이 기능이 가져오는 이점은 더 커집니다. 실제 상황에 따라 비활성화 여부를 결정할 수 있습니다.

2. 만들다select * ~이 되다select id, nameRails7의 특정 필드공식 솔루션그게 다야

  • Rails7에서 enumerate_columns_in_select_statements를 true로 설정합니다.
# config/application.rb
module MyApp
  class Application < Rails::Application
    config.active_record.enumerate_columns_in_select_statements = true
  end
end
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • Rails7 아래에는 그러한 구성이 없습니다. 이를 달성하려면 무시된_열을 사용할 수 있습니다.
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
  #__fake_column__是自定义的,不要是某个表中的字段就行,如果是[:id],那么 User.all就会被解析为select name from users,没有id了
  self.ignored_columns = [:__fake_column__] 
end
  • 1
  • 2
  • 3
  • 4
  • 5

결론: 이 솔루션의 문제점은 필드 추가는 완벽하게 해결할 수 있지만 필드를 삭제하면 여전히 오류가 발생한다는 것입니다. 예를 들어 이름 필드를 삭제한 후에도 준비된 명령문의 이름인 select id, name from users가 존재하지 않는 경우, 오류가 보고됩니다. Rails7 공식 솔루션에도 이 문제가 있습니다.

3. 레일 애플리케이션을 다시 시작하세요.

  • 준비된 명령문 캐시의 수명 주기는 하나의 데이터베이스 세션에만 존재합니다. 데이터베이스 연결이 닫히면(애플리케이션을 다시 시작하면 원래 연결이 닫히고 새 연결이 다시 설정됨) 원래 준비된 명령문 캐시가 지워지고 다시 시작하면 SQL 요청이 다시 시작됩니다. 준비된 문을 캐시하면 정상적으로 데이터를 가져올 수 있습니다.

결론: 애플리케이션을 다시 시작하면 임시 서비스 502 Unavailable이 발생합니다. 물론 애플리케이션을 배포할 때 서비스도 다시 시작해야 하며 502도 나타나므로 아무도 액세스하지 않을 때 배포하는 것이 가장 좋습니다. 한밤중?) 오류가 최대한 적게 나타나도록PreparedStatementCacheExpired오류 신고

4. 다시 작성 transaction 방법

class ApplicationRecord < ActiveRecord::Base
  class << self
    def transaction(*args, &block)
      retried ||= false
      super
    rescue ActiveRecord::PreparedStatementCacheExpired
      if retried
        raise
      else
        retried = true
        retry
      end
    end
  end
end
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 다시 작성한 후에는 코드에서 트랜잭션이 기록되는 위치가 다음을 사용하도록 변경됩니다. ApplicationRecord.transaction do ... end 또는MyModel.transaction

결론: 중요 사항: 이메일을 보내거나 API에 게시하거나 트랜잭션의 일부로 외부 세계와 상호 작용하는 기타 작업을 수행하는 경우 이러한 작업 중 일부가 때때로 두 번 발생할 수 있습니다. 이것이 Rails가 공식적으로 재시도를 자동으로 수행하지 않고 애플리케이션 개발자에게 맡기는 이유입니다.

&gt;&gt;&gt;&gt;&gt;&gt;&gt;이 방법을 직접 테스트해도 여전히 오류가 발생합니다.

5. 준비된 명령문 캐시를 수동으로 지우기

 ActiveRecord::Base.connection.clear_cache!
  • 1

5. 최종 답변

완벽한 해결책을 찾지 못함