Mert Özen Her Satırda Daha İleri
DevOps Cloud Temelleri — #08

Managed Database'ler: Azure SQL Database, PostgreSQL Flexible Server ve Cosmos DB Arasında Seçim, Yedekleme ve Bağlantı Dayanıklılığı

Mert Özen 15 Kas 2026 16 dk 8 görüntülenme
Managed Database'ler: Azure SQL Database, PostgreSQL Flexible Server ve Cosmos DB Arasında Seçim, Yedekleme ve Bağlantı Dayanıklılığı

BookstoreDb için doğru seçim, instance özelliklerine ihtiyacı olmadığından Azure SQL Database: dev'de ücretsiz teklif, production'da zone-redundant General Purpose. Managed Instance, PostgreSQL Flexible Server ve Cosmos DB ile karşılaştırmayı, bacpac ile taşımayı, point-in-time restore ve failover group'u, Entra-only sunucuyu, EnableRetryOnFailure'ın kırdığı transaction'ı ve SqlClient ile EF Core'un üst üste binen retry katmanlarını gerçek ölçümlerle anlatıyoruz.

Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.404), Entity Framework Core 10.0.15, Microsoft.Data.SqlClient 6.1.7, SqlPackage 170.5.96, bir SQL Server 2025 container'ı ve Azure CLI 2.92.0 ile denenmiştir. Bağlantı ve retry deneyleri yerel SQL Server'a karşı yapıldı; Azure'da veritabanı oluşturulmadı. Servis davranışları Microsoft Learn'den, liste fiyatları Azure Retail Prices API'den alındı (14 Kasım 2026).

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) Azure SQL Database'e taşınıyor. Learn'ün ilk önerdiği şeyi yapıyoruz: Entity Framework Core'a (EF Core; .NET'in veritabanı erişim kütüphanesi) tek satırla EnableRetryOnFailure() ekliyoruz, böylece geçici bağlantı hataları yeniden deneniyor. İki soru. Bu tek satırdan sonra mevcut testlerden hangisi kırılır? İkincisi: biri connection string'deki veritabanı adını yanlış yazdı. Kitap detayı isteği kaç saniye sonra 500 döner? İkisini de yerelde ölçtük.

SQL Server'ı Bulutta Çalıştırmanın Üç Yolu

BookstoreDb bir SQL Server veritabanı ve Azure'da onu çalıştırmanın üç yolu var. SQL Server on Azure VM, IaaS (Infrastructure as a Service; sanal makineyi kiralayıp gerisini kendin yönettiğin model): işletim sistemi, SQL Server sürümü, patch ve yedek senin işin, karşılığında instance'ın tamamı senin. Azure SQL Managed Instance, yönetilen ama instance seviyesinde bir SQL Server: SQL Agent, veritabanları arası sorgu, CLR (Common Language Runtime; SQL Server içinde .NET kodu çalıştırma) ve linked server gibi instance özelliklerini kullanan eski bir uygulamayı taşırken doğru seçim. Azure SQL Database ise tek bir veritabanı sunar; sunucu sadece mantıksal bir kapsayıcıdır. Bookstore'un instance özelliklerine ihtiyacı yok, bu yüzden seçimimiz SQL Database.

SQL Database'te iki satın alma modeli var. DTU (Database Transaction Unit; CPU, bellek ve disk erişimini tek bir sayıda birleştiren eski ölçü) modelinde Basic, Standard, Premium paketlerinden birini seçersin. Learn'ün önerdiği vCore modelinde ise işlemci sayısını ve tier'ı ayrı seçersin: General Purpose (uzak storage, çoğu uygulama için), Business Critical (yerel disk ve ek replikalar, düşük gecikme) ve Hyperscale (büyük veritabanları, hızlı ölçekleme). General Purpose'ta compute iki türlü: provisioned (sabit vCore, saatlik ücret) ve serverless (kullanılan vCore kadar, saniye başı ücret). Serverless veritabanı belirli bir süre boşta kalınca kendini durdurabiliyor (auto-pause); bu süre General Purpose'ta en az 15 dakika. Duran veritabanına gelen ilk bağlantı 40613 hatası alır ve veritabanının uyanması Learn'e göre genellikle bir dakika kadar sürer.

Fiyat farkını hesaba katmak gerekiyor. West Europe'ta General Purpose Gen5 için liste fiyatı (14 Kasım 2026) provisioned'da vCore başına saatte yaklaşık 0,167 dolar, serverless'ta 0,574 dolar. Serverless'ın vCore-saati üç katından pahalı; yani veritabanı günün büyük bölümünde boş değilse provisioned daha ucuza gelir. Dev ortamı için ise ücretsiz teklif var: Learn'e göre her subscription'da en fazla 10 General Purpose veritabanı, her biri için ayda 100.000 vCore-saniye, 32 GB veri ve 32 GB yedek ücretsiz. Hak bitince ya ay sonuna kadar durur ya da ücretlendirmeye geçer; seçimi sen yaparsın. SLA (Service Level Agreement; sağlayıcının taahhüt ettiği erişilebilirlik) yok, production için değil.

PostgreSQL Flexible Server, Cosmos DB ve AWS Karşılıkları

Bookstore SQL Server'da kalıyor ama başka bir ekip PostgreSQL seçebilir. Azure Database for PostgreSQL Flexible Server, Burstable, General Purpose ve Memory Optimized tier'larıyla gelir; en küçük Burstable B1ms sunucunun West Europe liste fiyatı saatte 0,0199 dolar. Yedekler varsayılan 7, en fazla 35 gün tutulur. Dev sunucusunu durdurabilirsin ama Learn'e göre en fazla yedi gün durur, sonra kendiliğinden başlar. Eski Single Server 28 Mart 2025'te emekliye ayrıldı.

Cosmos DB (Azure'un dağıtık veritabanı servisi) ilişkisel değil; NoSQL, yani tablo yerine doküman ya da key-value gibi modellerle çalışan bir servis. Kapasiteyi RU (Request Unit; okuma, yazma ve sorgu maliyetini ölçen birim) ile ölçer: provisioned modelde saniyelik RU kapasitesi ayırırsın, serverless modelde harcanan RU kadar ödersin (West Europe'ta milyon RU başına 0,305 dolar). Her subscription'da bir hesap için ilk 1.000 RU/s ve 25 GB ücretsiz. Sepet gibi anahtarla okunan, şeması sık değişen, birden çok region'a yazılan veri için anlamlı; Bookstore'un siparişleri gibi transaction ve ilişki isteyen veri için SQL daha doğal. Aşağıdaki tablo seçenekleri AWS (Amazon Web Services; Amazon'un sunduğu bulut platformu) karşılıklarıyla veriyor; RDS (Relational Database Service; AWS'nin yönetilen ilişkisel veritabanı servisi), EC2 (AWS'nin sanal makine servisi) ve DynamoDB (AWS'nin anahtar tabanlı NoSQL servisi) tabloda geçiyor; Aurora, AWS'nin PostgreSQL ve MySQL uyumlu kendi motoru.

AzureAWS karşılığıNot
SQL Server on Azure VMSQL Server on EC2Her şeyi sen yönetirsin
Azure SQL Managed InstanceRDS for SQL ServerInstance seviyesinde yönetilen SQL Server
Azure SQL Database serverlessAurora Serverless v2İkisi de boşta durabiliyor; Aurora sıfır kapasiteye inebiliyor
PostgreSQL Flexible ServerRDS for PostgreSQL, Aurora PostgreSQLYönetilen PostgreSQL
Cosmos DBDynamoDBAnahtar tabanlı, yatay ölçeklenen NoSQL
Otomatik yedek ve point-in-time restore (1–35 gün)RDS automated backup (0–35 gün)Belirli bir ana geri dönme

BookstoreDb'yi Taşımak: Bacpac

Küçük bir veritabanını taşımanın en basit yolu bacpac: şemayı ve veriyi tek bir dosyaya döken format. SqlPackage'ı repo'nun tool manifest'ine local tool olarak ekledik, dotnet tool restore yeterli. Yerel SQL Server'daki BookstoreDb'yi dışa aktardık:

$ dotnet tool install microsoft.sqlpackage
Tool 'microsoft.sqlpackage' (version '170.5.96') was successfully installed. Entry is added to the manifest file dotnet-tools.json.
$ dotnet sqlpackage /Action:Export /SourceConnectionString:"$SOURCE" /TargetFile:BookstoreDb.bacpac
Connecting to database 'BookstoreDb' on server '127.0.0.1,52714'.
Extracting schema
...
Processing Table '[catalog].[Books]'.
Processing Table '[ordering].[Orders]'.
...
Successfully exported database and saved it to file 'BookstoreDb.bacpac'.
Time elapsed 0:00:12.09

Dosyayı aynı sunucuda yeni bir veritabanına geri yükleyip sayımı kontrol ettik:

$ dotnet sqlpackage /Action:Import /TargetConnectionString:"$TARGET" /SourceFile:BookstoreDb.bacpac
...
Successfully imported database.
Time elapsed 0:00:18.39
Books StockItems LastMigration
----- ---------- -------------
20 20 20261114204035_AddBookCover

Learn, production'da tutarlı bir export için ya yazmaları durdurmayı ya da veritabanının bir kopyasından export almayı öneriyor; export sırasında yazılan satırlar dosyaya yarım girebilir. Blob'a yazılan bir bacpac'in sınırı 200 GB. Daha büyük ya da kesintisi az olması gereken taşımalarda Azure Database Migration Service ve transactional replication gibi yollar var. Microsoft'un eski aracı Azure Data Studio 28 Şubat 2026'da emekliye ayrıldı; yerine Visual Studio Code'un SQL Server extension'ı öneriliyor.

Taşımanın öbür yarısı şemayı güncel tutmak. Bookstore.Api, Development'ta migration'ları açılışta uyguluyor; production'da bunu yapmıyoruz. Migration'ı deploy sırasında ayrı bir adım olarak, ayrı bir kimlikle çalıştırmak Deployment Stratejileri serisinin "Migration'ların Deploy'daki Yeri: Expand-Contract, EF Core Migration Bundle ve Geriye Uyumlu Şema" yazısının konusu.

Yedekleme, Restore ve Failover

SQL Database yedekleri kendisi alır: haftalık full, 12 ya da 24 saatte bir differential ve yaklaşık 10 dakikada bir transaction log yedeği. Bu sayede point-in-time restore (PITR; yedekten belirli bir ana geri dönmek) ile saklama süresi içindeki herhangi bir ana dönebilirsin. Süre 1 ile 35 gün arası, varsayılanı 7 gün. Daha uzun saklamak için long-term retention ile haftalık, aylık ve yıllık yedekleri 10 yıla kadar tutarsın. Restore her zaman yeni bir veritabanı oluşturur; mevcut veritabanının üzerine yazmaz. Bir geliştiricinin yanlışlıkla sildiği siparişleri geri getirmek için restore edilen kopyadan ilgili satırları alıp asıl veritabanına yazarsın.

Yedeklerin nerede durduğu da bir ayar: varsayılan geo-redundant, istersen local ya da zone-redundant. Geo-restore, yani yedekten başka bir region'da veritabanı açmak, yalnız geo ya da GZRS (zone-redundant ve geo kopyalı) yedekte çalışır. Learn bu yolun RTO (Recovery Time Objective; kesintiden sonra hizmetin en geç ne zaman geri gelmesi gerektiği) ve RPO (Recovery Point Objective; en fazla ne kadarlık veri kaybının kabul edildiği) değerlerini "dakikalar ya da saatler" diye veriyor. Daha hızlısı için active geo-replication ve failover group var: ikincil region'da okunabilir bir kopya sürekli güncel tutulur. Failover group, failover sonrası değişmeyen bir dinleyici adı verir (<fog>.database.windows.net); uygulamanın connection string'i aynı kalır. Learn'e göre RTO genellikle 60 saniyenin altında. Zone-redundant yapılandırmada bir zone kaybı 30 saniyenin altında ve veri kaybı olmadan atlatılıyor. Bu seçimler 11. yazının konusu; burada bilmen gereken, hepsinin parası olduğu.

Platform bakım güncellemelerini de kendisi yapar. Varsayılan olarak etkili güncellemeler yerel saatle 08.00–17.00 arasında yapılmaz; istersen hafta içi ya da hafta sonu gece penceresi seçebilirsin. Bakım sırasında bağlantılar kısa süreliğine kopar. Bu da bizi uygulama tarafına getiriyor.

Azure Tarafı: Entra-only Sunucu ve Parolasız Kullanıcı

Sunucuyu ve veritabanını oluşturan script:

#!/usr/bin/env bash
# BookstoreDb için Azure SQL Database: yalnız Microsoft Entra ile kimlik doğrulama, serverless compute, yedek saklama süresi.
set -euo pipefail

: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 08-sql.sh <dev|staging|prod>}"
LOCATION="westeurope"
RG="rg-bookstore-${ENVIRONMENT}-weu"
SQL_SERVER="sql-bookstore-${ENVIRONMENT}-weu"
DBA_GROUP="sg-bookstore-dba"

az account set --subscription "$AZURE_SUBSCRIPTION_ID"
DBA_GROUP_ID=$(az ad group show --group "$DBA_GROUP" --query id --output tsv)

# Hiç SQL login'i yok: yalnız Entra kimlikleri girer, sunucu admin'i de bir kişi değil, bir group
az sql server create --name "$SQL_SERVER" --resource-group "$RG" --location "$LOCATION" \
  --enable-ad-only-auth --external-admin-principal-type Group \
  --external-admin-name "$DBA_GROUP" --external-admin-sid "$DBA_GROUP_ID" \
  --minimal-tls-version 1.2

if [[ "$ENVIRONMENT" == "dev" ]]; then
  # Ücretsiz teklif: aylık hak bitince veritabanı ücretlendirmek yerine durur
  az sql db create --name BookstoreDb --server "$SQL_SERVER" --resource-group "$RG" \
    --edition GeneralPurpose --family Gen5 --capacity 2 --compute-model Serverless \
    --use-free-limit --free-limit-exhaustion-behavior AutoPause \
    --backup-storage-redundancy Local
else
  # 0,5 ile 4 vCore arası serverless, hiç durmaz, replikalar availability zone'lara dağılır
  az sql db create --name BookstoreDb --server "$SQL_SERVER" --resource-group "$RG" \
    --edition GeneralPurpose --family Gen5 --capacity 4 --min-capacity 0.5 --compute-model Serverless \
    --auto-pause-delay -1 --zone-redundant true --backup-storage-redundancy Zone
fi

# Point-in-time restore aralığı: varsayılan 7 yerine 14 gün
az sql db str-policy set --name BookstoreDb --server "$SQL_SERVER" --resource-group "$RG" \
  --retention-days 14 --diffbackup-hours 12

if [[ "$ENVIRONMENT" == "prod" ]]; then
  # Long-term retention: haftalık yedekler 8 hafta, aylık yedekler 12 ay
  az sql db ltr-policy set --name BookstoreDb --server "$SQL_SERVER" --resource-group "$RG" \
    --weekly-retention P8W --monthly-retention P12M
fi

Sunucuda SQL kimlik doğrulaması hiç yok: --enable-ad-only-auth ile yalnız Entra ID kimlikleri giriş yapabiliyor ve admin bir kişi değil, sg-bookstore-dba grubu. Dev'de ücretsiz teklif, staging ve prod'da hiç durmayan, zone-redundant serverless kullanıyoruz. 5. yazıdaki connection string zaten Authentication=Active Directory Managed Identity;User Id=<clientId> içeriyordu; eksik olan, bu kimliğin veritabanında bir kullanıcıya karşılık gelmesi. Bunu admin grubundan biri veritabanında T-SQL ile yapıyor. Secret'sız bağlantının neden önemli olduğunu Configuration ve Secret Management serisinin "Secret Rotation ve Sızıntı Müdahalesi: Kesintisiz Rotation, Sızan Secret'ta İlk 60 Dakika ve Secret'sız Mimari" yazısı anlatıyor.

CREATE USER [id-bookstore-dev-weu] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [id-bookstore-dev-weu];
ALTER ROLE db_datawriter ADD MEMBER [id-bookstore-dev-weu];

CREATE USER [id-bookstore-deploy-dev-weu] FROM EXTERNAL PROVIDER;
ALTER ROLE db_ddladmin ADD MEMBER [id-bookstore-deploy-dev-weu];
ALTER ROLE db_datareader ADD MEMBER [id-bookstore-deploy-dev-weu];
ALTER ROLE db_datawriter ADD MEMBER [id-bookstore-deploy-dev-weu];

API'nin kimliği veri okuyup yazabiliyor ama şemayı değiştiremiyor; migration'ları çalıştıran deploy kimliği ayrı. Dosyanın tamamı repo'da infra/sql/08-app-user.sql. Yerel SQL Server'da sözdizimi kontrolünden (SET PARSEONLY ON) geçti, ama komutun kendisi Entra ID olmayan bir sunucuda çalışmıyor:

Msg 37525, Level 16, State 1, Line 1
Command 'CREATE USER FROM EXTERNAL PROVIDER' is not supported as Azure Active Directory is not configured for this instance.

Bir not: EF Core 10'un SQL Server provider'ı Microsoft.Data.SqlClient 6.1.7 kullanıyor ve Entra ID desteği bu pakette hazır. SqlClient 7.0 ile Azure bağımlılıkları ana paketten çıkarıldı; EF Core 11'e geçtiğinde Microsoft.Data.SqlClient.Extensions.Azure paketini eklemezsen Active Directory Managed Identity ile bağlanamazsın.

Bağlantı Dayanıklılığı: EnableRetryOnFailure

Bulutta veritabanı bağlantısı ara sıra kopar: failover, ölçekleme, bakım, serverless'ın uyanması. Bunlar geçici hatalardır ve birkaç saniye sonra aynı işlem başarılı olur. EF Core'un SQL Server provider'ı bunu execution strategy ile çözüyor. Veritabanı kaydını bir extension method'a taşıdık:

using Microsoft.EntityFrameworkCore;

namespace Bookstore.Data;

public static class DatabaseExtensions
{
    public static IServiceCollection AddBookstoreDatabase(this IServiceCollection services, string connectionString) =>
        services.AddDbContext<BookstoreContext>(options => options.UseSqlServer(connectionString, sql =>
            // Azure SQL failover, ölçekleme ve serverless uyanması sırasında bağlantıları koparır: bu hataları yeniden dene
            sql.EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null)));

    // Development kolaylığı: API açılırken bekleyen migration'ları (şema + seed) uygular.
    public static async Task MigrateDatabaseAsync(this WebApplication app)
    {
        await using AsyncServiceScope scope = app.Services.CreateAsyncScope();
        BookstoreContext db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();
        await db.Database.MigrateAsync();
    }
}

Varsayılan ayarlar 6 deneme ve en fazla 30 saniye bekleme; bir web isteği için uzun olduğundan 5 deneme ve 10 saniye seçtik. Strategy yalnız bilinen hata numaralarını geçici sayar: 40613 (veritabanı şu an erişilemiyor), 40501 (servis meşgul), 4060 (veritabanı açılamadı), 1205 (deadlock) gibi. Tahmin sorusunun ilk cevabı testlerde çıktı:

failed Bookstore.Api.Tests.OrderingTests.Placing_an_order_reserves_stock_and_removes_the_cart (9ms)
  System.InvalidOperationException: The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support user-initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()' to execute all the operations in the transaction as a retriable unit.

Sipariş verme kodu stok rezervasyonu, sipariş ve sepeti silmeyi tek bir transaction'da yapıyordu. Retry yapan bir strategy, transaction'ın ortasında bir hata olursa hangi adımdan devam edeceğini bilemez; bu yüzden EF Core kullanıcının açtığı transaction'ı reddediyor. Çözüm, bir denemeye ait her şeyi strategy'nin çalıştırdığı bir delegate'e koymak:

using Bookstore.Catalog;
using Bookstore.Data;
using Bookstore.Inventory;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

namespace Bookstore.Ordering;

public abstract record PlaceOrderOutcome
{
    public sealed record Placed(Order Order) : PlaceOrderOutcome;
    public sealed record CartNotFound : PlaceOrderOutcome;
    public sealed record CartEmpty : PlaceOrderOutcome;
    public sealed record OutOfStock(IReadOnlyList<string> Isbns) : PlaceOrderOutcome;
    public sealed record CartChanged : PlaceOrderOutcome;
}

public sealed class OrderService(
    BookstoreContext db, ICatalogService catalog, IInventoryService inventory, TimeProvider clock)
{
    // Retry yapan execution strategy, geçici bir hatadan sonra delegate'in tamamını yeniden çalıştırır;
    // bu yüzden bir denemeye ait her şey (okumalar, transaction, yazmalar) onun içinde durur.
    public Task<PlaceOrderOutcome> PlaceOrderAsync(PlaceOrderRequest request, CancellationToken ct) =>
        db.Database.CreateExecutionStrategy().ExecuteAsync(
            request, (_, req, token) => PlaceOrderOnceAsync(req, token), verifySucceeded: null, ct);

    private async Task<PlaceOrderOutcome> PlaceOrderOnceAsync(PlaceOrderRequest request, CancellationToken ct)
    {
        // Başarısız bir deneme context'te takip edilen entity'ler bırakmış olabilir
        db.ChangeTracker.Clear();

        Cart? cart = await db.Carts.Include(c => c.Items).FirstOrDefaultAsync(c => c.Id == request.CartId, ct);
        if (cart is null)
        {
            return new PlaceOrderOutcome.CartNotFound();
        }
        if (cart.Items.Count == 0)
        {
            return new PlaceOrderOutcome.CartEmpty();
        }

        IReadOnlyDictionary<int, BookInfo> books =
            await catalog.GetByIdsAsync([.. cart.Items.Select(i => i.BookId)], ct);

        // Stok rezervasyonu, sipariş ve sepetin silinmesi birlikte commit ya da rollback olur.
        await using IDbContextTransaction transaction = await db.Database.BeginTransactionAsync(ct);

        ReservationResult reservation = await inventory.ReserveAsync(
            [.. cart.Items.Select(i => new StockLine(i.BookId, i.Quantity))], ct);
        if (!reservation.Succeeded)
        {
            return new PlaceOrderOutcome.OutOfStock([.. reservation.UnavailableBookIds.Select(id => books[id].Isbn)]);
        }

        Customer customer = await FindOrCreateCustomerAsync(request.CustomerName, request.CustomerEmail, ct);
        Order order = new()
        {
            Customer = customer,
            PlacedAtUtc = clock.GetUtcNow().UtcDateTime,
            Status = OrderStatus.Placed
        };
        foreach (CartItem item in cart.Items)
        {
            BookInfo book = books[item.BookId];
            order.Items.Add(new OrderItem
            {
                BookId = book.Id,
                Title = book.Title,
                UnitPrice = book.Price,
                Quantity = item.Quantity,
                LineTotal = OrderItem.CalculateLineTotal(book.Price, item.Quantity)
            });
        }
        order.Total = order.Items.Sum(i => i.LineTotal);

        db.Orders.Add(order);
        db.Carts.Remove(cart);
        try
        {
            await db.SaveChangesAsync(ct);
        }
        catch (DbUpdateConcurrencyException)
        {
            // Aynı sepet bu arada paralel bir istekle sipariş edildi.
            return new PlaceOrderOutcome.CartChanged();
        }
        await transaction.CommitAsync(ct);

        return new PlaceOrderOutcome.Placed(order);
    }

    private async Task<Customer> FindOrCreateCustomerAsync(string name, string email, CancellationToken ct)
    {
        string normalizedEmail = email.Trim().ToLowerInvariant();
        Customer? customer = await db.Customers.FirstOrDefaultAsync(c => c.Email == normalizedEmail, ct);
        if (customer is null)
        {
            customer = new Customer { Name = name.Trim(), Email = normalizedEmail };
            db.Customers.Add(customer);
        }
        return customer;
    }
}

İki ayrıntı var. Deneme sepeti kendisi okuyor, çünkü yeni denemede eski okuma geçersiz olabilir. ChangeTracker.Clear(), yarıda kalan bir denemenin context'te bıraktığı entity'leri temizliyor. Learn bir uç durumdan daha bahsediyor: bağlantı tam commit sırasında koparsa transaction'ın yazılıp yazılmadığı bilinmez ve yeni deneme siparişi ikinci kez oluşturabilir. Bunun için verifySucceeded parametresine, işlemin gerçekten yazılıp yazılmadığını kontrol eden bir fonksiyon verebilirsin. Bizde sepetin silinmesi bunu doğal olarak sınırlıyor: ikinci deneme sepeti bulamaz ve CartNotFound döner. Çözümün tamamında testler (Azurite çalışmadığı için kapak testlerinden ikisi atlandı):

Test run summary: Passed!
  total: 34
  failed: 0
  succeeded: 32

Retry'ın Gerçek Maliyeti: İki Katman

İkinci soruya geçelim. API'yi, var olmayan bir veritabanı adıyla başlatıp kitap detayını istedik. SQL Server her bağlantıda 4060 döndü ve strategy bunu geçici saydı:

info: Microsoft.EntityFrameworkCore.Infrastructure[10404]
      A transient exception occurred during execution. The operation will be retried after 0ms.
      Microsoft.Data.SqlClient.SqlException (0x80131904): Cannot open database "BookstoreDb" requested by the login. The login failed.
...
      A transient exception occurred during execution. The operation will be retried after 1035ms.
      A transient exception occurred during execution. The operation will be retried after 3026ms.
      A transient exception occurred during execution. The operation will be retried after 7631ms.
      A transient exception occurred during execution. The operation will be retried after 10000ms.
...
      Microsoft.EntityFrameworkCore.Storage.RetryLimitExceededException: The maximum number of retries (5) was exceeded while executing database operations with 'SqlServerRetryingExecutionStrategy'.
      Request finished HTTP/1.1 GET http://127.0.0.1:52718/api/books/9780441172719 - 500 - application/problem+json 52817.3700ms

Cevap 52,8 saniye. Bekleme sürelerinin toplamı 21,7 saniye; geri kalanı nereden geliyor? Bunu anlamak için ikinci bir deney yaptık: veritabanının adını geçici olarak değiştirip isteği gönderdik ve üç saniye sonra adı geri verdik. Varsayılan connection string'le:

20:57:33 request sent
20:57:36 database back
20:57:43 response 200 after 10.655411s

İstek başarılı oldu ama log'da EF Core'dan tek bir retry satırı yok. Retry'ı Microsoft.Data.SqlClient'in kendisi yaptı: bağlantı açarken geçici bir hata alınca ConnectRetryInterval kadar bekleyip tekrar deniyor. Learn'e göre varsayılanlar ConnectRetryCount=1 ve ConnectRetryInterval=10 saniye. Aynı deneyi connection string'e ConnectRetryCount=0 ekleyerek tekrarladık:

20:58:10 request sent
20:58:13 database back
20:58:22 response 200 after 12.228297s
$ grep "retried after" api.log
      A transient exception occurred during execution. The operation will be retried after 0ms.
      A transient exception occurred during execution. The operation will be retried after 1092ms.
      A transient exception occurred during execution. The operation will be retried after 3009ms.
      A transient exception occurred during execution. The operation will be retried after 7457ms.

Bu sefer retry'ı EF Core yaptı. İlk deneydeki 52 saniyenin sırrı da bu: her EF Core denemesi içinde SqlClient bir kez daha 10 saniye bekliyordu. İki katman birbirinin üstüne biniyor. Ne seçeceğin, isteğin ne kadar bekleyebileceğine bağlı: bir web isteğinde toplam bekleme, istemcinin ve load balancer'ın zaman aşımından kısa olmalı. Son bir gözlem: SQL container'ını durdurup isteği gönderdiğimizde hata pre-login handshake hatası olarak geldi, hata numarası listede olmadığı için hiç retry yapılmadı ve istek 5,5 saniyede 500 döndü. Bu, Docker'ın port yönlendirmesinin yerel bir davranışı; ama strategy'nin yalnız tanıdığı hataları tekrar denediğini gösteriyor.

Üretimde Dikkat

Connection pool'u say. SqlClient her connection string için varsayılan olarak en fazla 100 bağlantılık bir pool tutar. Container Apps'te 10 replica, her biri 100 bağlantı açarsa düşük bir tier'ın oturum sınırına takılabilirsin; tier'ın sınırlarını Learn'deki resource limits sayfasından kontrol et.

En düşük tier'larla production'a çıkma. Basic tier'ın West Europe liste fiyatı günde 0,161 dolar ve cazip görünüyor, ama disk erişimi ve log yazma hızı da ona göre sınırlı; kampanya gecesi ilk orada tıkanırsın.

Ağ erişimini daralt. Yeni bir sunucunun firewall'u her şeyi kapatır. "Allow Azure services and resources to access this server" seçeneği, yalnız senin değil, Azure'daki bütün müşterilerin kaynaklarına kapıyı açar. Doğru yol private endpoint ve public erişimi kapatmak; bir sonraki yazının konusu.

Sık Düşülen Tuzaklar

Serverless'ın uyanma süresini unutmak. Auto-pause'lu bir dev veritabanına sabah gelen ilk istek, yaklaşık bir dakika bekler ve 40613 alır. Retry yoksa kullanıcı hata görür; varsa bile ilk istek yavaştır.

Retry'ı açıp transaction'ları unutmak. Testi olmayan bir kod yolunda BeginTransactionAsync varsa hatayı production'da öğrenirsin. Projede BeginTransaction araması yapmak beş dakika sürer.

Restore'u denememek. Yedeğin varlığı, geri yüklenebildiğinin kanıtı değil. Point-in-time restore'u en az bir kez dev ortamında dene ve ne kadar sürdüğünü not et.

Uygulamaya db_owner vermek. Migration için gereken yetkiyi API'nin kimliğine verirsen, bir SQL injection açığı tabloyu silmeye kadar gidebilir.

Küçük Bir Deneme

★☆☆ EnableRetryOnFailure'ın deneme sayısını ve bekleme süresini configuration'a taşı. Development'ta 2 deneme, production'da 5 deneme olsun.

★★☆ PlaceOrderAsync için verifySucceeded fonksiyonunu yaz: commit sonrası bağlantı koparsa, sepetin silinip silinmediğine bakarak siparişin yazılıp yazılmadığına karar versin. Bunu nasıl test edebileceğini düşün.

★★★ Yazıdaki deneyi kendi makinende tekrarla ve ConnectRetryCount ile EnableRetryOnFailure'ın dört farklı birleşiminde, var olmayan bir veritabanı için 500'ün kaç saniyede döndüğünü bir tabloya yaz. Hangi birleşimi seçerdin?

Bir sonraki yazıda BookstoreDb'yi ve kapakların durduğu storage hesabını internetten çekiyoruz: VNet, subnet, NSG (Network Security Group; trafiği süzen kural listesi), private endpoint ve private DNS zone ile Bookstore'un ağ topolojisi.