지난주에 한 사용자로부터 피드백을 받았습니다.
그들의 Bencher Perf 페이지가 로딩하는 데 시간이 걸린다고 했습니다.
그래서 저도 확인해 보기로 했는데, 정말, 그들이 상냥하게 말한 것이었네요.
로딩하는 데 너무나 오래 걸렸어요! 부끄럽게도 오래 걸렸습니다.
특히 지속적 벤치마킹 도구의 선두주자에게는 더욱이요.
과거에, 저는 Rustls Perf 페이지를 리트머스 시험지로 사용해 왔습니다.
그들은 112개의 벤치마크를 가지고 있고 가장 인상적인 지속적 벤치마킹 설정 중 하나를 가지고 있습니다.
이전에는 로딩하는 데 대략 5초가 걸렸습니다. 이번에는… ⏳👀 … 38.8초가 걸렸네요!
이런 종류의 지연 시간으로는, 저는 파고들 수밖에 없었습니다. 성능 버그도 결국 버그니까요!
배경
처음부터, Bencher Perf API
가 성능 측면에서 가장 요구가 많은 엔드포인트 중 하나가 될 것이라는 것을 알고 있었습니다.
많은 사람들이 벤치마크 추적 도구를 다시 만들어야 했던 주된 이유는
기존의 현장 도구들이 필요한 고차원성을 처리하지 못하기 때문입니다.
“고차원성”이란 시간 경과에 따른 성능을 추적하고 브랜치, 테스트베드, 벤치마크, 측정치 등 여러 차원을 통해 추적할 수 있는 능력을 의미합니다.
이 다섯 가지 다른 차원을 통한 분석 능력은 매우 복잡한 모델로 이어집니다.
이러한 내재적 복잡성과 데이터의 성격 때문에,
Bencher에 시계열 데이터베이스를 사용하는 것을 고려했습니다.
결국에는, SQLite를 사용하기로 결정했습니다.
스케일링하지 않는 일을 처리하는 것이 스케일링하지 않는 일 하기는 실제로 도움이 될지 그렇지 않을지 알 수 없는 전혀 새로운 데이터베이스 아키텍처를 배우는 데 추가 시간을 소비하는 것보다 낫다고 판단했습니다.
시간이 지남에 따라 Bencher Perf API에 대한 요구 사항도 증가했습니다.
원래는 사용자가 수동으로 그래프에 표시하고자 하는 모든 차원을 선택해야 했습니다.
이것은 사용자가 유용한 그래프에 도달하는 데 많은 마찰을 일으켰습니다.
이를 해결하기 위해, Perf 페이지에 가장 최근 보고서 목록을 추가했습니다.
기본적으로 가장 최근 보고서가 선택되어 그래프에 표시되었습니다.
이는 가장 최근 보고서에 112개의 벤치마크가 있을 경우 112개 모두가 그래프에 표시됨을 의미합니다.
모델은 또한 임계값 경계를 추적하고 시각화하는 기능으로 더욱 복잡해졌습니다.
이러한 점을 염두에 두고, 성능 관련 개선을 몇 가지 실행했습니다.
Perf 그래프가 가장 최근 보고서로부터 플로팅을 시작해야 하므로,
Reports API를 리팩토링하여 데이터베이스를 순회하는 대신 단일 호출로 보고서의 결과 데이터를 얻었습니다.
기본 보고서 쿼리의 시간 창을 무제한이 아닌 네 주로 설정했습니다.
또한 모든 데이터베이스 핸들의 범위를 대폭 제한하여 잠금 경합을 줄였습니다.
사용자와 소통을 돕기 위해 Perf 플롯과 차원 탭 모두에 상태 바 스피너를 추가했습니다.
작년 가을에 단일 쿼리로 모든 Perf 결과를 가져오기 위해 복합 쿼리를 사용하려는 시도는 실패했습니다.
이는 사중 중첩된 for 루프를 사용하는 대신이었습니다.
이로 인해 Rust 타입 시스템 재귀 제한에 도달하게 되었고,
스택이 반복적으로 오버플로우되며,
정신이 나갈 것 같은(38초 이상) 컴파일 시간을 겪은 끝에,
SQLite 복합 선택문의 최대 항목 수 제한에서 궁극적으로 실패로 돌아왔습니다.
이 모든 경험을 바탕으로, 여기서 정말로 성능 엔지니어 체제를 갖추고 주력해야 한다는 것을 알았습니다.
SQLite 데이터베이스를 프로파일링한 적이 없었고,
솔직히 말해서 어떤 데이터베이스도 실제로 프로파일링한 적이 없었습니다.
잠깐, 당신이 생각할지도 모릅니다.
내 LinkedIn 프로필에는 거의 2년 동안 “데이터베이스 관리자”였다고 되어 있습니다.
그리고 저는 한 번도 데이터베이스를 프로파일링하지 않았습니까?
네. 아마도 그 이야기는 다음에 할 기회가 있겠죠.
ORM에서 SQL 쿼리로
제가 처음 마주친 문제는 Rust 코드에서 SQL 쿼리를 추출하는 것이었습니다.
저는 Bencher의 객체 관계 매퍼(ORM)로 Diesel을 사용합니다.
Diesel은 매개변수화된 쿼리를 생성합니다.
즉, SQL 쿼리와 해당 바인드 매개변수를 데이터베이스에 별도로 전송합니다.
즉, 대체 작업은 데이터베이스에서 수행됩니다.
따라서, Diesel은 사용자에게 완전한 쿼리를 제공할 수 없습니다.
제가 찾은 가장 좋은 방법은 the diesel::debug_query 함수를 사용하여 매개변수화된 쿼리를 출력하는 것이었습니다:
그리고 유효한 SQL로 쿼리를 손으로 정리하고 매개변수화했습니다:
더 나은 방법을 알고 계시다면 알려주세요!
이 방법은 프로젝트 관리자가 제안한 방법이긴 하지만,
그래서 저는 그대로 따랐습니다.
이제 SQL 쿼리를 얻었으니, 마침내… 많은 문서를 읽을 준비가 되었습니다.
SQLite 쿼리 플래너
SQLite 웹사이트에는 쿼리 플래너에 대한 훌륭한 문서가 있습니다.
이 문서는 SQLite가 SQL 쿼리를 어떻게 실행하는지 정확히 설명하고,
어떤 인덱스가 유용한지, 전체 테이블 스캔 같은 주의해야 할 작업이 무엇인지 알려줍니다.
내 Perf 쿼리를 쿼리 플래너가 어떻게 실행할지 보기 위해서,
내 도구 상자에 새로운 도구를 추가할 필요가 있었습니다: EXPLAIN QUERY PLAN
SQL 쿼리 앞에 EXPLAIN QUERY PLAN을 붙이거나
쿼리 전에 .eqp on 도트 명령을 실행할 수 있습니다.
어느 쪽이든, 이렇게 생긴 결과를 얻었습니다:
오, 보이시나요!
여기에는 많은 것들이 있습니다.
하지만 저에게 눈에 띄는 세 가지 큰 사항은 다음과 같습니다:
SQLite가 boundary 테이블의 전체 를 스캔하는 즉석에서 만들어진 머티리얼라이즈드 뷰를 생성하고 있습니다
SQLite가 그 다음으로 metric 테이블의 전체 를 스캔하고 있습니다
SQLite가 즉석에서 두 개의 인덱스를 생성하고 있습니다
그렇다면 metric과 boundary 테이블의 크기는 얼마나 될까요?
이들은 바로 가장 큰 두 테이블입니다,
왜냐하면 모든 메트릭과 경계값들이 여기에 저장되기 때문입니다.
SQLite 성능 튜닝의 첫 번째 경험이었기 때문에,
변경을 하기 전에 전문가와 상의하고 싶었습니다.
SQLite 전문가
SQLite에는 .expert 명령어로 활성화할 수 있는 실험적인 “전문가” 모드가 있습니다.
이 모드는 쿼리에 대한 인덱스를 제안하므로, 한번 사용해 보기로 했습니다.
이것이 제안한 것입니다:
분명히 개선되었습니다!
metric 테이블에 대한 스캔과 현장에서 생성된 인덱스 두 개를 없앴습니다.
솔직히, 저 혼자서는 처음 두 인덱스를 떠올리지 못했을 겁니다.
고마워요, SQLite 전문가!
이제 남은 것은 그 성가신 현장에서 생성된 머티리얼라이즈드 뷰를 없애는 것뿐입니다.
머티리얼라이즈드 뷰(Materialized View)
지난해 임계 경계를 추적 및 시각화할 수 있는 기능을 추가했을 때,
데이터베이스 모델에서 결정을 내려야 했습니다.
메트릭과 해당 경계 사이에는 1대0 또는 1대1 관계가 있습니다.
즉, 메트릭은 경계와 관련이 없거나 하나의 경계와 관련될 수 있고, 경계는 한 메트릭과만 관련될 수 있습니다.
그래서 metric 테이블을 확장하여 모든 boundary 데이터를 포함시키고, boundary 관련 필드를 모두 null 허용할 수도 있었습니다.
또는 metric 테이블에 UNIQUE 외래 키를 가진 별도의 boundary 테이블을 생성할 수도 있었습니다.
저에게는 후자의 옵션이 훨씬 깔끔해 보였고, 성능 문제는 나중에 언제든지 처리할 수 있을 것이라고 생각했습니다.
metric 테이블과 boundary 테이블을 생성하기 위해 사용된 실제 쿼리는 다음과 같습니다:
그리고 “나중”이 도착했습니다.
단순히 boundary(metric_id)에 대한 인덱스를 추가해 보려고 했지만 도움이 되지 않았습니다.
이유는 metric 테이블에서 파생된 Perf 쿼리는
그 관계가 0/1이거나 다시 말해 null 허용이므로 스캔되어야 하고(O(n))
검색할 수는 없다(O(log(n)))는 사실과 관련이 있다고 믿습니다.
이는 저에게 명확한 해결책을 남겼습니다.
SQLite가 동적으로 머티리얼라이즈드 뷰를 생성하는 것을 방지하기 위해
metric과 boundary 관계를 평면화하는 머티리얼라이즈드 뷰를 생성해야 했습니다.
새로운 metric_boundary 머티리얼라이즈드 뷰를 생성하기 위해 사용된 쿼리는 다음과 같습니다:
이 솔루션으로 공간을 런타임 성능과 교환합니다.
얼마나 많은 공간이냐고요?
놀랍게도 이 뷰는 데이터베이스에서 가장 큰 두 테이블에 대한 것임에도 불구하고 약 4%의 증가에 불과합니다.
최고의 부분은, 이를 통해 소스 코드에서 눈앞의 이익을 얻을 수 있다는 것입니다.
Diesel을 사용하여 머티리얼라이즈드 뷰를 생성하는 것이 놀랍도록 쉬웠습니다.
평소 스키마를 생성할 때 Diesel이 사용하는 정확히 같은 매크로를 사용하기만 하면 됩니다.
이 과정을 통해, 저는 Diesel을 훨씬 더 평가하게 되었습니다.
모든 맛있는 세부 사항은 보너스 버그를 참조하세요.
정리
세 개의 새로운 인덱스와 머티리얼라이즈드 뷰를 추가하면서, 이제 질의 계획자가 보여주는 것은 다음과 같습니다:
모든 SEARCH가 기존 인덱스를 사용하여 아름답게 표시됩니다! 🥲
그리고 제 변경 사항을 프로덕션에 배포한 후:
이제 마지막 테스트 시간이었습니다.
Rustls Perf 페이지가 얼마나 빨리 로드되는지?
저는 이미 Bencher로 Bencher를 도그푸딩하고 있지만, 기존의 벤치마크 하네스 어댑터들은 모두 마이크로-벤치마킹 하네스를 위한 것입니다.
대부분의 HTTP 하네스는 실제로 부하 테스트 하네스이며, 부하 테스트는 벤치마킹과 다릅니다.
더욱이, 저는 Bencher를 부하 테스트로 확장할 계획이 없습니다.
그것은 매우 다른 사용 사례이며 예를 들어 시계열 데이터베이스 같은 매우 다른 설계 고려사항을 요구할 것입니다.
심지어 부하 테스트를 갖추고 있다고 하더라도, 이를 발견하기 위해서는 제가 신규로 생성한 프로덕션 데이터에 대해 실행해야 할 것입니다.
이러한 변경으로 인한 성능 차이는 제 테스트 데이터베이스로는 무시할 수 있었습니다.
테스트 데이터베이스 벤치마크 결과 보기
변경 전:
인덱스 및 머티리얼라이즈드 뷰 변경 후:
이 모든 것이 저로 하여금 마이크로-벤치마크를 생성하게 했습니다.
그것은 Perf API 엔드포인트에 대해 실행되고 Bencher로 결과를 도그푸딩할 것입니다.
이러한 종류의 성능 회귀를 CI에서 잡을 수 있도록 상당한 크기의 테스트 데이터베이스가 필요할 것입니다.
이 작업을 추적하기 위해 트래킹 이슈를 생성했습니다. 관심이 있다면 따라와 주세요.
이 모든 것이 저를 생각하게 합니다:
만약 여러분이 SQL 데이터베이스 쿼리 플랜의 스냅샷 테스팅을 할 수 있다면 어떨까요?
즉, 현재 대비 후보 SQL 데이터베이스 쿼리 플랜을 비교할 수 있습니다.
SQL 쿼리 플랜 테스팅은 데이터베이스를 위한 지침 수 기반의 벤치마킹과 같은 종류가 될 것입니다.
쿼리 플랜은 데이터베이스 쿼리의 런타임 성능에 문제가 있을 수 있음을 나타내는 데 도움이 됩니다.
실제로 데이터베이스 쿼리를 벤치마킹할 필요 없이 말이죠.
이에 대해서도 트래킹 이슈를 생성했습니다.
생각이나 알고 있는 선행 연구에 대해 의견을 추가해 주시면 감사하겠습니다!
보너스 버그
저는 최근에 제 머티리얼라이즈드 뷰 코드에 버그가 있었습니다.
이게 SQL 쿼리의 모습입니다:
문제를 보시겠나요? 아니요. 저도 못 봤습니다!
문제는 바로 여기에 있습니다:
실제로는 이렇게 되어야 하죠:
저는 너무 영리하려고 했는데,
제 Diesel 머티리얼라이즈드 뷰 스키마에서 이 조인을 허용했습니다:
저는 이 매크로가 어떻게든 alert.boundary_id를 metric_boundary.boundary_id와 연관짓는지 알아낼 수 있을 거라고 생각했습니다.
하지만 그렇지 않았습니다.
그것은 단지 metric_boundary의 첫 번째 열(metric_id)을 alert과 관련시키는 것처럼 보였습니다.
버그를 발견한 후에는 수정하기 쉬웠습니다.
Perf 쿼리에서 명시적 조인을 사용하기만 하면 됐습니다:
🐰 여러분, 이게 전부입니다!
🤖 이 문서는 OpenAI GPT-4에 의해 자동으로 생성되었습니다. 정확하지 않을 수도 있고 오류가 있을 수도 있습니다. 오류를 발견하면 GitHub에서 문제를 열어주세요.