Partage de technologie

[ruby on rails] Raisons et solutions des erreurs ActiveRecord :: PreparedStatementCacheExpired lors du déploiement

2024-07-12

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

1. Question :

  • Parfois, lors du déploiement d'applications Rails sur Postgres, vous pouvez voir des erreurs ActiveRecord::PreparedStatementCacheExpired. Cela se produit uniquement lors de l'exécution de migrations dans un déploiement.
  • Cela se produit parce que Rails profite de la fonctionnalité PreparedStatementCache de Postgres pour améliorer les performances. Cette fonctionnalité est activée par défaut dans les rails.

2. Récurrence du problème :

  • Nous pouvons utiliser rspec pour reproduire cette erreur
 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

Insérer la description de l'image ici

3. Principe de production :

  • Instructions de requête Rails telles queUser.allpar active_record Analyser dans l'instruction SQLAprès cela, envoyez-le à la base de données,Exécutez PREPARE en premier Déclarations préparées, les instructions SQL seront analysées, analysées, optimisées et réécrites.Quand le suiviÉmettez une commande EXECUTE, la déclaration préparée sera planifiée et exécutée.
  • Rails enregistrera l'instruction de requête danspg_prepared_statementspour faciliter la prochaine fois que vous appellerez des déclarations similaires.exécuter directement déclarations, au lieu d'analyser, d'analyser et d'optimiser, évitant ainsi la duplication du travail et améliorant l'efficacité.
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
  • Dans Postgres, le cache des instructions préparées sera invalidé si le schéma de la table change affectant les résultats renvoyés. Plus précisément, il s'agit d'opérations DDL telles que l'ajout et la suppression de champs dans la table, ou la modification du type et de la longueur des champs.

Comme dans l'exemple suivant, lors de l'exécution de SELECT après avoir ajouté ou supprimé des champs,base de données pgva jetercached plan must not change result type, dans les railsenregistrement_actifObtenez cette erreur, puis lancezActiveRecord::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
  • Lors de l'exécution de la migration d'ajout, de soustraction ou de modification de champs dans le service de déploiement, l'instruction de requête émise par l'utilisateur récupérera directement le SQL du cache d'instructions préparé et exécutera directement l'exécution. Cependant, comme la structure de la table change à ce moment-là, le cache des instructions préparées devient invalide.base de données pgva jetercached plan must not change result typeerreur
  • Afficher le code source d'active_recordexec_cacheMéthode, j'ai trouvé que la méthode de gestion des erreurs de rails pour pg est la suivante :
    1. Dans la transaction, il sera lancé directement raise ActiveRecord::PreparedStatementCacheExpired.new(e.cause.message)
    2. Ceux qui ne participent pas à la transaction mettront en cache les @statementsSupprimez cette phrase et essayez, après une nouvelle tentative, l'instruction SQL sera réanalysée, analysée, optimisée et exécutée.prepare_statementméthode dans le cache des instructions préparées
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
  • Par conséquent, cette erreur qui apparaît dans la transaction entraînera l'annulation de la transaction. Pour l'entreprise, cela signifie que la demande a échoué et que nous devons la gérer nous-mêmes.

4. Solutions :

1. Désactivez la fonction d'instruction préparée par le cache (non recommandé)

Rails6 et versions ultérieures peuvent désactiver cette fonctionnalité en définissant Prepared_statements dans la base de données sur false.

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

Rails6 et versions antérieures n'ont pas été testés. Si ce qui précède ne fonctionne pas, vous pouvez essayer de créer un nouveau fichier d'initialisation.

# 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

vérifier:

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

Conclusion : dans les petits projets, cela n'a pas d'importance si vous désactivez cette fonction, et les performances ne seront pratiquement pas affectées. Cependant, dans les grands projets, plus il y a d'utilisateurs et d'instructions de requête complexes, plus cette fonction apportera d'avantages. vous pouvez donc décider de le désactiver en fonction de la situation réelle.

2. faireselect * devenirselect id, nameDe tels champs spécifiques, dans rails7Solution officielleC'est ça

  • Définissez enumerate_columns_in_select_statements sur true dans 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
  • Il n'existe pas de configuration de ce type sous Rails7, vous pouvez utiliser ignoré_columns pour y parvenir
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

Conclusion : le problème avec cette solution est que l'ajout de champs peut être parfaitement résolu, mais la suppression de champs entraînera toujours des erreurs. Par exemple, après la suppression du champ de nom, si le nom dans l'instruction préparée sélectionne l'identifiant, le nom des utilisateurs n'existe pas. une erreur sera signalée. La solution officielle de Rails7 présente également ce problème.

3. Redémarrez l'application Rails

  • Le cycle de vie du cache d'instructions préparées n'existe que dans une seule session de base de données. Si la connexion à la base de données est fermée (le redémarrage de l'application fermera la connexion d'origine et rétablira une nouvelle connexion), le cache d'instructions préparées d'origine sera vidé et le cache d'instructions préparées d'origine sera effacé. La requête SQL après le redémarrage sera redémarrée. Mettez en cache l'instruction préparée et vous pourrez obtenir les données normalement.

Conclusion : Le redémarrage de l'application entraînera un service temporaire 502 indisponible. Bien entendu, lors du déploiement de l'application, le service devra être redémarré, et 502 apparaîtra également, il est donc préférable de le déployer lorsque personne n'y accède (en plein milieu). la nuit ?), afin qu'il y ait le moins d'erreurs possible.PreparedStatementCacheExpiredSignaler une erreur

4. Réécrire transaction méthode

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
  • Après réécriture, l'endroit où les transactions sont écrites dans le code est modifié pour utiliser ApplicationRecord.transaction do ... end ouMyModel.transaction

Conclusion : remarque importante : si vous envoyez des e-mails, publiez sur des API ou effectuez d'autres opérations qui interagissent avec le monde extérieur dans le cadre d'une transaction, certaines de ces opérations peuvent parfois se produire deux fois. C'est pourquoi Rails n'effectue officiellement pas de tentatives automatiquement, mais laisse le soin aux développeurs d'applications.

&gt;&gt;&gt;&gt;&gt;&gt;&gt;Lorsque je teste moi-même cette méthode, j'obtiens toujours des erreurs.

5. Effacez manuellement le cache des instructions préparées

 ActiveRecord::Base.connection.clear_cache!
  • 1

5. Réponse finale

Je n'ai pas trouvé de solution parfaite