Bài 31 đã đưa schema của catalogue vào Flyway migration với spring.jpa.hibernate.ddl-auto=validate. Một table catalogue còn phải trả lời hai câu hỏi mà ticket hỗ trợ nào cũng bắt đầu bằng: row này được tạo hoặc sửa lần cuối lúc nào, và bởi ai. Bốn column giữ câu trả lời, created_at, updated_at, created_by và updated_by, và Spring Data JPA có thể tự điền chúng ở mỗi INSERT và UPDATE thông qua một entity listener, không cần dòng code nào trong service.
Bài này thêm bốn column đó vào Product của bài 26, rồi chạy thử từng khẳng định thường đi kèm tính năng: kiểu timestamp nào dùng được, mỗi field đổi khi nào, modifyOnCreate thay đổi gì, một detached entity xóa mất created_at ra sao, tên người dùng lấy từ đâu khi chưa có Spring Security, và statement nào không bao giờ đi qua listener. Các ví dụ dùng Spring Boot 4.1.1 và Java 21 với PostgreSQL 18 chạy trong Docker, trên một project Initializr có các dependency web, validation, data-jpa, h2, postgresql và flyway. Ứng dụng chạy ở port 8132 thay vì 8080 mặc định. JVM chạy với time zone Asia/Ho_Chi_Minh (UTC+7), còn các session psql dùng time zone mặc định của PostgreSQL là Etc/UTC; chi tiết này quan trọng ở phần nói về kiểu dữ liệu.
![]()
Nửa đầu cấu hình auditing và theo dõi từng field thay đổi; nửa sau đi qua các cái bẫy, các lựa chọn thay thế và API. Các lần chạy lab chỉ in tên ngắn của logger và nội dung message (logging.pattern.console=%logger{0}: %msg%n); các lần chạy HTTP dùng log pattern mặc định của Boot.
Vì sao cần column audit, và điều gì hỏng khi service tự gán
Column audit trả lời những câu hỏi vận hành mà dữ liệu nghiệp vụ không trả lời được: giá đổi lần cuối lúc nào, một lần import đã chạm vào những row nào, ai đã tạo ra một sản phẩm lẽ ra không tồn tại. Chúng chỉ giữ lần thay đổi gần nhất, không phải lịch sử; nhật ký thay đổi đầy đủ là một công cụ khác, Hibernate Envers, thuộc khóa Advanced.
Cách trực tiếp nhất là gán các field trong service. Một Product rút gọn với hai field Instant có setter, và một service chỉ nhớ gán chúng ở hai trong ba method:
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
@Transactional
public Product create(Product product) {
Instant now = Instant.now();
product.setCreatedAt(now);
product.setUpdatedAt(now);
return repository.save(product);
}
@Transactional
public Product changePrice(Long id, BigDecimal price) {
Product product = repository.findById(id).orElseThrow();
product.setPrice(price);
product.setUpdatedAt(Instant.now());
return product;
}
@Transactional
public Product restock(Long id, int quantity) {
Product product = repository.findById(id).orElseThrow();
product.setStock(product.getStock() + quantity);
return product;
}
}Một runner gọi lần lượt ba method, nghỉ một chút giữa mỗi lần. Trên PostgreSQL, đã bỏ các dòng SELECT và bind:
ProbeRunner: -- create
SQL: insert into products (created_at,name,price,sku,stock,updated_at) values (?,?,?,?,?,?)
ProbeRunner: Product[id=1, sku=KB-01, price=89.90, stock=25, createdAt=2026-09-13T10:41:47.101087Z, updatedAt=2026-09-13T10:41:47.101087Z]
ProbeRunner: -- changePrice
SQL: update products set created_at=?,name=?,price=?,sku=?,stock=?,updated_at=? where id=?
ProbeRunner: Product[id=1, sku=KB-01, price=84.90, stock=25, createdAt=2026-09-13T10:41:47.101087Z, updatedAt=2026-09-13T10:41:48.663469Z]
ProbeRunner: -- restock
SQL: update products set created_at=?,name=?,price=?,sku=?,stock=?,updated_at=? where id=?
ProbeRunner: Product[id=1, sku=KB-01, price=84.90, stock=35, createdAt=2026-09-13T10:41:47.101087Z, updatedAt=2026-09-13T10:41:48.663469Z] id | sku | price | stock | created_at | updated_at
----+-------+-------+-------+-------------------------------+-------------------------------
1 | KB-01 | 84.90 | 35 | 2026-09-13 10:41:47.101087+00 | 2026-09-13 10:41:48.663469+00
(1 row)Stock đã tăng từ 25 lên 35, nhưng updated_at vẫn khẳng định không có gì đổi kể từ 10:41:48. Không có lỗi, không có cảnh báo; method thứ ba chỉ đơn giản là được viết thiếu một dòng. Mỗi đường ghi dữ liệu mới, một lần import, một scheduled job, một service thứ hai, đều phải nhớ gán cả hai field, và không có gì kiểm tra việc đó.
Cấu hình JPA auditing trong Spring Boot
Không cần thêm dependency. @CreatedDate, @LastModifiedDate, @CreatedBy và @LastModifiedBy nằm trong org.springframework.data.annotation của spring-data-commons 4.1.1, còn @EnableJpaAuditing và AuditingEntityListener nằm trong spring-data-jpa 4.1.1, cả hai đã có sẵn trên classpath nhờ spring-boot-starter-data-jpa. Cần ba thứ: bật auditing, gắn listener vào entity, và đánh annotation cho các field.
@EnableJpaAuditing trong một class cấu hình
package com.example.demo.common;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
class AuditingConfig {
}@EnableJpaAuditing đăng ký một bean AuditingHandler tên là jpaAuditingHandler cùng bean definition của AuditingEntityListener nhận bean đó. Bốn attribute của nó, đọc bằng javap từ jar 4.1.1, là auditorAwareRef, setDates, modifyOnCreate và dateTimeProviderRef; các phần sau dùng ba attribute cuối. Class này nằm trong common, nơi các bean nó cần sẽ được thêm vào sau, thay vì đặt annotation lên DemoApplication.
AuditableEntity: @MappedSuperclass kèm @EntityListeners
package com.example.demo.common;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
@CreatedDate
@Column(nullable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
@CreatedBy
@Column(length = 64)
private String createdBy;
@LastModifiedBy
@Column(length = 64)
private String updatedBy;
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public String getCreatedBy() { return createdBy; }
public String getUpdatedBy() { return updatedBy; }
}@MappedSuperclassmap các field này vào table của mọi entity kế thừa class. Bản thân nó không có table riêng.@EntityListeners(AuditingEntityListener.class)khiến JPA gọi listener quanh mỗi lần ghi. Trong jar 4.1.1, methodtouchForCreatecủa nó mang@PrePersistvàtouchForUpdatemang@PreUpdate.- Bốn annotation đánh dấu những gì cần điền: hai timestamp và hai tên người dùng. Các field có getter mà không có setter, vì không code nào của ứng dụng nên ghi vào chúng.
@Columnkhông đặt tên nào:createdAtthànhcreated_atnhờ naming strategy của Boot, như SQL bên dưới cho thấy. Vớiddl-auto=validatecác attribute không sinh DDL; chúng ghi lại những gì migration tạo ra.
Product kế thừa class này và in thêm các field mới:
package com.example.demo.product;
import java.math.BigDecimal;
import com.example.demo.common.AuditableEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
public class Product extends AuditableEntity {
// fields, constructors, getters, setters, equals and hashCode as in article 26
@Override
public String toString() {
return "Product[id=" + id + ", sku=" + sku + ", price=" + price + ", stock=" + stock + ", status=" + status + "]";
return "Product[id=" + id + ", sku=" + sku + ", price=" + price + ", stock=" + stock
+ ", createdAt=" + getCreatedAt() + ", updatedAt=" + getUpdatedAt()
+ ", createdBy=" + getCreatedBy() + ", updatedBy=" + getUpdatedBy() + "]";
}
}Project còn có một bean AuditorAware, nguồn của created_by, được trình bày ở phần riêng bên dưới. Bên ngoài một web request nó không trả về gì, nên các lần chạy lab để trống cả hai column người dùng.
Flyway migration cho một table đã có dữ liệu
Entity giờ map bốn column mà table chưa có, và validate không cho ứng dụng khởi động cho đến khi chúng tồn tại. Profile postgres:
spring.datasource.url=jdbc:postgresql://localhost:55432/catalog
spring.datasource.username=catalog
spring.datasource.password=catalog
spring.jpa.hibernate.ddl-auto=validateTrong project của bài này, products được tạo bởi V1__create_products_table.sql và đã có ba row:
id | sku | price | stock
----+-------+-------+-------
1 | KB-01 | 89.90 | 25
2 | KB-02 | 59.00 | 12
3 | MS-01 | 24.50 | 3
(3 rows)Migration lấy version trống kế tiếp: V2 trong project của bài này, và V6__add_audit_columns_to_products.sql trong project của series, sau V5__add_product_description.sql của bài 31; table products ở project đó đã có column title của bài 31 thay cho name, còn các column audit không đụng tới nó. Lần thử đầu tiên, cách viết hiển nhiên nhất:
alter table products
add column created_at timestamp with time zone not null,
add column updated_at timestamp with time zone not null,
add column created_by varchar(64),
add column updated_by varchar(64);./gradlew bootJar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --spring.main.web-application-type=none2026-09-13T17:33:59.179+07:00 INFO 43166 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Current version of schema "public": 1
2026-09-13T17:33:59.184+07:00 INFO 43166 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Migrating schema "public" to version "2 - add audit columns to products"
2026-09-13T17:33:59.193+07:00 ERROR 43166 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Migration of schema "public" to version "2 - add audit columns to products" failed! Changes successfully rolled back.
...
Caused by: org.flywaydb.core.internal.exception.FlywayMigrateException: Failed to execute script V2__add_audit_columns_to_products.sql
SQL State : 23502
Error Code : 0
Message : ERROR: column "created_at" of relation "products" contains null values
Location : db/migration/V2__add_audit_columns_to_products.sql (...)
Line : 1
Statement : Run Flyway with -X option to see the actual statement causing the problemPostgreSQL phải cho ba row có sẵn một giá trị ở column mới, và một column NOT NULL không có default thì không có giá trị nào để cho. SQL State 23502 là lỗi vi phạm not-null. PostgreSQL chạy DDL bên trong transaction, nên "Changes successfully rolled back" đúng theo nghĩa đen: table không đổi, và bảng lịch sử vẫn chỉ có V1.
docker exec sb-a32-pg psql -U catalog -d catalog -c "select installed_rank, version, description, success from flyway_schema_history" installed_rank | version | description | success
----------------+---------+-----------------------+---------
1 | 1 | create products table | t
(1 row)Một default sẽ điền cho các row có sẵn ngay lúc thêm column:
alter table products
add column created_at timestamp with time zone not null,
add column updated_at timestamp with time zone not null,
add column created_at timestamp with time zone not null default now(),
add column updated_at timestamp with time zone not null default now(),
add column created_by varchar(64),
add column updated_by varchar(64);Cùng lệnh đó, sau khi build lại:
2026-09-13T17:35:23.158+07:00 INFO 43428 --- [demo] [ main] o.f.core.internal.command.DbValidate : Successfully validated 2 migrations (execution time 00:00.011s)
2026-09-13T17:35:23.185+07:00 INFO 43428 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Current version of schema "public": 1
2026-09-13T17:35:23.191+07:00 INFO 43428 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Migrating schema "public" to version "2 - add audit columns to products"
2026-09-13T17:35:23.208+07:00 INFO 43428 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema "public", now at version v2 (execution time 00:00.006s)
2026-09-13T17:35:24.240+07:00 INFO 43428 --- [demo] [ main] com.example.demo.DemoApplication : Started DemoApplication in 1.827 seconds (process running for 2.035)Started DemoApplication nghĩa là bước kiểm tra schema của Hibernate đã qua: timestamp with time zone được chấp nhận cho field Instant và varchar(64) cho String. Table và các row của nó:
docker exec sb-a32-pg psql -U catalog -d catalog -c "\d products" -c "select id, sku, created_at, updated_at, created_by, updated_by from products order by id" Table "public.products"
Column | Type | Collation | Nullable | Default
------------+--------------------------+-----------+----------+----------------------------------
id | bigint | | not null | generated by default as identity
name | character varying(120) | | not null |
sku | character varying(40) | | not null |
price | numeric(10,2) | | not null |
stock | integer | | not null |
category | character varying(60) | | not null |
status | character varying(20) | | not null |
created_at | timestamp with time zone | | not null | now()
updated_at | timestamp with time zone | | not null | now()
created_by | character varying(64) | | |
updated_by | character varying(64) | | |
Indexes:
"products_pkey" PRIMARY KEY, btree (id)
"products_sku_key" UNIQUE CONSTRAINT, btree (sku)
id | sku | created_at | updated_at | created_by | updated_by
----+-------+-------------------------------+-------------------------------+------------+------------
1 | KB-01 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
2 | KB-02 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
3 | MS-01 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
(3 rows)Cả ba row nhận cùng một giá trị ở cả hai column: now() trả về thời điểm bắt đầu của transaction hiện tại, và toàn bộ migration chạy trong một transaction. Với các row cũ, giá trị đó là thời điểm chạy migration chứ không phải thời điểm tạo thật, vốn chưa ai ghi lại. Các column người dùng vẫn cho phép null, vì row cũ không có tác giả và không phải lần ghi nào cũng đến từ một request. Default vẫn được giữ trên các column timestamp; nó chỉ có tác dụng khi một INSERT bỏ qua column đó, mà Hibernate thì không bao giờ bỏ qua, như phần tiếp theo cho thấy.
INSERT đầu tiên và listener chạy ở đâu
Các lần chạy lab dùng một ApplicationRunner nằm sau profile lab, khởi động không có web server, chạy các bước được liệt kê trong --lab.steps, cùng một bean chứa các bước cần transaction:
package com.example.demo.lab;
import java.math.BigDecimal;
import java.time.ZoneId;
import java.util.List;
import com.example.demo.product.CreateProductRequest;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import com.example.demo.product.ProductService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("lab")
class AuditLab implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(AuditLab.class);
private final ProductRepository repository;
private final ProductService service;
private final LabTransactions tx;
private final List<String> steps;
AuditLab(ProductRepository repository, ProductService service, LabTransactions tx,
@Value("${lab.steps}") List<String> steps) {
this.repository = repository;
this.service = service;
this.tx = tx;
this.steps = steps;
}
@Override
public void run(ApplicationArguments args) {
log.info("JVM time zone: {}", ZoneId.systemDefault());
for (String step : steps) {
log.info("-- {}", step);
try {
runStep(step);
} catch (RuntimeException e) {
for (Throwable t = e; t != null; t = t.getCause()) {
log.info("!!! {}: {}", t.getClass().getName(), t.getMessage());
}
}
}
}
private void runStep(String step) {
switch (step) {
case "create" -> {
Product saved = repository.save(new Product("USB-C hub", "HUB-01", new BigDecimal("39.00"), 10, "Accessories"));
log.info("returned {}", saved);
}
case "create2" -> {
Product saved = repository.save(new Product("Laptop stand", "AC-02", new BigDecimal("34.90"), 15, "Accessories"));
log.info("returned {}", saved);
}
case "update" -> log.info("returned {}", tx.changePrice("KB-01", "84.90"));
case "noop" -> log.info("returned {}", tx.readOnly("KB-01"));
case "saveall" -> {
List<Product> keyboards = repository.findAll().stream()
.filter(p -> p.getCategory().equals("Keyboards")).toList();
keyboards.forEach(p -> p.setStock(p.getStock() + 1));
repository.saveAll(keyboards).forEach(p -> log.info("returned {}", p));
}
case "bulk" -> log.info("changePrices -> {} rows", service.changePrices("Keyboards", new BigDecimal("1.10")));
case "native" -> log.info("restock -> {} rows", service.restock("MS-01", 5));
case "replace" -> {
Long id = repository.findBySku("HUB-01").orElseThrow().getId();
log.info("returned {}", service.replace(id, new CreateProductRequest("USB-C hub, 7 ports", "HUB-01",
new BigDecimal("42.00"), 8, "Accessories")));
}
default -> throw new IllegalArgumentException("unknown step " + step);
}
}
}package com.example.demo.lab;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@Profile("lab")
public class LabTransactions {
private static final Logger log = LoggerFactory.getLogger(LabTransactions.class);
private final ProductRepository repository;
LabTransactions(ProductRepository repository) {
this.repository = repository;
}
@Transactional
public Product changePrice(String sku, String price) {
Product product = repository.findBySku(sku).orElseThrow();
product.setPrice(new BigDecimal(price));
log.info("price set to {}, save() not called, leaving the method", price);
return product;
}
@Transactional
public Product readOnly(String sku) {
Product product = repository.findBySku(sku).orElseThrow();
log.info("loaded {}, nothing changed, leaving the method", product.getSku());
return product;
}
}Để thấy ai gọi listener, getCurrentAuditor được thêm tạm một dòng đầu tiên, new Exception("getCurrentAuditor called from").printStackTrace(System.out), bật bằng một system property:
java -Dlab.trace=true -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres,lab --spring.main.web-application-type=none --lab.steps=createTrên PostgreSQL, stack trace đã được cắt còn các frame quan trọng:
AuditLab: JVM time zone: Asia/Ho_Chi_Minh
AuditLab: -- create
java.lang.Exception: getCurrentAuditor called from
at com.example.demo.common.RequestHeaderAuditorAware.getCurrentAuditor(RequestHeaderAuditorAware.java:17)
at org.springframework.data.auditing.AuditingHandler.getAuditor(AuditingHandler.java:103)
at org.springframework.data.auditing.AuditingHandler.markCreated(AuditingHandler.java:86)
at org.springframework.data.jpa.domain.support.AuditingEntityListener.touchForCreate(AuditingEntityListener.java:93)
at org.hibernate.jpa.event.internal.ListenerCallback.performCallback(ListenerCallback.java:56)
at org.hibernate.jpa.event.internal.CallbackRegistryImpl.callback(CallbackRegistryImpl.java:117)
at org.hibernate.jpa.event.internal.CallbackRegistryImpl.preCreate(CallbackRegistryImpl.java:66)
at org.hibernate.event.internal.AbstractSaveEventListener.performSave(AbstractSaveEventListener.java:206)
...
at org.hibernate.internal.SessionImpl.persist(SessionImpl.java:692)
...
at org.springframework.data.jpa.repository.support.SimpleJpaRepository.save(SimpleJpaRepository.java:664)
...
at com.example.demo.lab.AuditLab.runStep(AuditLab.java:57)
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:36:02.071310Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [USB-C hub]
bind: binding parameter (5:NUMERIC) <- [39.00]
bind: binding parameter (6:VARCHAR) <- [HUB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [10]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:02.071310Z]
bind: binding parameter (10:VARCHAR) <- [null]
AuditLab: returned Product[id=4, sku=HUB-01, price=39.00, stock=10, createdAt=2026-09-13T10:36:02.071310Z, updatedAt=2026-09-13T10:36:02.071310Z, createdBy=null, updatedBy=null]Đọc từ dưới lên: save trên một entity mới gọi SessionImpl.persist, Hibernate chạy các callback @PrePersist của entity qua CallbackRegistryImpl.preCreate, method này gọi AuditingEntityListener.touchForCreate, và nó gọi tiếp AuditingHandler.markCreated. Handler hỏi AuditorAware về người dùng trước, rồi mới hỏi DateTimeProvider của nó về thời gian: trong bytecode của AuditingHandlerSupport, touchAuditor chạy trước touchDate, và touchDate gọi getNow() đúng một lần rồi gán cả hai timestamp từ giá trị đó. Chỉ sau khi các field đã được gán, Hibernate mới dựng câu INSERT, nên statement đã mang sẵn chúng: cùng giá trị 2026-09-13T10:36:02.071310Z ở created_at và updated_at, và null ở cả hai column người dùng. Mọi column audit đều có trong danh sách column, vì vậy default now() của migration không bao giờ áp dụng cho các row do ứng dụng ghi.

Chuyện gì xảy ra khi thiếu @EnableJpaAuditing hoặc entity listener?
Chạy thêm hai lần bước create2, một chiếc giá đỡ laptop có SKU AC-02: một lần đã xóa dòng @EnableJpaAuditing khỏi AuditingConfig, một lần đã xóa dòng @EntityListeners khỏi AuditableEntity. Cả hai ứng dụng đều khởi động mà log không có cảnh báo nào, và lần chạy đầu thất bại ở câu INSERT:
AuditLab: -- create2
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [null]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Laptop stand]
bind: binding parameter (5:NUMERIC) <- [34.90]
bind: binding parameter (6:VARCHAR) <- [AC-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [15]
bind: binding parameter (9:TIMESTAMP_UTC) <- [null]
bind: binding parameter (10:VARCHAR) <- [null]
error: HHH000247: ErrorCode: 0, SQLState: 23502
error: ERROR: null value in column "created_at" of relation "products" violates not-null constraint
Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).
AuditLab: !!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: null value in column "created_at" of relation "products" violates not-null constraint
Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).] [insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)]; SQL [insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)]; constraint [created_at]
AuditLab: !!! org.hibernate.exception.ConstraintViolationException: could not execute statement [ERROR: null value in column "created_at" of relation "products" violates not-null constraint
Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).] [insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)]
AuditLab: !!! org.postgresql.util.PSQLException: ERROR: null value in column "created_at" of relation "products" violates not-null constraint
Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).Lần chạy không có @EntityListeners in ra đúng các giá trị bind đó và đúng ba exception đó, chỉ khác row lỗi mang id 7. Không lỗi nào tự báo ra. Thiếu @EnableJpaAuditing thì không có handler; thiếu listener thì Hibernate không bao giờ gọi handler; trường hợp nào bốn field cũng giữ null và Hibernate bind bốn giá trị null. Chính constraint NOT NULL trên created_at đã chặn statement, và default now() của column không giúp được gì vì câu INSERT gửi null một cách tường minh. Với các column không có constraint, bốn giá trị null đó là thứ sẽ được lưu. Hãy giữ NOT NULL trên các timestamp: nó biến một cấu hình thiếu thành lỗi ngay ở lần ghi đầu tiên, thay vì những row không có ngày tháng.
Instant, LocalDateTime hay OffsetDateTime cho @CreatedDate?
Câu hỏi về kiểu cần ba entity chỉ khác nhau ở kiểu của hai field, nên nó được chạy trong một project probe riêng, trên database tên probes, với ddl-auto=create. Cấu hình probe chọn DateTimeProvider theo một property; default trả về CurrentDateTimeProvider.INSTANCE, chính provider mà AuditingHandler dùng khi không cấu hình gì:
package com.example.demo.probe;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.CurrentDateTimeProvider;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing(dateTimeProviderRef = "labDateTimeProvider")
class ProbeConfig {
@Bean
DateTimeProvider labDateTimeProvider(@Value("${lab.provider:default}") String mode) {
return switch (mode) {
case "default" -> CurrentDateTimeProvider.INSTANCE;
case "instant" -> () -> Optional.of(Instant.now());
case "offset" -> () -> Optional.of(OffsetDateTime.now());
default -> throw new IllegalArgumentException(mode);
};
}
} @Entity
@Table(name = "instant_probe")
@EntityListeners(AuditingEntityListener.class)
public static class InstantProbe implements Stamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
@CreatedDate private Instant createdAt;
@LastModifiedDate private Instant updatedAt;
// getId, setId, setName and toString
}LocalDateTimeProbe và OffsetDateTimeProbe giống hệt, chỉ khác kiểu. DDL mà Hibernate sinh cho chúng trên PostgreSQL:
SQL: create table instant_probe (created_at timestamp(6) with time zone, id bigint generated by default as identity, updated_at timestamp(6) with time zone, name varchar(255), primary key (id))
SQL: create table local_date_time_probe (created_at timestamp(6), id bigint generated by default as identity, updated_at timestamp(6), name varchar(255), primary key (id))
SQL: create table offset_date_time_probe (created_at timestamp(6) with time zone, id bigint generated by default as identity, updated_at timestamp(6) with time zone, name varchar(255), primary key (id))Mỗi probe được persist, rồi một giây sau được load và đổi tên trong transaction thứ hai. Với provider mặc định:
ProbeRunner: -- persist InstantProbe
ProbeRunner: InstantProbe[id=1, name=null, createdAt=2026-09-13T10:41:50.204205Z, updatedAt=2026-09-13T10:41:50.204205Z]
ProbeRunner: -- rename InstantProbe
ProbeRunner: InstantProbe[id=1, name=renamed, createdAt=2026-09-13T10:41:50.204205Z, updatedAt=2026-09-13T10:41:51.423762Z]
ProbeRunner: -- persist LocalDateTimeProbe
ProbeRunner: LocalDateTimeProbe[id=1, name=null, createdAt=2026-09-13T17:41:51.431023, updatedAt=2026-09-13T17:41:51.431023]
ProbeRunner: -- rename LocalDateTimeProbe
ProbeRunner: LocalDateTimeProbe[id=1, name=renamed, createdAt=2026-09-13T17:41:51.431023, updatedAt=2026-09-13T17:41:52.647257]
ProbeRunner: -- persist OffsetDateTimeProbe
ProbeRunner: !!! java.lang.IllegalArgumentException: Cannot convert unsupported date type java.time.LocalDateTime to java.time.OffsetDateTime; Supported types are [java.time.LocalDateTime, java.time.LocalDate, java.time.LocalTime, java.time.Instant, java.util.Date, java.lang.Long, long]CurrentDateTimeProvider.getNow() trả về LocalDateTime.now(), như bytecode của nó cho thấy. Handler chuyển giá trị đó sang kiểu của field bằng một ConversionService dựng từ DefaultFormattingConversionService của Spring cộng với Jsr310Converters của Spring Data. Instant và LocalDateTime có trong danh sách được hỗ trợ; OffsetDateTime thì không, và lệnh save thất bại trước khi có SQL nào được gửi đi.
PostgreSQL lưu gì trong timestamp with time zone và timestamp
docker exec sb-a32-pg psql -U catalog -d probes -c "show timezone" -c "select * from instant_probe" -c "select * from local_date_time_probe" TimeZone
----------
Etc/UTC
(1 row)
created_at | id | updated_at | name
-------------------------------+----+-------------------------------+---------
2026-09-13 10:41:50.204205+00 | 1 | 2026-09-13 10:41:51.423762+00 | renamed
(1 row)
created_at | id | updated_at | name
----------------------------+----+----------------------------+---------
2026-09-13 17:41:51.431023 | 1 | 2026-09-13 17:41:52.647257 | renamed
(1 row)Instanttrongtimestamp with time zonelà một thời điểm tuyệt đối. JVM chạy ở UTC+7,psqlchạy ở UTC, và giá trị đọc ra là10:41:50+00, đúng thời điểm row được ghi, dù ai đọc.LocalDateTimetrongtimestampgiữ giờ đồng hồ của JVM,17:41:51, không kèm time zone. Việc chuyển từLocalDateTime.now()đã dùng time zone của JVM; row không ghi lại điều đó, nên người đọc nào cũng phải biết nơi ghi chạy ở time zone nào.- Session JDBC không phải session
psql. Một lỗi PostgreSQL in ra ở phần sau của bài hiển thị một giá trịtimestamp with time zonelà2026-09-13 17:37:28.930979+07, tức time zone của JVM, trong khipsqlhiển thị cùng loại giá trị ở+00. Thời điểm được lưu là một; chỉ cách hiển thị khác.
OffsetDateTime và DateTimeProvider
Với một provider trả về Instant.now(), Instant và LocalDateTime lại chạy được, còn probe thứ ba thất bại với một kiểu nguồn khác:
ProbeRunner: !!! java.lang.IllegalArgumentException: Cannot convert unsupported date type java.time.Instant to java.time.OffsetDateTime; Supported types are [java.time.LocalDateTime, java.time.LocalDate, java.time.LocalTime, java.time.Instant, java.util.Date, java.lang.Long, long]Chỉ khi provider trả về OffsetDateTime.now() thì cả ba mới chạy được:
ProbeRunner: -- persist OffsetDateTimeProbe
ProbeRunner: OffsetDateTimeProbe[id=1, name=null, createdAt=2026-09-13T17:42:01.218922+07:00, updatedAt=2026-09-13T17:42:01.218922+07:00]
ProbeRunner: -- rename OffsetDateTimeProbe
ProbeRunner: OffsetDateTimeProbe[id=1, name=renamed, createdAt=2026-09-13T10:42:01.218922Z, updatedAt=2026-09-13T17:42:02.438637+07:00] created_at | id | updated_at | name
-------------------------------+----+-------------------------------+---------
2026-09-13 10:42:01.218922+00 | 1 | 2026-09-13 10:42:02.438637+00 | renamed
(1 row)Offset không được lưu lại: +07:00 do provider gán quay về từ database thành Z trong createdAt, giá trị mà Hibernate đã load để đổi tên.
| Kiểu field | Column trên PostgreSQL | Provider mặc định | Provider trả về Instant | Provider trả về OffsetDateTime | Giá trị được lưu |
|---|---|---|---|---|---|
Instant | timestamp(6) with time zone | chạy được | chạy được | chạy được | 2026-09-13 10:41:50.204205+00 |
LocalDateTime | timestamp(6) | chạy được | chạy được | chạy được | 2026-09-13 17:41:51.431023, giờ đồng hồ của JVM |
OffsetDateTime | timestamp(6) with time zone | IllegalArgumentException | IllegalArgumentException | chạy được | 2026-09-13 10:42:01.218922+00, mất offset |
Series dùng Instant: nó chạy được với provider mặc định lẫn provider dựa trên Clock ở phần sau, và column của nó lưu một thời điểm không thể hiểu sai.
@CreatedDate và @LastModifiedDate thay đổi khi nào?
Quay lại project chính trên catalog. Các lần chạy update, noop, saveall, bulk và native nối tiếp nhau, nên mỗi output psql bên dưới là trạng thái mà lần chạy kế tiếp bắt đầu.
Khi insert, và modifyOnCreate = false
Câu INSERT đầu tiên ở trên bind 2026-09-13T10:36:02.071310Z vào cả created_at lẫn updated_at. Với mặc định modifyOnCreate = true, handler gán cả các field last-modified khi tạo mới, từ cùng một giá trị. Đổi thành false:
@Configuration
@EnableJpaAuditing
@EnableJpaAuditing(modifyOnCreate = false)
class AuditingConfig {
}Bước create2, trên PostgreSQL:
AuditLab: -- create2
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:40:52.354547Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Laptop stand]
bind: binding parameter (5:NUMERIC) <- [34.90]
bind: binding parameter (6:VARCHAR) <- [AC-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [15]
bind: binding parameter (9:TIMESTAMP_UTC) <- [null]
bind: binding parameter (10:VARCHAR) <- [null]
error: HHH000247: ErrorCode: 0, SQLState: 23502
error: ERROR: null value in column "updated_at" of relation "products" violates not-null constraint
Detail: Failing row contains (8, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, 2026-09-13 17:40:52.354547+07, null, null, null).
AuditLab: !!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: null value in column "updated_at" of relation "products" violates not-null constraintcreated_at được gán, còn updated_at bị bind null, và column NOT NULL từ chối nó. modifyOnCreate = false nghĩa là "chưa từng sửa" được lưu thành null, nên nó cần một updated_at cho phép null, và mọi query sắp xếp theo column này phải xử lý null. Phần còn lại của bài giữ giá trị mặc định.
Khi update qua dirty checking
Bước update gọi LabTransactions.changePrice, method này load KB-01 và đổi giá mà không gọi save. Bật lại stack trace:
AuditLab: -- update
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [KB-01]
LabTransactions: price set to 84.90, save() not called, leaving the method
java.lang.Exception: getCurrentAuditor called from
at com.example.demo.common.RequestHeaderAuditorAware.getCurrentAuditor(RequestHeaderAuditorAware.java:17)
at org.springframework.data.auditing.AuditingHandler.getAuditor(AuditingHandler.java:103)
at org.springframework.data.auditing.AuditingHandler.markModified(AuditingHandler.java:98)
at org.springframework.data.jpa.domain.support.AuditingEntityListener.touchForUpdate(AuditingEntityListener.java:113)
at org.hibernate.jpa.event.internal.ListenerCallback.performCallback(ListenerCallback.java:56)
at org.hibernate.jpa.event.internal.CallbackRegistryImpl.callback(CallbackRegistryImpl.java:117)
at org.hibernate.jpa.event.internal.CallbackRegistryImpl.preUpdate(CallbackRegistryImpl.java:80)
at org.hibernate.event.internal.DefaultFlushEntityEventListener.invokeInterceptor(DefaultFlushEntityEventListener.java:341)
at org.hibernate.event.internal.DefaultFlushEntityEventListener.handleInterception(DefaultFlushEntityEventListener.java:325)
at org.hibernate.event.internal.DefaultFlushEntityEventListener.scheduleUpdate(DefaultFlushEntityEventListener.java:247)
at org.hibernate.event.internal.DefaultFlushEntityEventListener.onFlushEntity(DefaultFlushEntityEventListener.java:146)
...
at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:498)
at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:2100)
...
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:552)
...
at com.example.demo.lab.LabTransactions$$SpringCGLIB$$0.changePrice(<generated>)
at com.example.demo.lab.AuditLab.runStep(AuditLab.java:64)
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Keyboards]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:35:23.188558Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Mechanical keyboard]
bind: binding parameter (5:NUMERIC) <- [84.90]
bind: binding parameter (6:VARCHAR) <- [KB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [25]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:50.394160Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [1]
AuditLab: returned Product[id=1, sku=KB-01, price=84.90, stock=25, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:50.394160Z, createdBy=null, updatedBy=null] id | sku | price | stock | created_at | updated_at | created_by | updated_by
----+--------+-------+-------+-------------------------------+-------------------------------+------------+------------
1 | KB-01 | 84.90 | 25 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:50.39416+00 | |
2 | KB-02 | 59.00 | 12 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
3 | MS-01 | 24.50 | 3 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
4 | HUB-01 | 39.00 | 10 | 2026-09-13 10:36:02.07131+00 | 2026-09-13 10:36:02.07131+00 | |
(4 rows)Callback không chạy lúc giá được gán. Nó chạy sau khi method đã return, trong lúc Spring commit transaction: JpaTransactionManager.doCommit flush session, DefaultFlushEntityEventListener thấy KB-01 dirty và lên lịch một lần update, và chỉ khi đó @PreUpdate mới được gọi để touchForUpdate gán updatedAt mới. Câu UPDATE liệt kê mọi column, như bài 26 đã cho thấy với dirty checking; created_at được gửi lại với giá trị đã load. Trong table chỉ row 1 thay đổi, và chỉ ở giá cùng updated_at.
Transaction không thay đổi gì
Bước noop load KB-01 trong một transaction rồi trả về mà không sửa gì:
AuditLab: -- noop
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [KB-01]
LabTransactions: loaded KB-01, nothing changed, leaving the method
AuditLab: returned Product[id=1, sku=KB-01, price=84.90, stock=25, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:50.394160Z, createdBy=null, updatedBy=null]Không có UPDATE, và psql in ra đúng bốn row như trên. Lần flush không thấy gì dirty, nên không có update nào để lên lịch và không có @PreUpdate nào để gọi. Đọc một entity bên trong transaction đọc-ghi không làm updated_at thay đổi.
saveAll với detached entity
Bước saveall load mọi sản phẩm mà không có transaction, cộng thêm một vào stock của mỗi bàn phím rồi truyền danh sách detached vào saveAll:
AuditLab: -- saveall
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [2]
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [1]
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Keyboards]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:35:23.188558Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Compact keyboard]
bind: binding parameter (5:NUMERIC) <- [59.00]
bind: binding parameter (6:VARCHAR) <- [KB-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [13]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:55.037375Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [2]
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Keyboards]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:35:23.188558Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Mechanical keyboard]
bind: binding parameter (5:NUMERIC) <- [84.90]
bind: binding parameter (6:VARCHAR) <- [KB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [26]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:55.040412Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [1]
AuditLab: returned Product[id=2, sku=KB-02, price=59.00, stock=13, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:55.037375Z, createdBy=null, updatedBy=null]
AuditLab: returned Product[id=1, sku=KB-01, price=84.90, stock=26, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:55.040412Z, createdBy=null, updatedBy=null] id | sku | price | stock | created_at | updated_at | created_by | updated_by
----+--------+-------+-------+-------------------------------+-------------------------------+------------+------------
1 | KB-01 | 84.90 | 26 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.040412+00 | |
2 | KB-02 | 59.00 | 13 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.037375+00 | |
3 | MS-01 | 24.50 | 3 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
4 | HUB-01 | 39.00 | 10 | 2026-09-13 10:36:02.07131+00 | 2026-09-13 10:36:02.07131+00 | |
(4 rows)saveAll là save chạy trong vòng lặp bên trong một transaction. Mỗi bàn phím được merge, một SELECT chép nó vào persistence context, và lần flush chạy listener cho từng entity: hai câu UPDATE, hai giá trị updated_at khác nhau cách nhau ba mili giây. saveAll được audit giống như save. Chính thao tác merge giúp chuyện này chạy đúng cũng là nơi cái bẫy tiếp theo nằm.
Bẫy detached entity: save() ghi null vào created_at
PUT dựng một Product mới từ request
Một lối tắt hay gặp cho PUT là dùng lại DTO của thao tác tạo, dựng một Product mới từ nó và gán id lấy từ path:
@Transactional
public ProductResponse replace(Long id, CreateProductRequest request) {
Product product = mapper.toProduct(request);
product.setId(id);
return mapper.toResponse(repository.save(product));
} public Long getId() { return id; }
void setId(Long id) { this.id = id; } Object có id, nên save merge nó, tức SELECT rồi UPDATE như bài 26. Nó cũng không có createdAt và createdBy, vì trong request không có gì gán chúng. Bước replace trên HUB-01, chạy trên PostgreSQL:
AuditLab: -- replace
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [HUB-01]
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [4]
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [null]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [USB-C hub, 7 ports]
bind: binding parameter (5:NUMERIC) <- [42.00]
bind: binding parameter (6:VARCHAR) <- [HUB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [8]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:37:28.930979Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [4]
error: HHH000247: ErrorCode: 0, SQLState: 23502
error: ERROR: null value in column "created_at" of relation "products" violates not-null constraint
Detail: Failing row contains (4, USB-C hub, 7 ports, HUB-01, 42.00, 8, Accessories, ACTIVE, null, 2026-09-13 17:37:28.930979+07, null, null).
AuditLab: !!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: null value in column "created_at" of relation "products" violates not-null constraintSELECT đầu tiên là runner tìm id; SELECT thứ hai là của merge. Merge chép toàn bộ trạng thái của object mới lên instance đang được quản lý, kể cả các giá trị null, nên Product managed mất createdAt và createdBy. Lúc flush, listener gán updatedAt như đúng nhiệm vụ, và không hề nhìn tới các field created: touchForUpdate chỉ ghi cặp last-modified. Câu UPDATE bind null vào created_at và created_by. created_by cho phép null, nên giá trị null của nó lẽ ra đã được ghi; chỉ có constraint trên created_at chặn statement lại.
@Column(updatable = false)
Các field created không bao giờ nên nằm trong một câu UPDATE, và JPA cho phép khai báo điều đó:
@CreatedDate
@Column(nullable = false)
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
@CreatedBy
@Column(length = 64)
@Column(length = 64, updatable = false)
private String createdBy;Cùng bước replace:
AuditLab: -- replace
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [HUB-01]
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [4]
SQL: update products set category=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:VARCHAR) <- [USB-C hub, 7 ports]
bind: binding parameter (3:NUMERIC) <- [42.00]
bind: binding parameter (4:VARCHAR) <- [HUB-01]
bind: binding parameter (5:VARCHAR) <- [ACTIVE]
bind: binding parameter (6:INTEGER) <- [8]
bind: binding parameter (7:TIMESTAMP_UTC) <- [2026-09-13T10:43:41.465017Z]
bind: binding parameter (8:VARCHAR) <- [null]
bind: binding parameter (9:BIGINT) <- [4]
AuditLab: returned ProductResponse[id=4, name=USB-C hub, 7 ports, sku=HUB-01, price=42.00, stock=8, category=Accessories, createdAt=null, updatedAt=null] id | sku | price | stock | created_at | updated_at | created_by | updated_by
----+--------+-------+-------+------------------------------+-------------------------------+------------+------------
4 | HUB-01 | 42.00 | 8 | 2026-09-13 10:36:02.07131+00 | 2026-09-13 10:43:41.465017+00 | |
(1 row)created_at và created_by đã biến mất khỏi câu UPDATE, và row giữ nguyên created_at ban đầu. Object trong bộ nhớ thì vẫn sai: DTO ghi createdAt=null, vì instance managed mang giá trị null chép từ object của request, và updatedAt=null vì một lý do mà phần cuối bài giải thích. updatable = false bảo vệ row, không bảo vệ response. Hãy giữ nó như một lớp chặn, và viết update theo cách dirty checking mong đợi: load sản phẩm, chép request vào nó, và để lần flush ghi xuống.
@Transactional
public ProductResponse update(Long id, UpdateProductRequest request) {
Product product = find(id);
mapper.update(request, product);
return mapper.toResponse(product);
}UpdateProductRequest mang name, price, stock và category, và ProductMapper.update chép chúng qua các setter. Từ đây endpoint PUT gọi method này.
@CreatedBy khi chưa có Spring Security: AuditorAware và header X-User
@CreatedBy và @LastModifiedBy lấy giá trị từ một bean AuditorAware. Project này chưa có xác thực, nên tên người dùng đến từ một header của request, X-User. Client nào cũng có thể gửi bất kỳ giá trị gì trong đó; nó chỉ đứng tạm chỗ cho một danh tính thật cho đến Chapter 5, nơi header được thay bằng người dùng đã xác thực lấy từ SecurityContext.
package com.example.demo.common;
import java.util.Optional;
import org.springframework.data.domain.AuditorAware;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Component
class RequestHeaderAuditorAware implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes)) {
return Optional.empty();
}
String user = attributes.getRequest().getHeader("X-User");
return StringUtils.hasText(user) ? Optional.of(user) : Optional.empty();
}
}@EnableJpaAuditing ở đây không có auditorAwareRef. AuditingBeanDefinitionRegistrarSupport dựng bean definition của AuditingHandler với autowiring theo type, setAutowireMode(2) trong bytecode của nó, nên bean AuditorAware duy nhất trong context được inject tự động. Các lần chạy bên dưới cho thấy nó hoạt động. Khi có nhiều hơn một bean AuditorAware, hãy chỉ định bean cần dùng bằng auditorAwareRef.
Bên trong một request
Ứng dụng web, trên PostgreSQL:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8132 --spring.profiles.active=postgres
curl -i -s -H 'Content-Type: application/json' -H 'X-User: alice' -d '{"name":"Ergonomic keyboard","sku":"KB-04","price":129.00,"stock":7,"category":"Keyboards"}' http://localhost:8132/api/productsCâu INSERT trong log của server, cắt còn các column audit:
2026-09-13T17:38:10.561+07:00 DEBUG 44546 --- [demo] [nio-8132-exec-3] org.hibernate.SQL : insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
2026-09-13T17:38:10.562+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind : binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:38:10.556859Z]
2026-09-13T17:38:10.562+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind : binding parameter (3:VARCHAR) <- [alice]
2026-09-13T17:38:10.563+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind : binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:38:10.556859Z]
2026-09-13T17:38:10.563+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind : binding parameter (10:VARCHAR) <- [alice]Hai giây sau, một request PUT vào sản phẩm vừa tạo, mang id 5, với tư cách bob:
curl -i -s -X PUT -H 'Content-Type: application/json' -H 'X-User: bob' -d '{"name":"Ergonomic keyboard","price":119.00,"stock":7,"category":"Keyboards"}' http://localhost:8132/api/products/5
docker exec sb-a32-pg psql -U catalog -d catalog -c "select id, sku, price, created_at, updated_at, created_by, updated_by from products where id = 5" id | sku | price | created_at | updated_at | created_by | updated_by
----+-------+--------+-------------------------------+-------------------------------+------------+------------
5 | KB-04 | 119.00 | 2026-09-13 10:38:10.556859+00 | 2026-09-13 10:38:12.694614+00 | alice | bob
(1 row)Khi tạo, modifyOnCreate đặt alice vào cả hai column người dùng; lần update chỉ thay updated_by.
Bên ngoài request: ApplicationRunner
Mọi lần chạy lab đều là một ApplicationRunner không có web request nào gắn với thread của nó. Ở đó RequestContextHolder.getRequestAttributes() trả về null, method trả về Optional.empty(), và câu INSERT đầu tiên của bài bind created_by <- [null] và updated_by <- [null] mà không có lỗi gì.
Một phiên bản ngắn hơn của cùng class này rất phổ biến, và thất bại ở đó:
@Override
public Optional<String> getCurrentAuditor() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
return Optional.ofNullable(attributes.getRequest().getHeader("X-User"));
}Bước create2 với phiên bản này:
AuditLab: -- create2
AuditLab: !!! org.springframework.dao.InvalidDataAccessApiUsageException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.
AuditLab: !!! java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.currentRequestAttributes() ném IllegalStateException khi không có request nào được gắn, exception đi ra từ callback @PrePersist, và Spring chuyển nó thành InvalidDataAccessApiUsageException. Không có SQL nào được gửi. Một scheduled job, một message listener hay một runner lúc khởi động mà lưu entity cũng sẽ thất bại y như vậy, nên hãy kiểm tra request bằng getRequestAttributes() và trả về Optional.empty().
Optional.empty() khi update giữ nguyên auditor trước đó
Vẫn sản phẩm đó, được cập nhật bằng một PUT không có header:
curl -i -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Ergonomic keyboard","price":109.00,"stock":7,"category":"Keyboards"}' http://localhost:8132/api/products/5 id | sku | price | created_at | updated_at | created_by | updated_by
----+-------+--------+-------------------------------+-------------------------------+------------+------------
5 | KB-04 | 109.00 | 2026-09-13 10:38:10.556859+00 | 2026-09-13 10:38:14.906025+00 | alice | bob
(1 row)Giá và updated_at đã đổi, còn updated_by vẫn là bob. Trong AuditingHandlerSupport, touchAuditor kiểm tra auditor có tồn tại hay không trước khi gọi setLastModifiedBy; một Optional rỗng để nguyên field như lúc load. Với entity mới điều đó nghĩa là null; với entity đã có, nó nghĩa là người dùng trước bị ghi nhận cho một thay đổi họ không hề làm. Nếu có thể có lần ghi ẩn danh, hãy trả về một giá trị cố định như "anonymous" hoặc "system" thay vì Optional.empty().
Clock cố định với DateTimeProvider
Provider mặc định đọc đồng hồ hệ thống, và một test kiểm tra created_at không thể biết trước giá trị đó. Một DateTimeProvider dựa trên bean Clock giúp thay được nguồn thời gian:
package com.example.demo.common;
import java.time.Clock;
import java.time.Instant;
import java.util.Optional;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider")
class AuditingConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
@Bean
DateTimeProvider auditingDateTimeProvider(Clock clock) {
return () -> Optional.of(Instant.now(clock));
}
}Provider trả về một Instant, kiểu mà các field Instant nhận thẳng không cần chuyển đổi. Để chứng minh clock thật sự được dùng, một profile lab thay nó bằng một clock cố định:
package com.example.demo.lab;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
@Configuration
@Profile("fixed-clock")
class FixedClockConfig {
@Bean
@Primary
Clock fixedClock() {
return Clock.fixed(Instant.parse("2026-01-01T09:00:00Z"), ZoneOffset.UTC);
}
}java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres,lab,fixed-clock --spring.main.web-application-type=none --lab.steps=create2AuditLab: -- create2
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-01-01T09:00:00Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Laptop stand]
bind: binding parameter (5:NUMERIC) <- [34.90]
bind: binding parameter (6:VARCHAR) <- [AC-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [15]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-01-01T09:00:00Z]
bind: binding parameter (10:VARCHAR) <- [null]
AuditLab: returned Product[id=9, sku=AC-02, price=34.90, stock=15, createdAt=2026-01-01T09:00:00Z, updatedAt=2026-01-01T09:00:00Z, createdBy=null, updatedBy=null] id | sku | price | stock | created_at | updated_at | created_by | updated_by
----+-------+-------+-------+------------------------+------------------------+------------+------------
9 | AC-02 | 34.90 | 15 | 2026-01-01 09:00:00+00 | 2026-01-01 09:00:00+00 | |
(1 row)Cả hai column giữ đúng thời điểm mà clock cố định trả về. Cùng bean Clock đó có thể inject vào bất cứ chỗ nào khác trong ứng dụng cần thời gian, và Chapter 6 thay nó bằng một clock cố định trong test.
Những gì bỏ qua auditing: bulk update với @Modifying và native SQL
Listener chỉ chạy cho các entity mà Hibernate flush. Bulk update của bài 27 và một native UPDATE trên cùng repository:
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySku(String sku);
@Modifying
@Query("update Product p set p.price = p.price * :factor where p.category = :category")
int changePrices(String category, BigDecimal factor);
@Modifying
@Query(value = "update products set stock = stock + :quantity where sku = :sku", nativeQuery = true)
int restockNative(String sku, int quantity);
}Cả hai được gọi từ các method @Transactional của service, changePrices và restock. Bước bulk và native, bắt đầu từ table sau saveAll:
AuditLab: -- bulk
SQL: update products p1_0 set price=(p1_0.price*?) where p1_0.category=?
bind: binding parameter (1:NUMERIC) <- [1.10]
bind: binding parameter (2:VARCHAR) <- [Keyboards]
AuditLab: changePrices -> 2 rows
AuditLab: -- native
SQL: update products set stock = stock + ? where sku = ?
bind: binding parameter (1:INTEGER) <- [5]
bind: binding parameter (2:VARCHAR) <- [MS-01]
AuditLab: restock -> 1 rows id | sku | price | stock | created_at | updated_at | created_by | updated_by
----+--------+-------+-------+-------------------------------+-------------------------------+------------+------------
1 | KB-01 | 93.39 | 26 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.040412+00 | |
2 | KB-02 | 64.90 | 13 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.037375+00 | |
3 | MS-01 | 24.50 | 8 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
4 | HUB-01 | 39.00 | 10 | 2026-09-13 10:36:02.07131+00 | 2026-09-13 10:36:02.07131+00 | |
(4 rows)Hai bàn phím giờ có giá 93.39 và 64.90, con chuột có 8 cái trong kho, và cả ba giá trị updated_at vẫn y nguyên như trước. Không statement nào load entity, nên không có gì để flush và không có @PreUpdate nào để gọi; SQL không có updated_at vì không ai viết nó vào đó.

Một bulk statement cần được tính là một lần sửa thì phải tự gán column, ví dụ set p.price = p.price * :factor, p.updatedAt = :now, và updated_by cũng vậy. Điều tương tự áp dụng cho SQL chạy từ migration, từ console của database hay từ một ứng dụng khác.
@CreationTimestamp và @UpdateTimestamp của Hibernate so với Spring Data auditing
Hibernate có riêng một cặp annotation không cần cấu hình và không cần listener. Trong project probe, hai entity mỗi cái có một cặp Instant, cái thứ hai do database sinh giá trị:
@Entity
@Table(name = "hibernate_vm_probe")
public static class HibernateVmProbe implements Stamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
// getId, setId, setName and toString
} @Entity
@Table(name = "hibernate_db_probe")
public static class HibernateDbProbe implements Stamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
@CreationTimestamp(source = SourceType.DB) private Instant createdAt;
@UpdateTimestamp(source = SourceType.DB) private Instant updatedAt;
// getId, setId, setName and toString
}Theo javap trên jar 7.4.5, cả hai annotation đều mặc định source = SourceType.VM, và không cái nào bị deprecated. Hibernate tạo cả hai table với created_at timestamp(6) with time zone not null, updated_at cũng vậy: khác với annotation của Spring Data, các annotation này thêm not null vào DDL được sinh. Mỗi probe đi qua năm transaction cách nhau một giây: persist, đổi tên, load mà không sửa gì, một JPQL update … set e.name = :name, và merge một object mới có cùng id và timestamp null, load lại row sau hai bước cuối. Probe sinh giá trị bằng JVM trên PostgreSQL, đã bỏ các SELECT sau bulk update và sau merge cùng các dòng bind của id và name:
ProbeRunner: -- persist HibernateVmProbe
SQL: insert into hibernate_vm_probe (created_at,name,updated_at) values (?,?,?)
bind: binding parameter (1:TIMESTAMP_UTC) <- [2026-09-13T10:44:00.612364Z]
bind: binding parameter (3:TIMESTAMP_UTC) <- [2026-09-13T10:44:00.612395Z]
ProbeRunner: HibernateVmProbe[id=1, name=null, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:00.612395Z]
ProbeRunner: -- rename HibernateVmProbe (dirty checking)
SQL: select hvp1_0.id,hvp1_0.created_at,hvp1_0.name,hvp1_0.updated_at from hibernate_vm_probe hvp1_0 where hvp1_0.id=?
SQL: update hibernate_vm_probe set name=?,updated_at=? where id=?
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:44:01.878889Z]
ProbeRunner: HibernateVmProbe[id=1, name=renamed, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:01.878889Z]
ProbeRunner: -- load HibernateVmProbe and change nothing
SQL: select hvp1_0.id,hvp1_0.created_at,hvp1_0.name,hvp1_0.updated_at from hibernate_vm_probe hvp1_0 where hvp1_0.id=?
ProbeRunner: HibernateVmProbe[id=1, name=renamed, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:01.878889Z]
ProbeRunner: -- bulk update HibernateVmProbe
SQL: update hibernate_vm_probe hvp1_0 set name=?
ProbeRunner: rows=1
ProbeRunner: after bulk, reloaded: HibernateVmProbe[id=1, name=bulk, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:01.878889Z]
ProbeRunner: -- merge a new HibernateVmProbe with the same id and null timestamps
SQL: select hvp1_0.id,hvp1_0.created_at,hvp1_0.name,hvp1_0.updated_at from hibernate_vm_probe hvp1_0 where hvp1_0.id=?
core: HHH000502: The [createdAt] property of the [com.example.demo.probe.Entities$HibernateVmProbe] entity was modified, but it won't be updated because the property is immutable.
SQL: update hibernate_vm_probe set name=?,updated_at=? where id=?
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:44:05.702885Z]
ProbeRunner: HibernateVmProbe[id=1, name=merged, createdAt=null, updatedAt=2026-09-13T10:44:05.702885Z]
ProbeRunner: after merge, reloaded: HibernateVmProbe[id=1, name=merged, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:05.702885Z]Probe do database sinh giá trị, các dòng chính:
ProbeRunner: -- persist HibernateDbProbe
SQL: insert into hibernate_db_probe (created_at,name,updated_at) values (localtimestamp,?,localtimestamp) returning id,created_at,updated_at
ProbeRunner: HibernateDbProbe[id=1, name=null, createdAt=2026-09-13T10:44:05.709053Z, updatedAt=2026-09-13T10:44:05.709053Z]
ProbeRunner: -- rename HibernateDbProbe (dirty checking)
SQL: update hibernate_db_probe set name=?,updated_at=localtimestamp where id=? returning updated_at
ProbeRunner: -- bulk update HibernateDbProbe
SQL: update hibernate_db_probe hdp1_0 set name=?
ProbeRunner: after bulk, reloaded: HibernateDbProbe[id=1, name=bulk, createdAt=2026-09-13T10:44:05.709053Z, updatedAt=2026-09-13T10:44:06.921441Z]
ProbeRunner: -- merge a new HibernateDbProbe with the same id and null timestamps
core: HHH000502: The [createdAt] property of the [com.example.demo.probe.Entities$HibernateDbProbe] entity was modified, but it won't be updated because the property is immutable.
core: HHH000502: The [updatedAt] property of the [com.example.demo.probe.Entities$HibernateDbProbe] entity was modified, but it won't be updated because the property is immutable.
SQL: update hibernate_db_probe set name=?,updated_at=localtimestamp where id=? returning updated_at created_at | id | updated_at | name
-------------------------------+----+-------------------------------+--------
2026-09-13 10:44:00.612364+00 | 1 | 2026-09-13 10:44:05.702885+00 | merged
(1 row)
created_at | id | updated_at | name
-------------------------------+----+-------------------------------+--------
2026-09-13 10:44:05.709053+00 | 1 | 2026-09-13 10:44:10.566356+00 | merged
(1 row)@UpdateTimestampđược gán khi insert. Với nguồn JVM, hai column nhận hai lần đọc đồng hồ riêng biệt, cách nhau 31 micro giây; Spring Data bind cùng một giá trị cho cả hai.@CreationTimestampbị loại khỏi mọi câu UPDATE. Lần đổi tên gửiset name=?,updated_at=?, và lần merge vớicreatedAtnull ghi log HHH000502 và giữ nguyên giá trị đã lưu. Nó có sẵn sự bảo vệ màupdatable = falsemang lại cho field của Spring Data, và cũng có giá trị null trong bộ nhớ sau merge như vậy.SourceType.DBchuyển đồng hồ vào trong statement. Hibernate viếtlocaltimestampvào câu INSERT và UPDATE rồi đọc giá trị về bằngreturning, không có bind parameter; thời điểm được lưu khớp với lúc ghi.- Bulk update bỏ qua cả hai.
update hibernate_vm_probe hvp1_0 set name=?đểupdated_atđứng yên ở 10:44:01.878889, và probe DB ở 10:44:06.921441.
@CreatedDate / @LastModifiedDate của Spring Data | @CreationTimestamp / @UpdateTimestamp của Hibernate | |
|---|---|---|
| Cần gì | @EnableJpaAuditing và AuditingEntityListener | không cần gì |
| Last-modified được gán khi insert | có, cùng giá trị với created; không, nếu modifyOnCreate = false | có, một lần đọc đồng hồ thứ hai (VM) hoặc cùng localtimestamp (DB) |
| Được gán khi update entity dirty | chỉ last-modified | chỉ @UpdateTimestamp |
| Giá trị created trong câu UPDATE | được gửi lại; null sau merge nếu thiếu updatable = false | không bao giờ, HHH000502 khi merge |
| Nguồn giá trị | DateTimeProvider, thay được bằng bean Clock | đồng hồ JVM, hoặc localtimestamp trong SQL với SourceType.DB |
NOT NULL trong DDL được sinh | không | có |
Bulk update @Modifying hoặc JPQL | bị bỏ qua | bị bỏ qua |
| Ai thực hiện thay đổi | @CreatedBy, @LastModifiedBy qua AuditorAware | không có tương đương |
Hibernate 7.4 còn có @CurrentTimestamp, với giá trị mặc định trong jar là source = SourceType.DB và các event INSERT, UPDATE, FORCE_INCREMENT. Nếu chỉ cần timestamp, annotation của Hibernate là cách cấu hình gọn hơn. Series giữ annotation của Spring Data vì @CreatedBy và vì đồng hồ thay được.
Đưa createdAt và updatedAt vào ProductResponse
DTO của response có thêm hai timestamp, vẫn được map trong service như trước:
package com.example.demo.product;
import java.math.BigDecimal;
import java.time.Instant;
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock, String category) {
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock, String category,
Instant createdAt, Instant updatedAt) {
} public ProductResponse toResponse(Product product) {
return new ProductResponse(product.getId(), product.getName(), product.getSku(), product.getPrice(),
product.getStock(), product.getCategory());
product.getStock(), product.getCategory(), product.getCreatedAt(), product.getUpdatedAt());
} @PutMapping("/{id}")
public ProductResponse update(@PathVariable Long id, @Valid @RequestBody UpdateProductRequest request) {
return service.update(id, request);
}Các column người dùng không nằm trong response; psql cho thấy chúng. Với AuditableEntity và AuditingConfig bản cuối, một request POST và một PUT trên PostgreSQL:
curl -i -s -H 'Content-Type: application/json' -H 'X-User: alice' -d '{"name":"Webcam 1080p","sku":"AC-05","price":64.00,"stock":14,"category":"Accessories"}' http://localhost:8132/api/productsHTTP/1.1 201
Location: http://localhost:8132/api/products/10
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:43:46 GMT
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":64.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:46.931802Z"}Jackson 3 ghi mỗi Instant thành một chuỗi ISO-8601 ở UTC với độ chính xác micro giây, đúng độ chính xác của giá trị. Hai giây sau:
curl -i -s -X PUT -H 'Content-Type: application/json' -H 'X-User: bob' -d '{"name":"Webcam 1080p","price":59.00,"stock":14,"category":"Accessories"}' http://localhost:8132/api/products/10
curl -i -s http://localhost:8132/api/products/10HTTP/1.1 200
Content-Type: application/json
Content-Length: 179
Date: Sun, 13 Sep 2026 10:43:49 GMT
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":59.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:46.931802Z"}
HTTP/1.1 200
Content-Type: application/json
Content-Length: 179
Date: Sun, 13 Sep 2026 10:43:49 GMT
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":59.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:49.079407Z"}updatedAt trong response của PUT chậm một lần flush
Response của PUT có giá mới nhưng updatedAt cũ; GET ngay sau đó thì hiện 10:43:49.079407Z. update map DTO bên trong transaction, trước khi method return, còn @PreUpdate chỉ chạy ở lần flush trong lúc commit, sau khi DTO đã được tạo. Output của replace trước đó in updatedAt=null cũng vì lý do này. Flush trước khi map sẽ chạy callback trước:
@Transactional
public ProductResponse update(Long id, UpdateProductRequest request) {
Product product = find(id);
mapper.update(request, product);
repository.flush();
return mapper.toResponse(product);
}curl -i -s -X PUT -H 'Content-Type: application/json' -H 'X-User: bob' -d '{"name":"Webcam 1080p","price":54.00,"stock":14,"category":"Accessories"}' http://localhost:8132/api/products/10HTTP/1.1 200
Content-Type: application/json
Content-Length: 179
Date: Sun, 13 Sep 2026 10:43:52 GMT
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":54.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:52.304028Z"}flush() gửi câu UPDATE ngay trong method, listener gán updatedAt, và DTO được dựng sau đó. Transaction vẫn commit hoặc rollback như một khối. Để so sánh, phiên bản replace của PUT trên cùng sản phẩm, gửi với X-User: carol, trả về "createdAt":null,"updatedAt":null, trong khi psql cho thấy created_at vẫn là 10:43:46.931802+00, created_by vẫn là alice và updated_by giờ là carol.
Tổng hợp các annotation auditing
Mọi dòng trong bảng đều lấy từ các lần chạy ở trên.
| Annotation | Được gán khi | Nguồn giá trị | Ghi chú |
|---|---|---|---|
@CreatedDate | @PrePersist, bên trong persist, trước câu INSERT | DateTimeProvider.getNow(); provider mặc định trả về LocalDateTime.now(), được chuyển sang kiểu của field | Instant và LocalDateTime chạy được với provider mặc định; OffsetDateTime chỉ chạy với provider trả về OffsetDateTime; merge chép null vào nó nếu thiếu updatable = false |
@LastModifiedDate | @PrePersist với cùng giá trị, và @PreUpdate lúc flush một entity dirty | cùng provider, một lần getNow() cho mỗi callback | null khi insert nếu modifyOnCreate = false; không đổi với transaction không sửa gì, bulk update @Modifying hay native UPDATE |
@CreatedBy | @PrePersist | AuditorAware.getCurrentAuditor(), gọi trước getNow() | Optional.empty() để nó null; currentRequestAttributes() bên ngoài request ném InvalidDataAccessApiUsageException |
@LastModifiedBy | @PrePersist và @PreUpdate | AuditorAware.getCurrentAuditor() | Optional.empty() khi update giữ nguyên giá trị trước đó |
@CreationTimestamp | INSERT | đồng hồ JVM theo mặc định, localtimestamp với SourceType.DB | không bao giờ có trong câu UPDATE; HHH000502 khi merge |
@UpdateTimestamp | INSERT và mọi câu UPDATE của entity dirty | đồng hồ JVM theo mặc định, localtimestamp với SourceType.DB | được gán khi insert với lần đọc đồng hồ riêng; bị bulk update bỏ qua |
FAQ
Vì sao @CreatedDate và @LastModifiedDate bị null?
Hoặc thiếu @EnableJpaAuditing, hoặc entity không có @EntityListeners(AuditingEntityListener.class), trên chính nó hay trên @MappedSuperclass của nó. Không lỗi nào ghi cảnh báo lúc khởi động: Spring Boot 4.1.1 khởi động bình thường, Hibernate bind null vào cả bốn column audit, và lỗi duy nhất đến từ constraint NOT NULL của PostgreSQL trên created_at. Khi update, created_at bị null thường có nghĩa là một detached object đã được save; xem câu hỏi thứ tư.
@LastModifiedDate có được gán khi tạo entity không?
Có, theo mặc định. @EnableJpaAuditing có modifyOnCreate = true, và câu INSERT đã bind cùng giá trị 2026-09-13T10:36:02.071310Z vào created_at và updated_at, cùng một người dùng vào created_by và updated_by. Với modifyOnCreate = false, cả hai field last-modified đều null khi insert, nên các column đó phải cho phép null.
Spring Data JPA auditing có hỗ trợ OffsetDateTime không?
Chỉ khi có một DateTimeProvider trả về OffsetDateTime. Với provider mặc định, vốn trả về LocalDateTime, và với provider trả về Instant, Spring Data JPA 4.1.1 ném IllegalArgumentException: Cannot convert unsupported date type … to java.time.OffsetDateTime. Field Instant và LocalDateTime chạy được với cả hai provider, và Instant trong column timestamp with time zone là lựa chọn không thể hiểu sai.
Vì sao save() gán created_at thành null khi update?
Vì save merge một object đã có id, và merge chép mọi field từ object đó, kể cả null. Một object dựng từ request không có createdAt, còn listener khi update chỉ gán các field last-modified, nên câu UPDATE bind null vào created_at và created_by. @Column(updatable = false) loại cả hai column khỏi câu UPDATE; load entity rồi sửa nó thì tránh được cả giá trị null trong bộ nhớ.
Bulk update với @Modifying có cập nhật @LastModifiedDate không?
Không. Listener chỉ chạy cho các entity mà Hibernate flush, còn bulk UPDATE bằng JPQL hay native SQL không load entity nào. Sau khi update products p1_0 set price=(p1_0.price*?) where p1_0.category=? đổi hai row, psql cho thấy cả hai giá trị updated_at không đổi. Hãy tự gán column trong statement nếu thay đổi đó cần được tính. Ngược lại, saveAll được audit cho từng entity.
@CreatedDate và @CreationTimestamp khác nhau thế nào?
@CreatedDate là của Spring Data: cần @EnableJpaAuditing và listener, lấy giá trị từ một DateTimeProvider thay được, và đi kèm @CreatedBy. @CreationTimestamp là của Hibernate: không cần cấu hình, dùng đồng hồ JVM hoặc database, và Hibernate giữ column đó ngoài mọi câu UPDATE. Annotation đi cặp với nó, @UpdateTimestamp, cũng được gán khi insert, từ một lần đọc đồng hồ riêng.
Làm sao điền @CreatedBy khi chưa có Spring Security?
Cung cấp một bean AuditorAware<String>. Bài này đọc header X-User qua RequestContextHolder.getRequestAttributes() và trả về Optional.empty() khi không có request nào được gắn; một bean duy nhất được nhận mà không cần auditorAwareRef. Auditor rỗng để created_by null khi insert và giữ nguyên updated_by trước đó khi update. Chapter 5 sẽ lấy người dùng từ SecurityContext thay cho header.
Kết luận
Auditing cần ba thứ: @EnableJpaAuditing trong một class cấu hình, AuditingEntityListener trên một @MappedSuperclass, và bốn field có annotation, cộng thêm một migration cho các row có sẵn giá trị default trước khi column trở thành NOT NULL. Listener chạy bên trong chính các callback của Hibernate: @PrePersist trong lúc persist, trước câu INSERT, và @PreUpdate trong lần flush lúc commit, chỉ với entity đã thay đổi. Nó hỏi AuditorAware về người dùng, hỏi DateTimeProvider về thời gian, và gán các field trước khi SQL được dựng. Thiếu annotation hoặc listener thì không có gì cảnh báo: các column chỉ đơn giản là null. Instant chạy được với provider mặc định và lưu một timestamp with time zone không thể hiểu sai; OffsetDateTime thất bại trừ khi provider trả về đúng kiểu đó.
Các cái bẫy đều nằm ở những gì listener không thấy. Merge một object dựng từ request ghi null vào created_at và created_by nếu chúng không có updatable = false; auditor rỗng khi update giữ nguyên người dùng trước; DTO được map trước lần flush hiển thị updatedAt cũ; bulk update với @Modifying và native update để updated_at đứng yên. @CreationTimestamp và @UpdateTimestamp của Hibernate tự bảo vệ thời điểm tạo, nhưng không có người dùng và không có đồng hồ thay được.
Bài này khép lại Chapter 4. Từ DataSource và JdbcClient, catalogue giờ đã có entity và quan hệ JPA, derived query và query tự viết, phân trang, transaction, migration có version và column audit. Bài tiếp theo mở đầu Chapter 5 với tổng quan về Spring Security: authentication và authorization khác nhau thế nào, filter chain, và cấu hình SecurityFilterChain.