Yoksa Oluştur Yarışı: Eşzamanlı İsteklerde İkiz Satır, Benzersiz Kısıt ve ON CONFLICT Tuzakları
Önce bak, yoksa ekle deseni tek istekte kusursuz çalışır, paralel isteklerde ikiz satır üretir. Uygulama kilidinin neden yetmediği, benzersiz kısıtın neden tek kalıcı çözüm olduğu; ON CONFLICT, kısmi indeks ve ikizler varken kısıt ekleme tuzakları.
Bir kaydı önce arayıp bulamayınca oluşturan kod, yazılımın en sıradan satırlarındandır: etiketi bul, yoksa aç; bu ayın klasörünü bul, yoksa oluştur; kullanıcıyı e-postasıyla bul, yoksa kaydet. Tek istekte kusursuz çalışır, testlerden geçer, aylarca ses çıkarmaz. Sonra bir gün aynı ada sahip iki satır görürsünüz. İkisi de aynı milisaniyede açılmıştır, ilişkili verinin bir kısmı birine, kalanı öbürüne bağlanmıştır. Sebep basittir: iki istek aynı anda "var mı" diye sormuş, ikisi de "yok" cevabını almış ve ikisi de eklemiştir. Arama ile ekleme arasındaki aralık ne kadar kısa olursa olsun, eşzamanlılık o aralığa er geç iki istek sığdırır.
Neden testte değil üretimde çıkar
Testler çoğunlukla sırayla koşar, geliştirici de formu bir kez gönderir. Yarışı tetikleyen şey gerçek kullanımın paralelliğidir: kuyruğundaki dosyaları aynı anda yükleyen bir mobil istemci, çift tıklanan bir düğme, zaman aşımına uğrayıp isteği yineleyen bir istemci, aynı mesajı iki kez teslim eden bir kuyruk, aynı saniyede gelen iki webhook. İkizler hep bir dönemin ilk kaydında görünür, çünkü yalnızca bir anahtarın ilk yazması yarışabilir: ayın ilk yüklemesi, yeni müşterinin ilk siparişi, yeni bir etiketin ilk kullanımı. Sonraki istekler var olan satırı bulur ve sorun görünmez hale gelir.
Uygulama tarafındaki önlemler neden yetmez
İlk refleks kodu kilitlemektir. Süreç içi bir kilit (mutex) yalnızca o süreci korur. Uygulama birden fazla işçi süreçte ya da birden fazla sunucuda çalışıyorsa her biri kendi kilidini tutar ve yarış aynen sürer.
İkinci refleks SELECT ... FOR UPDATE kullanmaktır, ama var olmayan bir satırı kilitleyemezsiniz. Sorgu sıfır satır döndürdüğünde PostgreSQL hiçbir şeyi kilitlemez ve iki işlem yine aynı anda ekler. MySQL'in InnoDB motoru varsayılan yalıtım düzeyinde bunun yerine boşluk kilidi (gap lock) alır. Boşluk kilitleri birbiriyle çakışmadığı için iki işlem de kilidi alır, ardından ikisi de eklemeye çalışınca kilitlenme (deadlock) hatası gelir. Yarışı çözmüş olmazsınız, yalnızca ürettiği hatayı değiştirmiş olursunuz.
Redis gibi bir dış sistemde dağıtık kilit tutmak çalışıyor gibi görünür, ama kilidin bir süresi vardır; takılan bir istek bu süreyi aşarsa kilit düşer ve ikinci istek içeri girer. Üstelik bu kilit yalnızca onu istemeyi bilen kodu durdurur. Elle açılmış bir SQL oturumu ya da tek seferlik bir içe aktarma betiği onu hiç görmez.
Kalıcı çözüm: benzersizliği veritabanına söylemek
Her yazan için geçerli olan tek koruma benzersiz kısıttır. Kısıt varsa ikinci ekleme veritabanı tarafından reddedilir; hangi koddan, hangi süreçten, hangi dilden gelirse gelsin.
ORM'lerin "bul ya da oluştur" yardımcıları bu konuda yanıltıcıdır. Django'nun belgeleri get_or_create için bunu açıkça yazar: ilgili alanlarda benzersizlik veritabanı düzeyinde zorlanmıyorsa eşzamanlı çağrılar aynı değerlerle birden fazla satır ekleyebilir. Rails'teki create_or_find_by ve Laravel'deki createOrFirst sırayı tersine çevirir: önce eklemeyi dener, benzersizlik ihlali gelirse var olan satırı okur. Yaklaşım doğrudur, ama ihlali üretecek bir kısıt yoksa ekleme her seferinde başarılı olur ve yardımcı hiçbir şeyi engellemez. Bu yardımcıların güvenliği yöntemin adında değil, şemadadır.
PostgreSQL'de doğru yazım ve RETURNING sürprizi
PostgreSQL çakışmayı istisnayla değil, sorgunun kendisinde karşılamaya izin verir:
INSERT INTO tags (name)
VALUES ('raporlar')
ON CONFLICT (name) DO NOTHING
RETURNING id;
İlk sürpriz RETURNING'in davranışıdır. Çakışma olduğunda satır eklenmez ve sorgu hiç satır döndürmez. Kimliğe ihtiyacınız varsa boş sonucun ardından ayrı bir SELECT ile var olan satırı okumanız gerekir.
Bu ikisini tek sorguda birleştirmek için eklemeyi ve okumayı bir CTE içinde UNION ALL ile bağlamak yaygındır. Ama CTE'nin bütün parçaları aynı anlık görüntüyü (snapshot) paylaşır. Çakışan satır sizin sorgunuz başladıktan sonra onaylanmışsa ekleme o işlemi bekleyip atlar, okuma kısmı ise yeni satırı göremez ve sorgu boş döner. Varsayılan READ COMMITTED düzeyinde her yeni sorgu yeni bir anlık görüntü aldığı için sorguyu bir kez daha çalıştırmak bunu çözer.
İkinci sürpriz, her durumda satır dönsün diye DO NOTHING yerine DO UPDATE SET name = EXCLUDED.name yazınca gelir. Satır döner, ama artık her çağrı gerçek bir güncelleme yapar: yeni bir satır sürümü yazılır, güncelleme tetikleyicileri çalışır, satır kilitlenir ve tablo boş yere şişer.
Üçüncüsü dizilerle (sequence) ilgilidir. Varsayılan değerini nextval ile alan bir kimlik sütunu, çakışmayla biten eklemede de bir değer tüketir ve çakışmanın sık olduğu tablolarda kimliklerde büyük boşluklar oluşur. Kimliklerin ardışık olduğunu varsayan kod varsa yanılır.
Yumuşak silme ve NULL: kısmi benzersiz indeks
Silinen kayıtları deleted_at sütunuyla işaretleyen tablolarda düz bir benzersiz indeks engel olur: silinmiş bir kayıt, aynı adla yenisinin açılmasını durdurur. (name, deleted_at) üzerine benzersiz indeks koymak da çözmez, çünkü benzersiz indeksler NULL değerleri birbirinden farklı sayar. Silinmemiş iki kayıt, ikisinde de deleted_at NULL olduğu için kısıttan rahatça geçer. Doğrusu kısmi benzersiz indekstir:
CREATE UNIQUE INDEX tags_name_active_unique
ON tags (name)
WHERE deleted_at IS NULL;
Bu indeks varken ON CONFLICT (name) yazarsanız PostgreSQL there is no unique or exclusion constraint matching the ON CONFLICT specification hatasını verir. Kısmi indeksin seçilebilmesi için koşulun çakışma hedefinde de tekrarlanması gerekir: ON CONFLICT (name) WHERE deleted_at IS NULL DO NOTHING.
NULL'ların gerçekten eşit sayılmasını istediğiniz durumlar için PostgreSQL 15 ile NULLS NOT DISTINCT seçeneği geldi. Büyük küçük harf farkı ikiz sayılmalıysa, e-posta adreslerinde olduğu gibi, indeksi lower(email) gibi bir ifade üzerine kurun; aksi halde [email protected] ile [email protected] iki ayrı kullanıcı olur.
Var olan ikizlerle kısıt eklemek
Kısıt ekleme kararı genellikle ikizler oluştuktan sonra verilir ve ikiz varken indeks oluşturma başarısız olur. Önce ikizleri bulun:
SELECT name, count(*) AS adet, min(id) AS kalacak
FROM tags
WHERE deleted_at IS NULL
GROUP BY name
HAVING count(*) > 1;
Birleştirmek, fazla satırı silmekten ibaret değildir. Silinecek satıra bağlı her şey (alt kayıtlar, dosya ilişkileri, paylaşım bilgileri) kalacak satıra taşınmalıdır; yoksa ya veri kaybedersiniz ya da yabancı anahtar silmeyi reddeder. Tablo küçükse birleştirmeyi ve indeksi aynı göçte (migration) ve tek işlemde yapın, böylece arada yeni bir ikiz oluşamaz.
Büyük tablolarda CREATE UNIQUE INDEX CONCURRENTLY tercih edilir, çünkü düz indeks oluşturma işlem boyunca tabloya yazmayı engeller. Ama eşzamanlı oluşturma bir işlemin içinde çalışamaz ve çalıştığı sırada yeni bir ikiz eklenirse başarısız olup arkasında geçersiz (INVALID) bir indeks bırakır. Bu indeks sorgularda kullanılmaz ama her yazmaya yük bindirmeye devam eder. SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid; ile kontrol edin, bir şey çıkarsa indeksi düşürüp yeniden deneyin.
İstisnayı işlem içinde yakalamak
Benzersizlik ihlalini uygulamada yakalayıp var olan satırı okumak da geçerli bir yoldur, ama PostgreSQL'de bir işlemin içinde hata veren sorgu bütün işlemi bozar. Ardından gelen her sorgu current transaction is aborted, commands ignored until end of transaction block hatasıyla döner. Ekleme denemesinin bir kayıt noktası (savepoint) içinde yapılması gerekir. Rails ve Laravel'in yardımcıları bunu kendileri yapar; elle yazılan kodda unutmak kolaydır ve hata yalnızca yarış anında, yani yeniden üretmesi en zor anda ortaya çıkar.
Eşitlik değil çakışma: dışlama kısıtı
Bazen benzersiz olması gereken şey bir değer değil, bir aralıktır: aynı odaya çakışan iki rezervasyon gibi. Benzersiz indeks bunu ifade edemez, ama PostgreSQL'in dışlama kısıtı (exclusion constraint) aynı güvenceyi verir:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE reservations
ADD CONSTRAINT reservations_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);
Burada during bir tstzrange sütunudur. Aynı oda için çakışan aralıkla gelen ikinci kayıt reddedilir ve "önce kontrol et, sonra ekle" kodu yazmaya hiç gerek kalmaz.
Ne zaman bu yol uygun değil
Benzersizlik kesin bir kural değilse kısıt yanlış araçtır. Birbirine benzeyen müşteri adlarını ya da farklı yazılmış adresleri birleştirmek bulanık bir iştir; şemaya değil, karar için insana soran bir eşleştirme işine aittir.
Parçalanmış (sharded) veritabanlarında da dikkat gerekir. Dağıtık veritabanlarının çoğunda benzersiz kısıt parça anahtarını içermek zorundadır, yani "e-posta bütün sistemde benzersiz" kuralı tek bir kısıtla kurulamaz. Orada benzersizliği sahiplenen ayrı bir tablo ya da servis tasarlamanız gerekir.
Geri kalan her "yoksa oluştur" satırı için kural kısadır: kodu yazmadan önce şemada benzersiz indeksin durduğunu doğrulayın. İndeks yoksa o satır er ya da geç bir ikiz üretecektir.