Если вам нужен быстрый поиск по внешнему ключу базы данных, вы индексируете его. Но когда ваш внешний ключ состоит из двух частей — идентификатора и типа — что вы должны индексировать?

(Нет времени? Пропустить до конца.)

Оказывается вариантов индексации полиморфных ассоциаций очень много. Мы могли бы индексировать любое поле отдельно, ни одно поле, оба (независимо) или оба с составным индексом. Но что наиболее эффективно? Будучи хорошим разработчиком, я нашел ответ на StackOverflow и обнаружил…

… в половине ответов говорилось об использовании составного индекса, начинающегося с «тип», а в другой половине предлагался составной индекс, начинающийся с «ID». Поскольку составные индексы должны выполнять поиск по первому столбцу перед доступом ко второму, эти ответы не совсем эквивалентны. Ни один из ответов, которые я нашел, не включал эталонные показатели, поэтому не было достаточно данных, чтобы определить, что делать. Поскольку единого ответа я не нашел, я решил провести тест для себя.

Методология

Для настройки я создал две таблицы для произвольно названных «альф» и «омег». Затем я создал серию других «таблиц проверки индекса», каждая из которых содержала полиморфную ассоциацию, которая могла ссылаться либо на строку альфа-таблицы, либо на одну строку омега-таблицы. Каждая из этих таблиц проверки индекса отличалась только тем, как были проиндексированы два столбца ассоциации. Всего я создал семь таблиц тестирования индексов, используя миграцию на моем сервере Rails 6.1, локально подключенном к Postgres:

  1. Неиндексированный
  2. Индекс только по ID
  3. Индекс только по типу
  4. Независимые индексы как по типу, так и по идентификатору
  5. Составной индекс с основным идентификатором, второстепенным типом
  6. Составной индекс с типом Major, ID Minor
  7. Индексируется с использованием типа t.reference Rails, с флагами полиморфности и индекса, установленными в true. На практике это создает ту же таблицу и индекс, что и #6. Я протестировал его, но с этого момента буду относиться к нему так же, как к # 6 в обсуждении.
class CreatePolymorphicTestTables < ActiveRecord::Migration[6.1]
  def change
    create_table :alphas

    create_table :omegas

    create_table :cmpd_id_type_refs do |t|
      t.bigint :relation_id, null: false
      t.text :relation_type, null: false
    end

    add_index :cmpd_id_type_refs, [:relation_id, :relation_type]

    create_table :cmpd_type_id_refs do |t|
      t.bigint :relation_id, null: false
      t.text :relation_type, null: false
    end

    add_index :cmpd_type_id_refs, [:relation_type, :relation_id]

    create_table :id_only_refs do |t|
      t.bigint :relation_id, null: false, index: true
      t.text :relation_type, null: false
    end

    create_table :type_only_refs do |t|
      t.bigint :relation_id, null: false
      t.text :relation_type, null: false, index: true
    end

    create_table :independent_type_id_refs do |t|
      t.bigint :relation_id, null: false, index: true
      t.text :relation_type, null: false, index: true
    end

    create_table :reference_refs do |t|
      t.references :relation, null: false, polymorphic: true
    end

    create_table :unindexed_refs do |t|
      t.references :relation, null: false, polymorphic: true, index: false
    end
  end
end

Затем я заполнил свои таблицы. Сначала создайте 100 альф и 100 омег. Затем я выбирал по одной случайной альфе за раз и создавал новую строку в каждой таблице тестирования индексов, которая на нее ссылалась. Затем я сделал то же самое для Омеги. Я повторил этот процесс 50 000 раз, чтобы каждая таблица проверки индекса содержала 100 000 записей. Этот процесс гарантировал, что каждая из семи таблиц проверки индекса будет иметь одинаковое количество строк в том же порядке, которые ссылаются на каждую альфу или омегу. Другими словами, содержимое таблиц было идентичным.

desc "Populates polymorphic test tables"

# Example call: `rake populate_polymorphic_test` to actually execute
task :populate_polymorphic_test => :environment do |task, args|
  start_time = Time.now
  puts "-- Started at #{start_time}"

  ActiveRecord::Base.transaction do
    100.times do |i|
      Alpha.create!(id: i+1)
      Omega.create!(id: i+1)
    end

    50000.times do
      idx = rand(100) + 1

      a = Alpha.find(idx)

      UnindexedRef.create!(relation: a)
      IdOnlyRef.create!(relation: a)
      TypeOnlyRef.create!(relation: a)
      IndependentTypeIdRef.create!(relation: a)
      CmpdIdTypeRef.create!(relation: a)
      CmpdTypeIdRef.create!(relation: a)
      ReferenceRef.create!(relation: a)

      idx = rand(100) + 1

      b = Omega.find(idx)

      UnindexedRef.create!(relation: b)
      IdOnlyRef.create!(relation: b)
      TypeOnlyRef.create!(relation: b)
      IndependentTypeIdRef.create!(relation: b)
      CmpdIdTypeRef.create!(relation: b)
      CmpdTypeIdRef.create!(relation: b)
      ReferenceRef.create!(relation: b)
    end
  end

  finish_time = Time.now
  puts "-- Finished at #{finish_time}. Elapsed time #{finish_time - start_time} seconds."
end

Наконец, я выполнил свои запросы. Для каждой альфы и каждой омеги я искал все их ассоциации в двух тестах, очищая кеш запросов перед каждым. Первый тест состоял из выборки столбцов идентификаторов, а второй состоял из подсчета количества связанных записей. Обе эти операции происходят в основном в базе данных, не заставляя Rails создавать экземпляр ApplicationRecord. Это означает, что львиная доля работы должна быть связана с поиском в базе данных с меньшей работой в памяти в Rails, чтобы замутить воду.

desc "Benchmark queries on polymorphic indexes"

# Example call: `rake run_polymorphic_query_test` to actually execute
task :run_polymorphic_query_test => :environment do |task, args|

  test_set = [*Alpha.all, *Omega.all]

  ActiveRecord::Base.connection.query_cache.clear

  p "---------------------------- Pluck ID Test ----------------------------"
  Benchmark.bm do |x|
    x.report("Unindexed") { test_set.each{|rl| rl.unindexed_refs.pluck(:id)} }
    x.report("ID Only") { test_set.each{|rl| rl.id_only_refs.pluck(:id)} }
    x.report("Type Only") { test_set.each{|rl| rl.type_only_refs.pluck(:id)} }
    x.report("Independent Indicies") { test_set.each{|rl| rl.independent_type_id_refs.pluck(:id)} }
    x.report("Reference (Compound Type-ID)") { test_set.each{|rl| rl.reference_refs.pluck(:id)} }
    x.report("Compound Type-ID") { test_set.each{|rl| rl.cmpd_type_id_refs.pluck(:id)} }
    x.report("Compound ID-Type") { test_set.each{|rl| rl.cmpd_id_type_refs.pluck(:id)} }
  end

  ActiveRecord::Base.connection.query_cache.clear

  p "---------------------------- Count Test ----------------------------"
  Benchmark.bm do |x|
    x.report("Unindexed") { test_set.each{|rl| rl.unindexed_refs.count} }
    x.report("ID Only") { test_set.each{|rl| rl.id_only_refs.count} }
    x.report("Type Only") { test_set.each{|rl| rl.type_only_refs.count} }
    x.report("Independent Indicies") { test_set.each{|rl| rl.independent_type_id_refs.count} }
    x.report("Reference (Compound Type-ID)") { test_set.each{|rl| rl.reference_refs.count} }
    x.report("Compound Type-ID") { test_set.each{|rl| rl.cmpd_type_id_refs.count} }
    x.report("Compound ID-Type") { test_set.each{|rl| rl.cmpd_id_type_refs.count} }
  end
end

Выводы

Моя интуиция заключалась бы в том, что составной индекс с основным идентификатором будет работать лучше всего, потому что поле идентификатора имеет самую высокую кардинальность (т.е. наибольшее разнообразие) и, следовательно, приведет к более полезному индексу. Так я был прав?

Вроде. Лучшие схемы индексации были от лучших к чуть менее хорошим

  1. Любой составной индекс
  2. Независимые индексы
  3. Индексация только по идентификатору
# Example Run Results
#
# The `real` timing seems to be the most stable and useful. However,
# don't trust the exact numbers in this example. There was a good
# amount of variation between runs.
#
# ---------------------------- Pluck ID Test ----------------------------
                               user       system     total    real
Unindexed                      0.219539   0.004578   0.224117 (  1.048511)
ID Only                        0.114350   0.006782   0.121132 (  0.244616)
Type Only                      0.144048   0.011984   0.156032 (  1.002924)
Independent Indicies           0.106722   0.007628   0.114350 (  0.223154)
Reference (Compound Type-ID)   0.106546   0.007616   0.114162 (  0.195699)
Compound Type-ID               0.109110   0.008631   0.117741 (  0.198535)
Compound ID-Type               0.110510   0.008175   0.118685 (  0.195146)

# ---------------------------- Count Test ----------------------------
                               user       system     total    real
Unindexed                      0.102973   0.006801   0.109774 (  0.933632)
ID Only                        0.078064   0.004718   0.082782 (  0.197286)
Type Only                      0.189893   0.002887   0.192780 (  1.047528)
Independent Indicies           0.080484   0.003792   0.084276 (  0.184939)
Reference (Compound Type-ID)   0.072188   0.008226   0.080414 (  0.158406)
Compound Type-ID               0.070968   0.008155   0.079123 (  0.152290)
Compound ID-Type               0.060601   0.012225   0.072826 (  0.147477)

Во многих прогонах два составных индекса работали очень похоже. Они были настолько близки, что, кажется, не имеет большого значения, что вы выберете. Любое несоответствие между ними было в пределах погрешности моего тестирования. В некоторых прогонах победит ID-major, а некоторые перейдут в type-major.

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

Две другие схемы были существенно медленнее. Производительность таблиц только для типов и неиндексированных таблиц была примерно одинаковой, обычно примерно в 5–6 раз медленнее, чем в других схемах.

Стоит отметить, что до того, как я остановился на своей окончательной методологии, я тестировал только 20 000 записей (а не 100 000) в каждой таблице тестирования индексов. При таком размере таблицы разница между методами была гораздо менее заметной. Даже последовательное сканирование неиндексированной таблицы выполняется аналогично любой другой схеме.

Выводы (tl;dr)

Если вы пишете указатель для своей полиморфной ассоциации, подойдет любая составная ассоциация.

Если вас беспокоит размер индекса или время записи индекса, вы также можете использовать индекс только для идентификатора и не заметите разницы.

На небольшой (‹10 000 строк) таблице вы, скорее всего, вообще не заметите никакой разницы.

И во всех случаях Postgres чертовски быстр.

Если вам — предприимчивому веб-разработчику — понравилась эта статья, вы также можете почерпнуть небольшой совет из других моих статей, показанных ниже. А если вы находитесь в США и ищете работу, прочтите статью Почему вы, инженер-программист, должны вместе со мной продавать лекарства для животных в Интернете.