Mongoid w praktyce: modelowanie dokumentów, gdy przychodzisz od ActiveRecord
Wdrażamy MongoDB z Mongoid na realnych projektach. Oto czym modelowanie dokumentów naprawdę różni się od ActiveRecord — embedded vs referenced, brak joinów, indeksy, zapisy atomowe, granice spójności i gdzie schemaless po cichu boli.
Jeśli spędziłeś lata z ActiveRecord, pierwsze tygodnie z Mongoidem
wydają się zwodniczo znajome. Piszesz field :name, wołasz where, dostajesz coś,
co kwacze jak model. A potem próbujesz myśleć tabelami i wyjść z problemu joinem —
i MongoDB po cichu odmawia współpracy.
Przeprowadziliśmy już kilka projektów na MongoDB i wniosek jest zawsze ten sam: to nie ORM jest trudny — trudny jest model danych. Mongoid świetnie sprawia, że Mongo wydaje się jak ActiveRecord, i to właśnie jest pułapka, bo oba nagradzają przeciwne odruchy. ActiveRecord nagradza normalizację: podziel dane na czyste tabele i pozwól bazie złożyć je z powrotem joinami przy odczycie. MongoDB nagradza odwrotność: zdecyduj, jak czytasz dane, a potem zapisz je mniej więcej w tym kształcie, joiny niech idą w diabły. Przynieś nawyki AR do Mongo bez refleksji, a wyprodukujesz schemat, który działa na deweloperce i przewraca się na produkcji.
Oto wszystko, co żałujemy, że nie zrozumieliśmy pierwszego dnia.
Dokumenty to nie wiersze — to agregaty
W ActiveRecord model mapuje się na tabelę, a wiersz to płaska lista typowanych
kolumn. W Mongoidzie model mapuje się na kolekcję, a dokument to zagnieżdżone
drzewo. To zagnieżdżenie jest całym sensem. Relacja, która w SQL wymusiłaby
has_many i joina, może po prostu żyć wewnątrz dokumentu rodzica:
class Order
include Mongoid::Document
field :placed_at, type: Time
field :total, type: BigDecimal
field :currency, type: String, default: "GBP"
embeds_many :line_items
end
class LineItem
include Mongoid::Document
field :sku, type: String
field :name, type: String
field :quantity, type: Integer
field :price, type: BigDecimal
embedded_in :order
end
Wczytanie zamówienia wczytuje teraz jego pozycje w jednym odczycie — bez joina, bez drugiego zapytania, bez N+1. Pozycje nie mają niezależnego bytu; rodzą się i giną wraz z zamówieniem, a na dysku są dosłownie pod-tablicą wewnątrz dokumentu zamówienia.
Model myślowy, dzięki któremu to wszystko wskakuje na miejsce, to agregat z domain-driven design: kłębek obiektów traktowany jako jedna jednostka na potrzeby wczytywania, zapisu i pilnowania niezmienników. Zamówienie i jego pozycje to jeden agregat. Wpis na blogu i jego tagi-jako-tekst to jeden agregat. MongoDB jest w istocie magazynem agregatów — znakomicie pobiera i utrwala jedno takie drzewo w jednej rundzie, a obojętne mu jest wszystko, co chciałbyś zrobić między nimi. Gdy zaczniesz wyznaczać granice agregatów świadomie, zamiast normalizować odruchowo, większość decyzji modelowych odpowiada sobie sama.
Embedded vs referenced: jedyna decyzja, która naprawdę się liczy
Niemal każdy błąd modelowania, jaki popełniliśmy na Mongo, sprowadza się do wybrania embed tam, gdzie powinniśmy zreferencować, albo na odwrót. Zrób to dobrze, a reszta to szczegóły; zrób źle, a będziesz walczył z bazą przez całe życie projektu. Zasada, przy której zostaliśmy:
- Embed, gdy dziecko należy do dokładnie jednego rodzica, ma ograniczony rozmiar i jest czytane razem z rodzicem. Pozycje na zamówieniu. Adresy użytkownika. Warianty i opcje produktu. Test brzmi: czy te dane byłyby kiedykolwiek odpytywane lub wyświetlane bez rodzica? Jeśli nie — osadź je.
- Reference, gdy dziecko jest współdzielone między rodzicami, odpytywane samodzielnie albo rośnie bez ograniczeń. Produkt wskazywany przez wiele zamówień. Komentarze liczone w tysiącach. Wszystko, co trzeba paginować niezależnie albo przekazać innej części systemu.
class Product
include Mongoid::Document
field :name, type: String
field :price, type: BigDecimal
has_many :reviews # referenced: recenzje są odpytywane samodzielnie
end
class Review
include Mongoid::Document
field :body, type: String
field :rating, type: Integer
belongs_to :product # przechowuje product_id, nie produkt
end
Czyhają tu dwie pułapki i obie są niewidoczne, dopóki nie przestaną być.
Limit 16 MB na dokument. Każdy dokument ma twardy pułap 16 megabajtów.
embeds_many bez naturalnego ograniczenia — wpisy logu aktywności, komentarze pod
wiralowym postem, zdarzenia na długowiecznym koncie — to bomba zegarowa. Działa
miesiącami, bo twoje dane testowe są małe, a potem jeden dokument przekracza granicę
na produkcji i każdy zapis do tego dokumentu zawodzi, często w ścieżce kodu
odległej od miejsca, w którym nastąpił wzrost. Rozwiązaniem nie jest „zrób większy”;
rozwiązaniem jest rozpoznanie już na etapie projektu, że nieograniczony zbiór dzieci
musi być zreferencowany, żyjący we własnej kolekcji, paginowany przy odczycie.
Referencja między kolekcjami nie daje prawdziwego joina. belongs_to /
has_many między kolekcjami Mongoid rozwiązuje drugim zapytaniem. W MongoDB nie
ma joina po stronie serwera (przynajmniej nie w 2013 — nie ma jeszcze $lookup).
Więc to N+1, które już znasz, tylko okrutniejsze:
# Wygląda niewinnie. Wykonuje jedno zapytanie o produkty, a potem jedno
# zapytanie NA KAŻDĄ dotkniętą kolekcję recenzji — między kolekcjami nie ma
# `includes`, które ratuje cię w pełni tak, jak w ActiveRecord.
Product.all.each do |product|
puts "#{product.name}: #{product.reviews.count} recenzji"
end
Mongoid potrafi w niektórych przypadkach eager-loadować referencje, ale nie możesz
na tym polegać tak, jak polegasz na includes w AR. Szczera odpowiedź jest taka, że
jeśli ciągle łapiesz się na chodzeniu po referencjach w pętlach, dane chciały być
osadzone — albo chciały być w Postgresie.
Joinów nie ma, więc denormalizuj świadomie
Patrząc od strony SQL, brak joinów wydaje się brakującą funkcją. Nie jest — to inny kontrakt. Odpowiedź MongoDB na „potrzebuję danych z dwóch miejsc naraz” jest dosadna: trzymaj je w jednym miejscu. Denormalizujesz celowo, kopiując garstkę pól, które musisz czytać razem, do dokumentu, który ich potrzebuje:
class Order
include Mongoid::Document
belongs_to :customer
# kopiowane od klienta przy zapisie, by lista zamówień i drukowana
# faktura nigdy nie musiały dotykać kolekcji klientów
field :customer_name, type: String
field :customer_email, type: String
before_save :denormalise_customer
private
def denormalise_customer
return unless customer
self.customer_name = customer.full_name
self.customer_email = customer.email
end
end
Teraz lista zamówień renderuje się, nie dotykając kolekcji klientów, a faktura sprzed trzech lat wciąż pokazuje nazwisko, które klient miał wówczas — co dla faktury jest dokładnie poprawne. Koszt jest oczywisty i trzeba go wziąć na siebie: gdy klient zmieni nazwisko, kopie na przeszłych zamówieniach zdezaktualizują się, o ile nie zdecydujesz się propagować zmiany.
Ta decyzja jest wyborem modelowym, nie przypadkiem. Czasem chcesz zamrożonej kopii
(faktury, zapisy audytowe, wszystko, co prawne). Czasem chcesz propagacji — wtedy
aktualizujesz kopie w zadaniu w tle, gdy zmienia się źródło. Dyscyplina polega po
prostu na zapisaniu, które pola są zdenormalizowane i jaka jest reguła propagacji,
bo następny programista nie zgadnie, że customer_name na zamówieniu to cache, a
nie źródło prawdy. Denormalizacja wymienia prostotę odczytu na złożoność zapisu; na
ekranach mocno czytanych — historia zamówień, dashboardy, listingi katalogu — ta
wymiana niemal zawsze się opłaca.
Zapytania: DSL jest uroczy, ale sens tkwi w operatorach atomowych
DSL zapytań Mongoida poczujesz od razu jak u siebie:
Order.where(:placed_at.gte => 1.month.ago)
.and(status: "paid")
.order_by(placed_at: :desc)
.limit(50)
Operatory-symbole (.gte, .in, .ne) kompilują się do operatorów zapytań Mongo
($gte, $in, $ne). Przyjemne, ale to nie ta część się liczy. Liczą się
atomowe operatory aktualizacji, bo pozwalają zmienić dokument na serwerze bez
wyścigu read-modify-write, który trapi naiwny wzorzec z ActiveRecord:
# NIE: odczyt, mutacja w Ruby, zapis z powrotem — dwa równoległe żądania
# oba czytają 5, oba zapisują 6, straciłeś jeden increment.
product.stock -= 1
product.save
# TAK: atomowy dekrement na serwerze, bez wyścigu
product.inc(stock: -1)
# atomowe dopisanie do osadzonej tablicy
order.push(line_items: { sku: "ABC", quantity: 1 })
# dodanie do zbioru tylko, jeśli nie ma już elementu
user.add_to_set(roles: "admin")
$inc, $push, $addToSet, $pull oraz pozycyjny operator $ (aktualizuj
pasujący element osadzonej tablicy) to narzędzia, które czynią magazyn dokumentów
bezpiecznym przy współbieżności. Sięgaj po nie, ilekroć więcej niż jeden proces może
dotknąć tego samego dokumentu — liczniki, stany magazynowe, zbiory przynależności.
Są też dramatycznie szybsze niż load-mutate-save, bo dokument nigdy nie odbywa
podróży do Ruby i z powrotem.
Indeksy: Mongo przeskanuje milion dokumentów i nigdy nie narzeknie
To tryb awarii, który ugryzł nas najmocniej, bo jest cichy. MongoDB z radością wykona pełny skan kolekcji dla zapytania bez indeksu. Na twojej bazie deweloperskiej z 200 dokumentami jest natychmiastowy. Na produkcyjnej kolekcji z dwoma milionami dokumentów to wielosekundowe zapytanie przypinające CPU — a jedynym protestem Mongo jest linia w logu wolnych zapytań, którego jeszcze nie czytasz.
Deklaruj indeksy jawnie w modelu i commituj je:
class Order
include Mongoid::Document
field :status, type: String, default: "pending"
field :placed_at, type: Time
index({ placed_at: -1 }) # zapytania o świeże zamówienia
index({ status: 1, placed_at: -1 }) # złożony: filtr + sortowanie
index({ "line_items.sku" => 1 }) # multikey: w głąb tablicy
index({ customer_email: 1 }, { unique: true }) # unikalność na poziomie bazy
end
Kilka rzeczy wartych przyswojenia. Kolejność w indeksie złożonym ma znaczenie:
indeks { status: 1, placed_at: -1 } obsłuży zapytanie filtrujące po status i
sortujące po placed_at, ale nie odwrotnie — reguła prefiksu jest ta sama, co przy
złożonych indeksach SQL. Indeksy w głąb osadzonych tablic ("line_items.sku")
są „multikey” i po prostu działają, co jest jedną z cichych radości osadzania. A
unikalność musi być egzekwowana przez bazę, nie tylko przez walidację Mongoida —
walidacja ma wyścig check-then-write, który przy współbieżności przepuszcza
duplikaty; unikalny indeks nie.
Następnie faktycznie je zbuduj przy deployu:
rake db:mongoid:create_indexes
Uczyń to częścią deployu, a nie czymś, co ktoś pamięta uruchomić. Używaj .explain
w konsoli, by potwierdzić, że zapytanie używa indeksu, o którym myślisz — różnica
między COLLSCAN a IXSCAN w wyniku to różnica między incydentem a nie-zdarzeniem.
Schemaless nie znaczy bez struktury
Uwodzicielska obietnica MongoDB to „żadnych migracji”. Jest w połowie prawdziwa, a ta druga połowa to miejsce, gdzie zespoły robią sobie krzywdę.
Naprawdę możesz dodać pole, deklarując je w modelu, a wczorajsze dokumenty po prostu
go nie będą miały. Gdy działasz szybko, a kształt danych wciąż się zmienia, to realny
przyspieszacz — żadnej migracji do napisania, żadnej tabeli do zablokowania, żadnej
choreografii kolejności deployu. Ale „baza przyjmie wszystko” to nie to samo, co
„twój kod poradzi sobie ze wszystkim”. Stare dokumenty zwracają nil dla pól
dodanych później, a nil po cichu przecieka do widoków, obliczeń i serializowanego
JSON-a, aż coś wybuchnie daleko od przyczyny.
Dyscyplina, która powstrzymuje schemaless przed zamianą w chaos:
class Order
include Mongoid::Document
field :status, type: String, default: "pending"
field :channel, type: String, default: "web" # nowe pole, bezpieczny default
validates :status, presence: true, inclusion: %w[pending paid shipped]
end
- Zawsze nadawaj nowym polom rozsądny default, albo zabezpieczaj każdy ich odczyt.
- Waliduj na warstwie modelu świadomie. Baza tego nie zrobi. Jeśli
statusmusi być jednym z ustalonego zbioru, powiedz to w modelu — Mongo z pewnością nie powstrzyma cię przed zapisaniem"banana". - I tak uruchom jednorazowy skrypt migracji danych, gdy zmienia się znaczenie. Schemaless usuwa przymus migracji, nie potrzebę migracji. Jeśli zmieniasz nazwę pola albo sposób interpretacji wartości, napisz skrypt, który przejdzie kolekcję i przepisze stare dokumenty. „Stare dokumenty wciąż są w starym kształcie” to błąd czekający na pechowy odczyt.
- Rozważ pole
schema_versionna długowiecznych, mocno zmienianych dokumentach, byś mógł rozgałęziać się po nim podczas stopniowej migracji.
Granice spójności: projektuj wokół braku transakcji
Oto ograniczenie, które najbardziej kształtuje model danych w Mongo, a łatwo je
przeoczyć, dopóki nie ugryzie: w 2013 nie ma transakcji wielodokumentowych.
Aktualizacja pojedynczego dokumentu jest atomowa — albo cała wchodzi, albo żadna —
ale w chwili, gdy operacja obejmuje dwa dokumenty (albo dwie kolekcje), nie ma
BEGIN ... COMMIT, które sprawiłoby, że obie powiodą się lub zawiodą razem.
To nie tyle wada, co czynnik wymuszający, i najgłębszy powód, by poważnie traktować granice agregatów. Jeśli dwa kawałki danych muszą zmieniać się razem atomowo, to silny sygnał, że należą do tego samego dokumentu. Zamówienie i jego pozycje zmieniają się razem, więc żyją razem, a pojedyncza atomowa aktualizacja utrzymuje ich spójność. Odwrotnie, jeśli dwie rzeczy naprawdę żyją w osobnych dokumentach, musisz zaprojektować się na możliwość, że jeden zapis się powiedzie, a drugi nie — operacjami idempotentnymi, zadaniem uzgadniającym albo świadomą postawą „ostatecznie spójne” — czyli dokładnie tą pracą, którą transakcja SQL wykonałaby za ciebie za darmo.
Gdy trafiamy na domenę przeszytą niezmiennikami obejmującymi wiele encji — „to jest poprawne tylko, jeśli te pięć rzeczy w czterech kolekcjach się zgadza” — to najjaśniejszy sygnał, że opuściliśmy strefę komfortu MongoDB i powinniśmy być w Postgresie.
Raportowanie: pipeline agregacji
Drugą rzeczą, której pozbywasz się bez joinów, jest doraźne raportowanie relacyjne.
Odpowiedzią Mongo jest pipeline agregacji — sekwencja etapów ($match, $group,
$sort, $project), które serwer wykonuje:
Order.collection.aggregate([
{ "$match" => { status: "paid" } },
{ "$unwind" => "$line_items" },
{ "$group" => { _id: "$line_items.sku",
units: { "$sum" => "$line_items.quantity" } } },
{ "$sort" => { units: -1 } },
{ "$limit" => 10 }
])
Jest potężny i działa po stronie serwera, ale to własny język z własnymi dziwactwami,
a złożone raportowanie w nim jest wyraźnie trudniejsze niż znane ci GROUP BY z SQL.
To także użyteczny sygnał: jeśli gros wartości systemu tkwi w raportowaniu między
encjami, system ten prawdopodobnie chciał mieć pod spodem bazę relacyjną.
Co naprawdę lubimy
Po okresie adaptacji jest sporo do pokochania. Dokumenty embedded pięknie mapują się na agregaty — wczytaj i zapisz całe drzewo jako jednostkę, z nietkniętymi niezmiennikami. Operatory atomowe czynią współbieżne liczniki i zbiory naprawdę łatwymi. Wolność schemaless to realny przyspieszacz we wczesnej, eksploracyjnej fazie projektu, gdy nie wiesz jeszcze, czym dane chcą być, a nadgorliwa migracja to tylko tarcie. Do przechowywania i serwowania samowystarczalnych dokumentów — profile użytkowników, treści, payloady zdarzeń, wszystko, co czytasz w całości — Mongo jest szybkie, przyjemne i dobrze dopasowane.
Na co uważamy
Sięgamy po Postgresa w chwili, gdy domena jest fundamentalnie relacyjna: gdy pytania, które zadajemy, obejmują wiele encji, gdy raportowanie potrzebuje doraźnych joinów, gdy chcemy prawdziwej integralności kluczy obcych i gdy poprawność zależy od transakcji obejmujących więcej niż jeden dokument. MongoDB znakomicie przechowuje i serwuje agregaty; nie jest bazą relacyjną w przebraniu, a udawanie, że jest, to droga, którą zespoły kończą, implementując joiny, transakcje i ograniczenia ręcznie w Ruby — wolniej, z większą liczbą błędów i bez żadnej z gwarancji, które dałaby im baza.
Główna lekcja to jedna myślowa zamiana. W SQL najpierw normalizujesz, a odczyty optymalizujesz później; w MongoDB najpierw modelujesz odczyt i akceptujesz koszt zapisu. Przyswój tę inwersję, a Mongoid jest naprawdę przyjemny w pracy. Walcz z nią — przeciągnij swoje instynkty tabel-i-joinów bez zmian — a spędzisz wieczory, debugując joina, którego baza i tak nigdy nie miała za ciebie zrobić.