Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
/tmp
/log
/public
.idea
7 changes: 7 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@ group :development do
# Access an interactive console on exception pages or by calling 'console' anywhere in the code.
gem 'web-console', '>= 3.3.0'
gem 'listen', '>= 3.0.5', '< 3.2'
gem 'ruby-prof'
gem 'meta_request'
# gem 'bullet'
gem 'pghero'
gem 'pg_query', '>= 0.9.0'
end

group :test do
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
# A library for bulk insertion of data into your database using ActiveRecord
gem 'activerecord-import'
16 changes: 16 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ GEM
activemodel (= 5.2.3)
activesupport (= 5.2.3)
arel (>= 9.0)
activerecord-import (1.0.2)
activerecord (>= 3.2)
activestorage (5.2.3)
actionpack (= 5.2.3)
activerecord (= 5.2.3)
Expand Down Expand Up @@ -67,6 +69,9 @@ GEM
mini_mime (>= 0.1.1)
marcel (0.3.3)
mimemagic (~> 0.3.2)
meta_request (0.7.2)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 7)
method_source (0.9.2)
mimemagic (0.3.3)
mini_mime (1.0.1)
Expand All @@ -77,8 +82,13 @@ GEM
nokogiri (1.10.2)
mini_portile2 (~> 2.4.0)
pg (1.1.4)
pg_query (1.1.0)
pghero (2.3.0)
activerecord (>= 5)
puma (3.12.1)
rack (2.0.6)
rack-contrib (2.1.0)
rack (~> 2.0)
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (5.2.3)
Expand Down Expand Up @@ -109,6 +119,7 @@ GEM
rb-fsevent (0.10.3)
rb-inotify (0.10.0)
ffi (~> 1.0)
ruby-prof (1.0.0)
ruby_dep (1.5.0)
sprockets (3.7.2)
concurrent-ruby (~> 1.0)
Expand All @@ -134,12 +145,17 @@ PLATFORMS
ruby

DEPENDENCIES
activerecord-import
bootsnap (>= 1.1.0)
byebug
listen (>= 3.0.5, < 3.2)
meta_request
pg (>= 0.18, < 2.0)
pg_query (>= 0.9.0)
pghero
puma (~> 3.11)
rails (~> 5.2.3)
ruby-prof
tzinfo-data
web-console (>= 3.3.0)

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/trips_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ class TripsController < ApplicationController
def index
@from = City.find_by_name!(params[:from])
@to = City.find_by_name!(params[:to])
@trips = Trip.where(from: @from, to: @to).order(:start_time)
@trips = Trip.where(from: @from, to: @to).includes(bus: :services).order(:start_time).to_a
end
end
5 changes: 5 additions & 0 deletions app/models/buses_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

# Model for convenient access to join table
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

class BusesService < ApplicationRecord
end
2 changes: 1 addition & 1 deletion app/models/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class Service < ApplicationRecord
'Телевизор общий',
'Телевизор индивидуальный',
'Стюардесса',
'Можно не печатать билет',
'Можно не печатать билет'
].freeze

has_and_belongs_to_many :buses, join_table: :buses_services
Expand Down
1 change: 0 additions & 1 deletion app/views/trips/_delimiter.html.erb

This file was deleted.

1 change: 0 additions & 1 deletion app/views/trips/_service.html.erb

This file was deleted.

6 changes: 0 additions & 6 deletions app/views/trips/_services.html.erb

This file was deleted.

5 changes: 0 additions & 5 deletions app/views/trips/_trip.html.erb

This file was deleted.

18 changes: 14 additions & 4 deletions app/views/trips/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@
<%= "Автобусы #{@from.name} – #{@to.name}" %>
</h1>
<h2>
<%= "В расписании #{@trips.count} рейсов" %>
<%= "В расписании #{@trips.size} рейсов" %>
</h2>

<% @trips.each do |trip| %>
<ul>
<%= render "trip", trip: trip %>
<li><%= "Отправление: #{trip.start_time}" %></li>
<li><%= "Прибытие: #{(Time.parse(trip.start_time) + trip.duration_minutes.minutes).strftime('%H:%M')}" %></li>
<li><%= "В пути: #{trip.duration_minutes / 60}ч. #{trip.duration_minutes % 60}мин." %></li>
<li><%= "Цена: #{trip.price_cents / 100}р. #{trip.price_cents % 100}коп." %></li>
<li><%= "Автобус: #{trip.bus.model} №#{trip.bus.number}" %></li>

<% if trip.bus.services.present? %>
<%= render "services", services: trip.bus.services %>
<li>Сервисы в автобусе:</li>
<ul>
<% trip.bus.services.each do |service| %>
<li><%= "#{service.name}" %></li>
<% end %>
</ul>
<% end %>
</ul>
<%= render "delimiter" %>
====================================================
<% end %>
89 changes: 89 additions & 0 deletions case-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
### 1. Оптимизация загрузки данных в базу приложения

#### Проблема
Рейк-таск для загрузки данных в базу приложения работает слишком долго: ~8 секунд для файла с 1_000 записей, ~67 секунд для файла с 10_000 записей.

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

#### Гарантия корректности работы программы
Для гарантии корректной работы написан тест проверяющий что в базу корректно загружается файл `fixtures/example.json`

#### Feedback-loop
Для получения быстрой обратной связи добавлены скрипты генерации отчетов профилировщиков.

#### Процесс оптимизации
В исходном состоянии скрипт загрузки имеет следующие показатели для файла small.json (1к записей, размер ~304K)
Потребление памяти (вместе со всем rails приложением)
```
Total allocated: 467.01 MB (4716422 objects)
Total retained: 10.68 MB (9833 objects)
```
Время работы - ~9 сек

Ruby-prof по времени не показал точек роста - отчет в основном состоит из внутренностей Rails.

Собрал лог из базы данных и скормил его pgbadger - оказалось что для вставки 1_000 записей выполняется 13_528 запросов к базе.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Из них 68% (9_253) это SELECT, т.е. даже не вставка данных. Несложно было догадаться что основная масса из них это проверка "справочников" и валидации AR выполняемые при сохранении данных.
Решил сделать как было предложено в подсказках к заданию - использовать гем `activerecord-import` а также собрать справочники и загрузить их одной пачкой.

Благодаря этим действиям удалось значительно снизить время загрузки данных :
Без AR валидаций small.json - ~1.1 секунд, large.json - ~7.7 секунд
С AR валидациями small.json - ~1.3 секунд, large.json - ~18.4 секунд
(данные, включая время старта окружения)

Потребление памяти также сократилось:
small.json:
```
Total allocated: 24.79 MB (159304 objects)
Total retained: 10.61 MB (9125 objects)
```
large.json
```
Total allocated: 710.76 MB (5513967 objects)
Total retained: 10.61 MB (9120 objects)
```
Естественно что без стриминга мы грузим в память файл который обрабатываем целиком, но bonus часть я не выполнял, так что оставляю как есть.

#### Результат
В результате проведенной оптимизации удалось уложиться в рамки задачи.

#### Защита от регрессии
Для защиты от регрессии добавлен тест, который проверяет время загрузки файла с 1к записей.


### 2. Оптимизация времени загрузки страницы

#### Проблема
Страница с расписанием автобусов загружается слишком долго.

#### Метрика
Чтобы понять, несут ли изменения положительный эффект, в качестве метрики выбрано время загрузки страницы для файла large.json.

#### Гарантия корректности работы программы
Для гарантии корректной работы написан тест проверяющий что контроллер выдает корректный html для файла `example.json`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


#### Feedback-loop
Профилировщики выдают отчеты прямо на оптимизируемой странице.

#### Процесс оптимизации
Перед оптимизацией страница загружалась за ~15-19 секунд для файла large.json.

Rails-panel и rack mini profiler показали что основная масса времени тратится на рендеринг.
Решил собрать все темплейты в один файл, поскольку они очень простые.
В результате время загрузки страницы почти не изменилось.

И оба профайлера выше и логи показали что страница делает ~2000 запросов в базу, прямо из вьюх (видимо поэтому rails panel отнес все затраты времени в рендеринг)
Уменьшил количество запросов к базе поправив N+2 (заюзал `includes`), и сделал принудительную загрузку данных в контроллере (`.to_a` на запросе)
Стало 6 запросов к базе, время загрузки сократилось значительно - до ~450-500 ms. Плюс стало четко видно сколько времени занимает непосредственно рендеринг - ~80-120ms

Добавил индексов на id поля, но pg_hero поругал меня за дубликаты, пришлось удалить :)

#### Результат
В результате проведенной оптимизации удалось уложиться в рамки задачи (~500ms)?

#### Защита от регрессии
Для защиты от регрессии добавил тест, который проверяет время ответа контроллера. К сожалению время ответа очень сильно различается от теста к тесту, поэтому с запасом указал 0.5 сек для файла example.json

#### P.S.
Понимаю, что нет предела совершенству - можно и "справочники" превратить в константы, и кеширование добавить во вьюхи и пагинацию на страницы, но устал :)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 😄

6 changes: 6 additions & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,10 @@
# Use an evented file watcher to asynchronously detect changes in source code,
# routes, locales, etc. This feature depends on the listen gem.
config.file_watcher = ActiveSupport::EventedFileUpdateChecker

# config.after_initialize do
# Bullet.enable = true
# Bullet.alert = true
# Bullet.console = true
# end
end
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
Rails.application.routes.draw do
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
mount PgHero::Engine, at: 'pghero' if Rails.env.development?

get "/" => "statistics#index"
get "автобусы/:from/:to" => "trips#index"
end
80 changes: 80 additions & 0 deletions lib/populate_database.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

# Populates database with records from json file
class PopulateDatabase
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍👍

include Singleton

class << self
delegate :call, to: :instance
end

def call(file_path:, validate: false)
json = JSON.parse(File.read(file_path))

clear_database
process_common_entities(json, validate)
process_trips(json, validate)
end

private

def clear_database
ActiveRecord::Base.transaction do
City.delete_all
Bus.delete_all
Service.delete_all
Trip.delete_all
BusesService.delete_all
end
end

def process_common_entities(json, validate)
cities = Set.new
buses = Set.new
services = Set.new

json.each do |trip|
cities << { name: trip['from'] }
cities << { name: trip['to'] }
buses << { number: trip['bus']['number'], model: trip['bus']['model'] }

trip['bus']['services'].each { |service_name| services << { name: service_name } }
end

City.import cities.to_a, validate: validate
Bus.import buses.to_a, validate: validate
Service.import services.to_a, validate: validate
end

def process_trips(json, validate)
cities = City.pluck(:name, :id).to_h
buses = Bus.all.each_with_object({}) do |bus, memo|
key = [bus.number, bus.model]
memo[key] = bus.id
end
services = Service.pluck(:name, :id).to_h
buses_services = Set.new
trips = []

json.each do |trip|
bus_key = [trip['bus']['number'], trip['bus']['model']]
bus_id = buses[bus_key]
trip['bus']['services'].each do |service_name|
service_id = services[service_name]
buses_services << { bus_id: bus_id, service_id: service_id }
end

trips << {
from_id: cities[trip['from']],
to_id: cities[trip['to']],
start_time: trip['start_time'],
duration_minutes: trip['duration_minutes'],
price_cents: trip['price_cents'],
bus_id: bus_id
}
end

BusesService.import buses_services.to_a, validate: false
Trip.import trips, validate: validate, batch_size: 1_000
end
end
36 changes: 5 additions & 31 deletions lib/tasks/utils.rake
Original file line number Diff line number Diff line change
@@ -1,34 +1,8 @@
# Наивная загрузка данных из json-файла в БД
# rake reload_json[fixtures/small.json]
task :reload_json, [:file_name] => :environment do |_task, args|
json = JSON.parse(File.read(args.file_name))
# frozen_string_literal: true

ActiveRecord::Base.transaction do
City.delete_all
Bus.delete_all
Service.delete_all
Trip.delete_all
ActiveRecord::Base.connection.execute('delete from buses_services;')
require 'populate_database'

json.each do |trip|
from = City.find_or_create_by(name: trip['from'])
to = City.find_or_create_by(name: trip['to'])
services = []
trip['bus']['services'].each do |service|
s = Service.find_or_create_by(name: service)
services << s
end
bus = Bus.find_or_create_by(number: trip['bus']['number'])
bus.update(model: trip['bus']['model'], services: services)

Trip.create!(
from: from,
to: to,
bus: bus,
start_time: trip['start_time'],
duration_minutes: trip['duration_minutes'],
price_cents: trip['price_cents'],
)
end
end
desc 'Loads datum from *.json dump file to DB'
task :reload_json, [:file_path] => :environment do |_task, args|
PopulateDatabase.call(file_path: args.file_path)
end
5 changes: 5 additions & 0 deletions profiling/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM perl:5.28-stretch
RUN apt-get update && apt-get install wget tar
RUN wget https://github.com/darold/pgbadger/archive/v11.0.tar.gz && tar xzf v11.0.tar.gz
RUN cd pgbadger-11.0/ && perl Makefile.PL && make && make install
CMD bash
Loading