@Entity & @Id — yêu cầu POJO và chiến lược sinh ID
Bóc tách đúng 2 câu hỏi atomic: tại sao @Entity yêu cầu mutable POJO (không phải record), và ba chiến lược @GeneratedValue — IDENTITY/SEQUENCE/UUID v7 — khác nhau ra sao ở mức Hibernate batching.
TL;DR: @Entity yêu cầu ba thứ cứng: no-arg constructor (để Hibernate instantiate qua reflection), class không final (để sinh CGLIB proxy cho lazy load), và field mutable với setter (để Hibernate ghi giá trị vào sau khi load từ DB). Java record vi phạm cả ba — là lý do dùng record cho DTO, class cho entity. @GeneratedValue có ba chiến lược thực dùng: IDENTITY đơn giản nhưng buộc Hibernate phát 1 INSERT mỗi lần vì phải đợi DB trả ID; SEQUENCE pre-fetch block ID trước INSERT nên batch được; UUID (v7, Hibernate 6.6+) app tự sinh ID local, không cần round-trip DB, phù hợp distributed system.
1. Vì sao JPA cần mutable POJO — không phải record
Trước khi đọc annotation, cần hiểu Hibernate làm gì với entity class. Hibernate không gọi constructor business của bạn khi load dữ liệu từ DB — nó:
- Instantiate bằng no-arg constructor (reflection
Class.newInstance()). - Set từng field qua setter hoặc trực tiếp vào field private sau khi load từ ResultSet.
- Sinh proxy CGLIB — một subclass của entity class — để intercept getter và thực hiện lazy load association.
Ba bước trên đặt ra ba yêu cầu cứng đối với entity class:
| Yêu cầu | Lý do kỹ thuật |
|---|---|
No-arg constructor (public hoặc protected) | Hibernate dùng Constructor.newInstance() khi tạo entity từ DB row |
Class không final | CGLIB không thể subclass final class — proxy fail → lazy load không hoạt động |
| Field mutable (có setter hoặc non-final) | Hibernate ghi giá trị column vào field sau instantiate |
1.1 Tại sao record vi phạm cả ba
Java record (JLS 16+) được thiết kế bất biến theo đặc tả ngôn ngữ:
// SAI — JPA khong the dung record
@Entity
public record Project(@Id Long id, String name, String status) {}
Ba lý do cụ thể:
recordlàfinalngầm — JLS §8.10 định nghĩa record class implicitly final. CGLIB subclass fail tại runtime vớiHibernateException: cannot proxy final class.- Không có no-arg constructor — record chỉ có canonical constructor (nhận tất cả component). Hibernate gọi
newInstance()không tham số →NoSuchMethodException. - Component immutable — record component không có setter. Hibernate không thể ghi giá trị column vào sau khi instantiate.
Pattern đúng: class cho entity, record cho DTO. Hai layer tách hoàn toàn.
// Entity (DB layer) — class mutable
@Entity
@Table(name = "projects")
public class Project {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
protected Project() {} // no-arg — JPA require
public Project(String name) {
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
// DTO (API layer) — record immutable
public record ProjectDto(Long id, String name) {
public static ProjectDto from(Project p) {
return new ProjectDto(p.getId(), p.getName());
}
}
Tách layer mang lại ba lợi ích: entity tự do thêm field mà không thay đổi API response, DTO có shape riêng (subset field, computed field), versioning API không động entity.
flowchart LR
subgraph DB["DB layer (@Entity)"]
direction TB
E1["class Project"]
E2["no-arg ctor"]
E3["setter fields"]
E4["non-final class"]
end
subgraph API["API layer (DTO)"]
direction TB
D1["record ProjectDto"]
D2["canonical ctor"]
D3["immutable fields"]
end
DB -->|"ProjectDto.from(entity)"| APISpring Data JDBC (khac Spring Data JPA) ho tro record lam entity vi JDBC khong co lazy loading proxy — khong can subclass. Module nay dung JPA+Hibernate nen van can class mutable.
2. @Id + @GeneratedValue — ba chiến lược thực dùng
@Id đánh dấu primary key. @GeneratedValue quyết định ai sinh ID và lúc nào. Câu hỏi "lúc nào" là then chốt — nó quyết định Hibernate có batch insert được hay không.
flowchart TB
subgraph IDENTITY["IDENTITY (auto-increment)"]
direction TB
I1["persist(entity)"]
I2["INSERT row vao DB"]
I3["DB tra id"]
I4["Hibernate nhan id"]
I1 --> I2 --> I3 --> I4
end
subgraph SEQUENCE["SEQUENCE (allocationSize=50)"]
direction TB
S1["nextval -- lay 50 id"]
S2["cache 50 id local"]
S3["assign id truoc INSERT"]
S4["batch 50 INSERT -- 1 DB call"]
S1 --> S2 --> S3 --> S4
end
subgraph UUID_V7["UUID v7 (Hibernate 6.6+)"]
direction TB
U1["app tu sinh id"]
U2["khong call DB"]
U3["INSERT voi id san co"]
U1 --> U2 --> U3
end2.1 IDENTITY — phổ biến nhất, không batch được
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Postgres DDL tương ứng:
CREATE TABLE projects (
id BIGSERIAL PRIMARY KEY, -- auto-increment
name VARCHAR(100) NOT NULL
);
Khi entityManager.persist(project), Hibernate phát:
INSERT INTO projects (name) VALUES (?) RETURNING id;
Vì sao không batch được: Hibernate cần biết ID ngay sau INSERT để cập nhật persistence context (map entity với row DB). IDENTITY column trả ID per-row sau mỗi INSERT — Hibernate không thể gộp 100 INSERT thành 1 batch statement vì sau batch statement không thể lấy từng ID riêng lẻ theo thứ tự tin cậy qua JDBC getGeneratedKeys().
Hệ quả thực tế: insert 1 000 entity với IDENTITY = 1 000 round-trip tới DB. Ở latency 0,5ms/RTT = 500ms chỉ để insert.
| Aspect | IDENTITY |
|---|---|
| Setup | Không cần thêm gì |
| DDL | BIGSERIAL / IDENTITY column |
| ID khi nào | Sau mỗi INSERT |
| Batch insert | Không |
| Dùng khi | CRUD thông thường, insert rate thấp |
2.2 SEQUENCE — batch được nhờ pre-fetch
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "project_seq")
@SequenceGenerator(
name = "project_seq",
sequenceName = "projects_id_seq",
allocationSize = 50 // fetch 50 id moi lan
)
private Long id;
Postgres DDL tương ứng:
CREATE SEQUENCE projects_id_seq INCREMENT 50 START 1;
CREATE TABLE projects (
id BIGINT PRIMARY KEY, -- khong phai BIGSERIAL
name VARCHAR(100) NOT NULL
);
Cơ chế batch hoạt động:
- Hibernate gọi
SELECT nextval('projects_id_seq'), DB trả50. - Hibernate biết block 50 ID có sẵn:
1, 2, 3, ..., 50. Cache local, không gọi DB thêm. - Assign ID cho từng entity trước INSERT — entity đã có ID hợp lệ trong bộ nhớ.
- Khi có đủ batch (hoặc flush): 1 câu
INSERT INTO projects (id, name) VALUES (1, ?), (2, ?), ..., (50, ?). - Khi hết block, Hibernate gọi
nextvallần nữa để nhận block tiếp theo.
Vì sao batch được: Hibernate có ID trước INSERT, không cần đợi DB trả. Gộp nhiều INSERT thành 1 statement được vì đã biết trước tất cả ID.
Insert 1 000 entity với SEQUENCE + allocationSize = 50: 20 lần nextval + 20 batch INSERT = 40 round-trip. Nhanh hơn IDENTITY ~25 lần ở cùng latency. Timeline round-trip của hai chiến lược:
sequenceDiagram
participant H as Hibernate
participant DB as Database
rect rgb(254, 226, 226)
Note over H,DB: IDENTITY -- moi row 1 round-trip, JDBC batching bi tat
H->>DB: INSERT row 1 RETURNING id
DB-->>H: id = 1
H->>DB: INSERT row 2 RETURNING id
DB-->>H: id = 2
Note over H,DB: ... lap lai cho TUNG row
end
rect rgb(220, 252, 231)
Note over H,DB: SEQUENCE (allocationSize=50) -- 2 round-trip cho 50 row
H->>DB: SELECT nextval('projects_id_seq')
DB-->>H: block id 1..50 (cache local)
H->>DB: 1 batch INSERT 50 row (id gan san)
DB-->>H: OK
end| Aspect | SEQUENCE |
|---|---|
| Setup | @SequenceGenerator + DDL CREATE SEQUENCE |
| DDL | BIGINT column + sequence object |
| ID khi nào | Trước INSERT (cached local) |
| Batch insert | Có (hibernate.jdbc.batch_size) |
| Dùng khi | Bulk import, insert rate cao (vượt 100/s) |
Bật batch trong application.yml:
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50
order_inserts: true
order_updates: true
Nếu allocationSize = 50 nhưng DB sequence INCREMENT 1, Hibernate cache 50 ID nhưng DB chỉ tăng 1 mỗi lần gọi nextval — gây trùng ID. Luôn đặt allocationSize bằng INCREMENT BY trong DDL sequence.
2.3 UUID v7 — distributed system, không cần DB
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@UuidGenerator(style = UuidGenerator.Style.TIME) // UUID v7 -- time-ordered
private UUID id;
DDL:
CREATE TABLE projects (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
UUID v4 vs v7: UUID v4 random hoàn toàn — 128 bit ngẫu nhiên. Khi insert nhiều row với UUID v4 làm primary key, B-tree index phải chèn vào vị trí ngẫu nhiên → gây page split liên tục → index fragmentation → query chậm dần. UUID v7 giải quyết bằng cách nhúng timestamp vào 48 bit đầu: ID tăng dần theo thời gian → insert vào cuối B-tree → không page split.
UUID v7 format:
|--- 48 bit timestamp (ms) ---|-- 4 bit ver --|-- 12 bit seq --|-- 62 bit random --|
Vì sao UUID không cần round-trip DB: app tự sinh ID hoàn toàn local — không cần nextval hay RETURNING id. Hibernate assign ID ngay khi persist(), INSERT ngay mà không cần hỏi DB.
| Aspect | UUID v7 |
|---|---|
| Setup | @UuidGenerator(style = TIME) — Hibernate 6.6+ |
| DDL | UUID column |
| ID khi nào | App sinh trước persist, không call DB |
| Batch insert | Có |
| Index locality | Tốt (time-ordered, không page split) |
| Dùng khi | Distributed system, microservices, public-facing ID không lộ count |
Spring Boot 3.4+ dùng Hibernate 6.6 — UUID v7 sẵn dùng. Boot 3.2-3.3 chỉ có UUID v4. Kiểm tra bằng spring-boot-dependencies BOM hoặc xem hibernate.version trong pom.xml.
3. So sánh ba chiến lược
| IDENTITY | SEQUENCE | UUID v7 | |
|---|---|---|---|
| ID type | Long | Long | UUID |
| Ai sinh ID | DB (auto-increment) | DB (sequence) | App (local) |
| Lúc nào có ID | Sau INSERT | Trước INSERT | Trước persist |
| Batch insert | Không | Có | Có |
| Distributed | Không (DB-coupled) | Không (DB-coupled) | Có |
| Sortable | Có (strict) | Có (cluster) | Có (v7 time-ordered) |
| Index size | 8 byte | 8 byte | 16 byte |
| Setup | Không cần | @SequenceGenerator + DDL | @UuidGenerator |
| Khi nào dùng | Default, CRUD thông thường | Bulk insert, batch job | Distributed, public ID |
Hướng dẫn chọn nhanh:
- App monolith, insert rate vừa phải → IDENTITY.
- Bulk import job, insert hàng nghìn row mỗi giây → SEQUENCE.
- Microservices, public-facing ID không lộ count record → UUID v7.
4. Pitfall thường gặp
Pitfall 1 — dùng @GeneratedValue(strategy = AUTO) không kiểm soát:
// Không khuyến khích
@Id
@GeneratedValue // = strategy = AUTO
private Long id;
AUTO để Hibernate tự chọn strategy theo dialect — Postgres thường chọn SEQUENCE nhưng tên sequence global (hibernate_sequence) thay vì per-table. Nhiều entity share cùng sequence → ID không liên tục trên mỗi table, gây khó đọc log. Luôn khai báo explicit strategy.
Pitfall 2 — allocationSize không khớp DB INCREMENT BY:
@SequenceGenerator(name = "seq", sequenceName = "projects_id_seq", allocationSize = 50)
-- Sai: INCREMENT 1 nhung allocationSize = 50
CREATE SEQUENCE projects_id_seq INCREMENT 1 START 1;
Hệ quả: Hibernate nghĩ mỗi lần nextval trả 50 ID liên tiếp, nhưng DB chỉ tăng 1. Lần đầu nextval trả 1, Hibernate cache 1..50. Lần hai nextval trả 2, Hibernate cache 2..51 — trùng ID 2 đến 50. Hibernate sẽ insert thất bại hoặc ghi đè row.
✅ Luôn đặt DDL INCREMENT BY bằng allocationSize.
Pitfall 3 — entity class final:
@Entity
public final class Project { ... } // Hibernate khong tao proxy -- fail
Hibernate ném HibernateException: cannot subclass final class tại startup. Bỏ final.
Pitfall 4 — quên no-arg constructor:
@Entity
public class Project {
@Id @GeneratedValue Long id;
public Project(String name) { ... } // chi co arg ctor
}
Hibernate ném InstantiationException khi load entity từ DB. Thêm protected Project() {}.
Liên hệ các bài khác
- EntityManager & JPQL: hiểu persistence context là nơi Hibernate track entity sau
persist()— nơi ID cần có để map entity với DB row, là lý do IDENTITY không batch được. - @Column, @Enumerated, @Embeddable: bài tiếp theo bóc annotation bổ sung cho field entity — naming strategy, enum mapping (pitfall ORDINAL), value object inline.
Tóm tắt
@Entityyêu cầu: no-arg constructor, class khôngfinal, field mutable. Cả ba phục vụ Hibernate reflection + CGLIB proxy.- Record vi phạm cả ba yêu cầu — dùng cho DTO, không phải entity.
- IDENTITY: DB sinh ID sau mỗi INSERT → không batch. Dùng cho CRUD thông thường.
- SEQUENCE: Hibernate pre-fetch block ID trước INSERT → batch được. Dùng khi insert rate cao.
- UUID v7: app sinh ID local, không cần DB → batch được, distributed-friendly, B-tree locality tốt. Cần Hibernate 6.6+.
allocationSizephải khớpINCREMENT BYcủa DB sequence,AUTOkhông khuyến khích vì khó kiểm soát.
Tự kiểm tra
Q1Hibernate cần class entity không final vì lý do gì? Điều gì xảy ra ở runtime nếu bạn đánh dấu entity final?▸
final vì lý do gì? Điều gì xảy ra ở runtime nếu bạn đánh dấu entity final?Hibernate sinh ra một proxy CGLIB — một subclass của entity class — để thực hiện lazy loading association. Khi bạn gọi getter trên association chưa load, proxy intercept lời gọi đó và thực hiện SQL query lấy dữ liệu từ DB.
Nếu entity class là final, CGLIB không thể tạo subclass — proxy generation fail. Hibernate ném HibernateException: cannot subclass final class ngay tại startup (khi build SessionFactory), không phải lúc runtime query.
Hệ quả thực tế: app không khởi động được khi có bất kỳ @Entity final class nào. Đây là lỗi tại startup rõ ràng, không phải lỗi im lặng.
Giải pháp: bỏ final khỏi entity class. Nếu muốn hạn chế kế thừa trong business code, đặt constructor protected thay vì dùng final.
Q2Giải thích cơ chế tại sao IDENTITY strategy không batch insert được, trong khi SEQUENCE thì batch được. Trả lời theo thứ tự thời điểm Hibernate có ID.▸
IDENTITY strategy không batch insert được, trong khi SEQUENCE thì batch được. Trả lời theo thứ tự thời điểm Hibernate có ID.IDENTITY — ID sau INSERT: khi dùng IDENTITY (auto-increment column), DB sinh ID ngay tại thời điểm INSERT và trả về qua RETURNING id (Postgres) hoặc JDBC getGeneratedKeys(). Hibernate cần ID ngay sau INSERT để cập nhật persistence context (ánh xạ entity object ↔ DB row). Vì phải nhận ID sau mỗi INSERT, Hibernate phải phát từng INSERT riêng lẻ — không thể gộp 100 INSERT thành 1 batch statement.
SEQUENCE — ID trước INSERT: khi dùng SEQUENCE, Hibernate gọi SELECT nextval(...) một lần và nhận block ID (ví dụ 50 ID liên tiếp). Hibernate cache block đó local và assign ID cho từng entity trước khi phát INSERT. Vì entity đã có ID hợp lệ, Hibernate có thể gộp nhiều INSERT thành 1 batch statement mà không cần đợi DB trả kết quả.
Kết quả hiệu năng: insert 1 000 entity — IDENTITY cần 1 000 round-trip; SEQUENCE với allocationSize=50 chỉ cần 20 lần nextval + 20 batch INSERT = 40 round-trip. Chênh lệch ~25 lần ở cùng network latency.
Q3UUID v4 random gây vấn đề gì với B-tree index của PostgreSQL? UUID v7 giải quyết như thế nào?▸
Vấn đề UUID v4 với B-tree: UUID v4 là 128 bit hoàn toàn ngẫu nhiên — không có thứ tự thời gian. Khi insert row mới, PostgreSQL phải chèn UUID vào vị trí ngẫu nhiên trong B-tree index. Điều này gây page split thường xuyên: B-tree page đầy phải tách đôi để chứa giá trị mới ở giữa. Sau hàng nghìn insert, index bị fragmentation nặng — các page không liên tiếp về mặt vật lý. Hệ quả: query range scan chậm, index bloat (index to hơn cần thiết), write amplification tăng.
UUID v7 giải quyết: UUID v7 nhúng Unix timestamp millisecond vào 48 bit đầu tiên. Các UUID sinh ra gần nhau về thời gian có prefix tăng dần — chúng xếp vào cuối B-tree thay vì vị trí ngẫu nhiên. Insert vào cuối không gây page split ở giữa — behavior tương tự BIGSERIAL. B-tree giữ nguyên cấu trúc compact, hiệu năng write và read ổn định theo thời gian.
UUID v7 cần Hibernate 6.6+ (Spring Boot 3.4+). Khai báo bằng @UuidGenerator(style = UuidGenerator.Style.TIME).
Q4Bạn đặt allocationSize = 100 trong @SequenceGenerator nhưng DB sequence được tạo với INCREMENT 1. Hãy mô tả lỗi xảy ra và cách khắc phục.▸
allocationSize = 100 trong @SequenceGenerator nhưng DB sequence được tạo với INCREMENT 1. Hãy mô tả lỗi xảy ra và cách khắc phục.Điều xảy ra: Hibernate giả định mỗi lần gọi nextval DB tăng 100 — nghĩa là sau lần đầu nextval trả 1, block ID local là 1..100. Lần hai nextval Hibernate expect trả 101 (để block tiếp theo là 101..200). Nhưng DB chỉ tăng 1 nên trả 2 — Hibernate nghĩ block tiếp là 2..101, trùng với 2..100 của block trước.
Hệ quả: Hibernate assign ID trùng nhau. PostgreSQL ném duplicate key value violates unique constraint khi INSERT — hoặc im lặng ghi đè nếu không có unique constraint. Đây là lỗi data corruption nghiêm trọng, khó phát hiện vì chỉ xảy ra khi insert đủ nhiều để exhaust block đầu tiên.
Khắc phục: đảm bảo INCREMENT BY trong DDL bằng allocationSize:
CREATE SEQUENCE projects_id_seq INCREMENT 100 START 1;
Hoặc ngược lại: hạ allocationSize = 1 nếu sequence đã tạo với INCREMENT 1 và không thể thay đổi. Khi đó mất lợi ích batch nhưng không bị trùng ID.
Q5Một team muốn dùng entity class như sau nhưng gặp lỗi khi khởi động app. Tìm đúng 3 vi phạm yêu cầu @Entity và sửa.
@Entity public final class Task { @Id @GeneratedValue Long id; String title; public Task(String title) { this.title = title; } }▸
@Entity và sửa.@Entity public final class Task { @Id @GeneratedValue Long id; String title; public Task(String title) { this.title = title; } }3 vi phạm:
1. Class là final: Hibernate không thể tạo CGLIB proxy subclass. Lỗi: HibernateException: cannot subclass final class Task tại startup. Sửa: bỏ final.
2. Không có no-arg constructor: class chỉ có Task(String title). Hibernate gọi Constructor.newInstance() không tham số khi load từ DB → InstantiationException: no no-arg constructor found. Sửa: thêm protected Task() {}.
3. Field không có setter (thiếu mutability): field title là package-private, không có setter. Hibernate có thể set field private qua reflection, nhưng khai báo không có setter vi phạm JPA spec requirement "persistent fields must be accessible" và gây lỗi với một số Hibernate configuration. Sửa: thêm getter và setter, đặt field private.
Entity đúng:
@Entity public class Task { @Id @GeneratedValue private Long id; private String title; protected Task() {} public Task(String title) { this.title = title; } public Long getId() { return id; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } }
Bonus: @GeneratedValue không có strategy → mặc định AUTO, Hibernate tự chọn theo dialect. Nên khai báo explicit strategy = GenerationType.IDENTITY để tránh behaviour không tường minh.
Bài tiếp theo: @Column, @Enumerated, @Embeddable & naming
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên