Libraries & Ecosystem · کتابخانه‌ها و اکوسیستم متوسطIntermediate ~90 دقیقه مطالعه~78 min read

JPA و Hibernate در عمقJPA & Hibernate In Depth

از چرخه‌ی حیات موجودیت و کش سطح‌اول تا N+1، تولید شناسه، صفحه‌بندی، نگاشت نوع‌ها، قفل‌گذاری و batching — با نمایش دقیقِ SQLای که Hibernate روی PostgreSQL و Oracle تولید می‌کند و تفاوت‌هایی که یک مهندس سنیور باید بشناسد.From the entity lifecycle and the first-level cache to N+1, identifier generation, pagination, type mapping, locking and batching — showing exactly what SQL Hibernate emits on both PostgreSQL and Oracle, and the differences a senior engineer must know.

پیش‌نیاز:Prerequisites: Spring Data JPA و تراکنش‌هاSpring Data JPA & Transactions


سلام. بیا همین اول یک حقیقت ناخوشایند را بپذیریم: بیشتر باگ‌های عجیب و کندی‌های شبانه‌ی یک اپلیکیشن جاوایی که با دیتابیس کار می‌کند، از یک سوءتفاهم ساده می‌آید — این‌که برنامه‌نویس فکر می‌کند Hibernate «فقط SQL می‌نویسد»، در حالی‌که Hibernate یک ماشین کامل با حافظه، وضعیت و ذهنیت خودش است. در این فصل قرار نیست چند annotation حفظ کنی؛ قرار است ذهنِ این ماشین را بفهمی: کِی کوئری می‌زند، کِی نمی‌زند، و چرا گاهی پشت سرت یک عالمه SQL بی‌سروصدا اجرا می‌کند.

و یک لایه‌ی دوم هم اضافه می‌کنیم که معمولاً از فصل‌های JPA حذف می‌شود: همان کدِ جاوا، روی PostgreSQL و Oracle دو SQLِ متفاوت تولید می‌کند. یک @GeneratedValue ساده روی Oracle می‌تواند throughput درج را ده برابر خراب کند. یک @Lob روی Oracle بی‌دردسر است و روی PostgreSQL استثنا می‌دهد. یک رشته‌ی خالی روی PostgreSQL ذخیره می‌شود و روی Oracle بی‌سروصدا NULL می‌شود. در سراسر فصل هر قطعه SQL را در دو تبِ PostgreSQL و Oracle می‌بینی؛ هر دو را بخوان — سنیور بودن یعنی بدانی همان کد روی موتور دیگر چه شکلی می‌شود.

نقشه‌ی راه این فصل
  1. JPA در برابر Hibernate و Dialect — لایه‌ای که همان کد را به SQLِ هر موتور ترجمه می‌کند.
  2. چرخه‌ی حیات موجودیت — چهار حالت transient / managed / detached / removed.
  3. Persistence Context — «کش سطح‌اول»، قلبِ تپنده‌ی Hibernate — و dirty checking و flush.
  4. تولید شناسه — IDENTITY در برابر SEQUENCE، allocationSize و optimizerها؛ بزرگ‌ترین تفاوت عملی دو موتور.
  5. fetch type و N+1 — بزرگ‌ترین قاتل کارایی و چهار راهِ درمانش.
  6. صفحه‌بندیLIMIT/OFFSET در برابر FETCH FIRST و ROWNUM، و keyset pagination.
  7. نگاشت رابطه‌ها — سمت مالک، mappedBy، و ایندکسِ FK که در Oracle اجباری است.
  8. نگاشت نوع‌ها — CLOB/TEXT، boolean/NUMBER(1)، رشته‌ی خالی، DATE و timestamptz و JSON.
  9. قفل‌گذاری@Version، NOWAIT/SKIP LOCKED و سطوح ایزولاسیون.
  10. Batching، کش سطح‌دوم، DTO، OSIV و رصدپذیری — و در پایان، پرسش‌های مصاحبه.

بخش صفر — واژه‌هایی که باید پیش از شروع حسشان کنی

  • ORM: پلی که «شیءهای جاوا» را به «ردیف‌های جدول» ترجمه می‌کند و برعکس. دنیای شیءگرا (ارث‌بری، ارجاع، گراف) و دنیای رابطه‌ای (جدول، ستون، کلید خارجی) دو زبان‌اند؛ به این ناهماهنگی می‌گویند object-relational impedance mismatch.
  • JPA (Jakarta Persistence API): یک مشخصات است، نه کد. فقط می‌گوید یک ابزار ماندگارسازی چه رابط‌هایی باید داشته باشد — EntityManager، @Entity، @OneToMany. از Jakarta EE 9 بسته‌ها از javax.persistence به jakarta.persistence رفتند؛ نسخه‌ی فعلی Jakarta Persistence 3.2 است.
  • Hibernate: محبوب‌ترین پیاده‌سازی آن مشخصات. Hibernate ORM 7 روی Jakarta Persistence 3.2 سوار است و حداقلِ جاوایش Java 17 است.
  • موجودیت (Entity): کلاسی با @Entity که هر نمونه‌اش به یک ردیف نگاشت می‌شود. تراکنش: واحد کارِ همه‌یا‌هیچ.
  • Dialect (گویش): کلاسی که می‌داند دیتابیس مقصد چه نحوی دارد — صفحه‌بندی، نام نوع‌ها، خواندن sequence، گزینه‌های FOR UPDATE. org.hibernate.dialect.PostgreSQLDialect و org.hibernate.dialect.OracleDialect.
  • درایور JDBC: لایه‌ای که واقعاً بایت‌ها را می‌فرستد — org.postgresql:postgresql و com.oracle.database.jdbc:ojdbc11. رفتار batching به همان اندازه که به Hibernate بستگی دارد، به درایور هم بستگی دارد.
JPA «آیین‌نامه»، Hibernate «خودرو»، Dialect «گویشِ محلیِ جاده»

JPA مثل آیین‌نامه است: می‌گوید فرمان و ترمز باید باشند و چه می‌کنند، اما خودش تو را جایی نمی‌برد. Hibernate خودروی واقعی است. و Dialect مثل این است که همان خودرو در کشورهای مختلف تابلوهای متفاوت می‌خواند: در یکی «LIMIT» نوشته‌اند و در دیگری «FETCH FIRST»؛ در یکی boolean هست و در دیگری باید با NUMBER(1) تظاهر کنی. راننده (کد تو) عوض نمی‌شود؛ ترجمه را Dialect انجام می‌دهد.

چرا JPA و نه فقط JDBC؟

با JDBC خام باید دستی SQL بنویسی، ResultSet را ردیف‌به‌ردیف بخوانی و به شیء تبدیل کنی — کدِ تکراری و پرخطا. JPA این سیم‌کشی را حذف می‌کند و مدیریت حافظه، کش، تشخیص خودکار تغییرات و تولید SQL می‌دهد. اما این هوشمندی رایگان نیست: اگر ندانی زیر کاپوت چه می‌گذرد، همان هوشمندی به کندی و باگ تبدیل می‌شود.


Dialect: جایی که «یک کد» به «دو SQL» تبدیل می‌شود

مسیر از کد جاوا تا دیتابیس — Dialect همان مترجمِ گویش است / The path from Java code to the database.

flowchart LR
    A[Your code / Repository] --> B[EntityManager - JPA API]
    B --> C[Hibernate Session]
    C --> D[SQL AST + Dialect]
    D --> E[JDBC Driver]
    E --> F[(PostgreSQL 16/17)]
    E --> G[(Oracle 19c / 23ai)]

یک موجودیت ساده بگیر:

@Entity
@Table(name = "book")
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(nullable = false, length = 255)
    private String title;

    private boolean published;
    private BigDecimal price;
    private LocalDateTime createdAt;
}

و این DDLای است که واقعاً تولید می‌شود:

CREATE TABLE book (
    id         bigint         NOT NULL,
    title      varchar(255)   NOT NULL,
    published  boolean,
    price      numeric(38,2),
    created_at timestamp(6),
    PRIMARY KEY (id)
);

CREATE SEQUENCE book_seq START WITH 1 INCREMENT BY 50;
پنج تفاوت در همین یک DDL

bigint در برابر number(19,0)؛ varchar(255) در برابر varchar2(255 char)؛ boolean واقعی در برابر number(1,0)؛ numeric در برابر number؛ و نام‌ها که در PostgreSQL به حروف کوچک و در Oracle به حروف بزرگ تا خورده (fold) می‌شوند. هیچ‌کدام را تو ننوشتی — Dialect نوشت. اگر شِما را با Flyway/Liquibase می‌سازی، باید دقیقاً با انتظار Dialect بخواند وگرنه ddl-auto=validate سرِ بالا آمدن اپ داد می‌زند.

تله‌ی `VARCHAR2(255)` بایتی در Oracle — قاتلِ خاموشِ متن فارسی

در Oracle، VARCHAR2(255) بدون قید یعنی ۲۵۵ بایت (پیش‌فرضِ NLS_LENGTH_SEMANTICS). در AL32UTF8 هر حرف فارسی دو بایت و بسیاری از ایموجی‌ها چهار بایت می‌گیرند — یعنی آن ستون شاید فقط ۱۲۷ حرف فارسی جا بدهد و بعد ORA-12899: value too large for column بگیری. Hibernate خودش varchar2(255 char) تولید می‌کند و مشکلی ندارد؛ خطر از جدول‌های قدیمی و migrationهای دستی می‌آید. USER_TAB_COLUMNS.CHAR_USED را چک کن (C یعنی CHAR، B یعنی BYTE). در PostgreSQL این تله وجود ندارد.

Dialect را دستی ست نکن، مگر مجبور باشی

از Hibernate 6 به بعد Dialect از metadataی خودِ اتصال JDBC تشخیص داده می‌شود. ست‌کردنِ اشتباهش (مثلاً نسخه‌ی قدیمی Oracle) باعث می‌شود Hibernate از OFFSET ... FETCH FIRST صرف‌نظر کند و به ROWNUM برگردد. فقط برای تولید آفلاین DDL صریح بنویسش:

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
# spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.OracleDialect
spring.jpa.hibernate.ddl-auto=validate

چرخه‌ی حیات موجودیت: چهار حالتی که همه‌چیز حول آن می‌چرخد

مهم‌ترین مدل ذهنیِ کل JPA این است: هر موجودیت در هر لحظه در یکی از چهار حالت است و رفتار Hibernate کاملاً به آن بستگی دارد.

یک کارمند و بایگانی شرکت

EntityManager مثل مسئول بایگانیِ یک شرکت است. یک پرونده می‌تواند در چهار وضعیت باشد: (۱) تازه روی میزت نوشته‌ای و به بایگانی نداده‌ای — کسی جز تو خبر ندارد (transient). (۲) به بایگان دادی و او آن را زیر نظر گرفته؛ هر خط‌خوردگی را می‌بیند و ثبت می‌کند (managed). (۳) بایگان رفته خانه و در را قفل کرده؛ پرونده دستت هست ولی کسی حواسش به تغییراتش نیست (detached). (۴) دستور داده‌ای نابود شود؛ هنوز هست ولی محکوم به حذف است (removed).

  • Transient: با new ساخته‌ای؛ هیچ EntityManager نمی‌شناسدش و ردیفی در دیتابیس ندارد.
  • Managed: به یک persistence context متصل است؛ هر تغییر فیلد در پایان تراکنش خودکار به دیتابیس می‌رود (dirty checking). با persist() یا با خواندن (find, getReference, کوئری) به این حالت می‌رسی.
  • Detached: قبلاً managed بوده اما context بسته شده یا صریحاً جدایش کرده‌ای؛ تغییراتش دیگر ردیابی نمی‌شود.
  • Removed: با remove() علامت حذف خورده؛ در پایان تراکنش DELETE می‌شود.

گذرها بین چهار حالت / Transitions between the four states.

stateDiagram-v2
    [*] --> Transient: new
    Transient --> Managed: persist()
    Managed --> Detached: close() / detach() / clear()
    Detached --> Managed: merge() returns a copy
    Managed --> Removed: remove()
    Removed --> [*]: DELETE on commit
    Managed --> Managed: dirty check -> UPDATE
Book book = new Book("Clean Code");   // ۱) transient — Hibernate بی‌خبر است

em.getTransaction().begin();
em.persist(book);                     // ۲) managed — زیر نظر است، id می‌گیرد
book.setTitle("Clean Code 2");        // تغییر بدون هیچ save! ثبت می‌شود
em.getTransaction().commit();         // INSERT + UPDATE به دیتابیس می‌رود

em.close();                           // ۳) book حالا detached است
book.setTitle("ignored");             // این تغییر هیچ‌جا ثبت نمی‌شود

EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
Book merged = em2.merge(book);        // detached را دوباره managed می‌کند (کپی)
em2.remove(merged);                   // ۴) removed — در commit حذف می‌شود
em2.getTransaction().commit();

و این دقیقاً SQLای است که می‌رود:

SELECT nextval('book_seq');                                  -- گرفتن شناسه پیش از INSERT

INSERT INTO book (title, published, price, created_at, id)
VALUES ($1, $2, $3, $4, $5);

UPDATE book SET title = $1, published = $2, price = $3, created_at = $4
WHERE  id = $5;                                              -- نتیجه‌ی dirty checking

DELETE FROM book WHERE id = $1;
شکلِ placeholderها هم فرق دارد و در لاگ به کارت می‌آید

PostgreSQL پارامترها را $1, $2, ... می‌فرستد و در pg_stat_statements همان‌طور می‌بینی‌شان؛ Oracle از :1, :2, ... استفاده می‌کند و در V$SQL همان شکل را می‌بینی. وقتی می‌خواهی یک کوئریِ کند را از لاگ اپلیکیشن به لاگ دیتابیس وصل کنی، شناختن این دو شکل کار را راحت می‌کند.

تله‌ی کلاسیک: `merge` شیء ورودی را managed نمی‌کند

em.merge(book) یک کپیِ managed می‌سازد و برمی‌گرداند؛ خودِ book همچنان detached می‌ماند. پس همیشه با مقدار بازگشتی کار کن: book = em.merge(book);. اگر تغییرات بعدی را روی book قدیمی بزنی، هیچ اتفاقی نمی‌افتد.

`persist` در برابر `merge`

persist برای موجودیتِ تازه و transient؛ merge برای detachedی که می‌خواهی تغییراتش را برگردانی. persist روی یک detached با id موجود EntityExistsException می‌دهد. نکته‌ی پنهانِ merge: برای هر موجودیت یک SELECT اضافه می‌زند تا نسخه‌ی فعلی را بخواند؛ در حلقه‌ی هزارتایی یعنی هزار round-trip.


Persistence Context: همان کش سطح‌اول

Persistence Context نقشه‌ای درون‌حافظه‌ای است که همه‌ی موجودیت‌های managedِ یک EntityManager را با کلیدِ id نگه می‌دارد. به آن کش سطح‌اول هم می‌گویند و همیشه روشن است — نمی‌توانی خاموشش کنی.

میز کارِ یک ویراستار

میز کارِ ویراستار در طول یک روز: هر پرونده‌ای که از بایگانی درمی‌آوری، یک نسخه رویش می‌ماند. اگر دوباره بخواهی‌اش، تا بایگانی نمی‌رود — همان نسخه‌ی روی میز را می‌دهد. برای همین دو find با یک id همان شیء دقیق را برمی‌گرداند و فقط یک بار به دیتابیس می‌زند. آخر روز (commit) همه‌ی خط‌خوردگی‌ها با هم به بایگانی می‌روند؛ فردا (EntityManager جدید) میز خالی است.

Book b1 = em.find(Book.class, 1L);   // SELECT اجرا می‌شود
Book b2 = em.find(Book.class, 1L);   // هیچ SELECTی! از کش سطح‌اول می‌آید
System.out.println(b1 == b2);        // true — دقیقاً همان شیء

تنها SELECTای که واقعاً روی سیم می‌رود:

SELECT b1_0.id, b1_0.created_at, b1_0.price, b1_0.published, b1_0.title
FROM   book b1_0
WHERE  b1_0.id = $1;
چرا این «تضمین یکتایی» طلاست

درون یک persistence context برای هر id فقط یک نمونه وجود دارد؛ پس نگرانِ ناسازگاری نسخه‌ها نیستی و == بین دو ارجاع به یک ردیف درست کار می‌کند. اما تله‌اش: این کش فقط تا پایان همان تراکنش زنده است و بین کاربران مشترک نیست. کشِ مشترک بین تراکنش‌ها همان «کش سطح‌دوم» است که جلوتر می‌بینیم.

حروف بزرگ و کوچک: دو موتور دقیقاً برعکس هم‌اند

هر دو موتور شناسه‌های بدون‌کوتیشن را تا می‌زنند، ولی در دو جهت مخالف: PostgreSQL به حروف کوچک و Oracle به حروف بزرگ. تا وقتی کوتیشن نگذاری مشکلی نیست. اما اگر کسی در Oracle جدولی با نام "Book" ساخته باشد، SELECT * FROM book خطای ORA-00942: table or view does not exist می‌دهد و باید در Hibernate با @Table(name = "\"Book\"") کوتیشن را نگه داری. قانون سنیوری: هرگز شناسه‌ی کوتیشن‌دار نساز؛ فقط snake_case بدون کوتیشن.

Flush، dirty checking و flush mode

Hibernate از کجا می‌فهمد یک فیلد را عوض کرده‌ای، وقتی هیچ save صدا نزدی؟ پاسخ dirty checking است.

عکس فوری در بدو ورود

وقتی موجودیتی managed می‌شود، Hibernate یک snapshot از مقادیر فیلدهایش می‌گیرد. هنگام flush، شیء فعلی را با آن عکس مقایسه می‌کند؛ هر فیلدی که فرق کرده «dirty» است و برایش UPDATE تولید می‌شود. لازم نیست بگویی «این عوض شد» — Hibernate با مقایسه می‌فهمد.

flush یعنی همگام‌سازیِ حافظه با دیتابیس از طریق اجرای SQL — ولی flush به معنای commit نیست؛ تا commit نکرده‌ای هنوز قابل rollback است. Hibernate در FlushModeType.AUTO (پیش‌فرض) در سه لحظه flush می‌کند: قبل از commit، قبل از کوئری JPQL/HQLای که ممکن است به تغییرات معلق وابسته باشد، و در em.flush() دستی.

Flush Mode چه زمانی flush می‌کند کاربرد
AUTO (پیش‌فرض) قبل از commit و قبل از کوئری‌های مرتبط امن‌ترین؛ همیشگیِ اکثر برنامه‌ها
COMMIT فقط قبل از commit تراکنش بهینه‌سازی؛ وقتی مطمئنی کوئری‌ها به تغییرات معلق وابسته نیستند
`COMMIT` می‌تواند نتیجه‌ی کوئری را غلط کند

اگر flush mode را COMMIT بگذاری، بعد موجودیتی را در حافظه عوض کنی و کوئریِ مرتبطی بزنی، کوئری داده‌ی قدیمی را می‌بیند چون تغییر هنوز flush نشده. پیش‌فرض AUTO را دست نزن.

به‌طور پیش‌فرض Hibernate در UPDATE همه‌ی ستون‌ها را می‌نویسد. با @DynamicUpdate فقط ستون‌های تغییرکرده می‌آیند:

-- بدون @DynamicUpdate
UPDATE book SET title = $1, published = $2, price = $3, created_at = $4, content = $5
WHERE  id = $6;

-- با @DynamicUpdate
UPDATE book SET title = $1 WHERE id = $2;
روی Oracle، `@DynamicUpdate` روی جدول‌های LOB‌دار اختلاف چند برابری می‌سازد

اگر جدولی ستون CLOB/BLOB دارد، UPDATEای که آن ستون را هم بنویسد — حتی با همان مقدار قبلی — یعنی نوشتنِ دوباره‌ی LOB و تولید undo/redo؛ به‌روزرسانیِ یک title می‌تواند مگابایت‌ها I/O بسازد. در PostgreSQL ستون‌های بزرگ در TOAST می‌نشینند و اگر عوض نشوند اشاره‌گرشان بازاستفاده می‌شود، پس فشار کمتر است. جمع‌بندی: @DynamicUpdate در هر دو خوب است، در Oracle حیاتی — در عوض هر بار SQL تازه می‌سازد و از مزیت statement cache کم می‌کند.


تولید شناسه: بزرگ‌ترین تفاوت عملیِ Oracle و PostgreSQL

اگر از کل این فصل فقط یک بخش را برای مصاحبه‌ی سنیوری حفظ کنی، همین است.

دو نوع نوبت‌دهی در بانک

IDENTITY مثل این است که هر بار جلوی باجه بروی و کارمند همان لحظه شماره‌ات را بنویسد؛ تا او ننویسد نمی‌دانی شماره‌ات چند است. SEQUENCE مثل دستگاه نوبت‌دهی است که یک‌بار می‌روی و پنجاه شماره می‌گیری و بعد پنجاه مشتری را بدون رفتن به دستگاه راه می‌اندازی. تفاوت در «چند بار باید بروی» است — و هر «رفتن» یک round-trip شبکه است.

حالت اول: IDENTITY

@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
-- استاندارد و توصیه‌شده در PostgreSQL 16/17
CREATE TABLE book (
    id    bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    title varchar(255) NOT NULL
);
-- سبک قدیمی‌تر: CREATE TABLE book (id bigserial PRIMARY KEY, ...);

-- Hibernate برای گرفتن مقدار تولیدشده از RETURNING استفاده می‌کند
INSERT INTO book (title) VALUES ($1) RETURNING id;
`IDENTITY` سکوتِ کاملِ JDBC batching است

با GenerationType.IDENTITY، Hibernate مجبور است هر INSERT را جداگانه اجرا کند: تا دیتابیس ردیف را ننویسد شناسه‌ای نیست و بدون شناسه نمی‌تواند موجودیت را در persistence context بگذارد. نتیجه: JDBC batching کاملاً غیرفعال می‌شود، حتی با hibernate.jdbc.batch_size=50. برای ۱۰٬۰۰۰ ردیف یعنی ۱۰٬۰۰۰ round-trip به‌جای ۲۰۰. در Oracle — که هزینه‌ی round-trip و parse بالاتر است — این فاجعه است.

حالت دوم: SEQUENCE (پاسخِ درست برای Oracle، و معمولاً برای PostgreSQL هم)

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_gen")
@SequenceGenerator(name = "book_gen", sequenceName = "book_seq", allocationSize = 50)
private Long id;
CREATE SEQUENCE book_seq
    START WITH 1
    INCREMENT BY 50
    CACHE 1;                 -- در PostgreSQL، CACHE مربوط به هر session است

SELECT nextval('book_seq');

SELECT sequencename, increment_by, last_value
FROM   pg_sequences WHERE schemaname = current_schema();
چرا SEQUENCE پاسخِ درست برای Oracle است

۱. batching زنده می‌ماند — چون شناسه‌ها قبل از INSERT معلوم‌اند، صدها درج در یک batch می‌روند. تنها راه رسیدن به throughput واقعی در Oracle. ۲. round-tripها تقسیم می‌شوند — با allocationSize = 50 یک NEXTVAL پنجاه شناسه می‌دهد. ۳. در فرهنگِ Oracle، sequence شهروند درجه‌یک است — ستون identity در Oracle هم پشت صحنه دقیقاً یک sequence است، فقط دستِ تو را می‌بندد.

در PostgreSQL هم SEQUENCE معمولاً برنده است، فقط تفاوت کمتر است چون RETURNING id بخشی از هزینه‌ی IDENTITY را جبران می‌کند.

جریان تخصیص شناسه با optimizerِ pooled / Identifier allocation with the pooled optimizer.

sequenceDiagram
    participant App as Hibernate
    participant DB as Database
    App->>DB: select nextval / seq.NEXTVAL
    DB-->>App: 150 (hi value)
    Note over App: assigns 101..150 from memory
    App->>DB: INSERT batch of 50 rows
    App->>DB: select nextval / seq.NEXTVAL
    DB-->>App: 200
`allocationSize` باید دقیقاً با `INCREMENT BY` یکی باشد

خطرناک‌ترین ناسازگاریِ خاموش در کل JPA. اگر در جاوا allocationSize = 50 باشد ولی sequence در دیتابیس INCREMENT BY 1، Hibernate بازه‌ای را پخش می‌کند که مالکش نیست و به‌محض بالا آمدن instance دوم کلید تکراری می‌گیری: 23505 duplicate key در PostgreSQL و ORA-00001: unique constraint violated در Oracle. عدد migration و عدد کد باید در یک code review با هم دیده شوند.

`pooled` در برابر `pooled-lo`

pooled مقدارِ خوانده‌شده را سقفِ بازه می‌گیرد و pooled-lo آن را کفِ بازه. هر دو با برنامه‌های دیگری که مستقیم NEXTVAL می‌زنند سازگارند — برخلاف hilo قدیمی که با آن‌ها تصادم می‌کند (و در شِماهای سازمانیِ Oracle که همیشه یک اسکریپت PL/SQL هم می‌نویسد، دردسر می‌سازد).

spring.jpa.properties.hibernate.id.optimizer.pooled.preferred=pooled-lo
حفره در شناسه‌ها طبیعی است

با allocationSize = 50، ری‌استارت اپ باقیِ بازه را دور می‌ریزد؛ در Oracle با CACHE 100 هم بعد از restart صد شماره می‌پرد. اگر کسی «شماره‌ی فاکتور بدون حفره» خواست، آن یک نیازِ کسب‌وکار است نه primary key — با جدول شمارنده و قفل صریح حلش کن.

اگر روی Oracleِ قدیمی‌تر از 12c گیر کرده‌ای

پیش از 12c، Oracle ستون identity نداشت و الگوی مرسوم ترکیبِ sequence و trigger بود:

-- در PostgreSQL هرگز نیازی به این کار نبوده
CREATE TABLE book (id bigserial PRIMARY KEY, title varchar(255) NOT NULL);
-- bigserial پشت صحنه: DEFAULT nextval('book_id_seq')

با چنین شِمایی، در جاوا GenerationType.SEQUENCE با همان sequenceName و allocationSize = 1 بنویس و trigger را (اگر می‌توانی) بردار؛ triggerها هم کندند و هم شناسه را از دید Hibernate پنهان می‌کنند.


fetch type و مشکل افسانه‌ای N+1

هر رابطه (@OneToMany, @ManyToOne, ...) یک راهبرد واکشی (fetch type) دارد:

  • EAGER: وقتی موجودیت اصلی را می‌خوانی، رابطه‌اش هم فوراً با آن بارگذاری می‌شود.
  • LAZY: رابطه بارگذاری نمی‌شود تا لحظه‌ای که واقعاً به آن دست بزنی. Hibernate به‌جای داده یک proxy می‌گذارد و در اولین دسترسی کوئری می‌زند.
پیش‌فرض‌های fetch را حفظ کن

طبق مشخصات JPA: @ManyToOne و @OneToOne پیش‌فرض EAGER؛ @OneToMany و @ManyToMany پیش‌فرض LAZY. همان پیش‌فرض‌های EAGER روی *ToOne منبعِ خاموشِ خیلی از مشکلات کارایی‌اند.

N+1 چیست؟

List<Author> authors = em.createQuery(
        "select a from Author a", Author.class).getResultList();   // ۱ کوئری

for (Author a : authors) {
    System.out.println(a.getBooks().size());   // هر بار یک SELECT جدید!
}

اگر books رابطه‌ای LAZY باشد، کوئری اول همه‌ی نویسنده‌ها را می‌آورد، اما بعد به‌ازای هر نویسنده یک SELECT جدید می‌رود. برای ۱۰۰۰ نویسنده می‌شود ۱۰۰۱ کوئری. این همان N+1 است. در لاگ دقیقاً این را می‌بینی:

SELECT a1_0.id, a1_0.name FROM author a1_0;

-- و بعد N بار، یکی برای هر نویسنده
SELECT b1_0.author_id, b1_0.id, b1_0.title FROM book b1_0 WHERE b1_0.author_id = $1;
SELECT b1_0.author_id, b1_0.id, b1_0.title FROM book b1_0 WHERE b1_0.author_id = $1;
خرید ناشیانه از سوپرمارکت

N+1 مثل این است که به‌جای یک لیست خرید کامل، هر بار یک قلم یادت بیاید و تا سوپرمارکت بروی و برگردی: یک بار پیاز، یک بار گوجه، یک بار نمک. هر «رفت‌وآمد» یک round-trip به دیتابیس است — و همین round-tripها (نه خودِ کوئری) گلوگاه واقعی‌اند.

EAGER کردن، N+1 را حل نمی‌کند

باور غلطِ رایج: «رابطه را EAGER کنم تا N+1 نداشته باشم.» اما EAGER اغلب بدترش می‌کند: وقتی لیستی از والدها را با کوئری می‌خوانی، Hibernate ممکن است برای هرکدام یک SELECT جدا بزند — دقیقاً همان N+1، این‌بار بی‌آنکه بتوانی کنترلش کنی. قانون طلایی: همه‌ی رابطه‌ها LAZY، و در هر کوئری صراحتاً بگو چه می‌خواهی.

روی Oracle، N+1 گران‌تر تمام می‌شود

هزینه‌ی هر round-trip در Oracle معمولاً بالاتر است: علاوه بر تأخیر شبکه، هر اجرا باید در shared pool پیدا (soft parse) یا ساخته (hard parse) شود. یک N+1 که در لوکالِ PostgreSQL ۸۰ میلی‌ثانیه است، روی Oracleِ پروداکشن می‌تواند چند ثانیه شود و در AWR به‌شکل رویدادِ انتظارِ SQL*Net message from client خودش را نشان دهد.

چهار راه درمانِ N+1

۱) JOIN FETCH در JPQL:

List<Author> authors = em.createQuery(
        "select distinct a from Author a join fetch a.books",
        Author.class).getResultList();
SELECT a1_0.id, a1_0.name, b1_0.author_id, b1_0.id, b1_0.title
FROM   author a1_0
JOIN   book   b1_0 ON a1_0.id = b1_0.author_id;

۲) @EntityGraph — بدون نوشتن JPQL بگو کدام رابطه‌ها eager بیایند؛ برخلاف join fetch روی collection با pagination سازگار است:

public interface AuthorRepository extends JpaRepository<Author, Long> {
    @EntityGraph(attributePaths = {"books"})
    @Query("select a from Author a")
    List<Author> findAllWithBooks();
}

۳) @BatchSize — به‌جای N کوئری، کلیدها را دسته‌بندی می‌کند:

@OneToMany(mappedBy = "author")
@BatchSize(size = 25)   // به‌جای ۱۰۰۰ کوئری، دسته‌های ۲۵تایی => ۴۰ کوئری
private List<Book> books = new ArrayList<>();
SELECT b1_0.author_id, b1_0.id, b1_0.title
FROM   book b1_0
WHERE  b1_0.author_id IN ($1, $2, $3, $4, $5 /* ... تا ۲۵ */);

-- جایگزین بومیِ PostgreSQL که فقط یک bind می‌گیرد:
SELECT b.author_id, b.id, b.title FROM book b WHERE b.author_id = ANY($1);  -- bigint[]
سقفِ ۱۰۰۰تاییِ لیستِ `IN` در Oracle

در Oracle تا 21c یک عبارت IN حداکثر ۱۰۰۰ آیتم می‌پذیرد و بیشتر از آن ORA-01795: maximum number of expressions in a list is 1000 می‌دهد (در 23ai این سقف بسیار بالا رفته). PostgreSQL سقف سختی ندارد ولی لیست‌های چندهزارتایی آنجا هم plan را خراب می‌کنند. @BatchSize را بالای ۱۰۰ نگذار؛ عدد سالم ۱۰ تا ۵۰ است. و اگر خودت where id in :ids می‌نویسی و لیست از کاربر می‌آید، حتماً تکه‌تکه‌اش کن.

هر لیستِ `IN` با طول متفاوت، یک کوئریِ متفاوت است

IN (?,?,?) و IN (?,?,?,?) از نگاه دیتابیس دو دستور جداگانه‌اند: در Oracle یعنی hard parse و شلوغ‌شدن shared pool، در PostgreSQL یعنی از دست رفتن prepared statement caching. برای همین hibernate.query.in_clause_parameter_padding=true طول لیست را به نزدیک‌ترین توان ۲ گرد می‌کند. روی Oracle تقریباً همیشه روشنش کن.

۴) DTO Projection — وقتی فقط چند فیلد می‌خواهی، اصلاً موجودیت کامل را نیاور:

List<AuthorSummary> rows = em.createQuery(
    "select new com.app.AuthorSummary(a.id, a.name, count(b)) " +
    "from Author a left join a.books b group by a.id, a.name",
    AuthorSummary.class).getResultList();
کدام درمان، کِی؟
  • join fetch وقتی دقیقاً می‌دانی این رابطه را لازم داری و pagination نمی‌خواهی (با چند collection همزمان مراقبِ ضرب دکارتی باش).
  • @EntityGraph همان مزیت، ولی سازگار با pagination و با کوئریِ تمیزتر.
  • @BatchSize بهینه‌سازیِ سراسریِ عالی؛ ۱۰ تا ۵۰ منطقی است (بزرگ‌تر = خطر ORA-01795).
  • DTO projection بهترین گزینه برای صفحه‌های فقط‌خواندنی؛ نه persistence context را شلوغ می‌کند نه dirty checking لازم دارد.

صفحه‌بندی: جایی که نحو دو موتور آشکارا فرق می‌کند

em.createQuery("select b from Book b order by b.id", Book.class)
  .setFirstResult(20).setMaxResults(10).getResultList();
SELECT b1_0.id, b1_0.title, b1_0.price
FROM   book b1_0
ORDER  BY b1_0.id
LIMIT  10 OFFSET 20;

-- PostgreSQL نحو استاندارد را هم می‌پذیرد و کاملاً معادل است:
-- ORDER BY b1_0.id OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY;
چرا `ROWNUM` باید دو لایه باشد؟

چون ROWNUM قبل از ORDER BY تخصیص داده می‌شود. SELECT ... WHERE ROWNUM <= 10 ORDER BY id اول ده ردیفِ تصادفی می‌گیرد و بعد مرتب می‌کند — نه ده ردیفِ اول. الگوی درست تودرتو کردن است. Hibernate اگر Dialect را روی 12c یا بالاتر بشناسد این آشفتگی را ندارد — دلیلی دیگر برای این‌که Dialect را روی نسخه‌ی قدیمی قفل نکنی.

`OFFSET` بزرگ در هر دو موتور کند است — راه‌حل keyset pagination است

چه LIMIT/OFFSET چه OFFSET ... FETCH FIRST، دیتابیس همه‌ی ردیف‌های قبلی را تولید و دور می‌ریزد. صفحه‌ی ۱۰۰۰ با اندازه‌ی ۲۰ یعنی تولید ۲۰٬۰۲۰ ردیف برای تحویل ۲۰ تا. راه‌حل سنیوری: به‌جای «چند تا رد کن»، بگو «از این کلید به بعد».

SELECT b.id, b.title, b.created_at
FROM   book b
WHERE  (b.created_at, b.id) < ($1, $2)     -- مقایسه‌ی چندستونیِ استاندارد
ORDER  BY b.created_at DESC, b.id DESC
FETCH  FIRST 20 ROWS ONLY;
تله‌ی `join fetch` + pagination

اگر join fetch روی یک collection بزنی و همزمان setMaxResults بگذاری، Hibernate نمی‌تواند در SQL محدود کند (هر والد چند ردیف می‌شود)، پس همه‌ی داده را در حافظه می‌آورد و در جاوا صفحه‌بندی می‌کند — با هشدار HHH000104: firstResult/maxResults specified with collection fetch; applying in memory. این محدودیت منطقی است و در هر دو موتور یکسان. راه‌حل: @EntityGraph یا رویکرد دومرحله‌ای (اول idها با FETCH FIRST، بعد داده با where id in :ids).


نگاشت رابطه‌ها: دام‌های @OneToMany و @ManyToOne

کلید فهمش مفهوم سمت مالک (owning side) است.

کلید خارجی مثل «شماره‌ی میز» در رستوران

در رابطه‌ی یک‌به‌چند، فقط یک طرف واقعاً کلید خارجی را نگه می‌دارد — مثل این‌که هر «سفارش» شماره‌ی «میزی» را که به آن تعلق دارد رویش نوشته باشد، ولی میز فهرست سفارش‌هایش را ننوشته. در دیتابیس رابطه فقط یک ستون FK دارد (سمت @ManyToOne) و آن سمت مالک است. سمت @OneToMany فقط «نمای معکوس» است و با mappedBy می‌گوید «من مالک نیستم».

@Entity
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;

    @ManyToOne(fetch = FetchType.LAZY)   // سمت مالک: ستون FK اینجاست
    @JoinColumn(name = "customer_id")
    private Customer customer;
}

@Entity
public class Customer {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;

    @OneToMany(mappedBy = "customer")    // سمت معکوس: مالک نیست
    private List<Order> orders = new ArrayList<>();
}
CREATE TABLE orders (
    id          bigint PRIMARY KEY,
    customer_id bigint,
    CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customer (id)
);

CREATE INDEX ix_orders_customer ON orders (customer_id);
در Oracle، ایندکس‌نداشتنِ ستون FK یک باگِ همزمانی است، نه فقط مسئله‌ی کارایی

هیچ‌کدام از دو موتور خودکار روی FK ایندکس نمی‌سازند، ولی پیامد یکسان نیست. در Oracle اگر ستون FKِ فرزند ایندکس نداشته باشد، هر DELETE یا تغییر کلیدِ والد یک قفلِ share row exclusive روی کل جدول فرزند می‌گیرد — یعنی نوشتن روی هر ردیفِ آن جدول تا پایان تراکنش مسدود می‌شود و در ترافیک بالا به موجِ ORA-00060 (deadlock) تبدیل می‌شود. در PostgreSQL چنین قفل جدولی نیست، ولی بررسی FK به اسکن کامل جدول فرزند بدل می‌شود. قانون: همیشه روی هر ستون FK ایندکس بساز — باید خودت در migration بنویسی‌اش.

تله‌ی شماره‌یک: فراموش‌کردن همگام‌سازی دو طرف

اگر order.setCustomer(c) را بزنی ولی c.getOrders().add(order) را نه، ممکن است در همان تراکنش c.getOrders() سفارش تازه را نشان ندهد. راه‌حل استاندارد، متد کمکیِ همگام‌سازی است:

public void addOrder(Order order) {
    orders.add(order);
    order.setCustomer(this);   // هر دو طرف با هم
}
تله‌ی شماره‌دو: `mappedBy` را جا بیندازی

اگر mappedBy را ننویسی، Hibernate فکر می‌کند دو رابطه‌ی مستقل داری و به‌جای یک ستون FK یک جدول واسط می‌سازد یا UPDATEهای اضافی می‌زند. علامتش: در لاگ بعد از INSERT یک UPDATE عجیب برای ست‌کردن FK می‌بینی.

`List` یا `Set` برای collection؟

معمولاً Set امن‌تر است. با دو @OneToMany از نوع List که هر دو EAGER (یا در یک join fetch) باشند، MultipleBagFetchException می‌گیری چون Hibernate نمی‌تواند دو «کیسه‌ی» بی‌ترتیب را همزمان join کند. اگر ترتیب مهم است، @OrderColumn یا دو کوئری جدا.

`equals`/`hashCode` روی موجودیت‌ها را با احتیاط بنویس

id تولیدشده قبل از persist هنوز null است؛ اگر موجودیت را قبل از ذخیره در HashSet بگذاری و بعد ذخیره‌اش کنی، hashCodeاش عوض می‌شود و شیء در Set «گم» می‌شود. توصیه: یک business key پایدار، یا equals بر پایه‌ی id با hashCode ثابت (بر پایه‌ی کلاس). نکته: با استراتژی SEQUENCE شناسه قبل از flush هم موجود است و این مشکل کم‌رنگ‌تر می‌شود — مزیت پنهانِ دیگرِ SEQUENCE.


Cascade و orphanRemoval: انتشار عملیات روی گراف

پوشه و فایل‌هایش

CascadeType.REMOVE مثل حذف یک پوشه در سیستم‌عامل است که همه‌ی فایل‌های داخلش هم حذف شوند. CascadeType.PERSIST یعنی با ذخیره‌ی پوشه‌ی جدید، فایل‌های تازه‌اش هم خودکار ذخیره شوند.

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
Cascade Type یعنی مثال کاربرد
PERSIST ذخیره‌ی والد، فرزندان جدید را هم ذخیره کند ثبت سفارش با ردیف‌هایش
MERGE merge والد به فرزندان سرایت کند به‌روزرسانی گرافِ detached
REMOVE حذف والد، فرزندان را هم حذف کند حذف سفارش و ردیف‌هایش
REFRESH refresh والد به فرزندان سرایت کند همگام‌سازی مجدد از دیتابیس
DETACH detach والد فرزندان را هم detach کند خارج‌کردن گراف از context
ALL همه‌ی موارد بالا رابطه‌ی «مالکیت کامل» والد بر فرزند

نکته‌ای که خیلی‌ها نمی‌دانند: CascadeType.REMOVE در سطح جاوا اجرا می‌شود، نه در دیتابیس:

-- آنچه CascadeType.REMOVE واقعاً می‌فرستد: یک DELETE به‌ازای هر فرزند
SELECT l.id, l.order_id FROM order_line l WHERE l.order_id = $1;
DELETE FROM order_line WHERE id = $1;
DELETE FROM order_line WHERE id = $1;
DELETE FROM orders     WHERE id = $1;

-- جایگزینِ سطح‌دیتابیس
ALTER TABLE order_line ADD CONSTRAINT fk_line_order
    FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE;
`ON DELETE CASCADE` دیتابیس در برابر `cascade` در JPA

هر دو موتور ON DELETE CASCADE و ON DELETE SET NULL دارند، پس این بخش قابل‌حمل است. تفاوت ظریف: PostgreSQL از نسخه‌ی ۱۵ اجازه می‌دهد ON DELETE SET NULL (customer_id) بنویسی تا فقط یک ستون از کلید مرکب null شود؛ Oracle چنین گزینه‌ای ندارد. مهم‌تر: اگر cascade را به دیتابیس بسپاری، Hibernate از حذف فرزندان بی‌خبر می‌ماند و کش سطح‌اول/دوم کهنه می‌شود. یا @OnDelete(action = OnDeleteAction.CASCADE) بنویس تا Hibernate بداند، یا بعد از حذفِ والد em.clear() بزن.

تفاوت ظریف `CascadeType.REMOVE` با `orphanRemoval`

REMOVE وقتی فعال می‌شود که خودِ والد حذف شود. orphanRemoval = true قوی‌تر است: اگر فقط یک فرزند را از collection والد بیرون بیندازی (order.getLines().remove(line))، آن فرزندِ «یتیم» هم از دیتابیس حذف می‌شود، حتی اگر والد بماند. برای رابطه‌های صرفاً «اشتراکی» به‌کارش نبر، وگرنه داده‌ی مشترک را ناخواسته حذف می‌کنی.

cascade رو به `@ManyToOne` معمولاً اشتباه است

cascade = ALL روی @ManyToOne (مثلاً Order به Customer) یعنی «اگر سفارشی حذف شد، مشتری‌اش هم حذف شود» — تقریباً همیشه فاجعه. cascade را روی سمتی بگذار که واقعاً مالکِ چرخه‌ی حیات فرزند است.


نگاشت نوع‌ها: جایی که «قابل‌حمل بودن» واقعاً امتحان پس می‌دهد

نوع جاوا PostgreSQL Oracle نکته
Long bigint number(19,0) Oracle نوع صحیحِ اختصاصی ندارد
String (۲۵۵) varchar(255) varchar2(255 char) معناشناسیِ CHAR/BYTE
متن بلند text clob سقفِ VARCHAR2 پیش‌فرض ۴۰۰۰ بایت
boolean boolean number(1,0) 23ai نوع BOOLEAN واقعی دارد
BigDecimal numeric(p,s) number(p,s) تقریباً معادل
LocalDate date date Oracle DATE جزء زمان هم دارد!
OffsetDateTime timestamptz timestamp(6) with time zone معناشناسی متفاوت
byte[] bytea blob streaming متفاوت
UUID uuid raw(16) PostgreSQL نوع بومی دارد
JSON jsonb json (21c+) یا clob is json

متنِ بلند: CLOB در برابر TEXT

-- خطرناک: @Lob روی PostgreSQL می‌تواند به oid (large object) نگاشت شود
CREATE TABLE article (id bigint PRIMARY KEY, content oid);

-- چیزی که تقریباً همیشه می‌خواهی:
CREATE TABLE article (id bigint PRIMARY KEY, content text);
`@Lob String` روی PostgreSQL کلاسیک‌ترین استثنای «فقط در پروداکشن» است

روی PostgreSQL، @Lob باعث می‌شود Hibernate از APIهای Large Object استفاده کند و ستون از نوع oid بسازد؛ داده در جدول سیستمی pg_largeobject می‌رود. پیامدها: خطای Large Objects may not be used in auto-commit mode هنگام خواندن خارج از تراکنش، نیاز به vacuumlo برای پاک‌کردن objectهای یتیم، و از دست رفتن پشتیبان‌گیریِ ساده. راه‌حل قابل‌حملی که در Hibernate 6+ توصیه می‌شود:

@JdbcTypeCode(SqlTypes.LONGVARCHAR)
@Column(name = "content")
private String content;
// روی PostgreSQL => text ، روی Oracle => clob ، بدون هیچ تنظیم شرطی

@Lob را فقط وقتی نگه دار که واقعاً به streamingِ Clob/Blob نیاز داری.

boolean در برابر NUMBER(1)

CREATE TABLE account (
    id     bigint PRIMARY KEY,
    active boolean NOT NULL DEFAULT false
);

INSERT INTO account (id, active) VALUES (1, true);
SELECT id FROM account WHERE active;          -- مستقیم در WHERE
شِماهای موروثیِ Oracle معمولاً `'Y'/'N'` دارند، نه `1/0`

برای این کار Dialect را دست‌کاری نکن؛ یک AttributeConverter بنویس (یا از org.hibernate.type.YesNoConverter و NumericBooleanConverter آماده استفاده کن):

@Converter
public class YesNoConverter implements AttributeConverter<Boolean, String> {
    @Override public String convertToDatabaseColumn(Boolean v) {
        return Boolean.TRUE.equals(v) ? "Y" : "N";
    }
    @Override public Boolean convertToEntityAttribute(String db) { return "Y".equals(db); }
}

مزیتش این است که JPQL تو (where a.active = true) دست‌نخورده می‌ماند و فقط ترجمه عوض می‌شود.

تله‌ی رشته‌ی خالی در Oracle — عجیب‌ترین تفاوت دو موتور

-- در PostgreSQL، رشته‌ی خالی یک مقدارِ واقعی است و با NULL فرق دارد
INSERT INTO customer (id, middle_name) VALUES (1, '');

SELECT count(*) FROM customer WHERE middle_name = '';      -- 1
SELECT count(*) FROM customer WHERE middle_name IS NULL;   -- 0
SELECT length(middle_name) FROM customer WHERE id = 1;     -- 0
سه پیامدِ واقعیِ «رشته‌ی خالی = NULL» در Oracle

۱. NOT NULL می‌شکند: ورودیِ خالیِ فرم روی PostgreSQL ذخیره می‌شود و روی Oracle ORA-01400: cannot insert NULL می‌دهد — همان اپ، روی یک موتور کار می‌کند و روی دیگری نه. ۲. رفت‌وبرگشت داده تغییر می‌کند: رشته‌ی خالی می‌نویسی و null می‌خوانی؛ NullPointerException سر راهت است. ۳. الحاق فرق می‌کند: در Oracle 'a' || NULL برابر 'a' است، در PostgreSQL برابر NULL.

راهکار سنیوری: در لایه‌ی اپلیکیشن رشته‌ی خالی را همیشه به null نرمال کن (با AttributeConverter یا در سازنده) و در JPQL هرگز = '' ننویس — همیشه is null یا coalesce(...).

تاریخ و زمان: بزرگ‌ترین سوءتفاهم

CREATE TABLE event (
    id        bigint PRIMARY KEY,
    day       date,                        -- فقط تاریخ، بدون ساعت
    happened  timestamp(6),                -- بدون منطقه‌ی زمانی
    happenedz timestamptz                  -- به UTC نرمال می‌شود؛ خودِ منطقه ذخیره نمی‌شود
);

SET TIME ZONE 'UTC';
SELECT now(), current_date;
سه تفاوتِ زمانی که باید حفظ کنی

۱. DATE در Oracle یک DATE نیست: همیشه شامل ساعت، دقیقه و ثانیه است (فقط کسر ثانیه ندارد). نوشتن یک LocalDateTime در ستون DATE موروثی یعنی بریده‌شدنِ خاموشِ میلی‌ثانیه‌ها؛ و مقایسه‌ی day = DATE '2026-07-29' هیچ ردیفی برنمی‌گرداند مگر TRUNC(day) بزنی. در PostgreSQL date واقعاً فقط تاریخ است. ۲. timestamptz منطقه را ذخیره نمی‌کند: برخلاف اسمش، PostgreSQL مقدار را به UTC تبدیل و ذخیره می‌کند و با TimeZoneِ session نمایش می‌دهد — منطقه‌ی اصلی از بین می‌رود. اما TIMESTAMP WITH TIME ZONE در Oracle واقعاً offset یا نام منطقه را نگه می‌دارد. اگر لازم داری بدانی «کاربر در چه منطقه‌ای ثبت کرد»، در PostgreSQL باید ستون جدا بگذاری. ۳. TIMESTAMP WITH LOCAL TIME ZONE معادل PostgreSQL ندارد؛ در نگاشت‌های قابل‌حمل از آن دوری کن.

قانون طلایی زمان: همه‌چیز را UTC نگه دار
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
spring.jpa.properties.hibernate.timezone.default_storage=NORMALIZE_UTC

با NORMALIZE_UTC، هر OffsetDateTime/ZonedDateTime قبل از نوشتن به UTC تبدیل می‌شود و در یک ستون timestamp معمولی می‌نشیند — رفتار دو موتور دقیقاً یکی می‌شود. پیش‌فرضِ Hibernate 6.2+ مقدار DEFAULT است، یعنی اگر دیتابیس پشتیبانی بومی داشته باشد (هر دو دارند) از نوعِ with time zone استفاده می‌کند — که قابل‌حمل هست ولی معناشناسی‌اش، همان‌طور که دیدیم، یکی نیست. برای «فقط لحظه‌ی زمانی» از Instant استفاده کن.

JSON

CREATE TABLE doc (id bigint PRIMARY KEY, payload jsonb NOT NULL);
CREATE INDEX ix_doc_payload ON doc USING gin (payload);

SELECT id FROM doc WHERE payload @> '{"status":"ACTIVE"}';
SELECT payload ->> 'status' AS status FROM doc;
در جاوا هر دو یک نگاشت دارند

کافی است بنویسی @JdbcTypeCode(SqlTypes.JSON) private Map<String, Object> payload; و Hibernate روی PostgreSQL به jsonb و روی Oracle به json/clob نگاشت می‌کند. چیزی که قابل‌حمل نیست، کوئری‌زدن روی محتواست: @> و ->> معادل مستقیمی در Oracle ندارند و باید JSON_EXISTS/JSON_VALUE بنویسی. اگر بنا داری روی JSON فیلتر کنی، آن کوئری‌ها را در یک لایه‌ی جدا بگذار.


قفل‌گذاری: خوش‌بینانه در برابر بدبینانه

وقتی دو تراکنش همزمان یک ردیف را عوض کنند، بدون کنترل یکی تغییرِ دیگری را بی‌سروصدا پاک می‌کند — lost update.

ویرایش یک سند مشترک

قفل بدبینانه یعنی در اتاق را قفل کنی تا کسی نتواند وارد شود؛ امن ولی کند. قفل خوش‌بینانه یعنی در را باز بگذاری ولی روی سند یک «شماره‌ی نسخه» بزنی؛ موقع ذخیره اگر ببینی نسخه جلوتر رفته، می‌فهمی یکی وسط کار عوضش کرده و کارت را رد می‌کنی. خوش‌بین به این‌که «تعارض کم پیش می‌آید»، پس بهایش را فقط وقتی رخ داد بپرداز.

قفل خوش‌بینانه با @Version

@Entity
public class Account {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;
    private BigDecimal balance;

    @Version private long version;   // Hibernate خودکار +۱ می‌کند و در WHERE چک می‌کند
}
UPDATE account
SET    balance = $1, version = $2
WHERE  id = $3 AND version = $4;
-- نوعِ ستون: version bigint NOT NULL

اگر تعداد ردیف‌های به‌روزشده صفر باشد یعنی کسی زودتر نوشته — Hibernate OptimisticLockException پرتاب می‌کند و تو باید retry کنی یا خطا بدهی.

چرا قفل خوش‌بینانه روی هر دو موتور دقیقاً یکسان کار می‌کند

این تنها مکانیزم همزمانی در کل فصل است که هیچ وابستگی‌ای به موتور ندارد: یک شرط اضافه در WHERE و شمردن ردیف‌های تحت‌تأثیر — نه FOR UPDATE، نه قفل دیتابیس، نه قابلیت خاصی از Dialect. برای سیستم‌هایی که باید هم روی PostgreSQL و هم Oracle مستقر شوند، @Version پیش‌فرضِ عاقلانه است.

قفل بدبینانه با LockModeType

Account acc = em.find(Account.class, 1L, LockModeType.PESSIMISTIC_WRITE);

public interface AccountRepository extends JpaRepository<Account, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "0"))
    Optional<Account> findById(Long id);
}
-- قفل انحصاری، منتظر می‌ماند
SELECT a.id, a.balance, a.version FROM account a WHERE a.id = $1 FOR UPDATE;

-- بدون انتظار: خطای SQLSTATE 55P03 (lock_not_available)
SELECT a.id FROM account a WHERE a.id = $1 FOR UPDATE NOWAIT;

-- رد کردن ردیف‌های قفل‌شده (الگوی job queue)
SELECT j.id FROM job j WHERE j.status = 'READY'
ORDER BY j.id FETCH FIRST 10 ROWS ONLY FOR UPDATE SKIP LOCKED;

-- انتظار محدود: PostgreSQL «FOR UPDATE WAIT n» ندارد
SET LOCAL lock_timeout = '3s';

SELECT a.id FROM account a WHERE a.id = $1 FOR SHARE;   -- قفل اشتراکی
`jakarta.persistence.lock.timeout` روی دو موتور یک معنی ندارد
  • 0 در هر دو به NOWAIT ترجمه می‌شود (خطای 55P03 در PostgreSQL، ORA-00054 در Oracle). قابل‌حمل.
  • -2 به SKIP LOCKED ترجمه می‌شود؛ هر دو پشتیبانی می‌کنند (PostgreSQL از 9.5).
  • مقدار مثبت فقط در Oracle به FOR UPDATE WAIT n تبدیل می‌شود. در PostgreSQL چنین نحوی وجود ندارد و hint نادیده گرفته می‌شود — یعنی تراکنشت به‌جای سه ثانیه تا ابد منتظر می‌ماند. آنجا باید SET LOCAL lock_timeout بزنی.

هر دو خطا در جاوا به PessimisticLockException (در Spring: CannotAcquireLockException) نگاشت می‌شوند، پس کد قابل‌حمل می‌ماند؛ اما رفتار زیرین نه.

ویژگی خوش‌بینانه بدبینانه
مکانیزم @Version + چک در WHERE قفل واقعی دیتابیس (FOR UPDATE)
کشف تعارض هنگام flush/commit لحظه‌ی خواندن
قفل دیتابیس؟ خیر بله (تا پایان تراکنش)
قابل‌حمل؟ کاملاً نحو یکی، ولی timeout و خطاها متفاوت
مناسبِ تعارضِ کم، throughput بالا تعارضِ زیاد روی همان ردیف
انواع `LockModeType`
  • OPTIMISTIC: تضمین می‌کند نسخه تا پایان تراکنش عوض نشده، حتی برای موجودیتِ فقط‌خوانده‌شده.
  • OPTIMISTIC_FORCE_INCREMENT: نسخه را بالا می‌برد حتی بدون تغییر ردیف (برای وقتی تغییرِ فرزند باید نسخه‌ی والد را عوض کند).
  • PESSIMISTIC_READ: قفل اشتراکی. در PostgreSQL به FOR SHARE می‌رود؛ در Oracle چون قفل اشتراکیِ سطری وجود ندارد عملاً مثل FOR UPDATE اجرا می‌شود — یعنی روی Oracle سختگیرانه‌تر از انتظارت است.
  • PESSIMISTIC_WRITE: قفل انحصاری (FOR UPDATE در هر دو). PESSIMISTIC_FORCE_INCREMENT: همان + افزایش نسخه.

سطوح ایزولاسیون: شبیه به‌نظر می‌رسند، یکی نیستند

-- چهار سطح پذیرفته می‌شود، ولی READ UNCOMMITTED مثل READ COMMITTED رفتار می‌کند
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;   -- پیش‌فرض
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- snapshot واقعی در سطح تراکنش
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;     -- SSI واقعی؛ خطای 40001
SHOW default_transaction_isolation;
`@Transactional(isolation = REPEATABLE_READ)` روی Oracle خطا می‌دهد

Oracle این سطح را ندارد و هنگام شروع تراکنش InvalidIsolationLevelException می‌گیری. SERIALIZABLE هم در دو موتور دو چیز است: PostgreSQL از 9.1 Serializable Snapshot Isolation واقعی دارد و با 40001 serialization_failure تعارض را اعلام می‌کند؛ Oracleِ SERIALIZABLE در واقع snapshot isolation است و فقط ORA-08177: can't serialize access می‌دهد — یعنی برخی ناهنجاری‌های write-skew در Oracle ممکن می‌مانند. در هر دو، این خطاها گذرا هستند و کد باید retry داشته باشد، نه این‌که ۵۰۰ برگرداند.

پیش‌فرضِ عاقلانه: خوش‌بینانه

در اکثر اپ‌های وب تعارضِ همزمان روی یک ردیف نادر است، پس @Version پیش‌فرض درست است. سراغ بدبینانه فقط وقتی برو که نقطه‌ی داغِ رقابتی واقعی داری (کاهش موجودی در فروش لحظه‌ای). سیاست retry را جدا از منطق کسب‌وکار بنویس (مثلاً Spring Retry) تا هم OptimisticLockException را بگیرد هم 40001/ORA-08177.


Batching و نوشتنِ انبوه: جایی که تنظیمِ درایور به‌اندازه‌ی کد اهمیت دارد

spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true
@Transactional
public void bulkInsert(List<BookDto> input) {
    for (int i = 0; i < input.size(); i++) {
        em.persist(toEntity(input.get(i)));
        if (i % 50 == 0) { em.flush(); em.clear(); }   // هم‌اندازه با batch_size
    }
}

اما آنچه روی سیم می‌رود، در دو موتور یکی نیست:

-- بدون تنظیم خاص: درایور ۵۰ دستورِ جدا را در یک بسته می‌فرستد
INSERT INTO book (title, price, id) VALUES ($1, $2, $3);
INSERT INTO book (title, price, id) VALUES ($1, $2, $3);   -- ... ۵۰ بار

-- با reWriteBatchedInserts=true درایور آن‌ها را ادغام می‌کند:
INSERT INTO book (title, price, id)
VALUES ($1,$2,$3), ($4,$5,$6), ($7,$8,$9) /* ... تا ۵۰ مجموعه */;

-- jdbc:postgresql://db:5432/app?reWriteBatchedInserts=true&prepareThreshold=5
چرا این تفاوت مهم است

درایورِ PostgreSQL به‌طور پیش‌فرض دستورهای batch را یکی‌یکی می‌فرستد (فقط در یک بسته‌ی شبکه)، پس سرور همچنان ۵۰ بار اجرا می‌کند. reWriteBatchedInserts=true آن‌ها را به یک INSERT چندردیفی تبدیل می‌کند و معمولاً ۲ تا ۳ برابر سریع‌تر می‌شود — و فقط در JDBC URL قابل فعال‌سازی است، نه در Hibernate. در مقابل، درایور Oracle از ابتدا array binding واقعی دارد؛ آنجا اهرمِ اصلی، statement cache است تا hard parse نخوری. یعنی برای رسیدن به سرعت یکسان، در هر موتور یک اهرمِ متفاوت را می‌کشی.

`reWriteBatchedInserts` فقط برای `INSERT`های ساده کار می‌کند

اگر INSERT تو ON CONFLICT یا RETURNING داشته باشد — یا اگر از GenerationType.IDENTITY استفاده کنی که Hibernate را مجبور به RETURNING id می‌کند — بازنویسی انجام نمی‌شود. دلیلی دیگر برای انتخاب SEQUENCE در مسیرهای درجِ انبوه. ضمناً order_inserts=true لازم است تا درج‌های یک جدول کنار هم بیفتند؛ بدون آن یک INSERT به جدول دیگر وسط batch آن را می‌شکند.

عملیات انبوه با JPQL

int updated = em.createQuery(
        "update Book b set b.price = b.price * 1.1 where b.publishedYear < :y")
    .setParameter("y", 2000).executeUpdate();
UPDATE book SET price = price * 1.1 WHERE published_year < $1;

-- حذف انبوه با محدودیت تعداد (برای جلوگیری از تراکنش غول‌پیکر)
DELETE FROM book
WHERE  id IN (SELECT id FROM book WHERE archived ORDER BY id FETCH FIRST 10000 ROWS ONLY);
عملیات انبوه، persistence context را دور می‌زند

یک update/delete در JPQL مستقیم به SQL تبدیل می‌شود و هیچ موجودیتی در حافظه را به‌روز نمی‌کند و کش سطح‌دوم را هم بی‌اعتبار نمی‌کند (مگر با @Modifying(clearAutomatically = true, flushAutomatically = true)). بعد از بالک، شیءهای managed تو کهنه‌اند: یا در تراکنش جدا انجامش بده یا بلافاصله em.clear() بزن. این تله‌ی JPA است، نه تله‌ی دیتابیس.

upsert: ON CONFLICT در برابر MERGE

INSERT INTO stock (sku, qty) VALUES ($1, $2)
ON CONFLICT (sku) DO UPDATE SET qty = stock.qty + EXCLUDED.qty
RETURNING sku, qty;

-- PostgreSQL 15+ نحو استاندارد MERGE را هم دارد (و از ۱۷، MERGE ... RETURNING)
در سطح JPA، upsert وجود ندارد

سه گزینه داری: (۱) em.merge() که یک SELECT و بعد INSERT/UPDATE می‌زند — ساده ولی کند و در برابر رقابت همزمان امن نیست؛ (۲) native query با ON CONFLICT/MERGE که سریع و اتمیک است ولی قابل‌حمل نیست؛ (۳) از Hibernate 6.5 به بعد @SQLInsert یا HQLِ insert ... on conflict. اگر باید روی هر دو موتور بروی، گزینه‌ی دوم را با دو پیاده‌سازیِ آشکار پشت یک اینترفیس بنویس — نه یک SQLِ «هوشمند» که ادعا کند روی هر دو کار می‌کند.


کش سطح‌دوم: کش مشترک بین تراکنش‌ها

کش سطح‌دوم در سطحِ EntityManagerFactory است و بین همه‌ی تراکنش‌ها و کاربران مشترک می‌ماند.

انبار مرکزی در برابر میز شخصی

کش سطح‌اول میز شخصیِ هر ویراستار بود که آخر روز خالی می‌شد. کش سطح‌دوم یک انبار مرکزیِ مشترک است که بعد از رفتن همه هم پابرجاست. سریع است، اما خطرش «کهنگی» است: اگر داده در دیتابیس بیرون از Hibernate عوض شود، انبار مرکزی نمی‌فهمد.

کش سطح‌دوم پیش‌فرض خاموش است و یک provider می‌خواهد (EhCache، Infinispan، Hazelcast یا هر JCache):

@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Country {
    @Id private String code;
    private String name;
}
راهبرد یعنی مناسبِ
READ_ONLY داده هرگز عوض نمی‌شود جدول‌های ثابت (کشورها، ارزها)
NONSTRICT_READ_WRITE به‌روزرسانیِ گاه‌به‌گاه، بدون قفل داده‌ی کم‌تعارض با تازگیِ کم‌اهمیت
READ_WRITE همگام‌سازیِ نرم با soft lock داده‌ای که هم خوانده هم نوشته می‌شود
TRANSACTIONAL کش تراکنشی کامل (نیازمند JTA) محیط‌های سازمانی با XA
کش سطح‌دوم را با دقت روشن کن

فقط برای داده‌ای بصرفه است که زیاد خوانده و کم نوشته می‌شود و بیرون از Hibernate تغییر نمی‌کند. query cache هم جدا از entity cache است و باید با hibernate.cache.use_query_cache=true روشن شود. اگر عملیات انبوه داری یا کسی مستقیم روی دیتابیس دست می‌برد، این کش دقیقاً همان جایی است که داده‌ی کهنه به کاربر نشان داده می‌شود.

دیتابیس هم کش خودش را دارد — و اینجا هم دو مسیر متفاوت است
-- PostgreSQL کش نتیجه‌ی کوئری ندارد؛ فقط بلاک‌ها را در shared_buffers نگه می‌دارد
SHOW shared_buffers;

SELECT sum(heap_blks_hit) * 100 / nullif(sum(heap_blks_hit + heap_blks_read), 0) AS hit_pct
FROM   pg_statio_user_tables;

برای جدول‌های مرجعِ کوچک در Oracle، RESULT_CACHE اغلب همان سودِ کش سطح‌دوم را بدون ریسک کهنگی می‌دهد، چون خودِ دیتابیس هنگام تغییر جدول کش را بی‌اعتبار می‌کند. PostgreSQL چنین چیزی ندارد و کش سطح‌دوم Hibernate آنجا جای خالیِ واقعی‌تری را پر می‌کند.


DTO Projection: وقتی موجودیت کامل زیادی است

رسید در برابر کل پرونده‌ی حساب

اگر فقط «نام مشتری و مبلغ کل» را می‌خواهی، آوردنِ کل Customer با تاریخچه‌ی سفارش‌ها مثل بیرون‌کشیدنِ کل پرونده‌ی بانکی برای دیدن یک عدد است. DTO یک «رسیدِ» کوچک و فقط‌خواندنی است.

// ۱) constructor expression در JPQL
record CustomerView(Long id, String name, BigDecimal total) {}

List<CustomerView> views = em.createQuery(
    "select new com.app.CustomerView(c.id, c.name, sum(o.amount)) " +
    "from Customer c join c.orders o group by c.id, c.name",
    CustomerView.class).getResultList();

// ۲) interface-based projection در Spring Data
interface CustomerView { Long getId(); String getName(); }
List<CustomerView> findByActiveTrue();

// ۳) Tuple یا Object[] برای موارد پویا

وقتی DTO از توابع تجمیعی استفاده می‌کند، ناچار از JPQL بیرون می‌زنی — و اینجا دو موتور آشکارا فرق می‌کنند:

SELECT c.id, c.name,
       SUM(o.amount)                              AS total,
       STRING_AGG(o.code, ', ' ORDER BY o.code)   AS codes,
       COALESCE(c.nickname, c.name)               AS display_name
FROM   customer c JOIN orders o ON o.customer_id = c.id
GROUP  BY c.id, c.name, c.nickname
ORDER  BY total DESC
FETCH  FIRST 20 ROWS ONLY;
`STRING_AGG` در برابر `LISTAGG` — و سقفِ پنهانِ Oracle

دو تفاوت: (۱) نحو مرتب‌سازی، که در PostgreSQL داخل تابع و در Oracle با WITHIN GROUP می‌آید؛ (۲) خروجیِ LISTAGG یک VARCHAR2 است و اگر مجموع طول از ۴۰۰۰ بایت بگذرد ORA-01489: result of string concatenation is too long می‌گیری — در 12.2+ می‌توانی LISTAGG(... ON OVERFLOW TRUNCATE) بنویسی. STRING_AGG چنین سقفی ندارد. این دقیقاً همان دسته گزارشی است که در تست کار می‌کند و در پروداکشن می‌شکند. ضمناً NVL معادلِ دوآرگومانیِ COALESCE است و خودِ COALESCE در Oracle هم کار می‌کند — برای کدِ قابل‌حمل همان بهتر است.

چرا DTO سریع‌تر است

DTOها موجودیتِ managed نیستند: نه snapshot برای dirty checking، نه اشغال persistence context، نه ریسکِ LAZY load و N+1. قانون سرانگشتی: موجودیت برای نوشتن، DTO برای خواندن.


ضدالگوی Open Session In View

OSIV یعنی persistence context در کل طول یک درخواست وب باز نگه داشته می‌شود — حتی هنگام رندر view یا سریال‌سازی JSON.

`spring.jpa.open-in-view` به‌طور پیش‌فرض `true` است

Spring Boot این تنظیم را پیش‌فرض روشن می‌گذارد و از نسخه‌ی ۲.۰ هنگام بالا آمدن اپ هشدار می‌دهد: «spring.jpa.open-in-view is enabled by default». این هشدار الکی نیست.

تاکسی‌ای که با تاکسی‌متر روشن منتظرت می‌ماند

OSIV مثل این است که تاکسی (connection) را از پارکینگ (pool) بگیری و تاکسی‌متر را از شروع منطق کسب‌وکار تا رندرِ کامل صفحه روشن نگه داری. connection تا آخرِ رندر اشغال می‌ماند، پس بقیه بیشتر در صف می‌مانند و throughput می‌افتد.

مشکلات اصلی: (۱) connection طولانی‌مدت اشغال می‌شود و pool زودتر خالی؛ (۲) مرز تراکنش مبهم می‌شود و LAZYها در لایه‌ی view خارج از تراکنش N+1های نامرئی می‌سازند؛ (۳) جداییِ لایه‌ها می‌شکند، چون لایه‌ی نمایش پنهانی کوئری می‌زند.

در هر دو موتور، connection منبعی کمیاب است — ولی به دلایل متفاوت

در PostgreSQL هر connection یک پروسه‌ی سیستم‌عامل جداست، پس max_connections معمولاً حدود ۱۰۰ تا ۲۰۰ تنظیم می‌شود و راه‌حل استاندارد یک pooler بیرونی مثل PgBouncer است. در Oracle (حالت dedicated server) هم هر session یک پروسه دارد و سقف‌های sessions/processes در سطح instance اعمال می‌شود؛ رد شدن از آن‌ها یعنی ORA-00018: maximum number of sessions exceeded و اپ اصلاً وصل نمی‌شود. نتیجه یکی است: OSIV این منبع را بی‌دلیل گروگان می‌گیرد. pool را هم بی‌دلیل بزرگ نکن — یک pool ۲۰تایی با تراکنش‌های کوتاه تقریباً همیشه بهتر از ۲۰۰تایی با تراکنش‌های طولانی است.

راه‌حل درست: OSIV را خاموش کن و DTO بده

spring.jpa.open-in-view=false بگذار و مطمئن شو هر داده‌ای که به لایه‌ی نمایش می‌رود داخل تراکنشِ سرویس کاملاً بارگذاری شده. این‌طور مرز تراکنش شفاف می‌شود، connection زودتر آزاد می‌شود، و اگر یک LAZY را خارج از تراکنش صدا بزنی به‌جای N+1 خاموش یک LazyInitializationException صریح می‌گیری. آن استثنا دوستِ توست.


رصدپذیری: چطور ببینی Hibernate واقعاً چه فرستاده

spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE
# برای شمارش واقعی کوئری‌ها: datasource-proxy یا p6spy

و سمت دیتابیس، دو دنیای کاملاً متفاوت:

-- پلنِ واقعی همراه با زمان و I/O
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT a.id, a.name FROM author a JOIN book b ON b.author_id = a.id WHERE a.name LIKE 'A%';

-- پرهزینه‌ترین دستورها
SELECT calls, total_exec_time, mean_exec_time, rows, query
FROM   pg_stat_statements
ORDER  BY total_exec_time DESC
FETCH  FIRST 20 ROWS ONLY;
شکارِ N+1 مستقیماً در دیتابیس

یک N+1 امضای مشخصی دارد: تعداد اجرای بسیار بالا و زمان میانگین بسیار پایین، با شکلِ ... WHERE fk_column = ?. در PostgreSQL با مرتب‌سازی pg_stat_statements روی calls و در Oracle با V$SQL روی executions پیدایش می‌کنی — بدون خواندن حتی یک خط کد جاوا. این روش را در مصاحبه بگو.

و برای دیدن این‌که چه کسی connection را گرفته و منتظر چیست:

SELECT pid, state, wait_event_type, wait_event, now() - query_start AS runtime, left(query, 80)
FROM   pg_stat_activity
WHERE  datname = current_database() AND state <> 'idle'
ORDER  BY runtime DESC;

-- قفل‌های مسدودکننده
SELECT pid, pg_blocking_pids(pid) AS blocked_by, left(query, 60)
FROM   pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;

دام‌های رایج و بهترین شیوه‌ها

  • همه‌ی رابطه‌ها را LAZY کن، حتی @ManyToOne/@OneToOne؛ سپس در هر کوئری صراحتاً fetch کن.
  • در Oracle (و معمولاً PostgreSQL) از SEQUENCE استفاده کن، نه IDENTITY — تنها راه زنده نگه‌داشتن JDBC batching.
  • allocationSize را با INCREMENT BY هم‌تراز کن و هر دو را در یک code review ببین.
  • روی هر ستون FK ایندکس بساز؛ در Oracle نبودنش قفل جدول و deadlock می‌سازد.
  • merge مقدار بازگشتی می‌دهد — همیشه x = em.merge(x).
  • N+1 را در لاگ SQL یا در pg_stat_statements/V$SQL با مرتب‌سازی روی تعداد اجرا شکار کن.
  • دو طرف رابطه‌ی دوطرفه را با متد کمکی همگام کن و cascade/orphanRemoval را فقط روی parent-child واقعی بگذار.
  • @Version را روی موجودیت‌های قابل‌ویرایشِ همزمان بگذار؛ تنها مکانیزم همزمانیِ کاملاً قابل‌حمل.
  • به‌جای @Lob String از @JdbcTypeCode(SqlTypes.LONGVARCHAR) استفاده کن تا هم text بگیری هم clob.
  • رشته‌ی خالی را قبل از رسیدن به Oracle نرمال کن؛ آنجا '' همان NULL است.
  • hibernate.jdbc.time_zone=UTC بگذار و به timestamptz برای نگه‌داری منطقه تکیه نکن.
  • reWriteBatchedInserts=true در URLِ PostgreSQL و implicitStatementCacheSize در Oracle.
  • in_clause_parameter_padding را روشن کن تا Oracle hard parse کمتری بخورد.
  • OSIV را خاموش کن، و برای صفحه‌های فقط‌خواندنی DTO بده.

پرسش‌های مصاحبه

اول خودت جواب بده، بعد پاسخ را باز کن.

۱) تفاوت JPA و Hibernate چیست و Dialect کجای ماجراست؟

JPA یک مشخصات است — رابط‌ها و annotationهایی در jakarta.persistence که هیچ کدی اجرا نمی‌کنند. Hibernate یک پیاده‌سازی است. به‌محض استفاده از امکانات مختصِ Hibernate (@BatchSize، @Cache) به آن گره می‌خوری. Dialect لایه‌ای در Hibernate است که تفاوت‌های SQLِ هر موتور را می‌داند: نام نوع‌ها (bigint در برابر number(19,0))، صفحه‌بندی (LIMIT/OFFSET در برابر OFFSET ... FETCH FIRST)، خواندن sequence (nextval('s') در برابر s.NEXTVAL FROM dual)، گزینه‌های قفل (FOR UPDATE WAIT n فقط در Oracle) و توابع (STRING_AGG در برابر LISTAGG). خودِ مشخصات JPA درباره‌ی هیچ‌کدام حرفی نمی‌زند. از Hibernate 6 به بعد Dialect خودکار تشخیص داده می‌شود و بهتر است دستی ست نشود.

۲) (Oracle در برابر PostgreSQL) چرا در Oracle باید از `SEQUENCE` استفاده کنی و نه `IDENTITY`؟

چون IDENTITY باعث می‌شود Hibernate JDBC batching را کاملاً غیرفعال کند: شناسه فقط بعد از INSERT تولید می‌شود و Hibernate برای گذاشتن موجودیت در persistence context به آن نیاز دارد، پس هر ردیف یک round-trip جدا می‌شود. با SEQUENCE شناسه‌ها قبل از درج معلوم‌اند، پس صدها INSERT در یک batch می‌روند و با allocationSize = 50 تعداد NEXTVAL هم پنجاه برابر کم می‌شود. در Oracle که هزینه‌ی round-trip و parse بالاتر است، این تفاوت یک مرتبه‌ی بزرگی است. در PostgreSQL هم IDENTITY batching را می‌کُشد، فقط RETURNING id ضربه را کمی نرم‌تر می‌کند.

۳) (Oracle در برابر PostgreSQL) `allocationSize` چیست و اگر با `INCREMENT BY` نخواند چه می‌شود؟

allocationSize می‌گوید با هر خواندن sequence چند شناسه در حافظه رزرو شود. اگر allocationSize = 50 باشد ولی sequence INCREMENT BY 1 باشد، Hibernate بازه‌ای را پخش می‌کند که مالکش نیست و با بالا آمدن instance دوم کلید تکراری می‌گیری: 23505 duplicate key در PostgreSQL و ORA-00001 در Oracle. در محیط‌های مشترک از optimizerِ pooled-lo استفاده کن که با برنامه‌هایی که مستقیم NEXTVAL می‌زنند سازگار است (برخلاف hilo قدیمی). و بدان که «حفره» در شناسه‌ها طبیعی است.

۴) چهار حالت چرخه‌ی حیات موجودیت را توضیح بده.

transient: با new ساخته، هیچ context نمی‌شناسدش. managed: به persistence context متصل، تغییراتش خودکار ردیابی و flush می‌شود. detached: قبلاً managed بوده ولی context بسته شده؛ تغییراتش ردیابی نمی‌شود. removed: با remove() علامت حذف خورده. گذرها: persist (transient→managed)، remove (managed→removed)، بستن EM (managed→detached)، merge (detached→کپیِ managed).

۵) (تله) این کد چه می‌کند؟
Book b = new Book("X");
em.merge(b);
em.getTransaction().commit();
System.out.println(b.getId());

b.getId() احتمالاً null است! چون merge شیءِ ورودی را managed نمی‌کند؛ یک کپیِ managed می‌سازد و id روی آن کپی ست می‌شود. راه درست: Book managed = em.merge(b);. برای موجودیتِ تازه اصلاً persist بهتر بود. نکته‌ی اضافه: merge برای هر موجودیت یک SELECT هم می‌زند، پس در حلقه‌های بزرگ گران است.

۶) persistence context چیست و چه تضمینی می‌دهد؟ dirty checking چطور کار می‌کند؟

نقشه‌ای درون‌حافظه‌ای در سطح EntityManager که موجودیت‌های managed را با کلید id نگه می‌دارد و همیشه روشن است. تضمینش یکتاییِ نمونه است: در یک context برای هر id فقط یک شیء وجود دارد. عمرش تا پایان تراکنش است و بین کاربران مشترک نیست. dirty checking یعنی Hibernate هنگام managed شدن یک snapshot می‌گیرد و در flush شیء را با آن مقایسه می‌کند؛ فیلدهای متفاوت UPDATE می‌گیرند — بدون هیچ save صریح.

۷) (Oracle) `@DynamicUpdate` چه می‌کند و چرا در Oracle مهم‌تر است؟

به‌طور پیش‌فرض UPDATE همه‌ی ستون‌ها را می‌نویسد؛ @DynamicUpdate فقط ستون‌های تغییرکرده را. در Oracle روی جدول‌های CLOB/BLOB حیاتی است، چون بازنویسیِ بی‌دلیلِ یک LOB مگابایت‌ها undo/redo می‌سازد و به‌روزرسانیِ یک title می‌تواند به I/O سنگین تبدیل شود. در PostgreSQL ستون‌های بزرگ در TOAST می‌نشینند و اگر عوض نشوند اشاره‌گرشان بازاستفاده می‌شود، پس فشار کمتر است. در عوض @DynamicUpdate هر بار SQL تازه می‌سازد و کارایی statement cache را کم می‌کند.

۸) مشکل N+1 را توضیح بده، راه‌حل‌هایش را نام ببر، و بگو چرا در Oracle بدتر است.

N+1 یعنی یک کوئری اصلی برای لیست والدها به‌علاوه‌ی N کوئری فرعی برای رابطه‌ی LAZY هر والد. راه‌حل‌ها: join fetch، @EntityGraph (سازگار با pagination)، @BatchSize (دسته‌بندی با IN)، و DTO projection. در Oracle بدتر است چون هزینه‌ی هر round-trip بالاتر است (soft/hard parse در shared pool، لایه‌ی listener، رویداد انتظار SQL*Net message from client)، و برای @BatchSize باید حواست به سقف ۱۰۰۰تاییِ لیست IN باشد (ORA-01795) که در PostgreSQL وجود ندارد.

۹) (تله) چرا EAGER کردنِ رابطه معمولاً N+1 را حل نمی‌کند؟

چون وقتی لیستی از والدها را با یک کوئری (نه find) می‌خوانی، Hibernate برای هر والدِ EAGER ممکن است یک SELECT جدا بزند — همان N+1، این‌بار همیشگی و غیرقابل‌کنترل. EAGER تصمیمِ fetch را به سطحِ نگاشت می‌برد، در حالی‌که fetch باید تصمیمِ هر کوئری باشد.

۱۰) (Oracle در برابر PostgreSQL) صفحه‌بندی چطور ترجمه می‌شود و چرا `OFFSET` بزرگ بد است؟

در PostgreSQL LIMIT n OFFSET m (نحو استاندارد OFFSET ... FETCH FIRST هم پذیرفته می‌شود). در Oracle 12c+ همان نحو استاندارد OFFSET m ROWS FETCH FIRST n ROWS ONLY؛ در 11g باید دو لایه ROWNUM تودرتو نوشت، چون ROWNUM قبل از ORDER BY تخصیص داده می‌شود. OFFSET بزرگ در هر دو کند است چون همه‌ی ردیف‌های قبلی تولید و دور ریخته می‌شوند؛ راه‌حل keyset pagination با شرط (created_at, id) < (?, ?) است — که در Oracle با پرانتز دوگانه نوشته می‌شود.

۱۱) (Oracle در برابر PostgreSQL) تله‌ی رشته‌ی خالی در Oracle چیست؟

در Oracle '' بی‌سروصدا به NULL تبدیل می‌شود؛ در PostgreSQL یک مقدار واقعی و متمایز است. پیامدها: ستونِ NOT NULL روی Oracle با ورودیِ خالی ORA-01400 می‌دهد ولی روی PostgreSQL ذخیره می‌شود؛ چیزی که نوشتی '' بود ولی null می‌خوانی (خطر NullPointerException)؛ و حتی الحاق فرق می‌کند، چون در Oracle 'a' || NULL برابر 'a' است و در PostgreSQL NULL. راه‌حل: رشته‌ی خالی را در لایه‌ی اپلیکیشن همیشه به null نرمال کن و در JPQL هرگز = '' ننویس.

۱۲) (Oracle در برابر PostgreSQL) `@Lob String` روی هر موتور چه می‌شود و توصیه‌ی امروزی چیست؟

روی Oracle به CLOB نگاشت می‌شود که همان چیز درست است. روی PostgreSQL Hibernate از APIهای Large Object استفاده می‌کند و ستون oid می‌سازد؛ داده در pg_largeobject می‌رود، خواندن خارج از تراکنش خطای «Large Objects may not be used in auto-commit mode» می‌دهد و برای objectهای یتیم باید vacuumlo بزنی. توصیه: به‌جای @Lob از @JdbcTypeCode(SqlTypes.LONGVARCHAR) استفاده کن که روی PostgreSQL به text و روی Oracle به clob می‌رود؛ @Lob را فقط برای streaming واقعی نگه دار.

۱۳) (Oracle در برابر PostgreSQL) `boolean` و تاریخ/زمان را چطور نگاشت می‌کنی؟

PostgreSQL boolean بومی دارد. Oracle تا 19c در SQL نوع boolean ندارد و Hibernate آن را به NUMBER(1,0) با مقادیر 1/0 نگاشت می‌کند (یک CHECK (col IN (0,1)) هم خوب است)؛ 23ai بالاخره BOOLEAN واقعی دارد. برای شِماهای موروثیِ 'Y'/'N' یک AttributeConverter بنویس. درباره‌ی زمان: DATE در Oracle ساعت هم دارد (برخلاف PostgreSQL) و مقایسه‌ی مستقیم با تاریخ بدون TRUNC جواب نمی‌دهد؛ و timestamptz در PostgreSQL منطقه را ذخیره نمی‌کند (به UTC نرمال می‌شود) در حالی که TIMESTAMP WITH TIME ZONE در Oracle واقعاً offset را نگه می‌دارد. راه امن: hibernate.jdbc.time_zone=UTC و timezone.default_storage=NORMALIZE_UTC.

۱۴) سمت مالک در رابطه‌ی دوطرفه یعنی چه، و چرا ایندکسِ FK در Oracle حیاتی است؟

در دیتابیس رابطه فقط با یک ستون FK نمایش داده می‌شود؛ سمتی که آن ستون را دارد (@ManyToOne) مالک است و Hibernate فقط تغییرات همان سمت را برای نوشتن FK می‌بیند. سمت @OneToMany با mappedBy می‌گوید «من نمای معکوسم»؛ فراموش‌کردن mappedBy یک join table اضافه یا UPDATEهای زائد می‌سازد. درباره‌ی ایندکس: در Oracle اگر ستون FKِ فرزند ایندکس نداشته باشد، هر DELETE یا تغییر کلیدِ والد یک قفلِ share row exclusive روی کل جدول فرزند می‌گیرد و به موجِ ORA-00060 تبدیل می‌شود؛ در PostgreSQL قفل جدول نیست ولی بررسی FK به اسکن کامل بدل می‌شود. هیچ‌کدام خودکار ساخته نمی‌شوند.

۱۵) تفاوت `CascadeType.REMOVE` و `orphanRemoval` چیست؟ `ON DELETE CASCADE` کجای ماجراست؟

REMOVE فقط با حذفِ خودِ والد فعال می‌شود. orphanRemoval = true قوی‌تر است: بیرون‌انداختن یک فرزند از collection والد (حتی بدون حذف والد) آن فرزند را از دیتابیس حذف می‌کند. هر دو در سطح جاوا اجرا می‌شوند — Hibernate فرزندان را می‌خواند و یک DELETE جدا برای هرکدام می‌زند. ON DELETE CASCADE همان کار را در یک دستور دیتابیسی انجام می‌دهد و در هر دو موتور موجود است، ولی Hibernate از آن بی‌خبر می‌ماند و کش کهنه می‌شود؛ با @OnDelete(action = OnDeleteAction.CASCADE) خبرش کن یا بعدش em.clear() بزن.

۱۶) قفل خوش‌بینانه و بدبینانه را مقایسه کن.

خوش‌بینانه با @Version کار می‌کند: شرط WHERE version = ? اضافه می‌شود و اگر نسخه عوض شده باشد OptimisticLockException می‌گیری و باید retry کنی؛ هیچ قفلی روی دیتابیس نمی‌گذارد و کاملاً قابل‌حمل است چون فقط SQL استاندارد می‌خواهد. بدبینانه با SELECT ... FOR UPDATE ردیف را از لحظه‌ی خواندن قفل می‌کند. در اکثر اپ‌های وب خوش‌بینانه پیش‌فرض بهتری است؛ بدبینانه فقط برای نقاط داغِ رقابتی.

۱۷) (Oracle در برابر PostgreSQL) `jakarta.persistence.lock.timeout` روی دو موتور چه می‌کند؟

0 در هر دو به FOR UPDATE NOWAIT ترجمه می‌شود (خطای 55P03 در PostgreSQL و ORA-00054 در Oracle) و -2 به FOR UPDATE SKIP LOCKED که هر دو دارند. اما مقدار مثبت فقط در Oracle معنا دارد و به FOR UPDATE WAIT n تبدیل می‌شود؛ در PostgreSQL چنین نحوی نیست و hint نادیده گرفته می‌شود، یعنی تراکنش بی‌نهایت منتظر می‌ماند. برای انتظارِ محدود در PostgreSQL باید SET LOCAL lock_timeout = '3s' بزنی.

۱۸) (Oracle در برابر PostgreSQL) سطوح ایزولاسیون چه تفاوتی دارند؟

PostgreSQL هر چهار سطح را می‌پذیرد (هرچند READ UNCOMMITTED مثل READ COMMITTED رفتار می‌کند)، REPEATABLE READ واقعاً snapshot در سطح تراکنش می‌دهد و SERIALIZABLE از 9.1 یک SSI واقعی است که با 40001 تعارض را اعلام می‌کند. Oracle فقط READ COMMITTED (snapshot در سطح دستور) و SERIALIZABLE (که عملاً snapshot isolation است و ORA-08177 می‌دهد) را دارد؛ REPEATABLE READ وجود ندارد، پس @Transactional(isolation = REPEATABLE_READ) روی Oracle با InvalidIsolationLevelException می‌شکند. در هر دو، خطاهای سریال‌سازی گذرا هستند و کد باید retry داشته باشد.

۱۹) (Oracle در برابر PostgreSQL) برای درج ۵۰۰ هزار ردیف چه تنظیم می‌کنی؟

مشترک: hibernate.jdbc.batch_size=50، order_inserts=true، order_updates=true، استفاده از SEQUENCE با allocationSize بزرگ، و flush()+clear() هر batch. مخصوصِ PostgreSQL: reWriteBatchedInserts=true در JDBC URL تا درایور batch را به یک INSERT ... VALUES (...),(...) تبدیل کند (۲ تا ۳ برابر سریع‌تر) — و دقت کن که با RETURNING/ON CONFLICT و با IDENTITY این بازنویسی انجام نمی‌شود. مخصوصِ Oracle: درایور از ابتدا array binding واقعی دارد، پس به‌جایش oracle.jdbc.implicitStatementCacheSize را بالا ببر تا hard parse نخوری.

۲۰) (Oracle در برابر PostgreSQL) upsert را چطور می‌نویسی؟

JPA عملیات upsert استاندارد ندارد. PostgreSQL نحو بومیِ INSERT ... ON CONFLICT (col) DO UPDATE SET ... RETURNING دارد (و از ۱۵ به بعد MERGE استاندارد را هم). Oracle ON CONFLICT ندارد و نحو استانداردش MERGE INTO ... USING (SELECT ... FROM dual) ON (...) WHEN MATCHED THEN UPDATE ... WHEN NOT MATCHED THEN INSERT ... است؛ RETURNING هم فقط داخل PL/SQL با RETURNING ... INTO ممکن است. em.merge() جایگزین نیست: یک SELECT می‌زند و در برابر رقابت همزمان اتمیک نیست.

۲۱) `LazyInitializationException` کِی رخ می‌دهد و OSIV چه ربطی دارد؟

وقتی به یک رابطه‌ی LAZY دست می‌زنی در حالی‌که موجودیت دیگر managed نیست — مثلاً در لایه‌ی نمایش پس از پایان تراکنش. راه‌حلِ غلط: روشن‌کردن OSIV یا EAGER کردن همه‌چیز. OSIV persistence context را در کل درخواست باز نگه می‌دارد؛ Spring Boot پیش‌فرض true می‌گذارد ولی هشدار می‌دهد چون connection تا پایان رندر اشغال می‌ماند، مرز تراکنش مبهم می‌شود و N+1های نامرئی می‌سازد. در هر دو موتور connection کمیاب است (max_connections در PostgreSQL، سقف sessions/processes و ORA-00018 در Oracle). راه‌حلِ درست: OSIV را خاموش کن و داده را داخل تراکنش با join fetch/@EntityGraph یا DTO کامل آماده کن.

۲۲) کش سطح‌دوم چیست و چه فرقی با سطح‌اول دارد؟

سطح‌اول در سطح EntityManager/تراکنش است، همیشه روشن و بین کاربران مشترک نیست. سطح‌دوم در سطح EntityManagerFactory است، پیش‌فرض خاموش، یک provider می‌خواهد و بین همه‌ی تراکنش‌ها مشترک است. فقط برای داده‌ی پرخوانده، کم‌نوشته و بدون تغییر بیرون از Hibernate بصرفه است. نکته‌ی جانبی: در Oracle برای جدول‌های مرجع کوچک، RESULT_CACHE سمت دیتابیس همان سود را بدون ریسک کهنگی می‌دهد چون خودِ موتور هنگام تغییر جدول کش را بی‌اعتبار می‌کند؛ PostgreSQL چنین چیزی ندارد و فقط بلاک‌ها را در shared_buffers نگه می‌دارد.

۲۳) (سنیور) چرا برای صفحه‌های فقط‌خواندنی DTO را ترجیح می‌دهیم و کجا قابل‌حمل بودن تمام می‌شود؟

موجودیتِ managed سه هزینه دارد که در خواندن بی‌فایده‌اند: snapshot برای dirty checking، اشغال persistence context، و ریسکِ N+1 و LazyInitializationException. DTO هیچ‌کدام را ندارد. اما به‌محض این‌که DTO به توابع تجمیعی برسد، قابل‌حمل بودن تمام می‌شود: STRING_AGG(x, ',' ORDER BY y) در PostgreSQL در برابر LISTAGG(x, ',') WITHIN GROUP (ORDER BY y) در Oracle — که خروجی‌اش VARCHAR2 است و بالای ۴۰۰۰ بایت ORA-01489 می‌دهد مگر با ON OVERFLOW TRUNCATE.

۲۴) (سنیور) بدون خواندن کد جاوا، چطور یک N+1 را در دیتابیس پیدا می‌کنی؟

دنبال دستوری بگرد که تعداد اجرای بسیار بالا و زمان میانگین بسیار پایین دارد و شکلش ... WHERE fk_column = ? است. در PostgreSQL: SELECT calls, mean_exec_time, query FROM pg_stat_statements ORDER BY calls DESC. در Oracle: SELECT executions, elapsed_time/executions, sql_text FROM v$sql ORDER BY executions DESC. برای دیدن پلن هم در PostgreSQL EXPLAIN (ANALYZE, BUFFERS) و در Oracle /*+ GATHER_PLAN_STATISTICS */ به‌همراه DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST').


در یک نگاه
  • JPA یک مشخصات است و Hibernate پیاده‌سازی آن (ORM 7 روی Jakarta Persistence 3.2 و Java 17). Dialect همان لایه‌ای است که یک کد را به SQLِ PostgreSQL یا Oracle ترجمه می‌کند و از Hibernate 6 خودکار تشخیص داده می‌شود.
  • هر موجودیت در یکی از چهار حالت است؛ merge یک کپیِ managed برمی‌گرداند نه خودِ شیء. Persistence context یکتاییِ نمونه را تضمین می‌کند و dirty checking تغییرات را بدون save کشف می‌کند.
  • تولید شناسه مهم‌ترین تفاوت عملی دو موتور است: IDENTITY درِ batching را کاملاً می‌بندد؛ SEQUENCE با allocationSize هم‌تراز با INCREMENT BY پاسخِ درست است، به‌ویژه در Oracle.
  • بزرگ‌ترین قاتل کارایی N+1 است؛ درمانش join fetch، @EntityGraph، @BatchSize (با حواس‌جمعی به سقف ۱۰۰۰تاییِ IN در Oracle) و DTO projection. صفحه‌بندی در PostgreSQL LIMIT/OFFSET و در Oracle OFFSET ... FETCH FIRST (در 11g، ROWNUM تودرتو) است؛ برای صفحات عمیق سراغ keyset برو.
  • در نگاشت نوع‌ها: @Lob String را با @JdbcTypeCode(SqlTypes.LONGVARCHAR) عوض کن، boolean در Oracle یعنی NUMBER(1) (تا 23ai)، رشته‌ی خالی در Oracle همان NULL است، و timestamptz منطقه را ذخیره نمی‌کند در حالی که TIMESTAMP WITH TIME ZONE می‌کند — همه‌چیز را UTC نگه دار.
  • قفل خوش‌بینانه (@Version) تنها مکانیزم همزمانیِ کاملاً قابل‌حمل است؛ NOWAIT و SKIP LOCKED مشترک‌اند ولی WAIT n فقط در Oracle و در PostgreSQL باید lock_timeout بگذاری. Oracle REPEATABLE READ ندارد.
  • برای نوشتنِ انبوه، در PostgreSQL reWriteBatchedInserts=true و در Oracle statement cache را تنظیم کن؛ و روی هر ستون FK ایندکس بساز که در Oracle نبودنش قفل جدول و deadlock می‌سازد.
  • OSIV را خاموش کن، داده را داخل تراکنش کامل بارگذاری کن و برای خواندن DTO بده.

Let's accept an uncomfortable truth up front: most of the weird bugs and 3 AM slowdowns in a Java application that talks to a database come from one simple misunderstanding — the developer thinks Hibernate "just writes SQL," when in fact Hibernate is a full machine with its own memory, its own state, and its own mindset. In this chapter you won't memorize a handful of annotations; you'll come to understand the mind of this machine: when it fires a query, when it doesn't, and why it sometimes silently runs a flood of SQL behind your back.

And we add a second layer that JPA chapters usually skip: the same Java code produces two different SQL statements on PostgreSQL and on Oracle. A single @GeneratedValue can cut insert throughput tenfold on Oracle. A @Lob is harmless on Oracle and throws a bizarre exception on PostgreSQL. An empty string is stored on PostgreSQL and silently becomes NULL on Oracle. Throughout this chapter every SQL snippet appears in two tabs — PostgreSQL and Oracle. Read both: being senior means knowing what your code looks like on the other engine.

Roadmap for this chapter
  1. JPA vs Hibernate and the Dialect — the layer that translates one piece of code into each engine's SQL.
  2. The entity lifecycle — transient / managed / detached / removed.
  3. The persistence context — the "first-level cache" — plus dirty checking and flush.
  4. Identifier generation — IDENTITY vs SEQUENCE, allocationSize and optimizers; the biggest practical difference between the two engines.
  5. Fetch types and N+1 — the biggest performance killer and its four cures.
  6. PaginationLIMIT/OFFSET vs FETCH FIRST and ROWNUM, and keyset pagination.
  7. Mapping relationships — the owning side, mappedBy, and the FK index that is mandatory on Oracle.
  8. Type mapping — CLOB/TEXT, boolean/NUMBER(1), the empty-string trap, DATE, timestamptz and JSON.
  9. Locking@Version, NOWAIT/SKIP LOCKED and isolation levels.
  10. Batching, second-level cache, DTOs, OSIV and observability — then interview questions.

Part 0 — words you must feel before we start

  • ORM: the bridge that translates "Java objects" into "relational table rows" and back. The object world (inheritance, references, graphs) and the relational world (tables, columns, foreign keys) are two different languages; this friction is the object-relational impedance mismatch.
  • JPA (Jakarta Persistence API): a specification, not code. It only says which interfaces a persistence tool must expose — EntityManager, @Entity, @OneToMany. Since Jakarta EE 9 the packages moved from javax.persistence to jakarta.persistence; the current spec is Jakarta Persistence 3.2.
  • Hibernate: the most popular implementation of that spec. Hibernate ORM 7 builds on Jakarta Persistence 3.2 and baselines on Java 17.
  • Entity: a class marked @Entity, each instance mapping to one table row. Transaction: an all-or-nothing unit of work.
  • Dialect: the class that knows the target database's syntax — pagination, column type names, how to read a sequence, which FOR UPDATE options exist. org.hibernate.dialect.PostgreSQLDialect and org.hibernate.dialect.OracleDialect.
  • JDBC driver: the layer that actually puts bytes on the wire — org.postgresql:postgresql and com.oracle.database.jdbc:ojdbc11. Batching behaviour depends as much on the driver as on Hibernate.
JPA is the "highway code," Hibernate is the "car," the Dialect is the "local road signage"

JPA is the highway code: it says a steering wheel and brake must exist and what they do — but it doesn't drive you anywhere. Hibernate is the actual car. And the Dialect is what happens when the same car crosses a border and has to read different signs: one country writes "LIMIT," the other writes "FETCH FIRST"; one has a real boolean, the other makes you fake it with NUMBER(1). The driver (your code) doesn't change; the Dialect translates the signs.

Why JPA and not just JDBC?

With raw JDBC you hand-write SQL, read the ResultSet row by row and map it to objects — repetitive, error-prone plumbing. JPA removes that and hands you memory management, caching, automatic change detection and SQL generation. But that intelligence isn't free: if you don't know what happens under the hood, the same intelligence turns into slowness and bugs.


The Dialect: where "one piece of code" becomes "two SQL statements"

The path from Java code to the database — the Dialect is the translator / مسیر از کد جاوا تا دیتابیس.

flowchart LR
    A[Your code / Repository] --> B[EntityManager - JPA API]
    B --> C[Hibernate Session]
    C --> D[SQL AST + Dialect]
    D --> E[JDBC Driver]
    E --> F[(PostgreSQL 16/17)]
    E --> G[(Oracle 19c / 23ai)]

Take a simple entity:

@Entity
@Table(name = "book")
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(nullable = false, length = 255)
    private String title;

    private boolean published;
    private BigDecimal price;
    private LocalDateTime createdAt;
}

Here is the DDL that is actually produced:

CREATE TABLE book (
    id         bigint         NOT NULL,
    title      varchar(255)   NOT NULL,
    published  boolean,
    price      numeric(38,2),
    created_at timestamp(6),
    PRIMARY KEY (id)
);

CREATE SEQUENCE book_seq START WITH 1 INCREMENT BY 50;
Five differences in a single DDL

bigint vs number(19,0); varchar(255) vs varchar2(255 char); a real boolean vs number(1,0); numeric vs number; and identifiers, which fold to lower case on PostgreSQL and to UPPER case on Oracle. You wrote none of that — the Dialect did. If you build the schema by hand with Flyway or Liquibase (which is what you should do in production), it must match what the Dialect expects, or ddl-auto=validate will fail at startup.

The byte-semantics `VARCHAR2(255)` trap on Oracle — the silent killer of non-ASCII text

On Oracle, an unqualified VARCHAR2(255) means 255 bytes by default (NLS_LENGTH_SEMANTICS). Under AL32UTF8 a Persian or Arabic letter takes two bytes and many emoji take four — so that column may hold only 127 Persian characters before you get ORA-12899: value too large for column. Hibernate itself emits varchar2(255 char) and is fine; the danger comes from legacy tables and hand-written migrations. Check USER_TAB_COLUMNS.CHAR_USED (C = CHAR, B = BYTE). PostgreSQL has no such trap — varchar(255) always means 255 characters.

Don't set the Dialect by hand unless you must

Since Hibernate 6 the Dialect is detected from the JDBC connection metadata. Setting it wrongly (e.g. an old Oracle version) makes Hibernate give up OFFSET ... FETCH FIRST and fall back to ROWNUM. Only spell it out for offline DDL generation:

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
# spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.OracleDialect
spring.jpa.hibernate.ddl-auto=validate

The entity lifecycle: four states everything revolves around

The single most important mental model in all of JPA: at any instant an entity is in exactly one of four states, and Hibernate's behaviour depends entirely on that state.

A clerk and the company archive

Picture the EntityManager as a company archivist. A file can be in four situations: (1) you just scribbled it on your desk and haven't handed it in — nobody but you knows it exists (transient). (2) You gave it to the archivist and he now watches it; every scribble he sees and records (managed). (3) The archivist went home and locked the door; the file is in your hand but nobody tracks its changes (detached). (4) You ordered it destroyed; it still exists but is condemned to deletion (removed).

  • Transient: built with new; no EntityManager knows it and it has no row in the database.
  • Managed: attached to a persistence context; every field change is written automatically at transaction end (dirty checking). You get here via persist() or by reading (find, getReference, a query).
  • Detached: was managed, but the context closed or you detached it; changes are no longer tracked.
  • Removed: marked for deletion with remove(); a DELETE runs at transaction end.

Transitions between the four states / گذرها بین چهار حالت.

stateDiagram-v2
    [*] --> Transient: new
    Transient --> Managed: persist()
    Managed --> Detached: close() / detach() / clear()
    Detached --> Managed: merge() returns a copy
    Managed --> Removed: remove()
    Removed --> [*]: DELETE on commit
    Managed --> Managed: dirty check -> UPDATE
Book book = new Book("Clean Code");   // 1) transient — Hibernate is unaware

em.getTransaction().begin();
em.persist(book);                     // 2) managed — now watched, gets an id
book.setTitle("Clean Code 2");        // changed with no save! it will be recorded
em.getTransaction().commit();         // INSERT + UPDATE go to the database

em.close();                           // 3) book is now detached
book.setTitle("ignored");             // this change is recorded nowhere

EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
Book merged = em2.merge(book);        // re-attaches the detached one (as a copy)
em2.remove(merged);                   // 4) removed — deleted on commit
em2.getTransaction().commit();

And this is exactly what goes over the wire:

SELECT nextval('book_seq');                                  -- id fetched before the INSERT

INSERT INTO book (title, published, price, created_at, id)
VALUES ($1, $2, $3, $4, $5);

UPDATE book SET title = $1, published = $2, price = $3, created_at = $4
WHERE  id = $5;                                              -- result of dirty checking

DELETE FROM book WHERE id = $1;
Even the placeholder shape differs — and it helps you in the logs

PostgreSQL sends parameters as $1, $2, ... and that is how you see them in pg_stat_statements; Oracle uses bind variables :1, :2, ... and that is what appears in V$SQL. When you need to connect a slow query in the application log to a statement in the database, recognising these two shapes makes the job trivial.

The classic trap: `merge` does not manage the object you pass in

em.merge(book) creates and returns a managed copy; the original book stays detached. Always work with the return value: book = em.merge(book);. If you keep applying later changes to the old book, nothing happens, and you'll hunt the bug for hours.

`persist` vs `merge`

Use persist for a fresh, transient entity; merge for a detached one whose changes you want to re-attach. persist on a detached entity that already has an id throws EntityExistsException. The hidden cost of merge: it issues an extra SELECT per entity to read the current row — in a loop over a thousand objects that's a thousand round-trips.


The persistence context: the first-level cache

The persistence context is an in-memory map holding all managed entities of one EntityManager, keyed by id. It is also called the first-level cache, and it is always on — you cannot turn it off.

An editor's desk

The persistence context is an editor's desk over one working day. Every file you pull out of the archive stays as a copy on the desk. Ask for it again and the editor doesn't walk back to the archive — he hands you the same copy. That's why two find calls with the same id return the exact same object and hit the database only once. At the end of the day (commit) all the scribbles go back together; tomorrow (a new EntityManager) the desk is empty.

Book b1 = em.find(Book.class, 1L);   // a SELECT runs
Book b2 = em.find(Book.class, 1L);   // no SELECT! it comes from the first-level cache
System.out.println(b1 == b2);        // true — exactly the same object

The only SELECT that actually goes over the wire:

SELECT b1_0.id, b1_0.created_at, b1_0.price, b1_0.published, b1_0.title
FROM   book b1_0
WHERE  b1_0.id = $1;
Why this "uniqueness guarantee" is gold

Within one persistence context there is exactly one object instance per id, so you needn't worry about inconsistent in-memory versions and == between two references to the same row works. But the trap: this cache lives only until that transaction ends and is not shared across users. The cache shared across transactions is the second-level cache we'll meet later.

Identifier case folding: the two engines are exact opposites

Both engines fold unquoted identifiers, but in opposite directions: PostgreSQL to lower case, Oracle to UPPER CASE. As long as you never quote, you're fine. Trouble starts when someone created an Oracle table named "Book" — then SELECT * FROM book fails with ORA-00942: table or view does not exist and you have to preserve the quotes in Hibernate with @Table(name = "\"Book\""). Senior rule: never create a quoted identifier; use unquoted snake_case on both engines.

Flush, dirty checking, and flush mode

How does Hibernate know you changed a field when you never called save? The answer is dirty checking.

A snapshot on arrival

When an entity becomes managed, Hibernate takes a snapshot of its field values. At flush it compares the current object with that snapshot; any field that differs is "dirty" and gets an UPDATE. You never have to say "this changed" — Hibernate figures it out by comparison.

Flush means synchronising in-memory changes with the database by running SQL — but flush is not commit; until you commit it can still be rolled back. Under FlushModeType.AUTO (the default) Hibernate flushes at three moments: before commit, before a JPQL/HQL query whose result might depend on pending changes, and on an explicit em.flush().

Flush Mode When it flushes Use for
AUTO (default) Before commit and before related queries Safest; the default for most apps
COMMIT Only before transaction commit An optimization when queries don't depend on pending changes
`COMMIT` can make a query return wrong results

With flush mode COMMIT, if you change an entity in memory and then run a related query, the query sees the old database data because the change wasn't flushed. Unless you truly know what you're doing, don't touch the AUTO default.

By default Hibernate writes every column in an UPDATE. With @DynamicUpdate only the changed columns appear:

-- without @DynamicUpdate
UPDATE book SET title = $1, published = $2, price = $3, created_at = $4, content = $5
WHERE  id = $6;

-- with @DynamicUpdate
UPDATE book SET title = $1 WHERE id = $2;
On Oracle, `@DynamicUpdate` on LOB-bearing tables is worth several times the throughput

If a table has a CLOB/BLOB column, an UPDATE that also writes that column — even with the identical value — means rewriting the LOB and generating undo/redo; updating a single title can cost megabytes of I/O. On PostgreSQL large columns live in TOAST and, if unchanged, their pointer is reused, so the pressure is lower. Bottom line: @DynamicUpdate is good on both, critical on Oracle — the trade-off is that it builds fresh SQL every time and reduces the benefit of the driver's statement cache.


Identifier generation: the biggest practical difference between Oracle and PostgreSQL

If you memorise only one section of this chapter for a senior interview, make it this one.

Two kinds of queue ticket

IDENTITY is like walking to the counter and having the clerk write your number right then; until he writes it, you don't know your number. SEQUENCE is like a ticket machine you visit once to take fifty consecutive numbers, then serve fifty customers without walking back. The difference is "how many times you have to walk" — and every walk is a network round-trip.

Option one: IDENTITY

@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
-- standard and recommended on PostgreSQL 16/17
CREATE TABLE book (
    id    bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    title varchar(255) NOT NULL
);
-- older style: CREATE TABLE book (id bigserial PRIMARY KEY, ...);

-- Hibernate uses RETURNING to get the generated value back
INSERT INTO book (title) VALUES ($1) RETURNING id;
`IDENTITY` silently kills JDBC batching

With GenerationType.IDENTITY, Hibernate must execute every INSERT separately: no id exists until the database writes the row, and without an id it cannot put the entity in the persistence context. The result: JDBC batching is disabled entirely, even with hibernate.jdbc.batch_size=50. For 10,000 rows that's 10,000 round-trips instead of 200. On Oracle, where round-trip and parse costs are higher, it is a disaster.

Option two: SEQUENCE (the right answer for Oracle, and usually for PostgreSQL too)

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_gen")
@SequenceGenerator(name = "book_gen", sequenceName = "book_seq", allocationSize = 50)
private Long id;
CREATE SEQUENCE book_seq
    START WITH 1
    INCREMENT BY 50
    CACHE 1;                 -- on PostgreSQL, CACHE is per session

SELECT nextval('book_seq');

SELECT sequencename, increment_by, last_value
FROM   pg_sequences WHERE schemaname = current_schema();
Why SEQUENCE is the right answer on Oracle
  1. Batching survives — because ids are known before the INSERT, hundreds of inserts go out in one batch. This is the only way to reach real throughput on Oracle.
  2. Round-trips are divided — with allocationSize = 50 one NEXTVAL yields fifty ids.
  3. In Oracle culture a sequence is a first-class citizen — an Oracle identity column is a sequence behind the scenes, it just ties your hands.

On PostgreSQL SEQUENCE usually wins too; the gap is just smaller because RETURNING id absorbs part of the IDENTITY cost.

Identifier allocation with the pooled optimizer / جریان تخصیص شناسه.

sequenceDiagram
    participant App as Hibernate
    participant DB as Database
    App->>DB: select nextval / seq.NEXTVAL
    DB-->>App: 150 (hi value)
    Note over App: assigns 101..150 from memory
    App->>DB: INSERT batch of 50 rows
    App->>DB: select nextval / seq.NEXTVAL
    DB-->>App: 200
`allocationSize` must exactly equal `INCREMENT BY`

This is the most dangerous silent mismatch in all of JPA. If Java says allocationSize = 50 but the database sequence is INCREMENT BY 1, Hibernate hands out a range it doesn't own, and the moment a second instance starts you get duplicate keys: 23505 duplicate key value violates unique constraint on PostgreSQL and ORA-00001: unique constraint violated on Oracle. The number in the migration and the number in the code must be reviewed together.

`pooled` vs `pooled-lo`

pooled treats the value read from the sequence as the ceiling of the range; pooled-lo treats it as the floor. Both interoperate with other programs that call NEXTVAL directly — unlike the legacy hilo, which collides with them (a real problem in enterprise Oracle schemas where some PL/SQL script always writes to the same table).

spring.jpa.properties.hibernate.id.optimizer.pooled.preferred=pooled-lo
Gaps in ids are normal

With allocationSize = 50, restarting the app throws away the rest of the range; on Oracle with CACHE 100 a restart burns a hundred numbers. If someone demands "invoice numbers with no gaps," that is a business requirement, not a primary key — solve it with a dedicated counter table and an explicit lock.

If you're stuck on Oracle older than 12c

Before 12c Oracle had no identity columns and the usual pattern was a sequence plus a trigger:

-- PostgreSQL never needed this
CREATE TABLE book (id bigserial PRIMARY KEY, title varchar(255) NOT NULL);
-- bigserial behind the scenes: DEFAULT nextval('book_id_seq')

With such a schema, use GenerationType.SEQUENCE with the same sequenceName and allocationSize = 1, and drop the trigger if you can — triggers are slow and they hide the id from Hibernate.


Fetch types and the legendary N+1 problem

Every association (@OneToMany, @ManyToOne, ...) has a fetch strategy:

  • EAGER: reading the main entity loads its association immediately along with it.
  • LAZY: the association isn't loaded until you actually touch it. Hibernate installs a proxy and fires a query on first access.
Memorize the fetch defaults

Per the JPA spec: @ManyToOne and @OneToOne default to EAGER; @OneToMany and @ManyToMany default to LAZY. Those EAGER defaults on *ToOne associations are a silent source of many performance problems.

What is N+1?

List<Author> authors = em.createQuery(
        "select a from Author a", Author.class).getResultList();   // 1 query

for (Author a : authors) {
    System.out.println(a.getBooks().size());   // a fresh SELECT every time!
}

If books is LAZY, the first query fetches all authors, then each author triggers a new SELECT. For 1,000 authors that's 1,001 queries. That's N+1, and this is what the log looks like:

SELECT a1_0.id, a1_0.name FROM author a1_0;

-- then N times, once per author
SELECT b1_0.author_id, b1_0.id, b1_0.title FROM book b1_0 WHERE b1_0.author_id = $1;
SELECT b1_0.author_id, b1_0.id, b1_0.title FROM book b1_0 WHERE b1_0.author_id = $1;
A clumsy trip to the supermarket

N+1 is like cooking a dish and, instead of one shopping list, remembering one item at a time and walking to the shop and back for each: once for onions, once for tomatoes, once for salt. Each round trip is a database round-trip — and it's those round-trips, not the query itself, that are the real bottleneck.

Making it EAGER does not fix N+1

"Let me make the association EAGER so I avoid N+1" usually makes it worse: when you read a list of parents with a query, Hibernate may fire a separate SELECT per parent — exactly the same N+1, only now you can't control it. The golden rule: keep every association LAZY and say explicitly, per query, what should be fetched.

On Oracle, N+1 costs more than on PostgreSQL

Each round-trip is typically more expensive on Oracle: on top of network latency, every execution must be found (soft parse) or built (hard parse) in the shared pool. An N+1 that takes 80 ms against a local PostgreSQL can take seconds against production Oracle and shows up in AWR as SQL*Net message from client wait time.

The four cures for N+1

1) JOIN FETCH in JPQL:

List<Author> authors = em.createQuery(
        "select distinct a from Author a join fetch a.books",
        Author.class).getResultList();
SELECT a1_0.id, a1_0.name, b1_0.author_id, b1_0.id, b1_0.title
FROM   author a1_0
JOIN   book   b1_0 ON a1_0.id = b1_0.author_id;

2) @EntityGraph — declare at runtime which associations come eager; unlike join fetch on a collection, it works with pagination:

public interface AuthorRepository extends JpaRepository<Author, Long> {
    @EntityGraph(attributePaths = {"books"})
    @Query("select a from Author a")
    List<Author> findAllWithBooks();
}

3) @BatchSize — instead of N individual queries, Hibernate batches the keys:

@OneToMany(mappedBy = "author")
@BatchSize(size = 25)   // instead of 1000 queries, batches of 25 => 40 queries
private List<Book> books = new ArrayList<>();
SELECT b1_0.author_id, b1_0.id, b1_0.title
FROM   book b1_0
WHERE  b1_0.author_id IN ($1, $2, $3, $4, $5 /* ... up to 25 */);

-- the idiomatic PostgreSQL alternative, which takes a single bind:
SELECT b.author_id, b.id, b.title FROM book b WHERE b.author_id = ANY($1);  -- bigint[]
Oracle's 1000-element `IN` list limit

Up to Oracle 21c an IN expression accepts at most 1000 items; beyond that you get ORA-01795: maximum number of expressions in a list is 1000 (23ai raises this limit dramatically). PostgreSQL has no hard cap, though multi-thousand lists wreck plans there too. Never set @BatchSize above 100 — 10 to 50 is the sane range. And if you write where id in :ids yourself with a user-supplied list, chunk it; this is one of the classic bugs that only appears in Oracle production.

Every `IN` list length is a different statement

IN (?,?,?) and IN (?,?,?,?) are two separate statements to the database: on Oracle that means hard parses and a bloated shared pool, on PostgreSQL it means losing prepared-statement caching. That is why hibernate.query.in_clause_parameter_padding=true rounds the list length up to the next power of two. On Oracle, turn it on almost always.

4) DTO Projection — when you only need a few fields, don't fetch the entity at all:

List<AuthorSummary> rows = em.createQuery(
    "select new com.app.AuthorSummary(a.id, a.name, count(b)) " +
    "from Author a left join a.books b group by a.id, a.name",
    AuthorSummary.class).getResultList();
Which cure, when?
  • join fetch when you know exactly that you need this association and don't need pagination (watch the Cartesian product with multiple collections).
  • @EntityGraph for the same benefit, pagination-compatible and with a cleaner query.
  • @BatchSize a great global optimization; 10–50 is sensible (larger risks ORA-01795).
  • DTO projection the best option for read-only screens; it neither clutters the persistence context nor needs dirty checking.

Pagination: where the two dialects visibly diverge

em.createQuery("select b from Book b order by b.id", Book.class)
  .setFirstResult(20).setMaxResults(10).getResultList();
SELECT b1_0.id, b1_0.title, b1_0.price
FROM   book b1_0
ORDER  BY b1_0.id
LIMIT  10 OFFSET 20;

-- PostgreSQL also accepts the standard syntax, fully equivalent:
-- ORDER BY b1_0.id OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY;
Why `ROWNUM` needs two layers

Because ROWNUM is assigned before ORDER BY. SELECT ... WHERE ROWNUM <= 10 ORDER BY id grabs ten arbitrary rows and then sorts them — not the first ten. The correct pattern nests: the inner layer sorts, the middle assigns ROWNUM, the outer slices. Hibernate avoids this mess entirely when it knows the Dialect is 12c or later — another reason not to pin the Dialect to an old version.

A large `OFFSET` is slow on both engines — the fix is keyset pagination

Whether LIMIT/OFFSET or OFFSET ... FETCH FIRST, the database must produce and discard every preceding row. Page 1000 with size 20 means producing 20,020 rows to deliver 20. The senior fix: instead of "skip this many," say "start after this key."

SELECT b.id, b.title, b.created_at
FROM   book b
WHERE  (b.created_at, b.id) < ($1, $2)     -- standard row-value comparison
ORDER  BY b.created_at DESC, b.id DESC
FETCH  FIRST 20 ROWS ONLY;
The `join fetch` + pagination trap

If you join fetch a collection and also set setMaxResults, Hibernate cannot limit in SQL (each parent expands into several rows), so it loads everything into memory and paginates in Java, emitting HHH000104: firstResult/maxResults specified with collection fetch; applying in memory. This is a logical limitation, identical on both engines. Use @EntityGraph, or a two-step approach (fetch ids with FETCH FIRST, then the data with where id in :ids).


Mapping relationships: the @OneToMany and @ManyToOne traps

The key to understanding it is the concept of the owning side.

A foreign key is like the "table number" in a restaurant

In a one-to-many, only one side actually holds the foreign key — as if each "order" had the "table number" written on it, while the table never wrote down its list of orders. In the database the relationship has just one FK column (the @ManyToOne side); that side is the owner. The @OneToMany side is only an inverse view and, via mappedBy, says "I'm not the owner."

@Entity
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;

    @ManyToOne(fetch = FetchType.LAZY)   // owning side: the FK column is here
    @JoinColumn(name = "customer_id")
    private Customer customer;
}

@Entity
public class Customer {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;

    @OneToMany(mappedBy = "customer")    // inverse side: not the owner
    private List<Order> orders = new ArrayList<>();
}
CREATE TABLE orders (
    id          bigint PRIMARY KEY,
    customer_id bigint,
    CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customer (id)
);

CREATE INDEX ix_orders_customer ON orders (customer_id);
On Oracle, an unindexed FK column is a concurrency bug, not just a performance issue

Neither engine indexes FK columns automatically, but the consequences differ. On Oracle, if the child's FK column has no index, every DELETE or parent-key update takes a share row exclusive lock on the entire child table — writes to any row of that table block until the transaction ends, and under load this turns into waves of ORA-00060 (deadlock). PostgreSQL takes no such table lock, but the FK check degenerates into a full scan of the child table. Rule: always index every FK column — you have to write it in the migration yourself.

Trap #1: forgetting to sync both sides

If you call order.setCustomer(c) but not c.getOrders().add(order), then within the same transaction reading c.getOrders() may not show the new order. The standard fix is a sync helper:

public void addOrder(Order order) {
    orders.add(order);
    order.setCustomer(this);   // both sides together
}
Trap #2: omitting `mappedBy`

Without mappedBy, Hibernate thinks you have two independent relationships and builds an extra join table or fires redundant UPDATEs. The symptom: after the INSERT you see a strange UPDATE in the log just to set the FK.

`List` or `Set` for a collection?

A Set is usually safer. With two @OneToMany List associations both EAGER (or in one join fetch) you get a MultipleBagFetchException, because Hibernate can't join two unordered "bags" at once. If order truly matters, use @OrderColumn or two separate queries.

Write `equals`/`hashCode` on entities with great care

A generated id is still null before persist, so if you put the entity into a HashSet before saving and then save it, its hashCode changes and the object gets "lost" in the Set. Use a stable business key, or base equals on id while keeping hashCode constant (e.g. per class). Note: with the SEQUENCE strategy the id exists before flush, which softens this problem considerably — another hidden advantage of SEQUENCE over IDENTITY.


Cascade and orphanRemoval: propagating operations across the graph

A folder and its files

CascadeType.REMOVE is like deleting a folder in your OS and having all the files inside deleted too. CascadeType.PERSIST means saving a new folder automatically saves its new files.

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
Cascade Type Means Example use
PERSIST Saving the parent also saves new children Placing an order with its lines
MERGE Merging the parent propagates to children Updating a detached graph
REMOVE Deleting the parent also deletes children Deleting an order and its lines
REFRESH Refreshing the parent propagates to children Re-syncing from the database
DETACH Detaching the parent detaches children too Evicting a graph from the context
ALL All of the above A "full ownership" parent-child relationship

Something many people don't know: CascadeType.REMOVE runs in Java, not in the database:

-- what CascadeType.REMOVE really sends: one DELETE per child
SELECT l.id, l.order_id FROM order_line l WHERE l.order_id = $1;
DELETE FROM order_line WHERE id = $1;
DELETE FROM order_line WHERE id = $1;
DELETE FROM orders     WHERE id = $1;

-- the database-level alternative
ALTER TABLE order_line ADD CONSTRAINT fk_line_order
    FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE;
Database `ON DELETE CASCADE` vs JPA `cascade`

Both engines support ON DELETE CASCADE and ON DELETE SET NULL, so this part is portable. A subtle difference: since version 15 PostgreSQL lets you write ON DELETE SET NULL (customer_id) to null only one column of a composite key; Oracle has no such option. More importantly, if you delegate cascading to the database, Hibernate is unaware of the deleted children and your first- and second-level caches go stale. Either declare @OnDelete(action = OnDeleteAction.CASCADE) so Hibernate knows, or call em.clear() after deleting the parent.

The subtle difference between `CascadeType.REMOVE` and `orphanRemoval`

REMOVE fires when the parent itself is deleted. orphanRemoval = true is stronger: merely removing a child from the parent's collection (order.getLines().remove(line)) deletes that now-orphaned child from the database, even if the parent survives. Don't use it on merely "shared" relationships or you'll delete shared data unintentionally.

Cascade on `@ManyToOne` is usually a mistake

cascade = ALL on the @ManyToOne side (e.g. Order to Customer) means "if an order is deleted, delete its customer too" — almost always a disaster. Put cascade on the side that truly owns the child's lifecycle.


Type mapping: where "portability" is really put to the test

Java type PostgreSQL Oracle Note
Long bigint number(19,0) Oracle has no dedicated integer type
String (255) varchar(255) varchar2(255 char) CHAR/BYTE semantics
Long text text clob VARCHAR2 caps at 4000 bytes by default
boolean boolean number(1,0) 23ai has a real BOOLEAN
BigDecimal numeric(p,s) number(p,s) nearly equivalent
LocalDate date date Oracle DATE carries a time part!
OffsetDateTime timestamptz timestamp(6) with time zone different semantics
byte[] bytea blob different streaming
UUID uuid raw(16) PostgreSQL has a native type
JSON jsonb json (21c+) or clob is json

Long text: CLOB vs TEXT

-- dangerous: @Lob on PostgreSQL can map to oid (large object)
CREATE TABLE article (id bigint PRIMARY KEY, content oid);

-- what you almost always want:
CREATE TABLE article (id bigint PRIMARY KEY, content text);
`@Lob String` on PostgreSQL is the classic "only in production" exception

On PostgreSQL @Lob makes Hibernate use the Large Object API and creates an oid column; the data lives in the system table pg_largeobject. Consequences: Large Objects may not be used in auto-commit mode when reading outside a transaction, the need for vacuumlo to clean up orphaned objects, and loss of straightforward backups. The portable fix recommended since Hibernate 6:

@JdbcTypeCode(SqlTypes.LONGVARCHAR)
@Column(name = "content")
private String content;
// PostgreSQL => text, Oracle => clob, with no conditional configuration

Keep @Lob only when you genuinely need Clob/Blob streaming.

boolean vs NUMBER(1)

CREATE TABLE account (
    id     bigint PRIMARY KEY,
    active boolean NOT NULL DEFAULT false
);

INSERT INTO account (id, active) VALUES (1, true);
SELECT id FROM account WHERE active;          -- usable directly in WHERE
Legacy Oracle schemas usually store `'Y'/'N'`, not `1/0`

Don't hack the Dialect for this; write an AttributeConverter (or use the ready-made org.hibernate.type.YesNoConverter / NumericBooleanConverter):

@Converter
public class YesNoConverter implements AttributeConverter<Boolean, String> {
    @Override public String convertToDatabaseColumn(Boolean v) {
        return Boolean.TRUE.equals(v) ? "Y" : "N";
    }
    @Override public Boolean convertToEntityAttribute(String db) { return "Y".equals(db); }
}

The advantage: your JPQL (where a.active = true) stays untouched; only the translation changes.

The empty-string trap on Oracle — the strangest difference of all

-- on PostgreSQL an empty string is a real value, distinct from NULL
INSERT INTO customer (id, middle_name) VALUES (1, '');

SELECT count(*) FROM customer WHERE middle_name = '';      -- 1
SELECT count(*) FROM customer WHERE middle_name IS NULL;   -- 0
SELECT length(middle_name) FROM customer WHERE id = 1;     -- 0
Three real consequences of "empty string = NULL" on Oracle
  1. NOT NULL breaks: an empty form field is stored on PostgreSQL but raises ORA-01400: cannot insert NULL on Oracle — the same application works on one engine and fails on the other.
  2. Round-tripping changes: you write an empty string and read back null; a NullPointerException is waiting for you.
  3. Concatenation differs: on Oracle 'a' || NULL is 'a', on PostgreSQL it is NULL.

The senior fix: normalise in the application layer — an AttributeConverter or constructor that always turns an empty string into null — and never write = '' in JPQL; always use is null or coalesce(...).

Date and time: the biggest misunderstanding

CREATE TABLE event (
    id        bigint PRIMARY KEY,
    day       date,                        -- date only, no time
    happened  timestamp(6),                -- no time zone
    happenedz timestamptz                  -- normalized to UTC; the zone itself is NOT stored
);

SET TIME ZONE 'UTC';
SELECT now(), current_date;
Three time-related differences you must memorise
  1. Oracle's DATE is not a date: it always includes hours, minutes and seconds (it just has no fractional seconds). Writing a LocalDateTime into a legacy DATE column silently truncates milliseconds, and day = DATE '2026-07-29' returns nothing unless you use TRUNC(day). On PostgreSQL date really is date-only.
  2. timestamptz does not store the zone: despite the name, PostgreSQL converts the value to UTC on write and renders it in the session TimeZone on read — the original zone is lost. Oracle's TIMESTAMP WITH TIME ZONE really keeps the offset or region name. So if you need to know "which zone did the user submit this in," PostgreSQL needs a separate column.
  3. TIMESTAMP WITH LOCAL TIME ZONE has no PostgreSQL equivalent; avoid it in portable mappings.
The golden rule of time: keep everything in UTC
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
spring.jpa.properties.hibernate.timezone.default_storage=NORMALIZE_UTC

With NORMALIZE_UTC, every OffsetDateTime/ZonedDateTime is converted to UTC before being written into a plain timestamp column — the behaviour of the two engines becomes identical. The Hibernate 6.2+ default is DEFAULT, which uses a native with time zone type when the database supports it (both do) — portable, but as we just saw, not semantically identical. For "a moment in time," prefer Instant.

JSON

CREATE TABLE doc (id bigint PRIMARY KEY, payload jsonb NOT NULL);
CREATE INDEX ix_doc_payload ON doc USING gin (payload);

SELECT id FROM doc WHERE payload @> '{"status":"ACTIVE"}';
SELECT payload ->> 'status' AS status FROM doc;
In Java both share one mapping

Just write @JdbcTypeCode(SqlTypes.JSON) private Map<String, Object> payload; and Hibernate maps it to jsonb on PostgreSQL and json/clob on Oracle. What is not portable is querying inside the JSON: @> and ->> have no direct Oracle counterpart and become JSON_EXISTS/JSON_VALUE. If you plan to filter on JSON, isolate those queries in a separate layer.


Locking: optimistic vs pessimistic

When two transactions change the same row concurrently, without control one silently wipes out the other's change — the lost update.

Editing a shared document

Pessimistic locking is like locking the room door so nobody else can enter; safe but slow. Optimistic locking leaves the door open but stamps a "version number" on the document; when you save, if the version has moved past the one you took, you know someone edited it mid-way and you reject your write. You're optimistic that conflicts are rare, so you pay only when one actually happens.

Optimistic locking with @Version

@Entity
public class Account {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;
    private BigDecimal balance;

    @Version private long version;   // Hibernate auto-increments it and checks it in WHERE
}
UPDATE account
SET    balance = $1, version = $2
WHERE  id = $3 AND version = $4;
-- column type: version bigint NOT NULL

If zero rows were updated, someone wrote first — Hibernate throws an OptimisticLockException and you must retry or surface an error.

Why optimistic locking behaves identically on both engines

This is the only concurrency mechanism in the whole chapter with no engine dependency: an extra WHERE predicate and a check of the affected-row count. No FOR UPDATE, no database lock, no Dialect capability. For systems that must run on both PostgreSQL and Oracle, @Version is the sensible default.

Pessimistic locking with LockModeType

Account acc = em.find(Account.class, 1L, LockModeType.PESSIMISTIC_WRITE);

public interface AccountRepository extends JpaRepository<Account, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "0"))
    Optional<Account> findById(Long id);
}
-- exclusive lock, waits
SELECT a.id, a.balance, a.version FROM account a WHERE a.id = $1 FOR UPDATE;

-- no waiting: fails immediately with SQLSTATE 55P03 (lock_not_available)
SELECT a.id FROM account a WHERE a.id = $1 FOR UPDATE NOWAIT;

-- skip locked rows (the job-queue pattern)
SELECT j.id FROM job j WHERE j.status = 'READY'
ORDER BY j.id FETCH FIRST 10 ROWS ONLY FOR UPDATE SKIP LOCKED;

-- bounded wait: PostgreSQL has no "FOR UPDATE WAIT n"
SET LOCAL lock_timeout = '3s';

SELECT a.id FROM account a WHERE a.id = $1 FOR SHARE;   -- shared lock
`jakarta.persistence.lock.timeout` does not mean the same thing on both engines
  • 0 maps to NOWAIT on both (55P03 on PostgreSQL, ORA-00054 on Oracle). Portable.
  • -2 maps to SKIP LOCKED; both support it (PostgreSQL since 9.5).
  • A positive value only means something on Oracle, where it becomes FOR UPDATE WAIT n. PostgreSQL has no such syntax and the hint is ignored — so instead of waiting three seconds your transaction waits forever. There you must issue SET LOCAL lock_timeout before the query.

Both errors map to PessimisticLockException in Java (CannotAcquireLockException in Spring), so the Java code stays portable; the underlying behaviour does not.

Feature Optimistic Pessimistic
Mechanism @Version + check in WHERE Real DB lock (FOR UPDATE)
Conflict detected at Flush/commit time The moment of reading
Holds a DB lock? No Yes (until transaction end)
Portable across engines? Completely Same syntax, different timeout and error codes
Good for Rare conflicts, high throughput Heavy contention on the same row
The `LockModeType` variants
  • OPTIMISTIC: guarantees the version hasn't changed by transaction end, even for a read-only entity.
  • OPTIMISTIC_FORCE_INCREMENT: forces the version up even without changing the row (when a child change must bump the parent's version).
  • PESSIMISTIC_READ: a shared lock. On PostgreSQL it becomes FOR SHARE; on Oracle, which has no shared row lock, Hibernate effectively runs FOR UPDATE — stricter than you expect.
  • PESSIMISTIC_WRITE: an exclusive lock (FOR UPDATE on both). PESSIMISTIC_FORCE_INCREMENT: the same plus a version bump.

Isolation levels: they look alike, they are not

-- all four levels accepted, though READ UNCOMMITTED behaves like READ COMMITTED
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;   -- default
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- true transaction-level snapshot
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;     -- real SSI; error 40001
SHOW default_transaction_isolation;
`@Transactional(isolation = REPEATABLE_READ)` fails on Oracle

Oracle has no such level, and you get an InvalidIsolationLevelException when the transaction starts. SERIALIZABLE also means two different things: PostgreSQL has had true Serializable Snapshot Isolation since 9.1 and reports conflicts as 40001 serialization_failure; Oracle's SERIALIZABLE is really snapshot isolation and only raises ORA-08177: can't serialize access — meaning some write-skew anomalies remain possible on Oracle. On both, these errors are transient and your code must retry rather than return a 500.

The sensible default: optimistic

In most web applications concurrent conflict on one specific row is rare, so @Version is the right default. Reach for pessimistic locking only for a genuinely contended hotspot (decrementing stock in a flash sale). Keep the retry policy separate from business logic (e.g. Spring Retry) so it covers OptimisticLockException as well as 40001/ORA-08177.


Batching and bulk writes: where driver configuration matters as much as code

spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true
@Transactional
public void bulkInsert(List<BookDto> input) {
    for (int i = 0; i < input.size(); i++) {
        em.persist(toEntity(input.get(i)));
        if (i % 50 == 0) { em.flush(); em.clear(); }   // match batch_size
    }
}

But what goes over the wire is not the same on both engines:

-- with no extra configuration the driver sends 50 separate statements in one packet
INSERT INTO book (title, price, id) VALUES ($1, $2, $3);
INSERT INTO book (title, price, id) VALUES ($1, $2, $3);   -- ... 50 times

-- with reWriteBatchedInserts=true the driver folds them together:
INSERT INTO book (title, price, id)
VALUES ($1,$2,$3), ($4,$5,$6), ($7,$8,$9) /* ... up to 50 tuples */;

-- jdbc:postgresql://db:5432/app?reWriteBatchedInserts=true&prepareThreshold=5
Why this difference matters

By default the PostgreSQL driver sends batched statements one by one (merely in one network packet), so the server still executes 50 statements. reWriteBatchedInserts=true turns them into a single multi-values INSERT, typically 2–3× faster — and it can only be enabled in the JDBC URL, not in Hibernate. The Oracle driver, by contrast, has had real array binding from the start; there the lever to pull is the statement cache (oracle.jdbc.implicitStatementCacheSize) so you avoid hard parses. Same goal, different lever per engine.

`reWriteBatchedInserts` only works for plain `INSERT`s

If your INSERT carries ON CONFLICT or RETURNING — or if you use GenerationType.IDENTITY, which forces Hibernate into RETURNING id — no rewriting happens and you gain nothing. One more reason to choose SEQUENCE on bulk-insert paths. Also order_inserts=true is required so inserts into the same table are grouped; without it, one INSERT into another table in the middle breaks the batch.

Bulk operations with JPQL

int updated = em.createQuery(
        "update Book b set b.price = b.price * 1.1 where b.publishedYear < :y")
    .setParameter("y", 2000).executeUpdate();
UPDATE book SET price = price * 1.1 WHERE published_year < $1;

-- bounded bulk delete (to avoid one gigantic transaction)
DELETE FROM book
WHERE  id IN (SELECT id FROM book WHERE archived ORDER BY id FETCH FIRST 10000 ROWS ONLY);
Bulk operations bypass the persistence context

A JPQL update/delete becomes SQL directly and updates none of your in-memory entities, nor does it invalidate the second-level cache (unless you use @Modifying(clearAutomatically = true, flushAutomatically = true)). After a bulk statement your managed objects are stale: run it in its own transaction, or call em.clear() right after. This is a JPA trap, not a database one — identical on both engines.

Upsert: ON CONFLICT vs MERGE

INSERT INTO stock (sku, qty) VALUES ($1, $2)
ON CONFLICT (sku) DO UPDATE SET qty = stock.qty + EXCLUDED.qty
RETURNING sku, qty;

-- PostgreSQL 15+ also has the standard MERGE (and MERGE ... RETURNING since 17)
There is no upsert at the JPA level

You have three options: (1) em.merge(), which issues a SELECT then an INSERT or UPDATE — simple but slow and not atomic under concurrency; (2) a native query with ON CONFLICT/MERGE, fast and atomic but not portable; (3) since Hibernate 6.5, @SQLInsert or the HQL insert ... on conflict. If you must run on both engines, choose option two but write two explicit implementations behind one interface — never one "clever" SQL that claims to work on both.


The second-level cache: shared across transactions

The second-level cache lives at the EntityManagerFactory level and is shared across all transactions and users.

A central warehouse vs a personal desk

The first-level cache was each editor's personal desk, wiped clean at day's end. The second-level cache is a shared central warehouse that persists after everyone leaves. It's fast, but its danger is staleness: if the data changes in the database outside Hibernate, the warehouse doesn't know.

It is off by default and needs a provider (EhCache, Infinispan, Hazelcast or any JCache):

@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Country {
    @Id private String code;
    private String name;
}
Strategy Means Good for
READ_ONLY Data never changes Static tables (countries, currencies)
NONSTRICT_READ_WRITE Occasional updates, no lock Low-conflict data where freshness matters little
READ_WRITE Soft synchronization with a soft lock Data that is both read and written
TRANSACTIONAL Full transactional cache (requires JTA) Enterprise environments with XA
Turn the second-level cache on carefully

It only pays off for data that is read a lot and written rarely and doesn't change outside Hibernate. Also remember the query cache is separate and must be enabled with hibernate.cache.use_query_cache=true. If you run bulk operations or someone writes to the database directly, this cache is exactly where stale data reaches your users.

The database has its own cache — and here too the paths differ
-- PostgreSQL has no query result cache; it only keeps blocks in shared_buffers
SHOW shared_buffers;

SELECT sum(heap_blks_hit) * 100 / nullif(sum(heap_blks_hit + heap_blks_read), 0) AS hit_pct
FROM   pg_statio_user_tables;

For small reference tables on Oracle, RESULT_CACHE often gives the same benefit as the second-level cache with no staleness risk, because the engine invalidates it when the table changes. PostgreSQL has nothing like it, so Hibernate's second-level cache fills a more real gap there.


DTO projections: when the full entity is too much

A receipt vs the whole account file

If you only want "the customer's name and total amount," fetching the entire Customer with its order history is like pulling someone's whole bank file to read one number. A DTO is a small, read-only receipt with exactly the items you need.

// 1) constructor expression in JPQL
record CustomerView(Long id, String name, BigDecimal total) {}

List<CustomerView> views = em.createQuery(
    "select new com.app.CustomerView(c.id, c.name, sum(o.amount)) " +
    "from Customer c join c.orders o group by c.id, c.name",
    CustomerView.class).getResultList();

// 2) interface-based projection in Spring Data
interface CustomerView { Long getId(); String getName(); }
List<CustomerView> findByActiveTrue();

// 3) Tuple or Object[] for dynamic cases

The moment a DTO uses aggregate functions you have to leave JPQL — and the engines visibly diverge:

SELECT c.id, c.name,
       SUM(o.amount)                              AS total,
       STRING_AGG(o.code, ', ' ORDER BY o.code)   AS codes,
       COALESCE(c.nickname, c.name)               AS display_name
FROM   customer c JOIN orders o ON o.customer_id = c.id
GROUP  BY c.id, c.name, c.nickname
ORDER  BY total DESC
FETCH  FIRST 20 ROWS ONLY;
`STRING_AGG` vs `LISTAGG` — and Oracle's hidden ceiling

Two differences: (1) the ordering syntax, inside the function on PostgreSQL and via WITHIN GROUP on Oracle; (2) LISTAGG returns a VARCHAR2, so once the concatenated length exceeds 4000 bytes you get ORA-01489: result of string concatenation is too long — on 12.2+ you can write LISTAGG(... ON OVERFLOW TRUNCATE). STRING_AGG has no such ceiling. This is exactly the kind of report that works in test and breaks in production. Note also that NVL is the two-argument form of COALESCE; COALESCE works on Oracle too and is the better choice for portable code.

Why a DTO is faster

DTOs are not managed entities: no dirty-checking snapshot, no persistence-context footprint, no LAZY-loading or N+1 risk. Rule of thumb: entities for writing, DTOs for reading.


The Open Session In View anti-pattern

OSIV means the persistence context is held open for the entire duration of a web request — even while the view is rendered or the JSON is serialized.

`spring.jpa.open-in-view` is `true` by default

Spring Boot leaves this setting on by default and, since version 2.0, prints a warning at startup: "spring.jpa.open-in-view is enabled by default." That warning is no accident.

A taxi that waits with the meter running

OSIV is like taking a taxi (a database connection) from the depot (the pool) and keeping the meter running from the start of your business logic all the way to the full page render. The connection stays occupied until the view finishes, so other requests queue longer and throughput drops.

The main problems: (1) the connection is held for a long time and the pool drains sooner; (2) transaction boundaries blur, and LAZY loads in the view layer run outside the transaction, creating invisible N+1s; (3) layer separation breaks, because the presentation layer silently issues queries.

On both engines a connection is a scarce resource — for different reasons

On PostgreSQL every connection is a separate operating-system process with its own memory, so max_connections is typically set around 100–200 and the standard answer for high connection counts is an external pooler like PgBouncer. On Oracle (dedicated server mode) each session is also a process, and the instance-level sessions/processes limits apply; exceeding them means ORA-00018: maximum number of sessions exceeded and the app cannot connect at all. Either way OSIV holds that scarce resource hostage for no reason. Don't inflate the pool either — 20 connections with short transactions beats 200 with long ones almost every time.

The right fix: turn OSIV off and hand over DTOs

Set spring.jpa.open-in-view=false and make sure every piece of data going to the presentation layer is fully loaded inside the service transaction. Transaction boundaries become clear, connections free up sooner, and if you touch a LAZY association outside the transaction you get an explicit LazyInitializationException instead of a silent N+1. That exception is your friend.


Observability: how to see what Hibernate actually sent

spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE
# for a real query count: datasource-proxy or p6spy

And on the database side, two completely different worlds:

-- the real plan with timings and I/O
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT a.id, a.name FROM author a JOIN book b ON b.author_id = a.id WHERE a.name LIKE 'A%';

-- the most expensive statements
SELECT calls, total_exec_time, mean_exec_time, rows, query
FROM   pg_stat_statements
ORDER  BY total_exec_time DESC
FETCH  FIRST 20 ROWS ONLY;
Hunting N+1 straight from the database

An N+1 has a very specific signature: a very high execution count with a very low average time, shaped like ... WHERE fk_column = ?. Find it by sorting pg_stat_statements on calls in PostgreSQL, or V$SQL on executions in Oracle — without reading a single line of Java. Say this in an interview; it shows you can look from the database side too.

And to see who is holding a connection and what they're waiting on:

SELECT pid, state, wait_event_type, wait_event, now() - query_start AS runtime, left(query, 80)
FROM   pg_stat_activity
WHERE  datname = current_database() AND state <> 'idle'
ORDER  BY runtime DESC;

-- blocking locks
SELECT pid, pg_blocking_pids(pid) AS blocked_by, left(query, 60)
FROM   pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;

Common pitfalls and best practices

  • Make every association LAZY, even @ManyToOne/@OneToOne; then fetch explicitly per query.
  • On Oracle (and usually PostgreSQL) use SEQUENCE, not IDENTITY — the only way to keep JDBC batching alive.
  • Align allocationSize with INCREMENT BY and review both together.
  • Index every FK column; on Oracle its absence causes table locks and deadlocks.
  • merge returns a value — always write x = em.merge(x).
  • Hunt N+1 in the SQL log, or in pg_stat_statements/V$SQL sorted by execution count.
  • Sync both sides of a bidirectional relationship with a helper, and put cascade/orphanRemoval only on genuine parent-child relationships.
  • Put @Version on concurrently editable entities; it's the only fully portable concurrency mechanism.
  • Replace @Lob String with @JdbcTypeCode(SqlTypes.LONGVARCHAR) to get text and clob respectively.
  • Normalise empty strings before they reach Oracle, where '' is NULL.
  • Set hibernate.jdbc.time_zone=UTC and don't rely on timestamptz to preserve a zone.
  • reWriteBatchedInserts=true in the PostgreSQL URL and implicitStatementCacheSize on Oracle.
  • Enable in_clause_parameter_padding to reduce Oracle hard parses.
  • Turn OSIV off and hand over DTOs for read-only screens.

Interview Questions

Answer each yourself first, then open the answer.

1) What is the difference between JPA and Hibernate, and where does the Dialect fit?

JPA is a specification — interfaces and annotations in jakarta.persistence that run no code. Hibernate is an implementation. The moment you use Hibernate-specific features (@BatchSize, @Cache) you tie yourself to it. The Dialect is the Hibernate layer that knows each engine's SQL differences: type names (bigint vs number(19,0)), pagination (LIMIT/OFFSET vs OFFSET ... FETCH FIRST), sequence access (nextval('s') vs s.NEXTVAL FROM dual), locking options (FOR UPDATE WAIT n only on Oracle) and functions (STRING_AGG vs LISTAGG). The JPA spec says nothing about any of it. Since Hibernate 6 the Dialect is auto-detected and should not be set by hand.

2) (Oracle vs PostgreSQL) Why must you use `SEQUENCE` rather than `IDENTITY` on Oracle?

Because IDENTITY makes Hibernate disable JDBC batching entirely: the id only exists after the INSERT runs, and Hibernate needs it to place the entity in the persistence context, so every row becomes its own round-trip. With SEQUENCE the ids are known before insertion, so hundreds of INSERTs go out in one batch, and allocationSize = 50 cuts NEXTVAL calls fiftyfold. On Oracle, where round-trip and parse costs are higher, this is an order-of-magnitude difference. IDENTITY kills batching on PostgreSQL too; RETURNING id just softens the blow.

3) (Oracle vs PostgreSQL) What is `allocationSize` and what happens if it doesn't match `INCREMENT BY`?

allocationSize says how many ids Hibernate reserves in memory per sequence read. If Java says 50 but the sequence is INCREMENT BY 1, Hibernate hands out a range it doesn't own and a second application instance produces duplicate keys: 23505 duplicate key on PostgreSQL, ORA-00001 on Oracle. In shared environments use the pooled-lo optimizer, which interoperates with other programs calling NEXTVAL directly (unlike the legacy hilo). And know that gaps in ids are perfectly normal.

4) Explain the four entity lifecycle states.

transient: built with new, no context knows it. managed: attached to a persistence context, changes tracked and flushed automatically. detached: was managed but the context closed; changes no longer tracked. removed: marked for deletion with remove(). Transitions: persist (transient→managed), remove (managed→removed), closing the EM (managed→detached), merge (detached→a managed copy).

5) (Trap) What does this code do?
Book b = new Book("X");
em.merge(b);
em.getTransaction().commit();
System.out.println(b.getId());

b.getId() is likely null! merge doesn't manage the object you pass in; it creates a managed copy and the id is set on that copy. The right way: Book managed = em.merge(b);. For a fresh entity persist would have been better anyway. Bonus: merge also issues a SELECT per entity, so it is expensive in large loops.

6) What is the persistence context and what does it guarantee? How does dirty checking work?

An in-memory map at the EntityManager level holding managed entities keyed by id; always on. Its guarantee is instance uniqueness: one object per id per context, so two finds return the exact same object and hit the database once. It lives until the transaction ends and is not shared across users. Dirty checking means Hibernate snapshots the field values when the entity becomes managed and compares at flush; differing fields get an UPDATE — no explicit save needed.

7) (Oracle) What does `@DynamicUpdate` do and why does it matter more on Oracle?

By default an UPDATE writes every column; @DynamicUpdate writes only the changed ones. On Oracle this is critical for tables with CLOB/BLOB columns, because needlessly rewriting a LOB generates megabytes of undo/redo — updating one title can turn into heavy I/O. On PostgreSQL large columns live in TOAST and unchanged ones keep their pointer, so the pressure is lower. The trade-off: @DynamicUpdate builds fresh SQL every time and reduces statement-cache effectiveness.

8) Explain N+1, name the fixes, and say why it is worse on Oracle.

N+1 means one main query for the parents plus N follow-up queries for each parent's LAZY association. Fixes: join fetch, @EntityGraph (pagination-compatible), @BatchSize (batching keys with IN), and DTO projections. It is worse on Oracle because each round-trip costs more (soft/hard parse in the shared pool, the listener layer, SQL*Net message from client waits), and because @BatchSize must respect Oracle's 1000-element IN limit (ORA-01795), which PostgreSQL does not have.

9) (Trap) Why does making an association EAGER usually not fix N+1?

Because when you read a list of parents with a query (not find), Hibernate may fire a separate SELECT for each EAGER parent's association — the same N+1, only now permanent and uncontrollable. EAGER pushes the fetch decision to the mapping level, whereas fetching should be a per-query decision.

10) (Oracle vs PostgreSQL) How does pagination translate, and why is a large `OFFSET` bad?

On PostgreSQL Hibernate emits LIMIT n OFFSET m (the standard OFFSET ... FETCH FIRST is also accepted). On Oracle 12c+ it emits the standard OFFSET m ROWS FETCH FIRST n ROWS ONLY; on 11g you need two nested ROWNUM layers because ROWNUM is assigned before ORDER BY. On both, a large OFFSET is slow because every preceding row is produced and discarded; the fix is keyset pagination with (created_at, id) < (?, ?) — written with doubled parentheses on Oracle.

11) (Oracle vs PostgreSQL) What is the empty-string trap on Oracle?

On Oracle '' is silently converted to NULL; on PostgreSQL it is a real value distinct from NULL. Consequences: a NOT NULL column accepts an empty form field on PostgreSQL but raises ORA-01400 on Oracle; you write '' and read back null (hello NullPointerException); and even concatenation differs, since 'a' || NULL is 'a' on Oracle but NULL on PostgreSQL. Fix: always normalise empty strings to null in the application layer and never write = '' in JPQL.

12) (Oracle vs PostgreSQL) What does `@Lob String` do on each engine, and what is the modern recommendation?

On Oracle it maps to CLOB, which is exactly right. On PostgreSQL Hibernate uses the Large Object API and creates an oid column; data goes to pg_largeobject, reading outside a transaction fails with "Large Objects may not be used in auto-commit mode," and orphaned objects need vacuumlo. The modern recommendation: replace @Lob with @JdbcTypeCode(SqlTypes.LONGVARCHAR), which yields text on PostgreSQL and clob on Oracle. Keep @Lob only for genuine streaming.

13) (Oracle vs PostgreSQL) How do you map `boolean`, dates and times?

PostgreSQL has a native boolean. Oracle has no SQL boolean up to 19c, so Hibernate maps it to NUMBER(1,0) with 1/0 (a CHECK (col IN (0,1)) is good practice); 23ai finally adds a real BOOLEAN. For legacy 'Y'/'N' schemas, write an AttributeConverter. On time: Oracle's DATE carries a time component (unlike PostgreSQL), so direct comparison with a date fails without TRUNC; and PostgreSQL's timestamptz does not store the zone (it normalises to UTC) whereas Oracle's TIMESTAMP WITH TIME ZONE really keeps the offset. Safe route: hibernate.jdbc.time_zone=UTC plus timezone.default_storage=NORMALIZE_UTC.

14) What is the owning side, and why is the FK index critical on Oracle?

In the database the relationship is one foreign-key column; the side holding it (@ManyToOne) is the owner, and Hibernate only looks at that side's changes to write the FK. The @OneToMany side declares mappedBy; forgetting it creates an extra join table or redundant UPDATEs. On the index: on Oracle, if the child's FK column is unindexed, every parent DELETE or key update takes a share row exclusive lock on the whole child table, which becomes a wave of ORA-00060. PostgreSQL takes no table lock but degrades to a full scan of the child. Neither engine creates the index for you.

15) What is the difference between `CascadeType.REMOVE` and `orphanRemoval`? Where does `ON DELETE CASCADE` fit?

REMOVE fires only when the parent itself is deleted. orphanRemoval = true is stronger: removing a child from the parent's collection deletes that orphan from the database even if the parent survives. Both run in Java — Hibernate reads the children and issues one DELETE per child. Database ON DELETE CASCADE does the same in a single statement and exists on both engines, but Hibernate is unaware of it and the caches go stale; declare @OnDelete(action = OnDeleteAction.CASCADE) or call em.clear() afterwards.

16) Compare optimistic and pessimistic locking.

Optimistic uses a @Version field: WHERE version = ? is added to the UPDATE, and if the version moved you get an OptimisticLockException and must retry; it holds no database lock and is fully portable because it needs only standard SQL. Pessimistic locks the row from the moment of reading with SELECT ... FOR UPDATE. In most web apps optimistic is the better default; pessimistic suits contended hotspots only.

17) (Oracle vs PostgreSQL) What does `jakarta.persistence.lock.timeout` do on each engine?

0 maps to FOR UPDATE NOWAIT on both (55P03 on PostgreSQL, ORA-00054 on Oracle) and -2 maps to FOR UPDATE SKIP LOCKED, supported by both. But a positive value only means something on Oracle, where it becomes FOR UPDATE WAIT n; PostgreSQL has no such syntax and ignores the hint, so the transaction waits forever. For a bounded wait on PostgreSQL you must issue SET LOCAL lock_timeout = '3s' before the query.

18) (Oracle vs PostgreSQL) How do isolation levels differ?

PostgreSQL accepts all four standard levels (though READ UNCOMMITTED behaves like READ COMMITTED), gives a true transaction-level snapshot at REPEATABLE READ, and since 9.1 offers real Serializable Snapshot Isolation reporting conflicts as 40001. Oracle has only READ COMMITTED (statement-level snapshot) and SERIALIZABLE (really snapshot isolation, raising ORA-08177); REPEATABLE READ does not exist, so @Transactional(isolation = REPEATABLE_READ) fails on Oracle with InvalidIsolationLevelException. On both, serialization errors are transient and the code must retry.

19) (Oracle vs PostgreSQL) How do you configure a 500,000-row insert?

Common: hibernate.jdbc.batch_size=50, order_inserts=true, order_updates=true, SEQUENCE with a large allocationSize, and flush()+clear() per batch. PostgreSQL-specific: reWriteBatchedInserts=true in the JDBC URL so the driver folds the batch into one INSERT ... VALUES (...),(...) (usually 2–3× faster) — and note the rewrite is skipped with RETURNING/ON CONFLICT and with IDENTITY. Oracle-specific: the driver already does real array binding, so instead raise oracle.jdbc.implicitStatementCacheSize to avoid hard parses.

20) (Oracle vs PostgreSQL) How do you write an upsert?

JPA has no standard upsert. PostgreSQL has native INSERT ... ON CONFLICT (col) DO UPDATE SET ... RETURNING (and standard MERGE since 15). Oracle has no ON CONFLICT; its standard form is MERGE INTO ... USING (SELECT ... FROM dual) ON (...) WHEN MATCHED THEN UPDATE ... WHEN NOT MATCHED THEN INSERT ..., and RETURNING exists only inside PL/SQL as RETURNING ... INTO. em.merge() is not a substitute: it issues a SELECT and is not atomic under concurrency.

21) When does `LazyInitializationException` occur, and how is OSIV related?

When you touch a LAZY association while the entity is no longer managed — e.g. in the view layer after the transaction ended. The wrong fix: enabling OSIV or making everything EAGER. OSIV keeps the persistence context open for the whole request; Spring Boot defaults it to true but warns, because the connection is held until rendering finishes, transaction boundaries blur, and invisible N+1s appear. Connections are scarce on both engines (max_connections on PostgreSQL; sessions/processes limits and ORA-00018 on Oracle). The right fix: turn OSIV off and fully prepare the data inside the transaction with join fetch/@EntityGraph or a DTO.

22) What is the second-level cache and how does it differ from the first?

The first-level cache is per EntityManager/transaction, always on, not shared across users. The second-level cache is at the EntityManagerFactory level, off by default, needs a provider, and is shared across all transactions. It only pays off for data that is read a lot, written rarely and never changed outside Hibernate. Side note: on Oracle, RESULT_CACHE on the database side gives small reference tables the same benefit with no staleness risk, because the engine invalidates it on table change; PostgreSQL has no equivalent and only keeps blocks in shared_buffers.

23) (Senior) Why prefer DTO projections for read-only screens, and where does portability end?

A managed entity carries three costs that are useless for reads: a dirty-checking snapshot, persistence-context memory, and the N+1 / LazyInitializationException risk of its LAZY associations. A DTO has none. But portability ends the moment the DTO reaches aggregate functions: STRING_AGG(x, ',' ORDER BY y) on PostgreSQL vs LISTAGG(x, ',') WITHIN GROUP (ORDER BY y) on Oracle — which returns VARCHAR2 and raises ORA-01489 above 4000 bytes unless you add ON OVERFLOW TRUNCATE.

24) (Senior) Without reading Java code, how do you find an N+1 in the database?

Look for a statement with a very high execution count and a very low average time, shaped like ... WHERE fk_column = ?. On PostgreSQL: SELECT calls, mean_exec_time, query FROM pg_stat_statements ORDER BY calls DESC. On Oracle: SELECT executions, elapsed_time/executions, sql_text FROM v$sql ORDER BY executions DESC. For the plan, use EXPLAIN (ANALYZE, BUFFERS) on PostgreSQL and /*+ GATHER_PLAN_STATISTICS */ with DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST') on Oracle.


In a nutshell
  • JPA is a specification and Hibernate its implementation (ORM 7 on Jakarta Persistence 3.2 and Java 17). The Dialect is the layer that turns one piece of code into PostgreSQL or Oracle SQL, and since Hibernate 6 it is auto-detected.
  • Every entity is in one of four states; merge returns a managed copy, not the object itself. The persistence context guarantees instance uniqueness and dirty checking finds changes with no explicit save.
  • Identifier generation is the biggest practical difference between the engines: IDENTITY shuts the door on batching; SEQUENCE with allocationSize aligned to INCREMENT BY is the right answer, especially on Oracle.
  • The biggest performance killer is N+1; the cures are join fetch, @EntityGraph, @BatchSize (minding Oracle's 1000-element IN limit) and DTO projections. Pagination is LIMIT/OFFSET on PostgreSQL and OFFSET ... FETCH FIRST on Oracle (nested ROWNUM on 11g); for deep pages switch to keyset.
  • In type mapping: replace @Lob String with @JdbcTypeCode(SqlTypes.LONGVARCHAR), remember boolean is NUMBER(1) on Oracle (until 23ai), that an empty string is NULL there, and that timestamptz does not store a zone while TIMESTAMP WITH TIME ZONE does — keep everything in UTC.
  • Optimistic locking (@Version) is the only fully portable concurrency mechanism; NOWAIT and SKIP LOCKED are shared, but WAIT n is Oracle-only and PostgreSQL needs lock_timeout. Oracle has no REPEATABLE READ.
  • For bulk writes, set reWriteBatchedInserts=true on PostgreSQL and tune the statement cache on Oracle; and index every FK column, whose absence causes table locks and deadlocks on Oracle.
  • Turn OSIV off, load data fully inside the transaction and hand over DTOs for reads.