Compartilhamento de tecnologia

[ruby on rails] Razões e soluções para erros ActiveRecord::PreparedStatementCacheExpired durante a implantação

2024-07-12

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

1. Pergunta:

  • Às vezes, ao implantar aplicativos Rails no Postgres, você pode ver erros ActiveRecord::PreparedStatementCacheExpired. Isso só acontece ao executar migrações em uma implantação.
  • Isso acontece porque o Rails aproveita o recurso PreparedStatementCache do Postgres para melhorar o desempenho. Este recurso está habilitado por padrão no Rails.

2. Recorrência do problema:

  • Podemos usar rspec para reproduzir este erro
 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

Insira a descrição da imagem aqui

3. Princípio de produção:

  • Instruções de consulta Rails, comoUser.allpor active_record Analisar em instrução sqlDepois disso, envie para o banco de dados,Execute PREPARE primeiro Instruções preparadas, instruções SQL serão analisadas, analisadas, otimizadas e reescritas.Quando o acompanhamentoEmita um comando EXECUTE, a declaração preparada será planejada e executada.
  • Rails salvará a instrução de consulta empg_prepared_statementspara facilitar na próxima vez que você ligar para declarações semelhantes.executar diretamente declarações, em vez de analisar, analisar e otimizar, evitando duplicação de trabalho e melhorando a eficiência.
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
  • No Postgres, o cache de instruções preparado será invalidado se o esquema da tabela for alterado, afetando os resultados retornados. Especificamente, são operações DDL, como adicionar e excluir campos da tabela ou modificar o tipo e o comprimento dos campos.

Como no exemplo a seguir, ao executar SELECT após adicionar ou excluir campos,banco de dados pgvai jogarcached plan must not change result type, em trilhosregistro_ativoObtenha este erro e depois jogueActiveRecord::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
  • Ao executar a migração de adição, subtração ou modificação de campos no serviço de implantação, a instrução de consulta emitida pelo usuário buscará diretamente o SQL do cache de instruções preparado e executará excute diretamente. o cache de instruções preparado torna-se inválido.banco de dados pgvai jogarcached plan must not change result typeerro
  • Veja o código-fonte do active_recordexec_cacheMétodo, descobri que o método de tratamento de erros do Rails para pg é:
    1. Na transação, será lançado diretamente raise ActiveRecord::PreparedStatementCacheExpired.new(e.cause.message)
    2. Aqueles fora da transação armazenarão em cache @statementsExclua esta frase e tente, após tentar novamente, a instrução SQL será reanalisada, analisada, otimizada e executada.prepare_statementmétodo no cache de instruções preparadas
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
  • Portanto, esse erro que aparece na transação fará com que a transação seja revertida. Para o negócio, significa que a solicitação falhou e precisamos cuidar disso nós mesmos.

4. Solução:

1. Desative a função de instrução preparada em cache (não recomendado)

Rails6 e superiores podem desabilitar esse recurso definindo prepare_statements no banco de dados como false.

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

Rails6 e anteriores não foram testados. Se o procedimento acima não funcionar, você pode tentar criar um novo arquivo de inicialização.

# 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

verificar:

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

Conclusão: Em projetos pequenos, não importa se você desabilita esta função, e o desempenho quase não será afetado. Porém, em projetos grandes, quanto mais usuários e mais complexas as instruções de consulta, maiores serão os benefícios que esta função trará. para que você possa decidir se deseja desativá-lo de acordo com a situação real.

2. fazerselect * tornar-seselect id, nameTais campos específicos, em rails7Solução oficialÉ isso

  • Defina enumerate_columns_in_select_statements como verdadeiro em Rails7
# 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
  • Não existe tal configuração abaixo do Rails7, você pode usar ignore_columns para alcançá-la
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

Conclusão: O problema com esta solução é que adicionar campos pode ser perfeitamente resolvido, mas excluir campos ainda causará erros. Por exemplo, após excluir o campo de nome, se o nome na instrução preparada selecione id, o nome dos usuários não existe, um erro será relatado. A solução oficial do Rails7 também tem esse problema.

3. Reinicie o aplicativo Rails

  • O ciclo de vida do cache de instruções preparadas existe apenas em uma sessão de banco de dados. Se a conexão com o banco de dados for fechada (reiniciar o aplicativo fechará a conexão original e restabelecerá uma nova conexão), o cache de instruções preparadas original será limpo e o cache de instruções preparadas original. A solicitação SQL após a reinicialização será reiniciada em cache a instrução preparada e você poderá obter os dados normalmente.

Conclusão: Reiniciar o aplicativo causará um serviço temporário 502 Indisponível. Claro, ao implantar o aplicativo, o serviço deve ser reiniciado e 502 também aparecerá, por isso é melhor implantar quando ninguém estiver acessando-o (no meio de). a noite?), para que haja o mínimo de erros possível.PreparedStatementCacheExpiredInformar um erro

4. Reescrever transaction método

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
  • Após a reescrita, o local onde as transações são escritas no código é alterado para usar ApplicationRecord.transaction do ... end ouMyModel.transaction

Conclusão: Observação importante: se você enviar e-mails, postar em APIs ou realizar outras operações que interajam com o mundo externo como parte de uma transação, isso poderá fazer com que algumas dessas operações ocorram ocasionalmente duas vezes. É por isso que o Rails oficialmente não executa novas tentativas automaticamente, mas deixa isso para os desenvolvedores da aplicação.

&gt;&gt;&gt;&gt;&gt;&gt;&gt;Quando eu mesmo testo esse método, ainda recebo erros.

5. Limpe manualmente o cache de instruções preparadas

 ActiveRecord::Base.connection.clear_cache!
  • 1

5. Resposta final

Não encontrei uma solução perfeita