Case: deposit, withdraw ve exchange servisini baştan kurmak
Bir live coding mülakatında sorulan deposit/withdraw/exchange case'ini netleştirme sorularından kapasite hesabına, veri modelinden race condition çözümlerine, 3rd party kur servisinden test ve operasyona kadar, gerçek PostgreSQL üzerinde ölçülmüş sonuçlarla adım adım çözer.
Bu bölümde
Bu bölüm tek bir gerçek mülakat sorusunun eksiksiz çözümüdür: para yatırma (deposit), para çekme (withdraw) ve kuru bir 3rd party servisten alınan döviz çevirme (exchange) işlemlerini, tek PostgreSQL instance'ı ve günde 1 milyon istek varsayımıyla sıfırdan kurmak. Netleştirme sorularını, kapasite hesabını, veri modelini, her race condition için çalışan SQL ve Spring kodunu, 3rd party hatalarını, test stratejisini ve 90 dakikalık bir live coding oturumunu nasıl sıralayacağını öğreneceksin.
Mülakatta bu tür bir soru "sistemi baştan kur" diye açılır, live coding ile devam eder ve takip sorularıyla ("iki istek aynı anda gelirse?", "kur servisi cevap vermezse?", "100 milyon olursa?") derinleşir. Bölümdeki eşzamanlılık iddialarının hepsi PostgreSQL 16 üzerinde birden fazla oturum ve pgbench ile denendi; ölçüm sonuçları ilgili yerlerde veriliyor.
Konu anlatımı
Case'e girmeden önce, çözümün üzerine kurulduğu dört yapı taşını netleştirelim: parayı nasıl temsil ettiğin, bakiyeyi nasıl kaydettiğin, bu case'te doğruluğu tehdit eden şeylerin neler olduğu ve tek bir isteğin "tam bir kez" etki etmesinin ne anlama geldiği. Genel kavramların ayrıntısı ilgili bölümlerde; burada sadece bu case'e özgü olanlar var.
Para nasıl temsil edilir: BigDecimal, scale ve yuvarlama
double ikili (binary) kayan noktalı sayıdır; 0,1 gibi ondalık kesirleri tam temsil edemez. Bu container'da Java 21 ile çalıştırılan sonuçlar:
0.1 + 0.2 -> 0.30000000000000004
10 kez 0.1 toplamı -> 0.9999999999999999
new BigDecimal(0.1) -> 0.1000000000000000055511151231257827...
BigDecimal.valueOf(0.1) -> 0.1
new BigDecimal("1.0").equals(new BigDecimal("1.00")) -> false
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) -> 0
new BigDecimal("10").divide(new BigDecimal("3")) -> ArithmeticException
Buradan çıkan kurallar:
- Para
BigDecimalile tutulur,String'den üretilir.new BigDecimal(double)double'ın taşıdığı hatayı aynen kopyalar. JSON'dan gelen sayıyı JacksonBigDecimalalanına doğrudan okur; aradadouble'a dönüştürme. - Karşılaştırma
compareToile yapılır.equalsscale'i de karşılaştırır:1.0ile1.00eşit değildir.HashSet<BigDecimal>veyaMapanahtarında da aynı tuzak vardır. - Bölme her zaman scale ve
RoundingModeister. Sonsuz ondalıklı bir sonuç (10/3)ArithmeticExceptionfırlatır. - Scale para birimine bağlıdır.
Currency.getInstance("TRY").getDefaultFractionDigits()2, JPY için 0, KWD için 3 döner. 10,005 TL geçerli bir tutar değildir; böyle bir istek sessizce yuvarlanmamalı, reddedilmelidir.
Yuvarlama. RoundingMode.HALF_EVEN (banker's rounding) tam ortadaki değeri çift komşuya yuvarlar: 2,345 → 2,34, 2,355 → 2,36. HALF_UP ise 2,345'i 2,35 yapar. Çok sayıda işlemde HALF_UP sistematik olarak yukarı kayar; HALF_EVEN bu sapmayı dengeler. Hangisinin kullanılacağı bir iş ve regülasyon kararıdır; mülakatta "varsayılanım HALF_EVEN, ama bunu iş birimiyle netleştiririm" demek doğru cevaptır.
PostgreSQL'in round(numeric) fonksiyonu yarımları sıfırdan uzağa yuvarlar (HALF_UP davranışı): bu ortamda round(2.5::numeric) 3, round(0.125::numeric, 2) 0,13 döndü. Aynı tutarı bir yerde Java'da HALF_EVEN ile, başka bir yerde SQL'de round ile yuvarlarsan iki sonuç kuruş farkıyla ayrışır. Yuvarlamayı tek bir yerde (domain kodunda) yap; SQL sadece toplama ve çıkarma yapsın.
Minor units alternatifi. Tutarı en küçük birim cinsinden long olarak tutmak (100,50 TL = 10050 kuruş) hızlıdır ve yuvarlama hatası üretmez. Bedeli: her para biriminin üssünü (TRY 2, JPY 0, KWD 3) ayrıca bilmek, kur çarpımında yine ondalıklı aritmetiğe ihtiyaç duymak ve taşma sınırlarını düşünmek. Ödeme sağlayıcılarının API'lerinde yaygındır. Bu case'te BigDecimal + currency'ye göre scale seçiyoruz; veritabanında NUMERIC(19,4) hem 2 hem 3 basamaklı para birimlerini taşır, her para biriminin kendi scale'ini domain katmanı zorlar.
Bu kuralları taşıyan küçük bir değer nesnesi (bu container'da derlenip çalıştırıldı):
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
int scale = currency.getDefaultFractionDigits(); // TRY/USD 2, JPY 0, KWD 3
if (amount.stripTrailingZeros().scale() > scale) {
throw new InvalidAmountException(
"%s en fazla %d ondalık basamak alır: %s".formatted(currency, scale, amount));
}
amount = amount.setScale(scale, RoundingMode.UNNECESSARY); // 10 -> 10.00; değer kaybı yoksa
}
public static Money of(String amount, String currencyCode) {
return new Money(new BigDecimal(amount), Currency.getInstance(currencyCode));
}
public boolean isPositive() {
return amount.signum() > 0;
}
public Money plus(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
public Money minus(Money other) {
requireSameCurrency(other);
return new Money(amount.subtract(other.amount), currency);
}
public boolean isLessThan(Money other) {
requireSameCurrency(other);
return amount.compareTo(other.amount) < 0; // equals değil: 1.0 ve 1.00 farklı scale
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new CurrencyMismatchException(currency, other.currency);
}
}
}
RoundingMode.UNNECESSARY, yuvarlama gerekiyorsa exception fırlatır; yani Money asla sessizce yuvarlamaz. Veritabanından gelen 660.0000 (scale 4) 660.00'a dönüşür, ama 10.005 TL reddedilir. Döviz çevrimindeki tek bilinçli yuvarlama noktası ayrı bir fonksiyondadır:
public final class FxConversion {
private FxConversion() {
}
/** rate: 1 birim kaynak para = rate birim hedef para. */
public static Money convert(Money source, Currency target, BigDecimal rate) {
if (rate.signum() <= 0) {
throw new InvalidAmountException("Kur pozitif olmalı: " + rate);
}
BigDecimal exact = source.amount().multiply(rate); // çarpma kesindir, yuvarlama yok
BigDecimal rounded = exact.setScale(target.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
return new Money(rounded, target); // tek yuvarlama noktası burası
}
}
Çalıştırılan örnekler: 1.000 TRY × 0,0291 = 29,10 USD; 123,45 TRY × 0,02913457 = 3,5966626665 → 3,60 USD; 0,50 × 0,05 = 0,025 → HALF_EVEN ile 0,02; 12,34 USD × 151,237 = 1.866,26 → JPY'nin scale'i 0 olduğu için 1.866 JPY.
Bakiye ve hareket: ledger, double-entry ve değişmezlik
Bakiyeyi sadece accounts.balance kolonunda tutup üzerine yazmak, "bu bakiye neden 480?" sorusunu cevapsız bırakır. Bankacılıkta bakiye bir sonuçtur; asıl kayıt hareketlerdir (ledger). İki model vardır:
- Değişmez hareket kaydı (append-only ledger): Her işlem için
ledger_entriestablosuna işaretli tutarla (çekim negatif, yatırma pozitif) bir satır eklenir; satırlar asla güncellenmez veya silinmez. Hata düzeltmesi de yeni bir ters kayıtla yapılır. Değişmezlik kuralı:accounts.balance = SUM(ledger_entries.amount). - Double-entry (çift taraflı kayıt): Her işlem en az iki kayıttan oluşur ve bir işlemin kayıtlarının toplamı her para birimi için sıfırdır. Müşteri 1.000 TL yatırdığında müşteri hesabına +1.000, bankanın "gelen EFT takas" hesabına −1.000 yazılır. Para yoktan var olmaz, bir yerden bir yere gider. Exchange'de TRY tarafında müşteri −340 / bankanın TRY pozisyon hesabı +340, USD tarafında pozisyon hesabı −9,89 / müşteri +9,89 olur.
Bu case'te müşteri hesapları için değişmez hareket kaydı kuruyoruz ve bakiyeyi accounts.balance kolonunda önbellek olarak, aynı transaction'da güncelliyoruz. Neden önbellek? "Bakiyem ne?" sorusu her seferinde milyonlarca satırı toplamamalı ve "bakiye yetiyor mu?" kontrolü tek bir satır üzerinde atomik yapılabilmeli. Ama bayatlayabilen bir önbellek değildir: ledger ile aynı transaction'da güncellendiği için para hareketi kararının dayandığı operasyonel kaynak bu kolondur; ledger ise denetim ve mutabakatın kaynağıdır. İkisi ayrışırsa bu otomatik "düzeltilecek" bir durum değil, incelenecek bir olaydır (incident). Tam double-entry'ye geçiş, bankanın kendi sistem hesaplarını (takas, kasa, döviz pozisyonu) ekleyerek yapılır; bunun getirdiği "hot account" problemi "Sorulmadı ama sorulabilir" kısmında.
Mülakatta söylenecek cümle: "Bakiye bir projeksiyondur; doğruluk kaynağı değişmez hareketlerdir. Bakiye kolonunu performans için tutarım ama her gece hareketlerin toplamıyla karşılaştırırım."
Bu case'te doğruluğu tehdit eden dört şey
| Tehdit | Örnek | Çözüm ailesi |
|---|---|---|
| Lost update | İki çekim aynı bakiyeyi okur, ikisi de "yetiyor" der | Atomik koşullu UPDATE, satır kilidi, optimistic lock, SERIALIZABLE |
| Tekrarlanan istek | Mobil uygulama timeout sonrası aynı çekimi tekrar gönderir | Idempotency key + unique constraint |
| Çok satırlı kilit sırası | TRY→USD ve USD→TRY exchange'leri aynı anda iki hesabı ters sırada kilitler | Tutarlı kilit sırası |
| Belirsiz dış sonuç | Kur sağlayıcısına "çevir" komutu gitti, timeout alındı | Quote/execute ayrımı, PENDING durumu, reconciliation |
Bölümün geri kalanı bu dört tehdidin her birini kapatır. Dikkat: listede "yüksek trafik" yok. Bu case'te göreceğimiz gibi asıl problem ölçek değil, doğruluktur.
"Tam bir kez" ne demek?
Ağ üzerinden "exactly once" teslimat yoktur; istemci cevabı alamadığında isteğin işlenip işlenmediğini bilemez ve tekrar dener. Gerçekleştirilebilen şey etkinin tam bir kez olmasıdır: istek birden fazla kez gelebilir ama bakiye bir kez değişir. Bunun iki bileşeni vardır: aynı mantıksal işlemi tanıyan bir anahtar (idempotency key) ve bu anahtarın iş verisiyle aynı transaction'da kaydedilmesi. Anahtar ayrı bir sistemde (Redis) veya ayrı bir transaction'da tutulursa, "anahtar yazıldı ama para düşmedi" ya da tersi mümkün olur. HTTP tarafındaki genel tasarım Bölüm 13 ve 18'de; burada bu case'e uygulanmış halini göreceğiz.
Mülakattaki case: adım adım çözüm
Soru: "Bana deposit, withdraw ve 3rd party bir servise giden exchange servisi yaz. App tek bir DB'de, diğerleri mikroservis gibi düşün. DB tek instance, her gün 1 milyon istek geliyor. Nasıl oluşturursun, neleri göze alırsın, neleri yaparsın, race condition'ı nasıl engellersin? Sistemi baştan kur."
Mülakatta beklenen: Hemen kod yazmaya başlamak değil; önce kapsamı netleştirmek, kapasiteyi kabaca hesaplayıp "asıl risk doğruluk" teşhisini koymak, sonra en kritik akışı (withdraw) doğru ve testli yazıp idempotency, exchange ve dayanıklılıkla genişletmek.
Adım 1: İlk 5 dakika: netleştirme soruları ve varsayımlar
"Sistemi baştan kur" sorusu kasıtlı olarak eksiktir. Mülakatçı, eksikleri senin bulmanı bekler. Her soru bir tasarım kararını değiştirdiği için sorulur; bunu sesli söyle.
| Soru | Neden önemli | Makul varsayım |
|---|---|---|
| Hesap tek para birimli mi, müşterinin her para birimi için ayrı hesabı mı var? | Exchange iki hesaplı bir işlem mi, tek hesap içinde mi? | Müşterinin her para birimi için ayrı hesabı var: (customer_id, currency) benzersiz |
| Negatif bakiye (overdraft) var mı? | Yetersiz bakiye kontrolünün şekli | Yok: balance >= 0 kesin kural |
| Exchange'i 3rd party mi yürütüyor, yoksa sadece kur mu veriyor? | Belirsiz sonuç ve telafi gerekip gerekmediği | Sağlayıcı sadece kur veriyor; çevrimi banka kendi defterinde yapıyor. Diğer durum Adım 6'da |
| Kurun geçerlilik süresi ne? Müşteri gördüğü kurla mı işlem yapmalı? | Quote/execute ayrımı | Evet: müşteri kuru görür, 30 saniye içinde onaylar |
| Okuma/yazma oranı ne? Bakiye ve hareket sorgusu da bu 1 milyonun içinde mi? | Kapasite ve index tasarımı | 1 milyonun yaklaşık yarısı okuma |
| Tutarlılık beklentisi ne? Çekimden hemen sonra bakiye doğru görünmeli mi? | Read replica kullanımı | Evet, güçlü tutarlılık; yazma sonrası okuma primary'den |
| Deposit'in kaynağı ne? Kart, EFT, şube? | Deposit'i kim çağırıyor, kimlik doğrulama | Ödeme altyapısından gelen, idempotency key'li bir çağrı |
| Audit ve regülasyon: kim, ne zaman, hangi kanaldan? Kayıt ne kadar saklanacak? | Değişmez kayıt, saklama, silme yasağı | Her hareket değişmez; regülasyonun öngördüğü süre boyunca saklanır |
| Limitler (günlük çekim limiti), fraud kontrolü var mı? | Ek eşzamanlılık kuralları | İlk sürümde yok; sonra eklenebilir (bkz. "Sorulmadı ama sorulabilir") |
| "Diğerleri mikroservis gibi" ne demek? Hesap ve kur servisleri ayrı deploy mu? | Mimari sınır | Tek DB'yi paylaşan modüller; tek deploy da olabilir, kararı Adım 3'te tartışıyorum |
Mülakatta 5-6 soru sorar, cevapsız kalanlara varsayım koyar ve bunları editöre yazarsın:
Varsayımlar
- Müşterinin her para birimi için ayrı hesabı var; overdraft yok (balance >= 0).
- Exchange: kuru 3rd party verir, çevrimi biz kendi defterimizde yaparız. Kur 30 sn geçerli.
- 1M istek/gün; yaklaşık yarısı okuma (bakiye, hareketler).
- Güçlü tutarlılık: başarılı çekimden sonra bakiye hemen doğru görünür.
- Her hareket değişmez kayıt; düzeltme ters kayıtla.
- Kimlik doğrulama gateway'de; servis customerId'yi token'dan alır.
Mülakatçı "sen karar ver" derse karar ver ve gerekçesini tek cümleyle söyle: "Overdraft yok varsayıyorum, çünkü kuralı daha sıkı tutmak doğruluk problemini daha net gösteriyor; overdraft gelirse kural balance + overdraft_limit >= amount olur, mekanizma aynı kalır."
Adım 2: Kapasite hesabı: problem ölçek değil, doğruluk
Kaba hesap, mimariyi büyütmemek için yapılır:
1.000.000 istek / 86.400 sn ≈ 11,6 istek/sn (ortalama)
Gün içi dağılım (trafik ~8-10 saate yığılır) ≈ 2-3x
Maaş günü, kampanya, kur dalgalanması ≈ 10x tepe
Tasarım hedefi ≈ 100-200 istek/sn
Okuma ~%50 -> tepe ~100 okuma/sn (tek satırlık PK ve index sorguları)
Yazma ~%50 -> tepe ~100 yazma/sn; exchange quote'ları ayrıca 3rd party'ye gider
Bir yazma işlemi (idempotency kaydı + koşullu UPDATE + ledger insert) birkaç milisaniyelik kısa bir transaction'dır. Little's law ile gereken eşzamanlı DB bağlantısı: 100 tps × 0,005 sn = 0,5. HikariCP'nin varsayılan 10 bağlantılık havuzu fazlasıyla yeter (bkz. Bölüm 10).
Bunu ölçtüm. 2 vCPU'lu bu container'da, fsync ve synchronous_commit açık PostgreSQL 16 üzerinde, bölümdeki withdraw transaction'ının tamamı (INSERT transactions + koşullu UPDATE accounts + INSERT ledger_entries) pgbench ile 16 istemciden koşturuldu:
| Senaryo | tps | Ortalama gecikme |
|---|---|---|
| 1.000 farklı hesaba dağılmış çekimler | ~2.270 | ~7 ms |
| Hepsi tek bir hesaptan (hot row) | ~400 | ~40 ms |
Bunlar küçük bir test ortamının gösterge sayılarıdır, benchmark değildir; ama mesajı nettir: tepe hedefimizin 10 katından fazlası, en kötü durum olan tek hesap senaryosunda bile karşılanıyor. Depolama tarafında ölçülen ledger_entries satırı index'leriyle birlikte yaklaşık 210 byte tuttu: günde ~400 bin hareket yaklaşık 85 MB, yılda ~30 GB eder. Tek instance için sorun değildir, ama büyüme planı gerekir (Adım 9).
Sonuç cümlesi, mülakatta söylemen gereken asıl şey: "Bu yük tek bir PostgreSQL instance'ı için küçük. Asıl zorluk ölçek değil doğruluk: eşzamanlı çekimlerde bakiyenin negatife düşmemesi, tekrarlanan isteklerin iki kez işlenmemesi, iki hesaplı exchange'de deadlock olmaması ve 3rd party hatalarında paranın kaybolmaması."
Over-engineering tuzağı. Bu sayılar için Kafka, sharding, Redis cache, CQRS veya event sourcing şart değildir; her biri yeni bir tutarsızlık kaynağı ve işletim yükü getirir. Ne zaman gerekir: Redis cache, okuma yükü primary'yi zorlarsa ve bakiye için bayat veri kabul edilirse (bankacılıkta çoğu zaman edilmez); Kafka, bildirim/analitik gibi asenkron tüketiciler çoğalınca (o zaman da outbox ile, bkz. Bölüm 13); sharding, yazma throughput'u veya veri boyutu tek primary'nin sınırına yaklaşınca (100M/gün tartışması "Üç seviyede cevap" kısmında). "Şu an gerekmiyor, şu metrik şu eşiği geçerse eklerim" demek senior bir cevaptır.
Adım 3: Mimari: tek deploy, net modül sınırları
"Diğerleri mikroservis gibi" ifadesine rağmen tek bir DB instance'ı var. Bakiye, hareket, idempotency kaydı ve quote aynı transaction içinde yazılmak zorunda. Bunları ayrı servislere bölüp ayrı DB'lere koymak, tek bir ACID transaction'la çözülen problemi saga ve telafiyle çözülen bir probleme dönüştürür (bkz. Bölüm 13). Bu yüzden önerim:
- Modular monolith: Tek deploy edilebilir Spring Boot uygulaması, içinde net sınırlı modüller. Yatayda 2-3 pod (erişilebilirlik için, yük için değil).
- 3rd party kur sağlayıcısı gerçek bir dış servis; ona giden her şey
fx-clientmodülünün arkasında. - Mülakatçı "servisler ayrı olsun" derse: account + ledger + exchange aynı serviste kalır (aynı transaction'ı paylaşırlar), fx-client ayrı bir servis olabilir çünkü sadece kur okur. "Servisler + tek DB" ise her servisin kendi şemasına (schema) sahip olması ve başkasının tablosuna yazmaması kuralıyla kabul edilebilir bir ara adımdır.
Modüller:
| Modül | Sorumluluk | Sahip olduğu tablolar |
|---|---|---|
account | Hesap, bakiye, deposit/withdraw kuralları | accounts |
ledger | Değişmez hareket kaydı, mutabakat sorguları | transactions, ledger_entries |
exchange | Quote üretme, quote'u tek seferlik yürütme | exchange_quotes |
fx-client | 3rd party kur sağlayıcısı; timeout, retry, circuit breaker | Yok (bellek içi son kur) |
flowchart LR
C[Mobil / Web] -->|HTTPS + JWT| GW[API Gateway]
GW --> APP
subgraph APP[account-service: tek deploy, 2-3 pod]
API[REST controller'lar<br/>Idempotency-Key, ProblemDetail]
ACC[account modülü]
LED[ledger modülü]
EXC[exchange modülü]
FXC[fx-client modülü<br/>timeout, retry, CB]
API --> ACC
API --> EXC
ACC --> LED
EXC --> ACC
EXC --> LED
EXC --> FXC
end
APP -->|tek transaction| DB[(PostgreSQL 16<br/>tek primary + standby)]
FXC -->|GET /v1/rates| FX[3rd party kur sağlayıcısı]Modül sınırını kodda paket yapısıyla ve kurallarla (ArchUnit testi veya Spring Modulith) korursun: exchange modülü accounts tablosuna SQL yazmaz, AccountRepository'nin yayınladığı metotları çağırır. Böylece ileride exchange'i ayırmak gerekirse dikiş yeri hazırdır.
Adım 4: Veri modeli
Aşağıdaki DDL bu container'daki PostgreSQL 16'da çalıştırıldı ve bölümdeki bütün eşzamanlılık deneyleri bu şema üzerinde yapıldı.
CREATE TABLE accounts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
balance NUMERIC(19,4) NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT accounts_balance_non_negative CHECK (balance >= 0),
CONSTRAINT accounts_status_valid CHECK (status IN ('ACTIVE', 'BLOCKED', 'CLOSED')),
CONSTRAINT accounts_customer_currency_uq UNIQUE (customer_id, currency)
);
CREATE TABLE exchange_quotes (
id UUID PRIMARY KEY,
customer_id BIGINT NOT NULL,
source_account_id BIGINT NOT NULL REFERENCES accounts (id),
target_account_id BIGINT NOT NULL REFERENCES accounts (id),
source_amount NUMERIC(19,4) NOT NULL CHECK (source_amount > 0),
target_amount NUMERIC(19,4) NOT NULL CHECK (target_amount > 0),
rate NUMERIC(19,8) NOT NULL CHECK (rate > 0),
provider_ref VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT quotes_status_valid CHECK (status IN ('ACTIVE', 'USED')),
CONSTRAINT quotes_accounts_differ CHECK (source_account_id <> target_account_id)
);
CREATE TABLE transactions (
id UUID PRIMARY KEY,
customer_id BIGINT NOT NULL,
idempotency_key VARCHAR(255) NOT NULL,
request_hash CHAR(64) NOT NULL, -- SHA-256(kanonik istek)
type VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL,
account_id BIGINT NOT NULL REFERENCES accounts (id),
amount NUMERIC(19,4) NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL,
quote_id UUID REFERENCES exchange_quotes (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT transactions_type_valid CHECK (type IN ('DEPOSIT', 'WITHDRAWAL', 'EXCHANGE')),
CONSTRAINT transactions_status_valid CHECK (status IN ('PENDING', 'COMPLETED', 'FAILED')),
CONSTRAINT transactions_idem_uq UNIQUE (customer_id, idempotency_key),
CONSTRAINT transactions_quote_uq UNIQUE (quote_id) -- bir quote tek bir işleme
);
CREATE TABLE ledger_entries (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
transaction_id UUID NOT NULL REFERENCES transactions (id),
account_id BIGINT NOT NULL REFERENCES accounts (id),
amount NUMERIC(19,4) NOT NULL CHECK (amount <> 0), -- işaretli: çekim negatif
currency CHAR(3) NOT NULL,
balance_after NUMERIC(19,4) NOT NULL CHECK (balance_after >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ledger_entries_account_idx ON ledger_entries (account_id, id);
CREATE INDEX ledger_entries_transaction_idx ON ledger_entries (transaction_id);
CREATE INDEX transactions_pending_idx ON transactions (updated_at) WHERE status = 'PENDING';
CREATE FUNCTION reject_ledger_change() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'ledger_entries is append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER ledger_entries_append_only
BEFORE UPDATE OR DELETE ON ledger_entries
FOR EACH ROW EXECUTE FUNCTION reject_ledger_change();
Kararların gerekçesi:
CHECK (balance >= 0)son savunma hattıdır, çözüm değildir. Koşulsuz birbalance - 200güncellemesi 100 TL'lik hesaptaviolates check constraint "accounts_balance_non_negative"ile reddedildi. Ama Adım 5'te göreceğin gibi lost update bakiyeyi negatife düşürmeden de para kaybettirir; constraint onu yakalamaz.transactionshem iş kaydı hem idempotency kaydıdır.UNIQUE (customer_id, idempotency_key)anahtarı müşteri kapsamında benzersiz yapar (başka müşterinin anahtarı tahmin edilerek onun cevabı görülemez). Ayrı biridempotency_keystablosu da kurulabilir (bkz. Bölüm 13); burada tekrar gelen isteğin cevabı zatentransactions+ledger_entries'ten yeniden üretilebildiği için ayrı birresponse_bodysaklamaya gerek yok.request_hash: Aynı anahtar farklı bir gövdeyle gelirse (istemci hatası) ilk cevabı sessizce dönmek yerine422dönmek için.ledger_entries.balance_after: Hareket ekstresinde her satırın sonrasındaki bakiyeyi gösterir, mutabakatta da "hangi hareketten sonra kaydı" sorusunu cevaplar.- Append-only: Trigger, yanlışlıkla yazılmış bir
UPDATE'i reddetti (ERROR: ledger_entries is append-only). Daha güçlüsü uygulama rolünden yetkiyi almaktır:REVOKE UPDATE, DELETE, TRUNCATE ON ledger_entries FROM bank_app;sonrasıbank_appile yapılanDELETEpermission denied for table ledger_entriesaldı. İkisi birlikte kullanılır. exchange_quotes.status+expires_at: Quote'un tek seferlik kullanımı ve süresi DB'de tek bir koşullu UPDATE ile kontrol edilir (Adım 6).version: Koşullu UPDATE yaklaşımında şart değil; optimistic locking veETagiçin hazır tutuluyor, ayrıca her güncellemede artması debug'da işe yarar.NUMERIC(19,4): 15 tam basamak ve 4 ondalık; para biriminin gerçek scale'iniMoneyzorlar. KurNUMERIC(19,8): kurlar genelde 4-6 anlamlı ondalıkla gelir ve kur erken yuvarlanmamalıdır.
Mutabakat sorguları (her ikisi de bu şemada çalıştırıldı; bakiye elle bozulduğunda ilk sorgu farkı yakaladı):
-- 1) Bakiye = hareketlerin toplamı mı?
SELECT a.id, a.balance, COALESCE(SUM(l.amount), 0) AS ledger_balance
FROM accounts a
LEFT JOIN ledger_entries l ON l.account_id = a.id
GROUP BY a.id, a.balance
HAVING a.balance <> COALESCE(SUM(l.amount), 0);
-- 2) Her tamamlanmış işlemin hareket sayısı tipine uygun mu?
SELECT t.id, t.type, t.status, count(l.id) AS entries
FROM transactions t
LEFT JOIN ledger_entries l ON l.transaction_id = t.id
WHERE t.status = 'COMPLETED'
GROUP BY t.id, t.type, t.status
HAVING count(l.id) <> CASE t.type WHEN 'EXCHANGE' THEN 2 ELSE 1 END;
Bu sorgular boş dönmelidir; dönmüyorsa alarm üretilir. Tablo büyüdükçe sadece son 24 saatte değişen hesaplar için çalıştırılır.
Adım 5: Race condition'lar ve çözümleri
Mülakatçının "race condition'ı nasıl engellersin?" sorusunun cevabı tek bir araç değil, her yarış için doğru araçtır. Önce problemi göster, sonra çözümleri karşılaştır.
Problem: oku, Java'da kontrol et, yaz
En doğal ama yanlış kod:
@Transactional
public void withdraw(long accountId, BigDecimal amount) {
Account account = accountRepository.findById(accountId).orElseThrow(); // SELECT
if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(...);
}
account.setBalance(account.getBalance().subtract(amount)); // commit'te UPDATE
}
@Transactional burada hiçbir şeyi korumaz. PostgreSQL'in varsayılan izolasyonu READ COMMITTED'dır; düz bir SELECT kilit almaz. İki istek aynı anda 100 TL okur, ikisi de 70 TL'nin yettiğine karar verir, ikisi de balance = 30 yazar. İki psql oturumuyla birebir tekrarlandı: iki 70 TL'lik çekim de başarılı oldu, bakiye 30 kaldı. Müşteriye 140 TL ödendi, hesaptan 70 TL düştü.
Daha çarpıcısı yük altında: bakiye 500 TL olan hesaba 50 eşzamanlı istemciden toplam 100 adet 10 TL'lik çekim gönderildi (okuma ile yazma arasına 10 ms'lik bir iş mantığı gecikmesi koyarak):
naive (oku-kontrol et-yaz): 100 çekimin 100'ü başarılı, ledger toplamı -1.000 TL, bakiye 480 TL
koşullu UPDATE: 50 başarılı, 50 reddedildi, ledger toplamı -500 TL, bakiye 0 TL
Naive sürümde banka 1.000 TL ödedi, bakiye sadece 20 TL azaldı: 980 TL'lik fark. Dikkat: bakiye hiç negatife düşmediği için CHECK (balance >= 0) bir kez bile tetiklenmedi. Lost update, constraint'lerin göremediği bir hatadır.
(a) Koşullu atomik UPDATE: ilk tercih
Kontrolü ve değişikliği tek bir SQL ifadesine koy:
UPDATE accounts
SET balance = balance - :amount, version = version + 1, updated_at = now()
WHERE id = :id AND customer_id = :customerId AND currency = :currency
AND status = 'ACTIVE' AND balance >= :amount
RETURNING balance;
Etkilenen satır 1 ise para düştü ve RETURNING yeni bakiyeyi verir; 0 ise kurallardan biri tutmadı. Neden güvenli: UPDATE satırı kilitler. Aynı satırı güncellemek isteyen ikinci transaction bekler; READ COMMITTED'da birinci commit olunca satırın en güncel sürümünü alır ve WHERE koşulunu onun üzerinde yeniden değerlendirir. Doğrulama: oturum A 100 TL'lik hesaptan 70 düşüp 2 saniye commit etmeden bekledi; oturum B aynı UPDATE'i gönderdi, 1,7 saniye bekledi ve A commit olunca UPDATE 0 aldı. Bakiye 30 kaldı.
Aynı UPDATE REPEATABLE READ'de çalıştırıldığında B beklemenin sonunda yeniden değerlendirme yapmadı, could not serialize access due to concurrent update (SQLSTATE 40001) aldı. Yani bu desen READ COMMITTED varsayımına dayanır; izolasyon seviyesini yükseltirsen retry yazman gerekir (bkz. Bölüm 10).
Bu yaklaşımın güzelliği: kilit sadece UPDATE'ten commit'e kadar tutulur (milisaniyeler), uygulama kodunda kilit yoktur, retry yoktur, çoklu pod'da kendiliğinden doğru çalışır.
(b) SELECT ... FOR UPDATE: pessimistic lock ve kilit sırası
Kural tek bir SQL koşuluna sığmıyorsa (günlük limit, hesap durumu, birden fazla hesap, domain nesnesinde çalışan iş kuralları) satırı önce kilitleyip sonra karar verirsin:
BEGIN;
SELECT id, customer_id, currency, balance, status
FROM accounts
WHERE id = :id
FOR UPDATE;
-- Java'da domain kuralları, sonra:
UPDATE accounts SET balance = :newBalance, version = version + 1 WHERE id = :id;
COMMIT;
Doğrulama: A satırı FOR UPDATE ile kilitleyip 1,5 saniye bekledi ve 700 TL düştü; B'nin FOR UPDATE'i 1,2 saniye bekledi ve 1.000 değil, güncel 300 TL'yi gördü.
Exchange iki satır kilitler: TRY hesabından düşer, USD hesabına ekler. Aynı müşterinin TRY→USD ve USD→TRY işlemleri aynı anda gelirse biri önce TRY'yi, diğeri önce USD'yi kilitler ve birbirlerini beklerler. İki oturumla tekrarlandı:
ERROR: deadlock detected
DETAIL: Process 624 waits for ShareLock on transaction 766; blocked by process 626.
Process 626 waits for ShareLock on transaction 765; blocked by process 624.
PostgreSQL bir kilit beklemesi deadlock_timeout (varsayılan 1 sn) süresini aşınca döngü arar ve transaction'lardan birini (SQLSTATE 40P01) düşürür. Çözüm tutarlı kilit sırası: iki hesabı her zaman küçük id'den başlayarak kilitle.
SELECT id, customer_id, currency, balance, status
FROM accounts
WHERE id IN (:first, :second) AND customer_id = :customerId
ORDER BY id
FOR UPDATE;
EXPLAIN bu sorgu için LockRows -> Sort -> ... planı gösterdi: sıralama kilitlemeden önce yapılır, satırlar sırayla kilitlenir. PostgreSQL dokümanı da ORDER BY'ın kilitleme maddesinden önce uygulandığını söyler. Daha açık olmak istersen iki ayrı SELECT ... FOR UPDATE'i Java'da id sırasıyla çalıştırırsın.
Yük altındaki fark (10 istemci, 10 saniye, rastgele yönde TRY↔USD exchange):
| Başarılı işlem | Deadlock ile düşen | tps | |
|---|---|---|---|
| Kilit sırası yok (önce kaynak, sonra hedef) | 6 | 30 | ~0,5 |
ORDER BY id FOR UPDATE ile sıralı | 2.227 | 0 | ~221 |
Sırasız sürümde throughput neredeyse sıfıra iniyor, çünkü her deadlock tespiti için 1 saniye bekleniyor ve o sırada diğer istekler de kuyrukta. Deadlock'ları retry ile maskelemek yerine sırayı düzeltmek gerekir. Java tarafındaki karşılığı (iki ReentrantLock'ı sırayla almak) Bölüm 5'te.
FK kilidi sıralamayı bozabilir. transactions ve ledger_entries accounts'a FK ile bağlıdır; bir satır eklemek, referans verilen hesap satırına commit'e kadar FOR KEY SHARE kilidi koyar. FOR UPDATE bu kilitle çakışır, FOR NO KEY UPDATE çakışmaz. Adım 7'deki exchange önce idempotency kaydını (kaynak hesaba FK) ekleyip sonra iki hesabı sıralı kilitler: TRY→USD işlemi USD satırında, USD→TRY işlemi TRY satırında karşı tarafın KEY SHARE kilidine takılır ve sıraya rağmen deadlock olur. Bu ortamda 10 istemciyle tekrarlandı: FOR UPDATE ile eşzamanlı çiftler 40P01 ile düştü; FOR NO KEY UPDATE ile 5 saniyede ~2.800 işlem, sıfır hata. Sadece anahtar olmayan kolon (bakiye) güncellendiği için doğru kilit FOR NO KEY UPDATE'tir; düz UPDATE de zaten bu kilidi alır. Alternatif: hesapları, onlara FK veren her INSERT'ten önce kilitlemek.
(c) Optimistic locking: @Version ve retry
Kilit tutmadan oku; yazarken versiyonun değişmediğini şart koş:
UPDATE accounts SET balance = :newBalance, version = version + 1
WHERE id = :id AND version = :expectedVersion;
Aynı version = 5 koşuluyla iki UPDATE'ten ilki UPDATE 1, ikincisi UPDATE 0 döndü. JPA'da @Version alanı bunu otomatik yapar ve çakışmada ObjectOptimisticLockingFailureException fırlatır (bkz. Bölüm 8). Doğrudur ama çekişmeye duyarlıdır. 500 TL'lik hesaba 50 istemciden 100 çekim, retry olmadan: 100 denemeden sadece 7'si başarılı oldu, 93'ü çakıştı. Bakiye doğru kaldı (430 TL, 7 hareket), ama müşterilerin 93'ü haksız yere hata aldı. Retry eklersen her çakışma tüm transaction'ı (yeni bir transaction'da) baştan çalıştırır; tek bir sıcak hesapta bu bir retry fırtınasıdır.
Ne zaman doğru araç: okuma ile yazma arasının uzun olduğu ve çakışmanın nadir olduğu akışlar. Örneğin kullanıcının hesap ayarlarını düzenleyip kaydetmesi (ETag/If-Match, bkz. Bölüm 18). Bakiye gibi sık güncellenen sayaçlar için değil.
(d) SERIALIZABLE + retry
En yüksek izolasyonda naive "oku-kontrol et-yaz" kodu da doğru hale gelir: PostgreSQL iki transaction'ın seri bir sırayla açıklanamayacak şekilde iç içe geçtiğini tespit edip birini 40001 ile düşürür. İki oturumla: ikisi de 100 okudu, ikisi de 30 yazmaya çalıştı, ikincisi could not serialize access due to concurrent update aldı. Bu hata aynı satırı güncelleyen transaction'lardaki "ilk güncelleyen kazanır" kontrolünden gelir (REPEATABLE READ'de de olur); SSI'ın asıl katkısı farklı satırlara yazan transaction'lardaki write skew'i yakalamaktır.
Yük altında maliyeti görünür: aynı 100 çekim, retry'sız: 3 başarılı, 97 serialization hatası. pgbench --max-tries=50 ile retry'lı: sonuç doğru (50 başarılı, bakiye 0), ama 77 transaction retry edildi ve toplam 1.905 retry yapıldı. SERIALIZABLE, kuralı tek bir satıra indiremediğin durumlar için (iki hesabın toplamı, write skew, bkz. Bölüm 10) değerlidir; tek satırlık bakiye düşümünde koşullu UPDATE'ten pahalıdır.
(e) synchronized, ReentrantLock, ConcurrentHashMap: neden yanlış
@Transactional
public synchronized void withdraw(long accountId, BigDecimal amount) { ... } // YANLIŞ
Üç ayrı sebeple yanlıştır:
- Çoklu pod.
synchronizedtek bir JVM içindeki thread'leri sıralar. 3 pod'da 3 ayrı monitor vardır; iki pod aynı hesaptan aynı anda çeker. - Proxy ve commit sırası.
@Transactionalbir proxy'dir; transaction metot çağrısından önce açılır, metot döndükten sonra commit olur.synchronizedmetot bittiğinde kilidi bırakır; commit henüz olmamıştır. Sıradaki thread kilidi alır, kendi transaction'ında eski (commit edilmemiş değişikliği görmeyen) bakiyeyi okur. Tek pod'da bile lost update olur. - JPA entity'leri. Her transaction aynı satır için farklı bir entity instance'ı yükler; entity üzerinde
synchronizedfarklı nesnelere kilit alır.ConcurrentHashMap<Long, Lock>ile hesap başına kilit tutmak da 1. maddeye takılır ve map'ten kilit temizliği ayrı bir problemdir.
Doğruluk paylaşılan durumun olduğu yerde, yani veritabanında sağlanmalıdır. JVM içi kilitlerin bu sistemdeki tek meşru kullanımı yan etkisiz işlerdir (örneğin FxRateClient'taki son kur önbelleği için ConcurrentHashMap).
Mülakatçı "DB yok, in-memory yaz" derse kurallar değişir ve bunu sesli söylersin: tek hesaplı işlemde ConcurrentHashMap.compute(accountId, ...) (aynı anahtar için atomik), iki hesaplı işlemde hesap başına ReentrantLock ve id sırasıyla kilitleme (bkz. Bölüm 5). Ardından "bu sadece tek JVM'de doğrudur; production'da kilidi veritabanına taşırım" cümlesini eklersin.
(f) Çift tıklama ve retry: idempotency key
Lost update'ten farklı bir yarış: aynı mantıksal istek iki kez gelir. Kullanıcı butona iki kez basar, mobil uygulama timeout sonrası tekrar dener, gateway 502 sonrası retry eder. Koşullu UPDATE burada işe yaramaz: iki istek de geçerli bir çekimdir ve bakiye yetiyorsa ikisi de başarılı olur.
İstemci her mantıksal işlem için bir UUID üretir, Idempotency-Key header'ında gönderir ve retry'larda aynısını kullanır. Sunucu tarafında akış:
BEGIN;
-- 1) Anahtarı sahiplen. Aynı anahtar varsa 0 satır.
INSERT INTO transactions (id, customer_id, idempotency_key, request_hash, type, status,
account_id, amount, currency)
VALUES (:txId, :customerId, :key, :hash, 'WITHDRAWAL', 'COMPLETED', :accountId, :amount, :currency)
ON CONFLICT (customer_id, idempotency_key) DO NOTHING;
-- 2) 1 satır eklendiyse: koşullu UPDATE + ledger insert. 0 ise: kayıtlı sonucu oku ve dön.
COMMIT;
Dört alt durum ve PostgreSQL'de doğrulanan davranışları:
| Durum | Ne oluyor | Cevap |
|---|---|---|
| Aynı key, ilk istek çoktan bitti | INSERT 0 0; kayıt okunur, hash aynı | İlk cevap aynen (201, Idempotent-Replayed: true) |
| Aynı key, farklı body | INSERT 0 0; hash farklı | 422 Unprocessable Content |
| Aynı key, eşzamanlı | İkinci INSERT unique index'te birincinin bitmesini bekler (ölçülen: ~1,2 sn, birincinin süresi kadar). Birinci commit olursa INSERT 0 0, aynı transaction'daki sonraki SELECT kaydı görür. Birinci rollback olursa (ör. yetersiz bakiye) ikinci INSERT 0 1 alır ve işi kendisi yapar | Commit: ilk cevap. Rollback: kendi sonucu. (IETF Idempotency-Key taslağı burada 409 önerir; beklemek de savunulabilir.) |
| Farklı key, aynı quote | ON CONFLICT sadece belirtilen kısıtı kapsar; transactions_quote_uq ihlali duplicate key value violates unique constraint hatası olarak gelir | 409 (quote kullanılmış) |
Neden ON CONFLICT DO NOTHING ve "önce SELECT, yoksa INSERT" değil: ikincisi check-then-act yarışıdır; iki istek de "yok" görür. Düz INSERT + hata yakalamak da çalışır (ikinci istek beklemenin sonunda 23505 aldı) ama PostgreSQL'de hata transaction'ı abort eder; savepoint kullanmadıkça aynı transaction'da kayıtlı cevabı okuyamazsın.
Bu tasarımın iki bilinçli kararı:
- Reddedilen istek kaydedilmez. Yetersiz bakiye exception'ı transaction'ı geri alır; idempotency kaydı da gider. Aynı key ile tekrar gelen istek yeniden değerlendirilir; arada para yatmışsa başarılı olabilir. İlk deneme hiçbir yan etki üretmediği için bu güvenlidir. Stripe'ın API v1'i ise uç nokta çalışmaya başladıktan sonraki sonuçları (500 dahil) saklar. İkisi de savunulabilir; hangisini seçtiğini söylemen yeter.
- İzolasyon READ COMMITTED olmalı. Aynı senaryo REPEATABLE READ'de denendiğinde, çakışan satır transaction'ın snapshot'ında görünmediği için
ON CONFLICT DO NOTHINGsessizce geçmek yerinecould not serialize access due to concurrent updateverdi.
Karşılaştırma
| Yöntem | Doğruluk | Çekişmede davranış (ölçülen) | Retry gerekir mi | Ne zaman |
|---|---|---|---|---|
| Naive oku-kontrol-yaz | Yanlış | 100/100 "başarılı", 980 TL kayıp | - | Asla |
| Koşullu atomik UPDATE | Doğru | 50 başarılı, 50 net red, 0 retry | Hayır | Tek satır, kural SQL'e sığıyor: bakiye düşümü |
FOR (NO KEY) UPDATE (sıralı) | Doğru | Bekleyerek sıralanır; sırasızsa deadlock | Hayır (deadlock'ta evet) | Çok satır veya kural domain kodunda: exchange, transfer, limit |
Optimistic @Version | Doğru | 100 denemeden 7 başarılı (retry'sız) | Evet | Nadir çakışma, uzun okuma-yazma arası: form düzenleme |
| SERIALIZABLE | Doğru | Retry'sız 3/100; retry ile 1.905 retry | Evet | Kural birden fazla satırın birlikte durumuna bağlı: write skew |
| JVM kilidi | Yanlış (çoklu pod, commit sonrası) | - | - | Sadece yan etkisiz bellek içi işler |
| Idempotency key | Tekrarı önler; lost update'i önlemez | Eşzamanlı aynı key: ikinci bekler, ilk cevabı alır | Hayır | Her para hareketi endpoint'i, diğer yöntemlerle birlikte |
Son satır önemli: idempotency ve eşzamanlılık kontrolü birbirinin alternatifi değildir. Biri "aynı istek iki kez", diğeri "farklı iki istek aynı anda" problemini çözer.
Adım 6: Exchange akışı ve 3rd party servis
Kur almak ile işlemi yürütmek ayrı adımlardır
Tek bir POST /exchanges içinde "kuru al, hesapla, düş, ekle" yapmak iki problem yaratır: müşteri hangi kurla işlem yaptığını işlemden önce göremez, ve kur çağrısı ya transaction'ın içine girer ya da kurla bakiye değişikliği arasında zaman açılır. Bankacılık ve döviz uygulamalarında yaygın model iki adımdır:
- Quote:
POST /api/v1/accounts/{id}/exchange-quoteskuru 3rd party'den alır, hedef tutarı hesaplar, teklifiexchange_quotestablosunaexpires_at = now() + 30 snile yazar ve müşteriye "340,00 TRY → 9,89 USD, 30 sn geçerli" der. Bakiye değişmez. - Execute: Müşteri onaylar;
POST /api/v1/accounts/{id}/exchanges{quoteId}veIdempotency-Keyile gelir. Bu adımda dış çağrı yoktur: kilitlenmiş kur ve tutarlar quote'tan okunur, her şey tek bir DB transaction'ında olur.
sequenceDiagram
participant M as Mobil
participant S as account-service
participant FX as Kur sağlayıcısı
participant DB as PostgreSQL
M->>S: POST /exchange-quotes {targetAccountId, 340 TRY}
S->>DB: Hesapları oku (sahiplik, para birimi)
S->>FX: GET /v1/rates?from=TRY&to=USD (timeout 1 sn)
FX-->>S: rate 0.0291, providerRef
S->>DB: INSERT exchange_quotes (expires_at = now() + 30 sn)
S-->>M: 201 {quoteId, 9.89 USD, expiresAt}
M->>S: POST /exchanges {quoteId} + Idempotency-Key
S->>DB: BEGIN
S->>DB: INSERT transactions ON CONFLICT DO NOTHING
S->>DB: UPDATE exchange_quotes SET status='USED' WHERE status='ACTIVE' AND expires_at > now()
S->>DB: SELECT ... WHERE id IN (TRY, USD) ORDER BY id FOR NO KEY UPDATE
S->>DB: UPDATE iki bakiye + INSERT iki ledger kaydı
S->>DB: COMMIT
S-->>M: 201 {transactionId, balanceAfter}Quote'un tek seferlik tüketimi yine bir koşullu UPDATE'tir. Doğrulama: A quote'u USED yapıp commit etmeden bekledi; B'nin aynı UPDATE'i bekledi ve UPDATE 0 aldı. Süresi geçmiş quote için de UPDATE 0 döndü. Süre kontrolü uygulama saatiyle değil now() ile, yani DB saatiyle yapılır; expires_at da insert sırasında DB'de hesaplanır (now() + (:ttl * interval '1 second')). Böylece pod'lar arası saat kayması sonucu etkilemez. now() transaction başlangıç zamanıdır; kısa transaction'larda bu fark önemsizdir.
Kurun bedeli: quote 30 saniye boyunca bankayı o kura bağlar. Piyasa bu sürede aleyhe hareket ederse farkı banka öder. TTL'in süresi, spread (alış-satış farkı) ve tutar limitleri bu yüzden hazine biriminin kararıdır; mülakatta "TTL'i ve spread'i iş birimiyle belirlerim" demek yeterli.
DB transaction'ı içinde HTTP çağrısı yapma
@Transactional
public Outcome exchange(...) {
accounts.lockInIdOrder(...); // satır kilitleri ve bir DB bağlantısı alındı
FxRate rate = fxRateClient.rate(...); // YANLIŞ: 1 sn'lik read timeout boyunca ikisi de tutulur
...
}
Bu kodda kur sağlayıcısı yavaşladığı an üç şey olur: (1) DB bağlantısı HTTP cevabı beklenirken havuzdan alınmış durur; 10 bağlantılık havuz 10 yavaş istekle tükenir ve deposit/withdraw gibi ilgisiz istekler de Connection is not available hatası alır. (2) Hesap satırlarının kilitleri saniyelerce tutulur; aynı hesaba gelen her işlem bekler. (3) Little's law: transaction süresi 5 ms'den 1 sn'ye çıkınca aynı yük için 200 kat bağlantı gerekir. Kural: dış çağrı transaction'dan önce (quote) veya sonra (aşağıdaki PENDING akışı) yapılır, asla içinde. Spring'de bunun anlamı: dış çağrı yapan metot @Transactional olmaz; transaction'ı kısa, ayrı bir bean metodunda veya TransactionTemplate ile açarsın (bkz. Bölüm 8).
Kur servisi için dayanıklılık
Kur çağrısı bir GET'tir ve yan etkisizdir; bu yüzden retry güvenlidir. Katmanlar (ayrıntılar Bölüm 14'te):
- Timeout: connect 500 ms, read 1 sn (sağlayıcının p99'u ~200 ms varsayımıyla). Timeout'suz bir çağrı Tomcat thread'lerini sonsuza kadar bekletebilir.
- Retry: En fazla 1 tekrar, kısa backoff ve jitter ile; sadece ağ hataları ve 5xx için. 4xx (desteklenmeyen para birimi çifti) tekrar denenmez.
CallNotPermittedException(devre açık) tekrar denenmez. - Circuit breaker: Hata veya yavaş çağrı oranı %50'yi geçerse devre açılır; açıkken çağrı hiç yapılmaz, istek hızla düşer, sağlayıcıya nefes aldırılır.
- Fallback: bayat kur kabul edilir mi? Bu bir iş kararıdır. Seçenekler: (1) hiç kabul etme,
503+Retry-Afterdön; (2) son başarılı kuru belirli bir yaşa kadar (ör. 60 sn) kullan; (3) bayat kuru daha geniş bir spread ile kullan. Bankanın kur riski aldığı için varsayılanım (2)'nin sıkı bir sınırla uygulanması veya doğrudan (1). Bayat kur ile verilen quote'un da 30 sn TTL'i olduğunu, yani toplam bayatlığın 90 sn'ye çıkabildiğini unutma. - Önbellek: Kurlar saniyeler mertebesinde değişir. Para birimi çifti başına birkaç saniyelik bir önbellek (Caffeine
expireAfterWrite) sağlayıcıya giden istek sayısını tepe trafikte saniyede onlarca çağrıdan çift sayısı kadar çağrıya indirir. Bu, fallback için tutulan "son bilinen kur"dan farklıdır; önbellek normal yolda kullanılır.
Sağlayıcı çevrimi gerçekten yürütüyorsa: PENDING, outbox ve mutabakat
Varsayımı değiştirelim: sağlayıcı sadece kur vermiyor, TRY'yi satıp USD'yi alan işlemi (trade) yürütüyor. Artık execute adımında bir dış yan etki var ve klasik belirsiz sonuç problemi doğuyor: "çevir" isteği gitti, timeout aldık. İşlem gerçekleşti mi?
Bu durumda tek transaction yetmez. Akış durum makinesine dönüşür:
stateDiagram-v2
[*] --> PENDING: Tx1 - TRY düşülür (ledger -340), transactions PENDING
PENDING --> COMPLETED: Sağlayıcı "gerçekleşti" / Tx2 - USD eklenir (ledger +9.89)
PENDING --> FAILED: Sağlayıcı kesin red / Tx2 - TRY iade edilir (ledger +340)
PENDING --> PENDING: Timeout veya 5xx / aynı referansla tekrar sor
note right of PENDING: N dakika belirsiz kalırsa alarm ve mutabakat
COMPLETED --> [*]
FAILED --> [*]- Tx1 (kısa, yerel): Idempotency kaydı
PENDINGolarak yazılır, quote tüketilir, kaynak hesaptan koşullu UPDATE ile düşülür ve ledger'a −340 yazılır. Para müşterinin kullanabileceği bakiyeden çıkmıştır ama hedefe henüz eklenmemiştir. Müşteriye202 Accepted+ işlem durumunu sorgulayacağıLocationdönülür (bkz. Bölüm 18, uzun süren işlemler). - Dış çağrı (transaction dışında): Sağlayıcıya
POST /tradesbizimtransactions.id'mizi idempotency key / client reference olarak taşıyarak gider. Sağlayıcı bunu desteklemiyorsa belirsizlik yönetilemez; bu, sağlayıcı seçiminde sorulacak ilk sorudur. - Sonuç:
- Başarılı → Tx2: hedef hesaba ekle, ledger +9,89,
UPDATE transactions SET status = 'COMPLETED' WHERE id = :id AND status = 'PENDING'. Durum geçişi de koşullu UPDATE'tir; böylece aynı sonucu işleyen iki worker çift ekleme yapamaz. - Kesin red (4xx, "yetersiz likidite") → Tx2: kaynak hesaba iade, ledger +340 (telafi kaydı; −340 silinmez),
FAILED. - Timeout, bağlantı hatası, 5xx → hiçbir şey yapma,
PENDINGkalsın. Ne başarı ne hata say.
- Başarılı → Tx2: hedef hesaba ekle, ledger +9,89,
- Kurtarma worker'ı:
PENDING'de belirli süreyi aşmış kayıtları alır ve sağlayıcıya aynı referansla tekrar sorar (durum sorgulama endpoint'i veya aynı idempotency key ile tekrar gönderim):
UPDATE transactions
SET updated_at = now() -- 30 sn'lik kira (lease)
WHERE id IN (SELECT id FROM transactions
WHERE status = 'PENDING' AND updated_at < now() - interval '30 seconds'
ORDER BY updated_at
LIMIT 50
FOR UPDATE SKIP LOCKED)
RETURNING id;
SKIP LOCKED birden fazla pod'daki worker'ların aynı kaydı almasını ve birbirini beklemesini önler (bkz. Bölüm 10). Satır kilidi sadece bu kısa transaction'da tutulur: worker kayıtları updated_at'i ileri alarak 30 saniyeliğine sahiplenir, commit eder ve sağlayıcıyı transaction dışında çağırır. Kilidi HTTP çağrısı boyunca tutmak, yukarıdaki "transaction içinde HTTP" hatasının aynısı olurdu. Kira dolup iki worker aynı kaydı işlese bile Tx2'deki WHERE status = 'PENDING' çift işlemeyi engeller. Dikkat edersen PENDING satırı burada bir outbox gibi çalışır: "yapılacak dış iş" iş verisiyle aynı transaction'da kalıcı hale gelir, pod çökse bile kaybolmaz. Sağlayıcıya Kafka üzerinden gidiliyorsa aynı rolü ayrı bir outbox_event tablosu üstlenir (bkz. Bölüm 13).
- Mutabakat (reconciliation): Gün sonunda sağlayıcının işlem raporu bizim
COMPLETED/FAILED/PENDINGkayıtlarımızla karşılaştırılır. Sınıflar: eşleşen, bizde var sağlayıcıda yok, sağlayıcıda var bizde yok, tutar/kur farkı. Her sınıfın bir aksiyonu ve sahibi olur. Belirsiz kalan son kayıtlar burada kapanır.
Bu, ödeme sistemlerindeki saga'nın (bkz. Bölüm 13) bu case'e uygulanmış halidir. Mülakatta sağlayıcının rolünü netleştirmeden bu karmaşıklığa girmemek, netleştirince de "sağlayıcı yürütüyorsa tek transaction yetmez; PENDING durum, idempotent dış çağrı, kurtarma worker'ı ve mutabakat gerekir" demek beklenen olgunluktur.
Adım 7: Spring Boot kodu
Kod Spring Boot 3.5, Java 21 ve JdbcClient (Spring Framework 6.1+) ile yazıldı. JPA yerine JdbcClient seçmemin sebebi: bu case'in kalbi olan koşullu UPDATE ... RETURNING, INSERT ... ON CONFLICT ve FOR UPDATE SQL'leri açıkça görünsün. Spring Data JPA ile aynı şey @Modifying @Query ile yapılır; o alternatif bölümün sonunda. Aşağıdaki sınıfların domain kısmı (Money, FxConversion, Account, exception hiyerarşisi) bu container'da javac ile derlenip çalıştırıldı; Spring'e bağlı sınıflar, kullandıkları Spring API imzalarının birebir stub'larına karşı derlendi (Maven Central erişimi olmadığı için gerçek bağımlılıklarla derlenemedi). Import satırları yer kazanmak için çıkarıldı.
Paketler: domain (Spring'siz), account, exchange, fx, web.
Domain: hesap kuralları ve exception'lar
Account (id, customerId, Money balance, status) kuralları bellekte çalıştıran saf bir domain sınıfıdır: deposit aktiflik, pozitiflik ve para birimini; withdraw ek olarak yeterli bakiyeyi kontrol edip ilgili domain exception'ını fırlatır. Deposit/withdraw'ın asıl yolunda kullanılmaz (orada kural koşullu UPDATE'tedir); iki yerde kullanılır: exchange'de kilitlenmiş satırlar üzerinde kuralları çalıştırmak ve UPDATE 0 satır döndüğünde "hangi kural bozuldu?" sorusunu cevaplamak.
Domain exception'ları HTTP bilmez; sealed hiyerarşi, web katmanındaki switch'in eksik dalı derleme anında yakalamasını sağlar:
public abstract sealed class DomainException extends RuntimeException
permits AccountNotFoundException, QuoteNotFoundException,
InsufficientFundsException, AccountNotActiveException, CurrencyMismatchException,
InvalidAmountException, IdempotencyKeyReusedException,
QuoteNotUsableException, ConcurrentUpdateException {
private final String code;
protected DomainException(String code, String message) {
super(message);
this.code = code;
}
public String code() {
return code;
}
}
Alt tipler aynı kalıpta, super(code, message) çağıran final sınıflardır: InsufficientFundsException, AccountNotFoundException, QuoteNotFoundException (404); QuoteNotUsableException, ConcurrentUpdateException (409); AccountNotActiveException, CurrencyMismatchException, InvalidAmountException, IdempotencyKeyReusedException (422).
Repository'ler
public record AccountRow(long id, long customerId, String currency, BigDecimal balance, String status) {
public Account toDomain() {
return new Account(id, customerId,
new Money(balance, Currency.getInstance(currency)),
Account.Status.valueOf(status));
}
}
@Repository
public class AccountRepository {
private final JdbcClient jdbc;
public AccountRepository(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public Optional<AccountRow> findOwned(long id, long customerId) {
return jdbc.sql("""
SELECT id, customer_id, currency, balance, status
FROM accounts
WHERE id = :id AND customer_id = :customerId
""")
.param("id", id)
.param("customerId", customerId)
.query(AccountRow.class)
.optional();
}
/** Kontrol ve düşüm tek ifadede. Boş dönerse hiçbir satır değişmemiştir. */
public Optional<BigDecimal> debitIfSufficient(long id, long customerId, Money amount) {
return jdbc.sql("""
UPDATE accounts
SET balance = balance - :amount, version = version + 1, updated_at = now()
WHERE id = :id AND customer_id = :customerId AND currency = :currency
AND status = 'ACTIVE' AND balance >= :amount
RETURNING balance
""")
.param("amount", amount.amount())
.param("id", id)
.param("customerId", customerId)
.param("currency", amount.currency().getCurrencyCode())
.query(BigDecimal.class)
.optional();
}
public Optional<BigDecimal> credit(long id, long customerId, Money amount) {
return jdbc.sql("""
UPDATE accounts
SET balance = balance + :amount, version = version + 1, updated_at = now()
WHERE id = :id AND customer_id = :customerId AND currency = :currency
AND status = 'ACTIVE'
RETURNING balance
""")
.param("amount", amount.amount())
.param("id", id)
.param("customerId", customerId)
.param("currency", amount.currency().getCurrencyCode())
.query(BigDecimal.class)
.optional();
}
/**
* İki hesabı her zaman küçük id'den başlayarak kilitler: deadlock'u önleyen kural.
* NO KEY UPDATE: önceden eklenen FK'li satırların KEY SHARE kilidiyle çakışmaz.
*/
public List<AccountRow> lockInIdOrder(long customerId, long firstId, long secondId) {
return jdbc.sql("""
SELECT id, customer_id, currency, balance, status
FROM accounts
WHERE id IN (:first, :second) AND customer_id = :customerId
ORDER BY id
FOR NO KEY UPDATE
""")
.param("first", firstId)
.param("second", secondId)
.param("customerId", customerId)
.query(AccountRow.class)
.list();
}
/** Sadece satır kilidi bu transaction'dayken çağrılır (lockInIdOrder sonrası). */
public void updateBalance(long id, BigDecimal newBalance) {
int rows = jdbc.sql("""
UPDATE accounts
SET balance = :balance, version = version + 1, updated_at = now()
WHERE id = :id
""")
.param("balance", newBalance)
.param("id", id)
.update();
if (rows != 1) {
throw new IllegalStateException("Kilitli hesap güncellenemedi: " + id);
}
}
}
TransactionRepository idempotency'nin iki yarısını taşır: anahtarı sahiplenmek ve tekrar gelen isteğe ilk cevabı yeniden üretmek.
@Repository
public class TransactionRepository {
private final JdbcClient jdbc;
public TransactionRepository(JdbcClient jdbc) {
this.jdbc = jdbc;
}
/**
* true: bu key ilk kez görüldü, işi yap. false: key zaten var (gerekirse onun commit'i beklendi).
* Aynı key ile eşzamanlı ikinci istek unique index üzerinde birincinin bitmesini bekler.
*/
public boolean insertIfAbsent(NewTransaction tx) {
int rows = jdbc.sql("""
INSERT INTO transactions (id, customer_id, idempotency_key, request_hash, type, status,
account_id, amount, currency, quote_id)
VALUES (:id, :customerId, :key, :hash, :type, 'COMPLETED',
:accountId, :amount, :currency, :quoteId)
ON CONFLICT (customer_id, idempotency_key) DO NOTHING
""")
.param("id", tx.id())
.param("customerId", tx.customerId())
.param("key", tx.idempotencyKey())
.param("hash", tx.requestHash())
.param("type", tx.type().name())
.param("accountId", tx.accountId())
.param("amount", tx.amount().amount())
.param("currency", tx.amount().currency().getCurrencyCode())
.param("quoteId", tx.quoteId(), Types.OTHER)
.update();
return rows == 1;
}
/** İlk isteğin cevabını DB'deki kayıttan yeniden üretir; body farklıysa 422. */
public TransactionView replay(long customerId, String key, String requestHash) {
record Stored(String requestHash, TransactionView view) { }
Stored stored = jdbc.sql("""
SELECT t.id, t.request_hash, t.type, t.status, t.amount, t.currency, l.balance_after
FROM transactions t
LEFT JOIN ledger_entries l ON l.transaction_id = t.id AND l.account_id = t.account_id
WHERE t.customer_id = :customerId AND t.idempotency_key = :key
""")
.param("customerId", customerId)
.param("key", key)
.query((rs, rowNum) -> new Stored(
rs.getString("request_hash"),
new TransactionView(
rs.getObject("id", UUID.class),
TransactionType.valueOf(rs.getString("type")),
rs.getString("status"),
rs.getBigDecimal("amount"),
rs.getString("currency"),
rs.getBigDecimal("balance_after"))))
.single();
if (!stored.requestHash().equals(requestHash)) {
throw new IdempotencyKeyReusedException(key);
}
return stored.view();
}
}
quoteId null olabildiği için tipi açıkça Types.OTHER verildi; PostgreSQL JDBC sürücüsü UUID'yi bu tiple bağlar. LedgerRepository.append tek bir INSERT'tür; RequestHash ise parçaları | ile birleştirip SHA-256 alır. Tutar Money'den geldiği için scale kanoniktir: 10 ve 10.00 aynı hash'i üretir, aksi halde aynı isteğin retry'ı "farklı body" sayılırdı.
AccountService: deposit ve withdraw
@Service
public class AccountService {
private final AccountRepository accounts;
private final TransactionRepository transactions;
private final LedgerRepository ledger;
public AccountService(AccountRepository accounts, TransactionRepository transactions,
LedgerRepository ledger) {
this.accounts = accounts;
this.transactions = transactions;
this.ledger = ledger;
}
@Transactional
public Outcome deposit(long customerId, long accountId, String idempotencyKey, Money amount) {
return apply(TransactionType.DEPOSIT, customerId, accountId, idempotencyKey, amount,
() -> accounts.credit(accountId, customerId, amount),
account -> account.deposit(amount));
}
@Transactional
public Outcome withdraw(long customerId, long accountId, String idempotencyKey, Money amount) {
return apply(TransactionType.WITHDRAWAL, customerId, accountId, idempotencyKey, amount,
() -> accounts.debitIfSufficient(accountId, customerId, amount),
account -> account.withdraw(amount));
}
private Outcome apply(TransactionType type, long customerId, long accountId, String key, Money amount,
Supplier<Optional<BigDecimal>> atomicUpdate, Consumer<Account> domainRule) {
if (!amount.isPositive()) { // negatif "deposit" gizli bir withdraw olurdu
throw new InvalidAmountException("Tutar pozitif olmalı: " + amount.amount());
}
String hash = RequestHash.of(type, accountId, amount.amount(), amount.currency());
UUID txId = UUID.randomUUID();
// 1) Idempotency: key'i iş verisiyle aynı transaction'da sahiplen.
if (!transactions.insertIfAbsent(new NewTransaction(txId, customerId, key, hash, type,
accountId, amount, null))) {
return Outcome.replay(transactions.replay(customerId, key, hash));
}
// 2) Bakiye: kontrol ve değişiklik tek atomik UPDATE.
BigDecimal balanceAfter = atomicUpdate.get()
.orElseThrow(() -> explainRejection(customerId, accountId, domainRule));
// 3) Değişmez hareket kaydı.
BigDecimal signed = type == TransactionType.WITHDRAWAL ? amount.amount().negate() : amount.amount();
ledger.append(txId, accountId, signed, amount.currency(), balanceAfter);
return Outcome.fresh(new TransactionView(txId, type, "COMPLETED", amount.amount(),
amount.currency().getCurrencyCode(), balanceAfter));
}
/**
* UPDATE 0 satır döndü: hangi kural bozuldu? Hesabı okuyup aynı kuralı bellekte çalıştırırız.
* Fırlatılan exception transaction'ı rollback eder; idempotency kaydı da geri alınır.
*/
private RuntimeException explainRejection(long customerId, long accountId, Consumer<Account> domainRule) {
Account account = accounts.findOwned(accountId, customerId) // başkasının hesabı da 404
.map(AccountRow::toDomain)
.orElseThrow(() -> new AccountNotFoundException(accountId));
try {
domainRule.accept(account);
} catch (DomainException violated) {
return violated;
}
// Okuma anında kural geçiyor: arada başka bir işlem bakiyeyi değiştirdi. Nadir; tekrar denenebilir.
return new ConcurrentUpdateException(accountId);
}
}
Üç not:
applyprivate'tır ama sorun yoktur: transaction, dışarıdan çağrılandeposit/withdrawproxy'si üzerinden açılır; private metot aynı transaction'da çalışır.- Pozitiflik kontrolü kritiktir:
creditile −50 "yatırmak" gizli bir çekim olurdu. Aynı kural DTO'da (@Positive) ve DB'de (CHECK (amount > 0)) de var; para kodunda savunma katmanlıdır. explainRejectionsadece hata yolunda çalışır; mutlu yol tek bir UPDATE'tir.
ExchangeService: quote ve execute
@Service
public class ExchangeService {
private static final int QUOTE_TTL_SECONDS = 30;
private final AccountRepository accounts;
private final QuoteRepository quotes;
private final TransactionRepository transactions;
private final LedgerRepository ledger;
private final FxRateClient fxRateClient;
public ExchangeService(AccountRepository accounts, QuoteRepository quotes,
TransactionRepository transactions, LedgerRepository ledger,
FxRateClient fxRateClient) {
this.accounts = accounts;
this.quotes = quotes;
this.transactions = transactions;
this.ledger = ledger;
this.fxRateClient = fxRateClient;
}
/** Bilerek @Transactional DEĞİL: dış HTTP çağrısı sırasında DB bağlantısı tutulmaz. */
public QuoteView quote(long customerId, long sourceAccountId, long targetAccountId, BigDecimal sourceAmount) {
Account source = loadOwned(customerId, sourceAccountId);
Account target = loadOwned(customerId, targetAccountId);
if (source.currency().equals(target.currency())) {
throw new CurrencyMismatchException(source.currency(), target.currency());
}
Money amount = new Money(sourceAmount, source.currency());
FxRate rate = fxRateClient.rate(source.currency(), target.currency());
Money targetAmount = FxConversion.convert(amount, target.currency(), rate.rate());
UUID quoteId = UUID.randomUUID();
var expiresAt = quotes.insert(quoteId, customerId, sourceAccountId, targetAccountId,
amount.amount(), targetAmount.amount(), rate.rate(), rate.providerRef(), QUOTE_TTL_SECONDS);
return new QuoteView(quoteId, amount.amount(), source.currency().getCurrencyCode(),
targetAmount.amount(), target.currency().getCurrencyCode(), rate.rate(), expiresAt);
}
@Transactional
public Outcome execute(long customerId, long sourceAccountId, String idempotencyKey, UUID quoteId) {
QuoteRow quote = quotes.findOwned(quoteId, customerId)
.filter(q -> q.sourceAccountId() == sourceAccountId)
.orElseThrow(() -> new QuoteNotFoundException(quoteId));
Money debit = new Money(quote.sourceAmount(), Currency.getInstance(quote.sourceCurrency()));
Money credit = new Money(quote.targetAmount(), Currency.getInstance(quote.targetCurrency()));
String hash = RequestHash.of(TransactionType.EXCHANGE, sourceAccountId, quoteId);
UUID txId = UUID.randomUUID();
// 1) Idempotency
boolean fresh;
try {
fresh = transactions.insertIfAbsent(new NewTransaction(txId, customerId, idempotencyKey, hash,
TransactionType.EXCHANGE, sourceAccountId, debit, quoteId));
} catch (DuplicateKeyException sameQuoteOtherKey) { // transactions.quote_id UNIQUE
throw new QuoteNotUsableException(quoteId);
}
if (!fresh) {
return Outcome.replay(transactions.replay(customerId, idempotencyKey, hash));
}
// 2) Teklifi tek seferlik tüket (süre kontrolü DB saatiyle)
if (!quotes.markUsed(quoteId, customerId)) {
throw new QuoteNotUsableException(quoteId);
}
// 3) İki hesabı id sırasıyla kilitle, kuralları domain nesnesinde çalıştır
List<AccountRow> locked = accounts.lockInIdOrder(customerId,
Math.min(quote.sourceAccountId(), quote.targetAccountId()),
Math.max(quote.sourceAccountId(), quote.targetAccountId()));
Account source = pick(locked, quote.sourceAccountId());
Account target = pick(locked, quote.targetAccountId());
source.withdraw(debit); // InsufficientFunds / AccountNotActive -> rollback
target.deposit(credit);
accounts.updateBalance(source.id(), source.balance().amount());
accounts.updateBalance(target.id(), target.balance().amount());
ledger.append(txId, source.id(), debit.amount().negate(), debit.currency(), source.balance().amount());
ledger.append(txId, target.id(), credit.amount(), credit.currency(), target.balance().amount());
return Outcome.fresh(new TransactionView(txId, TransactionType.EXCHANGE, "COMPLETED",
debit.amount(), debit.currency().getCurrencyCode(), source.balance().amount()));
}
private Account loadOwned(long customerId, long accountId) {
return accounts.findOwned(accountId, customerId)
.map(AccountRow::toDomain)
.orElseThrow(() -> new AccountNotFoundException(accountId));
}
private static Account pick(List<AccountRow> rows, long id) {
return rows.stream()
.filter(r -> r.id() == id)
.findFirst()
.map(AccountRow::toDomain)
.orElseThrow(() -> new AccountNotFoundException(id));
}
}
QuoteRepository'nin iki kritik metodu:
/** expires_at DB saatiyle hesaplanır; kontrol de DB saatiyle yapılır (pod saat kayması yok). */
public OffsetDateTime insert(UUID id, long customerId, long sourceAccountId, long targetAccountId,
BigDecimal sourceAmount, BigDecimal targetAmount, BigDecimal rate,
String providerRef, int ttlSeconds) {
return jdbc.sql("""
INSERT INTO exchange_quotes (id, customer_id, source_account_id, target_account_id,
source_amount, target_amount, rate, provider_ref, expires_at)
VALUES (:id, :customerId, :sourceId, :targetId, :sourceAmount, :targetAmount, :rate,
:providerRef, now() + (:ttl * interval '1 second'))
RETURNING expires_at
""")
// .param(...) çağrıları
.query(OffsetDateTime.class)
.single();
}
/** Teklifi tek seferlik tüketir: süresi geçmişse ya da kullanılmışsa false. */
public boolean markUsed(UUID id, long customerId) {
return jdbc.sql("""
UPDATE exchange_quotes
SET status = 'USED'
WHERE id = :id AND customer_id = :customerId
AND status = 'ACTIVE' AND expires_at > now()
""")
.param("id", id)
.param("customerId", customerId)
.update() == 1;
}
FxRateClient: timeout, retry, circuit breaker, fallback
@Configuration
public class FxClientConfig {
@Bean
RestClient fxRestClient(RestClient.Builder builder, @Value("${fx.base-url}") String baseUrl) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(500))
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
factory.setReadTimeout(Duration.ofSeconds(1)); // p99 ~200 ms varsayımıyla
return builder.baseUrl(baseUrl).requestFactory(factory).build();
}
@Bean
Clock clock() {
return Clock.systemUTC();
}
}
@Component
public class FxRateClient {
/** İş kararı: bundan eski bir kurla müşteriye teklif verilmez. */
private static final Duration MAX_STALENESS = Duration.ofSeconds(60);
private record CachedRate(FxRate rate, Instant fetchedAt) { }
private final RestClient fxRestClient;
private final Clock clock;
private final Map<String, CachedRate> lastKnown = new ConcurrentHashMap<>();
public FxRateClient(RestClient fxRestClient, Clock clock) {
this.fxRestClient = fxRestClient;
this.clock = clock;
}
// GET ve yan etkisiz: retry güvenli. Fallback en dış katmanda (Retry) ki önce denemeler tükensin.
@Retry(name = "fx", fallbackMethod = "lastKnownRate")
@CircuitBreaker(name = "fx")
public FxRate rate(Currency from, Currency to) {
FxRate rate = fxRestClient.get()
.uri("/v1/rates?from={from}&to={to}", from.getCurrencyCode(), to.getCurrencyCode())
.retrieve()
.body(FxRate.class);
if (rate == null || rate.rate() == null || rate.rate().signum() <= 0) {
throw new IllegalStateException("Geçersiz kur cevabı: " + rate);
}
lastKnown.put(key(from, to), new CachedRate(rate, clock.instant()));
return rate;
}
private FxRate lastKnownRate(Currency from, Currency to, Throwable cause) {
CachedRate cached = lastKnown.get(key(from, to));
if (cached != null && cached.fetchedAt().isAfter(clock.instant().minus(MAX_STALENESS))) {
return cached.rate();
}
throw new FxRateUnavailableException("Kur alınamadı: " + key(from, to), cause);
}
private static String key(Currency from, Currency to) {
return from.getCurrencyCode() + "/" + to.getCurrencyCode();
}
}
fx:
base-url: https://fx-provider.example.com
spring:
datasource:
hikari:
maximum-pool-size: 10
connection-timeout: 2000 # ms; havuz beklemesi varsayılan 30 sn olmasın
resilience4j:
circuitbreaker:
instances:
fx:
sliding-window-size: 50
minimum-number-of-calls: 20
failure-rate-threshold: 50
slow-call-duration-threshold: 800ms
slow-call-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 5
retry:
instances:
fx:
max-attempts: 2 # 1 çağrı + 1 tekrar
wait-duration: 100ms
enable-randomized-wait: true
retry-exceptions:
- org.springframework.web.client.ResourceAccessException
- org.springframework.web.client.HttpServerErrorException
ignore-exceptions:
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
Annotation'lar varsayılan sırada Retry dışta, CircuitBreaker içte sarılır; fallback Retry'a bağlı olduğu için önce denemeler tükenir, sonra son bilinen kura bakılır (bkz. Bölüm 14). resilience4j-spring-boot3 ve AOP starter'ı gerekir. Fallback her exception'da çalışır; desteklenmeyen para birimi çifti gibi bir 4xx için önbellekte kur olmayacağından sonuç yine FxRateUnavailableException olur. Bunu ayırmak istersen fallback'i exception tipine göre aşırı yüklersin (Resilience4j en spesifik imzayı seçer).
Veritabanı tarafında rol seviyesinde zaman aşımları, kilit beklemelerinin ve unutulmuş transaction'ların sınırsız sürmesini engeller:
ALTER ROLE bank_app SET lock_timeout = '2s';
ALTER ROLE bank_app SET statement_timeout = '5s';
ALTER ROLE bank_app SET idle_in_transaction_session_timeout = '10s';
REVOKE UPDATE, DELETE, TRUNCATE ON ledger_entries FROM bank_app;
Kilit bekleyen bir UPDATE 2 saniye sonra 55P03: canceling statement due to lock timeout aldı. Spring bunu CannotAcquireLockException'a (ConcurrencyFailureException ailesi) çevirir; aşağıdaki handler 409 + Retry-After döner. Transaction geri alındığı için istemcinin aynı Idempotency-Key ile tekrar denemesi güvenlidir.
Controller ve hata yönetimi
public record AmountRequest(
@NotNull @Positive @Digits(integer = 15, fraction = 4) BigDecimal amount,
@NotNull @Pattern(regexp = "[A-Z]{3}") String currency) {
public Money toMoney() {
try {
return new Money(amount, Currency.getInstance(currency));
} catch (IllegalArgumentException unknownCurrency) {
throw new InvalidAmountException("Bilinmeyen para birimi: " + currency);
}
}
}
@RestController
@RequestMapping("/api/v1/accounts/{accountId}")
public class AccountController {
// Gerçekte müşteri kimliği JWT'den gelir; header burada sadeleştirme.
private static final String CUSTOMER = "X-Customer-Id";
private static final String IDEMPOTENCY_KEY = "Idempotency-Key";
private final AccountService accountService;
private final ExchangeService exchangeService;
public AccountController(AccountService accountService, ExchangeService exchangeService) {
this.accountService = accountService;
this.exchangeService = exchangeService;
}
@PostMapping("/deposits")
public ResponseEntity<TransactionView> deposit(@PathVariable long accountId,
@RequestHeader(CUSTOMER) long customerId,
@RequestHeader(IDEMPOTENCY_KEY) @Size(min = 8, max = 255) String key,
@Valid @RequestBody AmountRequest request) {
return created(accountService.deposit(customerId, accountId, key, request.toMoney()));
}
@PostMapping("/withdrawals")
public ResponseEntity<TransactionView> withdraw(@PathVariable long accountId,
@RequestHeader(CUSTOMER) long customerId,
@RequestHeader(IDEMPOTENCY_KEY) @Size(min = 8, max = 255) String key,
@Valid @RequestBody AmountRequest request) {
return created(accountService.withdraw(customerId, accountId, key, request.toMoney()));
}
@PostMapping("/exchange-quotes")
public ResponseEntity<QuoteView> quote(@PathVariable long accountId,
@RequestHeader(CUSTOMER) long customerId,
@Valid @RequestBody QuoteRequest request) {
QuoteView quote = exchangeService.quote(customerId, accountId,
request.targetAccountId(), request.sourceAmount());
return ResponseEntity.status(201).body(quote);
}
@PostMapping("/exchanges")
public ResponseEntity<TransactionView> exchange(@PathVariable long accountId,
@RequestHeader(CUSTOMER) long customerId,
@RequestHeader(IDEMPOTENCY_KEY) @Size(min = 8, max = 255) String key,
@Valid @RequestBody ExchangeRequest request) {
return created(exchangeService.execute(customerId, accountId, key, request.quoteId()));
}
private static ResponseEntity<TransactionView> created(Outcome outcome) {
return ResponseEntity
.created(URI.create("/api/v1/transactions/" + outcome.view().transactionId()))
.header("Idempotent-Replayed", String.valueOf(outcome.replayed()))
.body(outcome.view());
}
}
@Size gibi metot parametresi constraint'leri Spring Framework 6.1+'ın yerleşik metot validasyonuyla çalışır (sınıfa @Validated koymaya gerek yok; ihlal HandlerMethodValidationException → 400). Idempotency-Key header'ı yoksa Spring MissingRequestHeaderException fırlatır; ResponseEntityExceptionHandler bunu 400 ProblemDetail'e çevirir. Tekrar gelen istek için de 201 ve aynı gövde dönülür; Idempotent-Replayed header'ı istemciye ve log'lara bunun bir tekrar olduğunu söyler.
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(DomainException.class)
ProblemDetail handleDomain(DomainException ex) {
HttpStatus status = switch (ex) { // sealed: eksik dal derleme hatası
case AccountNotFoundException e -> HttpStatus.NOT_FOUND;
case QuoteNotFoundException e -> HttpStatus.NOT_FOUND;
case QuoteNotUsableException e -> HttpStatus.CONFLICT;
case ConcurrentUpdateException e -> HttpStatus.CONFLICT;
case InsufficientFundsException e -> HttpStatus.UNPROCESSABLE_ENTITY;
case AccountNotActiveException e -> HttpStatus.UNPROCESSABLE_ENTITY;
case CurrencyMismatchException e -> HttpStatus.UNPROCESSABLE_ENTITY;
case InvalidAmountException e -> HttpStatus.UNPROCESSABLE_ENTITY;
case IdempotencyKeyReusedException e -> HttpStatus.UNPROCESSABLE_ENTITY;
};
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, ex.getMessage());
problem.setProperty("code", ex.code());
return problem;
}
/** Kilit zaman aşımı, kaçan bir deadlock vb.: transaction geri alındı, aynı key ile tekrar güvenli. */
@ExceptionHandler(ConcurrencyFailureException.class)
ResponseEntity<ProblemDetail> handleConcurrency(ConcurrencyFailureException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"İşlem eşzamanlı bir işlemle çakıştı, aynı Idempotency-Key ile tekrar deneyin");
problem.setProperty("code", "CONCURRENT_UPDATE");
return ResponseEntity.status(HttpStatus.CONFLICT).header(HttpHeaders.RETRY_AFTER, "1").body(problem);
}
@ExceptionHandler(FxRateUnavailableException.class)
ResponseEntity<ProblemDetail> handleFx(FxRateUnavailableException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
"Kur servisi geçici olarak kullanılamıyor");
problem.setProperty("code", "FX_UNAVAILABLE");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).header(HttpHeaders.RETRY_AFTER, "5").body(problem);
}
}
Durum kodlarının özeti:
| Durum | Kod | Gerekçe |
|---|---|---|
| İşlem yapıldı (veya aynı key ile tekrar) | 201 Created + Location | Yeni bir işlem kaynağı oluştu |
| Header eksik, gövde bozuk, validasyon | 400 | İstek sözdizimi/şekil olarak hatalı |
| Hesap/quote yok veya başkasının | 404 | Varlığını sızdırmamak için 403 yerine 404 |
| Quote süresi doldu/kullanıldı, eşzamanlı çakışma | 409 | Kaynağın mevcut durumuyla çakışma |
| Yetersiz bakiye, pasif hesap, para birimi uyumsuz, aynı key farklı body | 422 | İstek geçerli ama iş kuralı reddediyor |
| Kur servisi yok | 503 + Retry-After | Geçici, tekrar denenebilir |
Spring Data JPA alternatifi
JPA kullanan bir kod tabanında koşullu UPDATE şöyle yazılır:
public interface AccountJpaRepository extends JpaRepository<AccountEntity, Long> {
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("""
update AccountEntity a
set a.balance = a.balance - :amount, a.version = a.version + 1
where a.id = :id and a.status = 'ACTIVE' and a.balance >= :amount
""")
int debitIfSufficient(@Param("id") long id, @Param("amount") BigDecimal amount);
@Lock(LockModeType.PESSIMISTIC_WRITE) // pessimistic satır kilidi
@Query("select a from AccountEntity a where a.id in :ids order by a.id")
List<AccountEntity> lockInIdOrder(@Param("ids") List<Long> ids);
}
JPQL bulk update persistence context'i atlar; daha önce yüklenmiş entity eski bakiyeyi taşır, clearAutomatically = true bunu önler (bkz. Bölüm 8). @Version elle artırılır ve RETURNING olmadığı için yeni bakiye ayrı sorguyla okunur. PESSIMISTIC_WRITE, Hibernate 6'da PostgreSQL için FOR UPDATE üretir; Adım 5'teki FK kilidi uyarısı yüzünden hesapları FK'li kayıtları eklemeden önce kilitle ya da native sorguda FOR NO KEY UPDATE kullan.
Adım 8: Test stratejisi
Bu case'te en değerli test, mülakatçıya "race condition'ı engelledim" iddiasını kanıtlayan testtir. Seviyeler (genel strateji Bölüm 16'da):
| Seviye | Ne test edilir | Araç |
|---|---|---|
| Unit | Money scale kuralları, FxConversion yuvarlaması, Account kuralları | JUnit 5, AssertJ; Spring yok, milisaniyeler |
| Integration (DB) | Koşullu UPDATE, idempotency, kilit sırası, eşzamanlılık | Testcontainers ile gerçek PostgreSQL |
| Integration (HTTP client) | Timeout, 5xx, bozuk cevap, circuit breaker | WireMock |
| Web slice | Status kodları, ProblemDetail şekli, header'lar | @WebMvcTest + MockMvc |
Unit: Yuvarlamanın sınır durumları ve domain kuralları.
class FxConversionTest {
private static final Currency USD = Currency.getInstance("USD");
private static final Currency JPY = Currency.getInstance("JPY");
@Test
void roundsHalfEvenToTargetCurrencyScale() {
assertThat(FxConversion.convert(Money.of("123.45", "TRY"), USD, new BigDecimal("0.02913457")).amount())
.isEqualByComparingTo("3.60"); // 3.5966626665
assertThat(FxConversion.convert(Money.of("0.50", "TRY"), USD, new BigDecimal("0.05")).amount())
.isEqualByComparingTo("0.02"); // 0.025 -> çift komşu
assertThat(FxConversion.convert(Money.of("12.34", "USD"), JPY, new BigDecimal("151.237")).amount())
.isEqualByComparingTo("1866"); // JPY scale 0
}
@Test
void moneyRejectsMoreDecimalsThanCurrencyAllows() {
assertThatThrownBy(() -> Money.of("10.005", "TRY")).isInstanceOf(InvalidAmountException.class);
assertThat(Money.of("10.0500", "TRY").amount()).isEqualTo(new BigDecimal("10.05"));
}
}
Eşzamanlılık integration testi: 100 thread aynı anda aynı hesaptan çeker. CountDownLatch ile hepsi hazır olunca aynı anda başlatılır; aksi halde thread'ler sırayla çalışır ve test yarışı hiç üretmeden geçer.
@SpringBootTest
@Testcontainers
class WithdrawConcurrencyIT {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
private static final long CUSTOMER = 42L;
@Autowired AccountService accountService;
@Autowired JdbcClient jdbc;
@Test
void hundredConcurrentWithdrawalsNeverOverdrawAndLedgerMatchesBalance() throws Exception {
long accountId = jdbc.sql("INSERT INTO accounts (customer_id, currency) VALUES (:c, 'TRY') RETURNING id")
.param("c", CUSTOMER).query(Long.class).single();
accountService.deposit(CUSTOMER, accountId, UUID.randomUUID().toString(), Money.of("500", "TRY"));
int threads = 100;
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch start = new CountDownLatch(1);
AtomicInteger succeeded = new AtomicInteger();
AtomicInteger rejected = new AtomicInteger();
List<Future<?>> futures = new ArrayList<>();
try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
for (int i = 0; i < threads; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await(); // hepsi aynı anda başlasın
try {
accountService.withdraw(CUSTOMER, accountId, UUID.randomUUID().toString(),
Money.of("10", "TRY"));
succeeded.incrementAndGet();
} catch (InsufficientFundsException e) {
rejected.incrementAndGet();
}
return null;
}));
}
ready.await();
start.countDown();
for (Future<?> f : futures) {
f.get(30, TimeUnit.SECONDS); // beklenmeyen exception testi düşürsün
}
}
assertThat(succeeded.get()).isEqualTo(50);
assertThat(rejected.get()).isEqualTo(50);
assertThat(balance(accountId)).isEqualByComparingTo("0");
assertThat(ledgerSum(accountId)).isEqualByComparingTo(balance(accountId)); // değişmez kural
}
private BigDecimal balance(long id) {
return jdbc.sql("SELECT balance FROM accounts WHERE id = :id").param("id", id)
.query(BigDecimal.class).single();
}
private BigDecimal ledgerSum(long id) {
return jdbc.sql("SELECT COALESCE(SUM(amount), 0) FROM ledger_entries WHERE account_id = :id")
.param("id", id).query(BigDecimal.class).single();
}
}
Notlar:
- Test sınıfı
@Transactionalolmamalıdır. Test transaction'ı thread'lere yayılmaz, ama test metodunun kendi yazdıkları (hesap açma) commit edilmeden diğer thread'ler onları göremez. ExecutorServiceJava 19+'daAutoCloseable'dır;try-with-resourceskapanışta görevlerin bitmesini bekler.- 100 thread, 10 bağlantılık havuzu paylaşır; bu da gerçekçi bir kuyruklanma üretir.
- Aynı iddia bu bölümde
pgbenchile 50 istemciden doğrulandı (50 başarılı, 50 red, bakiye 0, ledger toplamı −500). Naive koda karşı aynı test kırmızıya döner; mülakatta testi önce naive koda karşı çalıştırıp kırmızıyı göstermek güçlü bir hamledir.
Aynı kalıpla iki test daha:
- Idempotency: 10 thread aynı key ile aynı çekimi gönderir. Beklenen: tek ledger kaydı, 10 cevabın hepsinde aynı
transactionId, 9'undareplayed = true. - Deadlock: 50 thread TRY→USD, 50 thread USD→TRY exchange'i aynı anda yürütür. Beklenen: hiçbir
ConcurrencyFailureExceptionyok, her iki hesaptabalance = SUM(ledger).
3rd party: WireMock. Timeout davranışı ancak gerçek bir HTTP sunucusuyla güvenilir test edilir. Spring context'siz, config sınıfının ürettiği gerçek RestClient ile:
class FxRateClientTimeoutTest {
@RegisterExtension
static WireMockExtension fx = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@Test
void slowProviderIsCutByReadTimeout() {
fx.stubFor(get(urlPathEqualTo("/v1/rates"))
.willReturn(okJson("""
{"from":"TRY","to":"USD","rate":0.0291,"providerRef":"r-1"}
""").withFixedDelay(3_000)));
RestClient restClient = new FxClientConfig().fxRestClient(RestClient.builder(), fx.baseUrl());
FxRateClient client = new FxRateClient(restClient, Clock.systemUTC());
assertTimeoutPreemptively(Duration.ofSeconds(2), () ->
assertThatThrownBy(() -> client.rate(Currency.getInstance("TRY"), Currency.getInstance("USD")))
.isInstanceOf(ResourceAccessException.class));
}
}
Proxy olmadan çağrıldığı için annotation'lar devrede değildir; bu test sadece timeout'un gerçekten kestiğini kanıtlar. Retry, circuit breaker ve fallback davranışı (503 + Retry-After, devre açıldıktan sonra sağlayıcıya istek gitmemesi, fx.verify(...) ile çağrı sayısı) @SpringBootTest + WireMock ile ayrı bir testte doğrulanır.
Adım 9: Gözlemlenebilirlik ve operasyon
Metrikler (Micrometer; genel kurulum Bölüm 15'te):
| Metrik | Neden |
|---|---|
bank.transactions counter, tag: type, outcome (completed, insufficient_funds, replayed, quote_expired...) | İş hacmi ve red oranı; ani insufficient_funds artışı istemci hatasını veya saldırıyı gösterir |
http.server.requests (Boot hazır) | Endpoint bazında p50/p99 ve 4xx/5xx oranı |
http.client.requests uri=/v1/rates (RestClient observation) | Sağlayıcı gecikmesi ve hata oranı |
resilience4j.circuitbreaker.state, ...calls | Devrenin açılması alarm |
hikaricp.connections.pending, ...usage | Havuz baskısı; usage artıyorsa transaction'lar uzamıştır |
bank.reconciliation.mismatches gauge | Mutabakat sorgularının bulduğu fark; sıfırdan farklıysa sayfa (page) |
bank.transactions.pending.age.max | En eski PENDING işlemin yaşı (sağlayıcının yürüttüğü modelde) |
Trace ve log: Her istekte traceId (Micrometer Tracing) ve log'larda transactionId, customerId, idempotencyKey. Tutar ve hesap bilgisi log'lanabilir ama kart numarası, kimlik numarası gibi hassas veri asla. INSUFFICIENT_FUNDS gibi iş redleri INFO/WARN, beklenmeyen hatalar ERROR.
Audit: Ledger zaten değişmez bir denetim izidir. Eksik olan "kim, hangi kanaldan, hangi IP/cihazdan" bilgisidir; transactions'a channel, actor_id, client_ip kolonları eklenir. Hesap durumu değişiklikleri (bloke etme) gibi para hareketi olmayan işlemler için ayrı bir audit_log tablosu.
Tek DB instance'ın riskleri:
- Tek hata noktası: "Tek instance" çoğu zaman "tek primary" demek olmalıdır. Senkron veya asenkron streaming replication ile bir standby ve otomatik failover (yönetilen servislerde Multi-AZ, kendi kurulumunda Patroni). Asenkron replikasyonda failover son commit'leri kaybedebilir; bankacılıkta senkron standby (
synchronous_standby_names+synchronous_commit = onveyaremote_apply) bu yüzden ciddi bir seçenektir. RPO/RTO hedefini iş birimiyle konuş. - Yedek ve PITR: Base backup + sürekli WAL arşivi ile istenen ana geri dönüş (point-in-time recovery). Yanlış bir migration'ın sabah 10:03'teki halinden önceye dönebilmek için. Yedeğin geri yüklenebildiğini düzenli olarak test et; test edilmemiş yedek yedek değildir.
- Bağlantı sayısı:
pod sayısı × havuz boyutu ≤ max_connections(varsayılan 100) ve yönetim bağlantıları için pay. 3 pod × 10 = 30 rahat. Pod sayısı artarsa PgBouncer (transaction pooling). - Read replica (ileride): Hareket listesi ve raporlar replica'ya gidebilir; ama "çekimden hemen sonra bakiye" primary'den okunmalı, çünkü replication lag vardır (bkz. Bölüm 10).
- Ledger büyümesi: Yılda ~30 GB ve sürekli artan index'ler.
ledger_entries'icreated_atüzerinden aylık partition'lara bölmek; eski partition'ları daha ucuz depolamaya taşımak veya arşivlemek. PostgreSQL'de partitioned tabloda unique constraint partition anahtarını içermek zorundadır; bu yüzden partition'lamaya geçerken primary key(id, created_at)olur ve FK tasarımı yeniden düşünülür. Bunu ilk günden yapmak gerekmez, ama "tablo 100 GB'ı geçince partition'larım" planı olmalıdır. - Sıcak satırlarda VACUUM: Her bakiye güncellemesi PostgreSQL'de satırın yeni bir sürümünü yaratır.
balanceindex'li bir kolon olmadığı için güncellemeler HOT (heap-only tuple) olabilir; bu ortamdaki ölçümdeaccountsgüncellemelerinin %98,8'i HOT oldu. Autovacuum'un sıcak tabloya yetişip yetişmediğinin_dead_tupile izle (bkz. Bölüm 10).
Adım 10: Live coding'de 90 dakikayı nasıl sıralardım
En kritik akışı doğru ve kanıtlanmış bitir, gerisini bilinçli bir sırayla ekle.
| Dakika | Ne yaparım | Neden bu sırada |
|---|---|---|
| 0-8 | Netleştirme soruları, varsayımları yaz, tek cümlelik kapasite hesabı: "asıl risk doğruluk" | Mülakatçı çerçeveyi duymak ister; yanlış problemi çözmeyi önler |
| 8-15 | Tablolar: accounts, transactions, ledger_entries; Money | Her şey bunun üstüne kurulur |
| 15-35 | withdraw: koşullu UPDATE + ledger; 100 thread'lik eşzamanlılık testi | Sorunun kalbi; test kanıttır |
| 35-45 | deposit (aynı iskelet), idempotency key: ON CONFLICT DO NOTHING + replay + hash | Retry güvenliği; withdraw'ı genişletir |
| 45-65 | Exchange: quote ve execute, kilit sırası; FxRateClient önce bir interface ve sahte (fake) implementasyon | Dış servis olmadan akış test edilebilir |
| 65-75 | RestClient timeout, retry/circuit breaker konfigürasyonu; ApiExceptionHandler ve status kodları | Dayanıklılık ve API sözleşmesi |
| 75-90 | Sesli anlatım: PENDING akışı, mutabakat, gözlemlenebilirlik, tek DB riskleri, ölçek | Kodlanmayanı tasarım olarak göster |
Zaman kalmazsa sesli söylenecekler, öncelik sırasıyla:
- "Exchange'de iki hesabı her zaman id sırasıyla kilitliyorum; aksi halde ters yönlü iki exchange deadlock olur."
- "Kur çağrısı asla DB transaction'ının içinde değil; quote ayrı bir adım, execute'ta dış çağrı yok."
- "Sağlayıcı işlemi yürütüyorsa PENDING durumu, aynı referansla idempotent dış çağrı, kurtarma worker'ı ve gün sonu mutabakatı eklerim."
- "Bakiye = hareketlerin toplamı kuralını gece job'ı ile kontrol eder, farkı alarm yaparım."
- "Bu yük için Kafka, Redis veya sharding eklemiyorum; şu metrikler şu eşiği geçerse eklerim."
Testcontainers'ın mülakat ortamında çalışmayacağı (Docker yok) bir durumda bunu baştan sor. Çalışmıyorsa H2'ye geçmek yerine: in-memory repository ile domain'i yaz, eşzamanlılık testini hesap başına kilitli in-memory implementasyona karşı çalıştır ve "production'da kural koşullu UPDATE'te; H2 PostgreSQL'in kilit davranışını birebir taklit etmediği için bu testi Testcontainers'la koşardım" de.
Üç seviyede cevap
Soru: "Deposit, withdraw ve exchange servisini baştan kur. Tek DB, günde 1 milyon istek."
seviye: İleri etiketler: [system-design, race-condition, ledger] kaynak: Gerçek mülakat
Controller, service, repository katmanlarını kurarım. Account entity'sinde bakiye tutarım; withdraw'da bakiyeyi okuyup yetiyorsa düşerim. Metotları @Transactional yaparım. Yük yüksek olduğu için Redis cache ve Kafka eklerim.
Önce kapsamı sorarım: para birimi başına hesap, overdraft, kurun kaynağı. 1M/gün ≈ 12 rps; tek PostgreSQL yeter. Tablolar: accounts, değişmez ledger_entries, idempotency key'li transactions. Withdraw koşullu UPDATE ... WHERE balance >= :amount ile; exchange'de kuru transaction dışında alırım ve iki hesabı id sırasıyla kilitlerim. Para BigDecimal. Kur client'ına timeout, retry ve circuit breaker koyarım.
Netleştirme sorularıyla başlar, varsayımları yazarım. Kapasite: ortalama ~12, tepe ~100-200 rps; ölçtüğüm tam withdraw transaction'ı 2 vCPU'da ~2.000 tps. Teşhisim: problem ölçek değil doğruluk. Modular monolith; account, ledger, exchange aynı transaction'ı paylaştığı için ayrı servis değil. Bakiye bir projeksiyon, doğruluk kaynağı append-only ledger; gece balance = SUM(ledger) mutabakatı. Withdraw tek koşullu UPDATE, her para endpoint'i (customer_id, key) unique idempotency ile. Exchange quote/execute: kur 30 sn kilitli, execute'ta dış çağrı yok, iki hesap id sırasıyla FOR NO KEY UPDATE. Sağlayıcı işlemi yürütüyorsa PENDING, idempotent dış çağrı, kurtarma worker'ı. Kanıtı Testcontainers'ta 100 thread'lik test. Kafka/Redis/sharding'i hangi metrikle ekleyeceğimi söylerim, şimdi eklemem.
Junior yükü abartıp gereksiz altyapı ekliyor, asıl riski (oku-kontrol-yaz yarışı) kodunda taşıyor. Mid doğru araçları seçiyor. Senior ölçerek teşhis koyuyor, modül sınırını transaction sınırına göre çiziyor, ledger ve mutabakatla doğruluğu sürekli kanıtlanabilir yapıyor ve neyi eklemediğini gerekçelendiriyor.
Takip sorusu: Bakiye kolonu ile ledger toplamı gece mutabakatında 980 TL farklı çıktı. Nasıl araştırırsın?
Soru: "Race condition'ı nasıl engellersin?"
seviye: Orta etiketler: [concurrency, locking] kaynak: Gerçek mülakat
Withdraw metodunu synchronized yaparım, aynı anda tek thread bakiyeyi değiştirir.
synchronized çoklu pod'da çalışmaz. DB'de UPDATE accounts SET balance = balance - :a WHERE id = :id AND balance >= :a yaparım; etkilenen satır 0 ise yetersiz bakiye. Satır kilidi ve READ COMMITTED'daki yeniden değerlendirme iki çekimin aynı parayı harcamasını önler. Ayrıca CHECK (balance >= 0).
Önce yarışları ayırırım: eşzamanlı farklı istekler (lost update), aynı isteğin tekrarı, çok hesaplı kilit sırası. Tek satır için koşullu UPDATE: retry yok, kilit milisaniyeler. Kural domain'de veya iki satırdaysa sıralı FOR NO KEY UPDATE (FK'li insert'lerin KEY SHARE kilidiyle çakışmasın diye); sırasız denediğimde 10 sn'de 30 deadlock, sıralı 0. Optimistic lock sıcak hesapta 100 denemenin 93'ünü düşürdü; SERIALIZABLE retry'la doğru ama 1.905 retry. Tekrar için idempotency key aynı transaction'da. synchronized hem çoklu pod hem "kilit commit'ten önce bırakılır" yüzünden yanlış. CHECK lost update'i yakalamaz: naive kod bakiyeyi negatife düşürmeden 980 TL kaybettirdi. lock_timeout ile beklemeyi sınırlarım.
Junior'ın cevabı tek pod'da bile yanlış. Mid doğru atomik UPDATE'i ve mekanizmasını biliyor. Senior yarış türlerini ayırıyor, her biri için aracı ölçümle seçiyor ve constraint'in neyi yakalamadığını biliyor.
Takip sorusu: "Günlük çekim limiti 10.000 TL" kuralı eklendi. Koşullu UPDATE hâlâ yeter mi?
Soru: "3rd party kur servisi yavaşlar ya da timeout olursa?"
seviye: İleri etiketler: [resilience, timeout, payments] kaynak: Tahmini
Try-catch ile yakalayıp hata dönerim, belki 3 kez tekrar denerim.
Connect/read timeout, sınırlı retry (GET olduğu için güvenli), circuit breaker. Çağrıyı @Transactional dışında yaparım ki DB bağlantısı tutulmasın. Devre açıksa 503 + Retry-After.
Önce rolünü netleştiririm. Sadece kur veriyorsa: timeout 1 sn, tek retry jitter'lı, circuit breaker, çift başına kısa önbellek; bayat kur ancak iş biriminin onayladığı yaşa kadar, yoksa 503. Execute adımında dış çağrı olmadığı için kur servisi düşse bile verilmiş quote'lar yürür. Sağlayıcı trade'i yürütüyorsa timeout ne başarı ne hatadır: işlem PENDING kalır, kaynak para düşülmüştür, aynı referansla sorulur, kesin red gelirse telafi kaydıyla iade; gün sonu raporla mutabakat. Sağlayıcının idempotency desteği seçim kriterim. İzlediklerim: sağlayıcı p99, devre durumu, en eski PENDING yaşı.
Junior retry'ı körlemesine yapıyor; yan etkili çağrıda çift işlem riski. Mid doğru katmanları biliyor. Senior çağrının yan etkisine göre ayrışıyor, belirsiz sonucu ayrı bir durum olarak modelliyor ve bayat kur kararını işe bırakıyor.
Takip sorusu: Trade isteği timeout aldı, kurtarma worker'ı sorduğunda sağlayıcı "böyle bir işlem yok" dedi. Hemen iade eder misin?
Soru: "Günde 1M için ne değişir, 100M olursa ne değişir?"
seviye: İleri etiketler: [scalability, capacity] kaynak: Tahmini
100M'de mikroservislere bölerim, Kafka ve Redis eklerim, Kubernetes ile ölçeklerim.
1M'de tek DB yeter. 100M ≈ 1.150 rps ortalama, tepede ~10 bin. Okumaları read replica'ya taşırım, cache eklerim, ledger'ı partition'larım, pod sayısını artırıp PgBouncer koyarım.
1M'de değişen bir şey yok; mevcut tasarım tepeyi 10 kat karşılar. 100M'de önce ölçerim: yazma tepesi binlerce tps, tek primary'nin sınırına yaklaşır. Sırayla: bakiye dışındaki okumalar replica'ya (yazma sonrası okuma primary'den), PgBouncer, ledger'ın aylık partition'ları, bildirim gibi kritik olmayan işler outbox + Kafka ile asenkron, sıcak sistem hesapları için bucket. Yine yetmezse customer_id ile sharding: exchange aynı müşterinin hesapları arasında olduğu için tek shard'da kalır, ACID korunur; sadece müşteriler arası transfer shard'lar arası saga olur. Shard anahtarını bu yüzden seçerim.
Junior ölçmeden mimari değiştiriyor. Mid doğru teknikleri sayıyor. Senior adımları maliyet sırasıyla diziyor ve shard anahtarını işlemlerin tek shard'da kalmasına göre seçerek tutarlılığı koruyor.
Takip sorusu: Sharding sonrası "bir müşterinin tüm hesaplarının TL karşılığı toplamı" raporu nasıl üretilir?
Sorulmadı ama sorulabilir
İki müşteri arasında transfer
Exchange'in kur olmayan hali: iki hesap, aynı para birimi, id sırasıyla kilit, iki ledger kaydı. Fark: sahiplik sadece kaynak hesapta kontrol edilir, hedefin varlığı ve durumu ayrıca doğrulanır. Özet: "Transfer de iki satırlı tek transaction; kilit sırası ve idempotency key aynen geçerli."
Limitler ve fraud
Günlük limit "bugünkü çekimlerin toplamı" kontrolüdür; iki eşzamanlı çekim toplamı ayrı ayrı okursa limit aşılır. Çözüm: hesap satırını kilitleyip toplamı okumak ya da daily_withdrawn sayacını koşullu UPDATE'e eklemek (AND daily_withdrawn + :a <= daily_limit). Fraud skoru senkron ve timeout'lu bir kural motoru ya da asenkron inceleme olabilir. Özet: "Limit de bir invariant; aynı atomik UPDATE'e girer."
Çoklu para birimi ve yuvarlama
Kur erken yuvarlanmaz, çevrimde tek yuvarlama noktası olur ve müşteri hedef tutarı quote'ta görür. Çapraz kurlarda (TRY→EUR, USD üzerinden) ara sonuç yuvarlanmaz. Özet: "Yuvarlama tek yerde, hedef para biriminin scale'iyle, HALF_EVEN."
Hot account problemi
Tam double-entry'de her deposit bankanın tek "takas" hesabına da yazılır; o satır bütün trafiğin kilidi olur (bu ortamda tek hesap ~400 tps). Çözümler: sistem hesabını N alt hesaba (bucket) bölmek, sistem hesabında bakiye satırı güncellemeden sadece ledger'a yazıp bakiyeyi periyodik toplamak, toplu (batch) netleştirme. Özet: "Sıcak hesabın bakiyesini her işlemde güncellemem; bölerim ya da ledger'dan türetirim."
Event sourcing
Ledger zaten değişmez bir olay günlüğüdür; tam event sourcing (durumun yalnızca olaylardan kurulması, projeksiyonlar, snapshot) bu case'e ek doğruluk kazandırmaz ama karmaşıklık ekler (bkz. Bölüm 13). Özet: "Append-only ledger + bakiye projeksiyonu, event sourcing'in ihtiyacım olan kısmı."
Outbox ile bildirim
"Hesabınızdan 500 TL çekildi" bildirimi, işlemle aynı transaction'da outbox_event'e yazılır, relay Kafka'ya taşır; SMS servisi idempotent tüketir (bkz. Bölüm 13). Özet: "Para hareketi ile bildirim aynı kaderi paylaşır; outbox."
Mutabakat
İç (bakiye = ledger toplamı, işlem tipi başına kayıt sayısı) ve dış (sağlayıcı raporu, takas dosyası) mutabakat. Her fark sınıflandırılır, sahibine gider; düzeltme ters kayıtla yapılır. Özet: "Doğruluğu bir kez sağlamak yetmez; her gün kanıtlarım."
Sık yapılan hatalar
- Bakiyeyi okuyup Java'da kontrol edip yazmak → lost update;
CHECKbile yakalamaz → koşullu atomik UPDATE. synchronizedveya JVM kilidi → çoklu pod'da ve commit sonrası işe yaramaz → kilit veritabanında.- İki hesabı istek sırasıyla kilitlemek → ters yönlü exchange'lerde deadlock → id sırasıyla
FOR NO KEY UPDATE. - Para için
doubleveyanew BigDecimal(double)→ ikili temsil hatası →String'denBigDecimal,compareTo. - SQL'de ve Java'da ayrı yuvarlamak → kuruş farkları → tek yuvarlama noktası.
@Transactionaliçinde HTTP çağrısı → bağlantı havuzu ve satır kilitleri dış servise bağlanır → quote ayrı adım, dış çağrı transaction dışında.- Idempotency key'i Redis'te veya ayrı transaction'da tutmak → "key var ama para düşmedi" → iş verisiyle aynı transaction, unique constraint.
- Timeout'u hata sayıp iade etmek → işlem sağlayıcıda gerçekleşmiş olabilir → PENDING, aynı referansla sor, mutabakat.
- 1M/gün için Kafka, sharding, Redis → doğruluk sorununu çözmez, yenilerini ekler → ölç, eşiği söyle.
Hızlı tekrar
S: 1M istek/gün kaç rps eder ve asıl risk nedir?
C: Ortalama ~11,6, tepe çarpanıyla ~100-200 rps; tek PostgreSQL rahat taşır. Asıl risk doğruluk: lost update, tekrar, deadlock, belirsiz dış sonuç.
S: UPDATE ... WHERE balance >= :amount neden güvenli?
C: Satır kilitlenir; ikinci transaction bekler ve READ COMMITTED'da güncel satırla WHERE'i yeniden değerlendirir, yetmezse 0 satır günceller.
S: CHECK (balance >= 0) lost update'i neden yakalamaz?
C: Lost update'te iki işlem aynı eski bakiyeden hesaplayıp yazar; bakiye negatife düşmeden para kaybolur.
S: Exchange'de deadlock nasıl önlenir?
C: İki hesap her zaman id sırasıyla kilitlenir (ORDER BY id FOR NO KEY UPDATE); döngü oluşamaz. FOR UPDATE, önceden eklenen FK'li satırların KEY SHARE kilidiyle çakışıp sırayı bozabilir.
S: Aynı Idempotency-Key ile eşzamanlı iki istek gelirse?
C: İkinci INSERT ... ON CONFLICT DO NOTHING unique index'te bekler; birinci commit olursa ilk cevabı döner, rollback olursa işi kendisi yapar.
S: Quote/execute ayrımı ne sağlar?
C: Müşteri kuru önceden görür, kur TTL boyunca kilitlenir ve execute'ta dış çağrı olmadığı için tüm işlem tek kısa DB transaction'ıdır.
S: Sağlayıcıya trade isteği timeout aldı. Ne yaparsın?
C: Ne başarı ne hata sayarım: PENDING bırakır, aynı referansla sorar, kesin sonuca göre tamamlar ya da ters kayıtla iade eder; kalanı mutabakat kapatır.
S: Bankacılıkta varsayılan yuvarlama ve para karşılaştırması?
C: RoundingMode.HALF_EVEN, para biriminin scale'iyle; karşılaştırma compareTo ile, equals scale'e de bakar.
S: Ledger'ın değişmezliği nasıl sağlanır ve kontrol edilir?
C: Uygulama rolünden UPDATE/DELETE yetkisi alınır (+ trigger); düzeltme ters kayıtla; balance = SUM(ledger) her gün kontrol edilir.
Kaynaklar
- PostgreSQL: Explicit Locking: Satır kilit modları, deadlock ve kilit sırası.
- PostgreSQL: Transaction Isolation: READ COMMITTED'daki yeniden değerlendirme ve 40001 hataları.
- PostgreSQL: SELECT, The Locking Clause:
ORDER BYileFOR UPDATEbirlikteliği. - PostgreSQL: INSERT, ON CONFLICT: Idempotency insert'inin semantiği.
- PostgreSQL: Continuous Archiving and PITR: Tek instance'ın yedek ve geri dönüş planı.
- Stripe API: Idempotent requests: Endüstri referansı idempotency davranışı.
- Designing robust and predictable APIs with idempotency (Stripe blog): Retry, backoff ve jitter ile birlikte idempotency.
- Accounting For Developers, Part I (Modern Treasury): Geliştirici için double-entry ve ledger.
- Spring Framework: JdbcClient: Bölümdeki repository API'si.
- WireMock: Simulating Faults: Gecikme ve bağlantı hatası simülasyonu.