← blog · 2 Kasım 2026

Kayıp Güncelleme Problemi: Atomik Güncelleme, Kötümser Kilit, Sürüm Sütunu ve If-Match

Oku, değiştir, yaz biçimindeki her kod eşzamanlı yük altında veri kaybettirir. Atomik güncelleme, FOR UPDATE ve iyimser sürüm kontrolü arasında nasıl seçim yapılır, HTTP'de ETag ile If-Match nasıl kullanılır ve hangi tuzaklar kontrolü sessizce etkisiz bırakır.

İki istek aynı satırı okur, ikisi de bellekte değiştirir, ikisi de geri yazar. İkinci yazma birincinin değişikliğini sessizce siler. Hata yoktur, log temizdir, kullanıcı sadece "kaydettiğim şey kayboldu" der. Buna kayıp güncelleme (lost update) denir ve oku, değiştir, yaz biçiminde çalışan her kodda bekler: stok düşmek, bakiye güncellemek, bir formu kaydetmek, JSON alanındaki bir listeye eleman eklemek. Çözümün tek bir doğru biçimi yok; işlemin şekline göre üç farklı araç var ve yanlış olanı seçmek ya sorunu çözmez ya da yeni bir sorun getirir.

Önce problemi doğru adlandırın

Kayıp güncelleme bir yarış durumudur, ama her yarış aynı ilacı istemez. Kendinize iki soru sorun:

  • Yeni değer eski değerden veritabanının hesaplayabileceği bir formülle mi çıkıyor (stok - 1, bakiye + tutar), yoksa uygulamanın ya da kullanıcının verdiği bir kararla mı?
  • Okuma ile yazma arasında ne kadar zaman geçiyor? Milisaniyeler mi, yoksa kullanıcının formu açık tutup kahve içtiği dakikalar mı?

Bu iki sorunun cevabı hangi yaklaşımın uygun olduğunu neredeyse tek başına belirler.

Birinci araç: atomik güncelleme

Yeni değer eskisinden hesaplanabiliyorsa okumayı uygulamaya hiç taşımayın. Hesabı veritabanına yaptırın:

UPDATE urunler
   SET stok = stok - 1
 WHERE id = $1 AND stok > 0
RETURNING stok;

Bu tek deyim satırı kilitler, güncel değeri görür ve koşulu uygular. Dönen satır yoksa stok bitmiştir; ayrı bir "önce kontrol et" sorgusuna gerek kalmaz. En hızlı, en basit ve en az hata çıkaran yol budur. Sayaçlar, bakiyeler, kotalar ve stok gibi işlemlerde ilk tercih bu olmalı.

Sık görülen hata, ORM'in "nesneyi yükle, alanı değiştir, kaydet" akışına alışıp sayaç artırmayı da böyle yazmaktır. Kod okunaklı görünür, testte geçer, eşzamanlı yük altında sayılar eksik çıkar. Çoğu ORM'in doğrudan artırma ya da ham ifade ile güncelleme yöntemi vardır; sayaç için onu kullanın.

İkinci araç: kötümser kilit

Karar uygulamada veriliyorsa ve okuma ile yazma aynı kısa işlemin içindeyse satırı okurken kilitleyebilirsiniz:

BEGIN;
SELECT * FROM siparisler WHERE id = $1 FOR UPDATE;
-- uygulama karar verir
UPDATE siparisler SET durum = 'onaylandi' WHERE id = $1;
COMMIT;

İkinci işlem aynı SELECT ... FOR UPDATE satırına geldiğinde ilki bitene kadar bekler, sonra güncel satırı görür. PostgreSQL'de READ COMMITTED düzeyinde bekleyen işlem kilidi aldıktan sonra WHERE koşulunu satırın yeni hâline göre yeniden değerlendirir; yani satır artık koşula uymuyorsa hiç dönmez. Bunu bilmeyen biri "kilitledim ama satır gelmedi" diye şaşırır.

Beklemek istemediğiniz durumlar için FOR UPDATE NOWAIT hemen hata verir, FOR UPDATE SKIP LOCKED kilitli satırları atlar. İkincisi iş kuyruğu tablolarında birden fazla işçinin aynı işi almasını önlemek için biçilmiş kaftandır, ama tutarlı bir okuma için kullanılmaz: atlanan satırlar sonuçta yoktur.

Kötümser kilidin tuzakları:

Kilidi uzun tutmak. İşlemin içinde dış bir HTTP çağrısı, dosya yüklemesi ya da kullanıcı onayı beklemek kilidi saniyelerce açık bırakır. Diğer istekler sıraya girer, bağlantı havuzu dolar ve sorun kilitle hiç ilgisi yokmuş gibi "veritabanı yavaşladı" diye görünür. Kural: kilitli işlemin içinde ağ çağrısı yapmayın.

Kilit sırası. İki işlem iki satırı ters sırayla kilitlerse kilitlenme (deadlock) olur. Veritabanı birini öldürür, ama bunu sık görüyorsanız birden fazla satırı her zaman aynı sırayla, örneğin birincil anahtara göre sıralayarak kilitleyin.

Kullanıcının düşünme süresi. Form açıldığında kilit alıp kaydedildiğinde bırakmak işe yaramaz; HTTP isteği bittiğinde işlem de bitmiştir. Kullanıcı süresi boyunca kilit tutmanın tek yolu uygulama düzeyinde bir "bu kaydı şu kişi düzenliyor" bayrağıdır, o da süre aşımı ve terk edilmiş kilit sorunlarını beraberinde getirir. Bu durumda doğru araç bir sonraki.

Üçüncü araç: iyimser eşzamanlılık

Satıra bir sürüm sütunu ekleyin. Okurken sürümü alın, yazarken yalnızca sürüm değişmemişse yazın:

UPDATE belgeler
   SET baslik = $2, govde = $3, surum = surum + 1
 WHERE id = $1 AND surum = $4;

Etkilenen satır sayısı sıfırsa biri sizden önce yazmıştır. Kilit tutulmaz, okuma ile yazma arasında saatler geçebilir. Kullanıcı düzenlemesi olan her ekran, uzun süreli formlar ve dağıtık istemciler için doğru yaklaşım budur. Hibernate ve JPA'da @Version, Rails'te lock_version sütunu bu deseni hazır verir.

HTTP tarafında aynı fikrin standart karşılığı vardır. Sunucu kaynağı ETag başlığıyla döner, istemci güncellerken bu değeri If-Match başlığında geri gönderir. Değer tutmuyorsa sunucu 412 Precondition Failed döner. İstemcinin koşulsuz güncelleme göndermesini tamamen engellemek istiyorsanız If-Match olmayan isteklere 428 Precondition Required dönebilirsiniz. If-Match güçlü karşılaştırma kullanır; zayıf W/ önekli bir ETag bu başlıkla eşleşmez, dolayısıyla sürüm için güçlü ETag üretin.

İyimser yaklaşımın tuzakları daha sinsidir, çünkü kod çalışıyor gibi görünür:

Sürümü yanlış yerden almak. En yaygın hata şudur: kaydetme isteği gelir, sunucu kaydı veritabanından yeniden yükler, alanları değiştirir ve kaydeder. Sürüm kontrolü her zaman geçer, çünkü karşılaştırılan sürüm kullanıcının gördüğü değil, bir milisaniye önce okunan sürümdür. Sürüm istemciden gelmeli: formun gizli alanında, If-Match başlığında ya da istek gövdesinde.

Zaman damgasını sürüm yapmak. updated_at sütunu cazip görünür ama aynı milisaniyede iki yazma olabilir, sütunun hassasiyeti uygulamanınkinden düşük olabilir, serileştirme sırasında kesir kırpılabilir. Tamsayı sayaç kullanın.

Çakışmayı körü körüne yeniden denemek. Sıfır satır etkilendiğinde aynı isteği tekrar göndermek, eski veriyi yeni sürüm numarasıyla yazmaktır; yani kayıp güncellemeyi elle yeniden üretmek. Doğru tepki iki türlüdür. Değişiklik makine tarafından hesaplanıyorsa güncel satırı yeniden okuyun, işlemi yeniden uygulayın ve sınırlı sayıda deneyin. Değişiklik kullanıcıdan geliyorsa çakışmayı kullanıcıya gösterin: "Bu kayıt siz düzenlerken değişti" ve farkı.

Sıcak satır. Çok sayıda isteğin aynı satıra yazdığı bir durumda iyimser yaklaşım her yazmada çakışma ve yeniden deneme üretir. Yük arttıkça başarı oranı düşer. Böyle bir satırda atomik güncellemeye ya da yazmaları tek bir işçide sıralayan bir kuyruğa geçin.

Yalıtım düzeyi bunu çözmez mi?

Kısmen, ve veritabanına göre değişir. PostgreSQL'de REPEATABLE READ düzeyinde iki işlem aynı satırı güncellemeye çalışırsa ikincisi could not serialize access due to concurrent update hatası alır (SQLSTATE 40001). Yani kayıp güncelleme engellenir, ama bedeli uygulamanın bu hatayı yakalayıp tüm işlemi baştan çalıştırmasıdır. Bunu yapmayan kod kullanıcıya rastgele 500 döner. SERIALIZABLE daha geniş anomalileri yakalar ve aynı yeniden deneme disiplinini ister.

MySQL InnoDB'de durum farklıdır: varsayılan REPEATABLE READ düzeyinde düz SELECT anlık görüntüden okur, UPDATE ise satırın güncel hâli üzerinde çalışır. Okuyup uygulamada hesaplayıp yazan kod bu düzeyde de güncellemeyi kaybeder. "Varsayılan yalıtım düzeyi beni korur" varsayımı veritabanı değiştiğinde sessizce çöker.

Yalıtım düzeyi ayrıca kullanıcının düşünme süresini kapsamaz; işlem bir HTTP isteğinden uzun yaşamaz. Bu yüzden onu iyimser sürümün yerine değil, kısa işlemler için ek bir güvenlik ağı olarak düşünün.

Gözden kaçan iki yer

Tüm alanları yazan ORM. Bazı ORM'ler kaydederken yalnızca değişen alanları değil satırın tamamını yazar. Bir istek başlığı, diğeri açıklamayı değiştirdiğinde farklı alanlara dokundukları hâlde biri diğerini ezer. Kullandığınız ORM'in yalnızca kirli alanları güncelleyip güncellemediğini kontrol edin ve bundan bağımsız olarak sürüm sütunu kullanın.

JSON ve dizi alanları. Bir JSON belgesindeki listeye eleman eklemek için belgeyi okuyup tamamını geri yazmak, iki eşzamanlı eklemeden birini kaybettirir. PostgreSQL'de jsonb_set ya da || ile yerinde güncelleme yapın, ya da sık değişen listeyi ayrı bir tabloya taşıyın.

Hangisini seçmeli

Taraf tutmak gerekirse: varsayılanınız atomik güncelleme olsun. Hesabı veritabanına yaptırabildiğiniz her yerde bunu yapın; hem kilit hem yeniden deneme mantığı ortadan kalkar. Kullanıcı düzenlemesi olan her kaynakta iyimser sürüm kullanın ve API'nizde ETag ile If-Match desteği verin; sonradan eklemek tüm istemcileri değiştirmeyi gerektirir. Kötümser kilidi, birden fazla satırı tutarlı biçimde değiştirmesi gereken, kısa süren ve içinde ağ çağrısı olmayan işlemlere saklayın.

İyimser yaklaşımı seçmemeniz gereken yer, yazmaların aynı birkaç satırda yoğunlaştığı sistemlerdir; orada çakışma oranı yaklaşımı işe yaramaz hâle getirir. Kötümser kilidi seçmemeniz gereken yer ise işlem süresini kontrol edemediğiniz her durumdur.

Son bir pratik öneri: bu hataları testte görmenin yolu eşzamanlılık testidir. Aynı kayda yirmi paralel istek gönderen ve sonunda beklenen değeri doğrulayan küçük bir test, kod incelemesinde gözden kaçan her oku, değiştir, yaz akışını yakalar.