یادداشتها راهنما
@Transactional اسپرینگ آن چیزی نیست که فکر میکنید
در بازبینی کد هنوز میبینم که @Transactional مثل یک دعای خیر به کار میرود: رویش بگذار روی یک متد، و پایگاهداده سر به راه میشود. نمیشود. اسپرینگ وقتی تراکنشی را شروع میکند که یک پروکسی فراخوانی ورودی را بگیرد. اگر فراخوانی هرگز به پروکسی نرسد، نه تراکنشی دارید، نه rollbackی، و در عوض یک باگ بسیار گیجکننده در محیط عملیاتی خواهید داشت.
آنچه باور داشتم ← آنچه حالا میکنم
قبلاً «محض احتیاط» روی هر متد سرویس کمی @Transactional میپاشیدم. حالا آن را روی همان یک متدی میگذارم که مرز مورد کاربرد است، تستی مینویسم که rollback را اثبات کند، و فراخوانیهای خصوصی درون یک کلاس را نشانهی بو میدانم.
کل ترفند، همان پروکسی است
یک بین اسپرینگ با @Transactional پیچیده میشود. فراخوانکنندههای بیرون کلاس از دل این پوشش عبور میکنند. پوشش تراکنش را شروع میکند، بعد متد شما را صدا میزند، بعد commit یا rollback میکند.
این فراخوانی تراکنش را شروع میکند:
OrderController → orderService.place(order)
این یکی شروع نمیکند، حتی اگر place() دارای @Transactional باشد:
orderService.place(order)
→ this.saveLines(order) // همان نمونه، بدون پروکسی
this.saveLines() یک فراخوانی سادهی متد جاواست. اسپرینگ اصلاً آن را نمیبیند. اگر saveLines همان متدی بود که حاشیهنویسی کرده بودید، هیچ اتفاقی نمیافتد. به این میگویند فراخوانی درونکلاسی (self-invocation). رایجترین باگ «ولی من که رویش @Transactional گذاشتم» است که اشکالزدایی کردهام.
رفعهایی که در یک PR میپذیرم:
- متد درونی را به بین دیگری منتقل کنید (
LineWriter) تا فراخوانی از یک پروکسی عبور کند. - یک متد عمومی را بهعنوان مرز نگه دارید و کار را در متدهای خصوصی درون همان تراکنش انجام دهید، نه به امید یک
@Transactionalدوم. - از
AopContext.currentProxy()استفاده نکنید، مگر آماده باشید در حادثهی بعدی توضیحش دهید.
REQUIRED پیشفرض است، و میچسبد
Propagation.REQUIRED (پیشفرض) به تراکنش موجود میپیوندد. متدهای @Transactional تودرتو روی بینهای دیگر commit مستقل خودشان را نمیگیرند. اگر متد بیرونی rollback شود، کار درونی هم برمیگردد. معمولاً همین را میخواهید.
REQUIRES_NEW تراکنش بیرونی را معلق میکند و تراکنش درونی را commit میکند، حتی اگر بیرونی بعداً شکست بخورد. من از آن برای لاگهای ممیزی و نوشتنهای outbox استفاده میکنم که باید از یک rollback کسبوکاری جان سالم به در ببرند. به این دلیل که «این متد مهم به نظر میرسد» از آن استفاده نمیکنم.
اگر به REQUIRES_NEW نیاز دارید، به یک بین دوم هم نیاز دارید. REQUIRES_NEW درون همان کلاس باز هم یک فراخوانی درونکلاسی است: شروع نخواهد شد.
rollback یعنی «هر استثنایی» نیست
اسپرینگ بهصورت پیشفرض روی استثناهای unchecked (RuntimeException، Error) rollback میکند. یک استثنای checked باعث commit میشود، مگر خلافش را بگویید:
@Transactional(rollbackFor = Exception.class)
دیدهام که یک سرویس پرداخت سطری در دفتر کل commit کرده، بعد یک RemoteServiceException از نوع checked پرتاب کرده، و بعد به مشتری گفته که ناموفق بود. پایگاهداده نظر دیگری داشت.
در مرز، صریح باشید. اگر متد میتواند طوری شکست بخورد که نتیجهاش نباید ماندگار شود، همان نوعها را در rollbackFor نام ببرید. به «ما فقط استثنای runtime پرتاب میکنیم» بهعنوان یک قرارداد تیمی تکیه نکنید. بالاخره یک نفر یک پوشش checked اضافه میکند.
مرز را کجا میگذارم
- کنترلر: بدون
@Transactional. فقط HTTP را تجزیه میکند و یک متد سرویس را صدا میزند. - متد مورد کاربرد در سرویس: یک حاشیهنویسی، یک واحد کار، با نامی برگرفته از کنش کسبوکار (
placeOrder،closePeriod). - ریپازیتوریها: بدون
@Transactionalاضافه «محض احتیاط». اسپرینگ دیتا از قبل متدهای ریپازیتوری را میپیچد. روی هم چیدنشان مرز واقعی را پنهان میکند.
تستی که دوست دارم ببینم: از طریق سرویس درج کنید، بعد از نوشتن استثنا پرتاب کنید، و بررسی کنید که سطر رفته باشد. اگر نوشتن آن تست سخت است، طراحی ایراد دارد، نه تست.
نکتههای کلیدی
@Transactionalفقط وقتی کار میکند که یک پروکسی اسپرینگ فراخوانی را بگیرد.this.foo()حساب نمیشود.REQUIREDپیشفرض به تراکنش فراخوانکننده میپیوندد.REQUIRES_NEWبرای کاری است که باید commit شود حتی اگر مورد کاربرد شکست بخورد.- استثناهای unchecked بهصورت پیشفرض rollback میکنند. استثناهای checked commit میکنند، مگر
rollbackForرا تنظیم کنید. - یک تراکنش روی مورد کاربرد بگذارید، نه روی هر متدی که به ریپازیتوری دست میزند.