Skip to content

Commit 3e6f7e5

Browse files
authored
PERF: Improve snapshots page performance (#518)
Currently, the time that the snapshots page (`/mini-profiler-resources/snapshots`) takes to load grows linearly as the number of snapshots grows. By default we limit the number of snapshots to 1000, which is a reasonable limit, but when we reach 1000 snapshots the performance gets really bad that the page takes 4 seconds to load when using the Redis storage backend (everything said here applies to the Redis backend). The reason for the bad performance is how snapshots are stored -- we keep all snapshots in a single redis hash where snapshot IDs map to marshalled snapshot objects, and when the snapshots page is requested we have to fetch everything from that redis hash and convert the data back to ruby objects to be able to group them and sort them etc. This means that we need to spend a lot time doing IO to transfer all the snapshots data from the redis server to the web server. A snapshot is on average ~280 KBs and since we allow 1000 snapshots by default, we have to fetch ~280 MBs worth of data everytime the snapshots page is requested. To fix this performance problem, this commit changes the storage model so that every snapshot group is stored separately in its own redis hash, and we keep track of all group names and their worst score in an "overview" redis sorted set. This storage model allows us to respond to the snapshots page without having to fetch all the snapshots data; we can simply read the overview redis sorted set which is very small. The downside of this new approach/storage model is that we have to do a little more work when saving a new snapshot. Specifically, we have to update the group's score in the overview set if necessary when a new snapshot is added to a group. We also have new limits on the number of groups and the number of snapshots per group that we need to respect when saving a new snapshot. That said, this extra overhead should be negligible and I think the tradeoff is completely worth it. Note that this change doesn't migrate existing snapshots to the new storage scheme, so when this change is deployed the old snapshots won't show up in the snapshots page but they'll still be retained in redis.
1 parent 0c243cd commit 3e6f7e5

13 files changed

Lines changed: 702 additions & 416 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ After enabling snapshots sampling, you can see the snapshots that have been coll
207207

208208
Access to the snapshots page is restricted to only those who can see the speed badge on their own requests, see the section below this one about access control.
209209

210-
Mini Profiler will keep a maximum of 1000 snapshots by default, and you can change that via the `snapshots_limit` config. When snapshots reach the configured limit, Mini Profiler will save a new snapshot only if it's worse than at least one of the existing snapshots and delete the best one (i.e. the snapshot whose request took the least time compared to other snapshots).
210+
Mini Profiler will keep a maximum of 50 snapshot groups and a maximum of 15 snapshots per group making the default maximum number of snapshots in the system 750. The default group and per group limits can be changed via the `max_snapshot_groups` and `max_snapshots_per_group` configuration options, see the configurations table below.
211211

212212
#### Snapshots Transporter
213213

@@ -430,7 +430,8 @@ show_total_sql_count|`false`|Displays the total number of SQL executions.
430430
enable_advanced_debugging_tools|`false`|Enables sensitive debugging tools that can be used via the UI. In production we recommend keeping this disabled as memory and environment debugging tools can expose contents of memory that may contain passwords. Defaults to `true` in development.
431431
assets_url|`nil`|See the "Register MiniProfiler's assets in the Rails assets pipeline" section above.
432432
snapshot_every_n_requests|`-1`|Determines how frequently snapshots are taken. See the "Snapshots Sampling" above for more details.
433-
snapshots_limit|`1000`|Determines how many snapshots Mini Profiler is allowed to keep.
433+
max_snapshot_groups|`50`|Determines how many snapshot groups Mini Profiler is allowed to keep.
434+
max_snapshots_per_group|`15`|Determines how many snapshots per group Mini Profiler is allowed to keep.
434435
snapshot_hidden_custom_fields|`[]`|Each snapshot custom field will have a dedicated column in the UI by default. Use this config to exclude certain custom fields from having their own columns.
435436
snapshots_transport_destination_url|`nil`|Set this config to a valid URL to enable snapshots transporter which will `POST` snapshots to the given URL. The transporter requires `snapshots_transport_auth_key` config to be set as well.
436437
snapshots_transport_auth_key|`nil`|`POST` requests made by the snapshots transporter to the destination URL will have a `Mini-Profiler-Transport-Auth` header with the value of this config. Make sure you use a secure and random key for this config.

lib/mini_profiler/config.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ def self.default
4040
@skip_sql_param_names = /password/ # skips parameters with the name password by default
4141
@enable_advanced_debugging_tools = false
4242
@snapshot_every_n_requests = -1
43-
@snapshots_limit = 1000
43+
@max_snapshot_groups = 50
44+
@max_snapshots_per_group = 15
4445

4546
# ui parameters
4647
@autorized = true
@@ -81,10 +82,10 @@ def self.default
8182
:start_hidden, :toggle_shortcut, :html_container
8283

8384
# snapshot related config
84-
attr_accessor :snapshot_every_n_requests, :snapshots_limit,
85+
attr_accessor :snapshot_every_n_requests, :max_snapshots_per_group,
8586
:snapshot_hidden_custom_fields, :snapshots_transport_destination_url,
8687
:snapshots_transport_auth_key, :snapshots_redact_sql_queries,
87-
:snapshots_transport_gzip_requests
88+
:snapshots_transport_gzip_requests, :max_snapshot_groups
8889

8990
# Deprecated options
9091
attr_accessor :use_existing_jquery

lib/mini_profiler/profiler.rb

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,10 @@ def user(env)
130130
def serve_results(env)
131131
request = Rack::Request.new(env)
132132
id = request.params['id']
133-
is_snapshot = request.params['snapshot']
134-
is_snapshot = [true, "true"].include?(is_snapshot)
133+
group_name = request.params['group']
134+
is_snapshot = group_name && group_name.size > 0
135135
if is_snapshot
136-
page_struct = @storage.load_snapshot(id)
136+
page_struct = @storage.load_snapshot(id, group_name)
137137
else
138138
page_struct = @storage.load(id)
139139
end
@@ -802,16 +802,16 @@ def handle_snapshots_request(env)
802802
headers = { 'Content-Type' => 'text/html' }
803803
qp = Rack::Utils.parse_nested_query(env['QUERY_STRING'])
804804
if group_name = qp["group_name"]
805-
list = @storage.find_snapshots_group(group_name)
805+
list = @storage.snapshots_group(group_name)
806806
list.each do |snapshot|
807-
snapshot[:url] = url_for_snapshot(snapshot[:id])
807+
snapshot[:url] = url_for_snapshot(snapshot[:id], group_name)
808808
end
809809
data = {
810810
group_name: group_name,
811811
list: list
812812
}
813813
else
814-
list = @storage.snapshot_groups_overview
814+
list = @storage.snapshots_overview
815815
list.each do |group|
816816
group[:url] = url_for_snapshots_group(group[:name])
817817
end
@@ -864,7 +864,7 @@ def rails_route_from_path(path, method)
864864
if defined?(Rails) && defined?(ActionController::RoutingError)
865865
hash = Rails.application.routes.recognize_path(path, method: method)
866866
if hash && hash[:controller] && hash[:action]
867-
"#{method} #{hash[:controller]}##{hash[:action]}"
867+
"#{hash[:controller]}##{hash[:action]}"
868868
end
869869
end
870870
rescue ActionController::RoutingError
@@ -876,8 +876,8 @@ def url_for_snapshots_group(group_name)
876876
"/#{@config.base_url_path.gsub('/', '')}/snapshots?#{qs}"
877877
end
878878

879-
def url_for_snapshot(id)
880-
qs = Rack::Utils.build_query({ id: id, snapshot: true })
879+
def url_for_snapshot(id, group_name)
880+
qs = Rack::Utils.build_query({ id: id, group: group_name })
881881
"/#{@config.base_url_path.gsub('/', '')}/results?#{qs}"
882882
end
883883

@@ -902,8 +902,12 @@ def take_snapshot(env, start)
902902
if Rack::MiniProfiler.snapshots_transporter?
903903
Rack::MiniProfiler::SnapshotsTransporter.transport(page_struct)
904904
else
905+
group_name = rails_route_from_path(page_struct[:request_path], page_struct[:request_method])
906+
group_name ||= page_struct[:request_path]
907+
group_name = "#{page_struct[:request_method]} #{group_name}"
905908
@storage.push_snapshot(
906909
page_struct,
910+
group_name,
907911
@config
908912
)
909913
end

lib/mini_profiler/storage/abstract_store.rb

Lines changed: 30 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -45,80 +45,53 @@ def should_take_snapshot?(period)
4545
raise NotImplementedError.new("should_take_snapshot? is not implemented")
4646
end
4747

48-
def push_snapshot(page_struct, config)
48+
def push_snapshot(page_struct, group_name, config)
4949
raise NotImplementedError.new("push_snapshot is not implemented")
5050
end
5151

52-
def fetch_snapshots(batch_size: 200, &blk)
53-
raise NotImplementedError.new("fetch_snapshots is not implemented")
52+
# returns a hash where the keys are group names and the values
53+
# are hashes that contain 3 keys:
54+
# 1. `:worst_score` => the duration of the worst/slowest snapshot in the group (float)
55+
# 2. `:best_score` => the duration of the best/fastest snapshot in the group (float)
56+
# 3. `:snapshots_count` => the number of snapshots in the group (integer)
57+
def fetch_snapshots_overview
58+
raise NotImplementedError.new("fetch_snapshots_overview is not implemented")
5459
end
5560

56-
def snapshot_groups_overview
57-
groups = {}
58-
fetch_snapshots do |batch|
59-
batch.each do |snapshot|
60-
group_name = default_snapshot_grouping(snapshot)
61-
hash = groups[group_name] ||= {}
62-
hash[:snapshots_count] ||= 0
63-
hash[:snapshots_count] += 1
64-
if !hash[:worst_score] || hash[:worst_score] < snapshot.duration_ms
65-
groups[group_name][:worst_score] = snapshot.duration_ms
66-
end
67-
if !hash[:best_score] || hash[:best_score] > snapshot.duration_ms
68-
groups[group_name][:best_score] = snapshot.duration_ms
69-
end
70-
end
71-
end
72-
groups = groups.to_a
61+
# @param group_name [String]
62+
# @return [Array<Rack::MiniProfiler::TimerStruct::Page>] list of snapshots of the group. Blank array if the group doesn't exist.
63+
def fetch_snapshots_group(group_name)
64+
raise NotImplementedError.new("fetch_snapshots_group is not implemented")
65+
end
66+
67+
def load_snapshot(id, group_name)
68+
raise NotImplementedError.new("load_snapshot is not implemented")
69+
end
70+
71+
def snapshots_overview
72+
groups = fetch_snapshots_overview.to_a
7373
groups.sort_by! { |name, hash| hash[:worst_score] }
7474
groups.reverse!
7575
groups.map! { |name, hash| hash.merge(name: name) }
7676
groups
7777
end
7878

79-
def find_snapshots_group(group_name)
79+
def snapshots_group(group_name)
80+
snapshots = fetch_snapshots_group(group_name)
8081
data = []
81-
fetch_snapshots do |batch|
82-
batch.each do |snapshot|
83-
snapshot_group_name = default_snapshot_grouping(snapshot)
84-
if group_name == snapshot_group_name
85-
data << {
86-
id: snapshot[:id],
87-
duration: snapshot.duration_ms,
88-
sql_count: snapshot[:sql_count],
89-
timestamp: snapshot[:started_at],
90-
custom_fields: snapshot[:custom_fields]
91-
}
92-
end
93-
end
82+
snapshots.each do |snapshot|
83+
data << {
84+
id: snapshot[:id],
85+
duration: snapshot.duration_ms,
86+
sql_count: snapshot[:sql_count],
87+
timestamp: snapshot[:started_at],
88+
custom_fields: snapshot[:custom_fields]
89+
}
9490
end
9591
data.sort_by! { |s| s[:duration] }
9692
data.reverse!
9793
data
9894
end
99-
100-
def load_snapshot(id)
101-
raise NotImplementedError.new("load_snapshot is not implemented")
102-
end
103-
104-
private
105-
106-
def default_snapshot_grouping(snapshot)
107-
group_name = rails_route_from_path(snapshot[:request_path], snapshot[:request_method])
108-
group_name ||= snapshot[:request_path]
109-
"#{snapshot[:request_method]} #{group_name}"
110-
end
111-
112-
def rails_route_from_path(path, method)
113-
if defined?(Rails) && defined?(ActionController::RoutingError)
114-
hash = Rails.application.routes.recognize_path(path, method: method)
115-
if hash && hash[:controller] && hash[:action]
116-
"#{hash[:controller]}##{hash[:action]}"
117-
end
118-
end
119-
rescue ActionController::RoutingError
120-
nil
121-
end
12295
end
12396
end
12497
end

lib/mini_profiler/storage/memory_store.rb

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def initialize(args = nil)
5353

5454
@token1, @token2, @cycle_at = nil
5555
@snapshots_cycle = 0
56+
@snapshot_groups = {}
5657
@snapshots = []
5758

5859
initialize_locks
@@ -152,28 +153,69 @@ def should_take_snapshot?(period)
152153
end
153154
end
154155

155-
def push_snapshot(page_struct, config)
156+
def push_snapshot(page_struct, group_name, config)
156157
@snapshots_lock.synchronize do
157-
@snapshots << page_struct
158-
@snapshots.sort_by! { |s| s.duration_ms }
159-
@snapshots.reverse!
160-
if @snapshots.size > config.snapshots_limit
161-
@snapshots.slice!(-1)
158+
group = @snapshot_groups[group_name]
159+
if !group
160+
@snapshot_groups[group_name] = {
161+
worst_score: page_struct.duration_ms,
162+
best_score: page_struct.duration_ms,
163+
snapshots: [page_struct]
164+
}
165+
if @snapshot_groups.size > config.max_snapshot_groups
166+
group_keys = @snapshot_groups.keys
167+
group_keys.sort_by! do |key|
168+
@snapshot_groups[key][:worst_score]
169+
end
170+
group_keys.reverse!
171+
group_keys.pop(group_keys.size - config.max_snapshot_groups)
172+
@snapshot_groups = @snapshot_groups.slice(*group_keys)
173+
end
174+
else
175+
snapshots = group[:snapshots]
176+
snapshots << page_struct
177+
snapshots.sort_by!(&:duration_ms)
178+
snapshots.reverse!
179+
if snapshots.size > config.max_snapshots_per_group
180+
snapshots.pop(snapshots.size - config.max_snapshots_per_group)
181+
end
182+
group[:worst_score] = snapshots[0].duration_ms
183+
group[:best_score] = snapshots[-1].duration_ms
162184
end
163185
end
164186
end
165187

166-
def fetch_snapshots(batch_size: 200, &blk)
188+
def fetch_snapshots_overview
167189
@snapshots_lock.synchronize do
168-
@snapshots.each_slice(batch_size) do |batch|
169-
blk.call(batch)
190+
groups = {}
191+
@snapshot_groups.each do |name, group|
192+
groups[name] = {
193+
worst_score: group[:worst_score],
194+
best_score: group[:best_score],
195+
snapshots_count: group[:snapshots].size
196+
}
170197
end
198+
groups
171199
end
172200
end
173201

174-
def load_snapshot(id)
202+
def fetch_snapshots_group(group_name)
175203
@snapshots_lock.synchronize do
176-
@snapshots.find { |s| s[:id] == id }
204+
group = @snapshot_groups[group_name]
205+
if group
206+
group[:snapshots].dup
207+
else
208+
[]
209+
end
210+
end
211+
end
212+
213+
def load_snapshot(id, group_name)
214+
@snapshots_lock.synchronize do
215+
group = @snapshot_groups[group_name]
216+
if group
217+
group[:snapshots].find { |s| s[:id] == id }
218+
end
177219
end
178220
end
179221

@@ -182,7 +224,7 @@ def load_snapshot(id)
182224
# used in tests only
183225
def wipe_snapshots_data
184226
@snapshots_cycle = 0
185-
@snapshots = []
227+
@snapshot_groups = {}
186228
end
187229
end
188230
end

0 commit comments

Comments
 (0)