Skip to content

Task 3 #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
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
17 changes: 13 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
/.bundle
/tmp
/log
/public
log/
tmp/
.generators
.idea/
data*.txt
result.json
ruby_prof_reports/
.ruby-version
.rubocop.yml
/docker-valgrind-massif/
/stackprof_reports/
1M.json
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

.byebug_history
8 changes: 6 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -7,20 +7,24 @@ gem 'rails', '~> 5.2.3'
gem 'pg', '>= 0.18', '< 2.0'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false

gem 'pghero'
gem 'oj'
gem 'activerecord-import'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end

group :development do
gem 'rack-mini-profiler'
# 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'
end

group :test do
gem 'minitest'
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
# gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
15 changes: 13 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -33,6 +33,8 @@ GEM
activemodel (= 5.2.3)
activesupport (= 5.2.3)
arel (>= 9.0)
activerecord-import (1.0.4)
activerecord (>= 3.2)
activestorage (5.2.3)
actionpack (= 5.2.3)
activerecord (= 5.2.3)
@@ -76,9 +78,14 @@ GEM
nio4r (2.3.1)
nokogiri (1.10.2)
mini_portile2 (~> 2.4.0)
oj (3.10.2)
pg (1.1.4)
pghero (2.4.1)
activerecord (>= 5)
puma (3.12.1)
rack (2.0.6)
rack-mini-profiler (1.1.6)
rack (>= 1.2.0)
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (5.2.3)
@@ -134,17 +141,21 @@ PLATFORMS
ruby

DEPENDENCIES
activerecord-import
bootsnap (>= 1.1.0)
byebug
listen (>= 3.0.5, < 3.2)
minitest
oj
pg (>= 0.18, < 2.0)
pghero
puma (~> 3.11)
rack-mini-profiler
rails (~> 5.2.3)
tzinfo-data
web-console (>= 3.3.0)

RUBY VERSION
ruby 2.6.3p62

BUNDLED WITH
2.0.2
2.1.4
2 changes: 1 addition & 1 deletion app/controllers/trips_controller.rb
Original file line number Diff line number Diff line change
@@ -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.eager_load(bus: [:services]).where(from: @from, to: @to).order(:start_time).load
Copy link
Collaborator

Choose a reason for hiding this comment

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

Плюсик за eager_load и load вместо includes

end
end
11 changes: 11 additions & 0 deletions app/models/bus.rb
Original file line number Diff line number Diff line change
@@ -17,4 +17,15 @@ class Bus < ApplicationRecord

validates :number, presence: true, uniqueness: true
validates :model, inclusion: { in: MODELS }

def self.find_cached_or_create(bus_data)
@all_buses ||= Bus.all.map{ |bus| [bus.number, bus]}.to_h
unless @all_buses[bus_data['number']]
@all_buses[bus_data['number']] ||= Bus.create!(
model: bus_data['model'],
services: Service.find_dumped(bus_data['services']),
number: bus_data['number'])
end
@all_buses[bus_data['number']]
end
end
6 changes: 6 additions & 0 deletions app/models/city.rb
Original file line number Diff line number Diff line change
@@ -5,4 +5,10 @@ class City < ApplicationRecord
def name_has_no_spaces
errors.add(:name, "has spaces") if name.include?(' ')
end

def self.find_cached_or_create(name)
#name = name.gsub(' ','')
@all_cities ||= City.all.map{|city| [city.name, city]}.to_h
@all_cities[name] ||= City.create!(name:name)
end
end
14 changes: 14 additions & 0 deletions app/models/service.rb
Original file line number Diff line number Diff line change
@@ -16,4 +16,18 @@ class Service < ApplicationRecord

validates :name, presence: true
validates :name, inclusion: { in: SERVICES }

def self.find_dumped(names)
@all_dumped ||= dump_all_to_db
@all_dumped.slice(*names).values
end

private

def self.dump_all_to_db
SERVICES.sort.reverse.each do |name|
Service.find_or_create_by!(name: name)
end
Service.all.map{ |s| [s.name, s]}.to_h
end
end
15 changes: 15 additions & 0 deletions app/models/trip.rb
Original file line number Diff line number Diff line change
@@ -29,4 +29,19 @@ def to_h
},
}
end

def to_h_old
{
'from' => from.name,
'to' => to.name,
'start_time' => start_time,
'duration_minutes' => duration_minutes,
'price_cents' => price_cents,
'bus' => {
'number' => bus.number,
'model' => bus.model,
'services' => bus.services.map(&:name),
},
}
end
end
36 changes: 36 additions & 0 deletions app/services/trips_load.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
class TripsLoad
Copy link
Collaborator

Choose a reason for hiding this comment

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

Плюсик за вынос в сервис
Кстати я бы его предложил назвать LoadTrips


def self.perform(file_name)
json = Oj.load(File.read(file_name))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Тут разбухание памяти при загрузке большого файла
Сначала весь файл грузится в память, и потом ещё весь json целиком формируется в памяти

ActiveRecord::Base.transaction do
City.delete_all
Bus.delete_all
Service.delete_all
Trip.delete_all
ActiveRecord::Base.connection.execute('delete from buses_services;')
trips_array = []

json.each_with_index do |trip, index|

from = City.find_cached_or_create(trip['from'])
to = City.find_cached_or_create(trip['to'])
bus = Bus.find_cached_or_create(trip['bus'])

trip_hash = {
from_id: from.id,
to_id: to.id,
bus_id: bus.id,
start_time: trip['start_time'],
duration_minutes: trip['duration_minutes'],
price_cents: trip['price_cents']
}
trips_array << trip_hash
if index%1000 == 999
Trip.import(trips_array, validate: true, validate_uniqueness: true)
trips_array =[]
end
end
Trip.import(trips_array, validate: true, validate_uniqueness: true)
end
end
end
2 changes: 1 addition & 1 deletion app/views/trips/_services.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<li>Сервисы в автобусе:</li>
<ul>
<% services.each do |service| %>
<%= render "service", service: service %>
<li><%= "#{service.name}" %></li>
<% end %>
</ul>
17 changes: 13 additions & 4 deletions app/views/trips/index.html.erb
Original file line number Diff line number Diff line change
@@ -2,15 +2,24 @@
<%= "Автобусы #{@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 %>
102 changes: 102 additions & 0 deletions case-study.db.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Case-study оптимизации загрузки данных

## Актуальная проблема

Необходимо импортировать файл с данными fixtures/large.json с 100_000 трипов менее чем за 1 минуту.

У нас уже была rake задача, которая умела делать нужную обработку.

Но она недостаточно производительна
Так:
small.json с 1K трипов обрабатывается за 9 секунд
medium.json с 10K трипов обрабатывается за 80 секунд

По грубым оценкам large.json с 100K трипов будет обрабатываться не менее 800 секунд.

## Формирование метрики
Для анализа влияния изменений на скорость работы возьмем время обработки файла small.json - 9 секунд

## Гарантия корректности работы оптимизированной программы
Для проверки корректности работы обновленной программы был написан тест, который заполнял БД данными из файла example.json, потом выгружал БД в json и сравнивал с исходным.
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 👍


## Feedback-Loop

1. Проверка корректности работы, замер метрики, сбор отчета
2. Изучение отчетов профайлеров

Например
rails test test/system/load_test.rb && rake reload_json"[fixtures/small.json]" && rake pghero:capture_query_stats
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


## Вникаем в детали системы, чтобы найти главные точки роста

Для того, чтобы найти "точки роста" для оптимизации я воспользовался
- pg_hero

Вот какие проблемы удалось найти и решить

### Находка №1
- pg_hero показал что запросы
```SELECT "services".* FROM "services" INNER JOIN "buses_services" ON "services"."id" = "buses_services"."service_id" WHERE "buses_services"."bus_id" = $1```
занимают 25% времени. explain показал последовательное чтение.
- добавить индекс ```:buses_services, [:bus_id, :service_id] ```
- значимого изменения метрики нет
- в отчете pg_hero запрос переместился с верхней строчки вниз

### Находка №2
- pg_hero показал что запросы ```SELECT ? AS one FROM "buses" WHERE "buses"."number" = $1 AND "buses"."id" != $2 LIMIT $3``` занимают 23% времени
- добавить индекс ```:buses, :number```
- метрика изменилась незначительно на 0.3 секунды
- в отчете pg_hero запрос переместился с верхней строчки вниз

### Находка №3
- pg_hero показал что запросы ```SELECT "services".* FROM "services" WHERE "services"."name" = $1 LIMIT $2``` занимают 18% времени
- принято решение заполнить все возможные сервисы предварительно и собрать в хеш и не запрашивать бд
- метрика снизилась примерно на 1,5 секунды до 7,2 секунд
- запрос исчез из отчета

### Находка №4
- pg_hero показал что запросы ```SELECT "cities".* FROM "cities" WHERE "cities"."name" = $1 LIMIT $2``` занимают 12% времени
- принято решение кешировать города в хеш
- метрика снизилась до 6 секунд
- запросы стал занимать менее 0.1% времени

### Находка №5
- pg_hero показал что запросы
```SELECT "services".* FROM "services" INNER JOIN "buses_services" ON "services"."id" = "buses_services"."service_id" WHERE "buses_services"."bus_id" = $1```
занимают 47% времени.
- принято решение переписать поиск и заполнение автобуса
- метрика уменьшилась до 4,8 секунды
- запрос исчез из отчета

### Протеситирована метрика время обработки medium.json - 21 секунда. Переходим на эту метрику
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


### Находка №6
- pg_hero показал что запросы
```SELECT "buses".* FROM "buses" WHERE "buses"."number" = $1 LIMIT $2``` занимают 33% времени
- принято решение кешировать автобусы аналогично городам
- метрика снизилась до 14 секунд
- запрос исчез из отчета

### Находка №7
- pg_hero показал что запросы вставки в таблицу trip занимают 40% времени
- вставлять записи пачками
- ничего не изменилось, потому что activerecord запрограммирован вставлять по одной

### Находка №8
- Существует гем activerecord-import, который позволяет вставлять записи пачками
- применить гем
- метрика уменьшилась до 7 секунд
- запросы вставки в таблицу trips самые емкие по времени, но с этим ничего не поделать

## Результаты
Copy link
Collaborator

Choose a reason for hiding this comment

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

Очень чётко сработали!

В результате проделанной оптимизации удалось обработать файл с данными за заданое время. Сейчас оно составляет 22 секунды.

###Какими ещё результами можете поделиться
1. Также можно применить activerecord-import на остальные модели.
1. В rails 6 в классе ActiveRecord есть метод insert_all.
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

1. При необходимости быстрой работы с бд надо организовывать ее на низком уровне.

## Защита от регрессии производительности

Для защиты от потери достигнутого прогресса при дальнейших изменениях программы был написан тест с проверкой загрузки файла medium.json менее чем за 8 секунд.
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


Loading