Что это и зачем нужно?
Предположим у вас есть крупное новостное издание, у которого много разных типов материалов.
Для каждого типа материала существует своя модель: Topics::Article
, Topics::Online
, Topics::NewsItem
и так далее. У них будут одинаковыми большинство полей, такие как заголовок, обложка, текст, авторы. Различие только в нескольких специфичных полях, уникальных для каждого типа топика.
Поэтому вам не хочется раскладывать их по отдельным таблицам. Кроме нежелания создавать почти полностью повторяющиеся таблицы, для этого могут быть и несколько других причин. Необходимость сложных выборок с разными комбинациями этих типов, водопады UNION и полиморфизм подключающихся моделей в том числе.
Под катом опыт организации похожих моделей внутри Postgresql, с итогом в виде миграции на наследование таблиц. Стрельба в ногу серебряной пулей тоже присутствует, куда же без нее.
Single Table Inheritance
Первое что приходит в голову, это классический Single Table Inheritance. В Рельсах он автоматически включается, если наследовать одну модель от другой.
Создав одну общую таблицу Topics и добавив в нее служебное поле type можно хранить все классы внутри одной таблицы.
class Topic < ActiveRecord::Base
end
class Topics::Article < Topic
end
class Topics::NewsItem < Topic
end
...
Общая логика (например, публикация материала) уходит в Topic. Специфичная в отнаследованные классы.
У этой простой и обкатанной схемы есть проблемы с масштабированием. Что делать, если типов топиков уже больше пятнадцати и у каждого от двух до десяти уникальных полей?
На этом моменте бд пуристы морщатся как будто бы от сильнейшей зубной боли — воображая себе таблицу, строки которой всегда заполнены не более чем на 15-20%.
STI + Jsonb
Jsonb
В Postgresql c 9.4 есть возможность создавать поля типа jsonb. Как это может нам помочь?
Добавив в topics поле data этого типа мы можем хранить все наши дополнительные поля в ключах json.
Подключить в Рельсах это можно так:
class Topics::Online < Topic
store_accessor :data, :start_at, :live
end
Теперь можно делать так:
online = Topics::Online.new(live: true)
online.live # => true
Или напрямую обращаться в json:
online['data']['live'] # => true
Проблемы Jsonb
Наслаждение достигнутым успехом быстро омрачается подпорками из костылей.
Преобразование типов
Помимо экзотики вроде массивов и объектов (хешей) jsonb предлагает для всех полей использовать только Number, String и Boolean.
Для других типов полей придется писать дополнительные методы. А если вы предпочитаете с сахаром, то и для этих полей тоже.
Предикаты:
def live?
live == true
end
Более сложный случай для DateTime:
def start_at
return Time.zone.parse(super) if super.is_a?(String)
end
Здесь нужно распарсить строку во время и не сломаться. Потому что ломаться нужно на этапе сохранения данных.
Валидация входных значений
Валидаций типа на уровне бд нет, можно легко и непринужденно сохранить такой топик:
online.live = 'Elvis'
online.start_at = 'Presley'
Существующие гемы, например activerecord-typedstore, частично решают проблему парсинга строк, но совсем не справляются с проверкой входных значений. Все нужно закрывать из приложения кастомными валидациями.
Громоздкие запросы
Булево поле:
scope live, -> { where(live: true) }
scope live, -> { where("(params->>'live')::bool = ?", true) }
А теперь даты:
scope :by_range, ->(start_date, end_date) { where(date: start_date..end_date) }
scope :by_range, lambda { |start_date, end_date|
where(
"to_date(params->>'date', 'YYYY-MM-DD') BETWEEN ? AND ?",
start_date, end_date
)
}
Помимо общей монструозности этот запрос еще и будет медленнее работать, из-за вынужденного использования to_date
Постгреса.
Uniq
Постгрес пока не умеет делать обычный DISTINCT (.uniq) для записей с jsonb, нужно делать так:
.select('DISTINCT ON (tags.id) tags.*')
Нет значений по умолчанию
Приходится использовать разные конструкции в before_initialize
вместо привычных null: false, default: false
в миграции.
Связи
Использовать рельсовые has_many
и belongs_to
не выйдет. Нужно писать что-то свое.
На этом этапе jsonb получил черную метку и до троллейбуса из буханки хлеба дело не дошло.
Мигрируем на Postgres Inheritance
Наследование таблиц появилось в Постгресе с версии 9.1 и близко концепции наследования классов.
Только не классов, а таблиц, и не в Руби, а в Постгресе.
У вас по прежнему есть таблица topics, но расширяете ее вы не через набор дополнительных полей в этой самой таблице, а через дополнительные таблицы, содержащие только уникальные для каждого класса поля.
Проще всего показать на примере:
CREATE TABLE topics (
headline text,
author_id int
);
CREATE TABLE topics_onlines (
status char(2)
) INHERITS (topics);
Создав topics_onlines
мы можем работать с ней как с обычной таблицей, у которой будут все три поля:
class Topics::Online < Topic
# headline, author_id, status
end
Это просто, красиво и не требует массивного переписывания кода.
Postgres Inheritance + Rails
SELECT c.tableoid, c.headline, c.author_id FROM topics c
tableoid | headline | author_id
----------+-----------+----------
139793 | Las Vegas | 2174
139793 | Mariposa | 1953
139798 | Madison | 845
Родительская и дочерняя таблицы линкуются внутри Постгреса через tableoid. Мы не увидими никаких джойнов по tableoid в explain, все это работает внутри Постгреса.
Из приложения topics_onlines
будет выглядеть как самая обычная таблица без наследований, содержащая все поля из topics
и специфичные для онлайна поля из topics_onlines
.
А это значит, что со стороны Рельс нужно только написать миграцию создающую таблицы.
И что наследование таблиц можно использовать с любым фреймворком.
Миграция с STI на PGI
Чтобы воспользоваться всей этой радостью нужно написать миграцию.
Для начала нам потребуется стандартная обертка для sql миграции в Рельсах:
class CreateInheritanceTablesForTopics < ActiveRecord::Migration
def change
reversible do |dir|
dir.up do
...
end
end
end
Код дальше вставляется на место многоточия. Чтобы не накидывать простыню кода сразу, я покажу миграцию по порциям.
Триггер для проверки на уникальность по всем таблицам топиков
Создаем, но пока нигде не используем триггер в Postgresql:
CREATE OR REPLACE FUNCTION check_for_topic_dups()
RETURNS trigger AS
$func$
BEGIN
PERFORM 1 FROM topics where NEW.id=id;
IF FOUND THEN
RAISE unique_violation USING MESSAGE = 'Duplicate ID: ' || NEW.id;
RETURN NULL;
END IF;
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;
Триггер вызывает ошибку, если топик с таким id уже существует. Это страховка на тот случай, если что-то пошло не так.
Самое важное ограничение PGI — для всех дочерних таблиц не действуют индексы и ограничения родительской таблицы. То есть в этом плане все действительно ощущается как разные физические таблицы.
Для наших условий у разных типов топиков не может повторяться айдишник, поэтому был добавлен этот триггер. Он опционален и нужен как страховка.
Создание таблиц
Topic.descendants.each do |topic_type|
sql = <<-SQL
CREATE TABLE #{topic_type.pgi_table_name} ( CHECK (type='#{topic_type}') )
INHERITS (topics);
CREATE RULE redirect_insert_to_#{topic_type.table_name} AS
ON INSERT TO topics WHERE
(type='#{topic_type}')
DO INSTEAD
INSERT INTO #{topic_type.table_name} VALUES (NEW.*);
CREATE TRIGGER check_uniquiness_#{topic_type.table_name}
BEFORE INSERT ON #{topic_type.table_name}
FOR EACH ROW EXECUTE PROCEDURE check_for_topic_dups();
SQL
execute(sql)
add_index topic_type.table_name, :id
end
- Создаются таблицы для каждого типа топиков (метамагия здесь больше для компактности, в миграциях так лучше не делать)
- Для каждой дочерней таблицы добавляется ограничение на тип топика (в
topics_onlines
можно вставить толькоTopics::Online
) - При попытке вставить топик с заполненным типом в topics он будет перенаправлен в дочернюю таблицу по типу
- Вешаем на каждую таблицу раннее созданный триггер для проверки уникальных айдишников
- Создаем индексы для дочерних таблиц
Разумеется, миграцию можно посадить на строгую диету, а все проверки вытащить в сами Рельсы.
Добавляем нативные поля в отнаследованные таблицы
В новые таблицы можно добавлять поля используя стандартные миграции:
Class PopulateTopicsTablesWithFields < ActiveRecord::Migration
def change
add_column :topics_onlines, :start_at, :datetime
add_column :topics_news, :main, :boolean, null: false, default: false
end
end
Если вы не готовы полностью избавиться от STI, то в классах топиков прописывается нужная таблица:
class Topics::Online < Topic
self.table_name = :topics_online
end
Осталось только изменить тип схемы на sql:
# config/application.rb
config.active_record.schema_format = :sql
И все готово.
Скорость — PGI vs jsonb
Заключительным этапом было бы интересно оценить производительность. Так как все это затевалось ради удобства разработки, то тестированию скорости PGI было уделено не так много времени, однако какие-то выводы сделать можно.
После миграции были подняты две версии приложения, PGI и старая с jsonb.
Больше 5_000_000
топиков в каждой базе.
Количество всех топиков
Самый синтетический пример:
PGI:
Topics::Topic.count
(8591.6ms) SELECT COUNT(*) FROM "topics"
=> 5316226
Jsonb:
Topics::Topic.count
(8580.1ms) SELECT COUNT(*) FROM "topics"
=> 5316226
Не удивляйтесь странному числу, топики создавались пока не закончилось место на ssd.
Количество топиков одного типа
PGI:
Gazeta::Topics::Sport::Online.count
* (219.5ms) SELECT COUNT(*) FROM "topics_sport_onlines"
WHERE "topics_sport_onlines"."type" IN ('Gazeta::Topics::Sport::Online')
=> 1000000
Jsonb:
Gazeta::Topics::Sport::Online.count
* (419.0ms) SELECT COUNT(*) FROM "topics"
WHERE "topics"."type" IN ('Gazeta::Topics::Sport::Online')
=> 1000000
Запрос по boolean полю
Индекс не используется из-за высокой селективности.
PGI:
Gazeta::Topics::Sport::Online.megauho.explain
* Gazeta::Topics::Sport::Online Load (1376.2ms) SELECT "topics_sport_onlines".*
FROM "topics_sport_onlines"
WHERE "topics_sport_onlines"."type" IN ('Gazeta::Topics::Sport::Online')
AND "topics_sport_onlines"."megauho" = $1 [["megauho", "t"]]
Jsonb:
Gazeta::Topics::Sport::Online.megauho.explain
* Gazeta::Topics::Sport::Online Load (5819.6ms) SELECT "topics".*
FROM "topics"
WHERE "topics"."type" IN ('Gazeta::Topics::Sport::Online')
AND ((topics.params->>'megauho')::bool = 't')
Разница существенная.
Запрос по boolean полю c лимитом
Хотя бы уже пересекается с реальным миром.
PGI:
Gazeta::Topics::Sport::Online.megauho.limit(1000).explain
* Gazeta::Topics::Sport::Online Load (9.1ms) SELECT "topics_sport_onlines".*
FROM "topics_sport_onlines"
WHERE "topics_sport_onlines"."type" IN ('Gazeta::Topics::Sport::Online')
AND "topics_sport_onlines"."megauho" = $1 LIMIT 1000 [["megauho", "t"]]
Jsonb:
Gazeta::Topics::Sport::Online.megauho.limit(1000).explain
* Gazeta::Topics::Sport::Online Load (23.7ms) SELECT "topics".*
FROM "topics"
WHERE "topics"."type" IN ('Gazeta::Topics::Sport::Online')
AND ((topics.params->>'megauho')::bool = 't') LIMIT 1000
Разница есть.
PGI для запроса с низкой селективностью
Поиск по индексу, вернет 123 записи из миллиона, Index Scan.
PGI:
Gazeta::Topics::Sport::Online.megauho.megauho_by_date('2015-12-26').explain
* Gazeta::Topics::Sport::Online Load (6.0ms) SELECT "topics_sport_onlines".*
FROM "topics_sport_onlines" WHERE "topics_sport_onlines"."type"
IN ('Gazeta::Topics::Sport::Online')
AND "topics_sport_onlines"."megauho" = $1
AND (topics_sport_onlines.date = '2015-12-26') [["megauho", "t"]]
QUERY PLAN
----------
* Index Scan using index_type_megauho_date on topics_sport_onlines
(cost=0.42..42.12 rows=20 width=682)
Index Cond: (((type)::text = 'Gazeta::Topics::Sport::Online'::text)
AND (megauho = true) AND ((date)::text = '2015-12-26'::text))
Filter: megauho
(3 rows)
Jsonb:
Gazeta::Topics::Sport::Online.megauho.megauho_by_date('2015-12-26').explain
* Gazeta::Topics::Sport::Online Load (7.7ms) SELECT "topics".*
FROM "topics" WHERE "topics"."type"
IN ('Gazeta::Topics::Sport::Online')
AND ((topics.params->>'megauho')::bool = 't')
AND (topics.params->>'date' = '2015-12-26')
QUERY PLAN
----------
* Index Scan using index_type_megauho_date on topics
(cost=0.56..217.61 rows=27 width=948)
Index Cond: (((type)::text = 'Gazeta::Topics::Sport::Online'::text)
AND ((params ->> 'date'::text) = '2015-12-26'::text))
Filter: ((params ->> 'megauho'::text))::boolean
(3 rows)
- PGI быстрее.
- Чем больше выборка — тем быстрее.
- Для запросов использующих индексы, PGI все еще быстрее, но разница уже менее ощутима.
Правильно готовим jsonb
Для задачи полноценного расширения моделей jsonb может оказаться крайне токсичным. Разумеется, PGI тоже имеет свои ограничения, но для наших задач они были преодолены за один раз, во время миграции.
И все же jsonb может быть полезен для слабо структурированных данных из внешнего источника. Эти поля даже не нужно определять через store_accessor
, их можно просто сохранять как есть, а дальше отдельный класс Builder соберет из них что-то полезное.
Для нас такими данными стали спортивные трансляции, забираемые из внешнего апи.
Автор: Rambler&Co