"포함" 사용 시 엔터티 프레임워크 성능에 대한 심층 분석
게시 됨: 2022-03-11일상 업무 중에는 Entity Framework를 사용합니다. 매우 편리하지만 경우에 따라 성능이 느립니다. EF 성능 향상에 대한 좋은 기사가 많이 있고 몇 가지 매우 훌륭하고 유용한 조언이 제공되지만(예: 복잡한 쿼리 피하기, 건너뛰기 및 가져오기의 매개변수, 보기 사용, 필요한 필드만 선택 등) 그렇게 할 수 있는 것은 많지 않습니다. 두 개 이상의 필드에서 복잡한 Contains
를 사용해야 할 때, 즉 데이터를 메모리 목록에 결합할 때 실제로 수행됩니다.
문제
다음 예를 확인해 보겠습니다.
var localData = GetDataFromApiOrUser(); var query = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId join t in localData on new { s.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; var result = query.ToList();
위의 코드는 EF 6에서 전혀 작동 하지 않으며 EF Core에서는 작동하지만 실제로는 조인이 로컬에서 수행됩니다. 데이터베이스에 천만 개의 레코드가 있으므로 모두 다운로드되고 모든 메모리가 소모되기 때문입니다. . 이것은 EF의 버그가 아닙니다. 예상됩니다. 하지만, 이를 해결할 수 있는 무언가가 있다면 환상적이지 않을까요? 이 기사에서는 이 성능 병목 현상을 해결하기 위해 다른 접근 방식으로 몇 가지 실험을 하려고 합니다.
해결책
나는 이것을 달성하기 위해 가장 간단한 것부터 더 고급까지 다양한 방법을 시도할 것입니다. 각 단계에서 소요 시간 및 메모리 사용량과 같은 코드 및 메트릭을 제공합니다. 벤치마킹 프로그램이 10분 이상 작동하면 실행을 중단합니다.
벤치마킹 프로그램에 대한 코드는 다음 저장소에 있습니다. C#, .NET Core, EF Core 및 PostgreSQL을 사용합니다. Intel Core i5, 8GB RAM 및 SSD가 있는 컴퓨터를 사용했습니다.
테스트용 DB 스키마는 다음과 같습니다.
옵션 1. 단순하고 순진한
시작하기 위해 간단한 것을 시도해 보겠습니다.
var result = new List<Price>(); using (var context = CreateContext()) { foreach (var testElement in TestData) { result.AddRange(context.Prices.Where( x => x.Security.Ticker == testElement.Ticker && x.TradedOn == testElement.TradedOn && x.PriceSourceId == testElement.PriceSourceId)); } }
알고리즘은 간단합니다. 테스트 데이터의 각 요소에 대해 데이터베이스에서 적절한 요소를 찾아 결과 컬렉션에 추가합니다. 이 코드에는 한 가지 장점이 있습니다. 구현하기가 매우 쉽습니다. 또한 읽기 쉽고 유지 관리가 가능합니다. 그것의 명백한 단점은 그것이 가장 느린 것입니다. 세 개의 열이 모두 인덱싱되더라도 네트워크 통신의 오버헤드는 여전히 성능 병목 현상을 만듭니다. 다음은 측정항목입니다.
따라서 대용량의 경우 약 1분이 소요됩니다. 메모리 소비는 합리적인 것 같습니다.
옵션 2. 병렬이 있는 순진함
이제 코드에 병렬 처리를 추가해 보겠습니다. 여기서 핵심 아이디어는 병렬 스레드에서 데이터베이스를 적중하면 전체 성능을 향상시킬 수 있다는 것입니다.
var result = new ConcurrentBag<Price>(); var partitioner = Partitioner.Create(0, TestData.Count); Parallel.ForEach(partitioner, range => { var subList = TestData.Skip(range.Item1) .Take(range.Item2 - range.Item1) .ToList(); using (var context = CreateContext()) { foreach (var testElement in subList) { var query = context.Prices.Where( x => x.Security.Ticker == testElement.Ticker && x.TradedOn == testElement.TradedOn && x.PriceSourceId == testElement.PriceSourceId); foreach (var el in query) { result.Add(el); } } } });
더 작은 테스트 데이터 세트의 경우 이 접근 방식이 첫 번째 솔루션보다 느리게 작동하지만 더 큰 샘플의 경우 더 빠릅니다(이 경우 약 2배). 메모리 소비는 약간 변경되지만 크게 변경되지는 않습니다.
옵션 3. 다중 포함
다른 접근 방식을 시도해 보겠습니다.
- Ticker, PriceSourceId 및 Date의 고유 값 컬렉션 3개를 준비합니다.
- 3개의 포함을 사용하여 한 번의 실행 필터링으로 쿼리를 수행합니다.
- 로컬에서 다시 확인하십시오(아래 참조).
var result = new List<Price>(); using (var context = CreateContext()) { var tickers = TestData.Select(x => x.Ticker).Distinct().ToList(); var dates = TestData.Select(x => x.TradedOn).Distinct().ToList(); var ps = TestData.Select(x => x.PriceSourceId) .Distinct().ToList(); var data = context.Prices .Where(x => tickers.Contains(x.Security.Ticker) && dates.Contains(x.TradedOn) && ps.Contains(x.PriceSourceId)) .Select(x => new { x.PriceSourceId, Price = x, Ticker = x.Security.Ticker, }) .ToList(); var lookup = data.ToLookup(x => $"{x.Ticker}, {x.Price.TradedOn}, {x.PriceSourceId}"); foreach (var el in TestData) { var key = $"{el.Ticker}, {el.TradedOn}, {el.PriceSourceId}"; result.AddRange(lookup[key].Select(x => x.Price)); } }
이 접근 방식은 문제가 있습니다. 실행 시간은 데이터에 매우 의존적입니다. 필요한 레코드만 검색할 수 있지만(이 경우 매우 빠름) 더 많은 레코드를 반환할 수도 있습니다(아마도 100배 이상).
다음 테스트 데이터를 고려해 보겠습니다.
여기에서 2018-01-01에 거래된 Ticker1의 가격과 2018-01-02에 거래된 Ticker2의 가격을 쿼리합니다. 그러나 실제로는 4개의 레코드가 반환됩니다.
Ticker
의 고유 값은 Ticker1
및 Ticker2
입니다. 2018-01-01
TradedOn
2018-01-02
입니다.
따라서 4개의 레코드가 이 표현식과 일치합니다.
그렇기 때문에 지역 재검토가 필요하고 이 접근 방식이 위험한 이유입니다. 측정항목은 다음과 같습니다.
엄청난 메모리 소모! 10분의 시간 초과로 인해 대용량 테스트가 실패했습니다.
옵션 4. 술어 빌더
패러다임을 바꾸자. 각 테스트 데이터 세트에 대해 오래된 Expression
을 작성해 봅시다.
var result = new List<Price>(); using (var context = CreateContext()) { var baseQuery = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId select new TestData() { Ticker = s.Ticker, TradedOn = p.TradedOn, PriceSourceId = p.PriceSourceId, PriceObject = p }; var tradedOnProperty = typeof(TestData).GetProperty("TradedOn"); var priceSourceIdProperty = typeof(TestData).GetProperty("PriceSourceId"); var tickerProperty = typeof(TestData).GetProperty("Ticker"); var paramExpression = Expression.Parameter(typeof(TestData)); Expression wholeClause = null; foreach (var td in TestData) { var elementClause = Expression.AndAlso( Expression.Equal( Expression.MakeMemberAccess( paramExpression, tradedOnProperty), Expression.Constant(td.TradedOn) ), Expression.AndAlso( Expression.Equal( Expression.MakeMemberAccess( paramExpression, priceSourceIdProperty), Expression.Constant(td.PriceSourceId) ), Expression.Equal( Expression.MakeMemberAccess( paramExpression, tickerProperty), Expression.Constant(td.Ticker)) )); if (wholeClause == null) wholeClause = elementClause; else wholeClause = Expression.OrElse(wholeClause, elementClause); } var query = baseQuery.Where( (Expression<Func<TestData, bool>>)Expression.Lambda( wholeClause, paramExpression)).Select(x => x.PriceObject); result.AddRange(query); }
결과 코드는 상당히 복잡합니다. 표현식을 작성하는 것은 가장 쉬운 일이 아니며 반영을 포함합니다(자체적으로 빠르지 않음). 그러나 이것은 … (.. AND .. AND ..) OR (.. AND .. AND ..) OR (.. AND .. AND ..) ...
를 많이 사용하여 단일 쿼리를 작성하는 데 도움이 됩니다. 결과는 다음과 같습니다.

이전 접근 방식보다 훨씬 나쁩니다.
옵션 5. 공유 쿼리 데이터 테이블
한 가지 방법을 더 시도해 보겠습니다.
쿼리 데이터를 저장할 새 테이블을 데이터베이스에 추가했습니다. 이제 각 쿼리에 대해 다음을 수행할 수 있습니다.
- 트랜잭션 시작(아직 시작되지 않은 경우)
- 해당 테이블에 쿼리 데이터 업로드(임시)
- 쿼리 수행
- 트랜잭션 롤백 - 업로드된 데이터 삭제
var result = new List<Price>(); using (var context = CreateContext()) { context.Database.BeginTransaction(); var reducedData = TestData.Select(x => new SharedQueryModel() { PriceSourceId = x.PriceSourceId, Ticker = x.Ticker, TradedOn = x.TradedOn }).ToList(); // Here query data is stored to shared table context.QueryDataShared.AddRange(reducedData); context.SaveChanges(); var query = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId join t in context.QueryDataShared on new { s.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; result.AddRange(query); context.Database.RollbackTransaction(); }
먼저 측정항목:
결과는 매우 좋습니다. 매우 빠릅니다. 메모리 소모도 좋습니다. 그러나 단점은 다음과 같습니다.
- 한 가지 유형의 쿼리만 수행하려면 데이터베이스에 추가 테이블을 만들어야 합니다.
- (어쨌든 DBMS 리소스를 소비하는) 트랜잭션을 시작해야 하고,
- 데이터베이스에 무언가를 작성해야 합니다(READ 작업에서!). 기본적으로 읽기 전용 복제본과 같은 것을 사용하는 경우에는 작동하지 않습니다.
그러나 그와 별개로 이 접근 방식은 빠르고 읽기 쉽습니다. 그리고 이 경우 쿼리 계획이 캐시됩니다!
옵션 6. MemoryJoin 확장
여기서는 EntityFrameworkCore.MemoryJoin이라는 NuGet 패키지를 사용하겠습니다. 이름에 Core라는 단어가 들어 있음에도 불구하고 EF 6도 지원합니다. MemoryJoin이라고 하지만 실제로는 지정된 쿼리 데이터를 VALUES로 서버에 보내고 모든 작업은 SQL 서버에서 수행됩니다.
코드를 확인해보자.
var result = new List<Price>(); using (var context = CreateContext()) { // better to select needed properties only, for better performance var reducedData = TestData.Select(x => new { x.Ticker, x.TradedOn, x.PriceSourceId }).ToList(); var queryable = context.FromLocalList(reducedData); var query = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId join t in queryable on new { s.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; result.AddRange(query); }
측정항목:
정말 멋져 보입니다. 이전 접근 방식보다 3배 더 빠르기 때문에 가장 빠릅니다. 64K 기록의 경우 3.5초! 코드는 간단하고 이해하기 쉽습니다. 이것은 읽기 전용 복제본에서 작동합니다. 세 가지 요소에 대해 생성된 쿼리를 확인해 보겠습니다.
SELECT "p"."PriceId", "p"."ClosePrice", "p"."OpenPrice", "p"."PriceSourceId", "p"."SecurityId", "p"."TradedOn", "t"."Ticker", "t"."TradedOn", "t"."PriceSourceId" FROM "Price" AS "p" INNER JOIN "Security" AS "s" ON "p"."SecurityId" = "s"."SecurityId" INNER JOIN ( SELECT "x"."string1" AS "Ticker", "x"."date1" AS "TradedOn", CAST("x"."long1" AS int4) AS "PriceSourceId" FROM ( SELECT * FROM ( VALUES (1, @__gen_q_p0, @__gen_q_p1, @__gen_q_p2), (2, @__gen_q_p3, @__gen_q_p4, @__gen_q_p5), (3, @__gen_q_p6, @__gen_q_p7, @__gen_q_p8) ) AS __gen_query_data__ (id, string1, date1, long1) ) AS "x" ) AS "t" ON (("s"."Ticker" = "t"."Ticker") AND ("p"."PriceSourceId" = "t"."PriceSourceId")
보시다시피, 이번에는 실제 값이 VALUES 구성에서 메모리에서 SQL 서버로 전달됩니다. 그리고 이것은 트릭을 수행합니다. SQL 서버는 빠른 조인 작업을 수행하고 인덱스를 올바르게 사용합니다.
그러나 몇 가지 단점이 있습니다(내 블로그에서 자세히 읽을 수 있음).
- 모델에 추가 DbSet을 추가해야 합니다(그러나 DB에 만들 필요는 없음).
- 확장은 3개의 문자열 속성, 3개의 날짜 속성, 3개의 가이드 속성, 3개의 부동/이중 속성 및 3개의 int/byte/long/decimal 속성과 같은 많은 속성이 있는 모델 클래스를 지원하지 않습니다. 이것은 90%의 경우에 충분하다고 생각합니다. 그러나 그렇지 않은 경우 사용자 지정 클래스를 만들어 사용할 수 있습니다. 따라서 힌트: 쿼리에서 실제 값을 전달해야 합니다. 그렇지 않으면 리소스가 낭비됩니다.
결론
여기에서 테스트한 것 중 저는 확실히 MemoryJoin을 선택했습니다. 다른 누군가는 극복할 수 없는 단점이 있다고 이의를 제기할 수 있으며 현재 모든 문제를 해결할 수 있는 것은 아니므로 확장 사용을 자제해야 합니다. 글쎄, 나에게 그것은 당신이 자신을 베일 수 있기 때문에 칼을 사용해서는 안된다고 말하는 것과 같습니다. 최적화는 주니어 개발자가 아니라 EF의 작동 방식을 이해하는 사람을 위한 작업이었습니다. 이를 위해 이 도구는 성능을 극적으로 향상시킬 수 있습니다. 누가 알아? 언젠가 Microsoft의 누군가가 동적 VALUES에 대한 핵심 지원을 추가할 것입니다.
마지막으로 결과를 비교할 몇 가지 다이어그램이 더 있습니다.
다음은 작업을 수행하는 데 걸리는 시간을 나타내는 다이어그램입니다. MemoryJoin은 합리적인 시간에 작업을 수행하는 유일한 것입니다. 큰 볼륨을 처리할 수 있는 접근 방식은 4가지(순진한 구현 2개, 공유 테이블 및 MemoryJoin)뿐입니다.
다음 다이어그램은 메모리 소비에 대한 것입니다. 모든 접근 방식은 여러 Contains
가 있는 경우를 제외하고 거의 동일한 숫자를 보여줍니다. 이 현상은 위에서 설명했습니다.