@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.
Spring 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.

2.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 — đúng hai hàng đầu của sơ đồ mục 2, và nhanh hơn IDENTITY ~25 lần ở cùng latency.
| 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
finalvì lý do gì? Điều gì xảy ra ở runtime nếu bạn đánh dấu entityfinal? - Q2Giải thích cơ chế tại sao
IDENTITYstrategy không batch insert được, trong khiSEQUENCEthì batch được. Trả lời theo thứ tự thời điểm Hibernate có ID. - 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?
- Q4Bạn đặt
allocationSize = 100trong@SequenceGeneratornhưng DB sequence được tạo vớiINCREMENT 1. Hãy mô tả lỗi xảy ra và cách khắc phục. - 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
@Entityvà sửa.@Entity public final class Task { @Id @GeneratedValue Long id; String title; public Task(String title) { this.title = title; } }
Bài tiếp theo: @Column, @Enumerated, @Embeddable & naming
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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