오프셋 페이지네이션 최적화
많은 REST API 엔드포인트에서, 우리는 결과를 페이지네이션하기 위해 page
URL 매개변수를 사용하는 오프셋 기반 페이지네이션을 사용합니다. 오프셋 페이지네이션은 선형적으로 확장되며, 페이지 번호가 높을수록 데이터베이스 쿼리는 느려집니다. 이것은 큰 페이지 번호의 경우 데이터베이스 쿼리가 타임아웃 될 수 있음을 의미합니다. 이는 일반적으로 제3자 통합 및 스크립트가 시스템과 상호 작용할 때 발생하며, 사용자가 의도적으로 높은 페이지 번호를 방문할 가능성은 낮습니다.
오프셋 페이지네이션과 관련된 확장성 문제를 처리하는 이상적인 방법은 키셋 페이지네이션으로 전환하는 것이지만, 이는 API의 주요 변경을 의미합니다. 임시로 사용할 수 있는 대체 조치를 제공하기 위해 Gitlab::Pagination::Offset::PaginationWithIndexOnlyScan
클래스를 사용할 수 있습니다. 이 최적화는 높은 OFFSET
값이 존재할 때 오프셋-페이지네이션 쿼리의 성능을 개선하는 데 도움이 될 수 있습니다. 성능 개선은 쿼리가 개선된 쿼리 타이밍과 함께 선형적으로 계속 확장됨을 의미하며, 이로 인해 데이터베이스 타임아웃에 도달하는 것이 훨씬 더 높은 page
번호에서 발생하게 됩니다(만약 발생한다면).
최적화를 사용하기 위한 요구 사항
이 최적화는 ORDER BY
, OFFSET
, LIMIT
절에 따라 레코드를 결정할 때 SELECT *
호출을 피하고 데이터베이스 I/O를 줄이기 위해 인덱스만 스캔하는 것을 시도합니다. 최적화를 사용하려면 키셋 페이지네이션과 동일한 요구 사항을 충족해야 합니다:
-
ORDER BY
절이 존재해야 합니다. -
ORDER BY
절은 하나의 데이터베이스 열을 고유하게 식별해야 합니다.- 좋음, 기본 키 사용:
ORDER BY id
- 나쁨,
created_at
비고유:ORDER BY created_at
- 좋음, 타이브레이커가 있음:
ORDER BY created_at, id
- 좋음, 기본 키 사용:
- 쿼리가 데이터베이스 인덱스로 잘 커버되어야 합니다.
최적화 클래스 사용 방법
최적화 클래스는 ActiveRecord::Relation
객체와 함께 사용할 수 있으며, 결과적으로 최적화된 kaminari-페이지네이션 ActiveRecord::Relation
객체를 반환합니다. 최적화를 적용할 수 없는 경우, 원래의 ActiveRecord::Relation
객체가 페이지네이션에 사용됩니다.
기본 사용법:
scope = Issue.where(project_id: 1).order(:id)
records = Gitlab::Pagination::Offset::PaginationWithIndexOnlyScan.new(scope: scope, page: 5, per_page: 100).paginate_with_kaminari
puts records.to_a
최적화는 항상 기능 플래그와 함께 롤아웃해야 하며, 특정 조건이 충족되었을 때 최적화를 사용할 수 있습니다.
# - 큰 페이지 번호 조회에 대해서만 최적화를 적용합니다.
# - label_names 필터 매개변수가 주어지면, 최적화는 효과가 없습니다(복잡한 JOIN).
if params[:page] > 100 && params[:label_names].blank? && Feature.enabled?(:my_optimized_offet_query)
Gitlab::Pagination::Offset::PaginationWithIndexOnlyScan.new(scope: scope, page: params[:page], per_page: params[:per_page]).paginate_with_kaminari
else
scope.page(params[:page]).per(params[:per_page])
end
최적화는 어떻게 작동합니까
최적화는 전달된 ActiveRecord::Relation
객체를 CTE로 이동시킵니다. CTE 내에서 원래 쿼리는 ORDER BY
열만 선택하도록 변경됩니다. 이를 통해 데이터베이스가 인덱스 전용 스캔을 사용할 수 있게 됩니다.
쿼리가 실행되면 CTE 내의 쿼리가 먼저 평가되며, CTE는 선택된 열로 LIMIT
수의 행을 포함하게 됩니다.
ORDER BY
값을 사용하여 LATERAL
쿼리는 전체 행을 하나씩 찾습니다. LATERAL
쿼리는 CTE의 각 행에 대해 테이블에서 전체 행을 조회하도록 강제하기 위해 사용됩니다.
원래 쿼리:
-
인덱스에서
OFFSET + LIMIT
수의 항목을 읽습니다. -
테이블에서
OFFSET + LIMIT
수의 행을 읽습니다.
최적화된 쿼리:
-
인덱스에서
OFFSET + LIMIT
수의 항목을 읽습니다. -
테이블에서
LIMIT
수의 행을 읽습니다.
최적화가 도움이 되는지 확인하기
높은 (100,000) OFFSET
값을 가진 데이터베이스 쿼리에 대해 EXPLAIN (buffers, analyze)
를 실행하면 최적화가 도움이 되는지 명확히 알 수 있습니다.
다음 사항을 확인합니다:
- 최적화된 쿼리 계획에는 인덱스 전용 스캔 노드가 있어야 합니다.
-
캐시된 버퍼 수와 타이밍을 비교하면 낮아야 합니다.
- 이는 동일한 쿼리를 2회 또는 3회 실행할 때 수행할 수 있습니다.
다음 쿼리를 고려하십시오:
SELECT issues.*
FROM issues
ORDER BY id
OFFSET 100000
LIMIT 100
이는 인덱스 스캔을 사용하는 실행 계획을 생성합니다:
Limit (cost=27800.96..27828.77 rows=100 width=1491) (actual time=138.305..138.470 rows=100 loops=1)
Buffers: shared hit=73212
I/O Timings: read=0.000 write=0.000
-> Index Scan using issues_pkey on public.issues (cost=0.57..26077453.90 rows=93802448 width=1491) (actual time=0.063..133.688 rows=100100 loops=1)
Buffers: shared hit=73212
I/O Timings: read=0.000 write=0.000
Time: 143.779 ms
- planning: 5.222 ms
- execution: 138.557 ms
- I/O read: 0.000 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 73212 (~572.00 MiB) from the buffer pool
- reads: 0 from the OS file cache, including disk I/O
- dirtied: 0
- writes: 0
최적화된 쿼리:
WITH index_only_scan_pagination_cte AS MATERIALIZED
(SELECT id
FROM issues
ORDER BY id ASC
LIMIT 100
OFFSET 100000)
SELECT issues.*
FROM
(SELECT id
FROM index_only_scan_pagination_cte) index_only_scan_subquery,
LATERAL
(SELECT issues.*
FROM issues
WHERE issues.id = index_only_scan_subquery.id
LIMIT 1) issues
실행 계획:
Nested Loop (cost=2453.51..2815.44 rows=100 width=1491) (actual time=23.614..23.973 rows=100 loops=1)
Buffers: shared hit=56167
I/O Timings: read=0.000 write=0.000
CTE index_only_scan_pagination_cte
-> Limit (cost=2450.49..2452.94 rows=100 width=4) (actual time=23.590..23.621 rows=100 loops=1)
Buffers: shared hit=55667
I/O Timings: read=0.000 write=0.000
-> Index Only Scan using issues_pkey on public.issues issues_1 (cost=0.57..2298090.72 rows=93802448 width=4) (actual time=0.070..20.412 rows=100100 loops=1)
Heap Fetches: 1063
Buffers: shared hit=55667
I/O Timings: read=0.000 write=0.000
-> CTE Scan on index_only_scan_pagination_cte (cost=0.00..2.00 rows=100 width=4) (actual time=23.593..23.641 rows=100 loops=1)
Buffers: shared hit=55667
I/O Timings: read=0.000 write=0.000
-> Limit (cost=0.57..3.58 rows=1 width=1491) (actual time=0.003..0.003 rows=1 loops=100)
Buffers: shared hit=500
I/O Timings: read=0.000 write=0.000
-> Index Scan using issues_pkey on public.issues (cost=0.57..3.58 rows=1 width=1491) (actual time=0.003..0.003 rows=1 loops=100)
Index Cond: (issues.id = index_only_scan_pagination_cte.id)
Buffers: shared hit=500
I/O Timings: read=0.000 write=0.000
Time: 29.562 ms
- planning: 5.506 ms
- execution: 24.056 ms
- I/O read: 0.000 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 56167 (~438.80 MiB) from the buffer pool
- reads: 0 from the OS file cache, including disk I/O
- dirtied: 0
- writes: 0