Kokeilin tänä viikonloppuna eräässä harrasteprojektissa Cassandraa. Cassandra on Facebookin kehittämä skaalautuva NoSQL-tietokanta. Se perustuu melko yksinkertaiseen key-value-tietomalliin, joka on kuitenkin hiukan hankalampi hahmottaa kuin esimerkiksi CouchDB:n dokumenttimalli.

Rivit ja avaimet

Cassandrassa jokaisella tallennetulla rivillä on avain. Tavallisesti avain on joko UUID ('e8b57734-86c9-48f5-bbf7-fe95085f90e11') tai sitten jokin muu yksilöivä merkkijono, kuten käyttäjätunnus tai sähköpostiosoite. Kun tietokantapalvelimia on useita, Cassandra hajauttaa rivit eri palvelimille tämän avaimen mukaan.

Riviavaimilla voi tehdä ainoastaan suoria hakuja noutaakseen yhden tietyn rivin tietokannasta. Cassandran Thrift-APIssa tämä on get-haku. Alempana näkyvästä esimerkkikannasta voisi etsiä minun käyttäjätietoni haulla get("Users", "e8b57734-86c9-48f5-bbf7-fe95085f90e1").

Sarakkeet

Jokainen rivi koostuu joukosta sarakkeita (column). SQL:stä poiketen sarakkeita ei määritellä etukäteen, vaan sovellus voi luoda niitä dynaamisesti niin paljon kuin haluaa. Niillä on kuitenkin erikoinen rajoitus. Sarakeperheessä (column family) määritellään, minkä tyyppisiä sarakkeiden nimet ovat. Yksinkertaisessa taulurakenteessa nimet voivat olla tyyppiä BytesType tai UTF8Type, jolloin niitä käsitellään merkkijonoina. Muita vaihtoehtoja ovat LongType, joka käsittelee nimiä numeroina, sekä TimeUUIDType, joka taas olettaa nimien olevan aikaleiman sisältäviä UUID:itä.

Miksi sarakkeiden nimien muoto on tärkeää? Siksi, että Cassandra järjestelee sarakkeet aina nimen mukaan järjestykseen. Kun tietokannasta noudetaan rivi, Cassandra palauttaa siihen kuuluvat sarakkeet tässä järjestyksessä. Tällä ei olisi merkitystä, jos sarakkeita olisi perinteisten SQL-taulujen tapaan vain muutama. Mutta Cassandrassa sarakkeita voi yhtä hyvin olla miljoona tai miljardi, mikä avaa uuden ulottuvuuden tietorakenteisiin.

Sarakkeilla on nimen lisäksi arvo. Arvon muotoa ei ole määritelty mitenkään, vaan se on täysin sovelluksen valittavissa oleva "blobikenttä".

Esimerkkitietokanta

Yksinkertainen käyttäjätietokanta voisi olla tällainen:

{
    "Users": {
        "e8b57734-86c9-48f5-bbf7-fe95085f90e1": {
            "login": "kennu",
            "email": "kennu@example.com.invalid",
            "realname": "Kenneth Falck",
        },
    },
    "UserLogins": {
        "kennu": {
            "uuid": "e8b57734-86c9-48f5-bbf7-fe95085f90e1"
        },
    },
}

Yllä "Users" on sarakeperhe (column family), "e8b57734-86c9-48f5-bbf7-fe95085f90e1" on yhden sen sisältämän rivin avain, ja itse rivi muodostuu kolmesta sarakkeesta, joiden nimet ovat "login", "email" ja "realname". Tämän lisäksi on erillinen sarakeperhe "UserLogins", joka mäppää käyttäjätunnukset UUID:eihin. Ilman tätä relaatiota kannasta ei voisi etsiä käyttäjiä heidän käyttäjätunnustensa perusteella. Vastaavasti voisi tehdä "UserEmails"-relaation sähköposteille. Näitä relaatioita voi käyttää myös pakottamaan tunnukset ja sähköpostit uniikeiksi.

Relaatioiden rakentaminen supersarakkeilla

Cassandrassa kaikki relaatiot täytyy rakentaa itse suoraan tietomallin osaksi. Käytössä ei ole mitään joineja tai indeksejä, vaan relaatiot pitää muodostaa sarakkeita hyödyntäen. Avuksi voi ottaa myös supersarakkeet (super columns), jotka lisäävät hierarkiaan vielä yhden taso. Yksi supersarake lähinnä sisältää joukon alasarakkeita.

Tässä esimerkissä on joukko kauppoja sekä niiden tuotteita kategorioittain ryhmiteltynä. Käytän UUID:n sijaan avaimia "s1" ja "p1" selkeyden vuoksi. Supersarakkeita on käytetty ShopProducts-sarakeperheessä, jossa on kaksitasoinen hierarkia ensin kaupan ja sitten tuoteryhmän mukaan.

{
    "Shops": {
        "s1": {
            "name": "Levykauppa",
            "url": "http://www.example.org/levykauppa"
        },
        "s2": {
            "name": "Karkkikauppa",
            "url": "http://www.example.org/karkkikauppa"
        },
    },
    "Products": {
        "p1": { "name": "Rokkilevy", "price": "20" },
        "p2": { "name": "Teknolevy", "price": "20" },
        "p3": { "name": "Toinen teknolevy", "price": "30" },
    },
    "ShopProducts": {
        "s1": {
            "rock": {
                "p1": "p1"
            },
            "techno": {
                "p2": "p2",
                "p3": "p3",
            },
            "__all__": {
                "p1": "p1",
                "p2": "p2",
                "p3": "p3",
            },
        },
        "__all__": {
            "__all__": {
                "p1": "p1",
                "p2": "p2",
                "p3": "p3",
        },
    },
}

Yllä määritelty malli mahdollistaa jo melko monipuoliset haut tuotetietokantaan. Jokaisen yksittäisen tuotteen voi aina hakea Products-sarakeperheestä suoraan sen UUID:llä. Kaupoittain ja kauppojen alla tuoteryhmittäin taas tuotteita voi hakea ShopProducts-sarakeperheen kautta.

ShopProductsin alla on myös erikoinen riviavain "all", jonka alta löytyvät kaikkien kauppojen tuotteet yhdisteltynä. Vastaavasti kunkin kaupan alla on tuoteryhmä "all", josta taas löyvät kaikkien ryhmien tuotteet yhdisteltynä. Idea vastaa sitä, että SQL:n SELECTissä jätettäisiin WHERE shop_id tai WHERE category_id -ehto pois.

Tuotteilla on myös järjestys. Oletetaan, että "p1", "p2" jne. ovat TimeUUID-tyyppisiä, eli uniikkeja avaimia, jotka sisältävät päivämäärän ja kellonajan. Tämä olisi määritelty ShopProducts-sarakeperheessä optiolla CompareSubcolumnsWith="TimeUUIDType". Kun kaupasta haetaan jonkin tuoteryhmän tuotteet esimerkiksi kutsulla get("ShopProducts", "s1", "techno"), vastauksena tulee joukko tuote-uuid:itä päivämääräjärjestyksessä. Varsinaiset tuotetiedot haetaan sitten toisella kutsulla multi_get("Products", ["p1", "p2", "p3", ...]).

Järjestely hinnan mukaan

Edellä rakennettu kauppajärjestelmä palauttaa tuotteet päivämäärän mukaan järjesteltynä. Mitä jos niitä haluaisi selailla myös hinnan mukaan? Cassandrassa ei voi järjestellä kyselyitä minkään arvokentän mukaan, koska se ei itse asiassa ymmärrä yhtään mitään tiedon sisällöstä. Kysely pitää "rakentaa" etukäteen sopivalla tietorakenteella esimerkiksi näin:

{
    "ShopProductsByPrice": {
        "s1": {
            "20": "p1",
            "20": "p2",
            "30": "p3",
        },
    }
}

Tällainen sarakeperhe määriteltäisiin CompareWith="LongType" -tyyppiseksi, jolloin Cassandra tietää, että "20" ja "30" ovat numeroita ja järjestelee sarakkeet numeeriseen järjestykseen. Erityisen mielenkiintoista on se, että sarakkeiden nimien ei tarvitse olla uniikkeja. Siksi niissä voi käyttää mitä tahansa lukuarvoja, kuten tuotteiden hintoja, blogien kommenttimääriä jne.

Sovelluksen on itse ylläpidettävä näitä tietorakenteita, kun tuotteita lisätään tai poistetaan. Eli kun uusi tuote lisätään Products-sarakeperheeseen, se pitää aina lisätä myös ShopProducts- ja ShopProductsByPrice-sarakeperheisiin.

Yhteenvetoa

Cassandran tietomalli on haastava oppia, mutta se mahdollistaa toisaalta melko rajattoman skaalautuvuuden uusia tietokantapalvelimia lisäämällä. Yhdelläkin palvelimella tietokanta on aina optimaalisen tehokas, koska jokaisen kyselyn vastaukset on tallennettu siihen etukäteen.

Ehkä suurimpana haasteena näkisin sen, että sovelluksen vastuulle jää pitää tietokanta konsistenttina. Käytössä ei ole mitään SQL:stä tuttuja apuväleintä, kuten foreign keytä, joten sarakeperheisiin saattaa jäädä lojumaan orpoja rivejä. Onneksi Cassandra tukee kuitenkin transaktioita tietyllä tasolla batch-operaatioina sekä tietokantaklusterin konsistenssitasoina (zero, one, quorum, all).