Спадкування в Postgresql таблиць з Ruby On Rails

Мігруючи на Postgres Inheritance
Що це і навіщо потрібно?
Припустимо, у вас є велике новинне видання, у якого багато різних типів матеріалів.
Для кожного типу матеріалу існує своя модель:
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
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
Спадкування таблиць з'явилося в Постгресе досить давно (швидше за все оновлювати версію не потрібно) і близько концепції успадкування класів.
Тільки не класів, а таблиць, і не в Рубі, а в Постгресе.
У вас є таблиця 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.do up

...

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 THEN FOUND
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 TO INSERT topics WHERE
(type='#{topic_type}')
DO INSTEAD
INSERT INTO #{topic_type.table_name} VALUES (NEW.*);
CREATE TRIGGER check_uniquiness_#{topic_type.table_name}
INSERT BEFORE 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 vs jsonb
Заключним етапом було б цікаво оцінити продуктивність. Так як все це затівалося заради зручності розробки, тестування швидкості PGI було приділено не так багато часу, однак якісь висновки зробити можна.
Після міграції були підняті дві версії додатка, PGI і стара з jsonb.
Більше
5_000_000
топіків в кожній базі.
Кількість всіх топіків
Самий синтетичний приклад:
PGI:
Topics::Topic.count
(8591.6 ms) SELECT COUNT(*) FROM "topics"
=> 5316226

Jsonb:
Topics::Topic.count
(8580.1 ms) SELECT COUNT(*) FROM "topics"
=> 5316226

Не дивуйтеся дивним числа, топіки створювалися поки не закінчилося місце на ssd.
Кількість топіків одного типу
PGI:
Gazeta::Topics::Sport::Online.count
* (219.5 ms) SELECT COUNT(*) FROM "topics_sport_onlines"
WHERE "topics_sport_onlines"."type" IN ('Gazeta::Topics::Sport::Online')
=> 1000000

Jsonb:
Gazeta::Topics::Sport::Online.count
* (419.0 ms) 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.2 ms) 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.6 ms) 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.1 ms) 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.7 ms) 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.0 ms) 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.7 ms) 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 збере з них щось корисне.
Для нас такими даними стали спортивні трансляції, забираемые з зовнішнього апі.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.