Wprowadzenie do NoSQL, część II

Autor: Piotr Zieliński

Spis treści:

Wstęp

Poprzednia część artykułu stanowiła teoretyczny wstęp do NoSQL. Zwykle programiści mówiąc bazy NoSQL mają na myśli konkretny typ bazy. Najczęstszy podział NoSQL obejmuje cztery typy baz: opartych na kluczach i wartościach, bazy kolumnowe, grafowe oraz dokumentowe. Istnieją silniki, które potrafią wspierać kilka typów jednocześnie. W tej części artykułu, zostaną opisane różne bazy wraz z przykładowymi implementacjami.

Key-Values

Najprostszym typem baz NoSQL są po prostu pary kluczy i wartości (key-values). W poprzedniej części artykułu została już pokazana przykładowa baza oparta na tej budowie. Na pierwszy rzut oka, przypominają one bazy relacyjne, ponieważ również zawierają tabele, a klucze można interpretować, jako kolumny:

RowID: 1FirstName:PiotrLastName:ZielinskiEmail:xxxPhone:xxx 
RowID: 2FirstName:xxxLastName:xxxICQ:xxx  
RowID: 3FirstName:xxxLastName:xxxGG:xxxSkype:xxxMobile:xxx

 

Tak jak w relacyjnych bazach, istnieją tutaj tabele. W przeciwieństwie jednak do RDBMS, nie ma oczywiście narzuconego formatu. Każdy wiesz może zawierać różny zestaw kluczy. Z tego względu klucze pełnią nieco inną rolę niż kolumny w RDBMS. Kolumny opisują dane, które występują w każdym wierszu. Klucze należy interpretować, jako właściwości danego wiersza\encji. Zwykle wyłącznie identyfikator wiersza jest wspólny dla wszystkich encji (wierszy). Tak jak to w NoSQL, relacje nie są tutaj gwarantowane przez sam silnik i jeśli jakieś encje w bazie opisują ten sam obiekt to programista musi zadbać o ich synchronizacje i utrzymanie.

Key-Value stores opierają się na funkcji haszującej. Z tego względu, najlepiej stosować ją do problemów gdzie często odczytuje się dane. Jeśli np. na stronie internetowej, bardzo często należy czytać konkretną wartość, wtedy za pomocą klucza (hashu) jest to dość szybkie. Innymi słowy, bazy KVS (key-value stores) opierają się na kolekcji słowników.

KVS są dość popularne i istnieje już wiele implementacji np.:

Przyjrzyjmy się przykładowej implementacji, a mianowicie Windows Azure Table. Artykuł ma na celu wyjaśnienie zasad działania różnych typów baz, a nie konkretnych API. Mimo wszystko, warto pokazać trochę kodu, co z pewnością ułatwi zrozumienie strony teoretycznej.

W Azure Table, najpierw należy stworzyć instancję obiektu CloudTableClient, za pomocą, której można dodawać nowe tabele:

Kolejnym krokiem jest zdefiniowanie encji. Każdy wiersz może zawierać różne kolumny, zatem można zdefiniować dwie różne klasy:

Obie encje będą przechowywane dokładnie w tej samej tabeli. Po prostu posiadają one różny zestaw kluczy. Można tworzyć dowolne kombinacje, chociaż w praktyce oczywiście dane przechowywane w tej samej tabeli, posiadają pewne cechy wspólne (reprezentują ten sam obiekt biznesowy). Jeśli jednak dana osoba posiada kilka numerów telefonów, wtedy po prostu możemy dodać kilka nowych właściwości i wykorzystać tą samą tabelę.

Dodanie tak zdefiniowanych encji, wygląda następująco:

Analogicznie można odczytać wszystkie encje z bazy:

W przypadku Azure Tables, każdy wiersz zawiera trzy obowiązkowe kolumny: RowId, PartitionId, Timestamp. Timestamp to prostu data ostatniej modyfikacji. Wartość jest automatycznie aktualizowana przez silnik. RowId i PartitionId stanowią klucz główny. RowId identyfikuje wiersz w obrębie danej partycji, która z kolei jest identyfikowana przez PartitionId. W celu uzyskania wysokiej skalowalności należy dobrać odpowiednią strategię partycji. Dostęp do wierszy o tej samej partycji jest szybki, ponieważ przechowywane są one na tym samym serwerze. Różne partycje z kolei mogą, (ale nie muszą) być przechowywane na różnych serwerach. Oczywiście w zależności od konkretnego silnika, inaczej implementuje się skalowalność. Dokładne poznanie możliwości bazy jest szerokim tematem i warto poświęcić temu więcej czasu.

Column stores

Kolejnym typem baz, są te oparte na kolumnach. Programiści zwykle przyzwyczajeni są do reprezentacji wierszowej, która jest powszechna w bazach relacyjnych. Dla wyjaśnienia różnicy między przechowywaniem danych w wierszach a kolumnach, posłużymy się plikiem CSV. Załóżmy, że mamy listę osób (ID, imię, nazwisko). W formie wierszowej, dane byłyby przechowane w następujący sposób: 

1,Piotr,Zielinski
2,AAA,BBB,
3,CCC,DDD

W postaci kolumnowej, dane z tej samej kolumny są zapisywane koło się, tzn.:

1,2,3,Piotr,AAA,CCC,Zielinski,BBB,DDD

Różnica jest, zatem szczegółem implementacyjnym silnika, który ma jednak bardzo duże znaczenie na wydajność i sposób, w jaki powinniśmy operować na danych. Każdy ze wspomnianych sposobów jest dobry, wszystko zależy od scenariuszy, które będą najczęściej występować.

Zastanówmy się, w jakich zapytaniach reprezentacja kolumnowa jest lepsza. Jeśli zapytanie czyta dane, które są w jednej kolumnie, to naturalnie, o wiele szybciej zostaną zwrócone dane, gdy są one ułożone w kolumnach. Nie trzeba wtedy skakać w pamięci z jednego miejsca na drugie – wystarczy przeczytać ciągły fragment pamięci. Nawiązując do poprzedniego przykładu, poniższe zapytanie będzie szybsze w reprezentacji kolumnowej niż wierszowej:

Jeśli wszystkie imiona są w pamięci przechowywane blisko siebie, to naturalnie dużo szybciej odczyta się takie dane. Z kolei w zapytaniach, gdzie operuje się na całych wierszach, reprezentacja wierszowa jest wydajniejsza:

Szczególnym przypadkiem są zapytania z funkcjami agregującymi. Zwykle są one bardzo intensywne obliczeniowo, ale w reprezentacji kolumnowej dużo łatwiej je wykonać. Funkcja agregująca, wykonuje obliczenia zwykle na jednej kolumnie, a nie na całym wierszu, stąd przewaga baz typu „column stores”. 

Ze względu, że dane z tej samej kolumny przechowywane są blisko siebie w pamięci, wiąże się to z jeszcze jedną korzyścią, a mianowicie łatwością kompresji danych. Naturalnie, istnieje większe prawdopodobieństwo, że dane będą takie same z tej samej kolumnie niż z różnych. Dane w kolumnie „Imię” mogą się częściej powtarzać niż dane z dwóch różnych kolumn (np. imię i nazwisko), co ma miejsce w reprezentacji wierszowej, gdzie dane z dwóch różnych kolumn są blisko siebie w pamięci.

Powyższe rozważania jasno wskazują, że bazy kolumnowe skalują się bardzo łatwo horyzontalnie – nie ma problemu ze stworzeniem dużej liczby kolumn, ponieważ odczyt i tak będzie szybki. Po za tym, łatwo przetwarzać dane równolegle z różnych wątków – dane są fizycznie od siebie oddzielone i nie będą zachodzić konflikty, jak to by miało miejsce w bazach wierszowych.

Reprezentacja wierszowa jest lepsza, gdy cały wiersz musi zostać odczytany. Ma to znaczenie szczególnie w przypadkach, gdy tylko jeden wiersz należy zwrócić. Wtedy w bazach kolumnowych trzeba przeskakiwać do stosunkowo dużej liczby miejsc. Innym, analogicznym scenariuszem jest dodawanie całych wierszy – w bazach kolumnowych jest to dużo wolniejsze. Oczywiście tak jak to w bazach NoSQL, nie ma z góry narzuconego formatu danych. Poszczególne implementacje dostarczają pewne formy schematu, ale zwykle są one elastyczne i każdy wiersz może zawierać różne dane.

Istotnym elementem kolumnowych baz jest również superkolumna, która stanowi z kolei kontener na inne kolumny:

Id:1FirstName:'text'LastName:'text'Address
Street:'text'Town:'text'
Id:2FirstName:'text'LastName:'text'Address
Street:'text'Street:'text'

 

W powyższym przykładzie, Address to superkolumna dla Street oraz Town. Jak widać, to nic innego jak pojemnik na kolumny – pamiętajmy, że w bazach kolumnowych tabela (ColumnFamily) może zawierać nawet setki różnych kolumn.

Jako przykład bazy kolumnowej, posłużę się Apache Cassandra. Przechowuje one dane kolumnowo, ale można zdefiniować coś w rodzaju schematu. Nie jest to oczywiście sztywny format i poszczególne wiersze danych, mogą przechowywać różne dane. Czasami po prostu łatwiej mieć jakiś schemat bazowy, bo ułatwia to pracę.

Warto również jeszcze raz podkreślić, że reprezentacja kolumnowa to wyłącznie sposób przechowywania danych w pamięci. Nadal słowo wiersz występuje w tych bazach, co oznacza zbiór danych przechowujący informacje o tej samej encji.

Cassandra została napisana w Javie, ale istnieje kilka różnych interfejsów za pomocą, których można korzystać z C#.

W artykule zostanie użyty FluentCassandra, który można pobrać z NuGet:

W Cassandra, należy najpierw stworzyć tzw. KeySpace, co jest głównym kontenerem na dane. Można to utożsamiać z instancją, bazą danych. Za pomocą FluentCassandra można napisać metodę, która stworzy dowolny keysapce:

Kolejnym elementem jest tzw. ColumnFamily, czyli po prostu kontener dla kolumn. Można to utożsamiać z tabelą z relacyjnych baz danych, pamiętając oczywiście o różnicach, które zostały opisane w tym jak i poprzednim artykule. Przykład:

W Cassandra istnieje jeszcze kilka innych kontenerów, ale ten artykuł dotyczy wyłącznie ogólnego opisu baz danych i przykłady są tutaj prezentowane wyłącznie w celach pomocniczych.

Następnie można skorzystać z interfejsu i dodać kilka encji:

Kod korzysta ze słowa dynamic, które umożliwia tworzenie różnych pól dla każdego z wierszy. Tak jak już zostało wspomniane, pomimo, że ColumnFamily posiada schemat bazowy, każda encja może mieć różny zestaw kolumn.

Innymi bazami należącymi do tej grupy to np. HBase albo Accumulo.

Document stores

Kolejny typ baz, przeznaczony jest do przechowywania dokumentów. Taki opis nie wiele na razie mówi, ponieważ należy najpierw zdefiniować, co to jest dokument i jakie ma on cechy.

Dokument to zwykle jakaś zawartość z różnymi atrybutami opisującymi np. formatowanie, rozmiar czcionki itp. Każdy dokument może mieć różny zestaw atrybutów. Dokumenty również zawierają różne załączniki i to jest dokładnie esencja baz opartych o dokumenty.

Cechą wyróżniająca document stores od innych, to dowolna liczba zagnieżdżeń. Każdy dokument może zawierać zagnieżdżony dokument, który z kolei może zawierać dowolną liczbę innych zagnieżdżeń. Z każdym dokumentem powiązanych jest dowolna liczba atrybutów, które go opisują. Oczywiście różne dokumenty mogą zawierać różny zestaw atrybutów. Tak samo jak metadane różnią się w zależności od opisywanego obiektu, tak samo tutaj możliwe jest przechowanie różnych tagów.

Przykłady implementacji baz dokumentowych to m.in:

MongoDB jest jedną z najpopularniejszych implementacji. Przechowuje dokumenty w formacie BSON, co oznacza binarny JSON. Większość baz dokumentowych bazuje na JSON i w zależności od konkretnej implementacji, możliwe jest wyeksponowanie danych za pomocą usługi RESTful.

Dokument to podstawowa jednostkach w tego typu bazach. W MongoDB dokumenty są przechowywane w kolekcji, która z kolei należy do bazy danych. W C# można zainstalować oficjalny sterownik MongoDB z NuGet:

Przykład dodawania do bazy dokumentu wraz zagnieżdżonymi innymi dokumentami:

Zapytania z kolei wyglądają następująco:

Wygenerowany dokument będzie miał następującą formę:

Jak widać, adres, czyli zagnieżdżony dokument został osadzony w dokumencie person. Istnieje jeszcze możliwość referencji, czyli przechowania w Person wyłącznie identyfikatora do adresu.

Bazy grafowe

Ostatni typ baz oparty jest na grafach. Jeśli logika systemu wymaga algorytmów grafowych, wtedy bazy grafowe mogą znaczącą ułatwić oraz przyśpieszyć przetwarzanie. Standardowym przykładem są portale społecznościowe (Facebook, LinkedIn), gdzie każda osoba może zostać opisana węzłem w grafie. Z kolei znajomi (kontakty) mogą być opisani za pomocą krawędzi (relacji). Mając dane w formie grafu, bardzo łatwo znaleźć np. najkrótszą ścieżkę pomiędzy dwoma wierzchołkami. Oczywiście dane te można byłoby przechować w standardowej relacyjnej bazie lub jednej z NoSQL. Obliczenia jednak byłyby bardzo czasochłonne, ponieważ to aplikacja musiałaby stworzyć graf i wykonywać operacje na nim. Bazy grafowe mają wsparcie natywne dla standardowych operacji na grafie.

Bardzo popularną bazą opartą na grafach jest Neo4J. Oczywiście również jest sterownik dla .NET, który można pobrać z NuGet:

 

Jako przykład, stworzymy graf reprezentujący relacje między grupą osób. Dla uproszczenia istnieć będą wyłącznie dwa typy relacji:

  1. Likes (lubi)

  2.  Hates (nienawidzi)

Encja, czyli wierzchołek (węzeł) w grafie będzie opisany następującą klasą:

Relacje w Neo4j muszą implementować specjalne interfejsy tzn.:

Każda relacja może zawierać również dodatkowe parametry. Można je definiować także dla dwóch różnych typów encji. Następnie można połączyć się z bazą, dodać wierzchołki oraz krawędzie:

Kod wygeneruje następujący graf:

Powyższy rysunek został wygenerowany przez panel administracyjny Neo4j. Tak jak w SQL Server Management Studio można przeglądać tabele danych, to w Neo4j dostarcza narzędzia do zarządzania grafami. Wszystkie dane, które przechowywane są w bazie, nalezą do grafu.

Neo4J posiada język zapytań Cypher, który umożliwia operacje na grafach w taki sposób, jak SQL ułatwia pracę na zbiorze danych.

Inne implementacje baz grafowych to m.in.:

Zakończenie

Wybór prawidłowego typu bazy jest kluczowym elementem. Jak widać, nie jest to wyłącznie wybór między RDBMS a NoSQL. Bardzo ważne w takiej sytuacji jest zrozumienie danych, logiki biznesowej oraz sposobu, w jakim na nich operuje się. Bardzo często, system będzie składał się z kilku baz, włączając w to RDBMS. W bardzo skomplikowanych systemach, które muszą dostarczyć wysoką przepustowość oraz dostępność, korzysta się z wyspecjalizowanych baz, a co za tym idzie, może zajść potrzeba wykorzystania kilku silników jednocześnie.