1. Entity & Persistence Context
Entity là gì?
Entity là một Java Class đại diện cho một bảng (table) trong Database. Mỗi instance (đối tượng) của Class này tương ứng với một dòng (row) trong bảng.
- Khai báo bằng annotation
@Entity. - Bắt buộc phải có một trường làm Primary Key được đánh dấu bằng
@Id.
Persistence Context (Ngữ cảnh lưu trữ) là gì?
Persistence Context là một vùng nhớ đệm (First-level Cache / L1 Cache) do JPA EntityManager quản lý trong suốt một Transaction.
- Nhiệm vụ: Quản lý các Entity instance, theo dõi các thay đổi của chúng (
Dirty Checking), và đồng bộ dữ liệu xuống DB khi Transactioncommithoặc khi thực hiệnflush(). - Phạm vi (Scope): Mặc định gắn liền với một
EntityManager/ Một Transaction (Transaction-scoped).
2. Entity States
Trong vòng đời của mình, một Entity có thể nằm ở 1 trong 4 trạng thái sau đối với Persistence Context:

2.1. Transient (Tạm thời)
- Đặc điểm: Entity vừa được tạo bằng từ khóa
newtrong Java, chưa có@Id(hoặc id chưa gắn với DB) và chưa được quản lý bởi Persistence Context. - Database: Chưa có bản ghi tương ứng dưới DB.
- Ví dụ:
User user = new User("Alice");
2.2. Persistent (Đang được quản lý)
- Đặc điểm: Entity đang được Persistence Context quản lý và có định danh ID hợp lệ dưới DB.
- Đặc tính Dirty Checking: Bất kỳ thay đổi nào trên các setter của Entity này sẽ tự động được update xuống DB khi Transaction commit mà
không cầngọi repository.save() hay merge(). - Ví dụ: Lấy ra từ DB bằng repository.findById(1L) hoặc sau khi gọi entityManager.persist(user).
2.3. Detached (Tách rời)
- Đặc điểm: Entity đã từng ở trạng thái Persistent (có ID trong DB), nhưng hiện tại không còn nằm trong/được quản lý bởi Persistence Context nữa (do Transaction đã kết thúc, hoặc do gọi
clear(), detach()). - Hệ quả: Các thay đổi trên đối tượng này sẽ không tự động cập nhật xuống DB. Muốn cập nhật phải đưa nó quay lại trạng thái Persistent bằng
entityManager.merge().
2.4. Removed (Đã xóa)
- Đặc điểm: Entity đang được Persistence Context quản lý nhưng đã được đánh dấu để xóa (bằng cách gọi
repository.delete()hoặcentityManager.remove()). - Database: Bản ghi vẫn còn trong DB cho tới khi Transaction thực hiện
flush()hoặccommit().
3. persist() vs save(), merge() vs saveOrUpdate()
persist()
- JPA Standard,
jakarta.persistence.EntityManager(JPA API). - void (không giá trị trả về).
- ❌ Quăng lỗi
PersistentObjectExceptionnếu truyền vào một Detached Entity. - Thời điểm thực thi SQL Chỉ đưa Entity vào Persistence Context. Lệnh
INSERTsẽ hoãn lại cho tới khiflush()hoặccommit().
save()
org.hibernate.SessionvàCrudRepository(Hibernate Native / Spring Data)- Trả về Entity instance (có thể là một instance khác).
- Deteched entity: ✅ Thực hiện câu lệnh UPDATE (hoặc INSERT một bản ghi mới tùy thuộc thiết lập ID).
- Thời điểm thực thi SQL: Sinh ra ID và có thể thực thi lệnh INSERT ngay lập tức để lấy ID trả về.
Tìm hiểu sâu vào code thì Spring Data JPA CrudRepository.save() gồm: persist() và merge()
// Đoạn code mã nguồn đơn giản hóa của SimpleJpaRepository trong Spring Data JPA
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
// Nếu là Entity mới -> Gọi persist() chuẩn JPA
entityManager.persist(entity);
return entity;
} else {
// Nếu là Entity cũ/Detached -> Gọi merge() chuẩn JPA
return entityManager.merge(entity);
}
}
So sánh tương tự merge() vs saveOrUpdate().
merge()(JPA Standard): Copy dữ liệu từ Detached Entity sang một Persistent Instance mới và trả về Instance mới đó. Entity truyền vào ban đầu vẫn giữ nguyên trạng thái Detached. Do copy nên trùng ID trong Persistence Context sẽ UPDATE.saveOrUpdate()(Hibernate Native): Chuyển chính Entity truyền vào thành Persistent Instance.- Tính an toàn:
merge()an toàn hơn vì giải quyết được xung đột khi đã có một Object cùng ID nằm sẵn trong Session, tránh bị lỗiNonUniqueObjectExceptionmàsaveOrUpdate()thường gặp.
4. Lazy Loading vs. Eager Loading
Đây là hai chiến lược tải dữ liệu quan hệ (Relationships: @OneToMany, @ManyToOne, v.v.).

@Entity
public class User {
@Id
private Long id;
// Default là LAZY
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders;
}
- Khi gọi User user = userRepository.findById(1L).get();
- Hibernate chỉ chạy 1 câu SQL: SELECT * FROM users WHERE id = 1;
- Trường orders lúc này chỉ chứa một Hibernate Proxy.
- Khi gọi user.getOrders().size();
- Hibernate mới kích hoạt câu SQL thứ 2: SELECT * FROM orders WHERE user_id = 1; để lấy danh sách đơn hàng.
- Tuy nhiên, việc query bổ sung này bắt buộc phải diễn ra bên trong một Persistence Context / Transaction còn sống.
4.1. Nguyên nhân gây lỗi: LazyInitializationException
// 1. Service Layer
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
// Không có @Transactional hoặc Transaction kết thúc ngay khi thoát hàm
public User getUser(Long id) {
return userRepository.findById(id).orElseThrow();
// Session đóng ngay tại đây sau khi tìm xong User!
}
}
// 2. Controller Layer
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
User user = userService.getUser(id);
// 💥 BÁO LỖI NGAY TẠI ĐÂY!
// Session đã đóng ở Service, nhưng Controller cố gọi getter của LAZY collection
int totalOrders = user.getOrders().size();
return new UserResponse(user.getName(), totalOrders);
}
}
Thông điệp lỗi điển hình:
org.hibernate.LazyInitializationException: could not initialize proxy [com.example.User#1] - no Session
4.2. Các Pattern chuẩn để giải quyết (Best Practices)
Để giải quyết triệt để, giải pháp chuẩn nhất là Fetch dữ liệu quan hệ ngay từ câu truy vấn ban đầu ở tầng Repository khi biết chắc chắn sẽ cần dùng tới dữ liệu đó.
Pattern 1: Dùng JOIN FETCH trong @Query (Phổ biến & Linh hoạt)
Cho phép bạn override chiến lược LAZY của field bằng cách chỉ định load kèm dữ liệu ngay trong câu JPQL.
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
Optional<User> findByIdWithOrders(@Param("id") Long id);
}

Ưu điểm: Chủ động, chính xác, Hibernate sẽ sinh ra 1 câu INNER JOIN hoặc LEFT JOIN để lấy cả User lẫn Order trong 1 query duy nhất.
Notes:
- ❌ Không dùng cho DTO Projection (vì FETCH chỉ đi kèm với Entity).
- ❌ Không dùng FETCH JOIN với Pagination (
Pageable,PageRequest,setFirstResult,setMaxResults) trên quan hệ 1-N (@OneToMany), Spring Data JPA/Hibernate vẫn cho phép chạy, nhưng đây là một cạm bẫy hiệu năng vô cùng nguy hiểm.
Pattern 2: Dùng @EntityGraph của Spring Data JPA
Đây là giải pháp khai báo (declarative) giúp tránh phải viết thủ công các câu JPQL dài.
public interface UserRepository extends JpaRepository<User, Long> {
// Tự động JOIN FETCH thuộc tính 'orders' khi query
@EntityGraph(attributePaths = {"orders"})
Optional<User> findById(Long id);
}
Ưu điểm: Code gọn gàng, tái sử dụng tốt với các hàm CRUD mặc định của Spring Data JPA.
Pattern 3: Dùng DTO Projection (Tốt nhất cho Read-Only APIs)
Thay vì trả về toàn bộ Managed Entity rồi bị vướng vào các mối quan hệ LAZY/EAGER, bạn query thẳng dữ liệu ra DTO ngay ở DB level.
// 1. Định nghĩa DTO Projection
public record UserOrderSummaryDto(String userName, int orderCount) {}
// 2. Repository query trực tiếp ra DTO
public interface UserRepository extends JpaRepository<User, Long> {
@Query("""
SELECT new com.example.UserOrderSummaryDto(u.name, SIZE(u.orders))
FROM User u WHERE u.id = :id
""")
Optional<UserOrderSummaryDto> getUserSummary(@Param("id") Long id);
}
Ưu điểm: Tối ưu hiệu năng vượt trội, không tốn tài nguyên quản lý Entity State trong Persistence Context, triệt tiêu 100% rủi ro LazyInitializationException.
Pattern 4: Mở rộng Transaction Boundary với @Transactional
Nếu logic xử lý thực sự nằm trong Service, hãy đảm bảo hàm gọi getter của Lazy Collection nằm bên trong phạm vi @Transactional.
@Service
public class UserService {
@Transactional(readOnly = true) // Giữ Session mở trong suốt hàm này
public UserDto getUserWithOrders(Long id) {
User user = userRepository.findById(id).orElseThrow();
// Hoạt động an toàn vì Session vẫn đang OPEN
List<OrderDto> orderDtos = user.getOrders().stream()
.map(OrderDto::fromEntity)
.toList();
return new UserDto(user.getName(), orderDtos);
}
}
4.3. Warning
- ❌ Đổi sang
FetchType.EAGERhàng loạt:- Tác hại: Tải dữ liệu thừa không cần thiết, làm chậm toàn bộ các API khác truy cập vào Entity đó.
5. Architectural Comparison: L1 vs. L2 Cache
Persistence Context (L1 Cache) operates at the EntityManager / Session level (bound to a single transaction), the L2 (Second-Level) Cache operates at the SessionFactory / Application level.
While L1 Cache is enabled by default and short-lived, L2 Cache is optional, shared across all sessions, and survives beyond the scope of a single transaction.


L1 Cache is transaction-scoped and mandatory, whereas L2 Cache is application-scoped, optional, and shared across all sessions. L2 cache prevents redundant database hits across different user requests for read-heavy, rarely-changing data. To use it, we configure a provider (like Ehcache or Redis), mark entities with @Cacheable, and choose an appropriate CacheConcurrencyStrategy (like READ_WRITE or READ_ONLY).