Как стать автором
Обновить

Перестаньте везде использовать first/last

Время на прочтение4 мин
Количество просмотров5.5K

Очень часто разработчики на 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: ↑3 и ↓1+4
Комментарии4

Публикации

Истории

Работа

Программист Ruby
4 вакансии
Ruby on Rails
4 вакансии

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань