Если вам нужен быстрый поиск по внешнему ключу базы данных, вы индексируете его. Но когда ваш внешний ключ состоит из двух частей — идентификатора и типа — что вы должны индексировать?
(Нет времени? Пропустить до конца.)
Оказывается вариантов индексации полиморфных ассоциаций очень много. Мы могли бы индексировать любое поле отдельно, ни одно поле, оба (независимо) или оба с составным индексом. Но что наиболее эффективно? Будучи хорошим разработчиком, я нашел ответ на StackOverflow и обнаружил…
… в половине ответов говорилось об использовании составного индекса, начинающегося с «тип», а в другой половине предлагался составной индекс, начинающийся с «ID». Поскольку составные индексы должны выполнять поиск по первому столбцу перед доступом ко второму, эти ответы не совсем эквивалентны. Ни один из ответов, которые я нашел, не включал эталонные показатели, поэтому не было достаточно данных, чтобы определить, что делать. Поскольку единого ответа я не нашел, я решил провести тест для себя.
Методология
Для настройки я создал две таблицы для произвольно названных «альф» и «омег». Затем я создал серию других «таблиц проверки индекса», каждая из которых содержала полиморфную ассоциацию, которая могла ссылаться либо на строку альфа-таблицы, либо на одну строку омега-таблицы. Каждая из этих таблиц проверки индекса отличалась только тем, как были проиндексированы два столбца ассоциации. Всего я создал семь таблиц тестирования индексов, используя миграцию на моем сервере Rails 6.1, локально подключенном к Postgres:
- Неиндексированный
- Индекс только по ID
- Индекс только по типу
- Независимые индексы как по типу, так и по идентификатору
- Составной индекс с основным идентификатором, второстепенным типом
- Составной индекс с типом Major, ID Minor
- Индексируется с использованием типа 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
Выводы
Моя интуиция заключалась бы в том, что составной индекс с основным идентификатором будет работать лучше всего, потому что поле идентификатора имеет самую высокую кардинальность (т.е. наибольшее разнообразие) и, следовательно, приведет к более полезному индексу. Так я был прав?
Вроде. Лучшие схемы индексации были от лучших к чуть менее хорошим
- Любой составной индекс
- Независимые индексы
- Индексация только по идентификатору
# 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 чертовски быстр.
Если вам — предприимчивому веб-разработчику — понравилась эта статья, вы также можете почерпнуть небольшой совет из других моих статей, показанных ниже. А если вы находитесь в США и ищете работу, прочтите статью Почему вы, инженер-программист, должны вместе со мной продавать лекарства для животных в Интернете.