<

MySQL'de Deadlock Avı: Bir Vaka Analizi

Mart 2025'te — henüz ajans tarafında, filo projelerine baktığım dönemde — bir lojistik firmasının sipariş sisteminde tuhaf bir desen başladı: her gün saat 14:00 civarında, beş-on dakikalık bir dilimde onlarca "Deadlock found when trying to get lock; try restarting transaction" hatası. Günün geri kalanı tertemiz. Ekip haftalardır "yoğunluktan oluyor" diye geçiştirmiş; ama trafiğe baktığımızda 14:00, günün en yoğun saati bile değildi. Bu vaka, deadlock avcılığı üzerine bildiğim her şeyi bir kez daha sınadı ve bu yazıda adım adım nasıl çözdüğümüzü anlatacağım — çünkü yöntem, bu spesifik vakadan daha değerli.

İlk şüpheli her zaman yanlış şüphelidir

Deadlock hatası gören ekiplerin ilk refleksi genelde hatayı fırlatan koda bakmak oluyor. Bu doğal ama yanıltıcı; çünkü InnoDB deadlock tespit ettiğinde kurbanı geri alma maliyeti düşük olan transaction'dan seçer. Yani hatayı gören kod, çoğu zaman masum taraftır. Suçlu, hatayı hiç görmeden yoluna devam eden öteki transaction'dır.

Bu yüzden ilk durak kod değil, SHOW ENGINE INNODB STATUS çıktısındaki "LATEST DETECTED DEADLOCK" bölümü. Burada iki transaction'ın da son çalıştırdığı sorguyu, hangi kilitleri tuttuklarını ve neyi beklediklerini görürsünüz. Bizim vakada tablo şöyleydi: birinci transaction, sipariş satırlarını güncelleyen bilindik API kodu. İkincisi ise kimsenin aklına gelmeyen bir şey — muhasebe entegrasyonu için saat başı çalışan, o gün 14:00'te aylık mutabakat moduna geçen bir toplu iş.

Buradaki ilk ders şu: deadlock'lar neredeyse her zaman iki farklı dünyanın kesişiminde doğar. Aynı ekibin yazdığı, aynı kod tabanında yaşayan sorgular genelde benzer sırayla kilit alır ve çakışmaz. Tehlike, API kodu ile batch job, uygulama ile manuel rapor sorgusu, eski modül ile yeni mikroservis kesiştiğinde başlar.

Suçlu sıralamaydı: biri yukarıdan, biri aşağıdan

Status çıktısını söktüğümüzde klasik bir desen çıktı. API kodu siparişleri güncellerken satırları sipariş kalemi ID'sine göre artan sırayla kilitliyordu. Mutabakat job'ı ise aynı satırlara müşteri numarasına göre gruplu bir UPDATE ile dokunuyordu — ki bu, fiziksel olarak bambaşka bir kilit alma sırası demek. Biri listeyi baştan, öteki ortasından kilitlemeye başlayınca, iki transaction'ın birbirinin tuttuğunu beklemesi an meselesi.

Çözümün ilk yarısı ders kitabı klasiği: herkes kilitleri aynı sırayla alsın. Mutabakat sorgusunu, güncellenecek satırları önce ID sırasıyla SELECT ... FOR UPDATE ile kilitleyip sonra güncelleyecek şekilde böldük. İkinci yarısı ise daha az konuşulan bir önlem: mutabakat job'ının dev tek transaction'ını 500'er satırlık parçalara ayırdık. Transaction ne kadar uzun yaşarsa, kilit tuttuğu pencere o kadar genişler ve çakışma olasılığı doğrusal değil, katlanarak artar. Parçalama tek başına bile deadlock sayısını onda birine düşürmüştü.

Gap lock: görünmez satırların kilidi

Hikâye burada bitse keskin bir vaka analizi olurdu ama gerçek hayat böyle değil. Düzeltmeden iki hafta sonra aynı sistemde, bu kez günde bir-iki adet, bambaşka bir deadlock belirdi. Status çıktısında bu kez tanıdık iki UPDATE değil, bir INSERT ve bir "lock_mode X locks gap before rec" ifadesi vardı. Gap lock — InnoDB'nin REPEATABLE READ altında, indeksteki iki kayıt arasındaki boşluğu kilitlemesi. Var olmayan satırların kilidi yüzünden deadlock yaşamak, ilk karşılaşan geliştirici için gerçeküstü bir deneyim; kilitli olan şeyi veritabanında arayıp bulamıyorsunuz çünkü ortada satır yok, boşluk var.

Bizim senaryoda iki eşzamanlı istek, aynı sipariş için önce kayıt var mı diye bakıp yoksa INSERT etmeye çalışıyordu. İkisi de aynı boşluğa gap lock koyuyor, sonra ikisi de INSERT için ötekinin kilidinin kalkmasını bekliyordu. Çözüm burada sorgu değil şema tarafındaydı: doğru tekil indeks ve INSERT ... ON DUPLICATE KEY UPDATE ile kontrol-sonra-ekle deseninin tamamen terk edilmesi. "Önce SELECT ile bak, yoksa ekle" deseni, eşzamanlılık altında çalışmayan ama testte hep çalışan sinsi bir tuzak; kod incelemelerinde bunu artık otomatik olarak işaretliyoruz.

Deadlock'u sıfırlamaya değil, zararsızlaştırmaya çalışın

Burada sevmediğim bir hedefin altını çizmem lazım: "deadlock'ları tamamen bitirelim." Yoğun eşzamanlı yazma yapan her sistemde deadlock olasılığı yaşamaya devam eder; InnoDB'nin tespit mekanizması zaten tam da bu yüzden var. Gerçekçi hedef, deadlock'u nadir ve zararsız kılmak. Zararsız kılmanın yolu da uygulama katmanında dürüst bir retry mantığı: deadlock hatası yakalandığında transaction'ı kısa ve rastgele bir bekleme sonrası en fazla iki-üç kez yeniden denemek. Hata mesajının kendisi zaten talimatı veriyor — "try restarting transaction" — ama şaşırtıcı sayıda kod tabanı bu hatayı kullanıcıya 500 olarak yansıtmakla yetiniyor.

Retry eklerken tek dikkat: transaction bloğunuz idempotent olmalı, yani yarısı çalışıp geri alınmış bir denemenin tekrarı çift kayıt üretmemeli. E-posta gönderimi gibi geri alınamaz yan etkileri transaction'ın içine gömen kod, retry ile birleşince müşterinize aynı faturayı üç kez göndertir. (Yaşandı. Bizde değil ama yıllar önce elime geçen bir kod tabanında, ve temizliği bana kalmıştı.)

İzolasyon seviyesi tartışması da bu noktada gündeme geldi ve dürüst cevabı yazayım: mutabakat okumaları gibi uzun, tutarlılığı satır bazında kritik olmayan sorgular için READ COMMITTED'a inmek, gap lock kaynaklı sürtüşmeyi ciddi azaltıyor. Ama bunu battaniye çözüm olarak tüm sisteme uygulamayı önermiyorum; izolasyonu düşürmek, kilit sorununu bazen çözmez, sadece veri tutarsızlığı sorununa dönüştürür ve tutarsızlık, deadlock'un aksine size hata mesajı gösterme nezaketinde bulunmaz. Biz seviye değişikliğini sorgu bazında, gerekçesi envantere yazılmış istisnalar olarak uyguluyoruz.

Bir de izleme tarafı var. Deadlock'ların sadece sonuncusunu değil hepsini görmek için innodb_print_all_deadlocks'u açıp log'u merkezî izlemeye bağladık; artık haftalık raporda deadlock sayısı bir metrik olarak duruyor. O lojistik firmasında sayı, müdahale öncesi haftada 200'ün üzerindeydi; ayrıldığımda üçün altına inmişti ve hepsi retry ile kullanıcıya hiç dokunmadan çözülüyordu. Aynı metriği bugün platformumuzun sipariş veritabanı için de haftalık raporda tutuyoruz.

Vakayı kapattıktan sonra başlattığım tatbikat geleneğini bugünkü ekibime de taşıdım: yeni gelen her geliştiriciye, staging ortamında bilerek deadlock ürettirip status çıktısını okutturuyoruz. Kulağa tuhaf bir mesai gibi gelebilir ama gece 03:00'te üretimde ilk kez deadlock çıktısı görmekle, daha önce beş kez sökmüş olduğunuz bir formatı görmek arasındaki fark, o gecenin kaç saat süreceğini belirliyor. Çıktıdaki kilit tiplerini, indeks adlarını, bekleyen-tutan ilişkisini rahat okuyan bir geliştirici için deadlock, gizemli bir felaket değil sıradan bir bakım kalemi.

Saat 14:00 gizemi ise tamamen kapandı — mutabakat job'ı hâlâ her gün aynı saatte çalışıyor, ama artık kimse fark etmiyor. İyi hata ayıklamanın nihai göstergesi budur bence: sorunun çözüldüğünü kanıtlayan grafik dışında hiçbir iz kalmaması. Sizin sisteminizde de açıklayamadığınız kilitlenme desenleri varsa yöntem aynı: status çıktısını okumak yarım saat, deseni bulmak çoğu zaman bir gün sürüyor ve bu bir gün, aylarca süren "ara sıra patlıyor" belirsizliğinden hep daha ucuz. Bu tür vakaları konuşmayı severim; iletişim sayfası açık.

📅 Yayınlanma:  ·  Yakup Zengin