Как выстрелить себе в ногу с prepared statements
Недавно столкнулся с необычной проблемой и пришлось восполнить еще один пробел знаний по Rails - на этот раз механизм prepared statements для SQL-запросов. Не то, чтобы я не знал, что Rails так умеет, но эта фича в свое время прошла как-то мимо меня. И только сейчас я занялся ею вплотную.
У нас на production‘е внезапно начали сыпаться необычные exception‘ы
ActiveRecord::StatementInvalid
:
PG::DuplicatePstatement: ERROR: prepared statement "a4205" already exists
: SELECT "parties".* FROM "parties" WHERE ...
Ошибки возникали в совершенно разных местах веб-приложения и шли сериями. В каждой серии в тексте сообщения был один тот же идентификатор (“a4205”), который менялся от серии к серии.
Поиск в сети достаточно быстро привел к правдоподобному объяснению - виновник exception‘ов это gem Rack::Timeout. Мы действительно использовали его в проекте и это хорошо объясняло случайность времени и места возникновения exception‘ов.
Немного матчасти
Здесь речь идет о так называемых prepared statements - возможность выполнять SQL-запросы в базе данных быстрее за счет кеширования плана выполнения. Каждый prepared statement имеет строковый идентификатор (“a4205” в примере выше) и может принимать параметры.
Работает это следующим образом - при выполнении SQL-запроса он сначала препарируется (парсится, анализируется и создается план выполнения запроса):
PREPARE usrrptplan (int, date) AS
SELECT * FROM users u, logs l WHERE u.usrid=$1 AND u.usrid=l.usrid
AND l.date = $2;
Затем выполняется, собственно, сам запрос:
EXECUTE foo(2, '2019-09-23');
(здесь и далее для примеров используется Postgres)
Препарированный запрос будет выполняться быстрее обычного потому, что пропускается этап подготовки плана выполнения запроса. Это может дать ощутимое ускорение для сложных SQL-запросах или если очень много раз выполнить простой запрос (с разными параметрами, например пачка INSERT-ов).
В Postgres область видимости prepared statements ограничена текущей сессией с базой данных. Они не видны другим клиентам и хранятся только до завершения текущей сессии.
Как это работает в Rails
Rails поддерживает prepared statements начиная с версии 3.1. По умолчанию этот механизм включен и Rails препарирует практически все SQL-запросы (за некоторым исключением).
prepared statements потребляют ресурсы базы данных - как минимум
оперативную память. Поэтому, чтобы сильно не нагружать базу, количество
prepared statements на одно соединение ограничено и по умолчанию лимит
составляет 1000 запросов. Если надо добавить новый prepared statement,
а лимит уже достигнут, то один из созданных ранее prepared statement
удаляется из базы данных (оператором DEALLOCATE
). Для адаптера
Postgres этот лимит можно выставить параметром statement_limit
. Чтобы
отключить prepared statements глобально в приложении нужно выставить
параметр prepared_statements: false
:
production:
adapter: postgresql
statement_limit: 200
или
production:
adapter: postgresql
prepared_statements: false
Идентификатор для нового prepared statement генерируется Rails’ми и
сохраняется в statement pool в виде key-value пары [текст
SQL-запроса, идентификатор]. Идентификатор имеет вид "a#{@counter + 1}"
(source).
Когда в следующий раз надо будет выполнить точно такой же запрос, Rails проверит, если ли он в statement pool. Далее используя найденный идентификатор просто выполнит запрос c конкретными параметрами.
Для примера возьмем следующий запрос:
Account.find(3)
В переводе на SQL это:
SELECT "accounts".* FROM "accounts" WHERE "accounts"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
Мы видим, что Rails сгенерировали максимально обобщенный SQL-запрос и
вынесли все что может измениться (id
и LIMIT
) в параметры. А в
служебной таблице для prepared statements появится новая запись:
SELECT * FROM pg_prepared_statements
-- => "a1", "SELECT \"accounts\".* FROM \"accounts\" WHERE \"accounts\".\"id\" = $1 LIMIT $2", "2019-09-24 18:06:04.887512+00", "{bigint,bigint}", false
В таблице pg_prepared_statements
Postgres сохранил идентификатор
(“a1”), текст запроса, время создания (“2019-09-24 18:06:04.887512+00”)
и типы параметров (“{bigint,bigint}”)
(документация).
Ограничения в Rails
Rails использует prepared statement только для SELECT-запросов и старается использовать их максимально эффективно - избегает замусоривания одноразовыми запросами.
Так, например, запрос без параметров (где все значения заинлайнены прямо в текст запроса) не будет препарироваться:
Account.where(id: [1, 2])
# SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" IN (1, 2)
ActiveRecord::Base.connection.execute('select * from pg_prepared_statements').count
# 0
Аналогично, динамический запрос (часть которого формируется в коде) тоже не будет препарироваться:
Account.where("id = 1")
# SELECT "accounts".* FROM "accounts" WHERE (id = 1) LIMIT $1 [["LIMIT", 11]]
ActiveRecord::Base.connection.execute('select * from pg_prepared_statements').count
# 0
Такие ограничения появились еще в Rails 5.0 (см коммит)
Так причем же здесь Rack::Timeout?
Ошибка ‘prepared statement “a4205” already exists’ говорит о том, что Rails попыталась создать новый prepared statement, 4205-й по счету, но идентификатор “a4205” уже занят. Он был зарегистрирован в Postgres но Rails по прежнему считает его свободным. И все последующие попытки создать prepared statement в этом конкретном соединении завершатся точно такой же ошибкой.
Такая ситуация возможна только если выполнение SQL-запроса Rails’ами
было прервано после создания prepared statement в Postgres но до
сохранения идентификатора в statement pool в Rails’ах. Как раз к
этому и может привести работа Rack::Timeout
. Это Rack-middleware,
который прерывает обработку HTTP-запроса если превышен заданный таймаут.
При этом используется беспощадный метод Thread#raise
(source).
Рассмотрим процедуру создания prepared statement в Rails (v4.2):
# lib/active_record/connection_adapters/postgresql_adapter.rb
def prepare_statement(sql)
sql_key = sql_key(sql)
unless @statements.key? sql_key
nextkey = @statements.next_key
begin
@connection.prepare nextkey, sql
rescue => e
raise translate_exception_class(e, sql)
end
# Clear the queue
@connection.get_last_result
@statements[sql_key] = nextkey
end
@statements[sql_key]
end
(source)
В statement pool‘е (@statements
) хранятся пары [текст SQL-запроса,
идентификатор]. Вызов @statements.next_key
генерирует новый
идентификатор на основе внутреннего счетчика. Вызов
@connection.prepare
создает prepared statement в Postgres, а
@statements[sql_key] = nextkey
добавляет в statement pool новый
prepared statement и увеличивает счетчик на единицу.
Если прервать выполнение между строчками @connection.prepare nextkey,
sql
и @statements[sql_key] = nextkey
, мы получим расхождение
между Postgres и Rails.
Хм, а насколько это вообще вероятно?
Давайте воспроизведем эту ситуацию руками.
Приведенный ниже код выполняет множество SQL-запросов, для каждого из
которых создается новый prepared statement. В параллельном потоке
периодически вызываем RuntimeError
, который прерывает выполнение
SQL-запросов в основном потоке (main_thread.raise 'Timout error'
).
Рано или поздно основной поток прервется как раз в нужный момент - после
препарирования запроса но до сохранения его в statement pool.
# timeout.rb
main_thread = Thread.current
Thread.new do
loop do
sleep 0.1
main_thread.raise 'Timout error'
end
end
(1..1000).each do |i|
User.where(id: 1).limit(i).to_a
rescue ActiveRecord::StatementInvalid => e
puts e
break
rescue
next
end
SQL-запрос генерируется так - User.where(id: 1).limit(i)
. Чтобы
текст запросов отличался - изменяем LIMIT
. Это значение инлайнится, а
не передается параметром. Поэтому каждый запрос будет отличаться от
остальных и будет препарироваться заново:
SELECT "accounts".* FROM "accounts" WHERE "accounts"."id" = $1 LIMIT 1 [["id", 1]]
Это поведение Rails 4.2 и в других версиях оно может отличаться - все больше и больше инлайнинг значений в текст запроса заменяется передачей значений в виде параметров.
Этот код воспроизводит приведенную выше ошибку
ActiveRecord::StatementInvalid
:
$ rails runner ./timeout.rb
PG::DuplicatePstatement: ERROR: prepared statement "a359" already exists
: SELECT "users".* FROM "users" WHERE "users"."deleted_at" IS NULL AND "users"."id" = $1 ORDER BY created_at DESC LIMIT 362
Ошибка воспроизводится на Rails 4.2, но уже в Rails 5.2 нужно
генерировать SQL-запрос иначе - значение для LIMIT
стало передаваться
параметром (подозреваю что это касается всех версий 5.x, не только 5.2).
Поэтому текст запроса не изменяется и препарируется только один раз:
SELECT "accounts".* FROM "accounts" WHERE "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 2]]
Для Rails 5.2 нужно заменить
User.where(id: 1).limit(i).to_a
на
User.where(id: [1] * i).to_a
Таким образом будут генерироваться разные SQL-запросы в зависимости от
длины массива [1] * i
- каждый элемент массива передается отдельным
параметром:
SELECT "accounts".* FROM "accounts" WHERE "accounts"."id" IN ($1, $2, $3, $4, $5)
Как мы видим, такая рассинхронизация вполне возможна и легко может возникнуть в каком-то не оптимизированном коде, который делает много SQL-запросов. Локально в среднем ошибка воспроизводится на первых 50-100 запросах.
Выводы
Rack::Timout
middleware как и аналогичный метод Timeout::timeout
из стандартной библиотеки Ruby - весьма опасные инструменты и должны
применяться аккуратно понимая какие могут быть последствия. Например,
Timeout::timeout
использовался для прерывания job‘ов в Sidekiq и
Sneakers, но в итоге его выпилили в обоих случаях.
Использование prepared statement может иметь и негативный эффект. Это
может привести к большому потреблению памяти сервера баз данных
(тыц,
тыц
и тыц).
Поэтому отключение prepared statement совсем или уменьшение лимита
statement_limit
иногда может быть полезным.