Menyelam Jauh ke dalam Kinerja Kerangka Entitas Saat Menggunakan "Berisi"

Diterbitkan: 2022-03-11

Selama pekerjaan saya sehari-hari, saya menggunakan Entity Framework. Ini sangat nyaman, tetapi dalam beberapa kasus, kinerjanya lambat. Meskipun ada banyak artikel bagus tentang peningkatan kinerja EF, dan beberapa saran yang sangat bagus dan berguna diberikan (misalnya, hindari kueri yang rumit, parameter dalam Lewati dan Ambil, gunakan tampilan, pilih bidang yang diperlukan saja, dll.), tidak banyak yang bisa benar-benar dilakukan saat Anda perlu menggunakan kompleks Contains pada dua bidang atau lebih—dengan kata lain, saat Anda menggabungkan data ke daftar memori .

Masalah

Mari kita periksa contoh berikut:

 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();

Kode di atas tidak berfungsi sama sekali di EF 6, dan meskipun berfungsi di EF Core, penggabungan sebenarnya dilakukan secara lokal—karena saya memiliki sepuluh juta catatan di database saya, semuanya diunduh dan semua memori digunakan . Ini bukan bug di EF. Hal ini diharapkan. Namun, bukankah akan luar biasa jika ada sesuatu untuk menyelesaikan ini? Dalam artikel ini, saya akan melakukan beberapa eksperimen dengan pendekatan berbeda untuk mengatasi kemacetan kinerja ini.

Larutan

Saya akan mencoba berbagai cara untuk mencapai ini mulai dari yang paling sederhana hingga yang lebih maju. Pada setiap langkah, saya akan memberikan kode dan metrik, seperti waktu yang dibutuhkan dan penggunaan memori. Perhatikan bahwa saya akan menghentikan program pembandingan jika bekerja lebih dari sepuluh menit.

Kode untuk program benchmarking terletak di repositori berikut. Ini menggunakan C#, .NET Core, EF Core, dan PostgreSQL. Saya menggunakan mesin dengan Intel Core i5, RAM 8 GB, dan SSD.

Skema DB untuk pengujian terlihat seperti ini:

Tabel dalam database: harga, sekuritas, dan sumber harga

Hanya tiga tabel: harga, sekuritas, dan sumber harga. Tabel harga memiliki puluhan juta catatan.

Opsi 1. Sederhana dan Naif

Mari kita coba sesuatu yang sederhana, hanya untuk memulai.

 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)); } }

Algoritmenya sederhana: Untuk setiap elemen dalam data uji, temukan elemen yang tepat dalam database dan tambahkan ke kumpulan hasil. Kode ini hanya memiliki satu keuntungan: Sangat mudah untuk diimplementasikan. Juga, itu dapat dibaca dan dipelihara. Kelemahannya yang jelas adalah bahwa ini adalah yang paling lambat. Meskipun ketiga kolom diindeks, overhead komunikasi jaringan masih menciptakan hambatan kinerja. Berikut metriknya:

Hasil percobaan pertama

Jadi, untuk volume yang besar, dibutuhkan waktu kurang lebih satu menit. Konsumsi memori tampaknya masuk akal.

Opsi 2. Naif dengan paralel

Sekarang mari kita coba menambahkan paralelisme ke kode. Ide inti di sini adalah bahwa memukul database di utas paralel dapat meningkatkan kinerja secara keseluruhan.

 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); } } } });

Sangat menarik bahwa, untuk kumpulan data pengujian yang lebih kecil, pendekatan ini bekerja lebih lambat daripada solusi pertama, tetapi untuk sampel yang lebih besar, ini lebih cepat (sekitar 2 kali dalam hal ini). Konsumsi memori sedikit berubah, tetapi tidak signifikan.

Hasil percobaan kedua

Opsi 3. Beberapa Berisi

Mari kita coba pendekatan lain:

  • Siapkan 3 kumpulan nilai unik Ticker, PriceSourceId, dan Date.
  • Lakukan kueri dengan pemfilteran satu kali dengan menggunakan 3 Berisi.
  • Periksa kembali secara lokal (lihat di bawah).
 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)); } }

Pendekatan ini bermasalah. Waktu eksekusi sangat bergantung pada data. Ini mungkin mengambil hanya catatan yang diperlukan (dalam hal ini akan sangat cepat), tetapi mungkin mengembalikan lebih banyak (bahkan mungkin 100 kali lebih banyak).

Mari kita perhatikan data pengujian berikut:

Data tanggapan

Di sini saya menanyakan harga untuk Ticker1 yang ditradingkan pada 01-01 2018 dan untuk Ticker2 yang ditradingkan pada 02-01-2018. Namun, empat catatan sebenarnya akan dikembalikan.

Nilai unik untuk Ticker adalah Ticker1 dan Ticker2 . Nilai unik untuk TradedOn adalah 2018-01-01 dan 2018-01-02 .

Jadi, empat record cocok dengan ekspresi ini.

Itulah mengapa pemeriksaan ulang lokal diperlukan dan mengapa pendekatan ini berbahaya. Metriknya adalah sebagai berikut:

Hasil percobaan ketiga

Konsumsi memori yang buruk! Tes dengan volume besar gagal karena batas waktu 10 menit.

Opsi 4. Pembuat Predikat

Mari kita ubah paradigma: Mari kita buat Expression lama yang bagus untuk setiap kumpulan data pengujian.

 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); }

Kode yang dihasilkan cukup kompleks. Membangun ekspresi bukanlah hal yang paling mudah dan melibatkan refleksi (yang, dengan sendirinya, tidak secepat itu). Tapi itu membantu kita untuk membangun satu query menggunakan banyak … (.. AND .. AND ..) OR (.. AND .. AND ..) OR (.. AND .. AND ..) ... . Ini adalah hasilnya:

Hasil percobaan keempat

Bahkan lebih buruk daripada salah satu pendekatan sebelumnya.

Opsi 5. Tabel Data Kueri Bersama

Mari kita coba satu pendekatan lagi:

Saya menambahkan tabel baru ke database yang akan menampung data kueri. Untuk setiap kueri, saya sekarang dapat:

  • Mulai transaksi (jika belum dimulai)
  • Unggah data kueri ke tabel itu (sementara)
  • Lakukan kueri
  • Kembalikan transaksi—untuk menghapus data yang diunggah
 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(); }

Metrik terlebih dahulu:

Hasil percobaan kelima

Hasilnya sangat bagus. Sangat cepat. Konsumsi memori juga baik. Tapi kekurangannya adalah:

  • Anda harus membuat tabel tambahan di database untuk melakukan hanya satu jenis kueri,
  • Anda harus memulai transaksi (yang menggunakan sumber daya DBMS), dan
  • Anda harus menulis sesuatu ke database (dalam operasi READ!)—dan pada dasarnya, ini tidak akan berfungsi jika Anda menggunakan sesuatu seperti replika baca.

Namun selain itu, pendekatan ini bagus—cepat dan mudah dibaca. Dan rencana kueri di-cache dalam kasus ini!

Opsi 6. MemoryJoin Extension

Di sini saya akan menggunakan paket NuGet bernama EntityFrameworkCore.MemoryJoin. Terlepas dari kenyataan bahwa namanya memiliki kata Core di dalamnya, ia juga mendukung EF 6. Ini disebut MemoryJoin, tetapi pada kenyataannya, ia mengirimkan data kueri yang ditentukan sebagai NILAI ke server dan semua pekerjaan dilakukan di server SQL.

Mari kita periksa kodenya.

 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); }

Metrik:

Hasil percobaan akhir

Ini terlihat luar biasa. Tiga kali lebih cepat dari pendekatan sebelumnya—yang menjadikannya yang tercepat. 3,5 detik untuk rekor 64K! Kodenya sederhana dan mudah dimengerti. Ini berfungsi dengan replika hanya-baca. Mari kita periksa kueri yang dihasilkan untuk tiga elemen:

 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")

Seperti yang Anda lihat, kali ini nilai aktual diteruskan dari memori ke server SQL dalam konstruksi VALUES. Dan ini berhasil: Server SQL berhasil melakukan operasi join cepat dan menggunakan indeks dengan benar.

Namun, ada beberapa kelemahan (Anda dapat membaca lebih lanjut di blog saya):

  • Anda perlu menambahkan DbSet ekstra ke model Anda (namun tidak perlu membuatnya di DB)
  • Ekstensi tidak mendukung kelas model dengan banyak properti: tiga properti string, tiga properti tanggal, tiga properti panduan, tiga properti float/double, dan tiga properti int/byte/long/desimal. Ini lebih dari cukup dalam 90% kasus, saya kira. Namun, jika tidak, Anda dapat membuat kelas khusus dan menggunakannya. Jadi, PETUNJUK: Anda harus meneruskan nilai aktual dalam kueri, jika tidak, sumber daya akan terbuang sia-sia.

Kesimpulan

Di antara hal-hal yang telah saya uji di sini, saya pasti akan memilih MemoryJoin. Orang lain mungkin keberatan bahwa kekurangannya tidak dapat diatasi, dan karena tidak semuanya dapat diselesaikan saat ini, kita harus menghindari penggunaan ekstensi. Nah, bagi saya, itu seperti mengatakan bahwa Anda tidak boleh menggunakan pisau karena Anda bisa melukai diri sendiri. Pengoptimalan adalah tugas bukan untuk pengembang junior tetapi untuk seseorang yang memahami cara kerja EF. Untuk itu, alat ini dapat meningkatkan kinerja secara dramatis. Siapa tahu? Mungkin suatu hari, seseorang di Microsoft akan menambahkan beberapa dukungan inti untuk NILAI dinamis.

Akhirnya, Berikut adalah beberapa diagram untuk membandingkan hasil.

Di bawah ini adalah diagram waktu yang dibutuhkan untuk melakukan suatu operasi. MemoryJoin adalah satu-satunya yang melakukan pekerjaan dalam waktu yang wajar. Hanya empat pendekatan yang dapat memproses volume besar: dua implementasi naif, tabel bersama, dan MemoryJoin.

Waktu yang dibutuhkan dalam berbagai kasus untuk setiap percobaan

Diagram berikutnya adalah untuk konsumsi memori. Semua pendekatan menunjukkan angka yang kurang lebih sama kecuali yang memiliki banyak Contains . Fenomena ini telah dijelaskan di atas.

Konsumsi memori dalam berbagai kasus untuk setiap eksperimen