Software · Oct 14, 2024 · 11 dk okuma
Database Nedir? Yazılımcının Veritabanını Gerçekten Anlaması Neden Önemlidir?
Database, uygulamanın verilerini sakladığı bir yerden fazlasıdır. Database engine, index, transaction, constraint ve execution plan gibi mekanizmaları anlamak, doğru çalışan ve büyüdüğünde de ayakta kalan yazılımlar geliştirmeyi sağlar.

Database, verilerin düzenli biçimde saklanmasını, ilişkilendirilmesini, sorgulanmasını ve değiştirilmesini sağlayan bir sistemdir. Ancak yazılımcının anlaması gereken asıl şey, verinin nerede tutulduğu değil, database'in bu veriyi nasıl yönettiğidir. Database, kendisine gönderilen komutları çalıştıran pasif bir depolama alanı değildir. Verinin tutarlılığını koruyan, eşzamanlı işlemleri koordine eden, sorgular için çalışma stratejisi seçen ve disk üzerindeki fiziksel düzeni yöneten aktif bir sistemdir.
Bu ayrım, uygulama geliştirmede verilen pek çok kararı değiştirir. Bir API endpoint'inin neden yavaşladığını, aynı siparişin neden iki kez oluşabildiğini veya silinen bir kaydın neden başka verileri geçersiz bıraktığını anlamak için SQL syntax bilmek yeterli değildir. Database'in hangi sorumlulukları üstlendiğini ve uygulamanın hangi kararları kendisinin vermesi gerektiğini bilmek gerekir. Bu sınırı anlamayan yazılımcı, başlangıçta çalışan fakat gerçek kullanım koşullarında güvenilirliğini kaybeden sistemler kurabilir.
Database engine veriyi nasıl yönetir?
Bir uygulama database'e sorgu gönderdiğinde, database engine bu sorguyu doğrudan disk üzerinde çalıştırmaya başlamaz. Önce komutun geçerli olup olmadığını kontrol eder, ilgili table ve column bilgilerini inceler, ardından veriye ulaşmak için bir çalışma yöntemi belirler. Database engine, sorguların yürütülmesini, verinin saklanmasını ve transaction kurallarının uygulanmasını sağlayan temel yazılım bileşenidir.
PostgreSQL, MySQL ve SQLite gibi sistemler farklı mimarilere sahip olsalar da ortak bir problemi çözerler: Uygulamanın mantıksal olarak tarif ettiği veriyi fiziksel olarak yönetmek. Uygulama bir müşterinin siparişlerini ister. Engine ise bu siparişlerin hangi sayfalarda bulunduğunu, hangi index üzerinden erişilebileceğini ve okuma sırasında başka işlemlerle nasıl etkileşileceğini belirler.
Burada iki düzey vardır. Logical model, verinin uygulama açısından nasıl düzenlendiğini tanımlar. Physical storage ise bu verinin disk sayfalarında, bellek alanlarında ve engine'in kullandığı veri yapılarında nasıl tutulduğuyla ilgilidir.
Bir table, aynı türden kayıtları belirli bir schema altında toplar. Row, bu table içindeki tek bir kaydı; column ise kaydın belirli bir özelliğini temsil eder. Örneğin customers table'ında id, name ve email column'ları bulunabilir. Her müşteri ayrı bir row olarak temsil edilir.
Bu yapı basit görünür. Fakat bir table, fiziksel olarak satırların art arda yazıldığı sıradan bir metin dosyası değildir. Database engine, kayıtları sayfalara yerleştirebilir, boş alanları yönetebilir, index yapılarını ayrı tutabilir ve değişiklikleri gerektiğinde transaction log üzerinden takip edebilir.
Bu nedenle bir sorgunun maliyeti yalnızca döndürdüğü row sayısına bağlı değildir. Engine'in kaç veri sayfasına eriştiği, bellekte hangi verilerin bulunduğu, hangi index'lerin kullanılabildiği ve diğer transaction'ların ne yaptığı da önemlidir.
Örneğin bir milyon row içeren table'dan tek bir müşteri kaydını istemek, uygun index varsa oldukça az veri erişimi gerektirebilir. Buna karşılık yalnızca yüz row döndüren başka bir sorgu, büyük bir table'ın tamamını taramak zorunda kalabilir.
Database performansının ilk önemli ilkesi budur: Sonuç kümesinin büyüklüğü ile sonucu bulmanın maliyeti aynı şey değildir.
Table ilişkileri verinin doğruluğunu belirler
Database tasarımında table oluşturmak kolaydır. Zor olan, sistemin hangi durumları geçerli kabul edeceğini doğru tanımlamaktır.
Bir e-ticaret uygulamasında customers, orders ve order_items table'ları bulunduğunu düşünelim. customers müşterileri, orders siparişleri, order_items ise siparişlerin içindeki ürün satırlarını temsil eder.
Her row'un diğerlerinden ayırt edilebilmesi gerekir. Primary key, bir table içindeki kaydı benzersiz biçimde tanımlayan column veya column grubudur. customers.id bu amaçla kullanılabilir. Primary key değerleri benzersizdir ve NULL olamaz.
Siparişin hangi müşteriye ait olduğu ise foreign key ile ifade edilir. orders.customer_id, customers.id değerine referans verebilir. Böylece database, var olmayan bir müşteriye bağlı sipariş oluşturulmasını engelleyebilir.
Foreign key yalnızca table'lar arasında bağlantı kurmak için kullanılan bir işaret değildir. Etkin biçimde tanımlandığında ve uygulandığında, ilişkinin geçerliliğini database seviyesinde koruyan bir constraint'tir.
Constraint, database'e hangi verilerin kabul edilebilir olduğunu söyleyen kuraldır. NOT NULL zorunlu alanları, UNIQUE tekrar edemeyen değerleri, CHECK belirli koşulları ve foreign key ilişkisel bütünlüğü tanımlar.
Örneğin bir sipariş satırında miktarın sıfırdan büyük olması gerekiyorsa CHECK (quantity > 0) kullanılabilir. Aynı ürünün bir siparişte yalnızca bir satırda bulunması isteniyorsa UNIQUE (order_id, product_id) tanımlanabilir.
Bu kurallar uygulama kodunda da kontrol edilebilir. Fakat yalnızca uygulama kodunda tutulmaları önemli bir boşluk yaratır.
Aynı database'e birden fazla API, arka plan görevi veya yönetim aracı erişebilir. Bir uygulama yolunda yapılan kontrol, başka bir yoldan gelen yazma işlemini korumaz. Constraint ise ilgili database işlemleri için ortak bir güvenlik sınırı oluşturur.
Database tasarımının ikinci önemli konusu normalization'dır. Normalization, veriyi tekrarları ve problemli bağımlılıkları azaltacak şekilde table'lara ayırma yaklaşımıdır.
Müşteri adresini her sipariş satırına kopyalamak, ilk bakışta sorguları kolaylaştırabilir. Ancak müşteri adresi değiştiğinde hangi kopyaların güncelleneceği belirsizleşir. Buna update anomaly denir: Aynı bilgiyi temsil eden kayıtların farklı değerlere dönüşmesi.
Normalization, bağımsız varlıkları ve aralarındaki ilişkileri ayırarak bu problemi azaltır. Bununla birlikte her tekrar yanlış değildir. Sipariş sırasında geçerli olan teslimat adresini sipariş üzerinde saklamak, müşterinin güncel adresini tekrar etmekten farklıdır. Burada amaç geçmişteki bir olayı doğru temsil etmektir.
Dolayısıyla normalization mekanik olarak mümkün olduğunca çok table oluşturmak değildir. Hangi bilginin hangi varlığa ait olduğunu ve zaman içinde nasıl değişmesi gerektiğini belirlemektir.
Index hızlandırır ama sorgunun maliyetini ortadan kaldırmaz
Database hakkında en yaygın eksik açıklamalardan biri, index'in kitap sonundaki dizin gibi çalıştığıdır. Bu benzetme başlangıç için yararlıdır, fakat performans problemlerinin asıl mekanizmasını açıklamaz.
Index, database engine'in belirli column değerlerine göre row'lara daha verimli ulaşmasını sağlayan ek bir veri yapısıdır. Birçok relational database, bu amaçla B-tree ailesinden yapılar kullanır. Bu yapılar sıralı arama ve aralık sorgularını destekler.
Örneğin orders table'ında customer_id için index varsa engine belirli müşterinin siparişlerini bulmak amacıyla bütün table'ı okumak zorunda kalmayabilir.
Ancak index ücretsiz değildir. Disk alanı tüketir, bellekte yer kaplayabilir ve ilgili veriler değiştiğinde güncellenmesi gerekir. Yeni bir sipariş eklemek, yalnızca table'a row yazmak anlamına gelmeyebilir; ilgili index yapılarına da kayıt eklenir.
Daha önemlisi, index'in varlığı kullanılacağı anlamına gelmez. Database engine, sorgu için bir execution plan seçer. Execution plan, sorgunun hangi erişim yöntemleriyle ve hangi işlem sırasıyla yürütüleceğini gösterir.
Bir sorgu table'ın büyük bölümünü okuyacaksa sequential scan, index üzerinden tek tek erişim yapmaktan daha ucuz olabilir. Çünkü çok sayıda dağınık erişim, ardışık veri okumaya göre daha maliyetli hale gelebilir.
Execution plan seçimi çoğunlukla optimizer tarafından yapılır. Optimizer, table istatistiklerinden ve maliyet tahminlerinden yararlanarak alternatif çalışma yollarını karşılaştırır.
Bu noktada kritik ayrım ortaya çıkar: SQL, çoğunlukla hangi sonucun istendiğini tarif eder; engine ise bu sonuca nasıl ulaşacağını seçer.
Aynı SQL sorgusu, table büyüdüğünde veya veri dağılımı değiştiğinde farklı execution plan kullanabilir. Bu nedenle geliştirme ortamında hızlı çalışan sorgu, production ortamında aynı performansı göstermeyebilir.
İlişkisel veritabanlarının güçlü işlemlerinden biri olan join de aynı mekanizmanın parçasıdır. Join, birden fazla table'daki row'ları belirli ilişki koşullarına göre birleştirir.
Örneğin müşterinin adı ile sipariş tarihini birlikte göstermek için customers ve orders table'ları join edilebilir. Ancak join, iki table'ın fiziksel olarak yan yana getirilmesi anlamına gelmez.
Engine, koşullara ve veri miktarına göre nested loop join, hash join veya merge join gibi algoritmalar seçebilir. Her algoritmanın avantajı farklıdır. Küçük bir sonuç kümesi için uygun olan yöntem, milyonlarca row arasında pahalı hale gelebilir.
Yaygın tavsiye olan 'yavaş sorguya index ekle' yaklaşımı bu yüzden eksiktir. Sorunun nedeni yanlış join sırası, güncel olmayan istatistikler, çok fazla row okunması veya uygulamanın gereksiz sorgu üretmesi olabilir.
Doğru başlangıç, sorgunun execution plan'ını incelemektir. PostgreSQL'de EXPLAIN, seçilen planı gösterir; EXPLAIN ANALYZE ise sorguyu gerçekten çalıştırarak gerçekleşen yürütmeye ilişkin ölçümler üretir. İkinci komutun veri değiştiren sorgularda yan etkilere yol açabileceği unutulmamalıdır.
Tahmin edilen row sayıları ile gerçekleşen değerler arasında büyük fark varsa optimizer yanlış maliyet varsayımlarıyla hareket ediyor olabilir. Index eklemeden önce bu farkın kaynağını anlamak çoğu zaman daha değerlidir.
Bir siparişin kaydedilmesi neden tek işlem değildir?
Bir backend geliştiricisinin sipariş oluşturma endpoint'i üzerinde çalıştığını düşünelim. İstek içinde customer_id, ürünlerin product_id değerleri ve miktarları bulunuyor. Endpoint'in görevi siparişi kaydetmek, stok durumunu güncellemek ve istemciye başarılı sonuç döndürmek.
İlk uygulama oldukça doğal görünebilir. Önce orders table'ına kayıt eklenir. Sonra her ürün için order_items oluşturulur. Ardından inventory table'ındaki stok miktarı azaltılır.
Fakat ikinci adımda hata oluşursa ne olur? Sipariş kaydı oluşmuş, ürün satırları ise eksik kalmış olabilir. Stok güncellemesi başarısız olursa sistem daha da karmaşık bir duruma girer.
Bu noktada transaction gerekir. Transaction, birden fazla database işlemini tek bir mantıksal çalışma birimi olarak yönetir. Transaction içindeki değişiklikler commit ile kalıcı hale getirilir; başarısızlık durumunda rollback ile geri alınabilir.
Transaction'ların temel özellikleri genellikle ACID kavramıyla anlatılır. Atomicity, transaction'ın kısmen uygulanmış halde bırakılmamasını ifade eder. Consistency, tanımlanmış bütünlük kurallarının korunmasıyla ilgilidir. Isolation, eşzamanlı transaction'ların birbirlerinin işlemlerini nasıl göreceğini belirler. Durability ise başarılı commit sonrasında değişikliklerin sistemin vaat ettiği dayanıklılık koşulları altında korunmasıdır.
Ancak transaction kullanmak, bütün eşzamanlılık problemlerinin otomatik olarak çözüldüğü anlamına gelmez.
Stok miktarı bir olan ürün için iki siparişin aynı anda geldiğini düşünelim. Her iki request de önce stok değerini okuyup sonra azaltmaya çalışırsa, kullanılan isolation level ve güncelleme yöntemi nedeniyle hatalı sonuç oluşabilir.
Bir çözüm, stok azaltmayı koşullu bir update olarak gerçekleştirmektir: UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 42 AND quantity >= 1. Ardından etkilenen row sayısı kontrol edilir. Sıfır row güncellendiyse yeterli stok yoktur veya ilgili kayıt bulunamamıştır.
Bu işlem uygun transaction sınırları içinde gerçekleştirildiğinde, aynı row üzerinde yarışan güncellemelerin güvenli biçimde koordine edilmesine yardımcı olur. Alternatif olarak bazı senaryolarda SELECT ... FOR UPDATE ile row lock alınabilir.
Burada lock, eşzamanlı işlemlerin aynı veriye erişimini veya onu değiştirmesini koordine eden mekanizmadır. Ancak lock kullanımı beklemeye, contention'a ve bazı durumlarda deadlock'a neden olabilir. Database engine deadlock tespit ettiğinde transaction'lardan birini iptal edebilir; uygulama bunun için retry stratejisine ihtiyaç duyabilir.
Sipariş örneği başka bir ayrımı da gösterir. Database transaction'ı, aynı database içindeki değişiklikleri koordine eder. Fakat ödeme sağlayıcısına gönderilen HTTP request'i otomatik olarak rollback edemez.
Ödeme başarıyla alınmış fakat database transaction'ı başarısız olmuşsa, yalnızca rollback yapmak yeterli değildir. Bu durumda idempotency key, outbox pattern veya telafi işlemleri gibi uygulama seviyesinde mekanizmalar gerekebilir.
Idempotency, aynı mantıksal isteğin tekrar gönderilmesinin ikinci bir bağımsız sonuç üretmemesini sağlamaktır. Örneğin ödeme isteğine benzersiz bir işlem anahtarı eklemek, network timeout sonrasında yapılan retry'nin ikinci tahsilata dönüşmesini engellemeye yardımcı olabilir. Bunun işe yaraması için ilgili sistemin anahtarı uygun biçimde işlemesi gerekir.
Bu örnekte database ile uygulama arasındaki sınır netleşir. Database, row bütünlüğünü, constraint'leri ve kendi transaction kurallarını uygular. Uygulama ise siparişin hangi iş durumlarından geçeceğini, ödeme hatasının nasıl karşılanacağını ve dış sistemlerle tutarlılığın nasıl sağlanacağını belirler.
Database'i sistemin ortağı olarak tasarlayın
Yazılımcı için en doğru yaklaşım, database'i yalnızca CRUD işlemlerinin hedefi olarak görmemektir. CRUD, kayıt oluşturma, okuma, güncelleme ve silme işlemlerini ifade eder. Bu işlemler uygulamanın görünen yüzüdür; database'in üstlendiği iş bundan daha geniştir.
İyi bir tasarımda database, kendi güvenilir biçimde uygulayabildiği kuralların sahibi olmalıdır. Primary key, foreign key, UNIQUE ve CHECK constraint gibi mekanizmalar, veri bütünlüğünün yalnızca uygulama koduna bırakılmasını önler.
Buna karşılık her business rule SQL içine taşınmamalıdır. Örneğin bir siparişin hangi koşullarda iptal edilebileceği, müşteri segmentine göre hangi fiyatlandırma politikasının uygulanacağı veya bir ödeme başarısızlığında hangi mesajın gösterileceği çoğu zaman application layer içinde daha anlaşılır biçimde yönetilir.
Sınırı belirlerken yararlı soru şudur: Bu kural ihlal edilirse database'deki veri kendi başına geçersiz hale gelir mi? Cevap evetse ve kural database tarafından güvenilir biçimde ifade edilebiliyorsa, constraint veya transaction seviyesinde korunması güçlü bir tercihtir.
Performans için de benzer bir disiplin gerekir. Önce uygulamanın gerçekte hangi sorguları ürettiğini görün. Ardından execution plan'ı, okunan row miktarını ve sorgu sıklığını inceleyin. ORM kullanılması bu sorumluluğu ortadan kaldırmaz; ORM, nesneler ile relational model arasındaki eşlemeyi kolaylaştırır fakat alttaki sorguların maliyetini silmez.
Özellikle N+1 query problemi buna iyi bir örnektir. Uygulama önce yüz sipariş getirip ardından her siparişin müşterisini ayrı sorguyla okuyorsa toplamda 101 sorgu gönderebilir. Uygun join veya toplu veri çekme yöntemi, birçok durumda bu gereksiz round-trip maliyetini azaltır.
Fakat bütün ilişkileri tek büyük join içinde toplamak da evrensel çözüm değildir. Birden fazla one-to-many ilişki aynı sorguda birleştirildiğinde sonuç kümesi beklenmedik biçimde büyüyebilir. Bu durumda ayrı ve toplu sorgular, tek devasa join'den daha verimli olabilir.
Transaction sınırlarını da mümkün olduğunca bilinçli seçmek gerekir. Uzun transaction'lar lock sürelerini artırabilir, diğer işlemleri bekletebilir ve kullanılan database engine'e bağlı olarak eski row sürümlerinin temizlenmesini geciktirebilir. Network request beklerken transaction'ı açık tutmak bu nedenle çoğu uygulamada kötü bir tercihtir.
Benim önerim, database tasarımını uygulamanın son aşamasına bırakmamaktır. Schema oluştururken her table'ın temsil ettiği varlığı, her constraint'in koruduğu kuralı ve her transaction'ın garanti ettiği iş sonucunu açıkça belirleyin. Performans problemi ortaya çıktığında önce engine'in gerçekten ne yaptığını ölçün; tahminle index eklemeyin.
Bu yaklaşımın sınırı da vardır. Geçici analiz verileri, append-only event kayıtları, document database kullanan sistemler veya yüksek hacimli dağıtık mimariler aynı relational model tercihlerini gerektirmeyebilir. Bazı sistemlerde denormalization, eventual consistency veya farklı storage modelleri bilinçli olarak seçilir.
Ancak bu alternatifler, database mekanizmalarını anlamayı gereksiz kılmaz. Tam tersine, hangi garantiden vazgeçildiğini bilmeyi gerektirir. Bir sistemde güçlü transaction garantileri yerine eventual consistency tercih ediliyorsa, uygulamanın geçici tutarsızlıkları nasıl yöneteceği açık olmalıdır.
Database'i gerçekten anlamak, her sorguyu ezbere yazabilmek değildir. Bir kayıt eklendiğinde hangi kuralların çalıştığını, bir sorgunun veriye nasıl ulaştığını, iki işlem çakıştığında ne olacağını ve sistem hata verdiğinde hangi verinin korunacağını açıklayabilmektir. Yazılımcının database konusunda ulaşması gereken seviye budur.
yorumlar
0Henüz yorum yok. İlk notu sen bırak.
Yorum yazmak için giriş yap.
Gmail ile saniyeler içinde, ya da e-posta ile kaydol.