Her yeni ihtiyaçta tabloya bir kolon daha ekliyoruz: veri modeli nerede yanlış kuruldu?
Veri modeli, şirketin işini anlatan cümlelerin tabloya çevrilmiş hali. Yanlış kurulduğunda hata mesajı vermez, belirti başka yerlerden çıkar: her yeni ihtiyaç bir kolon daha, her rapor bir istisna daha, her düzeltme başka bir ekranı bozar. Kök neden genelde üç yerde toplanıyor. Kavramın kendisi yerine o anki görüntüsü saklanmıştır, kurallar "esnek olsun" diye veritabanından uygulamaya taşınmıştır ve zaman boyutu hiç düşünülmemiştir. İyi haber şu: bunların büyük kısmı sistemi baştan yazmayı gerektirmiyor, ama düzeltme maliyeti her ay biraz daha artıyor.
Yanlış modelin faturası depolamada değil, destek kuyruğunda
Veri modeli tartışması çoğu ekipte "disk zaten ucuz" diye kapanıyor. Fatura orada kesilmiyor. Bir müşteri kaydının iki kez oluşabilmesi destek biletine dönüşüyor, durum alanında on yedi farklı serbest metin olması raporu yanlış gösteriyor, geçmişi saklamayan bir tablo yüzünden "bu fatura neden bu tutarda kesilmiş" sorusuna kimse cevap veremiyor.
Bu belirtilerin tamamı önce performans sorunu gibi görünür. Yavaş sorgu avına çıkmadan bir soru sorun: bu sorgu modelin eksiğini kapatmak için mi bu kadar karmaşık? Dört tabloyu birleştirip sonra uygulamada tekrar gruplayan bir rapor sorgusu genellikle indeks problemi değil, model problemidir.
"Durum" alanını serbest metin tutmak
Sipariş durumu, talep durumu, abonelik durumu. Bu alanlar serbest metin olarak başlar ve birkaç yıl içinde Beklemede, beklemede, BEKLIYOR ve bir boşluk karakteri fazlası olan dördüncü varyant aynı tabloda birlikte yaşar. Üç seçeneğiniz var ve seçim, değerleri kimin değiştireceğine bağlı.
Değer kümesi kapalıysa ve yılda bir iki kez değişiyorsa veritabanı enum tipi iş görür. PostgreSQL tarafında iki sınırı bilin: belgelerin belirttiği gibi ALTER TYPE ... ADD VALUE bir işlem bloğu içinde çalıştırıldığında yeni değer işlem commit edilene kadar kullanılamaz, ve enum'dan değer silmenin bir yolu yoktur. Yalnızca ekleme ve yeniden adlandırma var.
Değerleri iş birimi yönetecekse ya da değerin kendi nitelikleri olacaksa (ekranda görünen ad, sıralama, aktif mi, çeviri) referans tablosu ve yabancı anahtar doğru cevaptır. Üçüncü seçenek, durumu hiç tek bir alanda tutmamak: bazı varlıklarda asıl ihtiyaç, geçişlerin kaydıdır. Sipariş ne zaman onaylandı, kim iptal etti, hangi sırayla ilerledi. Bunu denetim izi tablosuyla tutup mevcut durumu oradan türetmek, sonradan sorulacak soruların yarısını baştan cevaplar.
NULL aynı anda üç farklı şeyi anlatıyor
Bir kolonda NULL görüyorsunuz. Bu "bilinmiyor" mu, "bu kayıt için geçerli değil" mi, yoksa "henüz girilmedi" mi? Üçü farklı iş kuralları doğurur ama veritabanında aynı görünür. Kurumsal müşterinin doğum tarihi yoktur, bireysel müşterininki girilmemiş olabilir. İkisini ayırt edemezseniz zorunlu alan kontrolü yazamazsınız.
NULL'ın iki davranışı da ekipleri şaşırtıyor. Birincisi, PostgreSQL belgelerinin açıkça yazdığı gibi, bir CHECK kısıtı ifade true ya da NULL döndüğünde sağlanmış sayılır. Yani CHECK (fiyat > 0) yazdığınızda NULL fiyat bu kontrolden geçer. Kontrolü gerçekten istiyorsanız NOT NULL'ı ayrıca koymanız gerekir.
İkincisi benzersizlik. Standart davranışta iki NULL birbirine eşit sayılmaz, dolayısıyla UNIQUE kolonda istediğiniz kadar NULL satır birikebilir. PostgreSQL 15 (13 Ekim 2022) bunu değiştirmek için UNIQUE NULLS NOT DISTINCT seçeneğini getirdi. Varsayılanın veritabanına göre değişebildiğini not edin, PostgreSQL belgeleri de bu noktada taşınabilirlik uyarısı veriyor.
Pratik kural: NULL'ı "henüz bilmiyoruz" anlamıyla sınırlayın. "Bu tip kayıtta bu alan yok" durumu bir model işaretidir, muhtemelen iki farklı varlığı tek tabloya sıkıştırmışsınızdır.
Kuralı veritabanına yazmazsanız, bir gün biri etrafından dolaşır
"Doğrulamayı uygulamada yapıyoruz" cümlesi, tek bir yazma yolu olduğu sürece doğru. Gerçekte yazan taraflar çoğalır: yönetim paneli, gece çalışan içe aktarma işi, bir defalık veri düzeltme betiği, entegrasyon servisi, bir de canlıda elle çalıştırılan o UPDATE. Yabancı anahtar, UNIQUE ve CHECK kısıtları bu yolların hepsinde geçerli olan tek katmandır.
Büyük tabloya kısıt eklemek korkutucu görünüyorsa iki aşamalı yol var. PostgreSQL'de kısıtı NOT VALID ile ekleyip mevcut satırları taramadan yürürlüğe sokabilir, sonra VALIDATE CONSTRAINT ile doğrulayabilirsiniz. Belgelerin anlattığı gibi doğrulama adımı yalnızca SHARE UPDATE EXCLUSIVE kilidi alır, çünkü yeni satırları zaten kısıt koruyor, geriye sadece eski satırları kontrol etmek kalıyor. Bu yüzden tabloyu kapatmadan temizliğe başlayabilirsiniz.
"Esnek olsun" diye açılan anahtar-değer tablosu
Her müşterinin farklı alanları olacak diye kurulan nitelik_adi ve nitelik_degeri tablosu, Bill Karwin'in SQL Antipatterns kitabında Entity-Attribute-Value adıyla anlatılan klasik tuzak. Kitabın ilk baskısı 2010'da çıktı, sorun hâlâ aynı sıklıkta karşımıza çıkıyor.
Bedeli şurada: değerler metin olarak saklandığı için tip kontrolü yok, tarih alanına "yarın" yazılabilir. NOT NULL koyamazsınız, çünkü kolon bütün nitelikler için ortak. Yabancı anahtar koyamazsınız. Ve her rapor, satırları kolona çeviren bir pivot sorgusuna dönüşür. On alanlı bir ekran için on kez kendisiyle birleştirilen bir tablo görüyorsanız sebebi budur.
Bu yapının meşru olduğu bir durum var: ürününüz gerçekten müşterinin tanımladığı alanları destekliyorsa. O zaman da yanına alan tanımlarını tutan bir meta veri tablosu koyun (alanın tipi, zorunlu mu, hangi kiracıya ait) ve doğrulamayı o tanıma göre yapın. Çok kiracılı bir üründe bu ayrım baştan netleşmezse, müşteriye özel alan ile ürünün çekirdek alanı birbirine karışır.
JSON kolonu: sakladığınız veri mi, sorguladığınız veri mi
Modern veritabanlarının JSON desteği gerçek bir rahatlık, ama ölçütü basit tutun. JSON kolonu, bütün olarak yazıp bütün olarak okuduğunuz veri için doğrudur: dış servisten gelen ham yanıt, bir formun gönderildiği andaki tam içeriği, isteğe bağlı ayarlar. Filtrelediğiniz, birleştirdiğiniz ve topladığınız veri için yanlıştır.
Ayrım sadece zevk meselesi değil, indeksleme tarafında karşılığı var. PostgreSQL jsonb üzerinde GIN indeksi verir. MySQL'de JSON kolonunu doğrudan indeksleyemezsiniz: belgelerin açıkladığı gibi değeri üretilmiş kolona çıkarıp fonksiyonel indeks kurmanız, dizi alanları için de CAST(... AS ... ARRAY) söz dizimiyle çok değerli indeks tanımlamanız gerekir. Çok değerli indeksler MySQL 8.0.17 ile (22 Temmuz 2019) geldi ve kendi sınırları var: kapsayan indeks olarak kullanılamıyor, aralık taramasını desteklemiyor, yabancı anahtarda yer alamıyor.
İşleyen kural şu: JSON içindeki bir alan bir raporun WHERE koşuluna girdiği gün, o alanı gerçek kolona terfi ettirin.
Anahtar seçimi: vergi numarası birincil anahtar olmaz
Doğal anahtarlar cazip görünür. Vergi numarası, e-posta, ERP'den gelen ürün kodu. Üçü de değişir. Şirket birleşir, müşteri e-postasını günceller, ERP kodlama şemasını yeniler. Birincil anahtarınız değiştiğinde ona bağlı her tablodaki yabancı anahtar da değişmek zorunda kalır.
Pratik yol: anlamı olmayan bir vekil anahtar kullanın, doğal anahtarı da ayrı bir kolonda UNIQUE kısıtıyla saklayın. Böylece iş kuralı korunur ama ilişkiler ona bağlanmaz.
Dağıtık üretim ya da istemci tarafında kimlik gerekiyorsa UUID'ye geçerken sürümü bilinçli seçin. UUID standardı Mayıs 2024'te RFC 9562 ile güncellendi ve RFC 4122'nin yerini aldı. RFC, rastgele UUID'lerin veritabanı indeksinde sorun çıkardığını açıkça yazıyor: ardışık üretilen değerler indekste birbirine yakın düşmediği için ekleme rastgele konumlara gidiyor. Zaman sıralı UUIDv7 ise Unix zaman damgasını en anlamlı bitlere koyduğu için indeks yerelliğini koruyor. Veritabanı anahtarı için tercih edilen sürüm bu.
Bugünün değeriyle dünün değeri aynı satırda duramaz
Model hatalarının en pahalısı zamanı unutmak. Müşterinin adresini müşteri tablosunda tutuyorsanız ve siparişte sadece müşteri kimliği varsa, adres değiştiği gün geçen yılın faturası da yeni adresi göstermeye başlar. Fiyat, KDV oranı, indirim, unvan, hepsi aynı problemi yaşar.
Çözüm, akademik anlamda normalizasyonu bozmak değil, iki farklı soruyu ayırmak. "Bu müşterinin bugünkü adresi nedir" ile "bu sipariş hangi adrese kesildi" farklı sorulardır. İkincisinin cevabı siparişin yanında, o anki haliyle donmuş olarak durmalı. Buna anlık görüntü (snapshot) denir ve kasıtlı tekrardır.
Aynı ayrım kayıt zamanı ile geçerlilik zamanı arasında da gerekir. Bir kur değeri sisteme dün akşam girilmiş olabilir ama geçerli olduğu gün bugündür. Tarih ve saat dilimi tarafındaki tuzaklar ile para ve kur saklama kuralları bu noktada birleşiyor.
Silmek de bir modelleme kararıdır
Çoğu ekip gerçek silme yerine silindi_mi ya da silinme_tarihi kolonu kullanıyor. Makul, ama iki şartı var. Birincisi, bundan sonra yazılan her sorgunun bu filtreyi içermesi gerekir ve bir gün biri unutur. Varsayılan olarak silinmemiş kayıtları döndüren bir görünüm (view) kullanmak bu riski azaltır.
İkincisi benzersizlik. Silinen bir kaydı aynı e-postayla yeniden oluşturmak isteyen kullanıcı, tam kolon üzerinde kurulu bir UNIQUE kısıt yüzünden hata alır. Çözüm kısmi indeks: benzersizliği yalnızca silinme tarihi boş olan satırlar için uygulayın.
Saklama süresinin kendisi de modelin parçası. Bazı kayıt türleri için saklama ya da silme süresi mevzuattan gelebilir, bu süreyi teknik tarafta tahmin etmeyin, hukuk tarafıyla netleştirip tabloya politika olarak yazın.
Şemayı değiştirmek göründüğü kadar pahalı değil, yanlış yapılırsa çok pahalı
Ekiplerin modeli düzeltmekten kaçınma sebebi genelde "tablo kilitlenir" korkusu. PostgreSQL'de bu korkunun çoğu artık yersiz. ALTER TABLE belgeleri net: uçucu olmayan bir DEFAULT ile kolon eklediğinizde değer tablonun meta verisinde saklanır ve tablo yeniden yazılmaz, işlem büyük tablolarda bile hızlıdır. Buna karşılık uçucu bir DEFAULT (clock_timestamp() gibi), saklanan üretilmiş kolon ya da identity kolonu eklemek tablonun ve indekslerinin tamamını yeniden yazdırır. Kolon tipini değiştirmek de normalde yeniden yazma demektir.
Asıl risk tek adımda yapılan değişikliklerde. Kolonu yeniden adlandırmak, eski uygulama sürümü hâlâ ayaktayken anında hata üretir. Danilo Sato'nun 2014'te Martin Fowler'ın sitesinde yayımladığı Parallel Change kalıbı burada işe yarıyor: genişlet, taşı, daralt. Önce yeni kolonu ekleyin ve iki tarafa da yazın, sonra okuyucuları taşıyın, en son eskisini kaldırın. Kalıbın kökeni Joshua Kerievsky'nin 2006'daki çalışmasına dayanıyor ve sıfır kesintili yayına alma pratiğinin veri tarafındaki karşılığı tam olarak bu.
Modeli kimse sahiplenmiyorsa her sprint biraz daha bozulur
Şema değişiklikleri çoğu ekipte tek bir göçün (migration) içinde, kimse bakmadan geçiyor. İki hafif önlem farkı kapatıyor. Birincisi, göç dosyalarını kod incelemesinde ayrı bir başlık yapın: yeni kolon neyi temsil ediyor, NULL ne anlama geliyor, hangi kısıt eksik. İkincisi, kısa bir veri sözlüğü tutun. Her ana tablo için bir paragraf: bu tablo neyi temsil ediyor, satırın tekilliğini ne belirliyor, kayıt kaynağı burası mı yoksa başka bir sistem mi. Raporların neden tutmadığı sorusunun cevabı çoğunlukla bu paragrafların hiç yazılmamış olması.
Bu haftaya sığan ilk adım
En çok şikâyet alan iki tabloyu seçin ve dört şeyi kontrol edin. Her kolonda NULL'ın ne anlama geldiğini yazın, anlamı belirsiz olanları işaretleyin. Uygulamada doğrulanan ama veritabanında kısıtı olmayan kuralları listeleyin, en kritik ikisini NOT VALID ile ekleyip doğrulayın. Serbest metin durum alanlarındaki farklı değerleri GROUP BY ile sayın, çıkan liste tahmin ettiğinizden uzunsa referans tablosuna geçiş planlayın. Son olarak, raporlarda sıkça sorulan bir geçmiş sorusunu seçin ve bugünkü modelin ona cevap verip veremediğine bakın.
Bu dört kontrol bir öğleden sonra sürüyor ve genelde sonraki altı ayın bilet kuyruğunu belirleyen şeyi ortaya çıkarıyor. Mevcut modelinizi gözden geçirmek ya da yeni bir ürünün şemasını doğru kurmak için destek isterseniz, somut bir tablo listesiyle gelin, konuşma çok daha hızlı ilerler.
Bu konuda yardıma mı ihtiyacınız var?