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
User.all
被 active_record 解析成 sql 语句后,发送给数据库,先执行 PREPARE预备语句,sql 语句会被解析、分析、优化并且重写。当后续发出一个 EXECUTE 命令时,该预备语句会被规划并且执行。pg_prepared_statements
中,以方便下次调用同类语句时候直接 execute 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]]
如下面的例子,添加或删除字段后执行 SELECT 时,pg 数据库就会抛出cached plan must not change result type
,rails 中active_record获取到这个错误然后会抛出ActiveRecord::PreparedStatementCacheExpired
ALTER TABLE users ADD COLUMN new_column integer;
ALTER TABLE users DROP COLUMN old_column;
添加或删除列,然后执行 SELECT *
删除 old_column 列然后执行 SELECT users.old_column
cached plan must not change result type
错误exec_cache
方法,发现 rails 对 pg 的这个错误处理方式是:
raise ActiveRecord::PreparedStatementCacheExpired.new(e.cause.message)
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
rails6 以上可以把 database 中 prepared_statements 设为 false 来禁用这个功能
default: &default
adapter: postgresql
encoding: unicode
prepared_statements: false
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)
验证:
User.all
ActiveRecord::Base.connection.execute('select * from pg_prepared_statements').values
==> []
结论:小型项目中其实禁用这个功能无所谓,性能几乎不影响,但是大型项目中,用户越多,越复杂的查询语句,这个功能带来的受益越大,所以可以根据实际情况来决定是否禁用
select *
变为 select id, name
这样的具体字段,rails7 中的官方解决方案就是这样的,但只能解决新增字段引起的报错# config/application.rb
module MyApp
class Application < Rails::Application
config.active_record.enumerate_columns_in_select_statements = true
end
end
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
#__fake_column__是自定义的,不要是某个表中的字段就行,如果是[:id],那么 User.all就会被解析为select name from users,没有id了
self.ignored_columns = [:__fake_column__]
end
结论:这个方案存在的问题是,增加字段可以完美解决,但是删除字段,还会出现报错,比如删除 name 字段后,预准备语句 select id, name from users 中的 name 不存在了,就会报错,删除字段可以在 User.rb 中增加 self.ignored_columns = [:name], 然后先重启服务,再进行部署,部署时候要最好把 self.ignored_columns = [:name] 删掉,避免以后再加回 name 字段后,select 不到,rails7 官方的方案也存在这个问题,所以这个方案感觉很麻烦
结论:重启应用会出现短暂服务 502 不可用,当然部署应用时候也是要重启服务的,也会出现 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
ApplicationRecord.transaction do ... end
或者 MyModel.transaction
或者 MyModel.transaction
或者obj.transaction
, 只要不用ActiveRecord::Base.transaction
就行结论:重要提示:如果在事务中有发送电子邮件、post 到 API 或执行其他与外界交互的操作,这可能会导致其中一些操作偶尔发生两次。这就是为什么 Rails 官方不会自动执行重试,而是将其留给应用程序开发人员。
>>>>>>>我本人测试这个方法还是会继续报错
transaction
方法结合手动清除预准备语句缓存gem 'rails-settings-cached'
,部署前先打开 Setting.clear_prepared_statements_cache
,部署完毕后再关掉,这样性能也基本不会有任何影响class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
class << self
def transaction(*args, &block)
connection.clear_cache! if Setting.clear_prepared_statements_cache
super
end
end
end
经过我们的测试,最终使用 方案 5 完美解决这个问题,已使用到生产环境,可以把Setting.clear_prepared_statements_cache
这个开关集成到部署脚本中就更方便了。