Spring · اسپرینگ متوسطIntermediate ~38 دقیقه مطالعه~31 min read
Spring Data JPA و تراکنشهاSpring Data JPA & Transactions
از صفر تا قهرمان یاد میگیری که پشت هر متد سادهی Spring Data چه خبر است — persistence context، dirty checking، مشکل N+1، دامهای lazy loading، پروکسی @Transactional، قفلگذاری و APIهای کوئری — با تشبیههای ملموس و همان gotchaهایی که در production کمر میشکنند.A zero-to-hero, story-driven tour of what really happens behind each Spring Data method — the persistence context, dirty checking, N+1, lazy-loading traps, the @Transactional proxy, locking, and the query APIs — taught through concrete analogies and the exact gotchas that break production.
پیشنیاز:Prerequisites: هستهٔ Spring: IoC، DI، Bean و AOPSpring Core: IoC, DI, Beans & AOP
بیا رُک باشیم: بیشترِ ما با Spring Data شروع میکنیم، یک findById صدا میزنیم، جواب میگیریم و خوشحالیم — تا روزی که در production چیزی میترکد. یک صفحه که باید ۱ کوئری بزند، ۵۰۱ کوئری میزند. یک save() که ظاهراً کار کرده ولی هیچچیز ذخیره نشده. یک آپدیت که با IOException باید برمیگشت، ولی commit شد و پول از حساب کم شد. تمام اینها ریشه در یک مشت مفهوم دارد که اگر یکبار درست بفهمی، دیگر هیچوقت غافلگیر نمیشوی.
این فصل دقیقاً همان یکبار است. قرار نیست چیزی را حفظ کنی؛ قرار است بفهمی چرا.
اول یک مدل ذهنی میسازیم: سه لایه (Spring Data، JPA، Hibernate) و قلبِ همهچیز، یعنی persistence context. بعد میرویم سراغ:
- چهار حالتِ زندگیِ یک entity و تلههای
merge/save. - dirty checking و flush — چرا بدون
save()هم دیتابیس آپدیت میشود. - مشکل N+1 و سه راهحلش.
- lazy در برابر eager و
LazyInitializationException. - معناشناسی
@Transactional— پروکسی، self-invocation، rollback، propagation، isolation. - قفل خوشبینانه و بدبینانه.
- JPQL / native / Criteria و دامهای صفحهبندی.
- یک چکلیست بهترین رویهها و ۱۵ سؤال مصاحبه با جوابِ کامل.
بخش ۰ — سه کلمه که باید از همین حالا بلد باشی
قبل از هر چیز، سه واژه را باز کنیم تا بعداً سرد و بدون توضیح پرتشان نکنم:
- Entity: یک کلاس جاوا که به یک جدولِ دیتابیس نگاشت شده. مثل
Userکه به جدولusersوصل است. هر شیءِUserمعادلِ یک ردیف است. - EntityManager: کارمندی که واقعاً با دیتابیس حرف میزند. تو به او میگویی «این کاربر را ذخیره کن» و او SQL مینویسد. در Spring معمولاً مستقیم با آن کار نمیکنی؛ Spring Data آن را از تو پنهان میکند.
- Transaction (تراکنش): یک «همه یا هیچ». مجموعهای از تغییرات که یا همه با هم موفق میشوند (commit) یا همه با هم لغو میشوند (rollback). مثل انتقال پول بین دو حساب: یا هر دو طرف اتفاق میافتد یا هیچکدام.
حالا با خیال راحت جلو برویم.
مدل ذهنی: واقعاً چه چیزی اجرا میشود؟
تصور کن به یک رستوران رفتهای. تو (کدِ خودت) فقط به گارسون میگویی «یک استیک میخواهم». گارسون خودش آشپزی نمیکند؛ سفارش را به آشپزخانه میبرد. آشپزخانه است که واقعاً گوشت را میپزد، ظرف را میچیند و پس میفرستد.
در Spring Data:
- گارسون = Spring Data JPA (سفارش را میگیرد، خوشبرخورد است، ولی خودش کاری نمیکند)
- منوی استاندارد رستوران = JPA (قواعد و اصطلاحاتی که همه سرش توافق دارند)
- آشپزخانه = Hibernate (کسی که واقعاً غذا را میپزد، یعنی SQL میسازد و اجرا میکند)
وقتی متدی از یک repository در Spring Data صدا میزنی، سه لایه با هم کار میکنند:
- Spring Data JPA — پیادهسازیِ repository را برایت تولید میکند (derived query،
@Query، صفحهبندی). یک لایهی نازک رویEntityManagerاست. خودش هیچ SQLای اجرا نمیکند. - JPA — خودِ مشخصات/استاندارد است (annotationها،
EntityManager، JPQL). فقط interface و قرارداد است، نه کدِ اجراشونده. - Hibernate — provider پیشفرضِ JPA که کارِ اصلی را انجام میدهد: تولید SQL، مدیریت persistence context، dirty checking و مدیریت connection/تراکنش.
حالا مهمترین جملهی کل این فصل: مهمترین مفهومی که باید همیشه در ذهن داشته باشی persistence context است — که همان first-level cache یا بهاختصار L1 است.
persistence context مثل میزِ کارِ کنارِ دستِ آشپز است، نه انبار (دیتابیس). آشپز ظرفهایی را که همین الان رویشان کار میکند، روی این میز میگذارد. تا وقتی میز باز است، هر تغییری که روی این ظرفها بدهی، آشپز میبیند. لحظهای که سفارش تمام میشود و میز جمع میشود، دیگر آن ظرفها زیرِ نظرِ کسی نیستند.
این میز چیست؟ یک Map<EntityKey, Object> که به یک EntityManager وابسته است و در یک اپلیکیشن معمول Spring به تراکنش گره خورده. یعنی: تراکنش که باز میشود، میز چیده میشود؛ تراکنش که commit میشود، میز جمع میشود.
تمام رفتار JPA — caching، dirty checking، lazy loading، LazyInitializationException، تضمینهای هویت (identity) — از درکِ همین یک شیء بیرون میآید. اگر این تصویر را در ذهن داشته باشی، بقیهی فصل تقریباً بدیهی میشود:
مرز تراکنش ──────────────────────────────────────────────
┌──────────────── PersistenceContext (L1 cache) ─────────┐
│ { (User,1) -> userEntity, (Order,7) -> orderEntity } │
│ • identity map: به ازای هر PK دقیقاً یک instance │
│ • snapshot از حالت لود شده برای dirty checking │
│ • صف اکشنها (INSERT/UPDATE/DELETE) که به DB flush میشود│
└─────────────────────────────────────────────────────────┘
سه چیز روی این میز هست، هر سه را در ادامه لمس میکنیم: یک identity map (بهازای هر کلیدِ اصلی، دقیقاً یک شیء)، یک snapshot (عکسِ لحظهی لود، برای فهمیدنِ اینکه چه چیزی عوض شده)، و یک صفِ اکشن (INSERT/UPDATE/DELETEهایی که هنوز به دیتابیس نرفتهاند).
چرخهی حیاتِ entity: چهار حالتِ زندگی
یک entity را مثل یک شخص در نسبت با «لیستِ حقوقِ شرکت» (persistence context) تصور کن:
- Transient: کسی که تازه از در وارد شده، هنوز استخدام نشده. اسمش هیچجا نیست.
- Managed: کارمندِ رسمیِ فعال. در لیست است، شرکت هوایش را دارد، هر تغییری در وضعیتش ثبت میشود.
- Detached: کارمندِ سابق. یکزمانی در لیست بود، ردیفش هنوز در آرشیو (دیتابیس) هست، ولی الان دیگر کسی حواسش به او نیست.
- Removed: کسی که برگهی اخراجش امضا شده ولی هنوز از سیستم پاک نشده — تا آخرِ ماه (flush) هنوز در آرشیو هست.
هر instance از یک entity همیشه دقیقاً در یکی از این چهار حالت نسبت به persistence context قرار دارد:
| حالت | معنی | در L1؟ | ردیف در DB؟ | ردیابیِ تغییرات؟ |
|---|---|---|---|---|
| Transient (جدید) | تازه new شده، هرگز persist نشده |
خیر | خیر | خیر |
| Managed (persistent) | به یک context باز متصل است | بله | بله (یا در flush) | بله |
| Detached | قبلاً managed بود، context بسته شد | خیر | بله | خیر |
| Removed | برای حذف علامت خورده، هنوز flush نشده | بله | تا flush بله | بله |
بیایید همین چهار حالت را در کد ببینیم — دنبال کن که هر خط، شیء را از یک حالت به حالتِ بعدی میبرد:
User u = new User("Ada"); // TRANSIENT
em.persist(u); // MANAGED (INSERT در صف، شاید هنوز اجرا نشده)
tx.commit(); // flush -> INSERT اجرا میشود
// context بسته میشود -> u حالا DETACHED است
u.setName("Ada Lovelace"); // بیاثر روی DB: detached است، کسی نظارت نمیکند
User merged = em.merge(u); // یک کپیِ MANAGED برمیگرداند؛ خودِ 'u' هنوز detached
em.remove(merged); // REMOVED، DELETE در صف
توجه کن به خطِ em.persist(u): بلافاصله INSERT اجرا نمیشود! فقط در صف میرود. این نکتهی ظریفی است که خیلیها را غافلگیر میکند و در بخشِ flush به آن برمیگردیم.
فراخوانیِ merge(entity) خودِ entity را managed نمیکند؛ بلکه حالتش را در یک instanceِ managedِ جداگانه کپی میکند و آن را برمیگرداند. باید از مقدارِ بازگشتی استفاده کنی؛ آرگومان همچنان detached میماند.
این دقیقاً کسانی را گاز میگیرد که merge را صدا میزنند و بعد آرگومانِ اصلی را تغییر میدهند و انتظار دارند در دیتابیس اثر کند:
User merged = em.merge(u); // این managed است
u.setName("..."); // این هیچ اثری ندارد! u هنوز detached است
merged.setName("..."); // باید روی merged کار کنی
متدِ CrudRepository.save() برای entityهای جدید persist() و برای موجودها merge() را صدا میزند — با isNew() تصمیم میگیرد کدام. حالا فرض کن entityای داری که ID اش را خودت دستی ست کردهای (نه generated). آنوقت isNew() مقدار false برمیگرداند (چون ID دارد)، پس save() فکر میکند این entity قدیمی است و یک merge میزند → و merge قبل از هر INSERT یک SELECT میزند تا ببیند آیا ردیف وجود دارد.
نتیجه: بهازای هر ذخیره، یک کوئریِ اضافه. راهحل: Persistable را پیادهسازی کن، یا @Version بگذار، یا یک فیلدِ auditing با @CreatedDate اضافه کن تا isNew() درست تشخیص دهد که این entity تازه است.
Dirty checking و flush: چرا بدونِ save() هم دیتابیس عوض میشود؟
اینجا اولین جادوی بزرگِ JPA است که تازهکارها را شگفتزده میکند: تو در JPA هرگز update() صدا نمیزنی.
persistence context موقعِ لودِ هر entity یک عکس (snapshot) از حالت آن میگیرد — مثل عکسی که یک کارگاهِ تعمیر ماشین موقعِ تحویلگرفتنِ خودرو میگیرد. لحظهی تسویه (flush)، عکسِ اول را با وضعیتِ فعلی مقایسه میکند. هر جا فرقی میبیند («این خط قبلاً نبود») صورتحساب مینویسد. در JPA این صورتحساب همان UPDATE است.
این مقایسهی خودکار را dirty checking میگویند (یعنی «آیا کثیف/دستخورده شده؟»). ببین چقدر تمیز است — نه save، نه update، هیچ:
@Transactional
public void renameUser(long id) {
User u = repo.findById(id).orElseThrow(); // MANAGED، snapshot گرفته میشود
u.setName("new name"); // نیازی به save() نیست
} // در commit -> flush -> UPDATE users SET name=? WHERE id=?
خب، این کلمهی flush که مدام میگویم چیست؟ Flush یعنی همگامسازیِ context با دیتابیس؛ یعنی اجرای همهی SQLهایی که در صف بودند. اما — و این مهم است — flush یعنی commit نیست. flush فقط دستورها را میفرستد؛ commit است که آنها را دائمی میکند. یک flush میتواند بعداً rollback شود.
حالتِ پیشفرض FlushModeType.AUTO در این دو موقعیت بهطور خودکار flush میکند:
- قبل از commitِ تراکنش،
- قبل از یک کوئریِ JPQL/HQL/Criteria که میتواند تحتِ تأثیرِ تغییراتِ معلق باشد (تا کوئری، نوشتههای خودت را هم ببیند).
حالتِ AUTO قبل از یک کوئریِ native (SQL خام) بهطور خودکار flush نمیکند. این منشأِ یک باگِ کلاسیک است: «کوئریِ nativeِ من ردیفی را که همین الان persist کردم ندید!» چون آن INSERT هنوز در صف بود و به دیتابیس نرفته بود. اگر کوئریِ native میزنی و انتظارِ دیدنِ تغییراتِ معلق را داری، دستی flush کن.
با @Transactional(readOnly = true)، Hibernate حالتِ flush را روی MANUAL میگذارد و اصلاً snapshot نمیگیرد (و Spring در صورتِ پشتیبانیِ درایور، connection را هم read-only علامت میزند). نتیجه: هر تغییری که روی entityهای managed بدهی، بیصدا persist نمیشود — چون کسی snapshot نگرفته که مقایسه کند.
برای مسیرهای فقطخواندنی این عالی است (هم سریعتر، هم امنتر). ولی اگر سهواً در یک متدِ readOnly چیزی را عوض کنی و انتظارِ ذخیرهشدن داشته باشی، یک دامِ تمامعیار است.
مشکل N+1: قاتلِ خاموشِ کارایی
فرض کن لیستِ ۱۰۰ سفارش را گرفتهای و برای هرکدام میخواهی نامِ مشتری را چاپ کنی. راهِ احمقانه: یکبار برو لیستِ سفارشها را بگیر (۱ سفر)، بعد برای هر سفارش یک سفرِ جداگانه برو و مشتریاش را بیاور (۱۰۰ سفر). جمعاً ۱۰۱ سفر برای کاری که با یک سفرِ درست شدنی بود. این همان مشکلِ N+1 است: ۱ کوئری برای والدها، بعلاوهی N کوئری برای فرزندها.
قاتلِ کلاسیکِ کارایی همین است. اول N والد را لود میکنی، بعد یک association از نوعِ lazy را لمس میکنی که بهازای هر والد یک کوئریِ اضافه میزند:
List<Order> orders = orderRepo.findAll(); // ۱ کوئری
for (Order o : orders) {
o.getCustomer().getName(); // N کوئری، یکی به ازای هر order
} // مجموع: N+1
این با collectionهای @OneToMany هم رخ میدهد و — نکتهی مهم — حتی با FetchType.EAGER هم حل نمیشود. EAGER دستهای (batch) لود نمیکند؛ فقط N+1 را زودتر جلو میاندازد. راهحلها را تقریباً بهترتیبِ اولویت ببینیم:
۱. Fetch join (در JPQL): به دیتابیس بگو همه را در یک کوئریِ JOINشده بیاورد.
@Query("select o from Order o join fetch o.customer")
List<Order> findAllWithCustomer();
یک کوئری، همهچیز JOIN شده. عیبش: نمیتوانی دو collection را در یک کوئری fetch-join کنی (ضربِ دکارتی میشود) و صفحهبندیِ سطحِ دیتابیس را هم میشکند (پایینتر مفصل میگوییم).
۲. @EntityGraph — راهِ اعلانی (declarative)؛ با derived query و صفحهبندی هم سازگار است:
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(OrderStatus status);
۳. Batch fetching — بهجای N لودِ lazy، تعدادِ ceil(N/size) کوئری با یک IN میزند. یعنی بهجای ۱۰۰ سفرِ تکنفره، چند اتوبوسِ ۵۰نفره میفرستد:
@BatchSize(size = 50) // روی entity یا collection
@OneToMany(mappedBy = "order")
private List<Item> items;
# پیشفرض سراسری
spring.jpa.properties.hibernate.default_batch_fetch_size=50
برای collectionها این گزینهی عملیِ پیشفرض است، چون از انفجارِ دکارتی جلوگیری میکند.
یک fetch join روی یک collection، ردیفهای تکراریِ والد برمیگرداند (بهازای هر فرزند یک ردیفِ والد). برای همین در JPQL باید distinct بگذاری.
نکتهی نسخه: از Hibernate 6 به بعد، DISTINCTِ واقعیِ SQL بهطور پیشفرض به دیتابیس فرستاده نمیشود وقتی فقط برای حذفِ تکرار در گرافِ entity لازم است (قبلاً برای این کار hibernate.query.passDistinctThrough=false تنظیم میکردی). یعنی حذفِ تکرار را میگیری بدونِ اینکه هزینهی یک distinctِ سنگین سمتِ دیتابیس را بپردازی.
Lazy در برابر eager و LazyInitializationException
یک association از نوعِ lazy مثل نامِ یک غذا در منوست: سبک، همیشه همراهت، ولی خودِ غذا نیست. لحظهای که واقعاً بخواهی بخوریاش (فیلدش را لمس کنی)، تازه به آشپزخانه سفارش میرود. eager یعنی از همان اول، چه بخواهی چه نخواهی، کلِ غذا را هم با منو برایت میآورند.
پیشفرضهایی که باید مثلِ اسمِ خودت حفظ باشی:
@ManyToOne، @OneToOne→ EAGER@OneToMany، @ManyToMany→ LAZY
بهترین رویه: همهچیز را LAZY کن (@ManyToOne(fetch = LAZY)) و بهازای هر use case صریحاً fetch کن. چرا؟ چون EAGER یک تصمیمِ سراسری است که در mapping پخته میشود؛ نمیتوانی آن را per-query خاموش کنی و همهجا بیسروصدا join و N+1 میسازد.
اما lazy یک دامِ معروف دارد. LazyInitializationException وقتی رخ میدهد که یک proxyِ lazy را بعد از بستهشدنِ persistence context لمس کنی — یعنی وقتی سراغِ غذا میروی که آشپزخانه دیگر تعطیل شده:
@Transactional
public Order load(long id){ return repo.findById(id).orElseThrow(); }
// در controller، تراکنش قبلاً commit شده:
order.getItems().size(); // LazyInitializationException: no Session
راهحلها، از درستترین به بدترین:
- آنچه لازم داری را داخلِ تراکنش fetch کن (fetch join یا entity graph). درست.
- یک DTO برگردان که داخلِ تراکنش ساخته شده، نه خودِ entity. بهترین گزینه برای لایهی web.
- Open Session In View (OSIV) — Spring Boot آن را بهطور پیشفرض فعال میکند (
spring.jpa.open-in-view=true). این context را برای کلِ درخواستِ HTTP باز نگه میدارد تا lazy loadها در view «خودکار کار کنند». اما N+1ها را پنهان میکند، یک connection را برای کلِ مدتِ درخواست نگه میدارد، و در سرویسها بهشدت anti-pattern محسوب میشود. خاموشش کن و صریح fetch کن.
هرگز Hibernate.initialize() را اینطرفوآنطرف بهعنوانِ چسبِ زخم برای LazyInitializationException نپاش؛ این نشانهی نبودِ یک fetch planِ درست است، نه راهحل. اگر مجبور شدی این کار را بکنی، یعنی جای دیگری اشتباه fetch کردهای.
معناشناسیِ @Transactional: همهچیز از یک پروکسی شروع میشود
@Transactional مثل یک منشی است که جلوی درِ اتاقِ متدِ تو نشسته. هر کس بخواهد وارد شود، اول باید از منشی رد شود. منشی قبلِ ورود میگوید «تراکنش را باز کردم»، بعدِ خروجِ سالم میگوید «commit کردم»، و اگر با داد و بیداد (exception) بیرون بیایی میگوید «rollback کردم». اما — و اینجا کلِ ماجراست — این منشی فقط کسانی را میبیند که از درِ اصلی وارد شوند. اگر از یک درِ مخفیِ داخلی وارد اتاق شوی، منشی اصلاً خبردار نمیشود.
فنیاش: @Transactional با یک proxyِ Spring AOP پیادهسازی میشود. پروکسی قبل از متدت تراکنش را باز میکند، بعد از returnِ عادی commit و در صورتِ exception، rollback میکند. همین ماهیتِ پروکسی، منشأِ تمامِ gotchaهای زیر است.
دامِ self-invocation (gotcha شماره ۱)
@Service
public class BillingService {
public void run() {
charge(); // ← THIS را صدا میزند، proxy را دور میزند
}
@Transactional
public void charge() { ... } // وقتی از run() صدا زده شود transactional نیست
}
annotation فقط وقتی کار میکند که فراخوانی از میانِ پروکسی عبور کند (یعنی از یک beanِ دیگر بیاید). یک this.charge() داخلی، همان درِ مخفی است: شیءِ خام را صدا میزند، پس @Transactional (و به همین دلیل @Async و @Cacheable هم) بیصدا نادیده گرفته میشود.
راهحلها: متد را به دو beanِ جدا تقسیم کن، یا پروکسی را به خودِ کلاس تزریق کن (self-injection)، یا از AopContext.currentProxy() استفاده کن.
با پروکسیِ پیشفرض (چه JDK dynamic proxy چه CGLIB)، @Transactional روی متدهای private، final یا static هم نادیده گرفته میشود — چون پروکسی از نظرِ فنی نمیتواند اینها را intercept کند. اگر تراکنشت کار نمیکند، اول امضای متد را چک کن.
قوانینِ rollback (gotcha شماره ۲)
اینجا یکی از خطرناکترین رفتارهای پیشفرض است: Spring بهطور پیشفرض فقط روی RuntimeException و Error rollback میکند. یعنی exceptionهای checked باعث rollback نمیشوند و تراکنش با خیالِ راحت commit میشود:
@Transactional
public void doIt() throws IOException {
repo.save(x);
throw new IOException("boom"); // save را COMMIT میکند! چون checked است
}
تصور کن این x یک تراکنشِ مالی باشد و آن IOException یعنی «سرور نتوانست رسید بفرستد» — با رفتارِ پیشفرض، پول کم شده ولی خطا دادهای. برای اصلاح، صریح بگو:
@Transactional(rollbackFor = Exception.class)
اگر یک exception را داخلِ متد catch کنی و دوباره throw نکنی، هیچ rollbackای رخ نمیدهد (منشی داد و بیدادی نشنید). اما یک تلهی ظریفتر: اگر یک فراخوانیِ تودرتو (مثلاً یک متدِ دیگر با تراکنشِ خودش) تراکنش را rollback-only علامت زده باشد، بعد تو exception را بخوری و بخواهی commit کنی، Spring یک UnexpectedRollbackException میاندازد: «گفتی commit، ولی کسی قبلاً این تراکنش را محکوم به rollback کرده بود.»
Propagation (انتشار): وقتی تراکنش تودرتو میشود
Propagation یعنی: وقتی یک متدِ تراکنشی، متدِ تراکنشیِ دیگری را صدا میزند، رفتارشان چطور با هم ترکیب شود؟ آیا در یک تراکنش میمانند یا هرکدام مالِ خودشان؟
| Propagation | رفتار |
|---|---|
REQUIRED (پیشفرض) |
به تراکنشِ فعلی بپیوند، یا یکی بساز |
REQUIRES_NEW |
فعلی را معلق کن، در تراکنشی کاملاً مستقل و جدید اجرا کن |
NESTED |
savepoint داخلِ تراکنشِ فعلی (savepoint سطح JDBC؛ تا savepoint برمیگردد) |
SUPPORTS |
اگر تراکنش باشد بپیوند، وگرنه بدونِ تراکنش |
MANDATORY |
باید از قبل در تراکنش باشد، وگرنه exception |
NOT_SUPPORTED |
هر تراکنش را معلق کن، بدونِ تراکنش اجرا کن |
NEVER |
اگر تراکنشی وجود دارد exception بینداز |
REQUIRES_NEW هنگامِ معلقکردنِ تراکنشِ اولی، از یک connectionِ دوم استفاده میکند (چون اولی هنوز باز و معلق است). حالا تصور کن یک تراکنشِ بیرونی در یک حلقه چندین REQUIRES_NEW صدا بزند: تعدادِ connectionهای همزمان بالا میرود و زیرِ بار میتواند poolِ connection را تمام کند و deadlock بسازد.
نکتهی مثبتش: commitِ آن مستقل است — برای audit logهایی که حتی اگر تراکنشِ بیرونی rollback شود باید باقی بمانند، عالی است. و یادت باشد: REQUIRES_NEW هم اگر با self-invocation صدا زده شود، به همان دلیلِ پروکسیِ بالا اصلاً کار نمیکند.
Isolation (انزوا)
Isolation یعنی: چقدر تراکنشهای همزمان از هم «بیخبر» باشند. به سطحِ isolationِ دیتابیس نگاشت میشود: READ_COMMITTED، REPEATABLE_READ، SERIALIZABLE، READ_UNCOMMITTED.
نکاتِ نسخهای که خوب است بدانی:
- Postgres پیشفرض
READ_COMMITTEDاست و نکته اینکهREAD_UNCOMMITTEDرا اصلاً پیادهسازی نمیکند (اگر بخواهی، مثلِREAD_COMMITTEDرفتار میکند). - MySQL/InnoDB پیشفرض
REPEATABLE_READاست.
سطحِ isolationِ بالاتر، ناهنجاریهای بیشتری را جلوگیری میکند — dirty read (خواندنِ چیزی که هنوز commit نشده)، non-repeatable read (دو خواندنِ مختلف در یک تراکنش) و phantom read (ظاهرشدنِ ردیفهای جدید) — اما به قیمتِ قفلگذاری و retryِ بیشتر.
readOnly
readOnly = true یک hint (اشاره) است، نه یک قانونِ سفتوسخت: حالتِ flush در Hibernate را MANUAL میکند (بدونِ dirty checking) و connectionِ سطحِ JDBC را read-only علامت میزند (درایورها ممکن است بر اساسِ آن به یک replica مسیردهی یا بهینهسازی کنند). ولی این خودش بهتنهایی یک دیتابیسِ فقطخواندنی را تحمیل نمیکند — اگر خیلی جدی SQLِ نوشتاری بزنی، ممکن است هنوز اجرا شود.
قفلِ خوشبینانه در برابر بدبینانه
دو نفر میخواهند یک سند را ویرایش کنند. رویکردِ خوشبینانه: هر دو کپیِ خودشان را میگیرند و کار میکنند؛ موقعِ ذخیره، سیستم چک میکند «آیا از وقتی گرفتی کسی دیگر تغییرش داده؟» اگر آری، به نفرِ دوم میگوید «نسخهات کهنه است، دوباره تلاش کن». رویکردِ بدبینانه: نفرِ اول در را قفل میکند؛ نفرِ دوم پشتِ در منتظر میماند تا او تمام کند. خوشبینانه سریعتر است اگر تعارض کم باشد؛ بدبینانه امنتر است اگر تعارض زیاد باشد.
قفلِ خوشبینانه (@Version): هیچ قفلی روی دیتابیس نمیگذارد؛ تعارضها را فقط هنگامِ نوشتن، با کمکِ یک ستونِ version تشخیص میدهد.
@Entity
class Account {
@Id Long id;
@Version long version; // در هر UPDATE افزایش مییابد
long balance;
}
// UPDATE account SET balance=?, version=? WHERE id=? AND version=?
// اگر ۰ ردیف آپدیت شد -> OptimisticLockException (یکی دیگر زودتر commit کرد)
جادویش در WHERE ... AND version=? است: اگر بینِ لود و نوشتنِ تو، کسِ دیگری این ردیف را عوض کرده باشد، version عوض شده و شرطِ WHERE هیچ ردیفی را پیدا نمیکند → ۰ ردیف آپدیت → OptimisticLockException. بازنده retry میکند. برای تعارضِ کم و throughputِ بالا بهترین است. @Version از انواعِ int/long/short/Timestamp پشتیبانی میکند.
قفلِ بدبینانه (قفلِ واقعیِ دیتابیس با SELECT ... FOR UPDATE): دسترسی را از همان ابتدا سریالایز میکند، یعنی بقیه پشتِ در منتظر میمانند.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.id = :id")
Account findForUpdate(@Param("id") Long id);
PESSIMISTIC_READ→ قفلِ اشتراکی (shared)، PESSIMISTIC_WRITE→ قفلِ انحصاری (FOR UPDATE).- از
PESSIMISTIC_FORCE_INCREMENTبرای افزایشِ@Versionحتی هنگامِ خواندن استفاده کن. - حتماً یک timeout بگذار تا از انتظارِ نامحدود و deadlock جلوگیری کنی:
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
Postgres همچنین از FOR UPDATE NOWAIT و SKIP LOCKED پشتیبانی میکند (که برای بارهای صفمانند فوقالعادهاند — چند worker میتوانند بدونِ انتظار، ردیفهای مختلف را بردارند).
بهطورِ پیشفرض خوشبینانه را انتخاب کن. سراغِ بدبینانه برو وقتی تعارضها مکرر است یا هزینهی retry بالاست — مثلاً کاهشِ موجودیِ انبار در یک ردیفِ «داغ» که هزاران درخواست همزمان به آن میکوبند.
JPQL در برابر native در برابر Criteria
سه راه برای نوشتنِ کوئری داری. تفاوتشان را در یک نگاه ببین:
| API | قابلحمل | type-safe | داینامیک | نکات |
|---|---|---|---|---|
| JPQL/HQL | بله | خیر (رشته) | دشوار | روی entity کار میکند نه جدول. انتخابِ پیشفرض. |
| Native SQL | خیر | خیر | دشوار | همهی امکاناتِ DB؛ برخی تضمینهای JPA را رد میکند (قبلش auto-flush نیست، مراقبِ L1 باش). |
| Criteria | بله | بله | بله | پرحرف؛ بهترین گزینه برای کوئریِ ساختهشده از فیلترهای اختیاری. |
توضیح واژهها: قابلحمل (portable) یعنی اگر دیتابیس را عوض کردی همچنان کار میکند؛ type-safe یعنی کامپایلر خطایت را میگیرد نه اینکه سرِ اجرا بترکد؛ داینامیک یعنی چقدر راحت میتوانی کوئری را بر اساسِ شرایط (مثلاً فیلترهایی که ممکن است باشند یا نباشند) در لحظه بسازی.
// JPQL — به نام entity/فیلد توجه کن نه نام جدول/ستون
@Query("select u from User u where u.email = :email")
Optional<User> byEmail(@Param("email") String email);
// Native — nativeQuery، نام واقعی ستونها
@Query(value = "SELECT * FROM users WHERE email = :email", nativeQuery = true)
Optional<User> byEmailNative(@Param("email") String email);
// کوئری تغییردهنده — باید annotate شود و context پاک شود
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update User u set u.active = false where u.lastLogin < :cutoff")
int deactivateStale(@Param("cutoff") Instant cutoff);
دقت کن که JPQL روی User و u.email کار میکند (اسمِ کلاس و فیلد)، ولی native روی users و ستونِ واقعی. این تفاوت را خیلیها اشتباه میکنند.
یک update/deleteِ انبوه (bulk) مستقیم به SQL میرود و از persistence context عبور میکند. یعنی entityهای managedِ روی میزِ کارت، بعد از آن کوئری کهنه (stale) هستند — هنوز مقدارِ قدیمی را نشان میدهند در حالی که دیتابیس عوض شده. راهحل: clearAutomatically = true (یا دستی em.clear()) بگذار تا میز پاک شود.
و یک هشدارِ دوم: bulk update، cascade و افزایشِ @Version را هم رد میکند. یعنی نه فرزندها cascade میشوند و نه versionها بالا میروند. پس با احتیاط استفاده کن.
دامهای صفحهبندی با join
LIMIT 10 یعنی «۱۰ سفارشِ اول را بده». اما وقتی fetch join روی یک collection انجام میدهی، هر سفارش با هر آیتمش یک ردیفِ جدا میشود؛ ۱۰ سفارش ممکن است ۸۰ ردیف شود. حالا LIMIT 10 روی این ۸۰ ردیف چه معنایی دارد؟ هیچ. برای همین Hibernate میگوید «بیخیالِ LIMIT در SQL» و کلِ نتیجه را میآورد و در حافظه صفحهبندی میکند — مثل کسی که برای برداشتنِ ۱۰ تکه، اول کلِ کیک را میآورد خانه.
تله — join fetch + Pageable روی یک collection. وقتی fetch join روی collection ردیفهای تکراری تولید میکند، Hibernate نمیتواند LIMIT/OFFSET را در SQL اعمال کند، پس کلِ نتیجه را میآورد و صفحهبندی را در حافظه انجام میدهد. این هشدار را در log میبینی: firstResult/maxResults specified with collection fetch; applying in memory. روی یک جدولِ بزرگ باعثِ OOM (پرشدنِ حافظه) میشود.
راهحلها:
- برای associationهای
@ToOneاز@EntityGraphروی یک متدِ derivedِ صفحهبندیشده استفاده کن (در SQL درست صفحهبندی میشود). - برای collectionها رویکردِ دوکوئریای: اول IDها را صفحهبندی کن، بعد collection را برای همان IDها fetch کن.
// ۱) صفحهبندیِ IDهای root (LIMIT کار میکند، بدون join fetch)
Page<Long> ids = repo.findIdsBy(status, pageable);
// ۲) fetch گراف کامل فقط برای همان IDها
List<Order> orders = repo.findWithItemsByIdIn(ids.getContent());
- یا
default_batch_fetch_sizeرا تنظیم کن تا collection بعد از یک کوئریِ rootِ تمیزِ صفحهبندیشده، با کوئریهایINِ دستهای لود شود.
یک Pageable بدونِ مرتبسازیِ پایدار، صفحههای غیرقطعی میدهد — دیتابیس در درخواستهای مختلف ممکن است ردیفها را با ترتیبِ متفاوت برگرداند، پس یک ردیف میتواند در دو صفحه ظاهر شود یا اصلاً دیده نشود. همیشه بر اساسِ یک کلیدِ یکتا مرتب کن.
برای صفحهبندیِ عمیق، keyset/seek pagination را به OFFSET ترجیح بده:
WHERE id > :lastId ORDER BY id LIMIT n
چون OFFSET همهی ردیفهای قبلی را اسکن و دور میریزد و هرچه offset بزرگتر شود کندتر میشود. همچنین یادت باشد Page یک کوئریِ count(*)ِ دوم هم میزند؛ اگر به تعدادِ کل نیاز نداری، از Slice استفاده کن تا آن کوئریِ اضافه را نپردازی.
چکلیستِ بهترین رویهها
اگر فقط یک تصویر از این فصل قاب بگیری، این باشد:
- همهی associationها را
LAZYکن؛ بهازای هر کوئری صریحاً با entity graph / fetch join لود کن. - OSIV را خاموش کن (
spring.jpa.open-in-view=false) و از سرویسها DTO برگردان. -
@Transactionalرا روی لایهی service (متدهای public) بگذار، نه repository و نه controller. - به aggregateهایی که ممکن است همزمان آپدیت شوند
@Versionاضافه کن. - وقتی checked exceptionای میاندازی که میخواهی rollback شود،
rollbackFor = Exception.classبگذار. -
default_batch_fetch_sizeرا تنظیم کن؛ برای write batching مقادیرِhibernate.jdbc.batch_sizeبعلاوهیorder_inserts/order_updatesرا فعال کن. - در محیطِ dev لاگِ SQL را روشن کن (
show-sql+ format_sql)، و برای گرفتنِ زودهنگامِ N+1 ازLOG_QUERIES_SLOWER_THAN_MSیا یکdatasource-proxyاستفاده کن. - هرگز کارِ کند (مثلِ فراخوانیِ HTTP) را داخلِ تراکنش نگذار — کلِ آن مدت یک connectionِ دیتابیس را نگه میداری.
سؤالات مصاحبه
حالا وقتِ آن است که بفهمی همهی اینها در اتاقِ مصاحبه چطور پرسیده میشوند. هر سؤال را جوری بخوان که انگار خودت باید جواب بدهی.
همان L1 cache / identity map است که entityهای managed را برای یک EntityManager نگه میدارد. در Spring پیشفرض به تراکنش وابسته است: با شروعِ تراکنش باز و در commit فلاش و بسته میشود. درونِ آن، هر PK دقیقاً به یک instance نگاشت میشود (تضمینِ identity) که dirty checking را ممکن و لودِ تکراری را حذف میکند.
بله، اگر داخلِ تراکنشِ فعال باشی. entity، managed است؛ در flush، Hibernate آن را با snapshotِ لحظهی لود مقایسه میکند (dirty checking) و UPDATE میزند. برای entityِ managed نیازی به save() نیست.
هیچ آپدیتی نمیشود. readOnly حالتِ flush را MANUAL میکند و Hibernate اصلاً snapshot/dirty checking نمیگیرد، پس تغییر بیصدا دور ریخته میشود.
لودِ N والد و سپس یک association از نوعِ lazy، بهازای هر والد یک کوئری میزند → N+1. راهحلها: (الف) join fetch در JPQL — یک کوئریِ joinشده؛ (ب) @EntityGraph — اعلانی و سازگار با صفحهبندی؛ (ج) @BatchSize / default_batch_fetch_size — لودِ associationها با کوئریهای INِ دستهای. fetch join روی collection صفحهبندی را میشکند؛ آنجا batch size انتخابِ امن است.
self-invocation: صداکردنِ آن با this.method() از پروکسیِ Spring عبور نمیکند، پس هیچ adviceای اجرا نمیشود. همچنین روی متدهای private/final/static و وقتی کلاس یک Spring bean نیست، نادیده گرفته میشود. راهحل: از میانِ beanِ دیگر یا پروکسیِ تزریقشده صدا بزن.
خیر — Spring پیشفرض فقط روی RuntimeException/Error rollback میکند. checked exceptionها commit میشوند. از rollbackFor = Exception.class استفاده کن.
@Transactional
public void run(){
Account a = repo.findById(1L).get();
Account b = repo.findById(1L).get();
System.out.println(a == b);
}
true. persistence context یک identity map است: findByIdِ دوم همان instanceِ managed را از L1 برمیگرداند، بدونِ SELECTِ دوم. این تضمینِ identity فقط درونِ یک persistence context برقرار است.
@Transactional
public Order get(long id){ return repo.findById(id).orElseThrow(); }
// جای دیگر، بدون تراکنش:
service.get(1).getItems().forEach(...); // items از نوع LAZY است
LazyInitializationException: context با returnِ متد بسته شد، پس collectionِ lazy نمیتواند initialize شود. راهحل: items را داخلِ تراکنش fetch کن (entity graph/fetch join) یا DTO برگردان.
خوشبینانه (@Version): بدونِ قفل، تشخیص هنگامِ نوشتن با بررسیِ version، بازنده retry میکند — بهترین برای تعارضِ کم/throughputِ بالا. بدبینانه (SELECT FOR UPDATE): قفلِ DB از ابتدا، دسترسی را سریالایز میکند — بهترین برای ردیفهای داغ / تعارضِ بالا که retry پرهزینه است. همیشه یک lock timeout بگذار تا از deadlock جلوگیری شود.
هنگامِ fetch یک collection، Hibernate نمیتواند LIMIT/OFFSET را به SQL بدهد (ردیفِ تکراری)، پس کلِ نتیجه را لود و در حافظه صفحهبندی میکند. راهحل: اول IDها را صفحهبندی و بعد fetch کن، یا @BatchSize. دنبالِ هشدارِ «applying in memory» بگرد.
updateهای انبوه SQL خام اجرا میکنند و از persistence context عبور میکنند. entityهای managed کهنه میشوند، cascade و @Version اعمال نمیشوند. از clearAutomatically = true / flushAutomatically = true استفاده کن.
persist: یک entity از transient را managed میکند (INSERT)، خروجی void، اگر از قبل وجود داشته باشد خطا میدهد. merge: حالتِ detached را در یک instanceِ managed کپی میکند و آن را برمیگرداند (آرگومان detached میماند). save: برای entityهای جدید persist (isNew()) و برای موجودها merge انتخاب میکند — که اگر isNew() روی IDهای دستی اشتباه کند میتواند SELECTِ اضافی بزند.
هر REQUIRES_NEW یک connectionِ فیزیکیِ دوم باز میکند در حالی که بیرونی معلق است. با poolِ کوچک و تراکنشهای داخلیِ کند میتوانی connectionها را تمام و deadlock کنی. همچنین اگر با self-call صدا زده شود، propagation کاملاً نادیده گرفته میشود.
Open-Session-In-View، persistence context را برای کلِ درخواستِ HTTP باز نگه میدارد (در Spring Boot پیشفرض روشن). lazy loading را در view کار میاندازد اما N+1ها را پنهان میکند، یک connection را برای کلِ درخواست (از جمله renderشدنِ view) نگه میدارد و کارِ DB را بیرون از مرزِ service میکشد. خاموشش کن و صریح fetch کن / DTO برگردان.
EAGER دستهای لود نمیکند — ممکن است N کوئریِ ثانویه بزند (یا یک joinِ سنگین). LAZY کردن، هزینهی پیشاپیش را حذف میکند اما N+1 را خودش حل نمیکند؛ اگر بعداً در یک حلقه association را لمس کنی باز N+1 میگیری. برای مسیرهایی که به association نیاز دارند باید یک fetch planِ صریح (fetch join / entity graph / batch size) اضافه کنی.
همهچیز از یک شیء شروع میشود: persistence context — میزِ کارِ آشپز که به تراکنش گره خورده. روی این میز یک identity map (بهازای هر PK یک شیء)، یک snapshot (برای dirty checking) و یک صفِ اکشن هست.
- چون snapshot هست، بدونِ save() هم dirty checking دیتابیس را آپدیت میکند — مگر
readOnly=trueکه این را خاموش میکند. - N+1 یعنی ۱ کوئریِ والد + N کوئریِ فرزند؛ با fetch join /
@EntityGraph/ batch size حلش کن.EAGERحلش نمیکند. - LazyInitializationException یعنی بعد از بستهشدنِ میز سراغِ غذا رفتی؛ داخلِ تراکنش fetch کن یا DTO برگردان. OSIV را خاموش کن.
- @Transactional یک پروکسی است: self-invocation و متدهای private/final/static آن را دور میزنند؛ فقط
RuntimeException/Errorباعثِ rollback میشود (checked نه —rollbackForبگذار)؛ propagation و isolation رفتارِ تودرتو و انزوا را تعیین میکنند. - قفل: پیشفرض خوشبینانه (
@Version)، بدبینانه (FOR UPDATE) برای ردیفهای داغ. - کوئری: JPQL پیشفرض، native برای امکاناتِ خاص، Criteria برای داینامیک؛ و مراقبِ
@Modifyingو دامهای صفحهبندی باش.
اگر این تصویرِ واحد را در ذهن نگه داری، دیگر هیچکدام از این gotchaها در production غافلگیرت نمیکنند.
Let's be honest: most of us start with Spring Data, call a findById, get a result, and feel great — until the day something explodes in production. A page that should run 1 query runs 501. A save() that "worked" but stored nothing. An update that should have rolled back on an IOException but instead committed and drained an account. Every one of these traces back to a small set of ideas that, once you truly understand them, will never surprise you again.
This chapter is that one time you learn them for good. You won't memorize anything; you'll understand why.
First we build a mental model: three layers (Spring Data, JPA, Hibernate) and the heart of everything, the persistence context. Then:
- The four lifecycle states of an entity, plus the
merge/savetraps. - Dirty checking and flush — why the DB updates without a
save(). - The N+1 problem and its three fixes.
- Lazy vs eager and
LazyInitializationException. @Transactionalsemantics — the proxy, self-invocation, rollback, propagation, isolation.- Optimistic vs pessimistic locking.
- JPQL / native / Criteria and pagination pitfalls.
- A best-practices checklist and 15 interview questions with full answers.
Part 0 — three words you need up front
Before anything, let's unpack three terms so I never drop them on you cold later:
- Entity: a Java class mapped to a database table. Think
Usermapped to theuserstable. EachUserobject equals one row. - EntityManager: the clerk who actually talks to the database. You tell it "save this user" and it writes SQL. In Spring you rarely touch it directly; Spring Data hides it from you.
- Transaction: an "all or nothing." A set of changes that all succeed together (commit) or all get undone together (rollback). Like moving money between two accounts: either both sides happen or neither does.
Now we can move forward with confidence.
Mental model: what actually runs
Imagine you're at a restaurant. You (your code) only tell the waiter "I'd like a steak." The waiter doesn't cook; he carries the order to the kitchen. The kitchen is what actually cooks the meat, plates it, and sends it back.
In Spring Data:
- Waiter = Spring Data JPA (takes the order, friendly, does no real work)
- The restaurant's standard menu = JPA (the rules and terms everyone agrees on)
- Kitchen = Hibernate (the one who actually cooks — generates and runs SQL)
When you call a Spring Data repository method, three layers cooperate:
- Spring Data JPA — generates the repository implementation for you (derived queries,
@Query, paging). It's a thin façade over anEntityManager. It runs no SQL itself. - JPA — the specification/standard (annotations,
EntityManager, JPQL). Interface and contract only, not runnable code. - Hibernate — the default JPA provider that does the real work: SQL generation, the persistence context, dirty checking, and connection/transaction handling.
Now the single most important sentence of the whole chapter: the one object to always hold in your head is the persistence context — a.k.a. the first-level cache, or L1.
The persistence context is like the chef's prep counter, not the pantry (the database). The chef puts the dishes he's actively working on right there. As long as the counter is out, every change you make to those dishes is seen by the chef. The moment the order is done and the counter is cleared, nobody is watching those dishes anymore.
What is this counter? A Map<EntityKey, Object> scoped to an EntityManager, which in a typical Spring app is bound to the transaction. So: the transaction opens, the counter is set; the transaction commits, the counter is cleared.
Everything about JPA behavior — caching, dirty checking, lazy loading, LazyInitializationException, identity guarantees — falls out of understanding this one object. Hold this picture and the rest of the chapter becomes almost obvious:
Transaction boundary ──────────────────────────────────────
┌──────────────── PersistenceContext (L1 cache) ─────────┐
│ { (User,1) -> userEntity, (Order,7) -> orderEntity } │
│ • identity map: one managed instance per PK │
│ • snapshot of loaded state for dirty checking │
│ • action queue (INSERT/UPDATE/DELETE), flushed to DB │
└─────────────────────────────────────────────────────────┘
Three things live on this counter, and we'll touch all three: an identity map (exactly one object per primary key), a snapshot (a photo taken at load time, to know what changed), and an action queue (INSERTs/UPDATEs/DELETEs not yet sent to the DB).
Entity lifecycle: four states of being
Think of an entity in relation to the "company payroll" (the persistence context):
- Transient: someone who just walked in, not hired yet. Their name is nowhere.
- Managed: an active, official employee. On the list, the company watches them, every change to their status is recorded.
- Detached: a former employee. Once on the list, their row is still in the archive (DB), but nobody is watching them now.
- Removed: someone whose termination is signed but not yet purged — until end of month (flush) they're still in the archive.
An entity instance is always in exactly one of four states relative to a persistence context:
| State | Meaning | In L1? | Has DB row? | Tracked for changes? |
|---|---|---|---|---|
| Transient (new) | Just new-ed, never persisted |
No | No | No |
| Managed (persistent) | Attached to an open context | Yes | Yes (or on flush) | Yes |
| Detached | Was managed, context closed/evicted | No | Yes | No |
| Removed | Marked for deletion, not yet flushed | Yes | Yes until flush | Yes |
Let's watch these four states in code — follow how each line moves the object from one state to the next:
User u = new User("Ada"); // TRANSIENT
em.persist(u); // MANAGED (INSERT queued, maybe not yet run)
tx.commit(); // flush -> INSERT executed
// context closes -> u is now DETACHED
u.setName("Ada Lovelace"); // no effect on DB: detached, nobody watching
User merged = em.merge(u); // returns a MANAGED copy; 'u' stays detached
em.remove(merged); // REMOVED, DELETE queued
Notice the em.persist(u) line: the INSERT does not run immediately! It's only queued. That subtle point trips up many people, and we'll come back to it under flush.
Calling merge(entity) does not make entity managed; it copies its state into a separate managed instance and returns that. You must use the return value; the argument stays detached.
This bites exactly the people who call merge and then mutate the original argument expecting it to hit the DB:
User merged = em.merge(u); // this one is managed
u.setName("..."); // no effect! u is still detached
merged.setName("..."); // you must work on merged
CrudRepository.save() calls persist() for new entities and merge() for existing ones — it decides using isNew(). Now suppose you have an entity whose ID you set manually (not generated). Then isNew() returns false (because it has an ID), so save() thinks the entity is old and does a merge → and merge runs a SELECT before every insert to check whether the row exists.
Result: an extra query per save. Fix: implement Persistable, or add @Version, or add an auditing field with @CreatedDate so isNew() correctly detects a fresh entity.
Dirty checking and flush: why the DB changes without save()
Here's JPA's first big magic trick that stuns beginners: in JPA you never call update().
On loading each entity, the persistence context takes a snapshot of its state — like the photo an auto-repair shop takes when it receives your car. At settlement time (flush), it compares the first photo to the current state. Wherever it finds a difference ("that scratch wasn't there before") it writes a bill. In JPA that bill is an UPDATE.
This automatic comparison is called dirty checking (as in "did it get dirty / touched?"). Look how clean it is — no save, no update, nothing:
@Transactional
public void renameUser(long id) {
User u = repo.findById(id).orElseThrow(); // MANAGED, snapshot taken
u.setName("new name"); // no save() call needed
} // on commit -> flush -> UPDATE users SET name=? WHERE id=?
So what is this word flush I keep using? Flush means synchronizing the context with the database; running all the SQL that was queued. But — and this matters — flush is not commit. Flush just sends the statements; commit is what makes them permanent. A flush can still be rolled back later.
The default FlushModeType.AUTO auto-flushes in these two moments:
- before the transaction commits,
- before a JPQL/HQL/Criteria query that could be affected by pending changes (so the query sees your own writes).
AUTO mode does not auto-flush before a native (raw SQL) query. This is the source of a classic bug: "my native query didn't see the row I just persisted!" — because that INSERT was still queued and hadn't reached the DB. If you run a native query and expect it to see pending changes, flush manually.
With @Transactional(readOnly = true), Hibernate sets flush mode to MANUAL and skips taking snapshots entirely (and Spring marks the JDBC connection read-only where the driver honors it). Result: any change you make to managed entities is silently not persisted — because nobody took a snapshot to compare against.
For read-only paths this is great (faster and safer). But if you accidentally mutate something in a readOnly method and expect it saved, it's a full-blown trap.
The N+1 problem: the silent performance killer
Say you have a list of 100 orders and you want to print each customer's name. The dumb way: go once to fetch the order list (1 trip), then for each order make a separate trip to fetch its customer (100 trips). That's 101 trips for a job doable in one proper trip. That's the N+1 problem: 1 query for the parents, plus N queries for the children.
This is the classic performance killer. You load N parents, then touch a lazy association, triggering one extra query per parent:
List<Order> orders = orderRepo.findAll(); // 1 query
for (Order o : orders) {
o.getCustomer().getName(); // N queries, one per order
} // total: N+1
It also happens with @OneToMany collections and — important — even FetchType.EAGER doesn't fix it. EAGER doesn't batch; it just moves the N+1 earlier. Fixes, in rough order of preference:
1. Fetch join (JPQL): tell the DB to bring everything in one JOINed query.
@Query("select o from Order o join fetch o.customer")
List<Order> findAllWithCustomer();
One query, JOINed. Downside: you cannot fetch-join two collections in one query (Cartesian product) and it breaks DB-level pagination (detailed below).
2. @EntityGraph — the declarative way; composes with derived queries and paging:
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(OrderStatus status);
3. Batch fetching — turns N lazy loads into ceil(N/size) queries with an IN clause. Instead of 100 single-person trips, it sends a few 50-seat buses:
@BatchSize(size = 50) // on the entity or the collection
@OneToMany(mappedBy = "order")
private List<Item> items;
# global default
spring.jpa.properties.hibernate.default_batch_fetch_size=50
Batch fetching is the pragmatic default for collections because it avoids Cartesian explosions.
A fetch join on a collection returns duplicate parent rows (one parent row per child). That's why you add distinct in JPQL.
Version note: since Hibernate 6, the real SQL DISTINCT is not sent to the DB by default when it's only needed to de-duplicate the entity graph (previously you'd set hibernate.query.passDistinctThrough=false). So you get de-duplication without paying for a costly DB-side distinct.
Lazy vs eager & LazyInitializationException
A lazy association is like a dish's name on a menu: lightweight, always with you, but not the dish itself. The moment you actually want to eat it (touch its field), the order finally goes to the kitchen. eager means from the very start, whether you want it or not, they bring the whole meal along with the menu.
Defaults you must memorize like your own name:
@ManyToOne,@OneToOne→ EAGER@OneToMany,@ManyToMany→ LAZY
Best practice: make everything LAZY (@ManyToOne(fetch = LAZY)) and fetch explicitly per use case. Why? Because EAGER is a global decision baked into the mapping; you can't turn it off per query, and it silently creates joins/N+1 everywhere.
But lazy has a famous trap. LazyInitializationException happens when you touch a lazy proxy after the persistence context is closed — i.e. you go for the meal but the kitchen is already closed:
@Transactional
public Order load(long id){ return repo.findById(id).orElseThrow(); }
// controller, tx already committed:
order.getItems().size(); // LazyInitializationException: no Session
Fixes, from most correct to worst:
- Fetch what you need inside the transaction (fetch join or entity graph). Correct.
- Return a DTO built inside the tx, not the entity. Best for web layers.
- Open Session In View (OSIV) — Spring Boot enables it by default (
spring.jpa.open-in-view=true). It keeps the context open for the whole HTTP request so lazy loads "just work" in the view. But it hides N+1s, holds a DB connection for the request duration, and is widely considered an anti-pattern in services. Disable it and fetch explicitly.
Never scatter Hibernate.initialize() around as a band-aid for LazyInitializationException; it's a smell for a missing fetch plan, not a fix. If you find yourself doing it, you fetched wrong somewhere else.
@Transactional semantics: it all starts with a proxy
@Transactional is like a receptionist sitting outside your method's room. Anyone who wants in must pass the receptionist first. Before entry she says "I opened a transaction," after a clean exit she says "I committed," and if you storm out shouting (an exception) she says "I rolled back." But — and this is the whole story — she only sees people who come through the front door. If you slip in through a hidden internal door, she never even notices.
Technically: @Transactional is implemented with a Spring AOP proxy. The proxy opens a transaction before your method, commits after a normal return, and rolls back on exception. This proxy nature drives every gotcha below.
The self-invocation trap (gotcha #1)
@Service
public class BillingService {
public void run() {
charge(); // ← calls THIS, bypasses the proxy
}
@Transactional
public void charge() { ... } // NOT transactional when called via run()
}
The annotation only works when the call goes through the proxy (i.e., comes from another bean). An internal this.charge() is that hidden door: it calls the raw object, so @Transactional (and therefore @Async and @Cacheable too) is silently ignored.
Fixes: split into two beans, self-inject the proxy, or use AopContext.currentProxy().
With the default proxy (JDK dynamic proxy or CGLIB), @Transactional on private, final, or static methods is also ignored — because the proxy technically can't intercept those. If your transaction isn't working, check the method signature first.
Rollback rules (gotcha #2)
Here's one of the most dangerous default behaviors: Spring by default rolls back only on RuntimeException and Error. That means checked exceptions do NOT trigger rollback and the transaction happily commits:
@Transactional
public void doIt() throws IOException {
repo.save(x);
throw new IOException("boom"); // COMMITS the save! checked exception
}
Imagine x is a financial transaction and that IOException means "couldn't send the receipt" — with the default behavior, the money is gone yet you threw an error. To fix, say it explicitly:
@Transactional(rollbackFor = Exception.class)
If you catch an exception inside the method and don't rethrow, no rollback happens (the receptionist heard no shouting). But a subtler trap: if a nested call (say another method with its own transaction) already marked the transaction rollback-only, and then you swallow the exception and try to commit, Spring throws an UnexpectedRollbackException: "you said commit, but someone already condemned this transaction to rollback."
Propagation: when transactions nest
Propagation means: when a transactional method calls another transactional method, how do their behaviors combine? Do they share one transaction or does each get its own?
| Propagation | Behavior |
|---|---|
REQUIRED (default) |
Join current tx, or start one |
REQUIRES_NEW |
Suspend current, run in a brand-new independent tx |
NESTED |
Savepoint inside current tx (JDBC savepoints; rolls back to savepoint) |
SUPPORTS |
Join if exists, else non-transactional |
MANDATORY |
Must already be in a tx, else throw |
NOT_SUPPORTED |
Suspend any tx, run non-transactionally |
NEVER |
Throw if a tx exists |
REQUIRES_NEW uses a second connection while suspending the first (because the first stays open and suspended). Now imagine an outer transaction calling many REQUIRES_NEW in a loop: the number of simultaneous connections rises and, under load, can exhaust the connection pool and deadlock.
Its upside: its commit is independent — perfect for audit logs that must survive even if the outer transaction rolls back. And remember: REQUIRES_NEW via self-invocation also doesn't work, for the same proxy reason above.
Isolation
Isolation means: how "unaware" concurrent transactions are of each other. It maps to the DB's isolation level: READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE, READ_UNCOMMITTED.
Version notes worth knowing:
- Postgres defaults to
READ_COMMITTEDand, notably, does not implementREAD_UNCOMMITTED(if you ask for it, it behaves asREAD_COMMITTED). - MySQL/InnoDB defaults to
REPEATABLE_READ.
Higher isolation prevents more anomalies — dirty read (reading something not yet committed), non-repeatable read (two different reads in one tx), and phantom read (new rows appearing) — at the cost of more locking and retries.
readOnly
readOnly = true is a hint, not a hard rule: it sets Hibernate's flush mode to MANUAL (no dirty checking) and marks the JDBC connection read-only (drivers may route to a replica or optimize based on it). But by itself it does not enforce a read-only database — if you really run write SQL, it may still execute.
Optimistic vs pessimistic locking
Two people want to edit one document. The optimistic approach: both take their own copy and work; at save time the system checks "did anyone else change it since you took it?" If yes, it tells the second person "your version is stale, try again." The pessimistic approach: the first person locks the door; the second waits outside until they're done. Optimistic is faster if conflicts are rare; pessimistic is safer if conflicts are frequent.
Optimistic locking (@Version): no DB locks; detect conflicts at write time via a version column.
@Entity
class Account {
@Id Long id;
@Version long version; // bumped on every UPDATE
long balance;
}
// UPDATE account SET balance=?, version=? WHERE id=? AND version=?
// 0 rows updated -> OptimisticLockException (someone else committed first)
The magic is in WHERE ... AND version=?: if someone else changed this row between your load and your write, version moved and the WHERE finds no row → 0 rows updated → OptimisticLockException. The loser retries. Best for low contention, high throughput. @Version supports int/long/short/Timestamp.
Pessimistic locking (real DB locks via SELECT ... FOR UPDATE): serialize access up front, i.e. others wait outside the door.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.id = :id")
Account findForUpdate(@Param("id") Long id);
PESSIMISTIC_READ→ shared lock,PESSIMISTIC_WRITE→ exclusive lock (FOR UPDATE).- Use
PESSIMISTIC_FORCE_INCREMENTto bump@Versioneven on a read. - Always set a timeout to avoid indefinite waits and deadlocks:
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
Postgres also supports FOR UPDATE NOWAIT and SKIP LOCKED (great for queue-style workloads — several workers can grab different rows without waiting).
Choose optimistic by default. Go pessimistic when conflicts are frequent or the retry cost is high — e.g. decrementing inventory on a hot row that thousands of concurrent requests hammer at once.
JPQL vs native vs Criteria
You have three ways to write queries. See the difference at a glance:
| API | Portable | Type-safe | Dynamic | Notes |
|---|---|---|---|---|
| JPQL/HQL | Yes | No (string) | Awkward | Operates on entities, not tables. Default choice. |
| Native SQL | No | No | Awkward | Full DB features; skips some JPA guarantees (no auto-flush before it, careful with L1). |
| Criteria | Yes | Yes | Yes | Verbose; best for query built from optional filters. |
Word check: portable means it still works if you switch databases; type-safe means the compiler catches your mistake instead of it blowing up at runtime; dynamic means how easily you can build the query on the fly based on conditions (e.g. filters that may or may not be present).
// JPQL — note entity/field names, not table/column names
@Query("select u from User u where u.email = :email")
Optional<User> byEmail(@Param("email") String email);
// Native — nativeQuery, real column names
@Query(value = "SELECT * FROM users WHERE email = :email", nativeQuery = true)
Optional<User> byEmailNative(@Param("email") String email);
// Modifying query — MUST annotate, and clear the context
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update User u set u.active = false where u.lastLogin < :cutoff")
int deactivateStale(@Param("cutoff") Instant cutoff);
Notice JPQL works on User and u.email (class and field names), while native works on users and the real column. Many people get this difference wrong.
A bulk update/delete goes straight to SQL and bypasses the persistence context. That means the managed entities on your prep counter are stale after that query — still showing the old value while the DB has changed. Fix: set clearAutomatically = true (or em.clear() manually) to wipe the counter.
A second warning: a bulk update also skips cascade and @Version bumps. Neither children are cascaded nor versions incremented. So use it with care.
Pagination pitfalls with joins
LIMIT 10 means "give me the first 10 orders." But when you fetch-join a collection, each order becomes one row per item; 10 orders might become 80 rows. Now what does LIMIT 10 mean over those 80 rows? Nothing. So Hibernate says "forget LIMIT in SQL," fetches the whole result, and paginates in memory — like someone who, to take 10 slices, first brings the entire cake home.
Gotcha — join fetch + Pageable on a collection. When a fetch join on a collection produces duplicate rows, Hibernate cannot apply LIMIT/OFFSET in SQL, so it fetches the entire result set and paginates in memory. You'll see the log warning: firstResult/maxResults specified with collection fetch; applying in memory. On a big table this OOMs (out-of-memory).
Fixes:
- For
@ToOneassociations, use@EntityGraphon a paged derived method (paginates in SQL fine). - For collections, do a two-query approach: page the IDs first, then fetch the collection for those IDs.
// 1) page the root IDs (SQL LIMIT works, no join fetch)
Page<Long> ids = repo.findIdsBy(status, pageable);
// 2) fetch full graph for just those IDs
List<Order> orders = repo.findWithItemsByIdIn(ids.getContent());
- Or set
default_batch_fetch_sizeso the collection loads in batchedINqueries after a clean paged root query.
A Pageable without a stable sort gives non-deterministic pages — the DB may return rows in different order across requests, so one row can appear on two pages or never show up at all. Always sort by a unique key.
For deep pagination, prefer keyset/seek pagination over OFFSET:
WHERE id > :lastId ORDER BY id LIMIT n
because OFFSET scans and discards all preceding rows and gets slower as the offset grows. Also remember Page runs a second count(*) query; if you don't need the total, use Slice to skip that extra query.
Best practices checklist
If you frame just one image from this chapter, make it this:
- Make all associations
LAZY; fetch explicitly per query with entity graphs / fetch joins. - Turn OSIV off (
spring.jpa.open-in-view=false) and return DTOs from services. - Keep
@Transactionalon the service layer (public methods), not repositories or controllers. - Add
@Versionto aggregates that can be concurrently updated. - Set
rollbackFor = Exception.classwhen you throw checked exceptions you want to roll back. - Configure
default_batch_fetch_size; enablehibernate.jdbc.batch_size+order_inserts/order_updatesfor write batching. - Log SQL in dev (
show-sql+format_sql), and addLOG_QUERIES_SLOWER_THAN_MSor adatasource-proxyto catch N+1 early. - Never put slow work (an HTTP call) inside a transaction — you hold a DB connection the whole time.
Interview Questions
Now it's time to see how all this gets asked in the interview room. Read each question as if you had to answer it yourself.
It's the L1 cache / identity map holding managed entities for an EntityManager. In Spring it's transaction-scoped by default: opened when the transaction starts, flushed and closed at commit. Within it, one PK maps to exactly one object instance (identity guarantee), enabling dirty checking and avoiding duplicate loads.
Yes, if inside an active transaction. The entity is managed; on flush Hibernate compares it to its load-time snapshot (dirty checking) and issues an UPDATE. save() is unnecessary for managed entities.
No update. readOnly sets flush mode to MANUAL and Hibernate skips snapshots/dirty checking entirely, so the change is silently discarded.
Loading N parents then a lazy association triggers one query per parent → N+1. Fixes: (a) join fetch in JPQL — single joined query; (b) @EntityGraph — declarative, composes with paging; (c) @BatchSize / default_batch_fetch_size — loads associations in batched IN queries. Fetch joins on collections break pagination; batch size is the safe default there.
Self-invocation: calling it via this.method() bypasses the Spring proxy, so no advice runs. It's also ignored on private/final/static methods and when the class isn't a Spring bean. Fix: call through another bean or the injected proxy.
No — Spring rolls back only on RuntimeException/Error by default. Checked exceptions commit. Use rollbackFor = Exception.class.
@Transactional
public void run(){
Account a = repo.findById(1L).get();
Account b = repo.findById(1L).get();
System.out.println(a == b);
}
true. The persistence context is an identity map: the second findById returns the same managed instance from L1, no second SELECT. This identity guarantee only holds within one persistence context.
@Transactional
public Order get(long id){ return repo.findById(id).orElseThrow(); }
// elsewhere, no transaction:
service.get(1).getItems().forEach(...); // items is LAZY
LazyInitializationException: the context closed at method return, so the lazy collection can't initialize. Fix: fetch items inside the tx (entity graph/fetch join) or return a DTO.
Optimistic (@Version): no locks, detect at write via version check, loser retries — best for low contention/high throughput. Pessimistic (SELECT FOR UPDATE): DB lock up front, serializes access — best for hot rows / high conflict where retries are costly. Always add a lock timeout to avoid deadlocks.
When fetching a collection, Hibernate can't push LIMIT/OFFSET to SQL (duplicate rows), so it loads the full result set and paginates in memory. Fix: page IDs first then fetch, or use @BatchSize. Look for the "applying in memory" warning.
Bulk updates execute raw SQL, bypassing the persistence context. Managed entities become stale, cascades and @Version aren't applied. Use clearAutomatically = true / flushAutomatically = true.
persist: makes a transient entity managed (INSERT), void, fails if it already exists. merge: copies detached state into a managed instance and returns it (the argument stays detached). save: Spring picks persist for new entities (isNew()) and merge for existing — which can cause a pre-SELECT if isNew() misfires on assigned IDs.
Each REQUIRES_NEW opens a second physical connection while the outer one is suspended. With a small pool and slow inner txs you can exhaust connections and deadlock. Also, if invoked via self-call, propagation is ignored entirely.
Open-Session-In-View keeps the persistence context open for the whole HTTP request (Spring Boot default on). It makes lazy loading work in the view but hides N+1s, holds a DB connection for the entire request (including view rendering), and pushes DB work outside the service boundary. Disable it and fetch explicitly / return DTOs.
EAGER doesn't batch — it may emit N secondary selects (or a heavy join). Making it LAZY removes the upfront cost but does NOT fix N+1 by itself; if you then touch the association in a loop you still get N+1. You must add an explicit fetch plan (fetch join / entity graph / batch size) for the code paths that need the association.
It all starts from one object: the persistence context — the chef's prep counter bound to the transaction. On this counter live an identity map (one object per PK), a snapshot (for dirty checking), and an action queue.
- Because of the snapshot, dirty checking updates the DB without save() — unless
readOnly=true, which turns it off. - N+1 means 1 parent query + N child queries; fix it with fetch join /
@EntityGraph/ batch size.EAGERdoes not fix it. - LazyInitializationException means you went for the meal after the counter closed; fetch inside the transaction or return a DTO. Turn OSIV off.
- @Transactional is a proxy: self-invocation and private/final/static methods bypass it; only
RuntimeException/Errorroll back (checked don't — setrollbackFor); propagation and isolation govern nesting and isolation. - Locking: optimistic (
@Version) by default, pessimistic (FOR UPDATE) for hot rows. - Queries: JPQL by default, native for special features, Criteria for dynamic; and watch out for
@Modifyingand pagination pitfalls.
Hold this single picture and none of these gotchas will surprise you in production again.