Mert Özen Her Satırda Daha İleri
Veritabanı Dapper — #15

Dapper Performansı: Query Cache, GetRowParser ve SqlBulkCopy, COPY ile Bulk Alternatifleri

Mert Özen 6 Kas 2026 11 dk 6 görüntülenme
Dapper Performansı: Query Cache, GetRowParser ve SqlBulkCopy, COPY ile Bulk Alternatifleri

Dapper'ın query cache'i neyi saklar ve neden testere dişi çizer, CommandFlags.NoCache ne zaman gerekir, GetRowParser ile satır başına tip seçmek ve Dapper'da olmayan bulk işlemler için SQLite'ta transaction, SQL Server'da SqlBulkCopy, PostgreSQL'de COPY — hepsi gerçek ölçümlerle, sonunda BenchmarkDotNet.

Sürüm notu: Bu yazıdaki kodlar Dapper 2.1 ve .NET 10 içindir.

Önce Tahmin Et

Dapper çalıştırdığı her SQL metni için bir cache girdisi tutar. Şimdi 2000 kitabı, her biri için id'yi SQL'e gömerek tek tek okuyoruz; yani 2000 farklı SQL metni üretiyoruz:

using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
conn.Execute("create table books (id integer primary key, title text not null, price real not null)");
conn.Execute("insert into books (title, price) values (@title, 10)",
    Enumerable.Range(1, 2000).Select(i => new { title = $"Book {i}" }));
SqlMapper.PurgeQueryCache();

var sw = Stopwatch.StartNew();
for (var id = 1; id <= 2000; id++)
{
    conn.QuerySingle<Book>($"select id, title, price from books where id = {id}");
    if (id % 400 == 0) Console.WriteLine($"{id}: cache = {SqlMapper.GetCachedSQLCount()}");
}
Console.WriteLine($"interpolated: {sw.ElapsedMilliseconds} ms");

sw.Restart();
for (var id = 1; id <= 2000; id++)
    conn.QuerySingle<Book>("select id, title, price from books where id = @id", new { id });
Console.WriteLine($"parameterized: {sw.ElapsedMilliseconds} ms, cache = {SqlMapper.GetCachedSQLCount()}");

public record Book(long Id, string Title, double Price);

Döngü bittiğinde cache'te kaç girdi olur? 2000 mü? Aradaki ara ölçümler nasıl bir grafik çizer?

400: cache = 400
800: cache = 301
1200: cache = 201
1600: cache = 101
2000: cache = 1
interpolated: 14 ms
parameterized: 7 ms, cache = 2

Cache sonsuza kadar büyümedi, testere dişi gibi inip çıktı. Süre farkı da beklenenden küçük. Bu yazıda Dapper'ın içinde neyin cache'lendiğine, cache'i ne zaman devre dışı bırakacağına, satır başına tip seçmeye ve Dapper'da hiç olmayan bulk işlemlere bakacağız. Sürelerin hepsi Apple M5 bir makinede, gerçek çalıştırmalardan.

Query Cache Neyi Saklar?

Dapper bir sorguyu ilk kez gördüğünde iki pahalı iş yapar: parameter object'inden IDbCommand'a değer kopyalayan kodu ve reader'dan senin tipine satır dolduran deserializer'ı runtime'da IL olarak üretir. Bu kodlar static bir ConcurrentDictionary'de tutulur. Anahtar SQL metni, command tipi, connection string, sonuç tipi ve parameter tipinden oluşur; birinin değişmesi yeni bir girdi demektir.

Testere dişinin sebebi kaynak kodda. Dapper yeni bir sorgu için cache'e iki kez yazıyor ve her 1000 yazmada bir, hiç tekrar kullanılmamış girdileri siliyor. Tek seferlik SQL metinleri böylece periyodik olarak temizleniyor. Süre farkının küçük olmasının sebebi de deserializer'ların ayrıca kolon setine göre paylaşılması: 2000 farklı SQL aynı üç kolonu döndürdüğü için IL bir kez üretildi.

Yani "interpolation cache'i patlatır" cümlesi Dapper tarafında abartılı. Gerçek bedeller başka yerde: her sorgu SQL injection'a açık, ve SQL Server gibi bir sunucuda her farklı metin ayrı bir plan derletir (13. yazıda gördük). Bir tehlike de Dapper'da kalıyor: aynı dinamik metin iki kez çalışırsa girdi "kullanılmış" sayılır ve temizlikten kurtulur. Kullanıcı id'sini SQL'e gömen ve her kullanıcının sayfayı birkaç kez yenilediği bir uygulamada cache gerçekten büyür.

CommandFlags.NoCache ve PurgeQueryCache

Bazı SQL metinleri gerçekten tek seferliktir: bir yönetim ekranında elle yazılan rapor sorgusu, bir migration aracının ürettiği command'lar. Bunları cache'e hiç sokmamak için CommandDefinition'ın flags parametresi var:

var command = new CommandDefinition(
    "select id, title, 10.0 as price from books where id = 1",
    flags: CommandFlags.NoCache);

var book = conn.QuerySingle<Book>(command);
Console.WriteLine($"{book.Title}, cache = {SqlMapper.GetCachedSQLCount()}");
Dune, cache = 0

CommandFlags bir flags enum'u: None = 0, Buffered = 1, Pipelined = 2, NoCache = 4. Varsayılan değer Buffered; yalnızca NoCache yazdığında buffering'i de kapatmış olursun, ikisini birden istiyorsan CommandFlags.Buffered | CommandFlags.NoCache yaz.

Cache'i tamamen boşaltmak için SqlMapper.PurgeQueryCache() var; 11. yazıda geç kaydedilen type handler'ı devreye almak için kullanmıştık. Purge edildiğini izlemek istersen SqlMapper.QueryCachePurged event'i tetiklenir. Cache'in içine bakmak için de GetCachedSQLCount() ve GetCachedSQL() var; ikincisi her girdinin SQL'ini ve kaç kez kullanıldığını döndürür. Prod'da bir endpoint'in arkasına koymak, dinamik SQL üreten bir yeri bulmanın en ucuz yolu.

ExecuteReader ve GetRowParser: Satır Başına Tip

Bookstore'da basılı kitaplar ve e-kitaplar aynı products tablosunda, bir kind kolonuyla ayrılıyor. EF Core'daki TPH (table-per-hierarchy) düzeninin aynısı; EF Core bunu discriminator ile kendisi çözer (EF Core serisinin 19. yazısı). Dapper'da Query<T> tek bir tip doldurur, ama reader'a inip satır başına karar verebilirsin:

conn.Execute("""
    create table products (
        id integer primary key, kind text not null, title text not null,
        pages integer, file_size_mb real
    );
    insert into products (kind, title, pages, file_size_mb) values
        ('print', 'Dune', 896, null),
        ('ebook', 'Dune Messiah', null, 2.4),
        ('print', 'The Dispossessed', 387, null);
    """);
DefaultTypeMap.MatchNamesWithUnderscores = true;

var products = new List<Product>();
using (var reader = conn.ExecuteReader("select id, kind, title, pages, file_size_mb from products order by id"))
{
    var printParser = reader.GetRowParser<Product>(typeof(PrintBook));
    var ebookParser = reader.GetRowParser<Product>(typeof(EBook));
    var kindIndex = reader.GetOrdinal("kind");

    while (reader.Read())
    {
        var parser = reader.GetString(kindIndex) == "ebook" ? ebookParser : printParser;
        products.Add(parser(reader));
    }
}

foreach (var p in products)
    Console.WriteLine(p);

public abstract record Product
{
    public long Id { get; init; }
    public string Title { get; init; } = "";
}

public record PrintBook : Product
{
    public long Pages { get; init; }
}

public record EBook : Product
{
    public double FileSizeMb { get; init; }
}
PrintBook { Id = 1, Title = Dune, Pages = 896 }
EBook { Id = 2, Title = Dune Messiah, FileSizeMb = 2.4 }
PrintBook { Id = 3, Title = The Dispossessed, Pages = 387 }

GetRowParser o reader'ın kolon yapısı için bir deserializer üretir ve bir Func<DbDataReader, T> döndürür. Parser'ları döngüden önce bir kez oluşturduğumuz için satır başına maliyet, normal bir Query'deki kadar. Her alt tip yalnızca tanıdığı kolonları doldurdu; EBook için pages kolonu sessizce atlandı. ExecuteReader ayrıca Dapper'ın parameter desteğini koruyup ham ADO.NET reader'a inmenin de en kısa yolu.

Dapper'da Bulk Yok: Execute(list) Ne Yapıyor?

05. yazıdan hatırla: Execute'a bir liste verdiğinde Dapper her element için command'ı bir kez çalıştırır. Bu bir döngüdür, bulk işlem değil. Döngünün maliyetini en çok belirleyen şey ise transaction. Dosya tabanlı bir SQLite veritabanına 5000 satırı iki kez ekliyoruz:

File.Delete("bookstore-bulk.db");
using var conn = new SqliteConnection("Data Source=bookstore-bulk.db");
conn.Open();
conn.Execute("create table books (id integer primary key, title text not null, price real not null)");

var rows = Enumerable.Range(1, 5000).Select(i => new { title = $"Book {i}", price = 10.0 + i % 20 }).ToList();
const string insert = "insert into books (title, price) values (@title, @price)";

var sw = Stopwatch.StartNew();
conn.Execute(insert, rows);
Console.WriteLine($"no transaction: {sw.ElapsedMilliseconds} ms");

sw.Restart();
using (var tx = conn.BeginTransaction())
{
    conn.Execute(insert, rows, transaction: tx);
    tx.Commit();
}
Console.WriteLine($"one transaction: {sw.ElapsedMilliseconds} ms");
Console.WriteLine(conn.ExecuteScalar<long>("select count(*) from books"));
no transaction: 978 ms
one transaction: 6 ms
10000

Aynı 5000 insert, 160 kat fark. Transaction yokken SQLite her insert'i ayrı bir transaction olarak ele alıyor ve her biri için journal'ı diske yazıp senkronize ediyor. Tek transaction'da bu iş bir kez yapılıyor. SQLite'ta "bulk insert" dendiğinde kastedilen pratikte budur: tek transaction ve tekrar kullanılan bir command. Dapper liste aldığında command'ı her element için yeniden oluşturmuyor; aynı command'ı kullanıp parameter'larını her seferinde temizleyip yeniden ekliyor.

SQL Server: SqlBulkCopy

İstemci-sunucu veritabanlarında transaction tek başına yetmez, çünkü her satır hâlâ ayrı bir ağ gidiş-dönüşü. SQL Server'ın gerçek bulk yolu TDS protokolündeki bulk load akışı ve SqlClient'ta onun adı SqlBulkCopy. Dapper burada devre dışı; doğrudan provider'ı kullanıyoruz. 12. yazıdaki docker SQL Server'a 10.000 satır:

var connectionString = "Server=localhost,1433;Database=Bookstore;User Id=sa;Password=Bookstore_Pass1;TrustServerCertificate=True";
using var conn = new SqlConnection(connectionString);
conn.Open();
conn.Execute("""
    drop table if exists dbo.bulk_books;
    create table dbo.bulk_books (id int identity primary key, title nvarchar(200) not null, price decimal(10, 2) not null);
    """);

var books = Enumerable.Range(1, 10_000)
    .Select(i => new { title = $"Book {i}", price = 10m + i % 20 })
    .ToList();

var sw = Stopwatch.StartNew();
using (var tx = conn.BeginTransaction())
{
    conn.Execute("insert dbo.bulk_books (title, price) values (@title, @price)", books, transaction: tx);
    tx.Commit();
}
Console.WriteLine($"Execute(list): {sw.ElapsedMilliseconds} ms");

var table = new DataTable();
table.Columns.Add("title", typeof(string));
table.Columns.Add("price", typeof(decimal));
foreach (var b in books)
    table.Rows.Add(b.title, b.price);

sw.Restart();
using (var bulk = new SqlBulkCopy(conn) { DestinationTableName = "dbo.bulk_books" })
{
    bulk.ColumnMappings.Add("title", "title");
    bulk.ColumnMappings.Add("price", "price");
    bulk.WriteToServer(table);
}
Console.WriteLine($"SqlBulkCopy: {sw.ElapsedMilliseconds} ms");
Console.WriteLine(conn.ExecuteScalar<int>("select count(*) from dbo.bulk_books"));
Execute(list): 2215 ms
SqlBulkCopy: 25 ms
20000

Transaction içinde bile 10.000 tekil insert iki saniyeyi geçti; SqlBulkCopy aynı veriyi 25 ms'de yazdı. İlk çalıştırmada bu süre 98 ms'ydi, çünkü SqlClient'ın bulk kod yolu ilk kez ısınıyordu. SQL Server burada emülasyonla çalışıyor, gerçek bir sunucuda iki rakam da farklı olur ama oran benzerdir. ColumnMappings'i boş bırakma: verilmezse kolonlar sırayla eşlenir ve identity kolonu her şeyi bir kaydırır. Denedim; title id'ye, price title'a gitti ve SQL Server "Cannot insert the value NULL into column 'price'" hatası verdi. Dapper.AOT'un dokümantasyonunda SqlBulkCopy için yardımcılar da var; 16. yazıda AOT'a geçiyoruz.

PostgreSQL: COPY

PostgreSQL'in karşılığı COPY command'ı. Npgsql onu binary formatta bir yazıcı olarak sunuyor:

var pgConnectionString = "Host=localhost;Port=5432;Username=postgres;Password=bookstore;Database=bookstore";
using var conn = new NpgsqlConnection(pgConnectionString);
conn.Open();
conn.Execute("""
    drop table if exists bulk_books;
    create table bulk_books (id int generated always as identity primary key, title text not null, price numeric(10, 2) not null);
    """);

var books = Enumerable.Range(1, 10_000)
    .Select(i => new { title = $"Book {i}", price = 10m + i % 20 })
    .ToList();

var sw = Stopwatch.StartNew();
using (var tx = conn.BeginTransaction())
{
    conn.Execute("insert into bulk_books (title, price) values (@title, @price)", books, transaction: tx);
    tx.Commit();
}
Console.WriteLine($"Execute(list): {sw.ElapsedMilliseconds} ms");

sw.Restart();
using (var writer = conn.BeginBinaryImport("copy bulk_books (title, price) from stdin (format binary)"))
{
    foreach (var b in books)
    {
        writer.StartRow();
        writer.Write(b.title);
        writer.Write(b.price);
    }
    writer.Complete();
}
Console.WriteLine($"COPY: {sw.ElapsedMilliseconds} ms");
Console.WriteLine(conn.ExecuteScalar<long>("select count(*) from bulk_books"));
Execute(list): 1193 ms
COPY: 10 ms
20000

Yüz katın üzerinde fark. Complete()'i çağırmayı unutursan yazıcı dispose edildiğinde işlem iptal edilir ve hiçbir satır yazılmaz. Denedim: exception yok, tablo boş. Sessiz olduğu için bu satırı kod incelemesinde özellikle ara. Binary formatta her değerin tipinin kolonla birebir uyması gerekir; numeric kolona decimal, text'e string yazdık.

Doğru Ölçmek: BenchmarkDotNet

Bu yazıdaki Stopwatch ölçümleri kaba büyüklükleri görmek için yeterli, çünkü farklar yüz kat düzeyinde. Mikrosaniyeler konuşuluyorsa Stopwatch yalan söyler: JIT ısınması, Dapper'ın ilk çağrıdaki IL üretimi ve GC araya girer. Bunun için BenchmarkDotNet (0.15.8) var. Dapper'ı elle yazılmış ADO.NET koduyla karşılaştıralım:

BenchmarkRunner.Run<BookQueries>();

[MemoryDiagnoser]
[ShortRunJob]
public class BookQueries
{
    private SqliteConnection _conn = null!;

    [GlobalSetup]
    public void Setup()
    {
        _conn = new SqliteConnection("Data Source=:memory:");
        _conn.Open();
        _conn.Execute("create table books (id integer primary key, title text not null, price real not null)");
        _conn.Execute("insert into books (title, price) values (@title, 10)",
            Enumerable.Range(1, 1000).Select(i => new { title = $"Book {i}" }));
    }

    [GlobalCleanup]
    public void Cleanup() => _conn.Dispose();

    [Benchmark(Baseline = true)]
    public Book AdoNet()
    {
        using var cmd = _conn.CreateCommand();
        cmd.CommandText = "select id, title, price from books where id = @id";
        cmd.Parameters.AddWithValue("@id", 500);
        using var reader = cmd.ExecuteReader();
        reader.Read();
        return new Book(reader.GetInt64(0), reader.GetString(1), reader.GetDouble(2));
    }

    [Benchmark]
    public Book Dapper() =>
        _conn.QuerySingle<Book>("select id, title, price from books where id = @id", new { id = 500 });
}
dotnet run -c Release
| Method | Mean     | Error     | StdDev    | Ratio | Gen0   | Allocated | Alloc Ratio |
|------- |---------:|----------:|----------:|------:|-------:|----------:|------------:|
| AdoNet | 1.253 us | 0.0324 us | 0.0018 us |  1.00 | 0.1297 |   1.07 KB |        1.00 |
| Dapper | 1.769 us | 0.2069 us | 0.0113 us |  1.41 | 0.1945 |    1.6 KB |        1.50 |

Tek satırlık bir okumada Dapper yaklaşık yarım mikrosaniye ve yarım KB ekliyor. ShortRunJob hızlı bir fikir verir ama yalnızca 3 iterasyon yapar; karar vereceğin bir ölçümde varsayılan job'u kullan. Gerçek bir veritabanında ağ gidiş-dönüşü milisaniyelerle ölçülürken bu fark çoğu zaman gürültüdür. Benchmark'ı her zaman Release modunda çalıştır; Debug derlemesinde BenchmarkDotNet "non-optimized" diyerek çalışmayı reddediyor.

Sık Düşülen Tuzaklar

İlk çağrıyı ölçmek. Dapper'ın ilk çağrısı IL üretir, provider'ın ilk çağrısı connection pool'u ve kendi iç yapısını kurar. Tek bir Stopwatch ölçümüyle "Dapper yavaş" demeden önce ısınma turu at ya da BenchmarkDotNet kullan.

Execute(list)'i bulk sanmak. N element N gidiş-dönüş demek. Birkaç yüz satıra kadar transaction içinde sorun yok; binlerce satırda provider'ın bulk yoluna geç.

Transaction'sız toplu yazmak. SQLite'ta yukarıda 160 kat gördük; diğer veritabanlarında da her satır ayrı commit edilir. Toplu bir yazma her zaman tek transaction içinde olsun.

Büyük listeleri buffered okumak. 10. yazıda gördüğümüz gibi milyon satırlık bir export'u Query<T> ile okumak hepsini belleğe alır; burada ExecuteReader ya da unbuffered okuma doğru araç.

CommandFlags.NoCache'i tek başına yazıp buffering'i kaybetmek. Flags bir bit maskesi; Buffered'ı da istiyorsan açıkça ekle.

Küçük Bir Deneme

★☆☆ Açılıştaki döngüde her id'yi iki kez sorgula. Ara ölçümlerdeki testere dişi kayboluyor mu, cache sonunda kaç girdiye ulaşıyor? Sonucu kaynak koddaki "hiç kullanılmamış girdiyi sil" kuralıyla açıkla.

★★☆ SQLite örneğine üçüncü bir yol ekle: transaction içinde tek bir SqliteCommand oluşturup parameter değerlerini elle değiştirerek 5000 kez çalıştır. Dapper'ın Execute(list)'iyle arasındaki farkı ölç.

★★★ SqlBulkCopy örneğindeki DataTable'ı kaldır ve listeyi bir IDataReader gibi sunan küçük bir sarmalayıcı yaz. 100.000 satırla iki yolun bellek kullanımını GC.GetTotalAllocatedBytes() ile karşılaştır.

Bu yazıda Dapper'ın runtime'da ürettiği kodun cache'ini konuştuk. Bir sonraki ve serinin son yazısı tam da bunu ortadan kaldıran Dapper.AOT: aynı kodu build sırasında source generator ve interceptor'larla üretmek, Native AOT publish ve DAP analyzer'ları.