Wydajność NHibernate przy dużych ilościach danych

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

  1. 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ąć.
  2. 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.