Skip to content

Commit d3454e4

Browse files
committed
second part
1 parent c536943 commit d3454e4

File tree

18 files changed

+234
-59
lines changed

18 files changed

+234
-59
lines changed

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ gem 'benchmark'
1515
gem 'ruby-prof'
1616
gem 'stackprof'
1717
gem 'oj'
18+
gem 'strong_migrations'
1819

1920
group :development, :test do
2021
gem 'rspec-rails', '~> 7.1.1'
2122
gem 'factory_bot_rails', '~> 6.4.4'
2223
gem 'rails-controller-testing'
2324
gem 'rspec-rake'
25+
gem 'rspec-benchmark'
2426
end
2527

2628
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem

Gemfile.lock

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ GEM
7575
ast (2.4.2)
7676
base64 (0.2.0)
7777
benchmark (0.4.0)
78+
benchmark-malloc (0.2.0)
79+
benchmark-perf (0.6.0)
80+
benchmark-trend (0.4.0)
7881
bigdecimal (3.1.9)
7982
bootsnap (1.18.4)
8083
msgpack (~> 1.2)
@@ -205,6 +208,15 @@ GEM
205208
regexp_parser (2.10.0)
206209
reline (0.6.0)
207210
io-console (~> 0.5)
211+
rspec (3.13.0)
212+
rspec-core (~> 3.13.0)
213+
rspec-expectations (~> 3.13.0)
214+
rspec-mocks (~> 3.13.0)
215+
rspec-benchmark (0.6.0)
216+
benchmark-malloc (~> 0.2)
217+
benchmark-perf (~> 0.6)
218+
benchmark-trend (~> 0.4)
219+
rspec (>= 3.0)
208220
rspec-core (3.13.3)
209221
rspec-support (~> 3.13.0)
210222
rspec-expectations (3.13.3)
@@ -243,6 +255,8 @@ GEM
243255
securerandom (0.4.1)
244256
stackprof (0.2.27)
245257
stringio (3.1.2)
258+
strong_migrations (2.2.0)
259+
activerecord (>= 7)
246260
thor (1.3.2)
247261
timeout (0.4.3)
248262
tzinfo (2.0.6)
@@ -274,11 +288,13 @@ DEPENDENCIES
274288
rack-mini-profiler
275289
rails (~> 8.0.1)
276290
rails-controller-testing
291+
rspec-benchmark
277292
rspec-rails (~> 7.1.1)
278293
rspec-rake
279294
rubocop
280295
ruby-prof
281296
stackprof
297+
strong_migrations
282298
tzinfo-data
283299

284300
RUBY VERSION
Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
11
class TripsController < ApplicationController
2+
before_action :set_pagination_params, :set_cities, only: :index
3+
24
def index
5+
@total_count = Trip.where(from: @from, to: @to).count
6+
@trips = Trip.where(from: @from, to: @to)
7+
.order(:start_time)
8+
.limit(@per).offset(@per * (@page - 1))
9+
.includes(bus: [:services])
10+
end
11+
12+
private
13+
14+
def set_pagination_params
15+
@page = params[:page].present? ? params[:page].to_i : 1
16+
@per = params[:per].present? ? params[:per].to_i : 100
17+
end
18+
19+
def set_cities
320
@from = City.find_by_name!(params[:from])
421
@to = City.find_by_name!(params[:to])
5-
@trips = Trip.where(from: @from, to: @to).order(:start_time)
622
end
723
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<% if @total_count > @per %>
2+
<% if @page < (@total_count / @per) %>
3+
<%= link_to 'Следующая страница', trips_path(@from.name, @to.name, page: @page + 1) %>
4+
<% end %>
5+
6+
<% if @page > 1 %>
7+
<%= link_to 'Предыдущая страница', trips_path(@from.name, @to.name, page: @page - 1) %>
8+
<% end %>
9+
<% end %>

app/views/trips/_service.html.erb

Lines changed: 0 additions & 1 deletion
This file was deleted.

app/views/trips/_services.html.erb

Lines changed: 0 additions & 6 deletions
This file was deleted.

app/views/trips/_trip.html.erb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
<li><%= "Отправление: #{trip.start_time}" %></li>
2-
<li><%= "Прибытие: #{(Time.parse(trip.start_time) + trip.duration_minutes.minutes).strftime('%H:%M')}" %></li>
3-
<li><%= "В пути: #{trip.duration_minutes / 60}ч. #{trip.duration_minutes % 60}мин." %></li>
4-
<li><%= "Цена: #{trip.price_cents / 100}р. #{trip.price_cents % 100}коп." %></li>
5-
<li><%= "Автобус: #{trip.bus.model}#{trip.bus.number}" %></li>
1+
<ul>
2+
<li><%= "Отправление: #{trip.start_time}" %></li>
3+
<li><%= "Прибытие: #{(Time.parse(trip.start_time) + trip.duration_minutes.minutes).strftime('%H:%M')}" %></li>
4+
<li><%= "В пути: #{trip.duration_minutes / 60}ч. #{trip.duration_minutes % 60}мин." %></li>
5+
<li><%= "Цена: #{trip.price_cents / 100}р. #{trip.price_cents % 100}коп." %></li>
6+
<li><%= "Автобус: #{trip.bus.model}#{trip.bus.number}" %></li>
7+
<% if trip.bus.services.present? %>
8+
<li>Сервисы в автобусе:</li>
9+
<ul>
10+
<% trip.bus.services.each do |service| %>
11+
<li><%= "#{service.name}" %></li>
12+
<% end %>
13+
</ul>
14+
<% end %>
15+
</ul>
16+

app/views/trips/index.html.erb

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,8 @@
22
<%= "Автобусы #{@from.name}#{@to.name}" %>
33
</h1>
44
<h2>
5-
<%= "В расписании #{@trips.count} рейсов" %>
5+
<%= "В расписании #{@total_count} рейсов" %>
66
</h2>
77

8-
<% @trips.each do |trip| %>
9-
<ul>
10-
<%= render "trip", trip: trip %>
11-
<% if trip.bus.services.present? %>
12-
<%= render "services", services: trip.bus.services %>
13-
<% end %>
14-
</ul>
15-
<%= render "delimiter" %>
16-
<% end %>
8+
<%= render 'shared/pagination' %>
9+
<%= render partial: 'trip', collection: @trips, spacer_template: 'delimiter' %>

case-study.md

Lines changed: 113 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,53 +2,67 @@
22

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

5-
1) Добавила в проект тесты
6-
2) Начем с оптимизации импорта данных
5+
В нашем проекте возникли 2 серьёзных проблемы:
6+
1) Необходимо было обработать файл с расписанием автобусов. У нас уже была программа на ruby, которая умела делать нужную обработку. Она успешно работала на файлах небольшого размера, но для большого файла она работала слишком долго.
7+
2) Страница отображения расписания автобусов тоже работала слишком долго. Пользователи жаловались на долгую загрузку страницы (расписание Таганрог/Владивосток загружалось 42 секунды (~2к записей)).
8+
9+
Я решила исправить обе проблемы, оптимизировав загрузку и отображение.
710

811
## Оптимизация импорта данных
912

10-
напишем раннер для запуска / мониторинга времени / памяти выполнения скрипта импорта
13+
## Формирование метрики
14+
15+
Конечная метрика: время выполнения импорта файла `fixtures/large.json` должно укладываться в 60 сек.
16+
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумала использовать такую метрику:
17+
1) обработка файла после внесенных оптимизаций должна быть меньше, чем до оптимизации
18+
19+
## Предварительная подготовка
20+
21+
1) Добавила в проект тесты
22+
2) Написала раннер для профилирования / мониторинга времени / памяти выполнения скрипта импорта
1123

24+
## Ваша находка №1
1225
1) время выполнения fixtures/small.json - 10.167809
1326
2) воспользуемся rbspy для профилирования скрипта импорта
1427
3) rbspy / rubyprof выдают много лишних данных при вызове таски, поэтому можно вынести импорт в отдельный класс и профилировать его
1528
4) вынесла в класс DataLoader, перенесла тесты
1629

1730
Пока выносила обратила внимание что файл считывается в память целиком, также в задании упоминается про файл 1М который весит примерно 3ГБ, перепишем сразу на стриминг
18-
добавила класс jsonStreamer - собирает объекты и возвращает по одному, какой то адекватный гем не нашла для такой обработки файла, теперь используется потоковая обработка файла
19-
31+
добавила класс JsonStreamer - собирает объекты и возвращает по одному. Гемы потоковой обработки выглядят сложновато для достаточно простой задачи, возможно они работают быстрее, но этим можно будет озаботиться попозже
2032

21-
1) время выполнения не поменялось
22-
2) время выполнения fixtures/small.json - 10.473295
23-
3) отчет stackfrof - главная точка роста:
33+
## Ваша находка №2
34+
1) время выполнения fixtures/small.json не поменялось - 10.473295
35+
2) отчет stackfrof - главная точка роста:
2436
в отчете видны 2 главные точки роста - update (41%) и find_or_create_by (25%)
25-
4) что делать с update пока непонятно, а вот такое кол-во find_or_create_by можно заменить
26-
5) Внесем правки в работу с городами, написано что в файле их не больше 100, попробуем собирать их в словать параллельно создавая
27-
6) время выполнения fixtures/small.json - 8.523640
28-
7) изменения в отчете профилировщика: find_or_create_by снизился до 18%
37+
3) что делать с update пока непонятно, а вот такое кол-во find_or_create_by можно заменить
38+
4) Внесем правки в работу с городами, написано что в файле их не больше 100, попробуем собирать их в словарь, параллельно создавая
39+
5) время выполнения fixtures/small.json - 8.523640
40+
6) изменения в отчете профилировщика: find_or_create_by снизился до 18%
2941

42+
## Ваша находка №3
3043
1) также у нас собирается еще 2 стравочника - services, buses
3144
2) заменим find_or_create_by и там
3245
3) services: 6.919434, find_or_create_by 23%
3346
4) buses: 5.319638, find_or_create_by больше нет
3447

35-
1) главная точка роста: update (64%)
48+
## Ваша находка №4
49+
1) отчет stackfrof - главная точка роста: update (64%)
3650
2) обновляется автобус сервисами, это обновление в целом выглядит довольно бесполезно, потому что сервисы останутся от последнего trip в файле
3751
3) поэтому обновление сервисами можно в принципе убрать, либо уточнить формат файла, должны ли они добавляться / обновляться или браться пересечение
38-
4) убрали обновление заменив его на создание сервисов при создании автобуса
52+
4) убрали обновление заменив его на создание сервисов при создании автобуса (добавила модель buses_service)
3953
5) время: 3.025756
4054
6) отчет профилировщика: update больше нет
4155

42-
1) главная точка роста: создание записей
43-
2) обратимся к логам и посмотрим на кол-во обращений к базе
44-
3) на файл example к базе было 10 (создание trip) + 2 (проверка существования автобуса + создание) + 4 (проверка городов + создание) + 2 (создание сревисов) = 18
45-
4) можно воспользоваться стримингом из readme
46-
5) время для small: 0.138599
47-
6) medium: 0.510029
48-
7) large: 3.958625
49-
8) 1M: 38.381950
50-
9) ну тут уже время упирается в стример, без каких либо преобразований он перебирает файл 1M за 34.973238
51-
10) причем потребление памяти на 1М не превышает 8МБ
56+
## Ваша находка №5
57+
1) обратимся к логам и посмотрим на кол-во обращений к базе
58+
2) на файл example (10 рейсов) к базе обращений было: 10 (создание trip) + 2 (проверка существования автобуса + создание) + 4 (проверка городов + создание) + 2 (создание сревисов) = 18
59+
3) можно воспользоваться алгоритмом и стримингом из readme
60+
4) время для small: 0.138599
61+
5) medium: 0.510029
62+
6) large: 3.958625
63+
7) 1M: 38.381950
64+
8) ну тут уже время упирается в стример, без каких либо преобразований он перебирает файл 1M за 34.973238
65+
9) причем потребление памяти на 1М не превышает 8МБ
5266
```
5367
INITIAL MEMORY USAGE: 103 MB
5468
MEMORY USAGE: 103 MB
@@ -61,4 +75,79 @@ MEMORY USAGE: 111 MB
6175
FINAL MEMORY USAGE: 110 MB
6276
```
6377

78+
## Оптимизация отображения расписания
79+
80+
## Формирование метрики
81+
82+
Конечная метрика: любая страница должна грузиться менее 0.3 секунды
83+
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумала использовать такую метрику:
84+
1) время рендеринга страницы после внесенных оптимизаций должно быть меньше, чем до оптимизации
85+
86+
## Оптимизация
87+
88+
Набор данных 1M. Для начала загрузим страницу http://localhost:3000/автобусы/Самара/Москва и посмотрим на логи
89+
90+
## Ваша находка №1
91+
1) Completed 200 OK in 745ms (Views: 228.0ms | ActiveRecord: 487.9ms (120 queries, 0 cached) | GC: 33.0ms)
92+
2) Выборка занимает бОльшую часть времени, начнем с нее. Также по логам видно, что для каждого trip делается отдельный запрос к базе чтобы достать автобус, а для него сервисы
93+
3) Попробуем воспользоваться includes
94+
4) Completed 200 OK in 157ms (Views: 55.8ms | ActiveRecord: 97.3ms (7 queries, 0 cached) | GC: 6.0ms)
95+
5) Время загрузки уменьшилось и кол-во запросов тоже
96+
6) Рендер тоже ускорился, потому что все вызовы были непосредствено из вьюхи
97+
98+
## Ваша находка №2
99+
1) Оптимизируем рендеринг (огромное кол-во рендеров)
100+
2) Сделаем рендер коллекции сервисов
101+
3) Completed 200 OK in 157ms (Views: 52.2ms | ActiveRecord: 92.7ms (7 queries, 0 cached) | GC: 4.5ms)
102+
103+
## Ваша находка №3
104+
1) Оптимизируем рендеринг дальше, сделаем рендер коллекции рейсов
105+
2) Completed 200 OK in 153ms (Views: 56.9ms | ActiveRecord: 92.2ms (7 queries, 0 cached) | GC: 1.0ms)
106+
3) В пределах погрешности ничего не поменялось, но все еще отдельно рендерятся delimeter / services
107+
108+
## Ваша находка №4
109+
1) Посмотрим на rack-mini-profiler
110+
2) Больше всего времени уходит на запрос SELECT COUNT(*) FROM "trips" WHERE "trips"."from_id" = $1 AND "trips"."to_id" = $2; (126 ms)
111+
3) Посмотрим на план запроса:
112+
Gather (cost=1000.00..24758.70 rows=87 width=34)
113+
Workers Planned: 2
114+
-> Parallel Seq Scan on trips (cost=0.00..23750.00 rows=36 width=34)
115+
Filter: ((from_id = 10) AND (to_id = 92))
116+
(4 rows)
117+
4) Избавимся от Seq Scan добавив составной индекс. Так как поиск идет всегда по (from_id, to_id). Также сделаем сразу сортированный индекс по времени
118+
5) План запроса после добавления индекса:
119+
Bitmap Heap Scan on trips (cost=13.75..3377.62 rows=909 width=34)
120+
Recheck Cond: ((from_id = 10) AND (to_id = 92))
121+
-> Bitmap Index Scan on index_trips_on_from_id_and_to_id (cost=0.00..13.53 rows=909 width=0)
122+
Index Cond: ((from_id = 10) AND (to_id = 92))
123+
(4 rows)
124+
6) Теперь запрос не является главной точкой роста (2.1 ms)
125+
126+
## Ваша находка №5
127+
1) Зальем 10М (~6 минут) и проверим на более объемных данных
128+
2) Расписание Самара/Москва (511 рейсов): Completed 200 OK in 342ms (Views: 321.6ms | ActiveRecord: 15.2ms (7 queries, 0 cached) | GC: 41.2ms)
129+
3) Главной точкой роста опять стал рендеринг, попробуем оптимизировать его еще лучше
130+
4) Попробуем убрать отдельный рендер разделителя и сервисов
131+
5) Воспользовалась spacer_template: Completed 200 OK in 276ms (Views: 217.5ms | ActiveRecord: 51.2ms (7 queries, 0 cached) | GC: 11.7ms)
132+
6) Уберем рендер сервисов в partial trip. некрасиво, но это ускорит рендер
133+
7) Completed 200 OK in 104ms (Views: 85.7ms | ActiveRecord: 14.4ms (7 queries, 0 cached) | GC: 5.2ms)
134+
135+
## Ваша находка №6
136+
1) Проверим на большем кол-ве
137+
2) самое большое кол-во рейсов - 4191 (Таганрог - Таганрог)
138+
3) Completed 200 OK in 620ms (Views: 499.6ms | ActiveRecord: 127.1ms (7 queries, 1 cached) | GC: 55.3ms)
139+
4) Время почти в 2 раза больше желательного
140+
5) Посмотрим на это со стороны того, что за раз пользователю не надо видеть все 4к записей, можно добавить пагинацию
141+
6) добавила пагинацию с дефолтным значением 100 записей (гем использовать не стала, потому что в текущем варианте это не сложно сделать)
142+
7) одна страница: Completed 200 OK in 39ms (Views: 23.5ms | ActiveRecord: 10.0ms (7 queries, 0 cached) | GC: 0.0ms)
143+
144+
Можно закончить оптимизацию на этом, так как время выполнения укладывается в приемлемые рамки
145+
146+
Для красоты конечно лучше бы добавить турбо фреймы, чтобы полностью не перезагружать страницу при пагинации, но это уже не входят в текущую задачу
147+
148+
## Результаты
149+
В результате проделанной оптимизации удалось ускорить импорт файла `fixtures/large.json` до 4 секунд и уложиться в метрику. Также удалось ускорить загрузку страницы Таганрог/Владивосток до ~300ms без пагинации, или ~50ms с пагинацией по 100 рейсов на странице
64150

151+
## Защита от регрессии производительности
152+
1) для импорта данных был написан тест на проверку времени выполнения
153+
2) для отображения расписания был бы написан тест на N+1 запрос, если бы rspec-sqlimit был совместим с rails 8.0.1

0 commit comments

Comments
 (0)