Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Эффективные модели в ActiveRecord: как избежать типичных проблем с производительностью и не переписывать все на SQL

Эффективные модели в ActiveRecord: как избежать типичных проблем с производительностью и не переписывать все на SQL

Презентована на сходке Kharkiv.rb #3

Полезные советы и истории из жизни

Leonid Shevtsov

February 22, 2019
Tweet

More Decks by Leonid Shevtsov

Other Decks in Programming

Transcript

  1. Эффективные модели на ActiveRecord: Как избегать типичных проблем с производительностью

    и не переписывать все на SQL Леонид Шевцов · KharkivRB · Февраль 2019
  2. !

  3. А смысл? → AR-зависимый код весь в куче и изолирован

    → остальной код свободен делиться на небольшие модули → модели не жиреют
  4. Изоляция доступа к базе class MyController < ApplicationController def update

    model = MyModel.find_by(criteria).includes(:associations) MyLogic.do_logic(model) model.save! redirect_to :show end end module MyLogic extend self def do_logic(model) # тут никакого доступа к базе - чисто бизнес логика end end
  5. А смысл? Проще тестировать → выборку из базы без сложной

    логики → сложную логику без выборки из базы
  6. class MyModel < ApplicationRecord after_update :update_related_models def update_related_models if "сложная

    проверка бизнес-логики" if "вторая проверка" "поставить флаг 1" else "поставить флаг 2" end else "поставить флаг 3" end end end → чтоб протестировать, приходится настроить много сложных контекстов и выполнить много сложных проверок результатов
  7. class MyModel < ApplicationRecord after_update :update_related_models def update_related_models MyLogic.update_related_models(self) end

    end module MyLogic extend self def update_related_models if complex_check_1 if complex_check_2 update_flag(1) else update_flag(2) end else update_flag(3) end end end
  8. Тестирование → проверяем, что проверка возвращает ожидаемые значения при нужном

    состоянии базы → проверяем, что изменяющие данные методы делают то, что мы от них ожидаем → и поверхностно проверяем все вместе
  9. Модуль генерации отчетов (такое себе дерево конфигурируемых презентеров) → 3000

    строк кода → (N+1)1000 запросов к базе откуда попало → только самое базовое покрытие тестами Задача - сделать так, чтобы это работало быстрее. (Переписывать не получится)
  10. Решение → начинаем с того, чтобы подгрузить все недостающие ассоциации

    посредством includes() и preload() → остается все равно куча N+1 запросов, например, что тут предзагружать? Author's top published post: <%= @author.posts.published.order(:rating).first %>
  11. Подставляем скоупы в ассоциации class Author < ApplicationRecord has_many :posts

    has_one :top_published_post, class_name: 'Post', -> { published.order(:rating) } end @author = Author. preload(:top_published_post). find(params[:id])
  12. Эффективные ассоциации нужно сразу так писать ассоциации, что в начале

    проекта не беспокоиться о предзагрузке, а добавить ее потом, когда появятся реальные проблемные места
  13. Проблема class Post < ApplicationRecord scope :published, where(published: true) end

    найти всех авторов, у которых есть опубликованная статья Author. joins(:posts). where({posts: {published: true}})
  14. Проблема усложняется... Теперь логика повторяется в двух местах: class Post

    < ApplicationRecord scope :published, where('published_at < CURRENT_TIMESTAMP') end # упс... Author. joins(:posts). where({posts: {published: true}})
  15. Проблема оценки (объект, начало периода, конец периода, показатель, оценка) →

    объектов 4000 → периодов, на данный момент, 2 → показателей 1000 → перемножаем, получаем 8 000 000 строк в таблице нужно показать для выбранного объекта все доступные периоды (периоды у всех разные) (переписывать не получится)
  16. в ретроспективе, думаю, понятно, что нужно было делать две таблицы:

    периоды (объект, начало, конец) оценки (период, показатель, оценка) Но что делать, если этот поезд уже ушел?
  17. CREATE MATERIALIZED VIEW CREATE MATERIALIZED VIEW periods AS SELECT DISTINCT

    object_id, start_at, end_at FROM scores WITH DATA -- после внесения новых данных REFRESH MATERIALIZED VIEW CONCURRENTLY periods
  18. ! → скрипты остались работать, как работали → периоды выбирать

    стало в 1000 раз быстрее → страницы летают → все довольны
  19. Проблема → приходит JSON с сложноватой древовидной структурой → нужно

    обновить существующую структуру в базе (быстро) → около 10000 записей → ну тут-то без грязного SQL не обойдешься
  20. проблема усложняется... CREATE TABLE items ( id INT NOT NULL,

    name VARCHAR NOT NULL, position INT NOT NULL) CREATE UNIQUE INDEX items_position ON items (position) ну и как теперь поменять строки местами, не теряя целостности? item1 = Item.create!(name: 'Foo', position: 1) item2 = Item.create!(name: 'Bar', position: 2) # упс... item1.update!(position: 2)
  21. Решение (грязненькое) CREATE UNIQUE INDEX items_position ON items(position) WHERE position

    IS NOT NULL правда, придется отступить целостность на проверке NOT NULL: # упс... item2.update!(position: nil) item1.update!(position: 2) item2.update!(position: 1)
  22. Решение хорошее - constraints CREATE TABLE items ( id INT

    NOT NULL, name VARCHAR NOT NULL, position INT) CONSTRAINT position_is_not_null CHECK (position IS NOT NULL); теперь... Item.transaction do Item.defer_constraints item1.update!(position: 2) item2.update!(position: 1) # ! end
  23. Что за defer_constraints? module Ext::DeferConstraints extend ActiveSupport::Concern class_methods do def

    defer_constraints connection.execute('SET CONSTRAINTS ALL DEFERRED') end end end
  24. Подпроблема 2 предзагрузка в пакетной обработке { "cities": [ {

    "name": "Kharkiv", "streets": [{ "name": "Sumska st", length: 4.2}, ...] }, ... ] }
  25. Решение медленное json["cities"].each do |city_json| city = City.find_by(name: city_json["name"]) city_json["streets"].each

    do |street_json| street = city.streets. find_by(street_json["name"]) street.update!(length: street_json["length"]) end end хотелось бы @cities = City.preload(:streets) ...ну и что потом с ними делать?
  26. Решение module Ext::DetectOrInitializeBy def detect_by(attributes) detect do |record| attributes.all? {

    |(name, value)| record[name] == value } end end def detect_or_initialize_by(attributes) detect_by(attributes) || build(attributes) end end
  27. class City < ApplicationRecord extend Ext::DetectOrInitializeBy has_many streets:, extend: Ext::DetectOrInitializeBy

    end cities = City.preload(:streets) json["cities"].each do |city_json| city = cities.detect_by(name: city_json["name"]) city_json["streets"].each do |street_json| street = city.streets. detect_or_initialize_by(street_json["name"]) street.update!(length: street_json["length"]) end end
  28. Проблема → на базе данных построен свой собственный язык запросов,

    конструирующий огромный SQL. → Работает ! . → Проблем нет ???
  29. Проблема усложняется → Надо расширить набор входных параметров. → Комбинаторный

    взрыв? → В реальном времени делать уже нереально. ...Давайте просчитывать и кешировать заранее все комбинации? Раз в день будем успевать !