提交 cbcdecd2 编写于 作者: S Sean Griffin

Do not cache prepared statements that are unlikely to have cache hits

Prior to this commit, Rails makes no differentiation between whether a
query uses bind parameters, and whether or not we cache that query as a
prepared statement. This leads to the cache populating extremely fast in
some cases, with the statements never being reused.

In particular, the two problematic cases are `where(foo: [1, 2, 3])` and
`where("foo = ?", 1)`. In both cases we'll end up quoting the values
rather than using a bind param, causing a cache entry for every value
ever used in that query.

It was noted that we can probably eventually change `where("foo = ?",
1)` to use a bind param, which would resolve that case. Additionally, on
PG we can change our generated query to be `WHERE foo = ANY($1)`, and
pass an array for the bind param. I hope to accomplish both in the
future.

For SQLite and MySQL, we still end up preparing the statements anyway,
we just don't cache it. The statement will be cleaned up after it is
executed. On postgres, we skip the prepare step entirely, as an API is
provided to execute with bind params without preparing the statement.

I'm not 100% happy on the way this ended up being structured. I was
hoping to use a decorator on the visitor, rather than mixing a module
into the object, but the way Arel has it's visitor pattern set up makes
it very difficult to extend without inheritance. I'd like to remove the
duplication from the various places that are extending it, but that'll
require a larger restructuring of that initialization logic. I'm going
to take another look at the structure of it soon.

This changes the signature of one of the adapter's internals, and will
require downstream changes from third party adapters. I'm not too
worried about this, as worst case they can simply add the parameter and
always ignore it, and just keep their previous behavior.

Fixes #21992.
上级 72fba7db
* Don't cache prepared statements containing an IN clause or a SQL literal, as
these queries will change often and are unlikely to have a cache hit.
*Sean Griffin*
* Fix `rewhere` in a `has_many` association.
Fixes #21955.
......
......@@ -29,7 +29,17 @@ def cacheable_query(arel) # :nodoc:
# Returns an ActiveRecord::Result instance.
def select_all(arel, name = nil, binds = [])
arel, binds = binds_from_relation arel, binds
select(to_sql(arel, binds), name, binds)
sql = to_sql(arel, binds)
if arel.is_a?(String)
preparable = false
else
preparable = visitor.preparable
end
if prepared_statements && preparable
select_prepared(sql, name, binds)
else
select(sql, name, binds)
end
end
# Returns a record hash with the column names as keys and column values
......@@ -67,7 +77,7 @@ def execute(sql, name = nil)
# Executes +sql+ statement in the context of this connection using
# +binds+ as the bind substitutes. +name+ is logged along with
# the executed +sql+ statement.
def exec_query(sql, name = 'SQL', binds = [])
def exec_query(sql, name = 'SQL', binds = [], prepare: false)
end
# Executes insert +sql+ statement in the context of this connection using
......@@ -358,9 +368,12 @@ def subquery_for(key, select)
# Returns an ActiveRecord::Result instance.
def select(sql, name = nil, binds = [])
exec_query(sql, name, binds)
exec_query(sql, name, binds, prepare: false)
end
def select_prepared(sql, name = nil, binds = [])
exec_query(sql, name, binds, prepare: true)
end
# Returns the last auto-generated ID from the affected table.
def insert_sql(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil)
......
require 'active_record/type'
require 'active_support/core_ext/benchmark'
require 'active_record/connection_adapters/determine_if_preparable_visitor'
require 'active_record/connection_adapters/schema_cache'
require 'active_record/connection_adapters/sql_type_metadata'
require 'active_record/connection_adapters/abstract/schema_dumper'
......
......@@ -153,6 +153,7 @@ def initialize(connection, logger, connection_options, config)
if self.class.type_cast_config_to_boolean(config.fetch(:prepared_statements) { true })
@prepared_statements = true
@visitor.extend(DetermineIfPreparableVisitor)
else
@prepared_statements = false
end
......
module DetermineIfPreparableVisitor
attr_reader :preparable
def accept(*)
@preparable = true
super
end
def visit_Arel_Nodes_In(*)
@preparable = false
super
end
def visit_Arel_Nodes_SqlLiteral(*)
@preparable = false
super
end
end
......@@ -142,7 +142,7 @@ def execute(sql, name = nil)
super
end
def exec_query(sql, name = 'SQL', binds = [])
def exec_query(sql, name = 'SQL', binds = [], prepare: false)
result = execute(sql, name)
@connection.next_result while @connection.more_results?
ActiveRecord::Result.new(result.fields, result.to_a)
......
......@@ -235,11 +235,11 @@ def client_encoding
@client_encoding = ENCODINGS[result.rows.last.last]
end
def exec_query(sql, name = 'SQL', binds = [])
def exec_query(sql, name = 'SQL', binds = [], prepare: false)
if without_prepared_statement?(binds)
result_set, affected_rows = exec_without_stmt(sql, name)
else
result_set, affected_rows = exec_stmt(sql, name, binds)
result_set, affected_rows = exec_stmt(sql, name, binds, cache_stmt: prepare)
end
yield affected_rows if block_given?
......@@ -378,12 +378,12 @@ def begin_db_transaction #:nodoc:
private
def exec_stmt(sql, name, binds)
def exec_stmt(sql, name, binds, cache_stmt: false)
cache = {}
type_casted_binds = binds.map { |attr| type_cast(attr.value_for_database) }
log(sql, name, binds) do
if binds.empty?
if binds.empty? || !cache_stmt
stmt = @connection.prepare(sql)
else
cache = @statements[sql] ||= {
......@@ -399,7 +399,7 @@ def exec_stmt(sql, name, binds)
# place when an error occurs. To support older MySQL versions, we
# need to close the statement and delete the statement from the
# cache.
if binds.empty?
if binds.empty? || !cache_stmt
stmt.close
else
@statements.delete sql
......
......@@ -156,8 +156,8 @@ def execute(sql, name = nil)
end
end
def exec_query(sql, name = 'SQL', binds = [])
execute_and_clear(sql, name, binds) do |result|
def exec_query(sql, name = 'SQL', binds = [], prepare: false)
execute_and_clear(sql, name, binds, prepare: prepare) do |result|
types = {}
fields = result.fields
fields.each_with_index do |fname, i|
......
......@@ -198,6 +198,7 @@ def initialize(connection, logger, connection_parameters, config)
@visitor = Arel::Visitors::PostgreSQL.new self
if self.class.type_cast_config_to_boolean(config.fetch(:prepared_statements) { true })
@prepared_statements = true
@visitor.extend(DetermineIfPreparableVisitor)
else
@prepared_statements = false
end
......@@ -553,16 +554,22 @@ def load_additional_types(type_map, oids = nil) # :nodoc:
FEATURE_NOT_SUPPORTED = "0A000" #:nodoc:
def execute_and_clear(sql, name, binds)
result = without_prepared_statement?(binds) ? exec_no_cache(sql, name, binds) :
exec_cache(sql, name, binds)
def execute_and_clear(sql, name, binds, prepare: false)
if without_prepared_statement?(binds)
result = exec_no_cache(sql, name, [])
elsif !prepare
result = exec_no_cache(sql, name, binds)
else
result = exec_cache(sql, name, binds)
end
ret = yield result
result.clear
ret
end
def exec_no_cache(sql, name, binds)
log(sql, name, binds) { @connection.async_exec(sql, []) }
type_casted_binds = binds.map { |attr| type_cast(attr.value_for_database) }
log(sql, name, binds) { @connection.async_exec(sql, type_casted_binds) }
end
def exec_cache(sql, name, binds)
......
......@@ -89,6 +89,7 @@ def initialize(connection, logger, connection_options, config)
if self.class.type_cast_config_to_boolean(config.fetch(:prepared_statements) { true })
@prepared_statements = true
@visitor.extend(DetermineIfPreparableVisitor)
else
@prepared_statements = false
end
......@@ -231,15 +232,18 @@ def pp(result) # :nodoc:
end
end
def exec_query(sql, name = nil, binds = [])
def exec_query(sql, name = nil, binds = [], prepare: false)
type_casted_binds = binds.map { |attr| type_cast(attr.value_for_database) }
log(sql, name, binds) do
# Don't cache statements if they are not prepared
if without_prepared_statement?(binds)
unless prepare
stmt = @connection.prepare(sql)
begin
cols = stmt.columns
unless without_prepared_statement?(binds)
stmt.bind_params(type_casted_binds)
end
records = stmt.to_a
ensure
stmt.close
......@@ -252,7 +256,7 @@ def exec_query(sql, name = nil, binds = [])
stmt = cache[:stmt]
cols = cache[:cols] ||= stmt.columns
stmt.reset!
stmt.bind_params type_casted_binds
stmt.bind_params(type_casted_binds)
end
ActiveRecord::Result.new(cols, stmt.to_a)
......
......@@ -127,7 +127,7 @@ def test_schema_names_logs_name
def test_statement_key_is_logged
bind = Relation::QueryAttribute.new(nil, 1, Type::Value.new)
@connection.exec_query('SELECT $1::integer', 'SQL', [bind])
@connection.exec_query('SELECT $1::integer', 'SQL', [bind], prepare: true)
name = @subscriber.payloads.last[:statement_name]
assert name
res = @connection.exec_query("EXPLAIN (FORMAT JSON) EXECUTE #{name}(1)")
......
......@@ -168,7 +168,7 @@ def test_drop_schema_with_nonexisting_schema
def test_raise_wraped_exception_on_bad_prepare
assert_raises(ActiveRecord::StatementInvalid) do
@connection.exec_query "select * from developers where id = ?", 'sql', [[nil, 1]]
@connection.exec_query "select * from developers where id = ?", 'sql', [bind_param(1)]
end
end
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册