Ostatnio w pracy napotkaliśmy się na problem związany z wydajnością NH przy opracowaniu dużej ilości danych (200k+ rekordów). Problem wystąpił przy próbie usunięcia tych rekordów.
Background
Mamy własny Windows Service, który co jakiś czas odpala nasze „joby”, taki sobie customowy task scheduler. Jeden z tasków przepracowuje tabele w której się znajduje prawie 1mln rekordów. Ten task robi dokładnie jedną rzecz – usuwa stare, niepotrzebne nam rekordy.
Rozwiązanie 1
Implementacja tego tasku była bardzo prosta i jak mi się wydałało nie powinna powodować problemów (u mnie działa).
using (var dbSession = NSessionFactory.Instance.Create()) { var queryBuilder = new StringBuilder("from VeryLargeTable l where"); if (dateFrom.HasValue) { queryBuilder.Append(" l.CreationDate >= ?"); } if (dateTo.HasValue) { if (dateFrom.HasValue) { queryBuilder.Append(" and"); } queryBuilder.Append(" l.CreationDate <= ?"); } dbSession.SetBatchSize(4096); if (dateFrom.HasValue && dateTo.HasValue) { dbSession.Delete( queryBuilder.ToString(), new object[] { dateFrom.Value, dateTo.Value }, new IType[] { NHibernateUtil.DateTime, NHibernateUtil.DateTime }); } else if (dateFrom.HasValue) { dbSession.Delete(queryBuilder.ToString(), dateFrom.Value, NHibernateUtil.DateTime); } else if (dateTo.HasValue) { dbSession.Delete(queryBuilder.ToString(), dateTo.Value, NHibernateUtil.DateTime); } }
Po przetestowaniu rozwiązania na środowisku DEV, stwierdziłem że jest OK pod wzgłędem wydajności i można to włączać do wersji na środowisko TEST. Tylko zapomniałem o jednej bardzo ważnej rzeczy, kilka dni przed implementacją tabela ta była wyczyszczono do zera, a więc miała w sobie tylko jakieś 20k rekordów.
No i jedneego cudownego poranku w poniedziałek – task jest odpalany co niedziele – nasz serwer TEST stał, nic nie dalo się zrobic oprócz hard reboot i szukanie przyczyn. Dzięki naszym adminom przyczynę znaleźliśmy bardzo szybko – zużycie pamięci jest na 100% a sam proces zżarł prawie 4Gb RAMu.
Po przejrzeniu się do wykonania tego tasku za pomocą dotMemory oraz dotTrace znalazłem przyczyne. Przyczyna jest banalnie prosta – NHibernate dla każdego rekodru co ma usunąć z tabeli tworzy instację encji z odpowiednio zmapowanymi polami. Co powodowało drastyczny wzrost zużycia pamięci, bardzo częste wywołania GC i nieodpowiadający serwer.
Rozwiązanie 2
Po próbie przyszpieszenia tego zapytania przez założenia indeksów na odpowiednie kolumny, stwierdziłem że akurat w tym tasku możemy zejść niżej w uzyć starego, dobrego ADO.NET.
SqlConnection dbConnection = null; SqlCommand dbCommand = null; try { dbConnection = new SqlConnection(_connectionString); dbConnection.Open(); dbCommand = new SqlCommand( "DELETE FROM [dbo].[VeryLargeTable] WHERE [CreationDate] <= @date", dbConnection) { CommandTimeout = dbConnection.ConnectionTimeout }; dbCommand.Parameters.Add("@date", SqlDbType.DateTime); dbCommand.Parameters[0].Value = DateTime.Today.AddMonths(-3); var affectedRows = dbCommand.ExecuteNonQuery(); Logger.Log( string.Format( "Deleted {0} rows from VeryLargeTable", affectedRows)); } catch (Exception ex) { Logger.Log(ex.Message); throw; } finally { if (dbCommand != null) { dbCommand.Dispose(); } if (dbConnection != null) { dbConnection.Close(); dbConnection.Dispose(); } }
Po uruchomieniu tego kodu od razu zauważyłem przyśpieszenie działania naszej aplikacji. Zużycie pamięci jest duuuużo niższe i usunięcie 200k+ rekordów skończyło się w wymaganym czasie.
TIL
- Dokładniej czytać dokumentację – prawie dwa lata używam NH na co dzień i nie wiedziałem o tym, że ISession.Delete(string) usuwa rekordy zwórcone przez wpisane zapytanie, co w sumie jest logiczne żeby najpierw te rekordy pobrać a potem usunąć.
- Czasem zejście na niższy poziom abstrakcji bardziej się opłaca niż użycie jednego konkretnego narzędzia z wyższej półki abstrakcji.