Очень часто разработчики на Rails для извлечения заведомо одной записи из базы используют один из методов first/last
из ActiveRecord. Это рабочее решение, но есть одно НО: чтобы извлечь всего одну запись база данных должна найти все, подходящие по условию, отсортировать их и вернуть только одну. Если вы подумали "Сортировка одной записи? Да это же легко!", то не будте так оптимистичны, я постараюсь показать, что это важно.
Там, где порядок записей имеет значение, обойтись без first/last
нет возможности, однако бывают ситуации, когда порядок не важен. Приведу практический пример: предположим у нас в системе есть пользователи, у пользователей есть бонусы, и только один бонус может иметь статус active и мы хотим иметь метод, который вернет этот единственный активный бонус
class Bonus
belongs_to :user
scope :active, -> { where(status: :active) }
end
class User
has_many :bonuses
def active_bonus
bonuses.active.first
end
end
Такая запись метода active_bonus не нравится мне по двум причинам:
- если есть
bonuses.active.first
, то возможен иbonuses.active.last
? Будет то же самое поведение или другое? При такой записи точно ответить нельзя - чтобы достать единственную запись Bonus postgres все равно задействует механизм сортировки
explain analyze SELECT "bonuses".* FROM "bonuses" WHERE "bonuses"."user_id" = 123 AND "bonuses"."status" = 'active' ORDER BY "bonuses"."id" ASC LIMIT 1;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.52..0.52 rows=1 width=905)
-> Sort (cost=0.52..0.52 rows=1 width=905)
Sort Key: id
Sort Method: quicksort Memory: 25kB
-> Index Scan using user_id_idx on bonuses (cost=0.27..0.51 rows=1 width=905)
Index Cond: (user_id = 812688)
Planning Time: 0.160 ms
Execution Time: 0.042 ms
В этом примере postgres в первую очередь достает все записи используя user_id_idx, затем пытается отсортировать их по id. Это дешево, но, все же, пустая трата времени.
ActiveRecord#Take
Того же поведения можно добиться используя Bonus#take
из ActiveRecord: bonuses.active.take
. Этот метод достает одну запись из базы данных не обращая внимания на сортировку. Такая запись дает нам небольшой буст в плане выполнения запроса, а так же убирает недопонимание о количестве активных бонусов
explain analyze SELECT "bonuses".* FROM "bonuses" WHERE "bonuses"."user_id" = 123 AND "bonuses"."status" = 'active' LIMIT 1;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.27..0.51 rows=1 width=905)
-> Index Scan using user_id_idx on bonuses (cost=0.27..0.51 rows=1 width=905)
Index Cond: (user_id = 812688)
Planning Time: 0.137 ms
Execution Time: 0.031 ms
В этом случае postgres использует все тот же поиск по индексу user_id_idx, но теперь ему вообще нет необходимости искать все записи со статусом active, после первой он сразу же может вернуть результат
Выглядит красиво, но есть ли от этого практическая польза?
Именно эта оптимизация ускорила большую часть нашего приложения: мы всегда имеем дело с курсами валют, все курсы хранятся с разбивкой по времени, чтобы получить курсы мы должны обратиться к базе с таким запросом CurrencyRate.where("currency = ? and period::tsrange @> ?::timestamp", currency, time).first
explain analyze SELECT "currency_rates".* FROM "currency_rates" WHERE (currency = 'RUB' and period::tsrange @> '2021-07-14 08:37:53.918222'::timestamp) ORDER BY "currency_rates"."id" ASC LIMIT 1;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.43..130.18 rows=1 width=1204)
-> Index Scan using currency_rates_pkey on currency_rates (cost=0.43..240935.03 rows=1857 width=1204)
Filter: ((period @> '2021-07-14 08:37:53.918222'::timestamp without time zone) AND (currency = 'RUB'::text))
Rows Removed by Filter: 6340297
Planning Time: 1.111 ms
Execution Time: 389.639 ms
Хммм, postgres использует currency_rates_pkey хотя никакой фильтрации по id у нас нету. Это из-за order by id asc
. Если переписать запрос так CurrencyRate.where("currency = ? and period::tsrange @> ?::timestamp", currency, time).take
можно увидеть более дружелюбный план
explain analyze SELECT "currency_rates".* FROM "currency_rates" WHERE (currency = 'RUB' and period::tsrange @> '2021-07-14 08:37:53.918222'::timestamp) LIMIT 1;
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.41..0.55 rows=1 width=1204)
-> Index Scan using currency_rates_period_currency_excl on currency_rates (cost=0.41..244.46 rows=1857 width=1204)
Index Cond: ((period @> '2021-07-14 08:37:53.918222'::timestamp without time zone) AND (currency = 'RUB'::text))
Planning Time: 0.095 ms
Execution Time: 0.938 ms
Теперь хорошо, postgres использует нужный индекс, время выполнения запроса уменьшилось во много раз
Комментарии (4)
Fortop
15.07.2021 22:27+2Т.е автора смущает
если есть bonuses.active.first, то возможен и bonuses.active.last? Будет то же самое поведение или другое? При такой записи точно ответить нельзя
Но, вот влепить два семантически разных запроса и считать их эквивалентными его не смущает....
SELECT "bonuses".* FROM "bonuses" WHERE "bonuses"."user_id" = 123 AND "bonuses"."status" = 'active' LIMIT 1;
Это означает - возьми первый попавшийся. И строго говоря в зависимости от БД и систем даже последовательных два таких запроса могут дать разный результат на одном и том же наборе данных.
SELECT "bonuses".* FROM "bonuses" WHERE "bonuses"."user_id" = 123 AND "bonuses"."status" = 'active' ORDER BY "bonuses"."id" ASC LIMIT 1;
Означает возьми первый по порядку (скорее всего добавления). И вот он будет всегда одинаков на одном и том же наборе данных.
Что касается вашей таблицы курсов...
Rows Removed by Filter: 6340297
То что-то у вас пошло не так. Посмотрите то же партиционирование. Оно должно дать существенный прирост скорости получения данных без нарушения семантики запроса.
motoroller95 Автор
16.07.2021 10:26Статья родилась из соображения о том, что если попросить среднестатистического рубиста (возможно даже среднестатистического приверженца ORM'ов) выбрать одну запись по какому-либо условию, то он напишет что-то из разряда
Model.where(...).first
Так же я намеренно в начале статьи говорю чтоТам, где порядок записей имеет значение, обойтись без
first/last
нет возможностиУпор статьи сделан на тех кейсах, когда по определенным условиям в выборке есть строго одна запись. В моих примерах это обусловлено
в случае с бонусами бизнес требованиями (не может быть в системе два активных бонуса, для этого в системе есть различные проверки)
в случае с курсами валют здравым смыслом (ну не может быть два разных курса у одной валюты в одном промежутке времени)
Поэтому два семантически разных запроса дадут один и тот же практический результат, но вот затраты с которыми этот результат получен уже разные.
Что касается курсов: у нас в проекте используется партиционирование в нескольких местах (где оно оправдано), в данном кейсе мы вполне обходимся индексами, проблем не замечаем.
Fortop
16.07.2021 17:07Поэтому два семантически разных запроса дадут один и тот же практический результат, но вот затраты с которыми этот результат получен уже разные.
Все правильно. Чаще всего это говорит лишь о том, что исходно использовался не тот запрос что нужен.
Возможно я не смог правильно прочесть это в статье в рамках расставленных акцентов.
в случае с курсами валют здравым смыслом (ну не может быть два разных курса у одной валюты в одном промежутке времени)
Зависит от величины промежутка времени. Даже официальные курсы центральных банков, которые устанавливаются на один день имели преценденты изменяться в течении этого самого дня.
ertaquo
Аналогично работает в Go с использованием библиотеки Gorm: https://gorm.io/docs/query.html