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 میبینی؛ هر دو را بخوان — سنیور بودن یعنی بدانی همان کد روی موتور دیگر چه شکلی میشود.
- JPA در برابر Hibernate و Dialect — لایهای که همان کد را به SQLِ هر موتور ترجمه میکند.
- چرخهی حیات موجودیت — چهار حالت transient / managed / detached / removed.
- Persistence Context — «کش سطحاول»، قلبِ تپندهی Hibernate — و dirty checking و flush.
- تولید شناسه — IDENTITY در برابر SEQUENCE، allocationSize و optimizerها؛ بزرگترین تفاوت عملی دو موتور.
- fetch type و N+1 — بزرگترین قاتل کارایی و چهار راهِ درمانش.
- صفحهبندی —
LIMIT/OFFSETدر برابرFETCH FIRSTوROWNUM، و keyset pagination. - نگاشت رابطهها — سمت مالک،
mappedBy، و ایندکسِ FK که در Oracle اجباری است. - نگاشت نوعها — CLOB/TEXT، boolean/NUMBER(1)، رشتهی خالی، DATE و timestamptz و JSON.
- قفلگذاری —
@Version،NOWAIT/SKIP LOCKEDو سطوح ایزولاسیون. - 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 مثل این است که همان خودرو در کشورهای مختلف تابلوهای متفاوت میخواند: در یکی «LIMIT» نوشتهاند و در دیگری «FETCH FIRST»؛ در یکی boolean هست و در دیگری باید با NUMBER(1) تظاهر کنی. راننده (کد تو) عوض نمیشود؛ ترجمه را Dialect انجام میدهد.
با 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;CREATE TABLE book (
id number(19,0) NOT NULL,
title varchar2(255 char) NOT NULL,
published number(1,0),
price number(38,2),
created_at timestamp(6),
PRIMARY KEY (id)
);
CREATE SEQUENCE book_seq START WITH 1 INCREMENT BY 50;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 سرِ بالا آمدن اپ داد میزند.
در 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 این تله وجود ندارد.
از 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;SELECT book_seq.NEXTVAL FROM dual; -- گرفتن شناسه پیش از 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;PostgreSQL پارامترها را $1, $2, ... میفرستد و در pg_stat_statements همانطور میبینیشان؛ Oracle از :1, :2, ... استفاده میکند و در V$SQL همان شکل را میبینی. وقتی میخواهی یک کوئریِ کند را از لاگ اپلیکیشن به لاگ دیتابیس وصل کنی، شناختن این دو شکل کار را راحت میکند.
em.merge(book) یک کپیِ managed میسازد و برمیگرداند؛ خودِ book همچنان detached میماند. پس همیشه با مقدار بازگشتی کار کن: book = em.merge(book);. اگر تغییرات بعدی را روی book قدیمی بزنی، هیچ اتفاقی نمیافتد.
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;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 تراکنش | بهینهسازی؛ وقتی مطمئنی کوئریها به تغییرات معلق وابسته نیستند |
اگر 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;-- بدون @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;اگر جدولی ستون 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;-- Oracle 12c به بعد ستون identity واقعی دارد
CREATE TABLE book (
id number(19,0) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title varchar2(255 char) NOT NULL
);
-- Oracle معادل RETURNING مستقل ندارد؛ درایور از getGeneratedKeys استفاده میکند
-- (در PL/SQL: INSERT INTO book (title) VALUES (:1) RETURNING id INTO :2;)
INSERT INTO book (title) VALUES (:1);با 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();CREATE SEQUENCE book_seq
START WITH 1
INCREMENT BY 50
CACHE 100 -- مقادیرِ پیشگرفته در حافظهی instance
NOORDER; -- روی RAC حیاتی است
SELECT book_seq.NEXTVAL FROM dual;
SELECT sequence_name, increment_by, cache_size, last_number
FROM user_sequences;۱. 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
خطرناکترین ناسازگاریِ خاموش در کل 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 آن را کفِ بازه. هر دو با برنامههای دیگری که مستقیم NEXTVAL میزنند سازگارند — برخلاف hilo قدیمی که با آنها تصادم میکند (و در شِماهای سازمانیِ Oracle که همیشه یک اسکریپت PL/SQL هم مینویسد، دردسر میسازد).
spring.jpa.properties.hibernate.id.optimizer.pooled.preferred=pooled-lo
با allocationSize = 50، ریاستارت اپ باقیِ بازه را دور میریزد؛ در Oracle با CACHE 100 هم بعد از restart صد شماره میپرد. اگر کسی «شمارهی فاکتور بدون حفره» خواست، آن یک نیازِ کسبوکار است نه primary key — با جدول شمارنده و قفل صریح حلش کن.
پیش از 12c، Oracle ستون identity نداشت و الگوی مرسوم ترکیبِ sequence و trigger بود:
-- در PostgreSQL هرگز نیازی به این کار نبوده
CREATE TABLE book (id bigserial PRIMARY KEY, title varchar(255) NOT NULL);
-- bigserial پشت صحنه: DEFAULT nextval('book_id_seq')CREATE SEQUENCE book_seq START WITH 1 INCREMENT BY 1 NOCACHE;
CREATE OR REPLACE TRIGGER book_bi
BEFORE INSERT ON book FOR EACH ROW
WHEN (new.id IS NULL)
BEGIN
:new.id := book_seq.NEXTVAL;
END;
/با چنین شِمایی، در جاوا GenerationType.SEQUENCE با همان sequenceName و allocationSize = 1 بنویس و trigger را (اگر میتوانی) بردار؛ triggerها هم کندند و هم شناسه را از دید Hibernate پنهان میکنند.
fetch type و مشکل افسانهای N+1
هر رابطه (@OneToMany, @ManyToOne, ...) یک راهبرد واکشی (fetch type) دارد:
EAGER: وقتی موجودیت اصلی را میخوانی، رابطهاش هم فوراً با آن بارگذاری میشود.LAZY: رابطه بارگذاری نمیشود تا لحظهای که واقعاً به آن دست بزنی. Hibernate بهجای داده یک proxy میگذارد و در اولین دسترسی کوئری میزند.
طبق مشخصات 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;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 اغلب بدترش میکند: وقتی لیستی از والدها را با کوئری میخوانی، Hibernate ممکن است برای هرکدام یک SELECT جدا بزند — دقیقاً همان N+1، اینبار بیآنکه بتوانی کنترلش کنی. قانون طلایی: همهی رابطهها LAZY، و در هر کوئری صراحتاً بگو چه میخواهی.
هزینهی هر 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;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[]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 /* ... تا ۲۵ */);
-- Oracle معادل ANY(array) ندارد؛ الگوی بومیاش join با یک collection type است:
-- SELECT b.author_id, b.id FROM book b JOIN TABLE(:ids) t ON b.author_id = t.column_value;در Oracle تا 21c یک عبارت IN حداکثر ۱۰۰۰ آیتم میپذیرد و بیشتر از آن ORA-01795: maximum number of expressions in a list is 1000 میدهد (در 23ai این سقف بسیار بالا رفته). PostgreSQL سقف سختی ندارد ولی لیستهای چندهزارتایی آنجا هم plan را خراب میکنند. @BatchSize را بالای ۱۰۰ نگذار؛ عدد سالم ۱۰ تا ۵۰ است. و اگر خودت where id in :ids مینویسی و لیست از کاربر میآید، حتماً تکهتکهاش کن.
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;-- Oracle 12c به بعد: نحو استاندارد SQL:2008
SELECT b1_0.id, b1_0.title, b1_0.price
FROM book b1_0
ORDER BY b1_0.id
OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY;
-- Oracle 11g و قدیمیتر: دو لایه ROWNUM تودرتو
-- SELECT * FROM (
-- SELECT inner_.*, ROWNUM rn FROM (
-- SELECT id, title, price FROM book ORDER BY id
-- ) inner_ WHERE ROWNUM <= 30
-- ) WHERE rn > 20;چون ROWNUM قبل از ORDER BY تخصیص داده میشود. SELECT ... WHERE ROWNUM <= 10 ORDER BY id اول ده ردیفِ تصادفی میگیرد و بعد مرتب میکند — نه ده ردیفِ اول. الگوی درست تودرتو کردن است. Hibernate اگر Dialect را روی 12c یا بالاتر بشناسد این آشفتگی را ندارد — دلیلی دیگر برای اینکه Dialect را روی نسخهی قدیمی قفل نکنی.
چه 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;SELECT b.id, b.title, b.created_at
FROM book b
WHERE (b.created_at, b.id) < ((:1, :2)) -- توجه: Oracle پرانتزِ دوگانه میخواهد
ORDER BY b.created_at DESC, b.id DESC
FETCH FIRST 20 ROWS ONLY;اگر 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);-- توجه: order یک کلمهی کلیدی است؛ در هر دو موتور جدول را orders بنام
CREATE TABLE orders (
id number(19,0) PRIMARY KEY,
customer_id number(19,0),
CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customer (id)
);
CREATE INDEX ix_orders_customer ON orders (customer_id);هیچکدام از دو موتور خودکار روی 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 را ننویسی، Hibernate فکر میکند دو رابطهی مستقل داری و بهجای یک ستون FK یک جدول واسط میسازد یا UPDATEهای اضافی میزند. علامتش: در لاگ بعد از INSERT یک UPDATE عجیب برای ستکردن FK میبینی.
معمولاً Set امنتر است. با دو @OneToMany از نوع List که هر دو EAGER (یا در یک join fetch) باشند، MultipleBagFetchException میگیری چون Hibernate نمیتواند دو «کیسهی» بیترتیب را همزمان join کند. اگر ترتیب مهم است، @OrderColumn یا دو کوئری جدا.
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;-- آنچه 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;
-- جایگزینِ سطحدیتابیس: Oracle هم ON DELETE CASCADE دارد
ALTER TABLE order_line ADD CONSTRAINT fk_line_order
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE;هر دو موتور 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() بزن.
REMOVE وقتی فعال میشود که خودِ والد حذف شود. orphanRemoval = true قویتر است: اگر فقط یک فرزند را از collection والد بیرون بیندازی (order.getLines().remove(line))، آن فرزندِ «یتیم» هم از دیتابیس حذف میشود، حتی اگر والد بماند. برای رابطههای صرفاً «اشتراکی» بهکارش نبر، وگرنه دادهی مشترک را ناخواسته حذف میکنی.
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);-- در Oracle، CLOB دقیقاً همان چیزی است که میخواهی
CREATE TABLE article (id number(19,0) PRIMARY KEY, content clob);
-- سقف VARCHAR2 پیشفرض ۴۰۰۰ بایت است؛ برای متن بلندتر CLOB اجباری است
-- (با MAX_STRING_SIZE = EXTENDED تا ۳۲۷۶۷، ولی پشت صحنه باز LOB است)روی 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 19c: boolean در SQL وجود ندارد؛ NUMBER(1) قرارداد رایج است
CREATE TABLE account (
id number(19,0) PRIMARY KEY,
active number(1,0) DEFAULT 0 NOT NULL,
CONSTRAINT ck_account_active CHECK (active IN (0, 1))
);
INSERT INTO account (id, active) VALUES (1, 1);
SELECT id FROM account WHERE active = 1; -- مقایسهی صریح لازم است
-- Oracle 23ai بالاخره نوع BOOLEAN واقعی دارد:
-- active boolean DEFAULT false NOT NULLبرای این کار 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-- در Oracle، رشتهی خالی همان NULL است — بیسروصدا تبدیل میشود
INSERT INTO customer (id, middle_name) VALUES (1, '');
SELECT count(*) FROM customer WHERE middle_name = ''; -- 0 (مقایسه UNKNOWN است)
SELECT count(*) FROM customer WHERE middle_name IS NULL; -- 1
SELECT length(middle_name) FROM customer WHERE id = 1; -- NULL، نه 0۱. 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;CREATE TABLE event (
id number(19,0) PRIMARY KEY,
day date, -- توجه: DATE در Oracle ساعت هم دارد!
happened timestamp(6), -- بدون منطقهی زمانی
happenedz timestamp(6) with time zone -- خودِ offset/منطقه ذخیره میشود
);
ALTER SESSION SET TIME_ZONE = 'UTC';
SELECT systimestamp, TRUNC(SYSDATE) FROM dual;۱. 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 ندارد؛ در نگاشتهای قابلحمل از آن دوری کن.
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;-- Oracle 21c به بعد نوع JSON بومی دارد
CREATE TABLE doc (id number(19,0) PRIMARY KEY, payload json NOT NULL);
CREATE SEARCH INDEX ix_doc_payload ON doc (payload) FOR JSON;
SELECT id FROM doc WHERE JSON_EXISTS(payload, '$?(@.status == "ACTIVE")');
SELECT JSON_VALUE(payload, '$.status') AS status FROM doc;
-- Oracle 19c: CLOB با محدودیت IS JSON
-- payload clob CONSTRAINT ck_doc_json CHECK (payload IS JSON)کافی است بنویسی @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 NULLUPDATE account
SET balance = :1, version = :2
WHERE id = :3 AND version = :4;
-- نوعِ ستون: version number(19,0) 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; -- قفل اشتراکی-- قفل انحصاری، منتظر میماند
SELECT a.id, a.balance, a.version FROM account a WHERE a.id = :1 FOR UPDATE;
-- بدون انتظار: خطای ORA-00054 (resource busy)
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;
-- انتظار محدود: Oracle این را بهصورت بومی در خودِ دستور دارد
SELECT a.id FROM account a WHERE a.id = :1 FOR UPDATE WAIT 3;
LOCK TABLE account IN SHARE MODE; -- Oracle قفل اشتراکیِ سطر ندارد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 بالا | تعارضِ زیاد روی همان ردیف |
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;-- Oracle فقط دو سطح دارد
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- پیشفرض؛ snapshot در سطح دستور
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- عملاً snapshot isolation؛ ORA-08177
-- REPEATABLE READ و READ UNCOMMITTED اصلاً وجود ندارند
SELECT sid, username FROM v$session WHERE sid = SYS_CONTEXT('USERENV','SID');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-- درایور Oracle همان یک دستور را با آرایهی bind اجرا میکند
INSERT INTO book (title, price, id) VALUES (:1, :2, :3);
-- یک بار parse، ۵۰ مجموعه مقدار در یک round-trip
-- معادلی برای reWriteBatchedInserts لازم نیست؛ batching استاندارد JDBC کافی است
-- jdbc:oracle:thin:@//db:1521/ORCLPDB1
-- oracle.jdbc.implicitStatementCacheSize=50درایورِ PostgreSQL بهطور پیشفرض دستورهای batch را یکییکی میفرستد (فقط در یک بستهی شبکه)، پس سرور همچنان ۵۰ بار اجرا میکند. reWriteBatchedInserts=true آنها را به یک INSERT چندردیفی تبدیل میکند و معمولاً ۲ تا ۳ برابر سریعتر میشود — و فقط در JDBC URL قابل فعالسازی است، نه در Hibernate. در مقابل، درایور Oracle از ابتدا array binding واقعی دارد؛ آنجا اهرمِ اصلی، statement cache است تا hard parse نخوری. یعنی برای رسیدن به سرعت یکسان، در هر موتور یک اهرمِ متفاوت را میکشی.
اگر 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);UPDATE book SET price = price * 1.1 WHERE published_year < :1;
-- حذف انبوه با محدودیت تعداد (برای جلوگیری از تراکنش غولپیکر)
DELETE FROM book
WHERE id IN (SELECT id FROM book WHERE archived = 1 ORDER BY id FETCH FIRST 10000 ROWS ONLY);یک 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)-- Oracle معادل ON CONFLICT ندارد؛ MERGE نحو استانداردِ اوست
MERGE INTO stock t
USING (SELECT :1 AS sku, :2 AS qty FROM dual) s
ON (t.sku = s.sku)
WHEN MATCHED THEN UPDATE SET t.qty = t.qty + s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty);
-- Oracle در SQLِ خالص RETURNING ندارد؛ فقط داخل PL/SQL با «RETURNING ... INTO»سه گزینه داری: (۱) 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 علاوه بر buffer cache، «کش نتیجهی کوئری» واقعی هم دارد
SELECT /*+ RESULT_CACHE */ code, name FROM country;
ALTER TABLE country RESULT_CACHE (MODE FORCE);
SELECT name, value FROM v$parameter WHERE name LIKE 'result_cache%';برای جدولهای مرجعِ کوچک در 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;SELECT c.id, c.name,
SUM(o.amount) AS total,
LISTAGG(o.code, ', ') WITHIN GROUP (ORDER BY o.code) AS codes,
NVL(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;دو تفاوت: (۱) نحو مرتبسازی، که در 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ها موجودیتِ managed نیستند: نه snapshot برای dirty checking، نه اشغال persistence context، نه ریسکِ LAZY load و N+1. قانون سرانگشتی: موجودیت برای نوشتن، DTO برای خواندن.
ضدالگوی Open Session In View
OSIV یعنی persistence context در کل طول یک درخواست وب باز نگه داشته میشود — حتی هنگام رندر view یا سریالسازی JSON.
Spring Boot این تنظیم را پیشفرض روشن میگذارد و از نسخهی ۲.۰ هنگام بالا آمدن اپ هشدار میدهد: «spring.jpa.open-in-view is enabled by default». این هشدار الکی نیست.
OSIV مثل این است که تاکسی (connection) را از پارکینگ (pool) بگیری و تاکسیمتر را از شروع منطق کسبوکار تا رندرِ کامل صفحه روشن نگه داری. connection تا آخرِ رندر اشغال میماند، پس بقیه بیشتر در صف میمانند و throughput میافتد.
مشکلات اصلی: (۱) connection طولانیمدت اشغال میشود و pool زودتر خالی؛ (۲) مرز تراکنش مبهم میشود و LAZYها در لایهی view خارج از تراکنش N+1های نامرئی میسازند؛ (۳) جداییِ لایهها میشکند، چون لایهی نمایش پنهانی کوئری میزند.
در PostgreSQL هر connection یک پروسهی سیستمعامل جداست، پس max_connections معمولاً حدود ۱۰۰ تا ۲۰۰ تنظیم میشود و راهحل استاندارد یک pooler بیرونی مثل PgBouncer است. در Oracle (حالت dedicated server) هم هر session یک پروسه دارد و سقفهای sessions/processes در سطح instance اعمال میشود؛ رد شدن از آنها یعنی ORA-00018: maximum number of sessions exceeded و اپ اصلاً وصل نمیشود. نتیجه یکی است: OSIV این منبع را بیدلیل گروگان میگیرد. pool را هم بیدلیل بزرگ نکن — یک pool ۲۰تایی با تراکنشهای کوتاه تقریباً همیشه بهتر از ۲۰۰تایی با تراکنشهای طولانی است.
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;-- پلنِ تخمینی
EXPLAIN PLAN FOR
SELECT a.id, a.name FROM author a JOIN book b ON b.author_id = a.id WHERE a.name LIKE 'A%';
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
-- پلنِ واقعی با آمار اجرا (معادل نزدیکِ EXPLAIN ANALYZE)
SELECT /*+ GATHER_PLAN_STATISTICS */ a.id, a.name
FROM author a JOIN book b ON b.author_id = a.id WHERE a.name LIKE 'A%';
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'));
-- پرهزینهترین دستورها
SELECT executions, elapsed_time, elapsed_time/executions AS avg_us, sql_text
FROM v$sql ORDER BY elapsed_time DESC FETCH FIRST 20 ROWS ONLY;یک 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;SELECT sid, status, event, seconds_in_wait, sql_id
FROM v$session
WHERE username IS NOT NULL AND status = 'ACTIVE'
ORDER BY seconds_in_wait DESC;
-- قفلهای مسدودکننده
SELECT s.sid, s.blocking_session, s.event, s.seconds_in_wait
FROM v$session s WHERE s.blocking_session IS NOT NULL;دامهای رایج و بهترین شیوهها
- همهی رابطهها را
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 یک مشخصات است — رابطها و 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 خودکار تشخیص داده میشود و بهتر است دستی ست نشود.
چون 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 ضربه را کمی نرمتر میکند.
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 هم میزند، پس در حلقههای بزرگ گران است.
نقشهای درونحافظهای در سطح EntityManager که موجودیتهای managed را با کلید id نگه میدارد و همیشه روشن است. تضمینش یکتاییِ نمونه است: در یک context برای هر id فقط یک شیء وجود دارد. عمرش تا پایان تراکنش است و بین کاربران مشترک نیست. dirty checking یعنی Hibernate هنگام managed شدن یک snapshot میگیرد و در flush شیء را با آن مقایسه میکند؛ فیلدهای متفاوت UPDATE میگیرند — بدون هیچ save صریح.
بهطور پیشفرض UPDATE همهی ستونها را مینویسد؛ @DynamicUpdate فقط ستونهای تغییرکرده را. در Oracle روی جدولهای CLOB/BLOB حیاتی است، چون بازنویسیِ بیدلیلِ یک LOB مگابایتها undo/redo میسازد و بهروزرسانیِ یک title میتواند به I/O سنگین تبدیل شود. در PostgreSQL ستونهای بزرگ در TOAST مینشینند و اگر عوض نشوند اشارهگرشان بازاستفاده میشود، پس فشار کمتر است. در عوض @DynamicUpdate هر بار SQL تازه میسازد و کارایی statement cache را کم میکند.
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 وجود ندارد.
چون وقتی لیستی از والدها را با یک کوئری (نه find) میخوانی، Hibernate برای هر والدِ EAGER ممکن است یک SELECT جدا بزند — همان N+1، اینبار همیشگی و غیرقابلکنترل. EAGER تصمیمِ fetch را به سطحِ نگاشت میبرد، در حالیکه fetch باید تصمیمِ هر کوئری باشد.
در 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 '' بیسروصدا به NULL تبدیل میشود؛ در PostgreSQL یک مقدار واقعی و متمایز است. پیامدها: ستونِ NOT NULL روی Oracle با ورودیِ خالی ORA-01400 میدهد ولی روی PostgreSQL ذخیره میشود؛ چیزی که نوشتی '' بود ولی null میخوانی (خطر NullPointerException)؛ و حتی الحاق فرق میکند، چون در Oracle 'a' || NULL برابر 'a' است و در PostgreSQL NULL. راهحل: رشتهی خالی را در لایهی اپلیکیشن همیشه به null نرمال کن و در JPQL هرگز = '' ننویس.
روی 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 واقعی نگه دار.
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 نمایش داده میشود؛ سمتی که آن ستون را دارد (@ManyToOne) مالک است و Hibernate فقط تغییرات همان سمت را برای نوشتن FK میبیند. سمت @OneToMany با mappedBy میگوید «من نمای معکوسم»؛ فراموشکردن mappedBy یک join table اضافه یا UPDATEهای زائد میسازد. دربارهی ایندکس: در Oracle اگر ستون FKِ فرزند ایندکس نداشته باشد، هر DELETE یا تغییر کلیدِ والد یک قفلِ share row exclusive روی کل جدول فرزند میگیرد و به موجِ ORA-00060 تبدیل میشود؛ در PostgreSQL قفل جدول نیست ولی بررسی FK به اسکن کامل بدل میشود. هیچکدام خودکار ساخته نمیشوند.
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 ردیف را از لحظهی خواندن قفل میکند. در اکثر اپهای وب خوشبینانه پیشفرض بهتری است؛ بدبینانه فقط برای نقاط داغِ رقابتی.
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' بزنی.
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 داشته باشد.
مشترک: 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 نخوری.
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 میزند و در برابر رقابت همزمان اتمیک نیست.
وقتی به یک رابطهی 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 نگه میدارد.
موجودیتِ 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.
دنبال دستوری بگرد که تعداد اجرای بسیار بالا و زمان میانگین بسیار پایین دارد و شکلش ... 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. صفحهبندی در PostgreSQLLIMIT/OFFSETو در OracleOFFSET ... 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بگذاری. OracleREPEATABLE 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.
- JPA vs Hibernate and the Dialect — the layer that translates one piece of code into each engine's SQL.
- The entity lifecycle — transient / managed / detached / removed.
- The persistence context — the "first-level cache" — plus dirty checking and flush.
- Identifier generation — IDENTITY vs SEQUENCE, allocationSize and optimizers; the biggest practical difference between the two engines.
- Fetch types and N+1 — the biggest performance killer and its four cures.
- Pagination —
LIMIT/OFFSETvsFETCH FIRSTandROWNUM, and keyset pagination. - Mapping relationships — the owning side,
mappedBy, and the FK index that is mandatory on Oracle. - Type mapping — CLOB/TEXT, boolean/NUMBER(1), the empty-string trap, DATE, timestamptz and JSON.
- Locking —
@Version,NOWAIT/SKIP LOCKEDand isolation levels. - 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 fromjavax.persistencetojakarta.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 UPDATEoptions exist.org.hibernate.dialect.PostgreSQLDialectandorg.hibernate.dialect.OracleDialect. - JDBC driver: the layer that actually puts bytes on the wire —
org.postgresql:postgresqlandcom.oracle.database.jdbc:ojdbc11. Batching behaviour depends as much on the driver as on Hibernate.
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.
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;CREATE TABLE book (
id number(19,0) NOT NULL,
title varchar2(255 char) NOT NULL,
published number(1,0),
price number(38,2),
created_at timestamp(6),
PRIMARY KEY (id)
);
CREATE SEQUENCE book_seq START WITH 1 INCREMENT BY 50;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.
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.
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.
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; noEntityManagerknows 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(); aDELETEruns 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;SELECT book_seq.NEXTVAL FROM dual; -- 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;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.
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.
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.
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;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;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.
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.
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 |
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;-- 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;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.
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;-- Oracle 12c and later has real identity columns
CREATE TABLE book (
id number(19,0) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title varchar2(255 char) NOT NULL
);
-- Oracle has no standalone RETURNING; the driver uses getGeneratedKeys
-- (in PL/SQL: INSERT INTO book (title) VALUES (:1) RETURNING id INTO :2;)
INSERT INTO book (title) VALUES (:1);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();CREATE SEQUENCE book_seq
START WITH 1
INCREMENT BY 50
CACHE 100 -- values pre-allocated in instance memory
NOORDER; -- essential on RAC
SELECT book_seq.NEXTVAL FROM dual;
SELECT sequence_name, increment_by, cache_size, last_number
FROM user_sequences;- 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. - Round-trips are divided — with
allocationSize = 50oneNEXTVALyields fifty ids. - 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
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 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
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.
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')CREATE SEQUENCE book_seq START WITH 1 INCREMENT BY 1 NOCACHE;
CREATE OR REPLACE TRIGGER book_bi
BEFORE INSERT ON book FOR EACH ROW
WHEN (new.id IS NULL)
BEGIN
:new.id := book_seq.NEXTVAL;
END;
/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.
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;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;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.
"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.
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;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[]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 */);
-- Oracle has no ANY(array); the idiomatic equivalent joins a collection type:
-- SELECT b.author_id, b.id FROM book b JOIN TABLE(:ids) t ON b.author_id = t.column_value;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.
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();
join fetchwhen you know exactly that you need this association and don't need pagination (watch the Cartesian product with multiple collections).@EntityGraphfor the same benefit, pagination-compatible and with a cleaner query.@BatchSizea great global optimization; 10–50 is sensible (larger risksORA-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;-- Oracle 12c and later: the SQL:2008 standard syntax
SELECT b1_0.id, b1_0.title, b1_0.price
FROM book b1_0
ORDER BY b1_0.id
OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY;
-- Oracle 11g and older: two nested ROWNUM layers
-- SELECT * FROM (
-- SELECT inner_.*, ROWNUM rn FROM (
-- SELECT id, title, price FROM book ORDER BY id
-- ) inner_ WHERE ROWNUM <= 30
-- ) WHERE rn > 20;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.
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;SELECT b.id, b.title, b.created_at
FROM book b
WHERE (b.created_at, b.id) < ((:1, :2)) -- note: Oracle wants doubled parentheses
ORDER BY b.created_at DESC, b.id DESC
FETCH FIRST 20 ROWS ONLY;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.
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);-- note: "order" is a keyword; name the table orders on both engines
CREATE TABLE orders (
id number(19,0) PRIMARY KEY,
customer_id number(19,0),
CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customer (id)
);
CREATE INDEX ix_orders_customer ON orders (customer_id);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.
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
}
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.
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.
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
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;-- 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: Oracle has ON DELETE CASCADE too
ALTER TABLE order_line ADD CONSTRAINT fk_line_order
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE 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.
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 = 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);-- on Oracle, CLOB is exactly what you want
CREATE TABLE article (id number(19,0) PRIMARY KEY, content clob);
-- VARCHAR2 caps at 4000 bytes by default, so longer text requires CLOB
-- (MAX_STRING_SIZE = EXTENDED raises it to 32767, but it is still a LOB underneath)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-- Oracle 19c: SQL has no boolean; NUMBER(1) is the common convention
CREATE TABLE account (
id number(19,0) PRIMARY KEY,
active number(1,0) DEFAULT 0 NOT NULL,
CONSTRAINT ck_account_active CHECK (active IN (0, 1))
);
INSERT INTO account (id, active) VALUES (1, 1);
SELECT id FROM account WHERE active = 1; -- explicit comparison required
-- Oracle 23ai finally has a real BOOLEAN type:
-- active boolean DEFAULT false NOT NULLDon'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-- on Oracle an empty string IS NULL — converted silently
INSERT INTO customer (id, middle_name) VALUES (1, '');
SELECT count(*) FROM customer WHERE middle_name = ''; -- 0 (comparison is UNKNOWN)
SELECT count(*) FROM customer WHERE middle_name IS NULL; -- 1
SELECT length(middle_name) FROM customer WHERE id = 1; -- NULL, not 0NOT NULLbreaks: an empty form field is stored on PostgreSQL but raisesORA-01400: cannot insert NULLon Oracle — the same application works on one engine and fails on the other.- Round-tripping changes: you write an empty string and read back
null; aNullPointerExceptionis waiting for you. - Concatenation differs: on Oracle
'a' || NULLis'a', on PostgreSQL it isNULL.
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;CREATE TABLE event (
id number(19,0) PRIMARY KEY,
day date, -- careful: Oracle DATE has a time part!
happened timestamp(6), -- no time zone
happenedz timestamp(6) with time zone -- the offset/region IS stored
);
ALTER SESSION SET TIME_ZONE = 'UTC';
SELECT systimestamp, TRUNC(SYSDATE) FROM dual;- Oracle's
DATEis not a date: it always includes hours, minutes and seconds (it just has no fractional seconds). Writing aLocalDateTimeinto a legacyDATEcolumn silently truncates milliseconds, andday = DATE '2026-07-29'returns nothing unless you useTRUNC(day). On PostgreSQLdatereally is date-only. timestamptzdoes not store the zone: despite the name, PostgreSQL converts the value to UTC on write and renders it in the sessionTimeZoneon read — the original zone is lost. Oracle'sTIMESTAMP WITH TIME ZONEreally 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.TIMESTAMP WITH LOCAL TIME ZONEhas no PostgreSQL equivalent; avoid it in portable mappings.
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;-- Oracle 21c and later has a native JSON type
CREATE TABLE doc (id number(19,0) PRIMARY KEY, payload json NOT NULL);
CREATE SEARCH INDEX ix_doc_payload ON doc (payload) FOR JSON;
SELECT id FROM doc WHERE JSON_EXISTS(payload, '$?(@.status == "ACTIVE")');
SELECT JSON_VALUE(payload, '$.status') AS status FROM doc;
-- Oracle 19c: a CLOB with an IS JSON constraint
-- payload clob CONSTRAINT ck_doc_json CHECK (payload IS JSON)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.
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 NULLUPDATE account
SET balance = :1, version = :2
WHERE id = :3 AND version = :4;
-- column type: version number(19,0) NOT NULLIf zero rows were updated, someone wrote first — Hibernate throws an OptimisticLockException and you must retry or surface an error.
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-- exclusive lock, waits
SELECT a.id, a.balance, a.version FROM account a WHERE a.id = :1 FOR UPDATE;
-- no waiting: fails immediately with ORA-00054 (resource busy)
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: Oracle has it natively in the statement
SELECT a.id FROM account a WHERE a.id = :1 FOR UPDATE WAIT 3;
LOCK TABLE account IN SHARE MODE; -- Oracle has no shared ROW lock0maps toNOWAITon both (55P03on PostgreSQL,ORA-00054on Oracle). Portable.-2maps toSKIP 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 issueSET LOCAL lock_timeoutbefore 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 |
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 becomesFOR SHARE; on Oracle, which has no shared row lock, Hibernate effectively runsFOR UPDATE— stricter than you expect.PESSIMISTIC_WRITE: an exclusive lock (FOR UPDATEon 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;-- Oracle has only two levels
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- default; statement-level snapshot
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- effectively snapshot isolation; ORA-08177
-- REPEATABLE READ and READ UNCOMMITTED simply do not exist
SELECT sid, username FROM v$session WHERE sid = SYS_CONTEXT('USERENV','SID');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.
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-- the Oracle driver executes one statement with array binding
INSERT INTO book (title, price, id) VALUES (:1, :2, :3);
-- parsed once, 50 value sets in a single round-trip
-- no reWriteBatchedInserts equivalent is needed; standard JDBC batching suffices
-- jdbc:oracle:thin:@//db:1521/ORCLPDB1
-- oracle.jdbc.implicitStatementCacheSize=50By 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.
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);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 = 1 ORDER BY id FETCH FIRST 10000 ROWS ONLY);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)-- Oracle has no ON CONFLICT; MERGE is its standard equivalent
MERGE INTO stock t
USING (SELECT :1 AS sku, :2 AS qty FROM dual) s
ON (t.sku = s.sku)
WHEN MATCHED THEN UPDATE SET t.qty = t.qty + s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty);
-- Oracle has no RETURNING in plain SQL; only "RETURNING ... INTO" inside PL/SQLYou 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.
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 |
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.
-- 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;-- besides the buffer cache, Oracle has a real query result cache
SELECT /*+ RESULT_CACHE */ code, name FROM country;
ALTER TABLE country RESULT_CACHE (MODE FORCE);
SELECT name, value FROM v$parameter WHERE name LIKE 'result_cache%';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
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;SELECT c.id, c.name,
SUM(o.amount) AS total,
LISTAGG(o.code, ', ') WITHIN GROUP (ORDER BY o.code) AS codes,
NVL(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;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.
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 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.
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 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.
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;-- the estimated plan
EXPLAIN PLAN FOR
SELECT a.id, a.name FROM author a JOIN book b ON b.author_id = a.id WHERE a.name LIKE 'A%';
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
-- the real plan with execution statistics (closest to EXPLAIN ANALYZE)
SELECT /*+ GATHER_PLAN_STATISTICS */ a.id, a.name
FROM author a JOIN book b ON b.author_id = a.id WHERE a.name LIKE 'A%';
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'));
-- the most expensive statements
SELECT executions, elapsed_time, elapsed_time/executions AS avg_us, sql_text
FROM v$sql ORDER BY elapsed_time DESC FETCH FIRST 20 ROWS ONLY;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;SELECT sid, status, event, seconds_in_wait, sql_id
FROM v$session
WHERE username IS NOT NULL AND status = 'ACTIVE'
ORDER BY seconds_in_wait DESC;
-- blocking locks
SELECT s.sid, s.blocking_session, s.event, s.seconds_in_wait
FROM v$session s WHERE s.blocking_session IS NOT NULL;Common pitfalls and best practices
- Make every association
LAZY, even@ManyToOne/@OneToOne; then fetch explicitly per query. - On Oracle (and usually PostgreSQL) use
SEQUENCE, notIDENTITY— the only way to keep JDBC batching alive. - Align
allocationSizewithINCREMENT BYand review both together. - Index every FK column; on Oracle its absence causes table locks and deadlocks.
mergereturns a value — always writex = em.merge(x).- Hunt N+1 in the SQL log, or in
pg_stat_statements/V$SQLsorted by execution count. - Sync both sides of a bidirectional relationship with a helper, and put
cascade/orphanRemovalonly on genuine parent-child relationships. - Put
@Versionon concurrently editable entities; it's the only fully portable concurrency mechanism. - Replace
@Lob Stringwith@JdbcTypeCode(SqlTypes.LONGVARCHAR)to gettextandclobrespectively. - Normalise empty strings before they reach Oracle, where
''isNULL. - Set
hibernate.jdbc.time_zone=UTCand don't rely ontimestamptzto preserve a zone. reWriteBatchedInserts=truein the PostgreSQL URL andimplicitStatementCacheSizeon Oracle.- Enable
in_clause_parameter_paddingto 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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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;
mergereturns a managed copy, not the object itself. The persistence context guarantees instance uniqueness and dirty checking finds changes with no explicitsave. - Identifier generation is the biggest practical difference between the engines:
IDENTITYshuts the door on batching;SEQUENCEwithallocationSizealigned toINCREMENT BYis the right answer, especially on Oracle. - The biggest performance killer is N+1; the cures are
join fetch,@EntityGraph,@BatchSize(minding Oracle's 1000-elementINlimit) and DTO projections. Pagination isLIMIT/OFFSETon PostgreSQL andOFFSET ... FETCH FIRSTon Oracle (nestedROWNUMon 11g); for deep pages switch to keyset. - In type mapping: replace
@Lob Stringwith@JdbcTypeCode(SqlTypes.LONGVARCHAR), rememberbooleanisNUMBER(1)on Oracle (until 23ai), that an empty string isNULLthere, and thattimestamptzdoes not store a zone whileTIMESTAMP WITH TIME ZONEdoes — keep everything in UTC. - Optimistic locking (
@Version) is the only fully portable concurrency mechanism;NOWAITandSKIP LOCKEDare shared, butWAIT nis Oracle-only and PostgreSQL needslock_timeout. Oracle has noREPEATABLE READ. - For bulk writes, set
reWriteBatchedInserts=trueon 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.