Tổng quan
Ba thứ hay bị nhầm thành một. Nắm rõ ranh giới này là câu hỏi mở đầu của hầu hết buổi phỏng vấn.
JPA, Hibernate và Spring Data JPA khác nhau thế nào
| Thành phần | Là gì | API tiêu biểu |
|---|---|---|
| JPA | Đặc tả chuẩn (interface và annotation), tự nó không chạy được | @Entity EntityManager JPQL |
| Hibernate | Implementation phổ biến nhất của JPA, có thêm tính năng riêng | Session @BatchSize @NaturalId HQL |
| Spring Data JPA | Lớp trừu tượng của Spring, tự sinh implementation cho repository dựa trên JPA | JpaRepository @Query Specification |
Luồng gọi: Service → Repository (proxy, thực chất là SimpleJpaRepository) → EntityManager → Hibernate Session → JDBC → DB.
Spring Boot 3 / Hibernate 6: điểm khác
- Package là
jakarta.persistence.*, không cònjavax.persistence.* - Cần Java 17 trở lên
- Hibernate tự nhận dialect, thường không cần khai báo
getReferenceById()thay chogetOne()ListCrudRepositorytrảListthay vìIterablePagingAndSortingRepositorykhông còn extendsCrudRepository@Wheređược thay bằng@SQLRestriction(Hibernate 6.3+)
Khi nào dùng cái gì
- CRUD đơn giản, truy vấn theo tên field: derived query
- Truy vấn có join hoặc điều kiện phức tạp:
@QueryJPQL - Filter động nhiều điều kiện tùy chọn:
Specification - Báo cáo, hàm đặc thù DB: native query hoặc projection
- Cần kiểm soát sâu session, cache, statistics: unwrap sang
Session
Cấu hình
Setup tối thiểu và những property đáng nhớ.
Dependency
application.yml
ddl-auto
| Giá trị | Hành vi |
|---|---|
| none | Không làm gì |
| validate | Kiểm tra schema khớp entity, lệch thì báo lỗi lúc khởi động |
| update | Thêm bảng/cột còn thiếu, không bao giờ xóa. Chỉ dùng khi dev |
| create | Drop rồi tạo lại mỗi lần chạy |
| create-drop | Như create, và drop khi tắt app. Hợp với test |
Production: dùng validate hoặc none kết hợp Flyway / Liquibase để quản lý migration.
Bật statistics để đếm query
Cách nhanh nhất để phát hiện N+1: đếm số câu SQL cho một request.
Mapping entity
Annotation cơ bản, chiến lược sinh khóa, kế thừa và các kiểu nhúng.
Entity mẫu
Yêu cầu với entity: có @Id, có constructor không tham số (public hoặc protected), class và field không final (để tạo proxy lazy), không dùng record làm entity.
Annotation hay dùng
| Annotation | Ý nghĩa |
|---|---|
| @Entity | Đánh dấu class là entity |
| @Table | Tên bảng, constraint, index |
| @Column | name, nullable, length, unique, updatable, insertable |
| @Enumerated | STRING hoặc ORDINAL |
| @Temporal | Chỉ cho java.util.Date. Kiểu java.time không cần |
| @Transient | Bỏ qua field khi persist |
| @Lob | CLOB / BLOB |
| @Convert | Dùng AttributeConverter để đổi kiểu |
| @Version | Cột version cho optimistic lock |
| @MappedSuperclass | Class cha chia sẻ field, bản thân không phải entity |
Chiến lược sinh khóa
| Strategy | Đặc điểm |
|---|---|
| IDENTITY | Auto-increment của DB. Phải INSERT ngay để lấy id nên tắt JDBC batch insert |
| SEQUENCE | Dùng sequence, allocationSize mặc định 50, hỗ trợ batch. Lựa chọn tốt cho PostgreSQL, Oracle |
| UUID | Sinh UUID phía app (JPA 3.1). Index kém hơn số tự tăng |
| TABLE | Giả lập sequence bằng bảng. Chậm, nên tránh |
| AUTO | Provider tự chọn theo DB, dễ gây bất ngờ |
Kế thừa
| Kiểu | Đặc điểm |
|---|---|
| SINGLE_TABLE | Mặc định. Một bảng + cột DTYPE. Nhanh, nhưng cột của lớp con phải nullable |
| JOINED | Mỗi lớp một bảng, join khi đọc. Chuẩn hóa tốt, chậm hơn |
| TABLE_PER_CLASS | Mỗi lớp cụ thể một bảng, truy vấn đa hình dùng UNION. Ít dùng |
| @MappedSuperclass | Không đa hình. Chỉ để dùng lại field |
Embedded và khóa phức hợp
AttributeConverter
Quan hệ giữa các entity
Phần dễ sai nhất. Nhớ ba khái niệm: bên sở hữu (owning side), cascade và fetch.
Mặc định fetch
| Quan hệ | Mặc định |
|---|---|
| @ManyToOne | EAGER |
| @OneToOne | EAGER |
| @OneToMany | LAZY |
| @ManyToMany | LAZY |
Khuyến nghị: đặt fetch = LAZY cho tất cả, rồi chủ động fetch bằng join fetch hoặc EntityGraph khi cần.
Owning side và mappedBy
- Bên sở hữu là bên giữ khóa ngoại, thường là phía
@ManyToOne - Bên inverse khai báo
mappedBy = "tênFieldBênSởHữu" - Chỉ thay đổi ở bên sở hữu mới được ghi xuống DB
- Quan hệ hai chiều phải đồng bộ cả hai phía bằng helper method
Một-nhiều hai chiều (mẫu chuẩn)
Nhiều-nhiều
Dùng Set thay vì List để tránh Hibernate xóa hết rồi chèn lại bảng nối. Khi bảng nối cần thêm cột, tách thành entity trung gian với hai @ManyToOne.
Một-một dùng chung khóa chính
@OneToOne phía inverse (có mappedBy) khó lazy thật sự vì Hibernate phải truy vấn để biết là null hay không. Dùng @MapsId là cách gọn nhất.
CascadeType
| Loại | Lan truyền thao tác |
|---|---|
| PERSIST | persist cha thì persist con |
| MERGE | merge cha thì merge con |
| REMOVE | xóa cha thì xóa con |
| REFRESH / DETACH | refresh / detach lan sang con |
| ALL | Tất cả các loại trên |
orphanRemoval = true: gỡ con khỏi collection của cha thì con bị xóa khỏi DB.
Không đặt CascadeType.REMOVE hoặc ALL trên @ManyToMany: xóa một phía sẽ kéo theo xóa entity ở phía kia.
Bẫy hay gặp ở quan hệ
@OneToManymột chiều không có@JoinColumnsinh thêm bảng nối và câu UPDATE thừatoString(),hashCode(), JSON serialize đi vòng giữa hai phía gâyStackOverflowError- Quên gọi helper nên chỉ set một phía, DB không thấy thay đổi
- Trả entity thẳng ra API làm lộ lazy proxy. Dùng DTO
Vòng đời entity và Persistence Context
Persistence context là “vùng nhớ làm việc” của EntityManager: nó theo dõi các entity managed và là cache cấp 1.
Bốn trạng thái
Một entity chỉ được theo dõi khi đang Managed. merge() trả về một bản Managed mới, còn object bạn truyền vào vẫn là Detached.
Thao tác của EntityManager
| Phương thức | Tác dụng |
|---|---|
| persist(e) | New → Managed. Chính instance đó trở thành managed |
| find(C, id) | Tìm trong context trước, không có mới query. Trả null nếu không thấy |
| getReference(C, id) | Trả proxy, chưa query. Lỗi EntityNotFoundException khi truy cập mà không tồn tại |
| merge(e) | Copy state của detached vào managed instance, trả về bản managed |
| remove(e) | Managed → Removed |
| refresh(e) | Nạp lại state từ DB, ghi đè thay đổi chưa flush |
| flush() | Đẩy SQL xuống DB, chưa commit |
| detach / clear | Bỏ một / toàn bộ entity khỏi context |
Dirty checking
Entity managed bị sửa thì Hibernate tự UPDATE khi flush. Không cần gọi save().
Flush xảy ra: khi commit, trước khi chạy query có thể bị ảnh hưởng (FlushMode AUTO), hoặc khi gọi flush() thủ công. Transaction readOnly = true đặt flush mode MANUAL nên bỏ qua dirty checking.
Cache cấp 1 trong thực tế
Phạm vi của cache cấp 1 là một EntityManager (thường là một transaction).
Lifecycle callback
@PrePersist @PostPersist @PreUpdate @PostUpdate @PreRemove @PostRemove @PostLoad
Dùng merge đúng cách
Spring Data JPA Repository
Chỉ cần khai báo interface, Spring tự tạo implementation lúc khởi động.
Hệ phân cấp interface
| Interface | Bổ sung |
|---|---|
| Repository<T,ID> | Marker, không có method |
| CrudRepository | save, findById, existsById, count, delete… |
| ListCrudRepository | Như trên nhưng trả List |
| PagingAndSortingRepository | findAll(Sort), findAll(Pageable) |
| JpaRepository | flush, saveAndFlush, deleteAllInBatch, getReferenceById |
Method CRUD có sẵn
| Method | Ghi chú |
|---|---|
| save / saveAll | persist nếu mới, merge nếu đã có id. Luôn dùng giá trị trả về |
| findById | Trả Optional |
| getReferenceById | Proxy lazy, tiện để gán FK |
| findAll / findAllById | Cẩn thận với bảng lớn |
| deleteById / delete | Load entity rồi xóa |
| deleteAllInBatch | Một câu DELETE duy nhất, không load |
| deleteAll | Load rồi xóa từng entity, chậm |
| saveAndFlush / flush | Ép đẩy SQL xuống DB ngay |
save() quyết định mới hay cũ thế nào
- Mới (
isNew): id null, hoặc cột@Versionnull →persist - Còn lại →
merge(có thể sinh thêm SELECT) - Id tự gán tay (không dùng
@GeneratedValue) làm mọi lần save đều thành merge, thừa một SELECT. Cách xử lý: implementPersistable<ID>hoặc thêm@Version
Từ khóa derived query
| Từ khóa | Ví dụ |
|---|---|
| And / Or | findByNameAndAge |
| Between | findByAgeBetween(a, b) |
| LessThan / GreaterThan (Equal) | findByAgeGreaterThanEqual |
| After / Before | findByCreatedAtAfter |
| IsNull / IsNotNull | findByDeletedAtIsNull |
| Like | Tự truyền ký tự % |
| StartingWith / EndingWith / Containing | Tự thêm % |
| In / NotIn | findByRoleIn(Collection) |
| True / False | findByActiveTrue |
| IgnoreCase | findByEmailIgnoreCase |
| OrderBy…Asc/Desc | findByRoleOrderByNameDesc |
| First / Top N, Distinct | findTop5By… |
| countBy / existsBy / deleteBy | existsByEmail |
Ví dụ derived query
@Query, tham số và update hàng loạt
@Modifying chạy thẳng xuống DB, bỏ qua persistence context nên entity đang giữ trong bộ nhớ có thể cũ. Dùng clearAutomatically = true để xóa context sau khi chạy.
Phân trang và sắp xếp
| Kiểu trả về | Chi phí |
|---|---|
| Page | Thêm một câu COUNT để biết tổng |
| Slice | Lấy size+1 dòng, không COUNT. Hợp với “tải thêm” |
| List + Pageable | Chỉ lấy dữ liệu, không metadata |
Chỉ số trang bắt đầu từ 0.
Projection: chỉ lấy cột cần
Open projection (@Value("#{target.a + target.b}")) phải load cả entity nên mất lợi thế.
Specification (filter động)
Trả null nghĩa là “không thêm điều kiện”. Ngoài ra có Query by Example (repo.findAll(Example.of(probe))) cho trường hợp lọc đơn giản theo ví dụ.
@EntityGraph
Cách khai báo fetch ngay trên method mà không cần viết JPQL.
Custom repository
Tên class implementation phải kết thúc bằng Impl.
JPQL nhanh
Viết trên entity và field, không phải bảng và cột.
Các mẫu hay dùng
Hàm có sẵn: lower upper concat length coalesce current_date và các hàm gom nhóm count sum avg min max.
Join fetch một collection kèm phân trang: Hibernate cảnh báo (HHH90003004) và phân trang trong bộ nhớ, rất tốn RAM. Cách xử lý: query 1 lấy id theo trang, query 2 fetch theo danh sách id; hoặc dùng @BatchSize.
Fetch & vấn đề N+1
Nguồn gốc của phần lớn vấn đề hiệu năng khi dùng JPA.
N+1 là gì
Phát hiện: bật org.hibernate.SQL hoặc statistics và đếm số câu SQL cho một request.
Cách khắc phục
| Cách | Khi dùng |
|---|---|
| JOIN FETCH | Biết trước cần quan hệ nào, viết JPQL |
| @EntityGraph | Như trên nhưng khai báo bằng annotation |
| @BatchSize / default_batch_fetch_size | Gom N câu thành N/size câu dạng IN (...) |
| Projection / DTO | Chỉ cần vài cột, không cần entity |
| @Fetch(SUBSELECT) | Nạp collection của tất cả entity bằng một subquery |
Ví dụ sửa
MultipleBagFetchException
Xảy ra khi join fetch hai List (bag) trong cùng một query. Cách xử lý:
- Đổi một trong hai sang
Set - Hoặc fetch từng collection bằng query riêng trong cùng transaction
- Hoặc dùng
@BatchSizethay cho join fetch
Fetch nhiều collection cùng lúc còn gây tích Descartes, số dòng nhân lên.
LazyInitializationException
Truy cập quan hệ lazy khi session đã đóng (thường ngoài transaction, hoặc ở controller).
- Fetch đủ dữ liệu trong service bằng join fetch / EntityGraph
- Map sang DTO trước khi ra khỏi tầng service
- Không “chữa” bằng cách chuyển sang EAGER hay bật open-in-view
spring.jpa.open-in-view mặc định là true: giữ EntityManager mở suốt request, che lỗi lazy nhưng giữ connection lâu và dễ sinh query ngầm ở tầng view. Nên đặt false.
getReferenceById để gán FK
Không cần load nguyên entity chỉ để gán khóa ngoại.
Transaction
@Transactional hoạt động qua proxy AOP. Hiểu cơ chế proxy là hiểu phần lớn bẫy.
Thuộc tính chính
| Thuộc tính | Ý nghĩa |
|---|---|
| propagation | Cách tham gia transaction hiện có. Mặc định REQUIRED |
| isolation | Mức cô lập. Mặc định là của DB |
| readOnly | Gợi ý chỉ đọc, bỏ dirty checking, tối ưu |
| timeout | Giây tối đa |
| rollbackFor / noRollbackFor | Tùy biến exception nào gây rollback |
Mặc định chỉ rollback với RuntimeException và Error. Checked exception không rollback, trừ khi khai báo rollbackFor = Exception.class.
Propagation
| Loại | Hành vi |
|---|---|
| REQUIRED | Có thì tham gia, chưa có thì tạo mới |
| REQUIRES_NEW | Luôn tạo transaction mới, treo cái cũ |
| SUPPORTS | Có thì tham gia, không thì chạy không transaction |
| NOT_SUPPORTED | Tạm ngưng transaction hiện có |
| MANDATORY | Bắt buộc đã có, không thì lỗi |
| NEVER | Có transaction thì lỗi |
| NESTED | Transaction lồng bằng savepoint |
Isolation và hiện tượng đọc
| Mức | Dirty | Non-repeatable | Phantom |
|---|---|---|---|
| READ_UNCOMMITTED | Có | Có | Có |
| READ_COMMITTED | Không | Có | Có |
| REPEATABLE_READ | Không | Không | Có* |
| SERIALIZABLE | Không | Không | Không |
* Theo chuẩn SQL. Một số DB như MySQL InnoDB chặn phần lớn phantom ở mức này.
Khi @Transactional không chạy
- Self-invocation: gọi
this.method()trong cùng class không đi qua proxy - Method
private,finalhoặcstatic - Bean không do Spring quản lý (tự
new) - Checked exception mà không có
rollbackFor catchexception rồi nuốt mất, transaction thấy như thành công- Chạy sang thread khác (
@Async, parallel stream)
Đặt ở đâu
Đặt ở tầng service, không đặt ở controller hay repository. SimpleJpaRepository đã có sẵn @Transactional(readOnly = true) ở cấp class và @Transactional ở method ghi.
Lock và đồng thời
Hai cách chống lost update: lạc quan (optimistic) và bi quan (pessimistic).
Optimistic lock
Hibernate sinh UPDATE ... WHERE id = ? AND version = ?. Nếu không có dòng nào bị ảnh hưởng, ném OptimisticLockException (Spring dịch thành ObjectOptimisticLockingFailureException).
- Không giữ khóa DB, hợp khi xung đột hiếm
- Xử lý bằng cách retry hoặc báo người dùng tải lại
Pessimistic lock
Sinh SELECT ... FOR UPDATE. Phải chạy trong transaction. Hợp khi xung đột thường xuyên, nhưng dễ deadlock và giảm throughput.
LockModeType
| Loại | Ý nghĩa |
|---|---|
| OPTIMISTIC | Kiểm tra version lúc commit (đọc) |
| OPTIMISTIC_FORCE_INCREMENT | Tăng version dù entity không đổi |
| PESSIMISTIC_READ | Khóa chia sẻ, người khác đọc được nhưng không ghi |
| PESSIMISTIC_WRITE | Khóa độc quyền (FOR UPDATE) |
| PESSIMISTIC_FORCE_INCREMENT | Khóa độc quyền và tăng version |
Cache
Cache cấp 1 và cấp 2
| Cấp 1 | Cấp 2 | |
|---|---|---|
| Phạm vi | Một EntityManager / Session | Toàn SessionFactory, dùng chung nhiều session |
| Mặc định | Luôn bật | Tắt, cần provider |
| Lưu | Chính entity | Dạng dữ liệu rời (dehydrated) |
Bật cache cấp 2 và query cache
Chỉ nên cache dữ liệu đọc nhiều, ít đổi (danh mục, cấu hình). Đừng nhầm với @Cacheable của Spring Cache, đó là cơ chế khác, cache ở tầng method.
Tính năng riêng của Hibernate
Không nằm trong chuẩn JPA, dùng được vì Hibernate là implementation phía dưới.
Annotation thường gặp
| Annotation | Tác dụng |
|---|---|
| @DynamicUpdate | UPDATE chỉ các cột thực sự đổi |
| @NaturalId | Khóa tự nhiên (email, mã…), tối ưu lookup có cache |
| @Formula | Field tính từ biểu thức SQL, chỉ đọc |
| @BatchSize | Gom lazy-load thành lô |
| @Fetch | SELECT, JOIN hoặc SUBSELECT |
| @Immutable | Entity không bao giờ UPDATE |
| @CreationTimestamp / @UpdateTimestamp | Tự gán thời gian |
| @UuidGenerator | Tùy biến cách sinh UUID |
| @Filter | Bộ lọc bật tắt theo session |
Soft delete
Gọi delete() sẽ thành UPDATE, mọi query tự thêm điều kiện deleted = false.
Truy cập Session
Thuật ngữ tương ứng: EntityManager ↔ Session, EntityManagerFactory ↔ SessionFactory, JPQL ↔ HQL.
Batch insert / update
- Đặt
hibernate.jdbc.batch_size(ví dụ 50) - Bật
order_insertsvàorder_updates - Dùng
SEQUENCE, không dùngIDENTITY - Với lượng rất lớn:
flush()rồiclear()theo từng lô để context không phình ra
Auditing
Tự ghi thời gian và người tạo/sửa
Nên và tránh
Danh sách rà soát nhanh trước khi review code hoặc đi phỏng vấn.
Nên
Đặt mọi quan hệ ở LAZY, fetch theo nhu cầu.
Viết helper add/remove cho quan hệ hai chiều.
Dùng @Enumerated(EnumType.STRING).
Dùng DTO / projection ở biên API.
Dùng Long, Integer cho id (cho phép null).
Đặt @Transactional(readOnly = true) cho luồng đọc.
Dùng SEQUENCE khi cần batch insert.
Tắt open-in-view.
Quản lý schema bằng Flyway / Liquibase.
Phân trang mọi danh sách có thể lớn.
Tránh
Trả entity trực tiếp ra REST.
findAll() rồi lọc bằng Java stream.
Lombok @Data, @EqualsAndHashCode, @ToString trên entity có quan hệ.
EAGER để “cho khỏi lỗi lazy”.
@Enumerated(ORDINAL).
ddl-auto: update trên production.
deleteAll() cho bảng lớn (dùng deleteAllInBatch hoặc query xóa).
@ManyToMany với CascadeType.REMOVE.
Bắt exception trong method @Transactional rồi bỏ qua.
equals và hashCode cho entity
Hoặc dùng khóa nghiệp vụ (@NaturalId) nếu có. Tránh so sánh trên mọi field vì kéo theo lazy collection và id thay đổi sau khi persist làm hỏng HashSet.
Hỏi nhanh đáp gọn
Bấm vào từng câu để xem đáp án. Thử tự trả lời trước khi mở.
persist() khác merge() thế nào?
persist nhận entity mới (transient), đưa chính instance đó vào managed; truyền entity detached sẽ lỗi. merge copy state vào một instance managed (có thể SELECT trước) và trả về bản managed, còn object truyền vào vẫn detached.
save() của Spring Data làm gì?
Entity mới (id null hoặc version null) thì gọi persist, ngược lại gọi merge. Luôn dùng giá trị trả về. save không flush ngay (trừ IDENTITY phải INSERT lấy id); saveAndFlush thì flush ngay.
LAZY khác EAGER, mặc định ra sao?
LAZY nạp khi truy cập lần đầu (qua proxy hoặc collection wrapper), EAGER nạp cùng entity cha. Mặc định: @ManyToOne, @OneToOne là EAGER; @OneToMany, @ManyToMany là LAZY. Nên đặt LAZY hết và fetch chủ động.
N+1 là gì, sửa thế nào?
1 query lấy danh sách rồi N query nạp quan hệ lazy cho từng phần tử. Sửa bằng JOIN FETCH, @EntityGraph, @BatchSize / default_batch_fetch_size hoặc dùng DTO projection.
@Transactional không có tác dụng khi nào?
Self-invocation (gọi nội bộ qua this), method private/final/static, bean không do Spring quản lý, checked exception không có rollbackFor, exception bị catch nuốt, hoặc chạy ở thread khác.
mappedBy dùng để làm gì, owning side là gì?
Owning side là phía giữ khóa ngoại và là phía duy nhất quyết định dữ liệu ghi xuống DB. Phía còn lại (inverse) dùng mappedBy trỏ về field của owning side để không tạo thêm cột hay bảng nối.
orphanRemoval khác CascadeType.REMOVE?
REMOVE xóa con khi xóa cha. orphanRemoval = true xóa con khi con bị gỡ khỏi collection của cha (và cả khi cha bị xóa).
Optimistic hay pessimistic lock?
Optimistic dùng @Version, không khóa DB, phát hiện xung đột lúc ghi rồi retry; hợp khi xung đột ít. Pessimistic dùng SELECT ... FOR UPDATE, chặn người khác; hợp khi xung đột nhiều nhưng dễ deadlock và giảm thông lượng.
Page, Slice và List khác nhau thế nào?
Page chạy thêm COUNT để có tổng số phần tử và số trang. Slice chỉ biết còn trang sau hay không (lấy size+1), không COUNT. List chỉ trả dữ liệu.
getReferenceById khác findById?
findById query ngay và trả Optional. getReferenceById trả proxy chưa query, tiện để gán FK; nếu bản ghi không tồn tại thì lỗi chỉ xuất hiện khi truy cập dữ liệu.
JPQL, native query và Specification chọn cái nào?
JPQL cho truy vấn tĩnh viết trên entity, độc lập DB. Native khi cần hàm hoặc cú pháp riêng của DB. Specification (Criteria API) khi điều kiện lọc ghép động từ nhiều tùy chọn.
Cache cấp 1 khác cấp 2?
Cấp 1 là persistence context, luôn bật, phạm vi một session/transaction. Cấp 2 dùng chung giữa các session, mặc định tắt, cần provider và cấu hình từng entity.
Vì sao entity cần constructor không tham số?
Hibernate tạo instance bằng reflection khi nạp dữ liệu, và sinh proxy lazy bằng cách tạo lớp con. Vì vậy class không được final và constructor nên là public hoặc protected.
Vì sao IDENTITY làm mất batch insert?
Hibernate cần id ngay sau khi persist, mà với IDENTITY id chỉ có sau khi INSERT chạy, nên buộc phải thực thi từng câu. SEQUENCE lấy id trước nên gom được lô.
Vì sao nên tắt open-in-view?
Nó giữ EntityManager (và thường là connection) suốt vòng đời request, che lỗi LazyInitializationException và cho phép query ngầm ở tầng view/serialize. Tắt đi buộc bạn fetch rõ ràng trong service, tránh cạn connection pool.
Dirty checking hoạt động ra sao?
Khi nạp entity, Hibernate lưu một bản snapshot. Lúc flush nó so sánh trạng thái hiện tại với snapshot; có khác biệt thì sinh UPDATE. Vì vậy sửa entity managed trong transaction là đủ, không cần gọi save.