Problem: annotasiya var, amma heç nə atomik deyil

Ödəniş servisi metodu @Transactional ilə işarələyir. Test göstərir ki, üçüncü addım uğursuz olanda ilk iki addım yenə də commit olunub. Başqa halda, uğursuzluqdan sonra qalmalı olan audit qeydi əsas əməliyyatla birlikdə yoxa çıxır. Üçüncü halda isə sorğular yavaş ödəniş gateway-ini gözləyərək açıq transaction-ları saxladığı üçün connection pool tükənir.

Bunların üçü də transaction sərhədi səhvidir və hər biri Spring-in @Transactional-ı necə həyata keçirməsindən qaynaqlanır.

Necə işləyir: bean-in ətrafında proxy

Spring sizin class-ınızı dəyişmir. Bean-də @Transactional metodlar olanda Spring bean-i proxy ilə (JDK dynamic proxy və ya CGLIB alt class-ı) əhatə edir. Çağıranlar proxy alır. Çağırış belə gedir:

caller --> [ proxy ] --> sizin bean metodu
              |
   transaction başla (və ya mövcuda qoşul)
   metodu çağır
   uğurda commit / xətada rollback

Transaction manager bağlantını cari thread-ə bağlayır, buna görə metod daxilindəki hər repository çağırışı eyni connection-dan və eyni transaction-dan istifadə edir. Bu, insanları təəccübləndirən bir neçə qaydanı izah edir:

  • Yalnız proxy-dən keçən çağırışlar tutulur.
  • Proxy əsaslı interception bean-in kənarından çağırılan public metodlar üçün işləyir (private və package-private davranışı proxy rejimindən asılıdır, ona görə annotasiyalı metodları public, bean-i isə non-final saxlayın).
  • Transaction thread-ə bağlıdır: başqa thread-ə verdiyiniz iş ona qoşulmur.

Tələ 1: self-invocation

@Serviceclass ReportService {    public void generate() {        // ...        saveResult();            // "this" üzərində birbaşa çağırış: proxy keçilir    }    @Transactional    public void saveResult() { /* ... */ }}

generate() saveResult()-u proxy üzərində yox, this üzərində çağırır, ona görə transaction başlamır. Annotasiya heç bir xəbərdarlıq olmadan nəzərə alınmır. Həllər, ən sadədən: saveResult()-u başqa bean-ə çıxarıb inject etmək; ya da transactional metodun giriş nöqtəsi olması üçün strukturu dəyişmək; ya da bir class daxilində proqramlı nəzarət üçün TransactionTemplate işlətmək. Bean-i özünə inject etmək işləyir, amma dizayn problemini gizlədir.

Tələ 2: rollback qaydaları

Default olaraq Spring unchecked exception-lar (RuntimeException və alt class-ları) və Error zamanı rollback edir. Checked exception rollback-ı işə salmır; transaction commit olunur.

@Transactionalpublic void pay(Order order) throws PaymentException {   // checked    orders.markPaid(order);    gateway.charge(order);      // PaymentException atır}// markPaid COMMIT OLUNUR, çünki PaymentException checked-dir

Qaydanı açıq yazaraq düzəldin:

@Transactional(rollbackFor = PaymentException.class)

və ya domen exception-ını unchecked edin. Əlaqəli tələlər: metod daxilində exception-ı tutub yenidən atmamaq proxy-yə normal qayıdış kimi görünür və commit baş verir; daxili transactional metod paylaşılan transaction-ı rollback-only işarələyirsə, xarici commit UnexpectedRollbackException ilə uğursuz olur.

Tələ 3: propagation

Propagation, transaction artıq mövcud olanda transactional metod çağırılanda nə olacağını müəyyən edir.

  • REQUIRED (default): mövcud transaction-a qoşul, yoxdursa yenisini başlat. Daxili və xarici taleyi bölüşür: daxili metoddakı rollback hər şeyi geri qaytarır.
  • REQUIRES_NEW: cari transaction-ı dayandır və ayrı connection-da yeni, müstəqil transaction başlat. O, öz-özünə commit və ya rollback olunur.
  • Digərləri də var (SUPPORTS, MANDATORY, NEVER, NESTED), onlara az-az ehtiyac olur.

REQUIRES_NEW biznes əməliyyatı uğursuz olsa da saxlanmalı olan audit log üçün uyğundur:

@Serviceclass AuditService {    @Transactional(propagation = Propagation.REQUIRES_NEW)    public void record(String event) {        auditRepository.save(new AuditEntry(event));    }}

Unutmayın ki, AuditService ayrı bean olmalıdır (self-invocation bölməsinə baxın) və REQUIRES_NEW birinci connection hələ tutularkən ikincini götürür. Yük altında çoxlu iç-içə yeni transaction pool-u deadlock-a sala bilər: hər thread bir connection tutur və digərini gözləyir.

Tələ 4: readOnly

@Transactional(readOnly = true) göstərişdir. Hibernate ilə adətən dirty checking atlanır və flush mode elə qurulur ki, session flush etməsin; bəzi driver və bazalar göstərişi replikaya yönləndirmək və ya yazıdan çəkinmək üçün istifadə edə bilər. Bu təhlükəsizlik mexanizmi deyil və hər bazada yazının uğursuz olması qaydası yoxdur. Niyyəti aydınlaşdırmaq və iş qənaət etmək üçün sorğu metodlarında işlədin.

Əlaqəli tələ: lazy loading üçün açıq session lazımdır. order.getLines()-ı transaction-dan kənarda oxumaq LazyInitializationException atır. Düzgün cavab lazım olanı service daxilində gətirməkdir (fetch join, entity graph və ya DTO projection), session-u web layer boyu açıq saxlamaq deyil.

Tələ 5: uzun transaction-lar və xarici çağırışlar

@Transactionalpublic void checkout(Order order) {    orders.save(order);    paymentGateway.charge(order);       // HTTP çağırışı, saniyələr çəkə bilər    emailClient.sendConfirmation(order); // başqa şəbəkə çağırışı}

Bu işləyərkən baza connection-ı tutulur, sətir kilidləri açıq qalır, pool kiçilir. Daha pisi, problem yalnız sürətdə deyil: email göndərilib sonra transaction rollback olunsa, müştəri mövcud olmayan sifariş üçün təsdiq alıb. Şəbəkə çağırışı geri qaytarıla bilməz.

Daha yaxşı struktur:

  1. Baza işini qısa transaction-da görün.
  2. Commit edin.
  3. Xarici çağırışı commit-dən sonra icra edin, məsələn @TransactionalEventListener(phase = AFTER_COMMIT) ilə, ya da niyyəti eyni transaction-da outbox cədvəlinə yazıb ayrı prosesdən göndərin.
  4. Xarici çağırışları idempotent edin ki, retry təhlükəsiz olsun.

Tez-tez edilən səhvlər

  1. @Transactional-ı vərdişlə controller-ə və ya hər metoda qoymaq. Sərhəd service-də bir use case-in ətrafında olmalıdır.
  2. private və ya eyni class daxilindəki çağırışların transactional olacağını gözləmək.
  3. catch blokunda exception-ı udub normal qayıtmaq.
  4. Verilənlərin ardıcıllığından nədən imtina etdiyini anlamadan "xətanı düzəltmək" üçün REQUIRES_NEW işlətmək.
  5. Transaction daxilində thread və ya reaktiv pipeline başladıb onun qoşulduğunu güman etmək.
  6. Bazanın default isolation səviyyəsini bilmədən default-a güvənmək. Hər səviyyənin nəyin qarşısını aldığı üçün isolation səviyyələri yazısına baxın.
  7. Spring-in @Transactional-ı ilə jakarta.transaction.Transactional-ı rollback default-ları və atributlardakı fərqləri bilmədən qarışdırmaq.

Trade-off-lar

Deklarativ transaction-lar yığcam və oxunaqlıdır, amma sərhədi gizlədir. TransactionTemplate daha açıqdır və proxy məhdudiyyətlərindən qaçır; bir az çox kod tələb edir. Qısa transaction-lar kilid rəqabətini və pool təzyiqini azaldır, amma addımlar arası ardıcıllıq barədə düşünməyə məcbur edir; bu da outbox və idempotent handler kimi patternlərə aparır. Böyük transaction-ları başa düşmək asandır, işlətmək isə pisdir.

Rollback-ı test etmək

Transaction davranışını mock ilə yoxlamaq mümkün deyil, çünki mock-da real transaction yoxdur. Real bazaya qarşı (məsələn Testcontainers ilə) inteqrasiya testi yazın: uğursuzluğu simulyasiya edin və yazının geri qaytarıldığını sorğu ilə yoxlayın.

@Testvoid paymentFailureRollsBackOrderStatus() {    given(gateway.charge(any())).willThrow(new PaymentException("declined"));    assertThatThrownBy(() -> orderService.pay(orderId)).isInstanceOf(PaymentException.class);    assertThat(orderRepository.findById(orderId).orElseThrow().getStatus())            .isEqualTo(OrderStatus.NEW);       // PAID olmamalıdır}

Diqqət: test metodunun özünü @Transactional etsəniz, test bütün işi tək transaction-da icra edir və rollback davranışını gizlədə bilər. Belə testləri transaction-sız qoyun və məlumatı sonda təmizləyin.

Yoxlama siyahısı

  • Transaction sərhədi bir use case-in service metodudur.
  • İçində uzaq sistemlərə çağırış yoxdur, əks halda niyə olduğu qərarlaşdırılıb və sənədləşdirilib.
  • Rollback etməli checked exception-lar rollbackFor-da qeyd olunub və ya exception-lar unchecked-dir.
  • Transactional metodlar public-dir və başqa bean vasitəsilə çağırılır.
  • Sorğu metodlarında readOnly = true.
  • Uğursuzluqda rollback-ı real bazada sübut edən test var.

Sonra nə oxumalı