Skip to content
← Wszystkie wpisy
11 min czytania Dawid Skłodowski

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 status musi 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_version na 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ć.