Backend uygulamalarında iş kuralları çoğunlukla şu iki adımla uygulanır:
- Veritabanından kayıt okunur ve geçerli olup olmadığı kontrol edilir.
- Kontrol başarılıysa kayıt güncellenir veya yeni bir kayıt oluşturulur.
İlk bakışta oldukça doğal görünen bu yaklaşım, eşzamanlı istekler altında veri tutarsızlığına neden olabilir.
Bu problem genel olarak TOCTOU — Time-of-Check to Time-of-Use olarak adlandırılır. Bir verinin kontrol edildiği an ile kullanıldığı an arasında değişmesi durumudur.
1. Problem: Time-of-Check to Time-of-Use
Aşağıdaki randevu iptal fonksiyonunu düşünelim:
async function cancelAppointment(appointmentId: string) {
// CHECK
const appointment = await db.appointments.findById(appointmentId);
if (!appointment) {
throw new NotFoundError("Appointment not found");
}
if (appointment.status === "CANCELLED") {
throw new ConflictError("Appointment is already cancelled");
}
// USE
appointment.status = "CANCELLED";
await appointment.save();
return appointment;
}
Kod tek bir istek açısından incelendiğinde doğru görünüyor:
- Randevu bulunuyor.
- Daha önce iptal edilip edilmediği kontrol ediliyor.
- Uygunsa durumu
CANCELLEDolarak değiştiriliyor.
Ancak kontrol ile güncelleme arasında başka bir işlem aynı kaydı değiştirebilir.
Production’da Ne Olur?
Node.js’in JavaScript kodunu tek bir thread üzerinde çalıştırması, bütün isteklerin baştan sona sırayla tamamlanacağı anlamına gelmez.
Bir fonksiyon await noktasında beklerken Event Loop başka bir isteğin ilerlemesini sağlayabilir. Ayrıca production ortamında uygulama genellikle birden fazla process, container veya server instance üzerinde çalışır.
Bu nedenle iki farklı istek aynı kaydı eşzamanlı olarak okuyabilir. Node.js’in Event Loop ve asenkron I/O modelini JavaScript Nasıl Asenkron Çalışır? Derinlemesine Bir Event Loop Rehberi başlıklı yazıda ayrıntılı olarak incelemiştik.
Örneğin hasta randevuyu iptal ederken doktor aynı randevuyu tamamlamaya çalışsın:
Request A — Hasta Request B — Doktor
│ │
├─► Randevuyu okur ├─► Randevuyu okur
│ status = CONFIRMED │ status = CONFIRMED
│ │
│ ├─► status = COMPLETED
│ └─► save()
│
├─► Daha önce okuduğu veriye
│ dayanarak status = CANCELLED
└─► save()
Her iki istek de başlangıçta CONFIRMED durumunu görür ve kendi kontrolünden başarıyla geçer.
Doktorun işlemi önce tamamlanmasına rağmen hastanın eski veriye dayanarak yaptığı güncelleme daha sonra çalışırsa son durum CANCELLED olabilir.
Bu durum bir race condition ve aynı zamanda bir lost update örneğidir.
Application seviyesindeki
ifkontrolleri iş kuralını ifade eder ancak kontrol ve güncellemenin atomik olmasını sağlamaz.
save() işleminin tam davranışı kullanılan ORM’ye göre değişebilir. Bazı ORM’ler bütün kaydı, bazıları yalnızca değişen alanları günceller. Ancak iki işlem aynı alanı değiştiriyorsa son yazan işlemin önceki değişikliği geçersiz kılması hâlâ mümkündür.
2. Çözüm Yolları ve Trade-off’lar
Concurrency kaynaklı veri tutarsızlıklarını önleyen tek bir yöntem yoktur. Uygun çözüm şu faktörlere göre değişir:
- İşlem kaç tabloyu etkiliyor?
- Aynı kayıt ne sıklıkla eşzamanlı olarak değiştiriliyor?
- Kullanıcıya conflict döndürmek kabul edilebilir mi?
- İşlem boyunca veritabanı bağlantısı bekletilebilir mi?
- Başarısız işlemler yeniden denenebilir mi?
En sık kullanılan üç yaklaşım şunlardır:
| Strateji | Uygun olduğu senaryo | Avantajı | Dezavantajı / riski |
|---|---|---|---|
| Atomic Condition Update | Tek satırlık durum geçişleri, sayaç ve stok güncellemeleri | Tek sorgu, kısa kilit süresi ve yüksek performans | Çok adımlı veya çok tablolu kurallarda tek başına yetersizdir |
| Optimistic Locking | Okuma–düzenleme–yazma akışları ve düşük çakışma | İş mantığı yürütülürken açık kilit tutulmaz | Conflict yönetimi gerekir; kontrolsüz retry yoğun yük oluşturabilir |
| Pessimistic Locking | Kısa transaction içindeki çok adımlı ve çakışan işlemler | Aynı kayıt üzerindeki çatışan işlemleri sıraya sokar | Bekleme, deadlock ve connection tüketimi oluşturabilir |
3. Yöntem A: Atomic Condition Update
Basit state geçişlerinde tercih edilebilecek en temiz yöntemlerden biri, kontrol ve güncellemenin tek SQL ifadesinde yapılmasıdır.
async function cancelAppointment(appointmentId: string) {
const result = await db.query(
`UPDATE appointments
SET status = 'CANCELLED'
WHERE id = $1
AND status = 'CONFIRMED'
RETURNING *`,
[appointmentId]
);
if (result.rowCount === 0) {
const appointment = await db.appointments.findById(appointmentId);
if (!appointment) {
throw new NotFoundError("Appointment not found");
}
throw new ConflictError(
"Appointment cannot be cancelled in its current state"
);
}
return result.rows[0];
}
Burada önemli olan bölüm şudur:
WHERE id = $1
AND status = 'CONFIRMED'
Bu koşul yalnızca CONFIRMED durumundaki randevuların iptal edilmesine izin verir.
Şöyle bir koşul da yazılabilirdi:
status NOT IN ('CANCELLED', 'COMPLETED')
Ancak bu yaklaşım ileride sisteme EXPIRED, NO_SHOW veya REFUNDED gibi yeni durumlar eklendiğinde beklenmeyen geçişlere izin verebilir.
Bu nedenle yasaklanan durumları listelemek yerine izin verilen durumları açıkça belirtmek genellikle daha güvenlidir.
Eşzamanlı İki İstek Gelirse Ne Olur?
İki işlem aynı anda aşağıdaki sorguyu çalıştırırsa PostgreSQL çakışan güncellemeleri kontrol eder:
İki işlem aynı satırı eşzamanlı olarak güncellemeye çalıştığında PostgreSQL, satır kilidini ilk alan işlemin devam etmesine izin verir. Diğer işlem aynı satırı değiştirmek istiyorsa ilk işlemin COMMIT veya ROLLBACK ile tamamlanmasını bekler.
Burada request’lerin sunucuya geliş sırası belirleyici değildir. Daha sonra gelen bir request, sorgusunu daha erken çalıştırarak satır kilidini önce alabilir.
İlk işlem tamamlandıktan sonra bekleyen UPDATE, WHERE koşulunu satırın güncel hâli üzerinde yeniden değerlendirir:
UPDATE appointments
SET status = 'CANCELLED'
WHERE id = $1
AND status = 'CONFIRMED';
İlk işlem randevuyu COMPLETED durumuna getirmişse status = 'CONFIRMED' koşulu artık sağlanmaz. Bu nedenle bekleyen iptal sorgusu hiçbir satırı güncellemez ve rowCount değeri 0 olur.
Buradaki güvenliği yalnızca satır kilidi sağlamaz. Satır kilidi iki yazma işlemini sıraya sokarken, koşullu WHERE ifadesi eski bir iş kuralına dayanarak yapılan ikinci güncellemenin uygulanmasını engeller.
Bu yönteme Atomic Condition Update denmesinin nedeni, mevcut durumun kontrol edilmesiyle yeni durumun yazılmasının tek SQL statement’ı içinde gerçekleştirilmesidir. Böylece application seviyesinde kontrol ile güncelleme arasında başka bir işlemin girebileceği TOCTOU aralığı oluşmaz.
PostgreSQL’in bu davranışı Transaction Isolation ve Explicit Locking belgelerinde açıklanmaktadır.
Atomic Update Hiç Kilit Kullanmaz mı?
Hayır.
UPDATE işlemi yine gerekli satır kilitlerini alır. Buradaki avantaj kilit kullanılmaması değil, ayrı bir SELECT ve açık bir kilitleme adımına ihtiyaç duyulmamasıdır.
İşlem tek sorguda gerçekleştiği için kilit süresi genellikle daha kısa olur.
İkinci Sorgu TOCTOU Problemini Geri Getirir mi?
Başarısız güncellemeden sonra yapılan şu sorgu yalnızca uygun hata mesajını belirlemek içindir:
const appointment = await db.appointments.findById(appointmentId);
Asıl state değişikliği zaten atomik olarak gerçekleşmiştir. Dolayısıyla ikinci okuma veri bütünlüğünü bozmaz.
Ancak kayıt bu iki sorgu arasında tekrar değişebilir. Bu nedenle 404 Not Found ve 409 Conflict ayrımının kesin bir snapshot üzerinden yapılması gerekiyorsa işlemi transaction içinde tasarlamak veya tek bir genel conflict cevabı vermek düşünülebilir.
4. Yöntem B: Optimistic Locking
Optimistic locking, bir kaydın okunduktan sonra başka bir işlem tarafından değiştirilip değiştirilmediğini anlamak için sürüm numarası kullanır.
Tabloya bir version alanı ekleyelim:
ALTER TABLE appointments
ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
Kayıt okunurken mevcut sürüm de alınır:
async function updateAppointmentNotes(
appointmentId: string,
newNotes: string
) {
const appointment =
await db.appointments.findById(appointmentId);
if (!appointment) {
throw new NotFoundError("Appointment not found");
}
const result = await db.query(
`UPDATE appointments
SET notes = $1,
version = version + 1
WHERE id = $2
AND version = $3
RETURNING *`,
[newNotes, appointmentId, appointment.version]
);
if (result.rowCount === 0) {
throw new ConflictError(
"Appointment was modified by another operation. Please refresh and try again."
);
}
return result.rows[0];
}
Örneğin kayıt okunduğunda version = 2 olsun.
Bu sırada başka bir işlem kaydı güncelleyip versiyonu 3 yaparsa aşağıdaki koşul artık sağlanmaz:
version = 2
Optimistic Locking Kullanırken Dikkat Edilmesi Gerekenler
version alanının güvenilir olabilmesi için ilgili kaydı değiştiren bütün kod yollarının aynı protokole uyması gerekir.
Başka bir kod aşağıdaki gibi versiyonu kontrol etmeden güncelleme yaparsa optimistic locking koruması atlanmış olur:
UPDATE appointments
SET notes = $1
WHERE id = $2;
Bu nedenle:
versionalanıNOT NULLolmalıdır.- Her başarılı güncellemede artırılmalıdır.
- Bütün writer’lar versiyon kontrolüne uymalıdır.
- Conflict durumunda kullanıcıdan veriyi yenilemesi istenebilir.
- Otomatik retry yapılacaksa deneme sayısı sınırlandırılmalıdır.
Optimistic locking düşük veya orta çakışmalı sistemlerde etkilidir. Ancak aynı kayıt çok sık değiştiriliyorsa çok sayıda conflict oluşabilir. Her conflict kontrolsüz şekilde yeniden denenirse retry storm meydana gelebilir.
5. Yöntem C: Pessimistic Locking
Pessimistic locking, işlemin başında ilgili kaydı kilitler ve transaction tamamlanana kadar başka işlemlerin aynı kaydı değiştirmesini engeller.
PostgreSQL’de bunun için SELECT ... FOR UPDATE kullanılabilir.
async function bookSeat(
seatId: string,
userId: string
) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const seatResult = await client.query(
`SELECT id, status
FROM seats
WHERE id = $1
FOR UPDATE`,
[seatId]
);
const seat = seatResult.rows[0];
if (!seat) {
throw new NotFoundError("Seat not found");
}
if (seat.status !== "AVAILABLE") {
throw new ConflictError("Seat is already taken");
}
await client.query(
`UPDATE seats
SET status = 'BOOKED',
user_id = $1
WHERE id = $2`,
[userId, seatId]
);
const bookingResult = await client.query(
`INSERT INTO bookings (seat_id, user_id)
VALUES ($1, $2)
RETURNING *`,
[seatId, userId]
);
await client.query("COMMIT");
return bookingResult.rows[0];
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
SELECT FOR UPDATE ile seçilen satır transaction sonuna kadar kilitli kalır.
Aynı satır üzerinde aşağıdaki işlemleri gerçekleştirmek isteyen diğer transaction’lar beklemek zorunda kalabilir:
UPDATEDELETESELECT FOR UPDATE- Diğer uyumsuz satır kilitleme işlemleri
Normal bir SELECT ise genel olarak bu satır kilidi nedeniyle engellenmez. Ayrıntılı kilit davranışları PostgreSQL Explicit Locking dokümantasyonunda açıklanmaktadır.
Bu Örnekte Atomic Update Kullanılamaz mı?
Yalnızca koltuğun durumunu değiştirmek isteseydik atomic update daha basit olurdu:
UPDATE seats
SET status = 'BOOKED',
user_id = $1
WHERE id = $2
AND status = 'AVAILABLE'
RETURNING *;
Ancak örnekte hem koltuğun durumu değiştiriliyor hem de ayrı bir rezervasyon kaydı oluşturuluyor. Bu iki işlemin birlikte başarılı veya başarısız olması gerekir. Pessimistic locking özellikle karar verildikten sonra transaction içinde birden fazla işlem yapılacaksa anlam kazanır.
Transaction’ı Kısa Tutmak Neden Önemlidir?
Kilit transaction sonuna kadar tutulur. Transaction açıkken aşağıdaki gibi yavaş işlemler yapılmamalıdır:
- Harici API çağrısı
- Kullanıcıdan cevap bekleme
- Uzun süren dosya işlemleri
- Gereksiz hesaplamalar
- E-posta gönderimi
Transaction uzadıkça diğer işlemlerin bekleme süresi ve connection kullanımı artar.
Deadlock Riski
Bir transaction önce A kaydını, sonra B kaydını kilitlerken başka bir transaction önce B’yi, sonra A’yı kilitlerse iki işlem birbirini bekleyebilir.
Bu duruma deadlock denir.
PostgreSQL deadlock’u algılar ve işlemlerden birini iptal eder. Uygulamanın bu hatayı yakalayıp gerekiyorsa sınırlı şekilde retry yapması gerekir.
Deadlock riskini azaltmanın temel yollarından biri, birden fazla kaydın her zaman aynı sırayla kilitlenmesidir.
6. Veritabanı Constraint’lerini Unutmamak Gerek
Locking yöntemleri application akışını korur. Ancak mümkün olan iş kuralları ayrıca veritabanı constraint’leriyle güvence altına alınmalıdır.
Örneğin aynı koltuk için yalnızca bir aktif rezervasyon bulunabilmesi gerekiyorsa uygun bir UNIQUE constraint veya partial unique index kullanılabilir.
CREATE UNIQUE INDEX one_active_booking_per_seat
ON bookings (seat_id)
WHERE status = 'ACTIVE';
Böylece application kodunda gözden kaçan bir yol olsa bile veritabanı aynı koltuk için iki aktif rezervasyon oluşturulmasını engeller.
PostgreSQL constraint seçenekleri hakkında daha fazla bilgiye Constraints dokümantasyonundan ulaşılabilir.
Application kontrolü kullanıcı deneyimini iyileştirir; veritabanı constraint’i ise veri bütünlüğünün son savunma hattıdır.
7. Hangi Yöntemi Seçmeliyiz?
Tek satır üzerinde basit bir state geçişi yapıyorsak ilk tercih çoğunlukla Atomic Condition Update olmalıdır.
Tek satırlık koşullu güncelleme
│
└──► Atomic Condition Update
Kayıt kullanıcı tarafından okunuyor, bir süre düzenleniyor ve daha sonra kaydediliyorsa Optimistic Locking uygun olabilir.
Oku → Düzenle → Daha sonra kaydet
│
└──► Optimistic Locking
Karar verildikten sonra aynı transaction içinde birden fazla sorgunun güvenli biçimde tamamlanması gerekiyorsa Pessimistic Locking değerlendirilebilir.
Kaydı kontrol et
│
├──► Başka kayıt oluştur
├──► Birden fazla tabloyu güncelle
└──► Hepsini birlikte commit et
│
└──► Pessimistic Locking
Bunlar birbirini tamamen dışlayan yöntemler değildir. Gerçek sistemlerde şu korumalar birlikte kullanılabilir:
- Atomic update
- Transaction
- Optimistic veya pessimistic locking
UNIQUE,CHECKveFOREIGN KEYconstraint’leri- Idempotency key
- Sınırlı retry mekanizması
Sonuç
TOCTOU probleminin temelinde kontrol ile kullanımın birbirinden ayrılması vardır.
Aşağıdaki yapı eşzamanlı işlemler altında tek başına güvenli değildir:
const data = await read();
if (isValid(data)) {
await write(data);
}
Çünkü read() ile write() arasında veri başka bir işlem tarafından değiştirilebilir.
Basit state geçişlerinde kontrolü doğrudan güncelleme sorgusuna taşımak çoğunlukla en sade çözümdür:
UPDATE ...
SET ...
WHERE id = ?
AND current_state = expected_state;
Daha uzun okuma–düzenleme akışlarında optimistic locking, çok adımlı ve aynı kayıt üzerinde seri çalışması gereken işlemlerde ise pessimistic locking kullanılabilir.
Buradaki temel soru şudur:
Kontrol ettiğim veri ile güncellediğim veri arasında başka bir işlem çalışırsa sistem hâlâ doğru kalır mı?
Cevap hayırsa kontrolü veritabanı seviyesinde atomik hâle getirmek veya uygun bir concurrency kontrol yöntemi kullanmak gerekir.
