Büyük Tablolarla Yaşamak: Batch'li DELETE/UPDATE, Arşivleme, Online Index ve Online Şema Değişikliği
Büyük tabloda eski veriyi silmenin güvenli yolu tek dev DELETE değil, kendi transaction'ında checkpoint yazan keyset tabanlı küçük batch'lerdir. Tek transaction lock escalation ile müşteri okumasını düşürdü, log'u 200 MB'a büyüttü ve replica'yı 22 MB geride bıraktı; batch'li job aynı işi okumaları hiç bloklamadan, kesilince kaldığı yerden yaptı. Resumable index, CREATE INDEX CONCURRENTLY ve tabloyu yeniden yazan ALTER'lar.
Sürüm notu: Bu yazıdaki örnekler SQL Server 2025 (CU10, 17.0.5035.5), PostgreSQL 18.7 (bir primary ve bir streaming replica), .NET 10, Microsoft.Data.SqlClient 7.1.2 ve Npgsql 10.0.3 ile tek makinelik bir lab'da çalıştırılmıştır. Süreler ve boyutlar bu lab'a aittir; önemli olan iki yaklaşım arasındaki fark.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) dört yıldır sipariş alıyor. Muhasebe ve hukuk ekibiyle anlaşılan kural basit: 3 yıldan eski siparişler canlı tablolarda durmayacak, arşive taşınacak. SQL Server'daki dbo.Orders tablosunda bu tanıma uyan 62.414 sipariş ve onlara ait 156.035 sipariş kalemi var. İlk akla gelen çözüm tek bir transaction:
BEGIN TRANSACTION;
DELETE oi
OUTPUT DELETED.OrderId, DELETED.LineNumber, DELETED.BookId, DELETED.Quantity, DELETED.UnitPrice
INTO dbo.OrderItemsArchive (OrderId, LineNumber, BookId, Quantity, UnitPrice)
FROM dbo.OrderItems AS oi
JOIN dbo.Orders AS o ON o.OrderId = oi.OrderId
WHERE o.CreatedAt < '2023-11-14';
DELETE o
OUTPUT DELETED.OrderId, DELETED.CustomerId, DELETED.Status, DELETED.TotalAmount, DELETED.Currency, DELETED.CreatedAt
INTO dbo.OrdersArchive (OrderId, CustomerId, Status, TotalAmount, Currency, CreatedAt)
FROM dbo.Orders AS o
WHERE o.CreatedAt < '2023-11-14';
Toplam 220 bin satır; modern bir sunucu için küçük bir sayı. Tahmin et: bu transaction commit edilmeden önce, bir müşteri "Siparişlerim" sayfasını açarak geçen ayki siparişini okumak isterse ne olur? Silinen satırlarla hiçbir ilgisi olmayan, üç yıl sonraki bir sipariş bu.
SET LOCK_TIMEOUT 3000;
SELECT TOP (3) OrderId, Status, CreatedAt FROM dbo.Orders WHERE CustomerId = 4242 ORDER BY CreatedAt DESC;
Msg 1222, Level 16, State 56, Line 1
Lock request time out period exceeded.
Okuma 3 saniye bekleyip düştü. Bu yazıda neden düştüğünü ölçüyor, sonra aynı işi canlı sistemi rahatsız etmeden, kesilirse kaldığı yerden devam edebilen bir batch job'la yapıyoruz. Arkasından büyük tablolarda index eklemenin ve şemayı değiştirmenin online yollarına bakıyoruz.
Tek Dev DELETE'in Bedeli
Aynı transaction'ın içinde, commit etmeden önce, ne kadar log ürettiğini ve hangi lock'ları tuttuğunu soruyoruz:
SELECT database_transaction_log_record_count AS log_records,
database_transaction_log_bytes_used / 1024 / 1024 AS log_mb
FROM sys.dm_tran_database_transactions
WHERE database_id = DB_ID() AND transaction_id = CURRENT_TRANSACTION_ID();
SELECT resource_type, request_mode, COUNT(*) AS locks
FROM sys.dm_tran_locks
WHERE request_session_id = @@SPID AND resource_database_id = DB_ID()
GROUP BY resource_type, request_mode
ORDER BY locks DESC;
SELECT OBJECT_NAME(resource_associated_entity_id) AS object_name, request_mode
FROM sys.dm_tran_locks
WHERE request_session_id = @@SPID AND resource_database_id = DB_ID() AND resource_type = 'OBJECT';
log_records|log_mb
-----------|------
285440|35
resource_type|request_mode|locks
-------------|------------|-----
PAGE|X|1296
KEY|X|381
EXTENT|X|23
OBJECT|IX|3
PAGE|IX|2
ALLOCATION_UNIT|S|2
DATABASE|S|2
OBJECT|X|1
object_name|request_mode
-----------|------------
OrderItems|IX
OrderItemsArchive|IX
OrdersArchive|IX
Orders|X
Üç şey oldu. Birincisi, tek bir transaction 285 bin log kaydı ve 35 MB log üretti. Log 8 MB'tan başlamıştı; bu transaction sırasında dosya 200 MB'a büyüdü. Transaction açık kaldıkça log'un bu kısmı yeniden kullanılamaz; SIMPLE recovery'de bile checkpoint onu temizleyemez. İkincisi, dbo.Orders'taki row lock'lar lock escalation ile tablo seviyesinde tek bir X lock'a dönüştü. SQL Server, tek bir statement bir tablo ya da index üzerinde yaklaşık 5.000 lock'a ulaştığında (ve bellek baskısında) daha ince lock'ları tablo lock'una çevirir; sys.dm_db_index_operational_stats'taki index_lock_promotion_count bu lab'da 3'ten 4'e çıktı. Tablo seviyesindeki X lock, o tablodaki her okumayı ve yazmayı commit ya da rollback'e kadar durdurur; müşterinin okuması bu yüzden düştü. Lock mode'ları ve escalation'ın ayrıntısı SQL Server Derinlemesine serisinin "Lock'lar ve Blocking" yazısında. Üçüncüsü, bunların hepsi rollback'te de aynı süre ve aynı log maliyetiyle geri alınır: bir dev transaction'ı yarıda kesmek, onu bitirmekten ucuz değildir.
Replica'sı olan bir sistemde bir bedel daha var. PostgreSQL'de aynı arşivleme işini tek statement olarak yapıp, primary ile streaming replica arasındaki farkı her 100 ms'de ölçtük:
WITH moved_items AS (
DELETE FROM order_items AS oi USING orders AS o
WHERE o.order_id = oi.order_id AND o.created_at < '2023-11-14'
RETURNING oi.*
), archived_items AS (
INSERT INTO order_items_archive SELECT * FROM moved_items
), moved_orders AS (
DELETE FROM orders WHERE created_at < '2023-11-14' RETURNING *
)
INSERT INTO orders_archive SELECT * FROM moved_orders;
INSERT 0 62499
Time: 658.505 ms
max_replay_lag_bytes=22379440 max_replay_lag_ms=58
Statement 49 MB WAL (Write-Ahead Log; PostgreSQL'in değişiklikleri önce yazdığı transaction log'u) üretti ve replica bir anda 22 MB geride kaldı. Bu lab'da replica aynı makinede ve boşta olduğu için kısa sürede yetişti; ama üretimde replica başka bir veri merkezindeyse, kendi okuma yükünü taşıyorsa ya da silinen veri 220 bin değil 50 milyon satırsa, bu fark saniyelere ve dakikalara uzar. Read replica'dan okuyan her ekran o süre boyunca eski veriyi gösterir. Replica lag'in nasıl ölçüldüğünü ve uygulamaya etkisini 04 ve 06'da ayrıntılı göreceğiz.
Keyset ile Batch'li Taşıma
Çözüm, işi küçük ve bağımsız transaction'lara bölmek. Her batch en fazla 2.000 sipariş taşır, kendi transaction'ında commit edilir ve nerede kaldığını aynı transaction içinde bir progress tablosuna yazar. Bir sonraki batch'i OFFSET ile değil, son taşınan OrderId'den sonrasını isteyerek (keyset) bulur; böylece her batch clustered index'te doğrudan doğru yere gider. SQL Server'daki batch:
SET XACT_ABORT ON;
DECLARE @batch TABLE (OrderId bigint PRIMARY KEY);
INSERT INTO @batch (OrderId)
SELECT TOP (@batchSize) OrderId
FROM dbo.Orders
WHERE OrderId > @lastId AND CreatedAt < @cutoff
ORDER BY OrderId;
DELETE oi
OUTPUT DELETED.OrderId, DELETED.LineNumber, DELETED.BookId, DELETED.Quantity, DELETED.UnitPrice
INTO dbo.OrderItemsArchive (OrderId, LineNumber, BookId, Quantity, UnitPrice)
FROM dbo.OrderItems AS oi
JOIN @batch AS b ON b.OrderId = oi.OrderId;
DELETE o
OUTPUT DELETED.OrderId, DELETED.CustomerId, DELETED.Status, DELETED.TotalAmount, DELETED.Currency, DELETED.CreatedAt
INTO dbo.OrdersArchive (OrderId, CustomerId, Status, TotalAmount, Currency, CreatedAt)
FROM dbo.Orders AS o
JOIN @batch AS b ON b.OrderId = o.OrderId;
DECLARE @count int = (SELECT COUNT(*) FROM @batch);
DECLARE @maxId bigint = (SELECT MAX(OrderId) FROM @batch);
MERGE dbo.ArchiveProgress AS t
USING (SELECT @jobName AS JobName) AS s ON t.JobName = s.JobName
WHEN MATCHED AND @count > 0 THEN
UPDATE SET LastOrderId = @maxId, MovedOrders = t.MovedOrders + @count, UpdatedAt = SYSUTCDATETIME()
WHEN NOT MATCHED AND @count > 0 THEN
INSERT (JobName, LastOrderId, MovedOrders, UpdatedAt) VALUES (@jobName, @maxId, @count, SYSUTCDATETIME());
SELECT @count, @maxId;
Bu yaklaşımın işe yaraması OrderId sırasının CreatedAt sırasıyla aşağı yukarı aynı olmasına dayanıyor: identity kolonlu bir sipariş tablosunda bu doğrudur. Değilse batch'i CreatedAt üzerindeki bir index'le ve (CreatedAt, OrderId) keyset'iyle seçmek gerekir. Döngüyü çalıştıran .NET kodu:
long lastId = await GetCheckpointAsync(connection, db);
Console.WriteLine($"resuming after order {lastId}");
while (true)
{
await using DbTransaction tx = await connection.BeginTransactionAsync();
await using DbCommand command = connection.CreateCommand();
command.Transaction = tx;
command.CommandText = MoveBatchSql(db);
AddParameter(command, "lastId", lastId);
AddParameter(command, "cutoff", db == "sql" ? cutoff : DateTime.SpecifyKind(cutoff, DateTimeKind.Utc));
AddParameter(command, "batchSize", batchSize);
AddParameter(command, "jobName", JobName);
await using DbDataReader reader = await command.ExecuteReaderAsync();
await reader.ReadAsync();
int count = reader.GetInt32(0);
long maxId = reader.IsDBNull(1) ? lastId : reader.GetInt64(1);
await reader.DisposeAsync();
await tx.CommitAsync();
if (count == 0)
{
break;
}
moved += count;
lastId = maxId;
if (count < batchSize)
{
break;
}
// batch'ler arasında replica'ya, log'a ve diğer session'lara nefes payı
await Task.Delay(pause);
}
Log'u yine 8 MB'a küçültüp aynı 62.414 siparişi bu job'la taşıdık. Job çalışırken her 0,7 saniyede bir, ilk örnekteki müşteri okumasını LOCK_TIMEOUT 3000 ile denedik:
resuming after order 0
moved=20000 last_order_id=20000 batch_ms=454
moved=40000 last_order_id=40000 batch_ms=181
moved=60000 last_order_id=60000 batch_ms=199
done: moved=62414 orders in 9.6 s
reader ok=10 fail=0
On okumanın onu da başarılı, yeni lock escalation yok, log dosyası 200 yerine 72 MB'a büyüdü. İş 0,6 saniye yerine 9,6 saniye sürdü; bunun büyük kısmı bilerek koyduğumuz 200 ms'lik beklemeler. Bu bir takas: arşivleme yavaşlıyor, canlı sistem hiç fark etmiyor. FULL recovery'de log'un büyümemesi için batch'ler arasında log backup'larının çalışabilmesi gerekir; job'u log backup aralığını düşünerek ayarla.
PostgreSQL'de aynı job'u replica lag'ini ölçerek çalıştırdık ve 3. saniyede process'i öldürdük:
resuming after order 0
moved=20000 last_order_id=20000 batch_ms=24
Process'i öldürüp job'u yeniden başlattık:
resuming after order 28000
moved=20000 last_order_id=48000 batch_ms=28
done: moved=34499 orders in 4.2 s
max_replay_lag_bytes=1099432 max_replay_lag_ms=14
Job kaldığı yerden devam etti, çünkü checkpoint batch'le aynı transaction'da yazılıyor: ya ikisi birden commit edilir ya hiçbiri. Sonunda archive_progress 62.499 taşınmış sipariş gösterdi, canlı tabloda eski sipariş kalmadı. Replica en fazla 1,1 MB geride kaldı (tek statement'ta 22 MB'tı); iki çalıştırmanın toplamı 33 MB WAL üretti (tek statement'ta 49 MB). PostgreSQL'deki batch, data-modifying CTE'lerle (Common Table Expression; WITH ile tanımlanan adlandırılmış alt sorgu) tek statement'ta silme, arşive yazma ve progress güncellemesini yapıyor:
WITH batch AS (
SELECT order_id FROM orders
WHERE order_id > @lastId AND created_at < @cutoff
ORDER BY order_id
LIMIT @batchSize
),
moved_items AS (
DELETE FROM order_items AS oi USING batch AS b
WHERE oi.order_id = b.order_id
RETURNING oi.*
),
archived_items AS (
INSERT INTO order_items_archive SELECT * FROM moved_items
),
moved_orders AS (
DELETE FROM orders AS o USING batch AS b
WHERE o.order_id = b.order_id
RETURNING o.*
),
archived_orders AS (
INSERT INTO orders_archive SELECT * FROM moved_orders RETURNING order_id
),
progress AS (
INSERT INTO archive_progress (job_name, last_order_id, moved_orders, updated_at)
SELECT @jobName, max(order_id), count(*), now() FROM archived_orders HAVING count(*) > 0
ON CONFLICT (job_name) DO UPDATE
SET last_order_id = EXCLUDED.last_order_id,
moved_orders = archive_progress.moved_orders + EXCLUDED.moved_orders,
updated_at = EXCLUDED.updated_at
)
SELECT count(*)::int, max(order_id) FROM archived_orders;
PostgreSQL'de silinen satırlar dead tuple olarak kalır ve VACUUM'u bekler; büyük bir silmeden sonra tablo hemen küçülmez. Batch'li silme autovacuum'a da zaman tanır. MVCC (Multi-Version Concurrency Control; satırın birden çok sürümünü tutan eşzamanlılık yöntemi) ve VACUUM'un ayrıntısı PostgreSQL serisinin "MVCC, VACUUM ve Autovacuum" yazısında. Job'u üretimde nasıl çalıştıracağın (zamanlama, tek instance'ta çalışma, deploy sırasında kesilme) Background Job'lar serisinin "Uzun Süren ve Batch Job'lar" yazısında anlatılıyor; buradaki checkpoint tablosu o yazıdaki yaklaşımın veritabanı tarafı.
Arşivi Nereye Koymalı?
Arşiv tablosunun yeri, arşivin ne için tutulduğuna bağlı. Aynı veritabanında bir archive tablosu en basit seçenek: aynı transaction'da taşınabilir, raporlar join ile ulaşabilir. Ama veritabanının toplam boyutu, backup süresi ve restore süresi küçülmez; yalnızca canlı tablolar ve onların index'leri küçülür. Ayrı bir arşiv veritabanı (ya da daha ucuz bir depolama katmanı) backup ve restore süresini de küçültür; karşılığında taşıma iki sistem arasında yapılır ve "her satır tam olarak bir kez taşındı" garantisini uygulama kurmak zorunda kalır (önce arşive yaz, doğrula, sonra sil; tekrar çalışınca aynı satırı iki kez yazmayan bir anahtar).
Bookstore'da kullanıcıya etkisi de düşünülmeli: üç yıldan eski siparişler "Siparişlerim" sayfasından kayboluyor mu, yoksa "eski siparişler" bağlantısıyla arşivden mi okunuyor? Fatura ve iade gibi yasal yükümlülükler arşivdeki verinin ne kadar süre ve hangi biçimde saklanacağını belirler. Soft delete ve archive tablosu arasındaki tercih Çözüm Desenleri serisinin "Soft Delete'i Doğru Yapmak" yazısında. Arşivlemeyi her ay tekrarlanan bir bakım işine çevirmenin daha temiz yolu partitioning; 07 ve 08'de eski ayın partition'ını tek komutla tablodan ayıracağız.
Online Index: SQL Server'da Resumable, PostgreSQL'de CONCURRENTLY
Büyük bir tabloya index eklemek de aynı soruyu sorar: index oluşurken tabloya yazılabiliyor mu? PostgreSQL'de düz CREATE INDEX tabloda bir ShareLock tutar; okumalar devam eder ama INSERT, UPDATE ve DELETE build bitene kadar bekler. 2,5 milyon satırlık order_items'a index eklerken, yarım saniye sonra başka bir session'dan yazmayı denedik:
SET lock_timeout = '200ms';
INSERT INTO order_items VALUES (1000000, 9, 42, 1, 99.00);
mode | granted
-----------+---------
ShareLock | t
ERROR: canceling statement due to lock timeout
Time: 204.779 ms
Aynı index CREATE INDEX CONCURRENTLY ile:
mode | granted
--------------------------+---------
ShareUpdateExclusiveLock | t
INSERT 0 1
Time: 1.211 ms
ShareUpdateExclusiveLock yazmalarla çakışmaz. Bedeli: tablo iki kez taranır, build daha uzun sürer (bu lab'da 1,18 yerine 1,51 saniye), komut bir transaction bloğunun içinde çalışamaz ve başlamadan önce tabloya dokunan eski transaction'ların bitmesini bekler. Yarıda kalırsa geride INVALID durumda bir index bırakır; onu silip yeniden denemek ya da REINDEX INDEX CONCURRENTLY çalıştırmak gerekir. İlerlemeyi pg_stat_progress_create_index view'ından izleyebilirsin.
SQL Server'da karşılığı ONLINE = ON; Enterprise ve Developer edition'da var, Azure SQL'de de. 2019'dan beri index oluşturma RESUMABLE = ON ile duraklatılıp devam ettirilebiliyor. Bu, bakım penceresi kısa olan sistemler için çok değerli: build'i gece başlatırsın, sabah trafik artınca durdurur, ertesi gece kaldığı yerden devam edersin.
CREATE INDEX IX_OrderItems_BookId ON dbo.OrderItems (BookId) INCLUDE (Quantity)
WITH (ONLINE = ON, RESUMABLE = ON, MAXDOP = 1);
Build sürerken başka bir session'dan bir UPDATE sorunsuz çalıştı. Ardından build'i duraklattık:
ALTER INDEX IX_OrderItems_BookId ON dbo.OrderItems PAUSE;
SELECT name, state_desc, CAST(percent_complete AS decimal(5,1)) AS percent_complete, page_count
FROM sys.index_resumable_operations;
Msg 1219, Level 16, State 1, Line 1
Your session has been disconnected because of a high priority DDL operation.
name|state_desc|percent_complete|page_count
----|----------|----------------|----------
IX_OrderItems_BookId|PAUSED|70.2|3142
Hata mesajı build'i başlatan session'a ait: PAUSE o session'ı keser, yapılan iş kaybolmaz. Build yüzde 70,2'de bekliyor; ALTER INDEX ... RESUME ile kaldığı yerden tamamlandı. Resumable işlem büyük tek bir transaction gerektirmediği için log sık sık temizlenebilir. Ama duraklatılmış hâlde de bedeli var: eski ve yeni index ikisi birden disk tutar ve her DML (Data Manipulation Language; INSERT, UPDATE, DELETE gibi veri değiştiren komutlar) işleminde güncellenir, tabloda tablo seviyesinde X lock isteyen işlemler (örneğin TABLOCK'lu INSERT) 10637 hatasıyla düşer; onu günlerce unutma. ELEVATE_ONLINE ve ELEVATE_RESUMABLE database-scoped configuration'ları, yanlışlıkla offline başlatılan index işlemlerini veritabanı seviyesinde engellemenin yolu.
Şemayı Değiştirmek: Hangi ALTER Tabloyu Yeniden Yazar?
Kolon eklemek ya da tip değiştirmek bazen yalnız metadata değişikliğidir, bazen tablonun bütün satırlarına dokunur. Farkı bilmek, "beş saniyelik bir migration" ile "iki saatlik kesinti" arasındaki fark. SQL Server'da her değişikliği bir transaction'da çalıştırıp log maliyetini ölçtük, sonra rollback yaptık:
ALTER TABLE dbo.Orders ADD Channel varchar(10) NOT NULL CONSTRAINT DF_Orders_Channel DEFAULT 'web';
ALTER TABLE dbo.Orders ALTER COLUMN Status varchar(30) NOT NULL;
ALTER TABLE dbo.OrderItems ALTER COLUMN Quantity bigint NOT NULL;
== ALTER TABLE dbo.Orders ADD Channel varchar(10) NOT NULL CONSTRAINT DF_Orders_Channel DEFAULT 'web';
log_records|log_kb|ms
-----------|------|--
19|2|0
== ALTER TABLE dbo.Orders ALTER COLUMN Status varchar(30) NOT NULL;
log_records|log_kb|ms
-----------|------|--
15|1|13
== ALTER TABLE dbo.OrderItems ALTER COLUMN Quantity bigint NOT NULL;
log_records|log_kb|ms
-----------|------|--
2343986|387410|2937
Sabit default'lu NOT NULL kolon eklemek ve varchar'ı büyütmek metadata değişikliği; int'ten bigint'e geçiş 2,3 milyon log kaydı ve 378 MB log üretti, çünkü her satır yeniden yazıldı. Bu süre boyunca tablo bir schema modification lock (Sch-M) altında. PostgreSQL'de bir değişikliğin tabloyu yeniden yazıp yazmadığını tablonun dosya numarasından anlarsın:
SELECT pg_relation_filenode('order_items');
ALTER TABLE order_items ADD COLUMN gift_wrap boolean NOT NULL DEFAULT false;
SELECT pg_relation_filenode('order_items');
ALTER TABLE order_items ALTER COLUMN quantity TYPE bigint;
SELECT pg_relation_filenode('order_items');
filenode_before
-----------------
16545
ALTER TABLE
Time: 2.974 ms
after_add_column
------------------
16545
ALTER TABLE
Time: 2136.829 ms (00:02.137)
after_alter_type
------------------
16635
Kolon eklemek 3 ms sürdü ve dosya aynı kaldı (PostgreSQL 11'den beri volatile olmayan default'lar satırlara yazılmadan metadata'da tutuluyor). Tip değişikliği 2,1 saniye sürdü ve yeni bir dosya oluştu: tablo baştan yazıldı. İki komut da ACCESS EXCLUSIVE lock alır; birincisinde bu birkaç milisaniye, ikincisinde bütün yeniden yazma süresi boyunca okuma da dahil her şey bekler. Üretimde her DDL (Data Definition Language; şemayı değiştiren komutlar) komutundan önce SET lock_timeout ver: lock'u alamayan migration, arkasında bir kuyruk biriktirmek yerine hızlıca düşsün ve yeniden denensin.
Tabloyu yeniden yazan bir değişiklik gerekiyorsa, bunu uygulamayla birlikte birkaç deploy'a yayarsın (expand-contract): önce yeni kolonu ekle (expand), uygulama iki kolona da yazsın, eski satırları yukarıdaki gibi batch'lerle doldur, okumaları yeni kolona geçir, en son eski kolonu kaldır (contract). Her adım geriye uyumludur ve tek başına geri alınabilir. Deploy tarafındaki sıralama ilerideki Deployment Stratejileri serisinde.
Üretimde Dikkat
Batch boyutunu ölçerek seç. SQL Server'da bir statement'ın tek tabloda tuttuğu lock sayısı yaklaşık 5.000'i geçmesin; satır sayısı değil lock sayısı önemli (bir sipariş 2,5 kalem demek). PostgreSQL'de batch süresini birkaç yüz milisaniyenin altında tut. Batch'ler arası bekleme, replica lag'i ve log backup'ı izleyerek ayarlanabilir olsun; lag eşiği aşılırsa job beklesin.
Job'u izle. Progress tablosu "ne kadar kaldı" sorusunun da cevabı: taşınan sayı, son id, son güncelleme zamanı. Son güncelleme 10 dakikadan eskiyse alarm ver.
İndeksleri unutma. Batch'i seçen sorgunun bir index'le doğrudan doğru yere gitmesi gerekir; her batch'te tablo taranıyorsa batch'leme işe yaramaz. Arşiv tablolarında da sorgulanacak kolonlara index ekle.
Trigger, foreign key ve cascade'leri hesaba kat. ON DELETE CASCADE bir siparişi silerken kalemlerini de siler; kalemleri arşive yazmadan siparişi silersen veri kaybolur. Bu yüzden örnekte önce kalemleri, sonra siparişi aynı transaction'da taşıdık.
Accelerated Database Recovery (ADR; SQL Server'da uzun transaction'ların rollback ve recovery süresini kısaltan özellik) açık bir veritabanında dev transaction'ın rollback'i hızlıdır, ama lock ve replica etkisi değişmez; ADR batch'lemenin yerini tutmaz.
Sık Düşülen Tuzaklar
Batch'i tek transaction'da yapmak: döngü bir transaction'ın içindeyse batch'lemenin hiçbir faydası kalmaz; lock'lar, log ve replica farkı dev DELETE ile aynıdır.
Batch'i transaction'sız yapmak: siparişi silip kalemleri silmeden önce process ölürse yarım kalmış bir sipariş kalır. Bir batch'in içindeki her şey ve checkpoint aynı transaction'da olmalı.
OFFSET ile sayfalamak: her batch'te atlanan satır sayısı büyür ve silinen satırlar sayfaları kaydırır. Keyset (son id'den sonrası) kullan.
Replica lag'i izlemeden silmek: primary'de her şey yolunda görünürken replica'dan okuyan raporlar ve ekranlar dakikalarca eski veri gösterebilir. Silme job'unun durdurma koşullarından biri replica lag olmalı.
Yarıda kalmış CREATE INDEX CONCURRENTLY'yi fark etmemek: INVALID index sorgularda kullanılmaz ama her yazmada güncellenir. pg_index.indisvalid'i kontrol et.
Küçük Bir Deneme
★☆☆ Batch boyutunu 500, 2.000 ve 10.000 yapıp SQL Server job'unu tekrar çalıştır. Hangi boyutta index_lock_promotion_count artmaya başlıyor?
★★☆ Job'a bir lag eşiği ekle: PostgreSQL'de pg_stat_replication'dan okunan replay lag 1 MB'ı geçerse bir sonraki batch'ten önce lag düşene kadar beklesin. Replica'yı pg_wal_replay_pause() ile durdurup job'un beklediğini doğrula.
★★★ OrderItems.Quantity kolonunu int'ten bigint'e expand-contract ile taşı: yeni kolon ekle, batch'lerle doldur, bir trigger ya da uygulama kodu ile yeni yazmaları iki kolona yaz, sonra kolonları değiştir. Her adımda log ve lock maliyetini ölç.
Sonraki yazıda replica'ya odaklanıyoruz: physical ve logical replication'ın farkı, sync ve async commit'in kaybedilen veri üzerindeki etkisi, SQL Server Always On ve PostgreSQL streaming replication, replication lag'ı ölçmek ve unutulmuş bir replication slot'un primary'nin diskini nasıl doldurduğu.