Очень часто разработчики на 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 использует нужный индекс, время выполнения запроса уменьшилось во много раз