레일즈의 ActiveRecord를 처음 봤을 때를 기억합니다. 그것은 계시였습니다. 이것은 2005년이었고 저는 PHP 앱에 대한 SQL 쿼리를 수동으로 코딩하고 있었습니다. 갑자기 데이터베이스 사용이 지루한 일에서 쉽고 - 감히 - 재미로 바뀌었습니다.
...그런 다음 성능 문제를 확인하기 시작했습니다.
ActiveRecord 자체는 느리지 않았습니다. 실제로 실행 중인 쿼리에 관심을 기울이지 않았습니다. 그리고 Rails CRUD 앱에서 사용되는 가장 관용적인 데이터베이스 쿼리 중 일부는 기본적으로 더 큰 데이터 세트로 확장하는 데 매우 취약합니다.
이 기사에서 우리는 가장 큰 범인 세 가지에 대해 논의할 것입니다. 그러나 먼저 DB 쿼리가 잘 확장되는지 여부를 알 수 있는 방법에 대해 이야기해 보겠습니다.
성능 측정
모든 DB 쿼리는 데이터 세트가 충분히 작으면 성능이 좋습니다. 따라서 실제로 성능을 느끼려면 프로덕션 규모의 데이터베이스에 대해 벤치마킹해야 합니다. 이 예에서는 faults
라는 테이블을 사용할 것입니다. 약 22,000개의 레코드가 있습니다.
우리는 포스트그레스를 사용하고 있습니다. Postgres에서 성능을 측정하는 방법은 explain
을 사용하는 것입니다. . 예:
# explain (analyze) select * from faults where id = 1;
QUERY PLAN
--------------------------------------------------------------------------------------------------
Index Scan using faults_pkey on faults (cost=0.29..8.30 rows=1 width=1855) (actual time=0.556..0.556 rows=0 loops=1)
Index Cond: (id = 1)
Total runtime: 0.626 ms
이것은 (cost=0.29..8.30 rows=1 width=1855)
쿼리를 수행하는 데 드는 예상 비용을 보여줍니다. 그리고 그것을 수행하는 데 걸린 실제 시간 (actual time=0.556..0.556 rows=0 loops=1)
더 읽기 쉬운 형식을 선호하는 경우 postgres에 결과를 YAML로 인쇄하도록 요청할 수 있습니다.
# explain (analyze, format yaml) select * from faults where id = 1;
QUERY PLAN
--------------------------------------
- Plan: +
Node Type: "Index Scan" +
Scan Direction: "Forward" +
Index Name: "faults_pkey" +
Relation Name: "faults" +
Alias: "faults" +
Startup Cost: 0.29 +
Total Cost: 8.30 +
Plan Rows: 1 +
Plan Width: 1855 +
Actual Startup Time: 0.008 +
Actual Total Time: 0.008 +
Actual Rows: 0 +
Actual Loops: 1 +
Index Cond: "(id = 1)" +
Rows Removed by Index Recheck: 0+
Triggers: +
Total Runtime: 0.036
(1 row)
지금은 "Plan Rows"와 "Actual Rows"에만 집중할 것입니다.
- 행 계획 최악의 경우 DB가 쿼리에 응답하기 위해 루프를 통과해야 하는 행 수
- 실제 행 쿼리를 실행할 때 DB가 실제로 루프를 돌았던 행은 몇 개입니까?
"Plan Rows"가 위와 같이 1이면 쿼리가 잘 확장될 것입니다. "Plan Rows"가 데이터베이스의 행 수와 같으면 쿼리가 "전체 테이블 스캔"을 수행하고 확장성이 좋지 않음을 의미합니다.
쿼리 성능을 측정하는 방법을 알았으므로 이제 몇 가지 공통 레일 관용구를 살펴보고 어떻게 쌓이는지 살펴보겠습니다.
카운팅
Rails 보기에서 다음과 같은 코드를 보는 것은 정말 일반적입니다.
Total Faults <%= Fault.count %>
그러면 다음과 같은 SQL이 생성됩니다.
select count(*) from faults;
explain
에 연결해 보겠습니다. 그리고 무슨 일이 일어나는지 보십시오.
# explain (analyze, format yaml) select count(*) from faults;
QUERY PLAN
--------------------------------------
- Plan: +
Node Type: "Aggregate" +
Strategy: "Plain" +
Startup Cost: 1840.31 +
Total Cost: 1840.32 +
Plan Rows: 1 +
Plan Width: 0 +
Actual Startup Time: 24.477 +
Actual Total Time: 24.477 +
Actual Rows: 1 +
Actual Loops: 1 +
Plans: +
- Node Type: "Seq Scan" +
Parent Relationship: "Outer"+
Relation Name: "faults" +
Alias: "faults" +
Startup Cost: 0.00 +
Total Cost: 1784.65 +
Plan Rows: 22265 +
Plan Width: 0 +
Actual Startup Time: 0.311 +
Actual Total Time: 22.839 +
Actual Rows: 22265 +
Actual Loops: 1 +
Triggers: +
Total Runtime: 24.555
(1 row)
와! 우리의 간단한 카운트 쿼리는 전체 테이블인 22,265개의 행을 반복합니다! postgres에서 카운트는 항상 전체 레코드 세트를 반복합니다.
where
를 추가하여 레코드 세트의 크기를 줄일 수 있습니다. 쿼리에 대한 조건. 요구 사항에 따라 성능이 허용되는 경우 크기를 충분히 낮출 수 있습니다.
이 문제를 해결하는 유일한 다른 방법은 카운트 값을 캐시하는 것입니다. Rails는 다음과 같이 설정하면 자동으로 수행할 수 있습니다.
belongs_to :project, :counter_cache => true
쿼리가 레코드를 반환하는지 확인할 때 다른 대안을 사용할 수 있습니다. Users.count > 0
대신 , 시도 Users.exists?
. 결과 쿼리는 훨씬 더 성능이 좋습니다. (이 부분을 지적해 주신 독자 Gerry Shaw에게 감사드립니다.)
정렬
인덱스 페이지입니다. 거의 모든 앱에는 하나 이상의 앱이 있습니다. 데이터베이스에서 최신 20개의 레코드를 가져와서 표시합니다. 무엇이 더 간단할 수 있습니까?
레코드를 로드하는 코드는 다음과 같습니다.
@faults = Fault.order(created_at: :desc)
이에 대한 SQL은 다음과 같습니다.
select * from faults order by created_at desc;
분석해 보겠습니다.
# explain (analyze, format yaml) select * from faults order by created_at desc;
QUERY PLAN
--------------------------------------
- Plan: +
Node Type: "Sort" +
Startup Cost: 39162.46 +
Total Cost: 39218.12 +
Plan Rows: 22265 +
Plan Width: 1855 +
Actual Startup Time: 75.928 +
Actual Total Time: 86.460 +
Actual Rows: 22265 +
Actual Loops: 1 +
Sort Key: +
- "created_at" +
Sort Method: "external merge" +
Sort Space Used: 10752 +
Sort Space Type: "Disk" +
Plans: +
- Node Type: "Seq Scan" +
Parent Relationship: "Outer"+
Relation Name: "faults" +
Alias: "faults" +
Startup Cost: 0.00 +
Total Cost: 1784.65 +
Plan Rows: 22265 +
Plan Width: 1855 +
Actual Startup Time: 0.004 +
Actual Total Time: 4.653 +
Actual Rows: 22265 +
Actual Loops: 1 +
Triggers: +
Total Runtime: 102.288
(1 row)
여기서 우리는 이 쿼리를 수행할 때마다 DB가 모든 22,265개의 행을 정렬하고 있음을 알 수 있습니다. 노 부에노!
기본적으로 SQL의 모든 "order by" 절은 레코드 세트가 실시간으로 즉시 정렬되도록 합니다. 캐싱이 없습니다. 당신을 구할 마법은 없습니다.
해결책은 인덱스를 사용하는 것입니다. 이와 같은 간단한 경우에는 created_at 열에 정렬된 인덱스를 추가하면 쿼리 속도가 상당히 빨라집니다.
Rails 마이그레이션에 다음을 넣을 수 있습니다.
class AddIndexToFaultCreatedAt < ActiveRecord::Migration
def change
add_index(:faults, :created_at)
end
end
다음 SQL을 실행합니다.
CREATE INDEX index_faults_on_created_at ON faults USING btree (created_at);
맨 끝에 (created_at)
정렬 순서를 지정합니다. 기본적으로 오름차순입니다.
이제 정렬 쿼리를 다시 실행하면 더 이상 정렬 단계가 포함되지 않음을 알 수 있습니다. 단순히 인덱스에서 미리 정렬된 데이터를 읽습니다.
# explain (analyze, format yaml) select * from faults order by created_at desc;
QUERY PLAN
----------------------------------------------
- Plan: +
Node Type: "Index Scan" +
Scan Direction: "Backward" +
Index Name: "index_faults_on_created_at"+
Relation Name: "faults" +
Alias: "faults" +
Startup Cost: 0.29 +
Total Cost: 5288.04 +
Plan Rows: 22265 +
Plan Width: 1855 +
Actual Startup Time: 0.023 +
Actual Total Time: 8.778 +
Actual Rows: 22265 +
Actual Loops: 1 +
Triggers: +
Total Runtime: 10.080
(1 row)
여러 열을 기준으로 정렬하는 경우 여러 열을 기준으로 정렬되는 인덱스도 생성해야 합니다. Rails 마이그레이션의 모습은 다음과 같습니다.
add_index(:faults, [:priority, :created_at], order: {priority: :asc, created_at: :desc)
더 복잡한 쿼리를 시작하면 explain
을 통해 쿼리를 실행하는 것이 좋습니다. . 일찍 그리고 자주하십시오. 쿼리에 대한 몇 가지 간단한 변경으로 인해 postgres가 정렬에 인덱스를 사용하는 것이 불가능했다는 것을 알 수 있습니다.
한도 및 오프셋
인덱스 페이지에는 데이터베이스의 모든 항목이 거의 포함되지 않습니다. 대신 페이지를 매겨 한 번에 10개 또는 30개 또는 50개 항목만 표시합니다. 이를 수행하는 가장 일반적인 방법은 limit
를 사용하는 것입니다. 및 offset
함께. Rails에서는 다음과 같습니다.
Fault.limit(10).offset(100)
그러면 다음과 같은 SQL이 생성됩니다.
select * from faults limit 10 offset 100;
이제 Explain을 실행하면 이상한 것이 보입니다. 스캔된 행의 수는 110이며 제한에 오프셋을 더한 값과 같습니다.
# explain (analyze, format yaml) select * from faults limit 10 offset 100;
QUERY PLAN
--------------------------------------
- Plan: +
Node Type: "Limit" +
...
Plans: +
- Node Type: "Seq Scan" +
Actual Rows: 110 +
...
오프셋을 10,000으로 변경하면 스캔된 행 수가 10010으로 점프하고 쿼리가 64배 느려지는 것을 볼 수 있습니다.
# explain (analyze, format yaml) select * from faults limit 10 offset 10000;
QUERY PLAN
--------------------------------------
- Plan: +
Node Type: "Limit" +
...
Plans: +
- Node Type: "Seq Scan" +
Actual Rows: 10010 +
...
이것은 혼란스러운 결론으로 이어집니다. 페이지를 매길 때 이후 페이지는 이전 페이지보다 로드 속도가 느립니다. 위의 예에서 페이지당 항목이 100개라고 가정하면 100페이지는 1페이지보다 13배 느립니다.
그래서 당신은 무엇을합니까?
솔직히 완벽한 해결책을 찾지 못했습니다. 처음에는 100개 또는 1000개 페이지를 가질 필요가 없도록 데이터 세트의 크기를 줄일 수 있는지 확인하는 것으로 시작하겠습니다.
레코드 세트를 줄일 수 없는 경우 가장 좋은 방법은 offset/limit를 where 절로 바꾸는 것입니다.
# You could use a date range
Fault.where("created_at > ? and created_at < ?", 100.days.ago, 101.days.ago)
# ...or even an id range
Fault.where("id > ? and id < ?", 100, 200)
결론
이 기사를 통해 db 쿼리에서 발생할 수 있는 성능 문제를 찾기 위해 postgres의 설명 기능을 실제로 활용해야 한다는 확신이 들었기를 바랍니다. 가장 단순한 쿼리라도 주요 성능 문제를 일으킬 수 있으므로 확인하는 것이 좋습니다. :)