E-ticaret: sepet, stok rezervasyonu ve sipariş akışı
Problem
"Büyük bir e-ticaret pazaryeri için sepet, stok ve sipariş oluşturma akışını tasarla. Binlerce satıcı, birden fazla depo, misafir ve üye kullanıcılar var. Kullanıcı sepetine ürün ekler, ödeme adımına geçer, sipariş verir. Stokta olmayan bir ürün satılmamalı (oversell), ama stok da gereksiz yere kilitlenip satış kaçırılmamalı. Kasım kampanyalarında trafik on katına çıkıyor."
Kitap Bölüm 20'de tek bir üründen 50.000 adetlik flash sale çözüldü: bekleme odası, Redis Lua ile atomik stok ve kovalar. Bu case gündelik e-ticaret akışıdır: milyonlarca farklı ürün, her biri birkaç adet ya da birkaç bin adet stokla, çok kalemli sepetler, satıcıların kendi sistemlerinden gönderdiği stok güncellemeleri ve sipariş-ödeme-stok arasındaki dağıtık akış. Mülakatçı burada en çok şu kararlara bakar: stok ne zaman ayrılır, ayırma nasıl atomik yapılır, rezervasyon nasıl geri verilir, sepet neden rezervasyon değildir ve sipariş akışı yarıda kalırsa ne olur.
Netleştirme soruları ve varsayımlar
| Soru | Neden önemli | Varsayım |
|---|---|---|
| Stok sepete eklerken mi, sipariş verirken mi, ödemede mi ayrılmalı? | Oversell ve satış kaybı dengesi | Sipariş anında, 15 dakikalık süreli rezervasyonla (aşağıda gerekçesi) |
| Stok kimin sisteminde doğru? | Kaynak ve senkronizasyon | Satıcılar kendi ERP'lerinden mutlak stok gönderir; rezervasyonu biz tutarız |
| Bir ürün birden fazla depoda olabilir mi? | Rezervasyon anahtarı | Evet: (sku, depo); sipariş kalemleri farklı depolardan çıkabilir |
| Misafir sepeti var mı, girişte birleşir mi? | Sepet kimliği | Evet: cihaz bazlı misafir sepeti, girişte üye sepetiyle birleşir |
| Sepetteki fiyat değişirse? | Checkout doğrulaması | Fiyat checkout'ta yeniden hesaplanır, değişiklik kullanıcıya gösterilir |
| Kısmi stok yetersizliğinde ne olsun? | Sipariş akışı | Sipariş oluşmaz; hangi kalemlerin yetmediği döner, kullanıcı karar verir |
| Ödeme nasıl? | Saga adımları | Provizyon + tahsilat (bkz. ödeme sistemi case'i) |
| Ürün sayfasında stok bilgisi ne kadar güncel olmalı? | Okuma yolunun tutarlılığı | Birkaç saniye gecikme kabul; karar anında kesin kontrol |
Kapsam dışı: flash sale (Kitap Bölüm 20), arama ve öneri, kargo takibi, iade süreci.
Kapasite tahmini
Kullanıcı : 20M DAU
Sepet işlemleri : kişi başı 5 (ekle, çıkar, görüntüle) = 100M/gün ≈ 1.160 /s
Kasım tepesi (x10) ≈ 11.600 /s, çoğu okuma
Sipariş : normal gün 2M ≈ 23 /s
Kasım günü 10M, en yoğun saatte %15 = 1,5M/saat ≈ 420 /s
en yoğun dakikalarda ~1.000 /s
Rezervasyon satırı : sipariş başına ortalama 2,5 kalem -> Kasım günü 25M satır
Aktif sepet : 30M sepet x ~2 KB ≈ 60 GB
Stok satırı : 50M SKU x ortalama 1,5 depo = 75M satır x ~100 B ≈ 7,5 GB (+ index)
Teşhis:
- Sepet okuma ağırlıklıdır ve kullanıcı bazında izoledir: iki kullanıcı aynı sepete yazmaz. Bu, sepeti kullanıcı id'siyle bölünen basit bir anahtar-değer erişimine çevirir.
- Stok tablosu küçüktür (index'lerle on GB mertebesi), tek bir PostgreSQL kümesine sığar. Sorun boyut değil, sıcak satırlardır: kampanyadaki popüler bir ürünün
(sku, depo)satırına saniyede yüzlerce rezervasyon gelir ve satır kilidinde sıraya girer. Kitap Bölüm 22'deki ölçümde tek bir sıcak satıra yazan tam bir transaction, satırlar dağınıkken ~2.270 tps olan hızın ~400 tps'ye düştüğü görüldü. - Sipariş akışı birden fazla servise dokunur (stok, ödeme, sipariş). Asıl zorluk tek bir yazmanın hızı değil, yarıda kalan akışın tutarlı şekilde tamamlanması ya da geri alınmasıdır.
API
GET /api/v1/cart
PUT /api/v1/cart/items/{skuId} { "sellerId": 42, "quantity": 2 } # idempotent: miktarı ayarla
DELETE /api/v1/cart/items/{skuId}
POST /api/v1/cart/merge { "guestCartToken": "..." } # girişten sonra
POST /api/v1/checkout/preview
-> 200 { "items": [...], "total": "1249.90", "priceVersion": "pv_9f..",
"warnings": [ { "skuId": 77, "code": "PRICE_CHANGED", "old": "199.90", "new": "219.90" },
{ "skuId": 81, "code": "LOW_STOCK", "available": 1 } ] }
POST /api/v1/orders
Idempotency-Key: 3c1f...
{ "priceVersion": "pv_9f..", "addressId": 5, "paymentMethodToken": "tok_..." }
-> 201 { "orderId": 98213, "status": "PENDING_PAYMENT", "payment": { "status": "REQUIRES_ACTION", "redirectUrl": "..." } }
-> 409 { "code": "OUT_OF_STOCK", "items": [ { "skuId": 81, "requested": 2, "available": 1 } ] }
-> 409 { "code": "PRICE_CHANGED", "priceVersion": "pv_a1.." }
GET /api/v1/orders/{orderId}
# Satıcı entegrasyonu
PUT /seller-api/v1/stocks [ { "sku": "S-77", "warehouseId": 3, "quantity": 50, "sourceUpdatedAt": "..." } ]
- Sepete ekleme
POSTyerinePUTmiktar ayarlama olarak modellenir: mobil istemci tekrar gönderirse miktar iki katına çıkmaz. priceVersion, kullanıcının onayladığı fiyatları temsil eder; sipariş anında fiyat değişmişse sipariş oluşmaz, kullanıcı yeni fiyatı görür. Kullanıcıya görmediği bir fiyatı ödetmemek hem ürün hem hukuk kuralıdır.- Sipariş oluşturma idempotency key ister: çift tıklama ya da timeout sonrası tekrar ikinci sipariş yaratmamalı (Kitap Bölüm 18).
Veri modeli
Sepet (kullanıcı id'siyle bölünmüş anahtar-değer ya da doküman deposu; örneğin Couchbase, ya da PostgreSQL'de JSONB):
{ "cartId": "u:7", "version": 18, "updatedAt": "...",
"items": [ { "skuId": 77, "sellerId": 42, "quantity": 2, "addedAt": "...", "priceAtAdd": "199.90" } ] }
priceAtAdd sadece "fiyatı düştü/yükseldi" göstermek içindir; sipariş fiyatı her zaman fiyat servisinden yeniden hesaplanır. version alanı aynı kullanıcının iki cihazdan eşzamanlı güncellemesinde optimistic locking içindir.
Stok ve rezervasyon (PostgreSQL):
CREATE TABLE inventory (
sku_id BIGINT NOT NULL,
warehouse_id INT NOT NULL,
on_hand INT NOT NULL CHECK (on_hand >= 0), -- fiziksel stok (satıcının bildirdiği)
reserved INT NOT NULL DEFAULT 0 CHECK (reserved >= 0),-- ödeme bekleyen siparişlere ayrılmış
version BIGINT NOT NULL DEFAULT 0,
source_updated_at TIMESTAMPTZ, -- satıcının son güncellemesinin kaynak zamanı
PRIMARY KEY (sku_id, warehouse_id),
CONSTRAINT inventory_reserved_le_on_hand CHECK (reserved <= on_hand)
);
CREATE TABLE stock_reservations (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
warehouse_id INT NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, CONFIRMED, RELEASED
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT stock_reservations_status_valid CHECK (status IN ('ACTIVE','CONFIRMED','RELEASED')),
CONSTRAINT stock_reservations_order_sku_uq UNIQUE (order_id, sku_id, warehouse_id)
);
CREATE INDEX stock_reservations_expiry_idx ON stock_reservations (expires_at) WHERE status = 'ACTIVE';
Satılabilir stok on_hand - reserved'dir. on_hand ile reserved'in ayrı tutulması, satıcının stok güncellemesi ile bizim rezervasyonlarımızın birbirini ezmemesini sağlar (aşağıda).
Sipariş: orders(id, user_id, status, total, price_version, idempotency_key, ...), order_items(order_id, sku_id, seller_id, warehouse_id, quantity, unit_price) ve olaylar için outbox.
Yüksek seviye tasarım
flowchart LR
C[Mobil ve web] --> GW[API gateway]
GW --> CART[Sepet servisi]
CART --> CS[(Sepet deposu)]
CART --> PRICE[Fiyat ve kampanya servisi]
GW --> CO[Sipariş servisi ve saga orkestratörü]
CO --> ODB[(Sipariş DB ve outbox)]
CO --> INV[Stok servisi]
INV --> IDB[(Stok DB)]
CO --> PAY[Ödeme servisi]
ODB -->|outbox relay| K[(Kafka)]
IDB -->|stok değişikliği CDC| K
K --> SRCH[Arama indeksi ve ürün sayfası projeksiyonu]
K --> NOTIF[Bildirim servisi]
SELLER[Satıcı ERP sistemleri] --> SAPI[Satıcı stok API'si]
SAPI --> INV
SW[Rezervasyon süpürücüsü] --> IDB- Sepet servisi stoğa dokunmaz; sadece "ürün şu an satılabilir görünüyor mu" diye projeksiyondan okur.
- Stok servisi satılabilir stoğun tek doğruluk kaynağıdır; rezervasyon, onay ve iade sadece onun API'leriyle yapılır.
- Sipariş servisi saga'nın orkestratörüdür: stok rezervasyonu, ödeme provizyonu, onay ve telafi adımlarını sırayla ve kalıcı durumla yürütür.
- Ürün sayfası ve arama stok bilgisini asenkron bir projeksiyondan okur (CDC ya da outbox ile güncellenir); birkaç saniyelik gecikme kabul edilir.
Derinlemesine
Stok ne zaman ayrılır?
| Seçenek | Artı | Eksi |
|---|---|---|
| Sepete eklerken | Kullanıcı sepetteki ürünü kesin alır | Terk edilen sepetler stoğu haftalarca kilitler; kötü niyetli kullanıcı stoğu "rehin" alabilir; sepet işlemleri stok DB'sine yük olur |
| Sipariş anında (süreli rezervasyon) | Stok sadece gerçekten ödemeye geçenlere ve kısa süreliğine ayrılır | Sepette görünen ürün checkout'ta tükenmiş olabilir |
| Ödeme başarılı olunca | En basit | Ödeme sürerken (3DS dakikalar sürebilir) aynı son ürünü iki kişi öder; birine iade gerekir |
Seçim: sipariş anında, süreli rezervasyon. Sepet bir alışveriş listesidir, rezervasyon değildir. Sipariş verildiğinde stok 15 dakikalığına ayrılır; ödeme tamamlanınca rezervasyon onaylanır, tamamlanmazsa süresi dolunca stoğa geri döner. Sepette gösterilen "son 2 ürün" uyarıları kullanıcıyı checkout'a yönlendirir ama bir söz değildir. Flash sale gibi talebin stoğu çok aştığı durumlarda farklı bir yapı gerekir (Kitap Bölüm 20).
Oversell'i önlemek: koşullu güncelleme ve son savunma
Rezervasyon, kontrol ve değişikliği tek ifadede birleştiren koşullu bir UPDATE'tir; rezervasyon satırıyla aynı transaction'da yazılır:
BEGIN;
WITH r AS (
UPDATE inventory
SET reserved = reserved + :qty, version = version + 1
WHERE sku_id = :sku AND warehouse_id = :wh
AND on_hand - reserved >= :qty
RETURNING sku_id, warehouse_id
)
INSERT INTO stock_reservations (order_id, sku_id, warehouse_id, quantity, expires_at)
SELECT :orderId, sku_id, warehouse_id, :qty, now() + interval '15 minutes' FROM r;
COMMIT;
UPDATE satırı kilitler; aynı satırı güncellemek isteyen ikinci transaction bekler ve READ COMMITTED'da birinci commit olunca satırın güncel halinde WHERE koşulunu yeniden değerlendirir (Kitap Bölüm 10 ve 22). Koşul tutmazsa etkilenen satır 0'dır ve rezervasyon satırı da eklenmez.
Beklenen davranış (gerçek PostgreSQL'de, ör. Testcontainers ile eşzamanlı bir testle doğrulanmalı): stoğu 100 olan tek bir ürüne 50 eşzamanlı istemciden toplam 2.000 rezervasyon denemesi gönderilirse tam olarak 100 rezervasyon oluşur, reserved 100'de kalır ve hiçbir transaction hata almaz; başarısız denemeler koşul tutmadığı için 0 satır döner.
CHECK (reserved <= on_hand) son savunmadır: uygulamadaki bir hata koşulu atlasa bile veritabanı fazla rezervasyonu reddeder. Ama Kitap Bölüm 22'deki uyarı burada da geçerlidir: constraint lost update'i yakalamaz; asıl doğruluk koşullu güncellemededir.
Çok kalemli sipariş. Sepette 5 farklı ürün varsa ya hepsi ayrılır ya hiçbiri. Stok tablosu tek bir veritabanındaysa beş koşullu güncelleme tek transaction'da yapılır; herhangi biri 0 satır dönerse transaction geri alınır ve hangi kalemin yetmediği kullanıcıya döner. İki önemli detay:
- Kilit sırası: İki sipariş aynı iki ürünü ters sırada kilitlerse deadlock olur. Kalemler her zaman
(sku_id, warehouse_id)sırasına göre işlenir. - Stok tablosu bölünmüşse (SKU'ya göre shard), çok kalemli rezervasyon dağıtık hale gelir: her shard'da ayrı rezervasyon, biri başarısız olursa diğerlerinin iadesi (saga içinde telafi). Tek veritabanı yetiyorsa bu karmaşıklıktan kaçınmak bilinçli bir tercihtir.
Depo seçimi
Ürün birden fazla depoda varsa rezervasyon belirli bir depodan yapılır. Basit kural: kullanıcıya en yakın, stoğu yeten depo; yetmezse kalemi ikinci depodan tamamlamak (bölünmüş gönderi) ya da bütün kalemi tek depodan karşılayabilecek depoyu seçmek. Seçim mantığı stok servisinin içindedir ve rezervasyon kaydı hangi depodan yapıldığını taşır; kargo ve depo operasyonları bu bilgiye göre çalışır.
Rezervasyonun yaşam döngüsü
ACTIVE ──ödeme onaylandı──────> CONFIRMED (on_hand -= q, reserved -= q; ardından tahsilat)
ACTIVE ──süre doldu / iptal───> RELEASED (reserved -= q)
Her geçiş kaynak durumu koşul olarak taşır (WHERE status = 'ACTIVE'); böylece aynı rezervasyonun iki kez iade edilmesi ya da iade edilmiş bir rezervasyonun onaylanması imkansızdır. Süpürücü ile ödeme onayı yarışırsa sadece biri kazanır. Ödeme onayı rezervasyonu ACTIVE bulamazsa (süresi dolmuş ve iade edilmiş) ödeme provizyonu iptal edilir ve sipariş başarısız olur; para alınıp ürün verilmemesi durumu oluşmaz (aynı sıra Kitap Bölüm 20'de).
Süresi dolanları iade etmek. Birden fazla süpürücü pod'u aynı satırları işlemesin diye FOR UPDATE SKIP LOCKED kullanılır:
WITH expired AS (
SELECT id FROM stock_reservations
WHERE status = 'ACTIVE' AND expires_at < now()
ORDER BY expires_at
LIMIT 100
FOR UPDATE SKIP LOCKED
), released AS (
UPDATE stock_reservations r SET status = 'RELEASED'
FROM expired e WHERE r.id = e.id
RETURNING r.sku_id, r.warehouse_id, r.quantity
), agg AS (
SELECT sku_id, warehouse_id, sum(quantity) AS qty
FROM released GROUP BY sku_id, warehouse_id
)
UPDATE inventory i SET reserved = i.reserved - agg.qty
FROM agg WHERE i.sku_id = agg.sku_id AND i.warehouse_id = agg.warehouse_id;
PostgreSQL dokümantasyonu SKIP LOCKED'ın tutarsız bir görünüm verdiğini, bu yüzden genel amaçlı sorgular için uygun olmadığını ama kuyruk benzeri tablolarda birden fazla tüketicinin kilit çekişmesini önlemek için kullanılabileceğini söyler; bu tam o kullanımdır. Örneğin 30 süresi dolmuş rezervasyon varken LIMIT 20 ile iki süpürücü eşzamanlı çalışırsa birincisi 20 kaydı kilitleyip işlerken ikincisi onları atlar ve kalan 10'u iade eder; toplam 30, çift iade yok. Partial index (WHERE status = 'ACTIVE') süpürücünün sorgusunu küçük tutar.
Neden Redis TTL'i ile değil? Redis keyspace bildirimleri kaybolabilir ve expired olayı zamanında üretilmeyebilir (Kitap Bölüm 20); para değerinde bir iade buna bağlanmaz.
Sipariş saga'sı
sequenceDiagram
participant U as Kullanıcı
participant O as Sipariş servisi
participant I as Stok servisi
participant P as Ödeme servisi
U->>O: POST /orders ve Idempotency-Key
O->>O: sipariş PENDING ve saga durumu kaydedilir
O->>I: kalemleri rezerve et, 15 dk
I-->>O: rezervasyon tamam
O->>P: provizyon iste
P-->>O: AUTHORIZED veya 3DS gerekli
O-->>U: 201 PENDING_PAYMENT
Note over O,P: 3DS tamamlanınca ödeme servisi event yayınlar
O->>I: rezervasyonları onayla
I-->>O: onaylandı
O->>P: tahsilat
O->>O: sipariş CONFIRMED ve outbox eventTelafi adımları:
| Başarısız adım | Telafi |
|---|---|
| Rezervasyon (stok yok) | Sipariş FAILED, kullanıcıya hangi kalemin yetmediği döner |
| Provizyon reddedildi | Rezervasyonlar iade (RELEASED), sipariş PAYMENT_FAILED |
| Rezervasyon onayı başarısız (süre dolmuş) | Provizyon iptali (void), sipariş FAILED |
| Tahsilat belirsiz (timeout) | Ödeme UNKNOWN; ödeme servisi aynı anahtarla çözer (bkz. ödeme sistemi case'i); sipariş bekler |
Saga'nın kendi durumu sipariş veritabanında saklanır; orkestratör pod'u çökse bile başka bir pod kaldığı adımdan devam eder. Her adım idempotent'tir (rezervasyon (order_id, sku_id, warehouse_id) unique, ödeme idempotency key'li), bu yüzden bir adımı tekrar çalıştırmak güvenlidir. Sipariş durumu değişiklikleri outbox ile Kafka'ya yayınlanır (Kitap Bölüm 13).
Satıcı stok güncellemeleri: dışarıdaki doğruluk kaynağı
Pazaryerinde fiziksel stoğu satıcı bilir; kendi ERP'sinden "S-77 ürünü, 3 numaralı depoda 50 adet" diye mutlak değer gönderir. Bu, bizim rezervasyonlarımızla çakışabilir:
- Güncelleme sadece
on_hand'i değiştirir;reserved'e dokunmaz. Satıcının "50" demesi, "ödeme bekleyen 5 rezervasyon dahil fiziksel stoğum 50" anlamına gelir; satılabilir stok 45 olur. - Sırasız güncellemeler: Satıcının iki güncellemesi ters sırayla gelirse eski değer yenisini ezer. Her güncelleme satıcı tarafındaki zaman damgasını veya sıra numarasını taşır; daha eski bir güncelleme yok sayılır (
WHERE source_updated_at < :incoming). - Stok rezervasyonun altına düşerse: Satıcı mağazada sattı ve yeni stok 3, ama 5 adetlik aktif rezervasyon var.
CHECK (reserved <= on_hand)bu güncellemeyi reddeder. Sessizce reddetmek yanlış (satıcının fiziksel gerçeği bu); sessizce kabul etmek de yanlış (oversell). İş kuralı açıkça seçilir: güncelleme kabul edilir, satılabilir stok sıfırlanır, fazla rezervasyonlar en yeniden başlayarak iptal edilir ve kullanıcılara bildirilir, olay satıcı performans metriğine yazılır. Bu bir mülakatta "constraint son savunmadır ama iş kuralının yerini tutmaz" demenin somut örneğidir.
Okuma yolu: ürün sayfasındaki stok bilgisi
Milyonlarca ürün sayfası görüntülemesi stok veritabanına gitmez. Stok değişiklikleri (CDC ya da outbox ile) Kafka'ya, oradan arama indeksine ve ürün sayfası cache'ine yayılır; birkaç saniye geriden gelir. "Stokta var" bilgisi bu yüzden bir ipucudur; karar checkout'ta stok servisinden verilir. Düşük stoklu ürünlerde "son 3 ürün" gibi gösterimler de projeksiyondan gelir. Sepet sayfası açıldığında sepetteki kalemler için toplu ve hafif bir uygunluk sorgusu yapılır (cache'lenmiş projeksiyondan), checkout önizlemesinde ise stok servisinden.
Sepet tasarımı
- Depolama: Sepet kullanıcı başına tek bir dokümandır, erişim anahtarla. Kullanıcılar sepetin cihazlar arası ve haftalarca kalmasını bekler; bu yüzden kalıcı bir depo (doküman deposu ya da PostgreSQL
JSONB) + önünde cache. Sadece Redis'te tutmak, eviction ya da failover'da sepet kaybı demektir. - Eşzamanlılık: Aynı kullanıcı iki cihazdan aynı anda değiştirirse
versionile optimistic locking; çakışmada sepet yeniden okunup değişiklik uygulanır. - Misafir sepeti ve birleştirme: Misafir sepeti cihaz token'ıyla tutulur. Girişte üye sepetiyle birleşir: aynı ürün iki sepette varsa miktarlar toplanır ama ürün başına satış limiti ve makul bir üst sınırla kırpılır; birleştirme idempotent olmalıdır (aynı misafir token'ıyla ikinci kez çağrılırsa miktarlar tekrar toplanmaz; birleştirilen misafir sepeti işaretlenir).
- Fiyat: Sepette gösterilen fiyat fiyat servisinden okunur (kampanya ve kupon kuralları dahil); checkout önizlemesi
priceVersionüretir ve sipariş bu versiyonla doğrulanır. - Limitler: Sepet kalem sayısı ve ürün başına adet sınırı (botların devasa sepetleri ve kötüye kullanım için).
- Ömür: Dokunulmayan sepetler 60-90 gün sonra silinir; terk edilen sepet hatırlatması bildirim sistemine bir olaydır.
Sıcak ürün: kampanyadaki çok satan
Normal bir kampanyada popüler bir ürünün tek (sku, depo) satırına saniyede yüzlerce rezervasyon gelebilir. Kademeli çözümler:
- Önce ölç. Koşullu güncelleme kısa bir transaction'dır; saniyede birkaç yüz rezervasyonu tek satırda karşılayabilir. Kampanya öncesi yük testi bu sınırı gösterir.
- Depolara bölünmüş stok: Ürün zaten birden fazla depodaysa rezervasyonlar depolar arasında dağılır; doğal bir kovalama.
- Mantıksal kovalar: Tek depodaki stok N alt satıra bölünür (
bucketkolonu); rezervasyon rastgele bir kovadan denenir, boşsa diğerine geçilir. - Redis önyüzü: Talebin stoğu çok aştığı durumlarda Redis'te atomik bir sayaç ön filtre olur ve veritabanı son savunma kalır (Kitap Bölüm 20). Bu, karmaşıklık getirdiği için sadece ölçüm gerektirdiğinde.
Kasım hazırlığı
Önceden ölçekleme (HPA tepki süresi dalgayı yakalayamaz), kampanya profiliyle yük testi, kritik olmayan özelliklerin (öneriler, "bunu alanlar şunu da aldı") yük altında kapatılabilmesi (feature flag), stok ve sipariş veritabanında connection havuzu ve index kontrolü, ve sipariş akışı için ayrı bir dashboard: rezervasyon başarı/başarısızlık oranı, aktif rezervasyon sayısı, süpürücü gecikmesi, saga'da bekleyen siparişler, ödeme UNKNOWN sayısı.
Darboğazlar, hatalar ve trade-off'lar
| Senaryo | Etki | Önlem |
|---|---|---|
| Sıcak SKU satırı | Kilit beklemesi, sipariş gecikmesi | Ölçüm, depo/kova bölme, gerekirse Redis önyüzü |
| Çok kalemli siparişlerde deadlock | Transaction'lar düşer | Kalemleri sabit sırada kilitlemek |
| Süpürücü durdu | Süresi dolan rezervasyonlar stoğu tutar, satış kaybı | Süpürücü gecikmesi alarmı, birden fazla süpürücü (SKIP LOCKED) |
| Ödeme ile süpürücü yarışı | Ödenen ama iade edilmiş stok | Koşullu durum geçişi; onay başarısızsa provizyon iptali |
| Satıcı stoğu rezervasyonun altına indirdi | Oversell riski | Açık iş kuralı: fazla rezervasyonları iptal ve bildirim |
| Sırasız satıcı güncellemeleri | Eski stok yeniyi ezer | Kaynak zaman damgası/sıra numarası ile karşılaştırma |
| Projeksiyon gecikti | Ürün sayfası "stokta var" der, checkout reddeder | Kabul edilen bayatlık; checkout'ta net mesaj, lag alarmı |
| Sepet deposu yavaşladı | Sepet sayfası yavaş | Cache, okuma replikası; sepet yazmaları kritik yol dışında değil, kapasite planı |
Trade-off'lar:
- Sipariş anında rezervasyon: Stok kilitlenmez; bedeli sepette görünen ürünün checkout'ta tükenebilmesi.
- Tek stok veritabanı: Çok kalemli rezervasyon tek transaction'da atomik; bedeli yazma ölçeğinin tek kümeyle sınırlı olması (75M satırlık tablo için yeterli).
- Asenkron stok projeksiyonu: Okuma yolu ucuz; bedeli birkaç saniyelik bayatlık.
- Orkestrasyonlu saga: Akış tek yerde görünür ve izlenir; bedeli orkestratörün kendi durumunu yönetmesi.
Üç seviyede cevap
Soru: "Stoğu ne zaman düşersin: sepete eklerken mi, sipariş verirken mi, ödeme alınınca mı?"
seviye: Orta etiketler: [inventory, reservation] kaynak: Tahmini
Ödeme alınınca düşerim, çünkü o zaman satış kesinleşmiş olur.
Ödeme alınınca düşersem ödeme sürerken aynı son ürünü iki kişi ödeyebilir, birine iade gerekir. Sepete eklerken düşersem terk edilen sepetler stoğu kilitler. Sipariş verilirken süreli bir rezervasyon yaparım, örneğin 15 dakika; ödeme gelirse onaylarım, gelmezse süre dolunca iade ederim.
Üç seçeneğin farklı başarısızlık modları var: sepette ayırmak terk edilen sepetler ve kötü niyetli kullanıcılar yüzünden stoğu rehin bırakır, ödemede düşmek 3DS dakikalarca sürebildiği için aynı son ürünü iki kişiye ödetir. Bu yüzden sepeti bir alışveriş listesi olarak görürüm, sipariş anında süreli rezervasyon yaparım. Stok tablosunda on_hand ile reserved'i ayrı tutarım; rezervasyon on_hand - reserved >= qty koşullu güncellemesi ve rezervasyon satırı tek transaction'da. Ödeme onaylanınca (provizyon alınınca) rezervasyonu onaylarım, on_hand ve reserved birlikte düşer, sonra tahsilat yapılır; süre dolarsa SKIP LOCKED ile çalışan süpürücü iade eder. Ödeme onayı ile süpürücü yarışırsa koşullu durum geçişi sayesinde sadece biri kazanır; onay kaybederse provizyonu iptal ederim. Rezervasyon süresi, ödeme akışının p99 süresine göre seçilen bir parametre; talebin stoğu çok aştığı flash sale'de ise bekleme odası ve Redis önyüzü gibi farklı bir yapıya geçerim.
Junior yarış penceresini görmüyor. Mid doğru cevabı (süreli rezervasyon) gerekçesiyle veriyor. Senior her seçeneğin başarısızlık modunu söylüyor, veri modelini (on_hand/reserved) ve atomik işlemi kuruyor, yaşam döngüsünü ve süpürücü-ödeme yarışını kapatıyor ve parametreyi ölçüme bağlıyor. Ayrıca bu tasarımın nerede yetersiz kalacağını (flash sale) biliyor.
Takip sorusu: 15 dakikalık rezervasyon süresini nasıl seçersin? 5 dakika ya da 60 dakika olsa neyi kaybederiz?
Soru: "Aynı ürünün son adedini iki kişi aynı anda sipariş ederse ne olur?"
seviye: Orta etiketler: [concurrency, locking] kaynak: Tahmini
Önce stok kontrolü yaparım, stok varsa siparişi oluştururum. @Transactional olduğu için ikisi aynı anda olmaz.
@Transactional tek başına yetmez; READ COMMITTED'da iki transaction da stoğu 1 okuyup ikisi de düşebilir. Kontrolü ve düşmeyi tek bir koşullu UPDATE ile yaparım: WHERE on_hand - reserved >= 1. Satır kilitlendiği için ikinci transaction bekler, birinci commit olunca koşulu yeniden değerlendirir ve 0 satır döner. İkinci kullanıcıya stok yok derim.
Kontrol ve değişiklik tek bir koşullu güncellemede olmalı; oku-kontrol et-yaz desenini @Transactional korumaz. UPDATE satır kilidi alır, ikinci transaction bekler ve READ COMMITTED'da satırın güncel halinde koşulu yeniden değerlendirir; bunu gerçek PostgreSQL'de stoğu 100 olan ürüne 2.000 eşzamanlı denemeyle test ederim, tam 100 rezervasyon oluşmalı. Arkada CHECK (reserved <= on_hand) son savunma. Sepette birden fazla kalem varsa hepsini tek transaction'da ve (sku, depo) sırasıyla kilitlerim ki ters sırada kilitleyen iki sipariş deadlock'a girmesin. İkinci kullanıcıya genel bir hata değil, hangi kalemin kaç adet kaldığını dönerim ki sepetini düzeltip devam edebilsin. Bu ürün kampanyada saniyede yüzlerce istek alıyorsa satır kilidi darboğaz olur; önce yük testiyle sınırı ölçer, gerekirse stoğu kovalara bölerim.
Junior @Transactional'ın neyi garanti ettiğini yanlış biliyor; bu, mülakatlarda en sık görülen hatadır. Mid doğru mekanizmayı ve izolasyon davranışını açıklıyor. Senior bunu çok kalemli siparişe (atomiklik ve kilit sırası), kullanıcı deneyimine (hangi kalem) ve ölçeğe (sıcak satır) genişletiyor ve iddiasını gerçek veritabanında bir eşzamanlılık testiyle doğrulamayı öneriyor.
Takip sorusu: Stok tablosunu SKU'ya göre iki veritabanına böldük ve sepetteki iki ürün farklı veritabanlarında. Atomikliği nasıl sağlarsın?
Soru: "Satıcı ERP'sinden 'stok 3' güncellemesi geldi ama o ürün için 5 adet aktif rezervasyon var. Ne yaparsın?"
seviye: İleri etiketler: [inventory, consistency] kaynak: Tahmini
Satıcının gönderdiği değeri yazarım, stok 3 olur.
Satıcının değeri fiziksel stok, rezervasyonlar ayrı tutuluyor. Stok 3, rezervasyon 5 olursa satılabilir stok negatif olur; veritabanındaki reserved <= on_hand kontrolü bu güncellemeyi reddeder. Satıcıya hata dönerim ve durumu operasyon ekibine bildiririm.
Önce semantiği netleştiririm: satıcının mutlak değeri fiziksel stoktur ve bizim rezervasyonlarımızı içerir. Güncellemeyi reddetmek satıcının fiziksel gerçeğini yok saymak olur, sessizce kabul etmek de 2 adet oversell. Bu bir iş kuralı kararı ve açıkça tanımlarım: güncellemeyi kabul ederim, satılabilir stok sıfırlanır, fazla 2 rezervasyonu en yeniden başlayarak iptal eder, siparişlerin provizyonlarını iptal eder ve kullanıcılara bildiririm; olayı satıcının performans metriğine yazarım. Bunu tek transaction'da yaparım ki constraint hiçbir anda ihlal edilmesin. Ayrıca güncellemenin bayat olup olmadığına bakarım: satıcı sistemi güncellemeleri sırasız gönderebilir, her güncellemenin kaynak zaman damgasını saklar ve daha eskisini yok sayarım. Kalıcı çözüm satıcıyla entegrasyonda: mutlak değer yerine "şu kadar sattım" gibi delta olayları ya da rezervasyonları satıcı tarafına bildiren bir akış.
Junior oversell'e kapı açıyor. Mid constraint'in reddedeceğini görüyor ama gerçek dünya problemini çözmüyor: satıcının stoğu gerçekten 3. Senior semantiği netleştiriyor, iş kuralını açıkça seçiyor, constraint'i ihlal etmeden uygulanacak bir transaction kuruyor, sırasız güncellemeleri ele alıyor ve kalıcı çözümü entegrasyon tasarımında arıyor. Pazaryeri deneyimi olan bir aday bu soruda belirgin şekilde öne çıkar.
Takip sorusu: İptal edilecek rezervasyonlardan biri ödemesi az önce tahsil edilmiş bir siparişe ait. Ne yaparsın?
Mülakatçı için
- Stoğun ne zaman ayrılacağını seçeneklerin başarısızlık modlarıyla karşılaştırarak seçiyor; sepeti rezervasyondan ayırıyor.
- Fiziksel stok ile rezerve stoğu ayrı modelliyor.
- Oversell'i koşullu atomik güncelleme ile önlüyor ve
@Transactionalile oku-kontrol et-yaz deseninin neden yetmediğini açıklıyor. - Veritabanı constraint'ini son savunma olarak ekliyor ama tek başına çözüm saymıyor.
- Çok kalemli siparişte atomikliği ve kilit sırasını (deadlock) ele alıyor.
- Rezervasyonun süresi dolunca güvenilir şekilde iade edilmesini tasarlıyor (birden fazla süpürücü,
SKIP LOCKEDveya eşdeğeri). - Ödeme onayı ile rezervasyon süresi arasındaki yarışı kapatıyor.
- Sipariş akışını saga olarak kuruyor; her adımın telafisini ve idempotency'sini söylüyor.
- Fiyatın checkout'ta yeniden doğrulanması ve kullanıcıya gösterilmesi gerektiğini fark ediyor.
- Ürün sayfası stok bilgisini asenkron projeksiyondan okuyor ve kararı kaynaktan veriyor.
- Pazaryerine özgü bir problemi (satıcı stok güncellemesi, depo seçimi) ele alıyor.
Takip soruları:
- Kupon ve kampanya kuralları sipariş anında nasıl doğrulanır; tek kullanımlık bir kuponun iki siparişte kullanılması nasıl engellenir?
- Kullanıcı ödeme sayfasında 14. dakikada. Rezervasyonu uzatır mısın?
- Ürün sayfasındaki "stokta" bilgisinin gecikmesini nasıl ölçersin?
- Siparişin bir kalemi kargoya çıkamadı (depoda hasarlı). Hangi servisler, hangi sırayla etkilenir?
Seviye beklentisi: Mid seviyede süreli rezervasyon, koşullu güncelleme, süpürücü ve saga'nın ana hatları beklenir. Senior'dan veri modelinde on_hand/reserved ayrımı, çok kalemli atomiklik ve kilit sırası, yarışların kapatılması, satıcı stok entegrasyonu gibi pazaryeri gerçekleri ve sıcak ürün için ölçüme dayalı kademeli çözüm beklenir.
Hızlı tekrar
S: Stok neden sepete eklerken ayrılmaz?
C: Terk edilen sepetler stoğu uzun süre kilitler ve kötü niyetli kullanıcılar stoğu rehin alabilir. Sepet bir listedir; rezervasyon sipariş anında, süreli yapılır.
S: Oversell'i önleyen SQL deseni nedir?
C: UPDATE inventory SET reserved = reserved + :q WHERE ... AND on_hand - reserved >= :q; etkilenen satır 0 ise stok yok. Rezervasyon satırıyla aynı transaction'da.
S: CHECK (reserved <= on_hand) neyi garanti eder, neyi etmez?
C: Fazla rezervasyonun veritabanına yazılmasını engeller (son savunma). Lost update'i ve iş kuralı kararlarını (satıcı stoğu düşürdü) çözmez.
S: Çok kalemli siparişte deadlock nasıl önlenir?
C: Kalemler her zaman sabit sırada (sku_id, warehouse_id) kilitlenir ve tek transaction'da işlenir.
S: Süresi dolan rezervasyonları birden fazla pod nasıl çakışmadan iade eder?
C: SELECT ... FOR UPDATE SKIP LOCKED ile her pod farklı satırları alır; geçişler WHERE status = 'ACTIVE' ile koşulludur.
S: Ödeme onayı gelmeden rezervasyonun süresi dolarsa ne olur?
C: Onay ACTIVE rezervasyon bulamaz; provizyon iptal edilir (void) ve sipariş başarısız olur. Para alınıp ürün verilmemesi oluşmaz.
S: Ürün sayfasındaki stok bilgisi nereden gelir?
C: CDC/outbox ile güncellenen asenkron projeksiyondan (arama indeksi, cache); birkaç saniye bayat olabilir. Kesin karar checkout'ta stok servisinden.
S: Satıcının sırasız gelen stok güncellemeleri nasıl ele alınır?
C: Her güncelleme kaynak zaman damgası ya da sıra numarası taşır; saklanandan eski olan güncelleme yok sayılır.
Kaynaklar
- Alex Xu, Sahn Lam, System Design Interview: An Insider's Guide (Cilt 2): "Hotel Reservation System" bölümündeki envanter ve eşzamanlılık tartışması.
- Martin Kleppmann, Designing Data-Intensive Applications: Bölüm 7 (transaction'lar, lost update, write skew).
- PostgreSQL: SELECT, The Locking Clause:
NOWAITveSKIP LOCKED'ın davranışı ve kuyruk benzeri tablolarda kullanımı. - PostgreSQL: Explicit Locking: Satır kilitleri, deadlock'lar ve advisory lock'lar.