Denormalization: Ne Zaman, Nasıl ve Tutarlılığı Kim Korur?
Denormalization, ölçümle verilen bir performans kararıdır: önce doğru index denenir, yetmezse kopya veri, onu güncel tutacak bir mekanizmayla birlikte saklanır. SQL Server'da kitap kartındaki yorum sayısı ve ortalama puanı her seferinde hesaplamayı, trigger ile books'ta tutmayı ve indexed view'ı logical reads ile karşılaştırıyor; persisted computed column, özet tablo ve tutarlılığı kimin koruduğunu gösteriyoruz.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) örnekleri SQL Server 2025 (17.0, Cumulative Update 10) üzerinde çalıştırılmıştır.
Önce Tahmin Et: Ortalama Puanı Her Seferinde Hesaplamak Pahalı mı?
Kategori sayfasında her kitabın yanında yorum sayısı ve ortalama puan duruyor. Normalize modelde bu iki değer hiçbir tabloda yok; reviews tablosundan her istekte hesaplanıyor. Bir gün biri "books tablosuna review_count ve avg_rating kolonları ekleyelim, her seferinde hesaplamayalım" diyor. Tahmin et: bu değişiklik okumayı kaç kat ucuzlatır, yazmayı ne kadar pahalılaştırır ve ilk yapılması gereken şey bu mu?
Bu yazıda bu soruyu ölçerek cevaplayacağız. Denormalization, normalize edilmiş bir modelde bilerek tekrar ya da türetilmiş veri saklamaktır. Önceki iki yazıda tekrarı nasıl kaldıracağımızı gördük; burada onu ne zaman ve nasıl geri getireceğimizi, getirdiğimizde de kopyanın tutarlılığını kimin koruyacağını konuşacağız.
Önce Ölç: Denormalization Bir Performans Kararıdır
SQL Temelleri serisinin Bookstore şemasını dm_08 veritabanına yükledim. Örnek veride 181 yorum var; farkı görebilmek için 4000 okuyucu ve rastgele puanlı 61.600 yorum ekledim:
INSERT INTO dbo.customers (first_name, last_name, email, country_code)
SELECT N'Reader', CONCAT(N'No', value), CONCAT('reader', value, '@example.com'), 'TR'
FROM GENERATE_SERIES(1, 4000);
INSERT INTO dbo.reviews (book_id, customer_id, rating, reviewed_at)
SELECT b.book_id, c.customer_id,
1 + ABS(CHECKSUM(NEWID())) % 5,
DATEADD(DAY, -((c.customer_id + b.book_id) % 365), '2026-06-30')
FROM dbo.books AS b
CROSS JOIN dbo.customers AS c
WHERE c.email LIKE 'reader%'
AND (c.customer_id * 7 + b.book_id * 13) % 5 = 0;
SELECT COUNT(*) AS review_count FROM dbo.reviews;
(4000 rows affected)
(61600 rows affected)
review_count
------------
61781
(1 rows affected)
Kategori sayfasının normalize sorgusu, kitapları yorumlarla birleştirip grupluyor. Container emülasyonla çalıştığı için süre yerine SET STATISTICS IO ile logical reads'e bakıyorum; çıktıdaki satırların yalnızca logical reads kısmını bıraktım:
SET STATISTICS IO ON;
SELECT b.book_id, b.title,
COUNT(r.review_id) AS review_count,
CAST(AVG(r.rating * 1.0) AS DECIMAL(3,2)) AS avg_rating
FROM dbo.books AS b
LEFT JOIN dbo.reviews AS r ON r.book_id = b.book_id
WHERE b.category_id = 3
GROUP BY b.book_id, b.title
ORDER BY b.title;
SET STATISTICS IO OFF;
book_id title review_count avg_rating
------- ------------------------- ------------ ----------
26 Dune 805 3.06
27 Dune Messiah 804 2.98
28 Foundation 804 2.99
34 Hyperion 801 2.95
29 I, Robot 805 3.02
33 Neuromancer 806 2.87
30 The Left Hand of Darkness 802 3.06
31 The Time Machine 801 2.90
32 The War of the Worlds 800 3.01
(9 rows affected)
Table 'Worktable'. Scan count 1, logical reads 171
Table 'reviews'. Scan count 1, logical reads 217
Table 'books'. Scan count 1, logical reads 3
reviews tablosunun tamamı tarandı: 217 page, üstüne gruplama için 171 page'lik bir worktable. Denormalize etmeden önce sorulacak ilk soru şu: bu sorgu kötü bir model yüzünden mi yavaş, yoksa eksik bir index yüzünden mi? reviews üzerinde book_id'ye göre sıralı ve puanı da taşıyan bir index ekleyip aynı sorguyu çalıştıralım (sonuç satırları aynı olduğu için yalnızca logical reads satırlarını gösteriyorum):
CREATE INDEX IX_reviews_book_id ON dbo.reviews (book_id) INCLUDE (rating);
GO
SET STATISTICS IO ON;
SELECT b.book_id, b.title,
COUNT(r.review_id) AS review_count,
CAST(AVG(r.rating * 1.0) AS DECIMAL(3,2)) AS avg_rating
FROM dbo.books AS b
LEFT JOIN dbo.reviews AS r ON r.book_id = b.book_id
WHERE b.category_id = 3
GROUP BY b.book_id, b.title
ORDER BY b.title;
SET STATISTICS IO OFF;
GO
DROP INDEX IX_reviews_book_id ON dbo.reviews;
Table 'reviews'. Scan count 9, logical reads 41
Table 'Worktable'. Scan count 0, logical reads 0
Table 'books'. Scan count 1, logical reads 3
Okuma 391 page'den 44 page'e indi ve şemada tek bir kopya yok. Çoğu "denormalize edelim" önerisi burada biter. Index, worktable ve plan operator'lerinin ayrıntısı SQL Server Derinlemesine serisinin "Nonclustered Index Tasarımı" yazısında. Denormalization ancak doğru index'ten sonra bile okuma maliyeti kabul edilemezse ve okuma, yazmadan çok daha sık oluyorsa masaya gelir. Kitap kartı tipik bir aday: bir kitap günde binlerce kez görüntülenir, birkaç kez yorum alır. Karar verirken iki sayıya ihtiyacın var: okuma/yazma oranı ve her yolun okuma ile yazma maliyeti. İkincisini aşağıda üç yol için ölçeceğiz.
Denormalization'ı bir tasarım tembelliğinden ayıran şey de bu: arkasında bir ölçüm ve kopyayı güncel tutacak bir mekanizma vardır. İkisi yoksa elindeki şey denormalization değil, 5. yazıdaki sipariş Excel'idir.
Persisted Computed Column: Aynı Satırın Türevi
En ucuz denormalization, aynı satırdaki kolonlardan türeyen değerdir. Kalem tutarı (quantity * unit_price) her sorguda hesaplanabilir; ama raporlarda sürekli kullanılıyorsa persisted computed column olarak saklanabilir. Veritabanı onu her INSERT ve UPDATE'te kendisi hesaplar, kopya hiçbir zaman kaynağından sapmaz. Sınırı da var: ifade yalnızca aynı satırı görebilir. Sipariş toplamını aynı yolla saklamayı deneyelim:
ALTER TABLE dbo.order_items ADD
line_total AS CAST(quantity * unit_price AS DECIMAL(12,2)) PERSISTED;
GO
ALTER TABLE dbo.orders ADD
total_amount AS (SELECT SUM(quantity * unit_price) FROM dbo.order_items AS oi
WHERE oi.order_id = order_id);
GO
SELECT TOP (3) order_id, quantity, unit_price, line_total
FROM dbo.order_items
ORDER BY order_item_id;
Msg 1046, Level 15, State 1, Server b8be943222fa, Line 2
Subqueries are not allowed in this context. Only scalar expressions are allowed.
order_id quantity unit_price line_total
-------- -------- ---------- ----------
1 2 290.90 581.80
1 1 460.90 460.90
2 1 590.00 590.00
(3 rows affected)
line_total eklendi; total_amount ise reddedildi, çünkü başka bir tablodaki satırlara bakıyor. Bir değer başka satırlardan türüyorsa, onu saklamak için bir mekanizma seçmen gerekir. Aşağıdaki her bölüm bir mekanizma.
Stored Derived Kolon: Aynı Transaction'da Uygulama Kodu
En doğrudan yol, kopyayı kaynağı değiştiren kodla aynı transaction'da güncellemek. Uygulamada bu, bir repository method'u ya da aggregate'in kendisi olur; burada aynı davranışı bir procedure ile gösteriyorum:
ALTER TABLE dbo.orders ADD total_amount DECIMAL(12,2) NOT NULL
CONSTRAINT DF_orders_total_amount DEFAULT (0);
GO
UPDATE o
SET total_amount = s.total
FROM dbo.orders AS o
JOIN (SELECT order_id, SUM(line_total) AS total
FROM dbo.order_items GROUP BY order_id) AS s ON s.order_id = o.order_id;
GO
CREATE OR ALTER PROCEDURE dbo.add_order_item
@order_id INT, @book_id INT, @quantity INT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
INSERT INTO dbo.order_items (order_id, book_id, quantity, unit_price)
SELECT @order_id, book_id, @quantity, list_price
FROM dbo.books WHERE book_id = @book_id;
UPDATE dbo.orders
SET total_amount += (SELECT line_total FROM dbo.order_items
WHERE order_id = @order_id AND book_id = @book_id)
WHERE order_id = @order_id;
COMMIT;
END;
SELECT order_id, total_amount FROM dbo.orders WHERE order_id = 1;
EXEC dbo.add_order_item @order_id = 1, @book_id = 26, @quantity = 2;
SELECT order_id, total_amount FROM dbo.orders WHERE order_id = 1;
order_id total_amount
-------- ------------
1 1042.70
(1 rows affected)
order_id total_amount
-------- ------------
1 1823.70
(1 rows affected)
İki Dune eklendi, toplam 781,00 arttı. Kalem ve toplam aynı transaction'da yazıldığı için ikisi birlikte ya commit olur ya rollback. Bu yolun zayıf noktası kapsamı: kural yalnızca bu procedure'den geçen yazmalar için geçerli. Bir destek script'i order_items.quantity'yi doğrudan güncellerse toplam sessizce yanlış kalır. Uygulama kodunda kopya tutmak, "bu tabloya yalnızca şu yoldan yazılır" disiplinine bağlıdır. DDD (Domain-Driven Design) dilinde bu, sipariş ve kalemlerinin tek bir aggregate olması ve toplamın aggregate'in invariant'ı olmasıdır; Domain-Driven Design serisinin "Aggregate Tasarımı" yazısı bu sınırı anlatır.
Kitap Kartı: Saklanan Kolonlar ve Trigger
Kitap kartına dönelim. books tablosuna yorum sayısını ve puan toplamını ekliyorum; ortalama, aynı satırdan türediği için bir computed column. Ortalama yerine toplamı saklamak bilinçli bir karar: toplam ve sayı artımlı güncellenebilir, ortalama güncellenemez.
ALTER TABLE dbo.books ADD
review_count INT NOT NULL CONSTRAINT DF_books_review_count DEFAULT (0),
rating_sum INT NOT NULL CONSTRAINT DF_books_rating_sum DEFAULT (0);
GO
ALTER TABLE dbo.books ADD
avg_rating AS CAST(rating_sum * 1.0 / NULLIF(review_count, 0) AS DECIMAL(3,2));
UPDATE b
SET review_count = s.review_count,
rating_sum = s.rating_sum
FROM dbo.books AS b
JOIN (SELECT book_id, COUNT(*) AS review_count, SUM(rating) AS rating_sum
FROM dbo.reviews
GROUP BY book_id) AS s ON s.book_id = b.book_id;
(77 rows affected)
Kopyayı güncel tutmak için bu kez kaynağın yanına, veritabanına bir trigger koyuyorum. Trigger, hangi yoldan gelirse gelsin her INSERT, UPDATE ve DELETE'te çalışır; destek script'i de yakalanır:
CREATE OR ALTER TRIGGER dbo.trg_reviews_book_stats
ON dbo.reviews
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
SET NOCOUNT ON;
WITH delta AS (
SELECT book_id, COUNT(*) AS cnt, SUM(rating) AS total FROM inserted GROUP BY book_id
UNION ALL
SELECT book_id, -COUNT(*), -SUM(rating) FROM deleted GROUP BY book_id
)
UPDATE b
SET review_count += d.cnt,
rating_sum += d.total
FROM dbo.books AS b
JOIN (SELECT book_id, SUM(cnt) AS cnt, SUM(total) AS total
FROM delta GROUP BY book_id) AS d ON d.book_id = b.book_id;
END;
SET STATISTICS IO ON;
SELECT book_id, title, review_count, avg_rating
FROM dbo.books
WHERE category_id = 3
ORDER BY title;
SET STATISTICS IO OFF;
book_id title review_count avg_rating
------- ------------------------- ------------ ----------
26 Dune 805 3.06
27 Dune Messiah 804 2.98
28 Foundation 804 2.99
34 Hyperion 801 2.95
29 I, Robot 805 3.02
33 Neuromancer 806 2.87
30 The Left Hand of Darkness 802 3.06
31 The Time Machine 801 2.90
32 The War of the Worlds 800 3.01
(9 rows affected)
Table 'Worktable'. Scan count 0, logical reads 0
Table 'books'. Scan count 1, logical reads 3
Okuma 3 page; reviews'e hiç dokunulmadı. Bedeli yazma tarafında:
SET STATISTICS IO ON;
INSERT INTO dbo.reviews (book_id, customer_id, rating, reviewed_at)
SELECT 26, customer_id, 5, '2026-07-01'
FROM dbo.customers WHERE email = 'ada.reviews@example.com';
SET STATISTICS IO OFF;
SELECT review_count, avg_rating FROM dbo.books WHERE book_id = 26;
Table 'customers'. Scan count 0, logical reads 4
Table 'books'. Scan count 0, logical reads 2
Table 'reviews'. Scan count 0, logical reads 5
Table 'books'. Scan count 0, logical reads 2
(1 rows affected)
review_count avg_rating
------------ ----------
806 3.07
(1 rows affected)
books iki kez görünüyor: biri foreign key kontrolü, diğeri trigger'ın UPDATE'i. Yorum başına iki page'lik ek yazma ucuz görünüyor, ama asıl maliyet sayıda değil: popüler bir kitaba aynı anda gelen her yorum aynı books satırını güncellemek ister ve bu satır bir sıcak nokta olur. Trigger'ların gizli maliyetleri, iç içe çalışma ve set-based yazma zorunluluğu SQL Server Derinlemesine serisinin "Function'lar ve Trigger'lar" yazısında.
Indexed View: Veritabanının Koruduğu Özet
Üçüncü yol, özeti tablo yerine bir indexed view olarak tanımlamak. View'a unique clustered index koyduğunda SQL Server sonucu diske yazar ve kaynak tablo her değiştiğinde aynı transaction içinde günceller; trigger yazmana gerek kalmaz. İlk deneme AVG ile:
CREATE VIEW dbo.book_review_avg
WITH SCHEMABINDING
AS
SELECT book_id, COUNT_BIG(*) AS review_count, AVG(rating) AS avg_rating
FROM dbo.reviews
GROUP BY book_id;
GO
CREATE UNIQUE CLUSTERED INDEX UX_book_review_avg ON dbo.book_review_avg (book_id);
Msg 10125, Level 16, State 1, Server b8be943222fa, Line 1
Cannot create index on view "dm_08.dbo.book_review_avg" because it uses aggregate "AVG". Consider eliminating the aggregate, not indexing the view, or using alternate aggregates. For example, for AVG substitute SUM and COUNT_BIG, or for COUNT, substitute COUNT_BIG.
Hata mesajı çözümü de söylüyor: AVG artımlı korunamaz, SUM ve COUNT_BIG korunabilir. Trigger'daki kararın aynısı. Indexed view'ın başka kuralları da var: WITH SCHEMABINDING, iki parçalı tablo adları, GROUP BY varsa COUNT_BIG(*), outer join ve subquery yok. Trigger'ı kapatıp view'ı kuruyorum:
DROP VIEW dbo.book_review_avg;
DISABLE TRIGGER dbo.trg_reviews_book_stats ON dbo.reviews;
GO
CREATE VIEW dbo.book_review_stats
WITH SCHEMABINDING
AS
SELECT book_id, COUNT_BIG(*) AS review_count, SUM(rating) AS rating_sum
FROM dbo.reviews
GROUP BY book_id;
GO
CREATE UNIQUE CLUSTERED INDEX UX_book_review_stats ON dbo.book_review_stats (book_id);
SET STATISTICS IO ON;
SELECT b.book_id, b.title, s.review_count,
CAST(s.rating_sum * 1.0 / s.review_count AS DECIMAL(3,2)) AS avg_rating
FROM dbo.books AS b
JOIN dbo.book_review_stats AS s WITH (NOEXPAND) ON s.book_id = b.book_id
WHERE b.category_id = 3
ORDER BY b.title;
INSERT INTO dbo.reviews (book_id, customer_id, rating, reviewed_at)
SELECT 26, customer_id, 4, '2026-07-01'
FROM dbo.customers WHERE email = 'alan.reviews@example.com';
SET STATISTICS IO OFF;
book_id title review_count avg_rating
------- ------------------------- ------------ ----------
26 Dune 806 3.07
27 Dune Messiah 804 2.98
28 Foundation 804 2.99
34 Hyperion 801 2.95
29 I, Robot 805 3.02
33 Neuromancer 806 2.87
30 The Left Hand of Darkness 802 3.06
31 The Time Machine 801 2.90
32 The War of the Worlds 800 3.01
(9 rows affected)
Table 'book_review_stats'. Scan count 0, logical reads 18
Table 'Worktable'. Scan count 0, logical reads 0
Table 'books'. Scan count 1, logical reads 3
Table 'book_review_stats'. Scan count 0, logical reads 6
Table 'Worktable'. Scan count 3, logical reads 12
Table 'customers'. Scan count 0, logical reads 4
Table 'books'. Scan count 0, logical reads 2
Table 'reviews'. Scan count 0, logical reads 5
(1 rows affected)
Okuma 21 page (view'dan dokuz seek, artı books), yazmada view index'i için 6 page ve 12 page'lik bir worktable. NOEXPAND hint'i view'ın index'inin doğrudan kullanılmasını istiyor; Microsoft Learn'e göre optimizer'ın indexed view'ı kendiliğinden kullanması yalnızca belirli edition'larda var, Standard edition'da bu hint gerekiyor. PostgreSQL'de karşılığı materialized view, ama davranışı farklı: PostgreSQL 18 dokümanına göre içerik ancak REFRESH MATERIALIZED VIEW çalıştırıldığında yenilenir, kaynak tablo değişince kendiliğinden güncellenmez. Okurları bloklamadan yenilemek için CONCURRENTLY ve view üzerinde bir unique index gerekir. Yani PostgreSQL'deki materialized view, bir sonraki bölümdeki özet tabloya daha yakın.
Özet Tablo ve Batch Yeniden Hesaplama
Her yazmada güncel olması gerekmeyen özet veriler için en basit ve en sağlam yol, özeti periyodik olarak yeniden hesaplamak. Günlük satış raporu dakikalık doğruluk istemez; gece çalışan bir job yeterlidir. Yeniden hesaplama, artımlı güncellemeden daha kolay doğru tutulur: dünkü hata bugünkü çalıştırmada kendiliğinden düzelir.
CREATE TABLE dbo.daily_sales (
sales_date DATE NOT NULL,
order_count INT NOT NULL,
units_sold INT NOT NULL,
revenue DECIMAL(14,2) NOT NULL,
computed_at DATETIME2(0) NOT NULL,
CONSTRAINT PK_daily_sales PRIMARY KEY (sales_date)
);
GO
DECLARE @from DATE = '2026-06-01', @to DATE = '2026-07-01';
BEGIN TRANSACTION;
DELETE FROM dbo.daily_sales WHERE sales_date >= @from AND sales_date < @to;
INSERT INTO dbo.daily_sales (sales_date, order_count, units_sold, revenue, computed_at)
SELECT CAST(o.ordered_at AS DATE), COUNT(DISTINCT o.order_id),
SUM(oi.quantity), SUM(oi.line_total), SYSUTCDATETIME()
FROM dbo.orders AS o
JOIN dbo.order_items AS oi ON oi.order_id = o.order_id
WHERE o.status <> 'Cancelled'
AND o.ordered_at >= @from AND o.ordered_at < @to
GROUP BY CAST(o.ordered_at AS DATE);
COMMIT;
SELECT TOP (3) sales_date, order_count, units_sold, revenue
FROM dbo.daily_sales
ORDER BY revenue DESC;
(0 rows affected)
(11 rows affected)
sales_date order_count units_sold revenue
---------- ----------- ---------- -------
2026-06-06 2 6 2433.20
2026-06-09 1 5 1922.70
2026-06-03 1 2 1540.50
(3 rows affected)
Pencere (@from, @to) önemli: geç gelen iadeler ve iptaller son birkaç günü değiştirebilir, bu yüzden job her çalıştığında yalnızca bugünü değil son birkaç günü yeniden hesaplamalı. computed_at kolonu da raporu okuyan kişiye verinin ne kadar taze olduğunu söyler.
İki teknik daha aynı aileden. Kopyalanmış görüntü kolonları, başka tablodaki bir değeri (yazar adı, kategori adı) listeleme kolaylığı için satıra kopyalar; her biri bir senkronizasyon yolu ister. JSON (JavaScript Object Notation) read model ise ekranın ihtiyaç duyduğu veriyi tek bir document olarak hazırlar ve event'lerle günceller; bu yolun mimari tarafı CQRS (Command Query Responsibility Segregation) ve Event Sourcing serisinin "Ayrı Read Model: Materialized View, Senkronizasyon ve Eventual Consistency ile Yaşamak" yazısında.
Tutarlılığı Kim Korur?
Her teknik aynı soruya farklı bir cevap veriyor: kaynak değiştiğinde kopyayı kim günceller ve bu hangi anda olur?
| Yol | Kopyayı kim günceller | Ne zaman tutarlı | Tipik bozulma |
|---|---|---|---|
| Persisted computed column | Veritabanı, aynı satırda | Her zaman | Yok; yalnızca aynı satır |
| Uygulama kodu, aynı transaction | Kodun kendisi | Commit anında | Koddan geçmeyen yazma |
| Trigger | Veritabanı, her yazmada | Commit anında | Trigger kapatılması, sıcak satır |
| Indexed view | Veritabanı motoru | Commit anında | Yazma maliyeti, view kısıtları |
| Batch yeniden hesaplama | Zamanlanmış job | Job çalıştığında | Job'un çalışmaması, pencere dışı değişiklik |
| Event ile read model | Event consumer'ı | Eventual | Kaybolan ya da sırası bozulan event |
Bu yazıda bir bozulmayı zaten ürettik. Indexed view'ı ölçmek için trigger'ı kapattım ve sonra bir yorum ekledim; büyük bir import'u hızlandırmak için trigger kapatan bir script de aynısını yapar. Saklanan sayıyı gerçeğiyle karşılaştıran bir kontrol sorgusu bunu yakalar:
SELECT b.book_id, b.review_count AS stored_count, COUNT(r.review_id) AS actual_count
FROM dbo.books AS b
LEFT JOIN dbo.reviews AS r ON r.book_id = b.book_id
GROUP BY b.book_id, b.review_count
HAVING b.review_count <> COUNT(r.review_id);
book_id stored_count actual_count
------- ------------ ------------
26 806 807
(1 rows affected)
Hangi mekanizmayı seçersen seç, böyle bir kontrolü bir job olarak çalıştır ve farkı alarma bağla. Kopya tutan her sistem er geç sapar; soru sapmanın olup olmayacağı değil, ne kadar sürede fark edileceği.
Üretimde Dikkat
Kararı ölçümle belgele. "Kategori sayfası 391 logical read'den 3'e indi, yorum başına yazma 2 page arttı, okuma/yazma oranı 2000'e 1" gibi bir not, denormalization'ın neden var olduğunu ve ne zaman kaldırılabileceğini söyler. Ölçümü üretime benzer veri hacmiyle yap; 181 yorumla yapılan ölçüm hiçbir şey söylemez.
Sıcak satırı izle. Trigger ya da uygulama koduyla tutulan sayaçlar, aynı satıra yoğun yazma geldiğinde lock bekleme süresini artırır. Popüler bir kitabın yorum sayacı için bu nadiren sorun olur; bir bilet satışının stok sayacı için ciddi bir darboğazdır. Böyle durumlarda batch ya da event tabanlı güncelleme daha uygun olabilir.
Backfill'i ve geri dönüşü planla. Yeni bir kopya kolon eklemek, mevcut satırlar için bir backfill demektir; büyük tablolarda bunu parçalar hâlinde yap. Kolonu kaldırmak da bir migration'dır. Şema değişikliğinin geriye uyumlu deploy'u Deployment Stratejileri serisinin "Migration'ların Deploy'daki Yeri" yazısında.
Kopyanın kaynağını şemada yaz. books.review_count kolonunun açıklamasına "reviews'ten trg_reviews_book_stats ile tutulur" yazmak, sonraki geliştiricinin kolonu elle güncellemeye kalkmasını önler.
Sık Düşülen Tuzaklar
Snapshot veriyi denormalization sanmak. order_items.unit_price, books.list_price'ın kopyası değil; sipariş anındaki fiyat, ayrı bir olgu. Onu katalog fiyatıyla "senkronize etmeye" çalışan bir job, geçmiş siparişlerin tutarını değiştirir. Teslimat adresi, fatura unvanı ve vergi oranı da aynı türden: bir kopya mı yoksa o anın kaydı mı olduğunu modelde açıkça yaz.
Senkronizasyon yolu olmayan kopya kolon. Listeleme kolay olsun diye order_items'a title kopyalayıp kitap adı düzeltildiğinde ne olacağını kararlaştırmamak. Ya bu bir snapshot'tır ve değişmemesi doğrudur, ya da bir kopyadır ve onu güncelleyen bir mekanizma olmalıdır. Arada bir durum yoktur.
Index'i denemeden denormalize etmek. Yukarıdaki ilk ölçümde okuma maliyetinin yüzde 89'u tek bir index ile gitti. Denormalization, index'in yetmediği yerde başlar.
Ortalamayı saklamak. Ortalama artımlı güncellenemez; yeni bir değer geldiğinde eski ortalamadan yenisini hesaplamak için zaten sayıya ihtiyacın var. Toplamı ve sayıyı sakla, ortalamayı türet.
Trigger'ı yalnızca INSERT için yazmak. Yorum silinir, puan değiştirilir. Kopyayı tutan mekanizma kaynağın her değişikliğini görmelidir; yukarıdaki trigger bu yüzden inserted ve deleted'ı birlikte işliyor.
Küçük Bir Deneme
★☆☆ Trigger'ı yeniden etkinleştir (ENABLE TRIGGER) ve books tablosundaki sapmayı tek bir UPDATE ile düzelt. Ardından kontrol sorgusunun boş döndüğünü doğrula.
★★☆ Bir yorumun puanını 1'den 5'e güncelle ve trigger'ın rating_sum'ı doğru değiştirdiğini kontrol et. Aynı UPDATE'i yorumun book_id'sini değiştirerek tekrarla: iki kitabın sayaçları da doğru mu?
★★★ daily_sales için bir sipariş iptal edildiğinde yalnızca etkilenen günü yeniden hesaplayan bir procedure yaz. Gece job'ının penceresini neye göre seçerdin? Bu soruyu veriyle cevaplamak için orders tablosunda hangi kolon eksik?
Denormalization'ın bilinçli bir karar olduğunu ve bedelini ölçtük. Bir sonraki yazıda bilinçsiz kararlara bakıyoruz: EAV (Entity-Attribute-Value), virgülle ayrılmış listeler, phone1/phone2/phone3 kolonları ve tek büyük lookup tablosu gibi modeling anti-pattern'leri, neden cazip göründükleri ve hangi sorguda patladıkları.