Очень часто разработчики на 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)


  1. ertaquo
    15.07.2021 16:56

    Аналогично работает в Go с использованием библиотеки Gorm: https://gorm.io/docs/query.html


  1. 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

    То что-то у вас пошло не так. Посмотрите то же партиционирование. Оно должно дать существенный прирост скорости получения данных без нарушения семантики запроса.


    1. motoroller95 Автор
      16.07.2021 10:26

      Статья родилась из соображения о том, что если попросить среднестатистического рубиста (возможно даже среднестатистического приверженца ORM'ов) выбрать одну запись по какому-либо условию, то он напишет что-то из разряда Model.where(...).first Так же я намеренно в начале статьи говорю что

      Там, где порядок записей имеет значение, обойтись без first/last нет возможности

      Упор статьи сделан на тех кейсах, когда по определенным условиям в выборке есть строго одна запись. В моих примерах это обусловлено

      1. в случае с бонусами бизнес требованиями (не может быть в системе два активных бонуса, для этого в системе есть различные проверки)

      2. в случае с курсами валют здравым смыслом (ну не может быть два разных курса у одной валюты в одном промежутке времени)

      Поэтому два семантически разных запроса дадут один и тот же практический результат, но вот затраты с которыми этот результат получен уже разные.

      Что касается курсов: у нас в проекте используется партиционирование в нескольких местах (где оно оправдано), в данном кейсе мы вполне обходимся индексами, проблем не замечаем.


      1. Fortop
        16.07.2021 17:07

        Поэтому два семантически разных запроса дадут один и тот же практический результат, но вот затраты с которыми этот результат получен уже разные.

        Все правильно. Чаще всего это говорит лишь о том, что исходно использовался не тот запрос что нужен.

        Возможно я не смог правильно прочесть это в статье в рамках расставленных акцентов.

        в случае с курсами валют здравым смыслом (ну не может быть два разных курса у одной валюты в одном промежутке времени)

        Зависит от величины промежутка времени. Даже официальные курсы центральных банков, которые устанавливаются на один день имели преценденты изменяться в течении этого самого дня.