İçeriğe geç
wedevit

16 Ekim 2026 · 9 dk okuma · yazılım

İlhan Buğra Aslan

İki kişi aynı kaydı aynı anda değiştirdi: son kaydeden kazanıyor, diğerinin işi kayboluyor


Aynı siparişi iki kişi açıyor. Biri teslimat adresini düzeltiyor, diğeri sipariş notunu yazıyor. İkisi de kaydediyor, ekranda hiçbir uyarı çıkmıyor, ama adres değişikliği yok olmuş oluyor. Bunun adı kayıp güncelleme (lost update) ve neredeyse her uygulamada var, çünkü varsayılan davranış "en son kaydeden kazanır". Çözüm daha uzun bir veritabanı işlemi açmak değil: kullanıcının formu açmasıyla kaydetmesi arasında dakikalar geçiyor ve hiçbir işlem o kadar açık tutulamaz. Yapılacak iş, kaydın okunduğu andaki sürümünü istemciye taşımak ve yazma anında o sürümün hâlâ geçerli olduğunu veritabanına doğrulatmak. Bu yazı o mekanizmanın SQL, ORM, API ve arayüz katmanlarında nasıl kurulduğunu anlatıyor.

Asıl kısıt kilit değil, kullanıcının düşünme süresi

Kilitlemeyi savunan her tartışma aynı yerde tıkanır: kaydı okuduğunuz an ile kullanıcının Kaydet'e bastığı an arasındaki süre. Bir dakika da olabilir, yarım saat de. Bu süre boyunca satırı kilitli tutarsanız, formu açıp toplantıya giden kişi tüm ekibi bekletir.

EF Core belgeleri bunu eşzamanlılık sayfasında doğrudan söylüyor: yüksek izolasyon seviyesine dayanmak, işlemin bütün adımları hemen çalıştığında uygun bir yöntem, ama işlem dış girdiye bağlı olarak uzayabiliyorsa değil. Kaydı sorgulayıp kullanıcının değişiklik yapmasını beklemek tam olarak o duruma örnek. Dolayısıyla çözüm iki parçalı: veritabanı işlemleri kısa kalacak, bu işlemler arasındaki boşluğu ise taşınan bir sürüm bilgisi kapatacak.

Veritabanının varsayılan ayarı sizi korumuyor

PostgreSQL'in varsayılan izolasyon seviyesi Read Committed. Bu seviyede aynı satıra giden ikinci güncelleme, birincisinin bitmesini bekler ve sonra kendi işini yapar. Belgelerdeki ifade şu: birinci güncelleyici işlemi tamamlarsa, ikincisi satırın güncellenmiş sürümü üzerinde işlemini uygulamaya çalışır ve komutun WHERE koşulu, güncellenmiş satırın koşula hâlâ uyup uymadığını görmek için yeniden değerlendirilir.

Aynı sayfadaki örnek bu davranışın ne kadar şaşırtıcı olabileceğini gösteriyor. Bir oturumda UPDATE website SET hits = hits + 1, diğerinde DELETE FROM website WHERE hits = 10 çalışıyor. Güncellemeden önce de sonra da hits = 10 olan bir satır bulunmasına rağmen DELETE hiçbir şey silmiyor, çünkü kilidi aldığı anda değer artık 11.

Sizin durumunuzda ezilen şey bir sayı değil, niyet. UPDATE siparis SET adres = 'yeni adres' WHERE id = 5 iki kez çalıştığında ikisi de başarılı döner ve ikincisi kazanır. Veritabanı hata vermez, çünkü ortada hata yoktur. Siz tam olarak bunu istediniz.

İzolasyonu yükseltmek bir seçenek ama bedelsiz değil. Repeatable Read seviyesinde PostgreSQL çakışmayı could not serialize access due to concurrent update hatasıyla (SQLSTATE 40001) bildiriyor ve belgeler ne yapılacağını da yazıyor: uygulama bu hatayı aldığında mevcut işlemi iptal edip işlemin tamamını baştan denemeli. Yani izolasyonu yükseltmek, yeniden deneme mantığı yazma zorunluluğunu da getiriyor. MySQL/InnoDB tarafında varsayılan zaten Repeatable Read, fakat orada kilitli okumalar en güncel işlenmiş durumu görüyor, kilitsiz okumalar ise işlem başındaki anlık görüntüyü. MySQL belgeleri tam bu yüzden ikisini aynı işlem içinde karıştırmamayı öneriyor.

İyimser kilit: bir sayaç kolonu ve etkilenen satır sayısı

Pratikte en çok işe yarayan yöntem iyimser kilitleme. Tabloya bir tamsayı version kolonu ekliyorsunuz, kaydı okurken sürümü de okuyup forma gizli alan olarak koyuyorsunuz ve yazarken şunu çalıştırıyorsunuz:

UPDATE siparis
   SET adres = $1, version = version + 1
 WHERE id = $2 AND version = $3;

Kritik kısım bu SQL değil, bir sonraki satır: etkilenen satır sayısını kontrol etmek. 1 dönerse yazma geçmiştir. 0 dönerse araya biri girmiştir ve çakışmayı kullanıcıya bildirmeniz gerekir. Bu kontrolü atlayan kod, iyimser kilidi kurmuş ama hiç kullanmamış olur. Sahada en sık gördüğümüz hata bu.

Çoğu ORM mekanizmayı hazır veriyor. Rails'te tabloya lock_version kolonunu eklediğinizde her güncelleme kolonu artırıyor ve ikinci kaydeden tarafa ActiveRecord::StaleObjectError fırlatılıyor. EF Core'da aynı iş eşzamanlılık belirteci (concurrency token) ile yapılıyor: UPDATE cümlesine belirtecin koşulu ekleniyor, sıfır satır etkilendiğinde DbUpdateConcurrencyException atılıyor. Belgelerin çakışma çözümü için üç ayrı değer kümesinden söz etmesi de önemli: uygulamanın yazmak istediği güncel değerler, kaydı okurken gördüğü özgün değerler ve veritabanındaki mevcut değerler. Çakışmayı kullanıcıya anlamlı biçimde sunacaksanız üçüne de ihtiyacınız var.

Sürüm kolonu olarak updated_at kullanmayın

Zaten tabloda durduğu için updated_at alanını sürüm bilgisi olarak kullanmak cazip geliyor. İki sorun çıkarıyor. Birincisi hassasiyet: aynı saniye içinde iki güncelleme olduğunda, kolonun hassasiyetine bağlı olarak iki damga eşit çıkabilir ve çakışma hiç görünmez. İkincisi damgayı kimin yazdığı: değeri uygulama sunucusu üretiyorsa, sunucu saatleri arasındaki birkaç milisaniyelik fark karşılaştırmayı bozmaya yeter.

Tamsayı sayaç bu iki problemi de yaşamaz, maliyeti bir kolondur. SQL Server'daki rowversion gibi veritabanının kendi ürettiği türleri kullanıyorsanız durum farklı; orada artışı veritabanı garanti ediyor ve EF Core'un [Timestamp] özniteliği tam olarak o kolona bağlanıyor. Hangi yolu seçerseniz seçin, kararı veri modelini kurarken verin; sürüm kolonunu dolu bir tabloya sonradan eklemek, mevcut satırların başlangıç değerini de düşünmeyi gerektirir.

Kötümser kilit nerede doğru cevap

İyimser kilit her yere uymaz. Çakışma ihtimali yüksekse ve kaybeden tarafın işi baştan yapması pahalıysa, satırı kilitlemek daha mantıklıdır. PostgreSQL'de SELECT ... FOR UPDATE bunu yapıyor ve iki seçeneği kararı kolaylaştırıyor: NOWAIT, satır hemen kilitlenemiyorsa beklemek yerine hata veriyor; SKIP LOCKED ise hemen kilitlenemeyen satırları atlıyor. İkincisi özellikle kuyruk tablosundan iş çeken işçiler için uygun.

Kötümser kilidin tek altın kuralı var: kilit, kullanıcının düşünme süresini kapsamasın. Aç, işi yap, kapat. Aksi halde bekleyen işlemler birikir ve karşılıklı kilitlenme riski artar. PostgreSQL'de deadlock_timeout varsayılanı 1 saniye ve bu değer, sunucunun kilitlenme kontrolü yapmadan önce kilit üzerinde beklediği süreyi belirliyor. Yani gerçek bir karşılıklı kilitlenme oluştuğunda bunu en erken bir saniye sonra öğreniyorsunuz. Kilit bekleyen istekler bu arada veritabanı tarafında yavaşlık olarak görünüyor, çünkü sorgunun kendisi yavaş değil, sırada.

Oku, değiştir, yaz yerine tek cümlede güncelleyin

Bazı durumlarda sürüm kolonuna hiç gerek yok, çünkü hesabı veritabanına tek cümlede yaptırabilirsiniz. Stok düşmenin doğru hali şu:

UPDATE urun
   SET stok = stok - 1
 WHERE id = $1 AND stok >= 1;

Karar yine etkilenen satır sayısında: 0 dönerse stok yetmemiştir, sipariş reddedilir. Stoğu okuyup bellekte bir eksiltip yeni değeri yazan kod bu kontrolü yapamaz, çünkü okuma ile yazma arasında başka bir istek aynı stoğu düşürmüş olabilir. Aynı mantık bakiye, puan, kredi ve her tür kullanım sayacı için geçerli. Kampanya saatinde çıkan aşırı satış şikâyetlerinin önemli bölümü bu üç satırlık farktan geliyor.

API katmanında çakışma: ETag, If-Match, 412 ve 428

Aynı problemin HTTP karşılığı yıllar önce standartlaşmış durumda. Sunucu kaynağı verirken bir ETag döndürüyor, istemci güncellerken bu değeri If-Match başlığında geri gönderiyor. RFC 9110'un 13.1.1 bölümü bu alanı tam olarak kayıp güncelleme problemi için tanımlıyor ve koşul tutmadığında sunucu 412 Precondition Failed döndürüyor.

İstemcinin If-Match göndermeyi unutması ayrı bir karar. RFC 6585, koşullu istek şart koşan sunucular için 428 Precondition Required kodunu tanımlıyor ve tipik kullanımını şöyle tarif ediyor: istemci kaynağın durumunu alır, değiştirir ve geri gönderir, bu sırada üçüncü bir taraf sunucudaki durumu değiştirmiştir. Aynı RFC'nin güvenlik değerlendirmesi bölümünde de şu uyarı var: 428 isteğe bağlıdır, istemciler kayıp güncelleme çatışmalarını önlemek için bu kodun kullanılacağına güvenemez. Kısacası 428, sunucu tarafında bir zorlama aracı; istemci tarafında bir güvence değil.

Bu başlıkları zorunlu hale getirmek mevcut entegrasyonlar için kırıcı bir değişiklik olduğundan, bir sürüm boyunca uyarı modunda çalıştırıp kaç istemcinin başlığı göndermediğini ölçmek doğru sıralama.

Çakışma çıktı, kullanıcıya ne göstereceksiniz

Teknik taraf bitince asıl soru kalıyor: 0 satır etkilendiğinde ekranda ne olacak? Üç yaklaşım var ve seçim, kaybedilecek emeğin değerine göre değişiyor.

En basiti kullanıcıyı uyarıp sayfayı yenilemek. Doğru ama acımasız; on dakika yazılmış bir not alanı varsa o metin gider. İkinci yaklaşım, kullanıcının girdiği değeri saklayıp iki sürümü yan yana göstermek ve hangisinin kalacağını sormak. Üçüncüsü alan bazında birleştirme: iki kişi farklı alanlara dokunduysa ortada çakışma yoktur, ikisi de yazılır.

Alan bazında birleştirmeyi mümkün kılan şey, formun tamamını değil yalnızca değişen alanları göndermek. Tüm satırı kapsayan tek bir belirteç kullanırsanız, kargo notunu değiştiren kişi fatura adresini değiştiren kişiyi boş yere bloke eder. EF Core belgelerinin uygulama yönetimli belirteç için verdiği gerekçe de bu: belirtecin hangi kolon değiştiğinde yenileneceğini siz seçerseniz gereksiz çakışmaları önlersiniz. Her çakışma ekranı aynı zamanda bir hata mesajı tasarımı işidir. "Kayıt başka bir kullanıcı tarafından değiştirildi" cümlesi, neyin değiştiğini söylemediği sürece kullanıcıyı tahmine zorlar.

Veri tek veritabanında değilse

Kayıt iki sistemde birden yaşıyorsa ortak bir işlem sınırı da yoktur. ERP'den gelen stok güncellemesi ile e-ticaret tarafındaki sipariş aynı kaydı farklı zamanlarda değiştirir, üstelik mesajlar üretildikleri sırayla gelmeyebilir. Burada sürüm bilgisini kaydın yanında değil mesajın içinde taşımanız gerekiyor: olayın üretildiği andaki sürüm numarası ya da kaynak sistemdeki değişiklik sırası. Alıcı taraf, elindekinden eski bir sürüm geldiğinde onu uygulamaz, atar. Bu kuralı koymayan entegrasyonlarda gecikmiş tek bir mesaj saatler önceki fiyatı geri getirir.

Çakışmaları sonradan araştırabilmek için bir denetim izine de ihtiyacınız olacak. "Bu alanı kim, ne zaman, hangi değerden hangi değere çekti" sorusunun cevabı yoksa, kayıp güncelleme olup olmadığını kanıtlayamazsınız ve tartışma kullanıcının hafızasına kalır.

Bunu nasıl test edersiniz

Eşzamanlılık hatalarının ortak özelliği, tek kullanıcıyla yapılan testlerde hiç görünmemeleri. İki ayrı şeyi test edin.

Birincisi çakışma yolunun kendisi: aynı kaydı iki ayrı bağlantıdan okuyun, birini kaydedin, sonra diğerini kaydedin ve ikincinin beklenen hatayı verdiğini doğrulayın. Bu test hızlı çalışır ve test paketinizin entegrasyon katmanına rahat sığar. İkincisi yarış durumunun kendisi: aynı anda 100 isteği aynı kayda gönderip sonunda sayacın doğru çıkıp çıkmadığına bakın. 100 eşzamanlı stok düşme isteğinden sonra stok tam olarak 100 azalmış olmalı. 97 azaldıysa kodunuzda oku, değiştir, yaz deseni var demektir ve yerini de böylece bulmuş olursunuz.

Bu haftaya sığan ilk adım

Kod tabanınızda şu deseni arayın: bir kaydı okuyan, bellekte değiştiren ve geri yazan her yer. Genelde bir find ve save ikilisi ya da bir ORM nesnesinin yüklenip güncellenmesi olarak görünür. Bulduklarınızı iki soruya göre ayırın: bu kayda birden fazla kişi veya birden fazla servis dokunuyor mu, ve yanlış değer yazılırsa parasal bir karşılığı var mı.

İkisine de evet diyen listeye bir version kolonu ve etkilenen satır kontrolü ekleyin. Sayaç, bakiye ve stok güncellemelerini tek cümlelik SQL'e çevirin. Dışarıya açık API uçlarınızdan hangilerinin ETag döndürdüğünü not edin.

Bu çalışma genelde bir iki gün sürüyor ve en çok fayda veren kısmı liste çıkarma aşaması oluyor; ekipler hangi kayıtların gerçekten eşzamanlı düzenlendiğini çoğu zaman ilk kez o tabloda görüyor. Uygulamanızda "kaydettim ama değişiklik gitmemiş" şikâyeti varsa, o ekranın okuma ve yazma yollarıyla birlikte gelin; konuşma soyut bir mimari tartışmasından çok daha hızlı sonuç verir.


Bu konuda yardıma mı ihtiyacınız var?

iletişime geç →← tüm yazılar