Mert Özen Her Satırda Daha İleri
Veritabanı PostgreSQL — #11

Npgsql ve Dapper ile PostgreSQL: NpgsqlDataSource, Type Mapping, COPY ve LISTEN/NOTIFY

Mert Özen 14 Kas 2026 10 dk 2 görüntülenme
Npgsql ve Dapper ile PostgreSQL: NpgsqlDataSource, Type Mapping, COPY ve LISTEN/NOTIFY

.NET'ten PostgreSQL'e uygulama boyunca tek bir NpgsqlDataSource ile bağlanılır; timestamptz'ye yalnızca UTC değer yazılır, Dapper ise Kind'ı belirsiz DateTime'ı hata vermeden üç saat kaydırabilir. DI ve pool varsayılanları, = ANY(@ids), DateOnly ve jsonb, Dapper'ın enum'ı integer göndermesi, 100.000 satırda Execute, NpgsqlBatch ve binary COPY, otomatik prepare, LISTEN/NOTIFY ile cache invalidation ve OpenTelemetry.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Npgsql 10.0.3, Npgsql.DependencyInjection 10.0.3, Npgsql.OpenTelemetry 10.0.3, Dapper 2.1.89 ve OpenTelemetry 1.21.0 ile derlenip PostgreSQL 18.7 (resmî postgres:18 Docker image'ı) üzerinde, 02'de kurduğumuz Bookstore lab'ında çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) İstanbul'daki depodan gelen kargo saatini kaydediyor. Depo sistemi saati yerel saat olarak, Kind bilgisi olmadan gönderiyor: 14 Kasım, 13:00. Kod Dapper ile yazılmış, kolon timestamptz. Aynı değeri bir de Npgsql parametresine tipini açıkça vererek gönderiyoruz:

DateTime localNoKind = new(2026, 11, 14, 13, 0, 0);

// Dapper
await connection.ExecuteScalarAsync<string>(
    "SELECT (@value::timestamptz)::text || '  (TimeZone=' || current_setting('TimeZone') || ')'",
    new { value = localNoKind });

// Npgsql, tip açıkça timestamptz
command.Parameters.Add(new NpgsqlParameter("value", NpgsqlDbType.TimestampTz) { Value = localNoKind });

Npgsql 6'dan beri timestamptz'ye yalnızca UTC (Coordinated Universal Time; saat dilimlerinin referans aldığı evrensel saat) DateTime yazılabildiğini duymuşsundur. İki satırdan hangisi hata verir, hangisi bir değer kaydeder ve kaydedilen değer doğru mu?

Dapper, Kind=Unspecified -> 2026-11-14 13:00:00+00  (TimeZone=Etc/UTC)
TimestampTz, Kind=Unspecified -> ArgumentException: Cannot write DateTime with Kind=Unspecified to PostgreSQL type 'timestamp with time zone', only UTC is supported. Note that it's not possible to mix DateTimes with different Kinds in an array, range, or multirange. (Parameter 'value')

Tipi açıkça verilen parametre hemen hata verdi; bu iyi bir şey. Dapper ise hata vermedi ve 13:00'ü UTC olarak kaydetti: İstanbul saatiyle 16:00, yani üç saat kaymış bir kayıt. Dapper DateTime'ı DbType.DateTime olarak gönderiyor, Npgsql bunu timestamp (saat dilimsiz) yapıyor ve timestamptz'ye dönüşümü sunucu, oturumun TimeZone ayarıyla (lab'da UTC) yapıyor. Bu yazıda Npgsql ve Dapper ile PostgreSQL'e erişimin temellerini görüyoruz: NpgsqlDataSource ve DI (dependency injection; bağımlılıkların nesneye dışarıdan verilmesi), type mapping'in tuzakları, PostgreSQL enum'ları, toplu yazma, otomatik prepare, LISTEN/NOTIFY ve OpenTelemetry.

NpgsqlDataSource ve Dependency Injection

Npgsql 7'den beri bağlantıların giriş noktası NpgsqlDataSource. Connection pool'u, type mapping ayarlarını ve logging'i tek bir nesnede toplar; uygulama boyunca tek olmalı. ASP.NET Core'da Npgsql.DependencyInjection paketiyle singleton olarak kaydedilir:

HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Services.AddNpgsqlDataSource(connectionString);
using IHost host = builder.Build();

NpgsqlDataSource dataSource = host.Services.GetRequiredService<NpgsqlDataSource>();
await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync();
int[] ids = [1, 2, 3, 40000];
IEnumerable<BookRow> books = await connection.QueryAsync<BookRow>(
    "SELECT book_id AS BookId, title, price FROM books WHERE book_id = ANY(@ids) ORDER BY book_id", new { ids });
$env:BOOKSTORE_PG = "Host=localhost;Port=5432;Database=bookstore;Username=bookstore_app;Password=<app-password>"
docker exec -it bookstore-pg psql -U bookstore_owner -d bookstore -f /lab/11-npgsql.sql
dotnet run --project src/Bookstore.PgLab -- defaults
Pooling=True MinPoolSize=0 MaxPoolSize=100 ConnectionIdleLifetime=300s MaxAutoPrepare=0
info: Npgsql.Command[2001]
      Command execution completed (duration=15ms): SELECT book_id AS BookId, title, price FROM books WHERE book_id = ANY($1) ORDER BY book_id
     1 Emma #1                32.77
     2 The Hobbit #2          39.00
     3 Middlemarch #3         35.69
 40000 Dune #40000            38.36

Pool varsayılanları: en fazla 100 bağlantı, 300 saniye boşta kalan fazla bağlantılar kapatılır. 01'de gördüğümüz process-per-connection modeliyle birlikte düşün: instance sayısı çarpı Max Pool Size, sunucunun max_connections'ını aşmamalı. DI'dan gelen data source uygulamanın logging altyapısını da kullanıyor; komutlar Npgsql.Command kategorisinde log'a düşüyor.

SQL Server'daki WHERE id IN @ids kullanımına alışkınsan dikkat: Dapper bunu IN (@ids1, @ids2, ...) diye açar ve her farklı liste uzunluğu ayrı bir sorgu metni olur. PostgreSQL'de array'i tek parametre olarak gönderip = ANY(@ids) yazmak daha iyi: sorgu metni sabit kalır, 07'deki pg_stat_statements tek satırda toplar. Dapper'ın connection yönetimi ve async kullanımını Dapper serisinin async ve connection yönetimi yazısında görmüştük; aynı kurallar burada da geçerli: await using ile bağlantıyı mümkün olan en kısa sürede pool'a geri ver.

Type Mapping: timestamptz, DateOnly ve jsonb

Önce Tahmin Et'in devamı:

TimestampTz, Kind=Utc -> 2026-11-14 10:00 Kind=Utc
DateTimeOffset +03:00 -> ArgumentException: Cannot write DateTimeOffset with Offset=03:00:00 to PostgreSQL type 'timestamp with time zone', only offset 0 (UTC) is supported.  (Parameter 'value')
date -> DateOnly 2023-01-01
jsonb from string -> 42804: column "payload" is of type jsonb but expression is of type text
jsonb with NpgsqlDbType.Jsonb -> 1 row

Kurallar 03'teki timestamptz bölümüyle uyumlu: kolon bir zaman anı tutar ve Npgsql bu anı yalnızca belirsizliği olmayan bir değerden kabul eder. DateTime için Kind=Utc, DateTimeOffset için offset 0. Okurken Kind=Utc döner. Yerel saati UTC'ye çevirmek uygulamanın işi: TimeZoneInfo.ConvertTimeToUtc ya da baştan DateTimeOffset/TimeProvider ile çalışmak (Çözüm Desenleri serisinin zaman ve time zone yazısı). Dapper ile çalışıyorsan tahmindeki sessiz kaymayı önlemenin yolu, değeri UTC'ye çevirip Kind=Utc ile göndermek; o zaman Npgsql parametreyi timestamptz olarak gönderir.

Npgsql 10 ile gelen bir değişiklik: date kolonları artık varsayılan olarak DateOnly, time kolonları TimeOnly olarak okunuyor; eski kod DateTime bekliyorsa cast hatası alır. jsonb tarafında ise .NET string'i text olarak gidiyor ve PostgreSQL text'ten jsonb'ye kendiliğinden dönüştürmüyor. Ya parametrenin tipini NpgsqlDbType.Jsonb ver ya da Dapper'da SQL'e @payload::jsonb yaz.

PostgreSQL Enum ve Dapper

03'te enum type'ı lookup tablosu ve CHECK ile karşılaştırmıştık. Enum kullanmaya karar verdiysen Npgsql'e C# enum'ıyla eşleşmesini data source seviyesinde söylersin; isimler varsayılan olarak snake_case'e çevrilir (Audiobook → audiobook):

NpgsqlDataSourceBuilder builder = new(connectionString);
builder.MapEnum<BookFormat>("book_format");
await using NpgsqlDataSource dataSource = builder.Build();
stored as 'audiobook'
Dapper with a C# enum -> 42804: column "format" is of type book_format but expression is of type integer
label as text + cast -> Ebook, Audiobook

Npgsql'in kendi parametresiyle (AddWithValue("format", BookFormat.Audiobook)) yazmak sorunsuz. Dapper ise enum parametrelerini, sağlayıcıdan bağımsız olarak, altta yatan int'e çevirip gönderiyor ve PostgreSQL integer'ı enum'a çevirmiyor. Dapper'ın type handler'ı da enum parametrelerinde devreye girmiyor; denedik, aynı hatayı verdi. Pratik çözüm, etiketi metin olarak gönderip sunucuda cast etmek: @format::book_format. Okuma tarafında sorun yok; mapping sayesinde Npgsql değeri BookFormat olarak döndürüyor.

Toplu Yazma: Execute(list), NpgsqlBatch ve Binary COPY

Sipariş event'lerini başka bir sistemden aktarıyoruz: 100.000 satır, içinde jsonb ve timestamptz kolonları var. Üç yöntemi aynı transaction yapısıyla karşılaştırdık. Binary COPY'nin temelini Dapper serisinin performans yazısında görmüştük; burada her kolonun tipini açıkça veriyoruz, çünkü binary formatta sunucu tip tahmini yapmaz:

await using NpgsqlBinaryImporter importer = await connection.BeginBinaryImportAsync(
    "COPY order_events (order_id, event_type, payload, occurred_at) FROM STDIN (FORMAT BINARY)");
foreach (OrderEvent e in events)
{
    await importer.StartRowAsync();
    await importer.WriteAsync(e.OrderId, NpgsqlDbType.Integer);
    await importer.WriteAsync(e.EventType, NpgsqlDbType.Text);
    await importer.WriteAsync(e.Payload, NpgsqlDbType.Jsonb);
    await importer.WriteAsync(e.OccurredAt, NpgsqlDbType.TimestampTz);
}
ulong written = await importer.CompleteAsync();
Dapper Execute(list): 12947 ms
NpgsqlBatch (1000 per batch): 858 ms
binary COPY: 90 ms, 100000 rows

Süreler lab makinesine göre değişir ama oranlar anlamlı. Dapper'ın listeyle Execute'u her satır için ayrı bir INSERT ve ayrı bir round trip demek. NpgsqlBatch 1000 komutu tek round trip'te gönderiyor ve 15 kat hızlanıyor; komutlar tek tek çalıştığı için RETURNING ile üretilen ID'leri almak istediğinde iyi bir orta yol. COPY ise bir başka 10 kat; toplu yüklemede ilk tercih. CompleteAsync çağrılmazsa veri yazılmaz. COPY constraint'leri ve trigger'ları atlamaz, ama tek bir hatalı satır bütün yüklemeyi iptal eder; hataları satır satır raporlamak gerekiyorsa önce staging tablosuna yükleyip SQL ile doğrulamak gerekir (Çözüm Desenleri serisinin toplu import yazısı).

Otomatik Prepare

PostgreSQL her sorguyu parse edip planlar. Prepared statement bu işi bir kez yapıp sonraki çalıştırmalarda yeniden kullanır. Npgsql bunu bağlantı dizesindeki Max Auto Prepare ile otomatik yapabiliyor; varsayılanı 0 (kapalı). Açıldığında bir sorgu metni 5 kez (Auto Prepare Min Usages) çalıştıktan sonra o bağlantıda prepare ediliyor. Aynı sorguyu 5.000 kez çalıştırdık:

Max Auto Prepare=0 : 5000 queries in 928 ms, prepared statements on the server: 0
Max Auto Prepare=20: 5000 queries in 561 ms, prepared statements on the server: 1

Kısa ve sık çalışan sorgularda belirgin kazanç. Prepared statement'lar bağlantıya aittir; Npgsql bağlantı pool'a dönüp tekrar kullanıldığında bunları korur. Araya transaction modunda çalışan bir PgBouncer koyacaksan prepared statement desteğinin ayarını kontrol et; bu konu Veritabanı Performansı ve Scaling serisinde.

LISTEN/NOTIFY ile Cache Invalidation

Kitap fiyatları uygulamada bellekte cache'leniyor; fiyat değişince bütün instance'ların haberdar olması gerekiyor. 11-npgsql.sql, books.price değişince pg_notify('catalog_changed', book_id) çağıran bir trigger kuruyor. Uygulama ayrı bir bağlantıda dinliyor:

listener.Notification += (sender, e) =>
{
    int bookId = int.Parse(e.Payload, CultureInfo.InvariantCulture);
    priceCache.TryRemove(bookId, out _);
    Console.WriteLine($"notification on '{e.Channel}': book {bookId} evicted from the cache");
};
await using (NpgsqlCommand listen = new("LISTEN catalog_changed", listener))
{
    await listen.ExecuteNonQueryAsync();
}
notification on 'catalog_changed': book 1 evicted from the cache
cache entries left: 0

İki fiyat güncellemesi yaptık: kitap 1 commit edildi, kitap 2 rollback edildi. Yalnızca birincisi bildirildi; NOTIFY transaction commit edildiğinde gönderilir. Dinleyen bağlantı WaitAsync ile bekliyor; bu bağlantı pool'dan alınıp geri verilmemeli, uygulama boyunca açık kalmalı.

Sınırları net: bildirim yalnızca o an dinleyen bağlantılara gider ve hiçbir yerde saklanmaz. Dinleyen instance o sırada yeniden başlıyorsa bildirimi kaçırır. Payload 8000 byte'tan kısa olmalı ve aynı transaction'daki aynı kanal ve payload'lı bildirimler teke indirilir. Bu yüzden LISTEN/NOTIFY "cache'i temizle" gibi kaçırılması felaket olmayan sinyaller için uygun; yeniden bağlanınca cache'i komple temizlemek ve cache'e TTL (time to live; kaydın ne kadar süre geçerli kalacağı) koymak bu boşluğu kapatır. Güvenilir mesajlaşma için 09'daki outbox ve bir message broker gerekir.

OpenTelemetry ile Tracing

Npgsql.OpenTelemetry paketi, her komutu bir span olarak yayınlıyor. Npgsql 10 bu span'leri OpenTelemetry'nin kararlı veritabanı adlandırmasına geçirdi; eski sürümlerden gelen dashboard'larda alan adlarını kontrol et:

using TracerProvider tracing = Sdk.CreateTracerProviderBuilder()
    .AddNpgsql()
    .AddConsoleExporter()
    .Build();
Activity.DisplayName:        postgresql
Activity.Kind:               Client
Activity.Tags:
    db.query.text: SELECT book_id AS BookId, title, price FROM books WHERE book_id = @id
    db.system.name: postgresql
    db.namespace: bookstore

ASP.NET Core uygulamasında aynı satır AddOpenTelemetry().WithTracing(t => t.AddNpgsql()) ile eklenir ve HTTP isteğinin span'inin altında her SQL komutu ayrı bir span olarak görünür. Sorgu metninde parametre değerleri yok; değerleri log'a yazmak için data source'ta EnableParameterLogging() açman gerekir, production'da kişisel veri sızdırabileceği için kapalı tut.

Üretimde Dikkat

Bağlantı dizesine Application Name=bookstore-api ekle; pg_stat_activity'de ve log'larda hangi bağlantının hangi servise ait olduğu görünür. Timeout (bağlantı alma, varsayılan 15 saniye) ve Command Timeout (varsayılan 30 saniye) değerlerini 09'daki sunucu tarafı timeout'larla uyumlu seç.

Pool tükenmesi uygulamada "The connection pool has been exhausted" hatası olarak görünür; sunucudaki 53300 hatasıyla (01) karıştırma. İlki uygulamanın, ikincisi sunucunun sınırı.

Uzun süren COPY tek bir transaction'dır; milyonlarca satırı tek seferde yüklemek uzun bir transaction ve 08'deki VACUUM etkisi demek. Çok büyük yüklemeleri parçalara böl.

Sık Düşülen Tuzaklar

DateTime.Now ya da Kind'ı belirsiz değer göndermek: tahmindeki gibi Dapper'da sessizce kayar. Uygulama sınırında (API girişi, mesaj okuma) her zamanı UTC'ye çevir.

Her istekte yeni bir NpgsqlDataSource oluşturmak: her data source kendi pool'unu taşır; istek başına oluşturmak pool'u boşa çıkarır ve her istek yeni bir sunucu process'i başlatır.

NOTIFY'ı güvenilir mesajlaşma sanmak: kalıcı değil, tekrar gönderilmez, dinleyen yoksa kaybolur.

COPY'de tipi vermeden Write(value) çağırmak: .NET tipi kolon tipine uymazsa (decimal yerine double, string yerine jsonb) hata ancak sunucuda, yükleme sırasında çıkar.

Küçük Bir Deneme

★☆☆ Bağlantı dizesine Timezone=Europe/Istanbul ekleyip tahmindeki Dapper satırını tekrar çalıştır. Kaydedilen değer ne oldu? Bu ayar sorunu çözüyor mu, yoksa yalnızca yerini mi değiştiriyor?

★★☆ copy demosuna dördüncü bir yöntem ekle: bütün değerleri dört array parametresiyle gönderip INSERT INTO order_events (...) SELECT * FROM unnest(@orderIds, @types, @payloads::jsonb[], @times) çalıştır. NpgsqlBatch ve COPY ile karşılaştır.

★★★ listen demosunda dinleyen bağlantıyı bilerek kapat (pg_terminate_backend), bu sırada bir fiyat güncelle ve yeniden bağlan. Kaçırılan bildirimi nasıl telafi edersin? Yeniden bağlanınca cache'i temizleyen bir yapı kur.

Bir sonraki yazıda aynı veritabanına Entity Framework Core (EF Core; tabloları C# nesnelerine eşleyen Microsoft kütüphanesi) ile bağlanıyoruz: Npgsql provider'ı, snake_case isimlendirme, array ve JSON mapping, enum, xmin ile concurrency ve migration farkları.