Tình huống xảy ra lỗi
Bạn gọi userRepository.save(user) và thấy dòng này trong log:
org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [users.UK_email]
nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement
Stack trace chôn vùi nguyên nhân thực sự dưới các lớp nội bộ của Hibernate. Hãy bỏ qua những thứ không cần thiết — phần có ý nghĩa là constraint [users.UK_email]. Đây là unique index trên cột email trong bảng users. Bạn đã cố gắng chèn hoặc cập nhật một hàng với địa chỉ email đã tồn tại.
Lỗi tương tự cũng xảy ra với mọi loại constraint: unique, not-null, foreign key, hoặc check constraint. Dù vi phạm gì, tên constraint trong ngoặc vuông sẽ cho bạn biết chính xác đó là cái nào.
Đọc thông báo lỗi đúng cách
Bước đầu tiên không phải là sửa mà là đọc tên constraint. Cú pháp luôn là constraint [table.constraint_name] hoặc đôi khi chỉ là constraint [constraint_name].
Các tên constraint phổ biến và ý nghĩa của chúng:
UK_emailhoặcusers_email_key— unique constraint trên cột emailFK_orders_user_id— vi phạm foreign key (hàng được tham chiếu không tồn tại)users_username_not_null— not-null constraint; bạn đã gửi null cho một trường bắt buộcCHK_age_positive— check constraint; giá trị không thỏa mãn điều kiện
Khi đã biết tên constraint, hãy tìm cột tương ứng trong entity hoặc DDL của bạn. Sau đó kiểm tra giá trị bạn đang thực sự cố gắng lưu vào.
Cách khắc phục nhanh: kiểm tra trước khi lưu
Đối với vi phạm unique constraint — trường hợp phổ biến nhất — hãy thêm một bước kiểm tra sự tồn tại trước khi gọi save():
// Trong repository của bạn
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email);
Optional<User> findByEmail(String email);
}
// Trong service của bạn
public User createUser(CreateUserRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException("Email already registered: " + request.getEmail());
}
return userRepository.save(new User(request));
}
Không còn exception từ database, không còn lỗi 500. Người dùng nhận được thông báo lỗi rõ ràng, dễ đọc.
Một lưu ý: hai request đến trong cùng một mili giây đều có thể vượt qua bước kiểm tra rồi tranh nhau chèn dữ liệu. Trên các luồng có tải cao, hãy bắt exception như một biện pháp dự phòng (xem bên dưới).
Bắt và xử lý exception đúng cách
Bọc lệnh gọi save và bắt DataIntegrityViolationException. Đừng dùng trực tiếp exception của Hibernate — Spring bọc nó lại có chủ đích để tách biệt code của bạn khỏi tầng ORM.
import org.springframework.dao.DataIntegrityViolationException;
public User createUser(CreateUserRequest request) {
try {
return userRepository.save(new User(request));
} catch (DataIntegrityViolationException ex) {
String message = ex.getMostSpecificCause().getMessage();
if (message != null && message.contains("UK_email")) {
throw new DuplicateEmailException("Email already registered: " + request.getEmail());
}
// Constraint khác — cho phép exception tiếp tục lan lên
throw ex;
}
}
Luôn dùng getMostSpecificCause().getMessage() để lấy thông báo lỗi SQL gốc, nơi chứa tên constraint. Thông báo của exception ở tầng trên có thể khác nhau giữa Hibernate 5 và 6 — đừng phân tích cái đó.
Giải pháp lâu dài: validate ở tầng ứng dụng
Hãy coi database constraint là lớp bảo vệ cuối cùng, không phải lớp đầu tiên. Annotation Bean Validation cho phép Spring bắt dữ liệu không hợp lệ trước khi câu SQL được chạy:
// Entity
@Entity
@Table(name = "users", uniqueConstraints = {
@UniqueConstraint(name = "UK_email", columnNames = "email")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
@NotBlank(message = "Email is required")
@Email(message = "Email format invalid")
private String email;
@Column(nullable = false)
@NotBlank(message = "Username is required")
private String username;
}
// DTO / request object
public class CreateUserRequest {
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 3, max = 50)
private String username;
}
// Controller — @Valid kích hoạt Bean Validation trước khi đến service
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.status(201).body(userService.createUser(request));
}
@Valid trên request body sẽ bắt email trống hoặc sai định dạng trước khi tầng service được gọi. Không cần truy vấn database. Không có DataIntegrityViolationException.
Global exception handler (cách xử lý sạch)
Việc bắt exception trong từng method của service rất lặp đi lặp lại. Một @ControllerAdvice xử lý vi phạm constraint ở một nơi duy nhất:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrity(
DataIntegrityViolationException ex) {
String cause = ex.getMostSpecificCause().getMessage();
String userMessage = "A database constraint was violated.";
if (cause != null && cause.contains("UK_email")) {
userMessage = "This email address is already registered.";
} else if (cause != null && cause.contains("FK_orders_user_id")) {
userMessage = "Referenced user does not exist.";
}
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(409, userMessage));
}
}
Trả về HTTP 409 Conflict, không phải 500. Lỗi 500 nghĩa là server của bạn gặp sự cố. Lỗi 409 nghĩa là client gửi dữ liệu xung đột với trạng thái hiện có — hoàn toàn khác nhau, cần phản hồi khác nhau.
Vi phạm foreign key
Tên constraint như FK_orders_user_id có nghĩa là bạn đang chèn một hàng tham chiếu đến bản ghi cha không tồn tại. Hãy kiểm tra bản ghi cha trước:
public Order createOrder(CreateOrderRequest request) {
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new EntityNotFoundException(
"User not found: " + request.getUserId()));
Order order = new Order(user, request.getItems());
return orderRepository.save(order);
}
Kiểm tra xem cách sửa có hoạt động không
- Đăng ký với email trùng lặp — bạn sẽ nhận được lỗi 409 với thông báo rõ ràng, không phải 500.
- Xem lại log —
DataIntegrityViolationExceptionkhông còn xuất hiện dưới dạng exception chưa được xử lý. - Bật
spring.jpa.show-sql=truevà xác nhận câu SQL được gửi đi khớp với những gì bạn mong đợi. - Viết integration test với database thực — H2 in-memory hoặc Testcontainers, không dùng mock — để kiểm tra hành vi của constraint từ đầu đến cuối:
@SpringBootTest
@Transactional
class UserServiceTest {
@Autowired UserService userService;
@Test
void createUser_duplicateEmail_throwsDuplicateEmailException() {
userService.createUser(new CreateUserRequest("alice@example.com", "alice"));
assertThrows(DuplicateEmailException.class, () ->
userService.createUser(new CreateUserRequest("alice@example.com", "alice2"))
);
}
}
Mẹo thêm
Đang migrate dữ liệu hàng loạt và liên tục gặp lỗi này? Hãy kiểm tra dữ liệu trùng lặp trước khi áp dụng bất kỳ unique constraint nào. Một câu query là đủ:
SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1;
Hãy dọn sạch dữ liệu trùng lặp trước. Nếu để Flyway hay Liquibase chạy migration trên dữ liệu chưa được làm sạch, chúng sẽ thất bại ngay khi khởi động với đúng exception này — và log sẽ không cho bạn thấy rõ lý do tại sao.
Khi bạn không muốn địa chỉ email hiển thị trong log lỗi, việc mã hóa hash là bước đáng làm thêm. Hash Generator của ToolCraft tạo hash SHA-256 ngay trên trình duyệt — không có dữ liệu nào rời khỏi máy bạn — rất tiện cho việc kiểm tra nhanh khi debug mà không làm lộ PII vào hệ thống tổng hợp log.

