İlişkisel mi Document mı? Aynı Bookstore'u İki Şekilde Modellemek
İlişkisel model "veri nedir?" diye, document model "veriye nasıl erişeceğim?" diye sorar; ikisi de model ister, yalnızca sınırları farklı yere çizer. Bookstore siparişini MongoDB'de kalemleri gömülü bir document olarak kuruyor, aggregate sınırını, document modeldeki update anomaly'yi, $lookup tuzağını ve schema validation'ı gösteriyor, SQL Server 2025'te ilişkisel veriden JSON document üretip PostgreSQL jsonb ile karşılaştırıyoruz.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) örnekleri SQL Server 2025 (17.0, Cumulative Update 10) üzerinde, document örnekleri MongoDB 8.3 ve mongosh ile, PostgreSQL notundaki örnek PostgreSQL 18.6 üzerinde çalıştırılmıştır.
Önce Tahmin Et: Sipariş Excel'i Aslında Bir Document muydu?
Serinin başından beri normalize ettiğimiz sipariş Excel'ine bir kez daha bak: her satır bir sipariş, siparişin kitapları, fiyatları ve adetleri aynı satırın içinde. Bu, bir document veritabanının sipariş document'ine çok benziyor: sipariş ve kalemleri tek bir kayıtta. Tahmin et: document modele geçmek, beş yazı boyunca kaçtığımız düz tabloya geri dönmek mi?
Değil, ama iki şey arasındaki fark ilk bakışta sanıldığından küçük. Excel'in sorunu sipariş ve kalemlerinin bir arada durması değildi; kalemlerin hizası bozulabilen paralel virgül listeleri olması ve müşterinin, kitabın, yazarın bilgilerinin her satırda kopyalanmasıydı. İyi bir document model birincisini yapısal olarak çözer, ikincisini ise ancak sen düşünürsen çözer. Bu yazıda aynı Bookstore requirement'larını document modelde kurup ilişkisel modelle karşılaştıracağız ve ilişkisel veritabanında JSON (JavaScript Object Notation) kolonunun doğru yerini konuşacağız.
Aynı Requirement'ları Document Modelde Düşünmek
İlişkisel modeling'in ilk sorusu "veri nedir?" idi: entity'ler, attribute'lar, dependency'ler. Erişim şekli ancak fiziksel modelde, index seçerken masaya geliyordu. Document modeling soruyu tersine çevirir: "veriye nasıl erişeceğim?" MongoDB'nin data modeling dokümanı bunu bir ilke olarak koyar; kendi kelimelerimizle: birlikte okunan veri birlikte saklanmalı. Serinin 1. yazısında not ettiğimiz access pattern listesi burada modelin doğrudan girdisi oluyor: sipariş detayı ekranı siparişi kalemleriyle birlikte okur, kitap sayfası kitabı yazarlarıyla birlikte okur, yorumlar sayfalı olarak ayrı okunur.
db.books.insertMany([
{ _id: 26, isbn: "9798900000268", title: "Dune",
authors: [{ authorId: 1, name: "Frank Herbert" }], listPrice: Decimal128("390.50") },
{ _id: 30, isbn: "9798900000305", title: "The Left Hand of Darkness",
authors: [{ authorId: 2, name: "Ursula K. Le Guin" }], listPrice: Decimal128("660.00") },
{ _id: 41, isbn: "9798900000411", title: "A Wizard of Earthsea",
authors: [{ authorId: 2, name: "Ursula Le Guin" }], listPrice: Decimal128("200.50") }
]);
db.orders.insertMany([
{ _id: 1001, orderedAt: ISODate("2026-03-02T00:00:00Z"), status: "Delivered",
customer: { customerId: 1, name: "Ada Lovelace" },
shippingAddress: { line: "Moda Cd. 12", district: "Kadikoy", city: "Istanbul" },
items: [{ bookId: 26, title: "Dune", quantity: 1, unitPrice: Decimal128("390.50") }],
total: Decimal128("390.50") },
{ _id: 1003, orderedAt: ISODate("2026-03-09T00:00:00Z"), status: "Delivered",
customer: { customerId: 1, name: "Ada Lovelace" },
shippingAddress: { line: "Moda Cd. 12", district: "Kadikoy", city: "Istanbul" },
items: [
{ bookId: 30, title: "The Left Hand of Darkness", quantity: 1, unitPrice: Decimal128("660.00") },
{ bookId: 26, title: "Dune", quantity: 2, unitPrice: Decimal128("351.45") }
],
total: Decimal128("1362.90") }
]);
printjson(db.orders.findOne({ _id: 1003 }));
{
_id: 1003,
orderedAt: ISODate('2026-03-09T00:00:00.000Z'),
status: 'Delivered',
customer: {
customerId: 1,
name: 'Ada Lovelace'
},
shippingAddress: {
line: 'Moda Cd. 12',
district: 'Kadikoy',
city: 'Istanbul'
},
items: [
{
bookId: 30,
title: 'The Left Hand of Darkness',
quantity: 1,
unitPrice: Decimal128('660.00')
},
{
bookId: 26,
title: 'Dune',
quantity: 2,
unitPrice: Decimal128('351.45')
}
],
total: Decimal128('1362.90')
}
Siparişin kalemleri bir array; her kalem kendi alanları olan bir alt document. Excel'deki dört paralel listenin hizalama sorunu burada yok, çünkü kitap, adet ve fiyat aynı alt document'te birlikte duruyor. Sipariş detayı ekranı tek bir okuma: ilişkisel modelde orders, order_items, books ve customers arasında üç join gerektiren iş. Fiyatlar Decimal128; bir önceki yazıdaki FLOAT tuzağı document dünyasında da aynen geçerli.
Aggregate Sınırı: Neyi Göm, Neye Referans Ver
Document modelin asıl tasarım kararı, hangi verinin aynı document'e gömüleceği ve hangisinin ayrı bir collection'da durup kimliğiyle referans verileceği. Örnekteki kararların her birinin bir nedeni var.
Kalemler gömülü, çünkü siparişe aitler: siparişsiz var olamazlar (serinin 3. yazısındaki weak entity), sayıları sınırlı ve her zaman siparişle birlikte okunurlar. Daha önemlisi, siparişin toplamı ile kalemlerin tutarlı olması bir invariant ve MongoDB'de tek bir document'e yazma atomiktir. Sipariş ve kalemleri aynı document'teyse bu invariant'ı korumak için transaction'a gerek yok. DDD'de (Domain-Driven Design) bu sınırın adı aggregate; Domain-Driven Design serisinin "Aggregate Tasarımı" yazısı aggregate'i bir consistency boundary olarak anlatır ve document modelde bu sınır genellikle document sınırıyla örtüşür.
Müşteri referans, ama adı ve teslimat adresi kopyalanmış. Müşteri siparişten bağımsız yaşar ve başka siparişleri de vardır; onu her siparişe gömmek, 5. yazıdaki "Ada'nın telefonu" anomaly'sini geri getirir. Teslimat adresi ise kopya değil, bir snapshot: siparişin hangi adrese gittiği, müşteri taşınsa da değişmemeli. Kalemdeki title ve unitPrice da aynı türden; 8. yazıda ayırdığımız snapshot ile kopya farkı document modelde daha da önemli, çünkü burada kopyalamak çok kolay.
Yorumlar ayrı bir collection. Bir kitabın yorumları sınırsız büyüyebilir ve MongoDB'de bir document'in en fazla 16 mebibyte olabileceği dokümanda yazılı. Sınıra ulaşmasan bile, her okumada yüzlerce yorumu taşıyan bir kitap document'i, kitap kartını gösteren her isteği pahalılaştırır.
Gömülü veri üzerinde sorgu ve index de mümkün. "Dune'u kim aldı?" sorusu için array içindeki alana index koyup sorgulayabilirsin:
db.orders.createIndex({ "items.bookId": 1 });
db.orders.find(
{ "items.bookId": 26 },
{ _id: 1, "customer.name": 1, orderedAt: 1 }
).forEach(printjson);
{
_id: 1001,
orderedAt: ISODate('2026-03-02T00:00:00.000Z'),
customer: {
name: 'Ada Lovelace'
}
}
{
_id: 1003,
orderedAt: ISODate('2026-03-09T00:00:00.000Z'),
customer: {
name: 'Ada Lovelace'
}
}
items.bookId üzerindeki index bir multikey index: array'in her elemanı için ayrı bir index girdisi oluşur. İlişkisel modelde order_items.book_id üzerindeki index'in karşılığı bu.
Document Modelde de Anomaly Var
Kitap document'leri yazarların adlarını taşıyor, çünkü kitap sayfası onları her zaman birlikte gösteriyor. Ama örnekte 6. yazıdaki veri kalitesi sorunu da geldi: aynı yazar bir kitapta "Ursula K. Le Guin", diğerinde "Ursula Le Guin". Yazarın kimliği (authorId) aynı olduğu için düzeltmek mümkün; ama düzeltme tek bir yerde değil, yazarın geçtiği her document'te yapılmalı:
const r = db.books.updateMany(
{ "authors.authorId": 2 },
{ $set: { "authors.$[a].name": "Ursula K. Le Guin" } },
{ arrayFilters: [{ "a.authorId": 2 }] }
);
printjson({ matched: r.matchedCount, modified: r.modifiedCount });
{
matched: 2,
modified: 1
}
İki kitap eşleşti, biri değişti. Bu, 5. yazıdaki update anomaly'nin ta kendisi; document modelin bunu ortadan kaldırması beklenmez, yalnızca maliyeti ödenir. MongoDB dokümanına göre updateMany her document için ayrı ayrı atomiktir ama işlem bütün olarak atomik değildir; güncelleme sırasında okuyan biri yazarın iki farklı adını görebilir. Yazar adı nadiren değiştiği ve kitap sayfası çok sık okunduğu için bu bilinçli bir takas: 8. yazıdaki denormalization kararının document modeldeki hâli. Siparişlerdeki title ise güncellenmedi ve güncellenmemeli; o, satış anının kaydı.
Document Modelde Normalize Edip Her Okumada $lookup
İlişkisel alışkanlıkla document modele gelen biri her şeyi ayrı collection'lara bölebilir: siparişler bir collection'da, kalemler başka birinde, kitaplar üçüncüsünde. MongoDB bunu destekler; birleştirme aracı aggregation pipeline'ındaki $lookup:
db.order_lines.insertMany([
{ orderId: 1003, bookId: 30, quantity: 1, unitPrice: Decimal128("660.00") },
{ orderId: 1003, bookId: 26, quantity: 2, unitPrice: Decimal128("351.45") }
]);
db.orders_split.insertOne({ _id: 1003, customerId: 1, status: "Delivered" });
db.orders_split.aggregate([
{ $match: { _id: 1003 } },
{ $lookup: { from: "order_lines", localField: "_id", foreignField: "orderId", as: "lines" } },
{ $unwind: "$lines" },
{ $lookup: { from: "books", localField: "lines.bookId", foreignField: "_id", as: "book" } },
{ $project: { _id: 0, title: { $first: "$book.title" }, quantity: "$lines.quantity" } }
]).forEach(printjson);
{
title: 'The Left Hand of Darkness',
quantity: 1
}
{
title: 'Dune',
quantity: 2
}
Sonuç doğru, ama elinde iki modelin de dezavantajı var. Sipariş detayı artık tek bir okuma değil, iki $lookup'lı bir pipeline. Kalemler ile sipariş farklı document'lerde olduğu için toplam ile kalemlerin tutarlılığı tek document atomikliğiyle korunamaz; MongoDB'nin multi-document transaction'ları var, ama dokümanı bunların tek document yazmasından daha pahalı olduğunu ve gömülü verili bir modelin çoğu zaman transaction ihtiyacını ortadan kaldırdığını söylüyor. Üstelik ilişkisel veritabanının foreign key, JOIN optimizer'ı ve constraint'leri burada yok. Document veritabanında her okumada join yapıyorsan ya model yanlış ya da veritabanı seçimi.
Şemasız, Modelsiz Değildir
Document veritabanları için sık kullanılan "şemasız" sözü, veritabanının şemayı zorunlu tutmadığı anlamına gelir; uygulamanın bir şemaya ihtiyacı olmadığı anlamına gelmez. Şema yine vardır, yalnızca yeri değişir: kodun içine, okuyan her servisin varsayımlarına dağılır. MongoDB, istersen şemayı veritabanında da tutmana izin veriyor:
db.runCommand({
collMod: "orders",
validator: { $jsonSchema: {
bsonType: "object",
required: ["customer", "items", "total"],
properties: {
items: { bsonType: "array", minItems: 1, items: {
bsonType: "object",
required: ["bookId", "quantity", "unitPrice"],
properties: {
quantity: { bsonType: "int", minimum: 1 },
unitPrice: { bsonType: "decimal" }
} } }
} } }
});
try {
db.orders.insertOne({ _id: 1006, customer: { customerId: 2, name: "Alan Turing" },
items: [{ bookId: 26, quantity: 0, unitPrice: 390.5 }], total: Decimal128("0") });
} catch (e) {
print(e.errmsg);
const item = e.errInfo.details.schemaRulesNotSatisfied[0]
.propertiesNotSatisfied[0].details[0].details[0];
printjson(item.propertiesNotSatisfied.map(p => ({ property: p.propertyName, reason: p.details[0].reason })));
}
Document failed validation
[
{
property: 'quantity',
reason: 'comparison failed'
},
{
property: 'unitPrice',
reason: 'type did not match'
}
]
Adedi sıfır ve fiyatı double olan kalem reddedildi. Bu validator, ilişkisel tablodaki CHECK ve kolon tipinin karşılığı. Esnek şemanın gerçek faydası, yeni bir alanı eski document'leri yeniden yazmadan ekleyebilmek; bu, şemayı düşünmemek için bir izin değil. Hangi alanların zorunlu olduğunu, tiplerin ne olduğunu ve hangi verinin gömülü, hangisinin referans olduğunu bu seride ilişkisel model için verdiğimiz kararlarla aynı titizlikle vermen gerekiyor. Document modeling'in derinliği, schema pattern'leri ve index stratejileri MongoDB serisinde.
İlişkisel Veritabanında JSON: Doğru Yer ve Sınır
Soru her zaman "ya biri ya öteki" değil. İlişkisel veritabanları JSON'u bir veri tipi olarak destekliyor ve bunun iki meşru kullanımı var. Birincisini 9. yazıda gördük: kitabın formatına özgü, kimsenin join yapmadığı metadata'yı SQL Server 2025'in native json kolonunda tutmak, CHECK ile kısıtlamak ve sık filtrelenen alanı computed column ile index'lemek. İkincisi tersi yönde: veriyi ilişkisel tutup API'ye (Application Programming Interface) document olarak vermek. SQL Server 2025'in JSON_OBJECT ve JSON_ARRAYAGG function'larıyla sipariş document'i doğrudan normalize tablolardan üretilebilir:
SELECT JSON_OBJECT(
'orderId': o.order_id,
'orderedAt': o.ordered_at,
'status': o.status,
'customer': JSON_OBJECT('customerId': c.customer_id,
'name': c.first_name + N' ' + c.last_name),
'items': (SELECT JSON_ARRAYAGG(JSON_OBJECT('bookId': oi.book_id, 'title': b.title,
'quantity': oi.quantity,
'unitPrice': oi.unit_price))
FROM dbo.order_items AS oi
JOIN dbo.books AS b ON b.book_id = oi.book_id
WHERE oi.order_id = o.order_id)
) AS order_document
FROM dbo.orders AS o
JOIN dbo.customers AS c ON c.customer_id = o.customer_id
WHERE o.order_id = 1;
{"orderId":1,"orderedAt":"2025-01-02T09:19:25","status":"Delivered","customer":{"customerId":17,"name":"Bjarne Stroustrup"},"items":[{"bookId":47,"title":"Murder on the Orient Express","quantity":2,"unitPrice":290.90},{"bookId":19,"title":"Frankenstein","quantity":1,"unitPrice":460.90}]}
(1 rows affected)
Okuma tarafı document şeklinde, yazma tarafı normalize ve constraint'li. Bu function'ların ayrıntısı SQL Temelleri serisinin "SQL Server 2025 ile T-SQL'e Gelenler" yazısında. PostgreSQL'de karşılığı jsonb tipi ve GIN (Generalized Inverted Index) index; containment operatörü @> ile document'in bir parçasını arayabilirsin:
CREATE TABLE books (
book_id integer PRIMARY KEY,
title text NOT NULL,
details jsonb NOT NULL DEFAULT '{}'
);
CREATE INDEX ix_books_details ON books USING gin (details);
INSERT INTO books VALUES
(26, 'Dune', '{"format": "ebook", "fileSizeMb": 4.2, "drm": false}'),
(28, 'Foundation', '{"format": "ebook", "fileSizeMb": 7.8, "drm": true}'),
(34, 'Hyperion', '{"format": "audiobook", "narrator": "Grace Hopper"}');
SELECT book_id, title, details ->> 'fileSizeMb' AS file_size_mb
FROM books
WHERE details @> '{"format": "ebook", "drm": false}';
book_id | title | file_size_mb
---------+-------+--------------
26 | Dune | 4.2
(1 row)
jsonb'nin operatörleri ve index türleri PostgreSQL serisinde ayrıntılı olarak ele alınıyor. Sınır iki veritabanında da aynı: JSON'un içindeki bir alan join'in, foreign key'in ya da raporun konusu olduğu anda, o alan bir kolon olmayı hak etmiştir. JSON kolonu, ilişkisel modelin içinde küçük bir document adası olarak kalmalı; modelin kendisinin yerine geçmemeli.
Karar Tablosu: İlişkisel mi Document mı?
Kararı veritabanının popülerliği değil, verinin ve erişimin şekli verir. Aşağıdaki tablo dört sorunun her birinde hangi tarafın öne çıktığını özetliyor:
| Soru | İlişkisel öne çıkar | Document öne çıkar |
|---|---|---|
| İlişki yoğunluğu | Entity'ler birbirine çok yönlü bağlı (kitap, yazar, kategori, sipariş, yorum) | Veri doğal olarak ağaç şeklinde ve bir kök etrafında okunuyor |
| Ad-hoc sorgu ihtiyacı | Önceden bilinmeyen rapor ve analiz soruları çok | Erişim şekilleri az sayıda ve önceden biliniyor |
| Transaction sınırı | Bir işlem birden çok bağımsız entity'yi birlikte değiştiriyor | Tutarlılık sınırı tek bir document'e sığıyor |
| Şema değişim hızı | Yapı oturmuş, kurallar veritabanında korunmalı | Yapı sık değişiyor ya da kayıttan kayda farklı |
Bookstore için cevap çoğunlukla ilişkisel: katalog, siparişler, ödemeler ve raporlar sıkı ilişkili ve bir önceki yazının raporları ad-hoc sorgu ihtiyacının ne kadar gerçek olduğunu gösterdi. Ama sipariş geçmişi ekranı, ürün kataloğunun formata özgü kısmı ya da event'lerle beslenen bir read model, document şeklinde tutulmaya iyi adaylar. Ölçek tarafındaki SQL mi NoSQL mi tartışması System Design serisinin "Veri Katmanını Ölçeklemek: Replication, Sharding ve SQL mi NoSQL mi?" yazısında.
Üretimde Dikkat
Document modeli access pattern listesinden başlat ve listeyi modelle birlikte sakla. Gömme kararlarının her biri bir erişim şekline dayanıyor; erişim şekli değişince model de değişmeli. "Kitabın son beş yorumu kitap sayfasında gösterilsin" gibi yeni bir istek, son yorumların bir kopyasını kitap document'ine gömmeyi gerektirebilir.
Büyüyen array'leri izle. Gömülü bir array'in sınırsız büyüyebildiği her yer (yorumlar, log satırları, takipçiler) eninde sonunda document boyut sınırına ya da her okumada taşınan gereksiz veriye dönüşür. Gömmeden önce "bu array'in üst sınırı ne?" diye sor.
Kopyalanan her alan için güncelleme yolunu yaz. Yazar adının kitap document'lerinde kopyalanması, yazar adı değişince çalışacak bir updateMany demek. Bu, 8. yazıdaki tutarlılık tablosunun document modeldeki karşılığı; kopyayı kim, ne zaman güncelliyor?
Validator'ı baştan koy. Bir collection'a validator sonradan eklendiğinde mevcut document'ler o anda kontrol edilmez; yanlış veriler, biri onları güncellemeye kalkana kadar orada kalır. Kurallar ne kadar erken konursa temizlenecek veri o kadar az olur.
Sık Düşülen Tuzaklar
"Şemasız" sözünü "modelsiz" anlamak. Veritabanının şemayı zorunlu tutmaması, uygulamanın şemasız çalıştığı anlamına gelmez; yalnızca kuralların koda dağıldığı anlamına gelir.
Document modelde normalize edip her okumada $lookup yapmak. Bu, ilişkisel veritabanının garantilerini kaybedip join maliyetini korumaktır.
Her şeyi tek document'e gömmek. Müşterinin bütün siparişlerini müşteri document'ine gömmek, sınırsız büyüyen bir array ve her siparişte koca bir document'i yeniden yazmak demek. Sipariş kendi yaşam döngüsü olan bir aggregate; ayrı bir document olmalı.
Snapshot'ı güncellemek. Kitap başlığı değişince siparişlerdeki başlıkları da güncelleyen bir script, geçmiş faturaların içeriğini değiştirir. Kopyalanan alanın kopya mı yoksa o anın kaydı mı olduğu modelde yazılı olmalı.
İlişkisel veritabanında JSON kolonunu kaçış yolu olarak kullanmak. "Sonra karar veririz" diye her şeyi bir JSON kolonuna koymak, 9. yazıdaki EAV (Entity-Attribute-Value) sorunlarını daha modern bir sözdizimiyle geri getirir.
Küçük Bir Deneme
★☆☆ orders collection'ına Alan Turing'in 1002 numaralı siparişini (The Pragmatic Programmer, 1150.00) validator'a uyan biçimde ekle. Hangi alanları müşteri referansından, hangilerini snapshot olarak yazdın?
★★☆ Bir reviews collection'ı tasarla ve kitap sayfası için "son beş yorum ve ortalama puan" ihtiyacını karşıla. Ortalamayı kitap document'inde mi tutarsın, her seferinde mi hesaplarsın? 8. yazıdaki ölçütleri kullan.
★★★ j1.sql'deki sorguyu bir view'a çevir ve bir müşterinin bütün siparişlerini tek bir JSON array'i olarak döndüren ikinci bir sorgu yaz. Aynı ekranı MongoDB'de tek bir okumayla karşılamak için modeli nasıl değiştirmen gerekirdi ve bunun yazma tarafındaki bedeli ne olurdu?
Aynı Bookstore'u ilişkisel, dimensional ve document olarak modelledik. Serinin son yazısında hepsini bir araya getiriyoruz: yeni bir requirement setinden (ön sipariş, kupon, stok rezervasyonu) başlayıp entity listesi, ER (Entity-Relationship) diyagramı, dependency'ler ve normal form'lar üzerinden fiziksel şemaya gidecek, sonunda da bir şema review checklist'i çıkaracağız.