Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
/tmp
/log
/public
fixtures/1M.json
fixtures/10M.json
fixtures/*.gz

1 change: 1 addition & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
--require spec_helper
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ gem 'pg'
gem 'puma'
gem 'listen'
gem 'bootsnap'
gem 'pry'
gem 'rspec'
gem 'rspec-rails'
gem 'rack-mini-profiler'
gem 'ruby-prof'
gem 'strong_migrations'
Copy link
Collaborator

Choose a reason for hiding this comment

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

<3


# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
39 changes: 39 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,16 @@ GEM
bootsnap (1.18.4)
msgpack (~> 1.2)
builder (3.3.0)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
crass (1.0.6)
date (3.4.1)
diff-lcs (1.5.1)
drb (2.2.1)
erubi (1.13.1)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
globalid (1.2.1)
activesupport (>= 6.1)
i18n (1.14.7)
Expand All @@ -107,6 +110,7 @@ GEM
net-pop
net-smtp
marcel (1.0.4)
method_source (1.1.0)
mini_mime (1.1.5)
minitest (5.25.4)
msgpack (1.8.0)
Expand All @@ -122,10 +126,15 @@ GEM
nio4r (2.7.4)
nokogiri (1.18.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-gnu)
racc (~> 1.4)
pg (1.5.9)
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
pry (0.15.2)
coderay (~> 1.1)
method_source (~> 1.0)
psych (5.2.3)
date
stringio
Expand Down Expand Up @@ -179,8 +188,32 @@ GEM
psych (>= 4.0.0)
reline (0.6.0)
io-console (~> 0.5)
rspec (3.13.0)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.3)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.2)
ruby-prof (1.7.1)
securerandom (0.4.1)
stringio (3.1.2)
strong_migrations (2.2.0)
activerecord (>= 7)
thor (1.3.2)
timeout (0.4.3)
tzinfo (2.0.6)
Expand All @@ -195,14 +228,20 @@ GEM

PLATFORMS
arm64-darwin-24
x86_64-linux

DEPENDENCIES
bootsnap
listen
pg
pry
puma
rack-mini-profiler
rails (~> 8.0.1)
rspec
rspec-rails
ruby-prof
strong_migrations
tzinfo-data

RUBY VERSION
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)

Choose a reason for hiding this comment

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

💪
Любопытно только, почему ты добавила его после where?

Copy link
Author

Choose a reason for hiding this comment

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

Не специально. Вообще ar соберёт.

Copy link
Collaborator

Choose a reason for hiding this comment

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

по идее не помешало бы ещё индекс на trips [from_id, to_id], можно составной

именно на время рендеринга это сильно не влияет (так как процент на выполнение этого SQL маленький), но если бы мы заходили со стороны оптимизации БД - там бы это очень помогло;

end
end
94 changes: 94 additions & 0 deletions app/services/trips_importer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
class TripsImporter
Copy link
Collaborator

Choose a reason for hiding this comment

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

лайк за отдельный класс

def initialize(file = 'fixtures/small.json')
@file = file
end

def self.call(...)
new(...).call
end

def call
json = JSON.parse(File.read(file))

Choose a reason for hiding this comment

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

Можно поробовать разбирать JSON потоков
https://github.com/dgraham/yajl-ffi
https://github.com/dgraham/json-stream

И ваще курутотень будет)


clean_database

ActiveRecord::Base.transaction do
city_names = Set.new
service_names = Set.new
buses = {}

# первый проход - собираем "справочные" данные - города, услуги, автобусы
# собираем в Set или хэш, так чтобы удобно было вставлять в бд
json.each do |trip|
city_names.add trip['from']
city_names.add trip['to']

service_names.merge trip['bus']['services']
buses[trip['bus']['number']] = trip['bus']['model']
end

# вставляем справочное
City.insert_all city_names.map { |name| { name: name } }
Service.insert_all service_names.map { |name| { name: name } }
Bus.insert_all buses.map { |number, model| { number: number, model: model }}

# формируем хэши, чтобы удобно получить доступ к id при втором проходе
cities = City.all.each_with_object({}) { |city, hash| hash[city.name] = city.id }
services = Service.all.each_with_object({}) { |service, hash| hash[service.name] = service.id }
buses = Bus.all.each_with_object({}) { |bus, hash| hash[bus.number] = bus.id }

# тут соберём пары автобус-услуга
buses_services = Set.new

# тут данные по поездкам для вставки
trips = []

json.each do |trip|
from_id = cities[trip['from']]
to_id = cities[trip['to']]
bus_id = buses[trip['bus']['number']]

# заполняем пары автобус-услуга
service_ids = services.values_at(*trip['bus']['services'])
service_ids.each do |service_id|
buses_services.add([bus_id, service_id])
end

trips.push({
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'],
})
end

# вставка данных о поездках
# пока не стала батчить, т.к. и так довольно быстро происходит
Trip.insert_all trips

# вот такой insert into buses_services
# вообще чаще используем has_and_belongs_to_many , потому что часто в связке потом нужны доп. данные и таймстемпы, и свой id
# тогда можно было бы insert_all использовать
if buses_services.present?

Choose a reason for hiding this comment

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

Ухты, не подумал что так можно было buses_services одним запросом вставить

# что-то решила на всякий случай подготовить строки )
values = buses_services.map { |arr| sprintf("(%s, %s)", arr[0], arr[1]) }.join(", ")
sql = "INSERT INTO buses_services (bus_id, service_id) VALUES #{values};"
ActiveRecord::Base.connection.execute(sql)
end
end
end

private

attr_reader :file

def clean_database
City.delete_all
Bus.delete_all
Service.delete_all
Trip.delete_all
ActiveRecord::Base.connection.execute('delete from buses_services;')
end
end
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.

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>
2 changes: 1 addition & 1 deletion app/views/trips/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
<%= render "services", services: trip.bus.services %>
<% end %>
</ul>
<%= render "delimiter" %>
====================================================
Copy link
Collaborator

Choose a reason for hiding this comment

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

можно оставить паршлы, применить рендеринг коллекций и там даже можно delimiter (spacer) задать параметром https://guides.rubyonrails.org/layouts_and_rendering.html#spacer-templates

так будет не настолько тормозить, и при этом сохраняется удобство декомпозиции на паршлы

<% end %>
72 changes: 72 additions & 0 deletions case_study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
## A. Импорт данных

Метрика:
- изначально замерила время на small.json - было около 15 секунд
- далее меряла small.json -> medium.json -> large.json

### Подготовка

Сначала вынесла код в сервис-обжект (PORO) `TripsImporter` для удобства + написала базовый тест.
Далее стала смотреть, как оптимизировать.

### Оптимизация - исследование

Сначала попробовала поэтапно идти:

- переписала на один insert trips, не трогая другие части; стало ещё медленнее - откатила пока
- добавляла уникальные индексы на справочные данные + использовала upsert, но во-первых не так уж сильно оптимизировало, во-вторых оказалось, что `upsert` не возвращает id, если нет вставки. Тест не отловил, т.к. там одна поездка. Дописывать тест не стала (поленилась), но в реальном приложении нужно.
Copy link
Collaborator

Choose a reason for hiding this comment

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

при вставке какого-то большого объёма данных может иметь смысл наоборот удалить индексы, вставить все данные, а потом уже пересоздать индексы заново

- попробовала рубипрофом попрофилировать, но такое себе - всё размазано, и так видно, что 100500 запросов идёт
- попробовала проверить, какая часть занимает много времени, комментируя куски кода и запуская, в принципе довольно наглядно. Понятно, что insert trips и sessions долго работает (ожидаемо)
- также смотрела рельсовые логи, видно, что также идёт 100500 мелких запросов
- также померяла, что сама загрузка (parse и обход) json занимает не так много времени, поэтому уж пару раз можно пройтись

### Оптимизация

Решила сначала собрать справочные данные по городам, автобусам и тд, потом вставить справочные данные и запросить получившиеся id из бд и сформировать подходящие структуры данных для дальнейшего поиска при подготовке данных для trips. Попутно добавила уникальные индексы на `name` и тд.

Далее пройтись ещё раз, подготовить данные по поездкам и услугам автобусов, и уже вставить их отдельно.

Эта оптимизация была эффективной - файл large стал обрабатываться за ~ 3-3,35 секунды.

```
anna@vivosaurus:~/apps/rails-optimization-task3$ be rake reload_json[fixtures/large.json]
3.356575947
```

```ruby
task :reload_json, [:file_name] => :environment do |_task, args|
start_time = Time.current

TripsImporter.call(args.file_name)

end_time = Time.current

p end_time - start_time
end
```

Решила рискнуть и попробовать на `1M.json`, получилось за 30 секунд. Как так? Все записи на месте в бд.

## Б. Отображение расписаний
Copy link
Author

@lightalloy lightalloy Feb 15, 2025

Choose a reason for hiding this comment

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

Не помешало бы реквест-тест добавить, моя недоработка )
Хоть и изменения минимальные в этом пункте.

Choose a reason for hiding this comment

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

в ПР видел ещё тесты на view, вот это крутотень конечно

Copy link
Author

Choose a reason for hiding this comment

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

Ну реквест- типа интеграционные, покроют и вью (в той мере, в к-й напишем :)


Изначально время: 8329сек

Сразу видим в rack-mini-profiler 1437 sql-запросов, делаем includes:
```ruby
@trips = Trip.where(from: @from, to: @to).includes(bus: :services).order(:start_time)
```

Время: 3930

Дальше видно, что очень много partials загружается:

- убрала `partial` `service` (незачем в отдельном файле рендерить одну строчку, это не бесплатно)

Время: 2336

- убрала аналогично `partial` `delimiter`

Время: 632

Решила, что этого хватит.

Choose a reason for hiding this comment

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

Аналогичное решение))


3 changes: 3 additions & 0 deletions config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
default: &default
adapter: postgresql
encoding: unicode
host: localhost
user: postgres
password: postgres
# For details on connection pooling, see Rails configuration guide
# http://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20250214163423_add_unique_index_to_services.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class AddUniqueIndexToServices < ActiveRecord::Migration[8.0]
disable_ddl_transaction!

def change
add_index :services, :name, unique: true, algorithm: :concurrently
end
end
7 changes: 7 additions & 0 deletions db/migrate/20250214164236_add_unique_index_to_bus_numbers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class AddUniqueIndexToBusNumbers < ActiveRecord::Migration[8.0]
disable_ddl_transaction!

def change
add_index :buses, :number, unique: true, algorithm: :concurrently
end
end
7 changes: 7 additions & 0 deletions db/migrate/20250214175040_add_unique_index_to_cities.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class AddUniqueIndexToCities < ActiveRecord::Migration[8.0]
disable_ddl_transaction!

def change
add_index :cities, :name, unique: true, algorithm: :concurrently
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class AddUniqueIndexToBusesServices < ActiveRecord::Migration[8.0]
disable_ddl_transaction!

def change
add_index :buses_services, [:bus_id, :service_id], unique: true, algorithm: :concurrently
end
end
Loading