Spring Boot və Real Dünya: Yüksək Yüklü Ödəniş Sistemində Race Condition, Outbox Pattern və Connection Pool Təcrübəm
Kod blokları tünd IDE stilindədir — seçib copy/paste et; şəkildəki diaqramlara kod yazılmayıb.
Hər şey lokal mühitdə (localhost) mükəmməl işləyir: unit və inteqrasiya testləri yaşıl yanır, bir neçə eşzamanlı istifadəçi ilə sınaqdan keçirəndə heç bir problem yaranmır. Lakin sistem istehsalat mühitinə (production) çıxıb eyni anda minlərlə real ödəniş sorğusu (concurrent request) almağa başlayanda backend-də gözlənilməz kaskad xətaları üzə çıxır.
Monolit arquitekturalardan paylanmış microservice sistemlərinə keçdikcə ən mürəkkəb məsələ biznes kodunun özü yox, şəbəkə gecikmələri (network latency), datanın konsistentliyi və resursların idarə edilməsidir. Bu yazıda real mühitdə qarşılaşdığım üç əsas problemi və onların Spring Boot mühitindəki həll yollarını ətraflı bölüşürəm.
1. Race Condition və Locking Strateqiyaları: Double-Spending Problemi
Eyni istifadəçi zəif internet bağlantısı səbəbindən iki dəfə üst-üstə "Ödə" düyməsinə basdıqda və ya iki fərqli microservice eyni milisaniyədə istifadəçinin balansına müraciət etdikdə nə baş verir?
Təsəvvür edin ki, istifadəçinin balansında $100 var və o, $50 dəyərində iki fərqli sorğu göndərir. Hər iki thread eyni anda bazadan $100 balans oxuyur. Hər ikisi biznes şərtini yoxlayır (balance >= 50), şərt ödənilir və hər iki thread balansı $50 azaldaraq bazaya $50 yazır. Nəticədə istifadəçi $100 balansla $100 dəyərində iki məhsul alır, amma bazada balansı $0 yox, $50 qalır (Double-Spending).
Bu problemi həll etmək üçün JPA üzərində iki əsas locking yanaşmasını müqayisə etdik:
Optimistic Locking (@Version): Cədvələ versiya sütunu əlavə edir. Oxumaq üçün kilid qoymur, ancaq yazarkən versiyanı dəyişir. Yükün az olduğu sistemlərdə çox sürətlidir. Lakin eyni balansa minlərlə paralel müraciət gəldikdə kütləvi OptimisticLockException xətaları yaradır və istifadəçi sorğularının böyük hissəsi imtina edilir.
Pessimistic Locking (SELECT FOR UPDATE): Məhz maliyyə əməliyyatlarında bu yanaşmaya üstünlük verdik. JPA Repository daxilində
@Lock(LockModeType.PESSIMISTIC_WRITE)annotasiyası tətbiq edərək bazada sətir səviyyəsində (row-level lock) bloklama yaratdıq:
JavaWalletRepository.java
public interface WalletRepository extends JpaRepository<Wallet, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT w FROM Wallet w WHERE w.userId = :userId")
Optional<Wallet> findByUserIdWithLock(@Param("userId") Long userId);
}Copy tip: kod panelinin içinə kliklə → Cmd+A → Cmd+C. Syntax rəngləri yalnız görünüş üçündür; paste olunan mətn təmiz Java-dır.
Bu zaman ilk daxil olan thread əməliyyatı bitirib transaction-ı commit edənədək digər thread-lər bazanın gözləmə sırasına (queue) keçir. Nəticədə balans dəqiq və konsistent şəkildə yenilənir.
Şəkil 1: Race Condition yaranma anı və Pessimistic Locking (SELECT FOR UPDATE) mexanizmi

2. Distributed Transaction və Transactional Outbox Pattern
Ödəniş uğurla tamamlandıqdan sonra sistem digər servisləri (məsələn, Bildiriş və ya Faktura servisini) xəbərdar etmək üçün Kafka-ya PaymentCompletedEvent göndərməlidir. İlkin versiyada tez-tez edilən klassik bir səhvi tətbiq etmişdik:
JavaDual-write (səhv)
@Transactionalpublic void processPayment(PaymentRequest request) {
Wallet wallet = walletRepository.findByUserIdWithLock(request.getUserId());
wallet.deduct(request.getAmount());
walletRepository.save(wallet); // 1. DB-də balansı yenilə
kafkaTemplate.send("payment-topic", new PaymentEvent(wallet.getId())); // 2. Kafka-ya mesaj at
}Bu yanaşma böyük bir risk daşıyır (Dual-Write Problem):
Əgər verilənlər bazası commit olunarsa, amma şəbəkə xətası səbəbindən Kafka-ya mesaj getməzsə, ödəniş alınır amma bildiriş göndərilmir.
Əgər Kafka mesajı qəbul edərsə, amma dərhal sonra DB-də xəta baş verib Rollback olarsa, pul çıxılmır amma digər servislər ödənişin keçdiyini zənn edir.
Həlli Transactional Outbox Pattern tətbiq etməkdə tapdıq:
Kafka-ya birbaşa mesaj göndərmək əvəzinə, eyni DB transaction-ı daxilində outbox cədvəlinə event-i yazırıq.
Ayrı bir asinxron background worker (və ya Debezium CDC) outbox cədvəlindən yeni mesajları oxuyaraq Kafka-ya zəmanətli şəkildə çatdırır (At-least-once delivery).
Mesaj Kafka-ya uğurla çatdıqdan sonra outbox cədvəlində status PROCESSED olunur.
Şəkil 2: Transactional Outbox Pattern — DB transaction daxilində outbox yazılması və asinxron Kafka ötürülməsi

3. HikariCP Connection Pool və Uzun Çəkən Transaction-lar
Yüksək trafik altında qarşılaşdığımız ən kritik problemlərdən biri Connection is not available, request timed out after 30000ms xətaları oldu.
Problemin kökünü araşdırarkən bəlli oldu ki, tərtibatçılar bankın xarici REST API-sinə müraciəti də @Transactional metodun daxilində icra edirlər.

1 nəfər bu məqaləni oxuyub
Şərhlər
Şərh yazmaq üçün daxil ol. Daxil olun