İÇİNDEKİLER
- •Saatler süren batch job'larınızı dakikalara indirelim
- •Temel Cursor Sözdizimi
- •Önemli Cursor Seçenekleri
- •Cursor’lar Neden Yavaştır?
- •Kalıp 1: Satır Satır Insert
- •Kalıp 2: Her Satırı Başka Bir Tablodan Güncellemek
- •Kalıp 3: Kümülatif Toplam (Running Total)
- •Kalıp 4: Metni Bölmek ve Kelime Saymak
- •Yönetim Döngüleri: Komutları Üretin
- •Örnek: Tüm Veritabanlarında Son Başarılı CHECKDB Tarihi
- •1. Cursor ile
- •2. WHILE Döngüsü ve Kuyruk Tablosu ile
- •Hangisi Daha Esnek?
- •Bu Örnekte Döngüye Gerek Olmayabilir
- •Bir Veritabanındaki Tüm Oturumları Sonlandırmak
- •Uygun Döngüler: Büyük Değişiklikleri Parçalara Bölmek
- •Cursor Kullanmanız Gerekiyorsa
- •En yavaş job’ınızın arkasında bir cursor mu var?
Cursor, T-SQL’in bir sonuç kümesini programlama dillerinin bir koleksiyon üzerinde döndüğü gibi satır satır işlemesini sağlar. Geliştiricilerin ona yönelmesinin nedeni de, pek çok yavaş batch job’ın arkasında çıkmasının nedeni de budur: SQL Server kümeler üzerinde çalışmak için tasarlanmıştır; cursor ise bir milyon satır üzerindeki tek bir ifadeyi bir milyon ayrı ifadeye dönüştürür.
Cursor yasak değildir. Veritabanları üzerinde dönen yönetim script’leri ya da gerçekten satır satır çalışması gereken mantık için en basit doğru araçtır. Bu rehberde cursor sözdizimini ve önemli seçenekleri, cursor’ların neden yavaş olduğunu, set tabanlı sorgulara dönüştürülmüş dört yaygın cursor kalıbını, bir veritabanındaki tüm oturumları sonlandırmanın doğru yolunu ve gerçekten gerektiğinde cursor’ın nasıl doğru yazılacağını anlatıyoruz.
Saatler süren batch job'larınızı dakikalara indirelim
Aryasoft, cursor ve satır satır çalışan T-SQL kodunu set tabanlı sorgulara dönüştürerek gece job'larının ve raporların süresini kısaltır.
Temel Cursor Sözdizimi
Her cursor aynı beş adımı izler: bir sorguyla tanımlanır, açılır, @@FETCH_STATUS 0 olmaktan çıkana kadar döngüde satırlar okunur, kapatılır ve bellekten kaldırılır.
DECLARE @AccountID int;
DECLARE account_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT AccountID
FROM dbo.Accounts
WHERE IsActive = 1;
OPEN account_cursor;
FETCH NEXT FROM account_cursor INTO @AccountID;
WHILE @@FETCH_STATUS = 0
BEGIN
EXEC dbo.RecalculateAccount @AccountID = @AccountID; -- satır başına yapılan iş
FETCH NEXT FROM account_cursor INTO @AccountID;
END;
CLOSE account_cursor;
DEALLOCATE account_cursor;
Önemli Cursor Seçenekleri
Seçenek belirtilmeden tanımlanan bir cursor neredeyse kimseye uymayan varsayılanları kullanır: global’dir (veritabanının CURSOR_DEFAULT seçeneği LOCAL değilse) ve dinamiktir; yani her fetch’te alttaki veriyi yeniden kontrol eder. Aşağıdaki seçenekler bunu değiştirir.
| Seçenek | Ne yapar | Ne zaman kullanılır |
|---|---|---|
| LOCAL | Kapsamı batch, procedure veya trigger ile sınırlar | Neredeyse her zaman; isim çakışmalarını ve sızıntıları önler |
| GLOBAL | Deallocate edilene kadar tüm bağlantıya görünür | Nadiren; yalnızca başka bir batch’in kullanması gerekiyorsa |
| FAST_FORWARD | Yalnızca ileri, salt okunur ve performans için optimize | Basit döngüler için varsayılan tercih |
| STATIC | Açılışta sonucu tempdb’ye kopyalar; sonradan yapılan değişiklikleri görmez | Döngü okuduğu tabloyu değiştiriyorsa |
| KEYSET | Key’leri tempdb’de tutar; key dışı kolonlardaki güncellemeleri görür, yeni satırları görmez | Nadiren |
| DYNAMIC | Gezinirken tüm değişiklikleri görür; en pahalısıdır | Nadiren |
| READ_ONLY / SCROLL_LOCKS / OPTIMISTIC | Satırların cursor üzerinden güncellenip güncellenemeyeceği ve nasıl | WHERE CURRENT OF kullanmıyorsanız READ_ONLY |
İpucu
Varsayılan olarak CURSOR LOCAL FAST_FORWARD yazın. Yalnızca döngü okuduğu veriyi değiştiriyorsa ve sabit bir anlık görüntüye ihtiyacınız varsa LOCAL STATIC READ_ONLY’ye geçin.
Cursor’lar Neden Yavaştır?
- Satır başına bir çalıştırma. Her FETCH ve döngü içindeki her ifade, tüm satırları işleyen tek bir plan yerine kendi yüküyle ayrı ayrı çalışır.
- Set tabanlı optimizasyon yok. Join’ler, hash aggregate’ler, batch mode ve paralellik, her seferinde tek satıra dokunan bir döngüye yardımcı olamaz.
- İfade başına loglama. Autocommit modunda bir milyon tek satırlık insert, her biri diski bekleyen bir milyon log flush’ı demektir.
- Daha uzun kilit ve transaction’lar. Döngü tek bir transaction içinde çalışıyorsa kilitler boyunca birikir ve diğer oturumları bloklar.
- İlk fetch’ten önce veriyi kopyalayan STATIC ve KEYSET cursor’lar için tempdb işi.
Fark yüzde birkaç değildir. Bir cursor’ı tek bir ifade olarak yeniden yazmak, dakikaları ya da saatleri sıklıkla saniyelere indirir.
Kalıp 1: Satır Satır Insert
Bir tablodan satır okuyup başka bir tabloya ekleyen cursor en yaygın kalıptır ve değiştirmesi en kolay olandır:
-- Cursor versiyonu: her satır için bir INSERT
DECLARE @ID int, @First varchar(50), @Last varchar(50);
DECLARE c CURSOR LOCAL FAST_FORWARD FOR
SELECT EmployeeID, FirstName, LastName FROM dbo.Employee;
OPEN c;
FETCH NEXT FROM c INTO @ID, @First, @Last;
WHILE @@FETCH_STATUS = 0
BEGIN
INSERT INTO dbo.EmployeeArchive (EmployeeID, FullName)
VALUES (@ID, @First + ' ' + @Last);
FETCH NEXT FROM c INTO @ID, @First, @Last;
END;
CLOSE c; DEALLOCATE c;
-- Set tabanlı versiyon: tek ifade
INSERT INTO dbo.EmployeeArchive (EmployeeID, FullName)
SELECT EmployeeID, CONCAT(FirstName, ' ', LastName)
FROM dbo.Employee;
Kalıp 2: Her Satırı Başka Bir Tablodan Güncellemek
-- Siparişler üzerinde dönüp müşterinin indirim oranını aramak yerine:
UPDATE o
SET o.DiscountRate = c.DiscountRate
FROM dbo.Orders AS o
JOIN dbo.Customers AS c ON c.CustomerID = o.CustomerID
WHERE o.OrderDate >= '20260101';
Kalıp 3: Kümülatif Toplam (Running Total)
Kümülatif toplam, window fonksiyonlarından önce cursor kullanmanın klasik nedenlerinden biriydi. SQL Server 2012’den bu yana tek bir sorgudur:
SELECT
AccountID,
TransactionDate,
Amount,
SUM(Amount) OVER (PARTITION BY AccountID
ORDER BY TransactionDate, TransactionID
ROWS UNBOUNDED PRECEDING) AS RunningBalance
FROM dbo.Transactions;
ROWS UNBOUNDED PRECEDING‘i açıkça belirtin. Belirtilmezse varsayılan çerçeve RANGE‘dir; daha yavaştır ve aynı ORDER BY değerine sahip satırları tek grup gibi ele alır.
Kalıp 4: Metni Bölmek ve Kelime Saymak
Metni karakter karakter ya da kelime kelime bir döngüde ayrıştırmak cursor’ların bir başka favorisidir. STRING_SPLIT ile (SQL Server 2016 ve sonrası, compatibility level 130 veya üzeri) gruplanmış bir sorgudur:
-- Tüm satırlarda en sık geçen üç kelime
SELECT TOP (3)
LOWER(s.value) AS word,
COUNT(*) AS occurrences
FROM dbo.Notes AS n
CROSS APPLY STRING_SPLIT(n.NoteText, ' ') AS s
WHERE LEN(s.value) > 2
GROUP BY LOWER(s.value)
ORDER BY occurrences DESC;
Yönetim Döngüleri: Komutları Üretin
DBA script’leri çoğu zaman tablolar ya da veritabanları üzerinde dönerek her biri için bir komut çalıştırır. Komutları tek bir sorguyla üretmek, gözden geçirmek ve sonra çalıştırmak genellikle daha nettir:
-- "tmp_" ile başlayan ve bir aydan eski tablolar
SELECT STRING_AGG(
CONVERT(nvarchar(max), N'DROP TABLE ' + QUOTENAME(s.name) + N'.' + QUOTENAME(t.name) + N';'),
CHAR(13) + CHAR(10)) AS drop_script
FROM sys.tables AS t
JOIN sys.schemas AS s ON s.schema_id = t.schema_id
WHERE t.name LIKE N'tmp[_]%'
AND t.create_date < DATEADD(MONTH, -1, SYSDATETIME());
Uyarı
LIKE içinde alt çizgi, herhangi tek bir karakter anlamına gelen bir joker karakterdir. ‘b_%’ gibi bir kalıp yalnızca “b_” ile başlayanları değil, b ile başlayan tüm tabloları eşleştirir. Bunu ‘b[_]%’ şeklinde kaçırın ve üretilen bir DROP script’ini çalıştırmadan önce mutlaka gözden geçirin.
Önce script üretmek, neyin değiştirildiğine dair bir kayıt da sağlar. Kimsenin silmeye cesaret edemediği eski tablolar teknik olduğu kadar bir yönetişim sorunudur; veri yönetişimi nedir yazımıza göz atabilirsiniz.
Gerçekten bir döngü gerektiğinde, örneğin her veritabanında sırayla DBCC CHECKDB çalıştırmak için, sys.databases üzerinde bir cursor tamamen uygundur. Tekrar sayısı küçüktür ve her tekrar büyük, set tabanlı bir işlemdir.
Örnek: Tüm Veritabanlarında Son Başarılı CHECKDB Tarihi
Her veritabanının son başarılı DBCC CHECKDB tarihi, o veritabanının boot page’inde dbi_dbccLastKnownGood alanında tutulur ve DBCC DBINFO ile okunabilir. Komut veritabanı bağlamında çalıştığı için veritabanları üzerinde tek tek dönmek gerekir; bu, döngünün gerçekten anlamlı olduğu tipik bir DBA işidir.
Aşağıda aynı işi önce cursor, sonra WHILE döngüsüyle yapıyoruz. İki script de belirlediğiniz gün sayısından eski ya da hiç çalışmamış CHECKDB’leri işaretler.
1. Cursor ile
SET NOCOUNT ON;
DECLARE @ThresholdDay int = 7; -- bu kadar günden eski CHECKDB "gecikmiş" sayılır
DECLARE @DatabaseName sysname,
@sql nvarchar(max);
IF OBJECT_ID(N'tempdb..#DBInfo') IS NOT NULL DROP TABLE #DBInfo;
CREATE TABLE #DBInfo (ParentObject nvarchar(255), [Object] nvarchar(255),
Field nvarchar(255), [Value] nvarchar(4000));
IF OBJECT_ID(N'tempdb..#Result') IS NOT NULL DROP TABLE #Result;
CREATE TABLE #Result (DatabaseName sysname, LastGoodCheckDb datetime);
DECLARE db_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT name
FROM sys.databases
WHERE state_desc = N'ONLINE'
AND is_read_only = 0
AND database_id > 4 -- sistem veritabanları hariç
ORDER BY name;
OPEN db_cursor;
FETCH NEXT FROM db_cursor INTO @DatabaseName;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @sql = N'USE ' + QUOTENAME(@DatabaseName) + N'; DBCC DBINFO() WITH TABLERESULTS;';
INSERT INTO #DBInfo EXEC sys.sp_executesql @sql;
INSERT INTO #Result (DatabaseName, LastGoodCheckDb)
SELECT TOP (1) @DatabaseName, CONVERT(datetime, [Value])
FROM #DBInfo
WHERE Field = N'dbi_dbccLastKnownGood';
TRUNCATE TABLE #DBInfo;
FETCH NEXT FROM db_cursor INTO @DatabaseName;
END
CLOSE db_cursor;
DEALLOCATE db_cursor;
SELECT DatabaseName,
LastGoodCheckDb,
CASE WHEN LastGoodCheckDb < '19000102' THEN N'Hiç çalışmamış'
WHEN LastGoodCheckDb < DATEADD(DAY, -@ThresholdDay, GETDATE()) THEN N'Gecikmiş'
ELSE N'OK' END AS CheckDbStatus
FROM #Result
ORDER BY LastGoodCheckDb;
DROP TABLE #DBInfo;
DROP TABLE #Result;
Veritabanı adı QUOTENAME ile köşeli paranteze alınır; böylece adında boşluk, tire ya da ] karakteri olan veritabanları da script’i bozmaz. Yalnızca DBCC DBINFO veritabanı bağlamına ihtiyaç duyduğu için dinamik SQL’e sadece o satır girer; sonuçların işlenmesi normal T-SQL olarak kalır. CHECKDB hiç çalışmamış bir veritabanında alan 1900-01-01 döner; script bunu ayrıca işaretler.
2. WHILE Döngüsü ve Kuyruk Tablosu ile
SET NOCOUNT ON;
DECLARE @ThresholdDay int = 7;
DECLARE @DatabaseName sysname,
@sql nvarchar(max);
IF OBJECT_ID(N'tempdb..#DBInfo') IS NOT NULL DROP TABLE #DBInfo;
CREATE TABLE #DBInfo (ParentObject nvarchar(255), [Object] nvarchar(255),
Field nvarchar(255), [Value] nvarchar(4000));
IF OBJECT_ID(N'tempdb..#Result') IS NOT NULL DROP TABLE #Result;
CREATE TABLE #Result (DatabaseName sysname, LastGoodCheckDb datetime);
-- Kuyruk: 0 = bekliyor, 1 = tamamlandı, 2 = hata
IF OBJECT_ID(N'tempdb..#Queue') IS NOT NULL DROP TABLE #Queue;
SELECT name AS DatabaseName,
CAST(0 AS tinyint) AS QueueStatus,
CAST(NULL AS nvarchar(4000)) AS ErrorMessage
INTO #Queue
FROM sys.databases
WHERE state_desc = N'ONLINE'
AND is_read_only = 0
AND database_id > 4;
WHILE EXISTS (SELECT 1 FROM #Queue WHERE QueueStatus = 0)
BEGIN
SELECT TOP (1) @DatabaseName = DatabaseName
FROM #Queue
WHERE QueueStatus = 0
ORDER BY DatabaseName;
BEGIN TRY
SET @sql = N'USE ' + QUOTENAME(@DatabaseName) + N'; DBCC DBINFO() WITH TABLERESULTS;';
INSERT INTO #DBInfo EXEC sys.sp_executesql @sql;
INSERT INTO #Result (DatabaseName, LastGoodCheckDb)
SELECT TOP (1) @DatabaseName, CONVERT(datetime, [Value])
FROM #DBInfo
WHERE Field = N'dbi_dbccLastKnownGood';
UPDATE #Queue SET QueueStatus = 1 WHERE DatabaseName = @DatabaseName;
END TRY
BEGIN CATCH
-- Hata veren veritabanını işaretle ve sıradakine geç
UPDATE #Queue
SET QueueStatus = 2, ErrorMessage = ERROR_MESSAGE()
WHERE DatabaseName = @DatabaseName;
END CATCH;
TRUNCATE TABLE #DBInfo;
END
SELECT DatabaseName,
LastGoodCheckDb,
CASE WHEN LastGoodCheckDb < '19000102' THEN N'Hiç çalışmamış'
WHEN LastGoodCheckDb < DATEADD(DAY, -@ThresholdDay, GETDATE()) THEN N'Gecikmiş'
ELSE N'OK' END AS CheckDbStatus
FROM #Result
ORDER BY LastGoodCheckDb;
-- Okunamayan veritabanları ve nedenleri
SELECT DatabaseName, ErrorMessage FROM #Queue WHERE QueueStatus = 2;
DROP TABLE #Queue;
DROP TABLE #DBInfo;
DROP TABLE #Result;
Hangisi Daha Esnek?
İki script de aynı sonucu üretir; fark, döngünün durumunun nerede tutulduğundadır.
Cursor’da durum cursor’ın kendisindedir: liste OPEN anında sorgudan gelir, FETCH sırayla ilerler ve döngü bittiğinde geride bir iz kalmaz. Kısa, okunaklı, tek seferlik bir tarama için en az kodla işi görür; LOCAL FAST_FORWARD ile ek yükü de düşüktür.
WHILE sürümünde durum bir tablodadır ve bu tablo üzerinde istediğiniz her şeyi yapabilirsiniz:
- Hata yönetimi: Bir veritabanı hata verdiğinde satırı “hata” olarak işaretleyip mesajını saklar, döngüye sıradaki veritabanından devam edersiniz. Sonunda hangi veritabanlarının neden atlandığını tek sorguyla görürsünüz.
- Kaldığı yerden devam: Kuyruğu geçici tablo yerine kalıcı bir tabloda tutarsanız, yarıda kesilen bir iş yeniden başlatıldığında yalnızca bekleyen veritabanlarını işler. Gece boyunca süren CHECKDB ya da index bakımı job’larında bu büyük fark yaratır.
- Sıra ve öncelik: ORDER BY’ı değiştirerek önce kritik ya da küçük veritabanlarını işleyebilir, tabloya bir öncelik kolonu ekleyebilirsiniz.
- Ölçüm ve kayıt: Başlangıç ve bitiş zamanı, deneme sayısı gibi kolonlar ekleyerek her adımın ne kadar sürdüğünü kayıt altına alabilirsiniz.
- İşi bölmek: Kalıcı bir kuyruk tablosunda satırları
UPDATE ... OUTPUTile “sahiplenerek” işi birden fazla oturuma ya da job’a dağıtabilirsiniz.
Bu esnekliğin bir bedeli vardır: döngünün ilerlemesi tamamen sizin elinizdedir. Bir satır hiçbir koşulda işaretlenmezse, örneğin hata durumunda UPDATE atlanırsa, WHILE EXISTS koşulu hiç sağlanmaz ve döngü sonsuza kadar aynı veritabanında döner. Bu yüzden yukarıdaki script satırı hem TRY hem CATCH bloğunda işaretler.
Pratik kural: tek seferlik, basit taramalar için LOCAL FAST_FORWARD bir cursor yeterlidir. Hata toleransı, yeniden başlatılabilirlik ya da kayıt gereken bakım işlerinde kuyruk tablosu ve WHILE döngüsü daha doğru seçimdir.
Bu Örnekte Döngüye Gerek Olmayabilir
SQL Server 2016 SP2, SQL Server 2017 CU9 ve sonrasında son başarılı CHECKDB zamanı DATABASEPROPERTYEX ile doğrudan okunabilir. Bu durumda iş, bu rehberin ana fikrine uygun şekilde tek bir set tabanlı sorguya iner:
SELECT name AS DatabaseName,
DATABASEPROPERTYEX(name, 'LastGoodCheckDbTime') AS LastGoodCheckDb
FROM sys.databases
WHERE state_desc = N'ONLINE'
AND database_id > 4
ORDER BY LastGoodCheckDb;
Döngülü yöntemler; eski sürümlerde, DBCC DBINFO‘nun döndürdüğü başka alanlara da ihtiyaç duyduğunuzda ya da her veritabanında gerçekten bir komut çalıştırmanız gerektiğinde işe yarar. DBCC DBINFO‘nun belgelenmemiş bir komut olduğunu ve çıktı biçiminin sürümler arasında değişebileceğini de unutmayın.
Bir Veritabanındaki Tüm Oturumları Sonlandırmak
Popüler bir cursor script’i sp_who çıktısını geçici bir tabloya okur ve bir veritabanındaki her oturum için KILL çalıştırır. Bunun için cursor’a gerek yoktur. Bir veritabanını özel kullanıma almak için işi SQL Server’a bırakın:
-- Herkesin bağlantısını kes ve açık transaction'larını geri al
ALTER DATABASE SalesDB SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
-- ... bakım ...
ALTER DATABASE SalesDB SET MULTI_USER;
Yalnızca oturumları kesip veritabanını kullanılabilir tutmanız gerekiyorsa, KILL komutlarını oturum DMV’sinden üretin:
DECLARE @sql nvarchar(max);
SELECT @sql = STRING_AGG(CONVERT(nvarchar(max), N'KILL ' + CONVERT(nvarchar(10), session_id) + N';'), N' ')
FROM sys.dm_exec_sessions
WHERE database_id = DB_ID(N'SalesDB')
AND session_id <> @@SPID
AND is_user_process = 1;
PRINT @sql; -- önce gözden geçirin
-- EXEC sys.sp_executesql @sql;
Kritik
KILL bir transaction’ı yok etmez, geri alır; geri alma da yaptığı işi geri almak kadar, bazen daha uzun sürebilir. Bir olay sırasında uzun transaction’ları kill etmek, oturumların KILLED/ROLLBACK durumunda takılı kalmasına ve kurtarmanın uzamasına sıkça yol açar. Kill etmeden önce oturumun ne yaptığını kontrol edin; bunu kesinti runbook’umuzda ele alıyoruz.
Uygun Döngüler: Büyük Değişiklikleri Parçalara Bölmek
Her döngü bir cursor sorunu değildir. On milyonlarca satırı tek ifadeyle silmek ya da güncellemek transaction log’u doldurabilir, tabloyu uzun süre bloklayabilir ve iptal edilirse geri alınması saatler sürebilir. Değişikliği parçalar halinde işleyen bir döngü burada doğru araçtır:
DECLARE @rows int = 1;
WHILE @rows > 0
BEGIN
DELETE TOP (5000)
FROM dbo.AuditLog
WHERE LoggedAt < DATEADD(YEAR, -2, SYSDATETIME());
SET @rows = @@ROWCOUNT;
END;
Her parça kısa, set tabanlı bir transaction’dır. Kilitler parçalar arasında bırakılır, log backup’lardan sonra log yeniden kullanılabilir ve job’ı durdurmak yalnızca o anki parçayı geri alır. Filtre kolonundaki bir index, her parçanın tabloyu taramasını önler.
Cursor Kullanmanız Gerekiyorsa
LOCAL FAST_FORWARDolarak tanımlayın (döngü kaynağı değiştiriyorsaLOCAL STATIC READ_ONLY).- Cursor’ın sonuç kümesini küçük tutun; filtrelemeyi döngü içinde değil, cursor sorgusunda yapın.
- Atomik olması gerekmiyorsa döngünün tamamını tek bir transaction’a sarmayın.
- CATCH bloğu dahil her zaman CLOSE ve DEALLOCATE yapın.
- İç içe cursor’lardan kaçının; neredeyse her zaman kılık değiştirmiş bir join’dir.
- Ölçün: döngünün gövdesi tek bir ifadeyse, büyük ihtimalle tüm döngü de tek bir ifade olabilir.
İlgili Yazılar
SQL Server Performans Danışmanlığı
En yavaş job’ınızın arkasında bir cursor mu var?
Aryasoft’un kıdemli DBA ekibi uzun süren job ve raporların arkasındaki satır satır çalışan kodu bulur, set tabanlı T-SQL olarak yeniden yazar ve sonuçların aynı olduğunu doğrular; böylece job, yaptığı iş değişmeden hızlanır.