เจาะลึกประสิทธิภาพของ Entity Framework เมื่อใช้ "Contains"
เผยแพร่แล้ว: 2022-03-11ระหว่างทำงานทุกวัน ฉันใช้ Entity Framework สะดวกมาก แต่ในบางกรณีประสิทธิภาพอาจช้า แม้จะมีบทความดีๆ มากมายเกี่ยวกับการปรับปรุงประสิทธิภาพของ EF และมีการให้คำแนะนำที่ดีและมีประโยชน์ (เช่น หลีกเลี่ยงคำถามที่ซับซ้อน พารามิเตอร์ใน Skip and Take ใช้มุมมอง เลือกฟิลด์ที่จำเป็นเท่านั้น ฯลฯ) ทำได้ไม่มากนัก ทำได้จริงเมื่อคุณต้องการใช้ 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 แต่การเข้าร่วมนั้นทำได้จริงในเครื่อง เนื่องจากฉันมีระเบียน 10 ล้านรายการในฐานข้อมูล ทั้งหมด จึงถูกดาวน์โหลดและใช้หน่วยความจำหมด . นี่ไม่ใช่ข้อบกพร่องใน EF เป็นที่คาดหวัง อย่างไรก็ตาม มันจะวิเศษมากไหมถ้ามีบางอย่างที่จะแก้ปัญหานี้ ในบทความนี้ ฉันจะทำการทดลองด้วยวิธีอื่นเพื่อแก้ไขปัญหาคอขวดด้านประสิทธิภาพ
สารละลาย
ฉันจะลองวิธีต่างๆ เพื่อให้บรรลุสิ่งนี้โดยเริ่มจากง่ายที่สุดไปจนถึงขั้นสูงกว่า ในแต่ละขั้นตอน ฉันจะให้รหัสและตัววัด เช่น เวลาที่ใช้และการใช้หน่วยความจำ โปรดทราบว่าฉันจะขัดจังหวะการทำงานของโปรแกรมการเปรียบเทียบหากใช้งานได้นานกว่าสิบนาที
รหัสสำหรับโปรแกรมการเปรียบเทียบอยู่ในที่เก็บต่อไปนี้ ใช้ C#, .NET Core, EF Core และ PostgreSQL ฉันใช้เครื่องที่มี Intel Core i5, RAM 8 GB และ SSD
DB schema สำหรับการทดสอบมีลักษณะดังนี้:
ตัวเลือกที่ 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)); } }
อัลกอริทึมนั้นเรียบง่าย: สำหรับแต่ละองค์ประกอบในข้อมูลการทดสอบ ให้ค้นหาองค์ประกอบที่เหมาะสมในฐานข้อมูลและเพิ่มลงในคอลเลกชันผลลัพธ์ รหัสนี้มีข้อดีเพียงข้อเดียว: ใช้งานได้ง่ายมาก นอกจากนี้ยังสามารถอ่านและบำรุงรักษาได้ ข้อเสียที่เห็นได้ชัดคือมันช้าที่สุด แม้ว่าทั้งสามคอลัมน์จะได้รับการจัดทำดัชนี แต่โอเวอร์เฮดของการสื่อสารเครือข่ายยังคงสร้างปัญหาคอขวดด้านประสิทธิภาพ นี่คือตัวชี้วัด:
ดังนั้นสำหรับปริมาณมาก จะใช้เวลาประมาณหนึ่งนาที การใช้หน่วยความจำดูเหมือนจะสมเหตุสมผล
ตัวเลือกที่ 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 ประกอบด้วยหลายรายการ
ลองใช้แนวทางอื่น:
- เตรียม 3 ชุดค่าที่ไม่ซ้ำกันของ Ticker, PriceSourceId และ Date
- ดำเนินการค้นหาด้วยการกรองครั้งเดียวโดยใช้ 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 เท่า)
ลองพิจารณาข้อมูลการทดสอบต่อไปนี้:
ที่นี่ฉันสอบถามราคาสำหรับ Ticker1 ที่ซื้อขายในวันที่ 2018-01-01 และสำหรับ Ticker2 ที่ซื้อขายในวันที่ 2018-01-02 อย่างไรก็ตาม สี่ระเบียนจะถูกส่งคืนจริง
ค่าที่ไม่ซ้ำกันสำหรับ Ticker
คือ Ticker1
และ Ticker2
ค่าที่ไม่ซ้ำกันสำหรับ TradedOn
คือ 2018-01-01
และ 2018-01-02
ดังนั้น สี่ระเบียนจึงตรงกับนิพจน์นี้
นั่นเป็นสาเหตุที่จำเป็นต้องมีการตรวจสอบซ้ำในพื้นที่และเหตุใดวิธีการนี้จึงเป็นอันตราย ตัวชี้วัดมีดังนี้:
การบริโภคหน่วยความจำแย่มาก! การทดสอบที่มีปริมาณมากล้มเหลวเนื่องจากการหมดเวลา 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
ที่นี่ฉันจะใช้แพ็คเกจ NuGet ชื่อ EntityFrameworkCore.MemoryJoin แม้ว่าชื่อจะมีคำว่า 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.5 วินาทีสำหรับบันทึก 64K! รหัสนั้นง่ายและเข้าใจได้ ใช้งานได้กับแบบจำลองแบบอ่านอย่างเดียว มาตรวจสอบการสืบค้นที่สร้างขึ้นสำหรับสามองค์ประกอบ:
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")
อย่างที่คุณเห็น ค่าจริงของเวลานี้จะถูกส่งผ่านจากหน่วยความจำไปยังเซิร์ฟเวอร์ SQL ในรูปแบบ VALUES และนี่คือเคล็ดลับ: เซิร์ฟเวอร์ SQL สามารถดำเนินการเข้าร่วมอย่างรวดเร็วและใช้ดัชนีได้อย่างถูกต้อง
อย่างไรก็ตาม มีข้อเสียอยู่บ้าง (คุณสามารถอ่านเพิ่มเติมในบล็อกของฉัน):
- คุณต้องเพิ่ม DbSet พิเศษให้กับโมเดลของคุณ (แต่ไม่จำเป็นต้องสร้างใน DB)
- ส่วนขยายไม่รองรับคลาสโมเดลที่มีคุณสมบัติหลายอย่าง: คุณสมบัติสตริงสามรายการ คุณสมบัติวันที่สามคุณสมบัติไกด์สามคุณสมบัติ คุณสมบัติทศนิยมสามรายการและคุณสมบัติ int/byte/long/decimal สามรายการ มันมากเกินพอแล้วใน 90% ของคดี ฉันเดา แต่ถ้าไม่ใช่ คุณสามารถสร้างคลาสที่กำหนดเองและใช้งานได้ ดังนั้น คำแนะนำ: คุณต้องส่งค่าจริงในแบบสอบถาม มิฉะนั้น ทรัพยากรจะสูญเปล่า
บทสรุป
ในบรรดาสิ่งที่ฉันได้ทดสอบที่นี่ ฉันจะเลือก MemoryJoin อย่างแน่นอน อาจมีคนอื่นคัดค้านว่าข้อเสียเปรียบนั้นผ่านไม่ได้ และเนื่องจากยังไม่สามารถแก้ไขได้ทั้งหมดในขณะนี้ เราจึงควรงดเว้นจากการใช้ส่วนขยาย สำหรับฉัน มันเหมือนกับว่าคุณไม่ควรใช้มีด เพราะคุณสามารถกรีดตัวเองได้ การเพิ่มประสิทธิภาพไม่ใช่งานสำหรับนักพัฒนารุ่นเยาว์ แต่สำหรับคนที่เข้าใจวิธีการทำงานของ EF ด้วยเหตุนี้ เครื่องมือนี้สามารถปรับปรุงประสิทธิภาพได้อย่างมาก ใครจะรู้? บางทีสักวันหนึ่ง คนที่ Microsoft จะเพิ่มการสนับสนุนหลักสำหรับ VALUES แบบไดนามิก
สุดท้าย ต่อไปนี้เป็นไดอะแกรมเพิ่มเติมบางส่วนเพื่อเปรียบเทียบผลลัพธ์
ด้านล่างนี้คือไดอะแกรมสำหรับเวลาที่ใช้ในการดำเนินการ MemoryJoin เป็นเครื่องเดียวที่ทำงานในเวลาที่เหมาะสม มีเพียงสี่วิธีเท่านั้นที่สามารถประมวลผลปริมาณมาก: การใช้งานที่ไร้เดียงสาสองรายการ ตารางที่ใช้ร่วมกัน และ MemoryJoin
แผนภาพต่อไปคือการใช้หน่วยความจำ วิธีการทั้งหมดแสดงให้เห็นตัวเลขที่เหมือนกันมากหรือน้อย ยกเว้นอันที่มีหลายรายการ Contains
ปรากฏการณ์นี้อธิบายไว้ข้างต้น