dinsdag 9 april 2013

Entity Framework met een Sharded Database

Wanneer we met enorme databases werken, lopen we onherroepelijk tegen problemen aan. Deze problemen zijn meestal hardware gerelateerd. Nu is dat niks nieuws en er bestaan heel wat oplossingen met allemaal hun voor- en nadelen. Tegenwoordig is het sharden van de database een populaire benadering. Dit houdt in dat je grote tabellen in stukken hakt (horizontale partitionering, dwz op rij gesplitst) en die stukken vervolgens op verschillende databaseservers plaatst. Dit brengt natuurlijk nieuwe problemen met zich mee (of uitdagingen, zoals managers dat noemen). Denk bijvoorbeeld aan een join over twee tabellen en die tabellen staan nu net in verschillende database-instanties. Die uitdaging los je dan weer op door ervoor te zorgen dat gerelateerde data binnen dezelfde database-instantie komen te staan. Om de data te kunnen splitsen, moet je een bepaald criterium bedenken waarop je wilt splitsen. Je kunt denken aan beginletters, bijvoorbeeld namen die met een "A" beginnen in shard A en namen die met een "B" beginnen in shard B enz. In ieder geval moet je een veld uit de te sharden tabel kiezen op basis waarvan gesplits kan worden en een criterium, dat bepaalt naar welke shard de data weggeschreven moet worden of uitgelezen. Zo'n veldkeuze en een criterium slaat men dan op in een shard key. Een goede shard key definiëren is niet eenvoudig en moet zorgvuldig overwogen worden.

In Windows Azure SQL Database is er al een voorziening voor sharding aanwezig. Ze noemen het daar alleen federation. Een federation in Windows Azure SQL Database bestaat uit een root federation en een aantal federation members. Die root federation is het centrale aanspreekpunt van de federation en bevat informatie over de federation key (shard key, distributed key), welke federation members (shards) er zijn en wat de status van die members is. De federation key beschrijft hoe de database gesplitst moet worden over de members. Daarvoor is een unieke identiteit nodig die in de te splitsen tabel als veld opgenomen moet zijn. Vaak zul je die identiteit als primary key en/of foreign key inzetten. Hij moet in ieder geval uniek zijn over alle federation members. De keuze voor de federation key is geen eenvoudige. Je zult goed moeten onderzoeken welke tabellen groot gaan worden en hoe je gerelateerde data wilt ophalen (joins over members is niet mogelijk). Uiteindelijk geef je bij de creatie van de tabel op of die gespitst moet worden met het statement FEDERATED ON (FederationKey = TabelKolom). Alle tabellen die dit statement niet hebben, worden als referentietabel aangemerkt en zullen met een splitsing in zijn geheel worden meegenomen op alle members. Dat maakt joins over die tabellen in ieder geval eenvoudiger, maar de keerzijde is dat die referentietabellen niet gerepliceerd worden, dus als je op één member een nieuw record aanmaakt, zal die  niet automatisch op de andere members meekomen. En dat zuigt behoorlijk.

Over hoe je zo'n federation op zet is al een blog geschreven. In dit artikel gaan we bekijken hoe je met Entity Framework (6 alpha release 3) tegen zo'n federation praat. In dit artikel gebruik ik een federation (Product) met "demo" als root federation en daaronder vier federation members (afbeelding 1).


Afbeelding 1. Vier federation member met ranges van 0 - 99999, 100000 - 199999, 200000 - 299999 en 300000 - 399999

Als datamodel heb ik drie tabellen (Brand, Product en ProductGroup) en een link tabel (ProductGroupProduct). Om die in Entity Framework te laden, moet je naar een specifiek federation member gaan (afbeelding 2)


Afbeelding 2. Het connectievenster met servernaam en een lijst van databases. Demo is de federation root en die vier andere "system" met die enorme vloek erachter zijn de federation members.
Als je het goed hebt gedaan, zie je de bekende Entity Data Model Wizard (afbeelding 3) van waaruit je selecteert welke tabellen je in jouw entity model wilt hebben.


Afbeelding 3. Entity Data Model Wizard.
In dit voorbeeld kies ik alle tabellen hetgeen leidt tot afbeelding 4.


Afbeelding 4. Het resultaat van het betere klikwerk.
Nu moet je niet vergeten de connection string aan te passen, zodat die verwijst naar de root federation. In dit voorbeeld zet ik dus de Initial Catalog naar "demo". Verder haal het stuk "MultipleActiveResultSets=True" eruit. Federation ondersteunt geen MARS (de mogelijkheid om meerdere datareaders open te hebben). Wanneer we data uit de database willen trekken, zullen we eerst moeten specificeren in welke federation member die data staat. Dit doe je met een speciaal sql-statement:


USE FEDERATION [Product] ([ProductKey] = val) WITH FILTERING = OFF, RESET
GO


Waarbij Product de naam van de federation is, ProductKey de federation key en val een specifieke waarde in de range van een member (als de waarde 153789 is, zal de tweede federation member worden aangesproken omdat die waarde in zijn range valt. Zie afbeelding 1). Voordat we een query kunnen afvuren, zullen we dus eerst dit statement moeten uitvoeren. Wellicht is het dan handig om die key waarde mee te geven als argument aan de constructor van de object context (gegenereerde class in Entity Framework, die in dit artikel "Entities" heet). Hiervoor maken we een partial class Entities aan.

public partial class Entities
{
    public void ChangeFederation(int keyValue, bool filtering = false)
    {
        DbConnection con = this.Database.Connection;
        if (con.State != System.Data.ConnectionState.Open)
        {
            con.Open();
        }
        DbCommand com = con.CreateCommand();
        com.CommandText = string.Format("USE FEDERATION Product(ProductKey = {0}) WITH RESET, FILTERING = {1}", keyValue, filtering ? "ON" : "OFF");
        com.ExecuteNonQuery();
    }

    public Entities(int keyValue) : this()
    {
        ChangeFederation(keyValue);
    }
}

De methode ChangeFederation zal op basis van een gegeven key het Federation statement uitvoeren. Deze methode wordt in de constructor aangeroepen. Hieronder zie je hoe je met deze aangepaste context omgaat. Het resultaat is in afbeelding 5 weergegeven.



static void Main(string[] args)
{
    Entities context = new Entities(0);
    var query = context.Products.Include(p => p.Brand).Take(10);
    foreach (Product product in query)
    {
        Console.WriteLine("{0, -8} {1, -10} {2}", product.ID, product.Brand.Name, product.Name);
    }
    Console.WriteLine("-------------------------------------");

    context = new Entities(100000);
    query = context.Products.Include(p => p.Brand).Take(10);
    foreach (Product product in query)
    {
        Console.WriteLine("{0, -8} {1, -10} {2}", product.ID, product.Brand.Name, product.Name);
    }
    Console.WriteLine("-------------------------------------");

    context = new Entities(200000);
    query = context.Products.Include(p => p.Brand).Take(10);
    foreach (Product product in query)
    {
        Console.WriteLine("{0, -8} {1, -10} {2}", product.ID, product.Brand.Name, product.Name);
    }
    Console.WriteLine("-------------------------------------");

    context = new Entities(300000);
    query = context.Products.Include(p => p.Brand).Take(10);
    foreach (Product product in query)
    {
        Console.WriteLine("{0, -8} {1, -10} {2}", product.ID, product.Brand.Name, product.Name);
    }
    Console.WriteLine("-------------------------------------");

    Console.ReadLine();
}

Afbeelding 5. Het resultaat van de listing hierboven. De getallen in de eerste kolom zijn de waarden van de federation key.
Wanneer je data wilt toevoegen, zul je eerst een nieuwe unieke federation key moeten genereren (identity kolommen zijn in een federation niet bruikbaar). Hiervoor zul je een strategie moeten bedenken hoe dat aan te pakken. Je zou kunnen denken aan een tabel in de federation root, waarin je het laatst uitgegeven ID bijhoudt. Dat zou je ook in de blob storage kunnen doen. Er zijn modules op de markt die dit werk voor jou kunnen doen, zoals SnowMaker of RustFlakes.
Eenmaal een nieuw ID gegenereerd, werkt het eigenlijk op dezelfde manier zoals dat je in Entity Framework gewend bent.

private static void InsertProduct()
{
    int newID = 350000;

    Brand acme = new Brand
    {
        ID = 8000,
        Name = "ACME",
        WebSite = "www.acme.com"
    };

    Product p = new Product
    {
        ID = newID,
        Name = "Explosive device",
        FamilyID = newID,
        IsAvailable = true,
        CreationDate = DateTime.Now
    };

    p.Brand = acme;

    Entities context = new Entities(newID);
    context.Products.Add(p);
    try
    {
        context.SaveChanges();
    }
    catch (Exception e)
    {
        Console.WriteLine(e.Message);
    }
}

Let wel op dat "Brand" een referentietabel is die op alle federation members in zijn geheel aanwezig is. Dat nieuwe merk staat echter alleen op de laatste federation member omdat die daarop is aangemaakt. Hij wordt niet gerepliceerd. Dat zul je zelf moeten doen.

Stel dat ik nu een specifiek product wil zoeken. Hoe pak je dat aan? Er zit dan niets anders op dan in alle federations te grasduinen. Helaas weet je met schaalbare oplossingen nooit precies hoeveel members er zijn. Gelukkig is die informatie wel in de root database aanwezig. Die informatie kun je eruit trekken met het volgende sql-statement:

SELECT * FROM sys.federation_member_distributions

Het levert het  resultaat op in afbeelding 6


Afbeelding 6. Het resultaat van de query over sys.federation_member_distributions
Gewapend met deze informatie kunnen we de partial Entities uitbreiden met de volgende methode:

public List<int> GetFederationMembers()
{
    List<int> lowerRanges = new List<int>();
    DbConnection con = this.Database.Connection;
    if (con.State != System.Data.ConnectionState.Open)
    {
        con.Open();
    }
    DbCommand com = con.CreateCommand();
    com.CommandText = "select range_low from sys.federation_member_distributions";
    DbDataReader reader = com.ExecuteReader();
    while (reader.Read())
    {
        lowerRanges.Add(reader.GetInt32(0));
    }
    reader.Close();
    return lowerRanges;
}

Nu we de minimumwaarden van de federation members hebben, kunnen we door de members itereren en op iedere member de zoekfunctie uitvoeren (bij voorkeur parallel)


public static List<Product> FindGlobalProduct(Expression<Func<Productbool>> filter)
{
    Entities context = new Entities();
    List<int> members = context.GetFederationMembers();
    ConcurrentBag<Product> results = new ConcurrentBag<Product>();
    Parallel.ForEach(members, member =>
    {
        Entities ctx = new Entities(member);
        foreach (Product p in ctx.Products.Include(p => p.Brand).Where(filter))
        {
            results.Add(p);
        }
    });

    return results.ToList();
}

De aanroep van deze functie gaat er dan als volgt uitzien:


private static void TestFindGlobalProduct()
{
    List<Product> prods = FindGlobalProduct(p => p.Name.StartsWith("EOS"));
    foreach (Product p in prods)
    {
        Console.WriteLine("{0, -10} {1}", p.Brand.Name, p.Name);
    }
}

Het resultaat van dit alles is in afbeelding 7 te bewonderen.


Afbeelding 7. Het resultaat van een zoekactie over alle federation members.
Zoals inmiddels duidelijk moge zijn, is het werken met sharded databases geen sinecure. Je zult in de eerste plaats goed moeten opletten welke tabellen je wilt splitsen en hoe je dat gaat doen, welke referentietabellen neem je mee en hoe ga je die tabellen synchroniseren? Wanneer je jouw database eenmaal goed hebt opgezet, heb je wel een hele schaalbare oplossing waarbij de grootte van de tabellen in ieder geval geen bottleneck meer zullen vormen.
Ook Entity Framework is goed te gebruiken op sharded databases, maar je moet wel wat voorzieningen inbouwen om het benaderen van de shards te vereenvoudigen. Helaas vervalt bij sharded databases wel een van de basisprincipes van Entity Framework: Een software-ontwikkelaar moet zich niet bezighouden met database structuren. Dat is voor database beheerders. Bij sharded databases is die kennis voor een ontwikkelaar weer onontbeerlijk.

Geen opmerkingen:

Een reactie posten