Command Palette

Search for a command to run...

[Spring Boot Basics] Database migration trong Spring Boot với Flyway: vì sao không dùng ddl-auto ở production

Bài 25 tạo table products bằng schema.sql, file mà Spring Boot chỉ chạy trên database embedded, còn bài 26 và 28 để Hibernate sinh mọi table qua spring.jpa.hibernate.ddl-auto. Cả hai cách đều dựng schema từ con số không. Không cách nào biết một database đang chạy đã nhận những thay đổi nào, và cũng không cách nào ghi lại rằng một thay đổi đã xảy ra. Bài này thay cả hai bằng Flyway: các file SQL đánh số nằm trong repository, chạy đúng một lần trên mỗi database, theo thứ tự, và được ghi vào một table.

Các ví dụ dùng Spring Boot 4.1.1 và Java 21 với PostgreSQL 18 chạy trong Docker. H2 xuất hiện ở những phần có ghi rõ, MySQL 8.4 trong một phép so sánh, và Liquibase ở cuối bài. Application chạy ở port 8131 thay vì 8080 mặc định, và đường dẫn dài trong output được rút gọn thành /…/.

Ba file versioned migration V1, V2 và V3, mỗi file đi vào database thành một row của schema history table

Phần đầu bắt đầu từ một catalogue có các table do ddl-auto=update tạo và quan sát nó làm gì với các row đã có sẵn. Phần còn lại dựng schema của bài 28 bằng Flyway và chạy thử từng lỗi mà một công cụ migration hay gặp.

Vì sao không dùng ddl-auto ở production

Database chạy trong container, giống bài 25:

Bash
docker run -d --name sb-a31-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 55431:5432 postgres:18

Profile postgres giữ thông tin kết nối. Dòng cuối của nó thuộc về phần nói về Hibernate và Flyway; các lần chạy trong phần này ghi đè nó trên command line:

src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:55431/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=validate

Các entity là Category, Tag, Product, OrderOrderLine của bài 28, bỏ các table customer, và Order chỉ còn id cùng các line. Product giữ các relationship của bài 28 và lấy kích thước column từ bài 26:

src/main/java/com/example/demo/product/Product.java
    @Column(nullable = false, length = 120)
    private String name;
 
    @Column(nullable = false, unique = true, length = 40)
    private String sku;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;
 
    @Column(nullable = false)
    private int stock;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;
 
    @ManyToMany
    @JoinTable(name = "product_tags",
            joinColumns = @JoinColumn(name = "product_id"),
            inverseJoinColumns = @JoinColumn(name = "tag_id"))
    private Set<Tag> tags = new HashSet<>();

Cho phần này, database thứ hai trong container, ddlauto, nhận các table từ update với Flyway bị tắt:

Bash
docker exec sb-a31-pg psql -U shop -d shop -c 'create database ddlauto;'
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8131 --spring.profiles.active=postgres --spring.datasource.url=jdbc:postgresql://localhost:55431/ddlauto --spring.flyway.enabled=false --spring.jpa.hibernate.ddl-auto=update --logging.level.org.hibernate.SQL=DEBUG --spring.main.web-application-type=none

--spring.main.web-application-type=none khiến process thoát ngay khi khởi động xong; mọi lần chạy trong bài đều dùng nó. update tạo sáu table, và psql thêm ba category cùng ba product: KB-01 giá 89.90, MS-01 giá 24.50 và HUB-07 giá 39.00. Hai bản sao của database đó, tạo bằng create database … template ddlauto, phục vụ lần đổi tên thứ hai bên dưới và phần nói về database có sẵn.

Đổi tên field với ddl-auto=update

Thay đổi đầu tiên đổi name thành title, từ mà các cửa hàng online hay dùng cho sản phẩm; parameter của constructor và getter đổi theo:

src/main/java/com/example/demo/product/Product.java
    @Column(nullable = false, length = 120)
    private String name; 
    private String title; 

Khởi động với update, application ghi log như sau. Lần chạy này cũng mang theo thay đổi SKU của phần kế tiếp, dòng log của nó nằm ở đó:

Text
2026-09-13T17:32:28.040+07:00 DEBUG 42679 --- [demo] [           main] org.hibernate.SQL                        : alter table if exists products add column title varchar(120) not null
2026-09-13T17:32:28.041+07:00  WARN 42679 --- [demo] [           main] o.h.t.s.i.ExceptionHandlerLoggedImpl     : GenerationTarget encountered exception accepting command : Error executing DDL "alter table if exists products add column title varchar(120) not null" via JDBC [ERROR: column "title" of relation "products" contains null values]
 
org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "alter table if exists products add column title varchar(120) not null" via JDBC [ERROR: column "title" of relation "products" contains null values]
...
Caused by: org.postgresql.util.PSQLException: ERROR: column "title" of relation "products" contains null values
...
2026-09-13T17:32:28.057+07:00  INFO 42679 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-09-13T17:32:28.134+07:00  INFO 42679 --- [demo] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.382 seconds (process running for 1.579)

PostgreSQL từ chối thêm một column NOT NULL vào table đã có row. Hibernate chỉ ghi lời từ chối đó thành một WARN, và application vẫn khởi động, với một mapping trỏ tới column mà table không có:

Bash
docker exec sb-a31-pg psql -U shop -d ddlauto -c 'select id, name, title, sku from products order by id'
Text
ERROR:  column "title" does not exist
LINE 1: select id, name, title, sku from products order by id
                         ^

Bỏ nullable = false khỏi field đã đổi tên, cùng lần khởi động đó trên bản sao ddlauto2 đi được xa hơn:

src/main/java/com/example/demo/product/Product.java
    @Column(nullable = false, length = 120) 
    @Column(length = 120) 
    private String title;
Text
2026-09-13T17:34:42.096+07:00 DEBUG 43295 --- [demo] [           main] org.hibernate.SQL                        : alter table if exists products add column title varchar(120)
Bash
docker exec sb-a31-pg psql -U shop -d ddlauto2 -c 'select id, name, title, sku, price from products order by id'
Text
 id |        name         | title |  sku   | price
----+---------------------+-------+--------+-------
  1 | Mechanical keyboard |       | KB-01  |    90
  2 | Wireless mouse      |       | MS-01  |    25
  3 | USB-C hub           |       | HUB-07 |    39
(3 rows)

Ba cái tên vẫn nằm trong name, column không còn field nào map tới, còn title tồn tại nhưng trống trơn; column price thuộc về phần kế tiếp. Column cũ còn giữ NOT NULL, nên một câu INSERT với đúng các column mà entity đang map, gửi từ psql, bị từ chối:

Bash
docker exec sb-a31-pg psql -U shop -d ddlauto2 -c "insert into products (title, sku, price, stock, category_id) values ('Webcam', 'CAM-01', 59, 4, 1)"
Text
ERROR:  null value in column "name" of relation "products" violates not-null constraint
DETAIL:  Failing row contains (4, null, 59, CAM-01, 4, 1, Webcam).

update so mapping với table rồi thêm những gì còn thiếu. Không có gì trong entity cho biết title từng là name, nên một lần đổi tên thành một column mới, và data không bao giờ được chuyển sang.

Đổi độ dài hoặc scale của column với update

Mỗi lần chạy trong hai lần trên còn mang thêm một thay đổi. Lần thứ nhất nới rộng SKU:

src/main/java/com/example/demo/product/Product.java
    @Column(nullable = false, unique = true, length = 40) 
    @Column(nullable = false, unique = true, length = 64) 
    private String sku;
Text
2026-09-13T17:32:28.036+07:00 DEBUG 42679 --- [demo] [           main] org.hibernate.SQL                        : alter table if exists products alter column sku set data type varchar(64)

Lần thứ hai còn bỏ scale = 2 khỏi price, kiểu thay đổi dễ lọt vào khi dọn lại annotation. scale mặc định của @Column là 0:

src/main/java/com/example/demo/product/Product.java
    @Column(nullable = false, precision = 10, scale = 2) 
    @Column(nullable = false, precision = 10) 
    private BigDecimal price;
Text
2026-09-13T17:34:42.090+07:00 DEBUG 43295 --- [demo] [           main] org.hibernate.SQL                        : alter table if exists products alter column price set data type numeric(10,0)
2026-09-13T17:34:42.095+07:00 DEBUG 43295 --- [demo] [           main] org.hibernate.SQL                        : alter table if exists products alter column sku set data type varchar(64)

Vậy update của Hibernate 7.4 có sửa column đã tồn tại. SKU rộng hơn thì vô hại; scale thì không. PostgreSQL chuyển các giá trị đang lưu sang numeric(10,0), và kết quả nằm trong output psql ở trên: 89.90 thành 90, 24.50 thành 25. Log của lần chạy đó không có một dòng WARN hay ERROR nào.

create, create-drop và việc không có lịch sử

Hai giá trị còn lại có đụng tới schema còn thô bạo hơn trên một database có data. create drop mọi table được map ở mỗi lần khởi động, như câu drop table if exists products cascade trong bài 26, nên mỗi lần restart đều bắt đầu với table rỗng. create-drop làm y như vậy và drop thêm lần nữa khi tắt application.

Và không giá trị nào để lại dấu vết. Không có gì ghi lại câu lệnh nào đã chạy trên database nào. DDL được sinh lúc khởi động từ những class tình cờ được deploy, nên không ai review nó trước khi chạy. Kết quả còn phụ thuộc vào con đường mà database đã đi qua: update đặt cho unique constraint của SKU trong ddlauto một cái tên sinh tự động, ukfhmd06dsmj6k0n90swsh8ie9g, trong khi create của bài 26 tạo ra products_sku_key cho cùng mapping đó.

Cùng lần đổi tên, viết thành một versioned migration mà một phần sau sẽ chạy trên đúng ba product đó:

Hai panel trên PostgreSQL 18.6. Bên trái, ddl-auto=update sau khi đổi name thành title: Hibernate thêm column title, ba cái tên nằm lại trong column name không còn được map, title là NULL ở mọi row và không có gì được ghi lại; với nullable = false lệnh ALTER thất bại và chỉ có một WARN. Bên phải, V4__rename_product_name_to_title.sql đổi tên column, các row giữ nguyên giá trị và flyway_schema_history ghi version 4 với checksum 715857858

Thêm Flyway vào project Spring Boot 4.1.1

Id trên Spring Initializr là flyway. Project của bài này được sinh bằng:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation,data-jpa,h2,postgresql,flyway" -o demo.zip

So với build của bài 26, có ba dòng mới:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-h2console'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-flyway'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'org.flywaydb:flyway-database-postgresql'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'org.postgresql:postgresql'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Starter này mang theo phần hỗ trợ Flyway của Boot, trong Boot 4 là một module riêng giống phần JPA ở bài 26, và chính Flyway:

Bash
./gradlew dependencies --configuration runtimeClasspath

Rút gọn còn các dòng liên quan tới Flyway:

Text
+--- org.springframework.boot:spring-boot-starter-flyway -> 4.1.1
|    +--- org.springframework.boot:spring-boot-starter:4.1.1 (*)
|    +--- org.springframework.boot:spring-boot-starter-jdbc:4.1.1 (*)
|    +--- org.springframework.boot:spring-boot-flyway:4.1.1
|    |    +--- org.springframework.boot:spring-boot:4.1.1 (*)
|    |    +--- org.springframework.boot:spring-boot-jdbc:4.1.1 (*)
|    |    \--- org.flywaydb:flyway-core:12.4.0
|    |         \--- tools.jackson.core:jackson-databind:3.1.1 -> 3.1.5
|    \--- org.springframework.boot:spring-boot-jdbc:4.1.1 (*)
+--- org.flywaydb:flyway-database-postgresql -> 12.4.0
|    \--- org.flywaydb:flyway-core:12.4.0 (*)
  • flyway-core 12.4.0 là engine. Bản thân nó có sẵn phần hỗ trợ cho một số database: các class của H2 nằm trong nó, dưới org/flywaydb/core/internal/database/h2/, còn của PostgreSQL thì không.
  • flyway-database-postgresql chứa PostgreSQLDatabaseType và, trong cùng jar, phần hỗ trợ CockroachDB. Initializr thêm nó vì PostgreSQL được chọn; với MySQL thì nó thêm org.flywaydb:flyway-mysql.
  • spring-boot-starter-flyway-test là bản -test mà starter nào của Boot 4 cũng có.

Khởi động khi thiếu flyway-database-postgresql

Bỏ đúng dependency đó đi, jar vẫn build được, và thất bại lúc khởi động trên PostgreSQL:

Text
2026-09-13T17:29:10.133+07:00 ERROR 42175 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
 
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/hibernate/autoconfigure/HibernateJpaConfiguration.class]: Failed to initialize dependency 'flywayInitializer' of LoadTimeWeaverAware bean 'entityManagerFactory': Error creating bean with name 'flywayInitializer' defined in class path resource [org/springframework/boot/flyway/autoconfigure/FlywayAutoConfiguration$FlywayConfiguration.class]: Unsupported Database: PostgreSQL 18.6
...
Caused by: org.flywaydb.core.api.FlywayException: Unsupported Database: PostgreSQL 18.6

Lúc build không có gì báo lỗi, vì không dòng code nào tham chiếu tới module này. Thông báo nêu tên database và version nhưng không nhắc tới dependency bị thiếu, trong khi PostgreSQL 18.6 hoàn toàn được hỗ trợ: có module trở lại, lần chạy ở phần sau đi qua mà không có một warning nào về nó.

Location mặc định và các thiết lập của Flyway

spring.flyway.locations không có giá trị mặc định trong property metadata của Boot. Giá trị được gán trong constructor của FlywayProperties, trong spring-boot-flyway-4.1.1.jar:

Bash
javap -c -p -classpath spring-boot-flyway-4.1.1.jar org.springframework.boot.flyway.autoconfigure.FlywayProperties
Text
      14: ldc           #15                 // String classpath:db/migration

Vậy Flyway đọc src/main/resources/db/migration, thư mục mà Initializr tạo sẵn, để trống. Cùng constructor đó đặt các quy ước đặt tên mà những phần sau dựa vào: prefix V, dấu phân cách __, đuôi .sql, prefix R cho repeatable migration và tên table flyway_schema_history. Các thiết lập bài này đụng tới, cùng giá trị mặc định trong 4.1.1:

PropertyMặc địnhCác lần chạy cho thấy
spring.flyway.enabledtrueFlyway chạy ở mọi lần khởi động khi có trên classpath
spring.flyway.locationsclasspath:db/migrationđặt trong FlywayProperties, không nằm trong metadata
spring.flyway.validate-on-migratetruemột migration bị sửa sẽ chặn khởi động
spring.flyway.validate-migration-namingfalsefile đặt sai tên bị bỏ qua, chỉ có một dòng INFO
spring.flyway.out-of-orderfalsemột version thấp hơn version hiện tại làm validate thất bại
spring.flyway.baseline-on-migratefalseschema có dữ liệu mà không có history table bị từ chối
spring.flyway.baseline-version1version mà row baseline ghi nhận
spring.flyway.clean-disabledtrueclean() ném exception

Viết versioned migration cho schema của catalogue

Các file nằm ở location mặc định:

Tree
src/main/resources
├── application.properties
├── application-postgres.properties
└── db
    └── migration
        ├── V1__create_catalog.sql
        └── V2__create_orders.sql

V1 tạo các table của catalogue:

src/main/resources/db/migration/V1__create_catalog.sql
CREATE TABLE categories (
    id   BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(60) NOT NULL,
    CONSTRAINT uk_categories_name UNIQUE (name)
);
 
CREATE TABLE tags (
    id   BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(40) NOT NULL,
    CONSTRAINT uk_tags_name UNIQUE (name)
);
 
CREATE TABLE products (
    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name        VARCHAR(120)   NOT NULL,
    sku         VARCHAR(40)    NOT NULL,
    price       NUMERIC(10, 2) NOT NULL,
    stock       INTEGER        NOT NULL,
    category_id BIGINT         NOT NULL,
    CONSTRAINT uk_products_sku UNIQUE (sku),
    CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES categories (id)
);
 
CREATE INDEX idx_products_category_id ON products (category_id);
 
CREATE TABLE product_tags (
    product_id BIGINT NOT NULL,
    tag_id     BIGINT NOT NULL,
    CONSTRAINT pk_product_tags PRIMARY KEY (product_id, tag_id),
    CONSTRAINT fk_product_tags_product FOREIGN KEY (product_id) REFERENCES products (id),
    CONSTRAINT fk_product_tags_tag FOREIGN KEY (tag_id) REFERENCES tags (id)
);

V2 tạo order, với các line tham chiếu tới product:

src/main/resources/db/migration/V2__create_orders.sql
CREATE TABLE orders (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY
);
 
CREATE TABLE order_lines (
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    order_id   BIGINT         NOT NULL,
    product_id BIGINT         NOT NULL,
    quantity   INTEGER        NOT NULL,
    unit_price NUMERIC(10, 2) NOT NULL,
    CONSTRAINT fk_order_lines_order FOREIGN KEY (order_id) REFERENCES orders (id),
    CONSTRAINT fk_order_lines_product FOREIGN KEY (product_id) REFERENCES products (id)
);
 
CREATE INDEX idx_order_lines_order_id ON order_lines (order_id);
CREATE INDEX idx_order_lines_product_id ON order_lines (product_id);

Đoạn SQL này cho Hibernate đúng thứ mà các entity cần, cộng thêm những thứ Hibernate sẽ không tự viết:

  • Tên khớp với mapping. products, categoriesorder_lines lấy từ @Table, category_id từ @JoinColumn, product_tags cùng product_idtag_id từ @JoinTable, còn unit_price từ field unitPrice, được naming strategy của Boot đổi sang snake case như DDL của bài 28 đã cho thấy.
  • BIGINT GENERATED BY DEFAULT AS IDENTITY là column mà GenerationType.IDENTITY cần, đúng định nghĩa Hibernate sinh ra ở bài 26.
  • Constraint có tên do mình chọn, uk_products_skufk_products_category thay vì UKfhmd06dsmj6k0n90swsh8ie9g, để migration sau có thể gọi tới.
  • Column foreign key có index. PostgreSQL không tự tạo index cho foreign key, và các table do update dựng không có index nào, như phần về database có sẵn sẽ cho thấy.
  • product_tags có primary key gồm hai column, đúng thứ Hibernate sinh cho một Set ở bài 28.

Lần chạy đầu tiên: Flyway ghi gì vào log lúc khởi động

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8131 --spring.profiles.active=postgres --spring.main.web-application-type=none

Trên database shop còn trống, từ connection đầu tiên của pool tới cuối quá trình khởi động, bỏ khối thông tin database của Hibernate:

Text
2026-09-13T17:29:12.738+07:00  INFO 42180 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2026-09-13T17:29:12.747+07:00  INFO 42180 --- [demo] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://localhost:55431/shop (PostgreSQL 18.6)
2026-09-13T17:29:12.774+07:00  INFO 42180 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Schema history table "public"."flyway_schema_history" does not exist yet
2026-09-13T17:29:12.775+07:00  INFO 42180 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 2 migrations (execution time 00:00.007s)
2026-09-13T17:29:12.785+07:00  INFO 42180 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Creating Schema History table "public"."flyway_schema_history" ...
2026-09-13T17:29:12.821+07:00  INFO 42180 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": << Empty Schema >>
2026-09-13T17:29:12.825+07:00  INFO 42180 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "1 - create catalog"
2026-09-13T17:29:12.841+07:00  INFO 42180 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "2 - create orders"
2026-09-13T17:29:12.850+07:00  INFO 42180 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 2 migrations to schema "public", now at version v2 (execution time 00:00.012s)
2026-09-13T17:29:12.888+07:00  INFO 42180 --- [demo] [           main] org.hibernate.orm.jpa                    : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-09-13T17:29:12.915+07:00  INFO 42180 --- [demo] [           main] org.hibernate.orm.core                   : HHH000001: Hibernate ORM core version 7.4.5.Final
...
2026-09-13T17:29:13.547+07:00  INFO 42180 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-09-13T17:29:13.614+07:00  INFO 42180 --- [demo] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.465 seconds (process running for 1.664)
  • Database: nêu URL và version của server. Flyway 12.4.0 không nói gì thêm về PostgreSQL 18.6; trên H2 thì nó có in một warning, trích ở phần về Hibernate và Flyway.
  • History table chưa tồn tại, nên Flyway tạo nó, báo << Empty Schema >> rồi mới migrate.
  • Mỗi migration có một dòng log, gồm version và phần mô tả lấy từ tên file, dấu gạch dưới đổi thành khoảng trắng.
  • Hibernate khởi động sau khi Flyway xong việc: HHH008540EntityManagerFactory đứng sau Successfully applied 2 migrations, và ddl-auto=validate tìm thấy đúng các table nó cần.
  • Version của chính Flyway không xuất hiện ở đâu trong log; muốn biết thì đọc dependency tree.

flyway_schema_history sau lần chạy đầu tiên

Bash
docker exec sb-a31-pg psql -U shop -d shop -c 'select installed_rank, version, description, type, script, checksum, installed_by, installed_on, execution_time, success from flyway_schema_history order by installed_rank'
Text
 installed_rank | version |  description   | type |         script         | checksum  | installed_by |        installed_on        | execution_time | success
----------------+---------+----------------+------+------------------------+-----------+--------------+----------------------------+----------------+---------
              1 | 1       | create catalog | SQL  | V1__create_catalog.sql |  18327164 | shop         | 2026-09-13 17:29:12.815284 |              8 | t
              2 | 2       | create orders  | SQL  | V2__create_orders.sql  | 678017735 | shop         | 2026-09-13 17:29:12.835719 |              4 | t
(2 rows)

Mỗi migration một row. checksum được tính từ nội dung file và chính là thứ mà phần sửa migration đã chạy sẽ đụng phải, installed_by là user của database, execution_time tính bằng mili giây. Sau lần chạy này, psql thêm vào shop đúng ba category và ba product như trong ddlauto.

Quy tắc đặt tên: prefix, version, hai dấu gạch dưới

Một versioned migration được đặt tên gồm V, version, hai dấu gạch dưới, phần mô tả và .sql. Các phần của version cách nhau bằng một dấu gạch dưới, nên V1_1 là version 1.1. Để xem Flyway sắp thứ tự version ra sao và xử lý thế nào với tên sai quy tắc, một database riêng tên naming nhận một thư mục gồm các migration một dòng, truyền vào bằng --spring.flyway.locations=filesystem:/…/naming:

Tree
naming
├── V1__create_step.sql
├── V1_1__one_point_one.sql
├── V2__two.sql
├── V9__nine.sql
├── V10__ten.sql
├── V11_single_underscore.sql
├── V13__wrong_suffix.txt
└── v12__lowercase_prefix.sql

Log lúc khởi động:

Text
2026-09-13T17:29:58.281+07:00  INFO 42246 --- [demo] [           main] o.f.c.i.resource.ResourceNameValidator   : 2 SQL migrations were detected but not run because they did not follow the filename convention.
2026-09-13T17:29:58.281+07:00  INFO 42246 --- [demo] [           main] o.f.c.i.resource.ResourceNameValidator   : Set 'validateMigrationNaming' to true to fail fast and see a list of the invalid file names.
...
2026-09-13T17:29:58.361+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "1 - create step"
2026-09-13T17:29:58.376+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "1.1 - one point one"
2026-09-13T17:29:58.384+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "2 - two"
2026-09-13T17:29:58.391+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "9 - nine"
2026-09-13T17:29:58.396+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "10 - ten"
2026-09-13T17:29:58.400+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 5 migrations to schema "public", now at version v10 (execution time 00:00.011s)
  • Version được so như số. 10 chạy sau 9, 1.1 nằm giữa 12, dù ls liệt kê V10__ten.sql đầu tiên.
  • Hai file bị bỏ qua chỉ với log INFO, và application vẫn khởi động mà không có chúng. Con số là 2 chứ không phải 3: V13__wrong_suffix.txt không có đuôi .sql, nên Flyway không coi nó là SQL migration.

Với spring.flyway.validate-migration-naming=true, cùng thư mục đó chặn khởi động và nêu tên cả hai file:

Text
Caused by: org.flywaydb.core.api.FlywayException: Invalid SQL filenames found:
Invalid versioned migration name format: V11_single_underscore.sql (could not recognise version number 11_single_underscore)
Unrecognised migration name format: v12__lowercase_prefix.sql

Một migration lặng lẽ không bao giờ chạy còn tệ hơn một lần khởi động thất bại, nên thiết lập này đáng bật:

src/main/resources/application.properties
spring.flyway.validate-migration-naming=true

Sửa một migration đã chạy

V1 đã chạy trên shop. Giả sử SKU cần 64 ký tự, và thay đổi được sửa ngay chỗ định nghĩa column, cùng với length = 64 trên entity:

src/main/resources/db/migration/V1__create_catalog.sql
CREATE TABLE products (
    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name        VARCHAR(120)   NOT NULL,
    sku         VARCHAR(40)    NOT NULL, 
    sku         VARCHAR(64)    NOT NULL, 
    price       NUMERIC(10, 2) NOT NULL,

Lần khởi động tiếp theo:

Text
2026-09-13T17:31:25.525+07:00 ERROR 42537 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
 
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/hibernate/autoconfigure/HibernateJpaConfiguration.class]: Failed to initialize dependency 'flywayInitializer' of LoadTimeWeaverAware bean 'entityManagerFactory': Error creating bean with name 'flywayInitializer' defined in class path resource [org/springframework/boot/flyway/autoconfigure/FlywayAutoConfiguration$FlywayConfiguration.class]: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : 18327164
-> Resolved locally    : 124720333
Either revert the changes to the migration, or run repair to update the schema history.
Need more flexibility with validation rules? Learn more: https://help.red-gate.com/help/flyway-cli12/help_4.aspx?topic=flyway-blog/older-posts/customize-validation-rules-with-ignoremigrationpatterns
...
Caused by: org.flywaydb.core.api.exception.FlywayValidateException: Validate failed: Migrations have failed validation

validate-on-migratetrue, nên Flyway so từng migration đã chạy với file của nó trước khi chạy bất cứ thứ gì. 18327164 là checksum được ghi khi V1 chạy, 124720333 là checksum của file đã sửa. Không có bước kiểm tra này, thay đổi sẽ không làm gì với shop, nơi V1 không bao giờ chạy lại, mà chỉ tới được các database tạo từ nay về sau, và những database đó sẽ có column sku khác với production.

Thông báo đưa ra hai lối thoát. repair sẽ ghi 124720333 vào history table và che đi sự khác biệt trong khi shop vẫn giữ VARCHAR(40). Lối còn lại mới là đúng ở đây.

Cách sửa đúng là một migration mới

V1 trở về đúng như lúc đã chạy, và thay đổi trở thành version kế tiếp:

src/main/resources/db/migration/V3__widen_product_sku.sql
ALTER TABLE products ALTER COLUMN sku TYPE VARCHAR(64);
Text
2026-09-13T17:31:30.632+07:00  INFO 42568 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 3 migrations (execution time 00:00.011s)
2026-09-13T17:31:30.657+07:00  INFO 42568 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 2
2026-09-13T17:31:30.662+07:00  INFO 42568 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "3 - widen product sku"
2026-09-13T17:31:30.675+07:00  INFO 42568 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v3 (execution time 00:00.006s)
2026-09-13T17:31:31.355+07:00  INFO 42568 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
Bash
docker exec sb-a31-pg psql -U shop -d shop -c '\d products' -c 'select installed_rank, version, description, script, checksum, success from flyway_schema_history order by installed_rank'
Text
                                    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(64)  |           | not null |
 price       | numeric(10,2)          |           | not null |
 stock       | integer                |           | not null |
 category_id | bigint                 |           | not null |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
    "idx_products_category_id" btree (category_id)
    "uk_products_sku" UNIQUE CONSTRAINT, btree (sku)
Foreign-key constraints:
    "fk_products_category" FOREIGN KEY (category_id) REFERENCES categories(id)
Referenced by:
    TABLE "order_lines" CONSTRAINT "fk_order_lines_product" FOREIGN KEY (product_id) REFERENCES products(id)
    TABLE "product_tags" CONSTRAINT "fk_product_tags_product" FOREIGN KEY (product_id) REFERENCES products(id)
 
 installed_rank | version |    description    |          script           |  checksum  | success
----------------+---------+-------------------+---------------------------+------------+---------
              1 | 1       | create catalog    | V1__create_catalog.sql    |   18327164 | t
              2 | 2       | create orders     | V2__create_orders.sql     |  678017735 | t
              3 | 3       | widen product sku | V3__widen_product_sku.sql | 1537025089 | t
(3 rows)

shop có column rộng hơn, V1 vẫn giữ checksum ban đầu, và một database tạo ngày mai nhận đúng column đó qua đúng hai file này. Tên constraint và index là những cái tên V1 đã chọn.

Diễn biến theo thời gian với Flyway 12.4.0 trên PostgreSQL 18.6: V1 đã chạy và được ghi lại với checksum 18327164; file V1 bị sửa thẳng từ VARCHAR(40) thành VARCHAR(64); lần khởi động sau thất bại với Migration checksum mismatch for migration version 1, applied 18327164, resolved locally 124720333; cách sửa là trả V1 về như cũ và thêm V3__widen_product_sku.sql, được ghi thành version 3 với checksum 1537025089

Flyway và Hibernate chia nhau quá trình khởi động thế nào

ddl-auto mặc định là none khi Flyway quản lý database

Bài 26 đọc trong HibernateDefaultDdlAutoProvider rằng Boot đặt create-drop cho database embedded, trừ khi có một công cụ quản lý schema như Flyway đảm nhận nó. Một runner in ra giá trị Hibernate nhận được, trên H2 in-memory không có thiết lập datasource nào:

src/main/java/com/example/demo/lab/DdlAutoInspector.java
package com.example.demo.lab;
 
import java.sql.Connection;
 
import javax.sql.DataSource;
 
import jakarta.persistence.EntityManagerFactory;
 
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
 
@Component
@Profile("inspect")
class DdlAutoInspector implements CommandLineRunner {
 
    private final EntityManagerFactory entityManagerFactory;
    private final DataSource dataSource;
    private final ApplicationContext context;
 
    DdlAutoInspector(EntityManagerFactory entityManagerFactory, DataSource dataSource, ApplicationContext context) {
        this.entityManagerFactory = entityManagerFactory;
        this.dataSource = dataSource;
        this.context = context;
    }
 
    @Override
    public void run(String... args) throws Exception {
        System.out.println("hibernate.hbm2ddl.auto = " + entityManagerFactory.getProperties().get("hibernate.hbm2ddl.auto"));
        System.out.println("Flyway beans           = " + java.util.Arrays.toString(context.getBeanNamesForType(org.flywaydb.core.Flyway.class)));
        try (Connection connection = dataSource.getConnection()) {
            var meta = connection.getMetaData();
            System.out.println("database               = " + meta.getDatabaseProductName() + " " + meta.getDatabaseProductVersion() + " at " + meta.getURL());
        }
    }
}
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8131 --spring.profiles.active=inspect --spring.sql.init.mode=never --spring.main.web-application-type=none
Text
hibernate.hbm2ddl.auto = null
Flyway beans           = [flyway]
database               = H2 2.4.240 (2025-09-22) at jdbc:h2:mem:8e4df5a9-a2c3-4186-b038-3d4ccac95aec

Cùng câu lệnh đó, thêm --spring.flyway.enabled=false:

Text
hibernate.hbm2ddl.auto = create-drop
Flyway beans           = []
database               = H2 2.4.240 (2025-09-22) at jdbc:h2:mem:2601e865-d175-4e70-8110-9f3b73a9097d

null nghĩa là none: Hibernate để schema cho Flyway, thứ vừa dựng nó từ V1 và V2 ngay trước đó. Điều quyết định là Flyway có được bật hay không, chứ không phải jar của nó có mặt hay không: khi tắt Flyway, jar vẫn còn đó và create-drop quay lại. Trên H2, Flyway 12.4.0 còn in ra warning mà nó chưa từng in cho PostgreSQL 18.6:

Text
2026-09-13T17:29:53.712+07:00  WARN 42241 --- [demo] [           main] o.f.c.internal.database.base.Database    : Using H2 2.4.240 which is newer than the version Flyway has been verified with. The latest verified version of H2 is 2.3.232.

--spring.sql.init.mode=never giữ data.sql, chủ đề của phần cuối mục này, ra khỏi lần chạy trên.

Flyway chạy trước EntityManagerFactory

Log của lần chạy đầu tiên đặt mọi dòng của Flyway trước HHH008540, và thứ tự đó được khai báo chứ không phải tình cờ. Mọi lần khởi động thất bại vì Flyway trong bài đều bắt đầu bằng cùng một chuỗi: Failed to initialize dependency 'flywayInitializer' of LoadTimeWeaverAware bean 'entityManagerFactory'. Boot tạo flywayInitializer, bean chạy migration, trước khi dựng EntityManagerFactory, nên một migration thất bại đồng nghĩa với việc Hibernate không bao giờ khởi động. Toàn bộ trình tự, theo log trên shop khi V3 đang chờ chạy:

Sáu bước theo thứ tự kèm log từ Spring Boot 4.1.1, Flyway 12.4.0 và PostgreSQL 18.6: quét classpath:db/migration và tìm thấy V1, V2, V3; đọc flyway_schema_history với version 1 và 2; validate checksum đã ghi, Successfully validated 3 migrations, hoặc Migration checksum mismatch; chạy các version đang chờ theo thứ tự, mỗi migration một transaction, Migrating schema public to version 3, hoặc failed và rollback; ghi một row cho mỗi version, 3 widen product sku 1537025089; khởi động Hibernate với validate, Initialized JPA EntityManagerFactory, hoặc Schema validation: missing column title

ddl-auto=validate so với schema đã migrate

Với spring.jpa.hibernate.ddl-auto=validate trong profile postgres, Hibernate so mapping với các table Flyway đã dựng. Sau V3, việc đổi name thành title ở phần đầu được đưa vào entity mà không có migration:

Text
2026-09-13T17:32:25.824+07:00  INFO 42677 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Schema "public" is up to date. No migration necessary.
2026-09-13T17:32:26.513+07:00 ERROR 42677 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Failed to initialize JPA EntityManagerFactory: Unable to build Hibernate SessionFactory  [persistence unit: default] ; nested exception is org.hibernate.tool.schema.spi.SchemaManagementException: Schema validation: missing column [title] in table [products]

Flyway không có gì để làm, và Hibernate từ chối khởi động. Đây là bước kiểm tra bắt được một thay đổi entity được deploy mà thiếu migration. Nhưng nó không so sánh đầy đủ. Trước khi có V3, entity đã ghi length = 64 trong khi sku vẫn là VARCHAR(40), và lần khởi động đó vẫn qua được validate:

Text
2026-09-13T17:31:28.504+07:00  INFO 42552 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Schema "public" is up to date. No migration necessary.
2026-09-13T17:31:29.195+07:00  INFO 42552 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'

Trong các lần chạy này, validate bắt được column bị thiếu và không so độ dài của varchar. Một column mà entity không map cũng không bị kiểm tra: column description mà V5 thêm sau này không có field nào, và mọi lần khởi động sau đó đều qua.

Đổi tên column bằng V4

Cách sửa cho column bị thiếu là migration trong hình ở phần đầu:

src/main/resources/db/migration/V4__rename_product_name_to_title.sql
ALTER TABLE products RENAME COLUMN name TO title;
Text
2026-09-13T17:32:29.682+07:00  INFO 42696 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 4 migrations (execution time 00:00.012s)
2026-09-13T17:32:29.711+07:00  INFO 42696 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 3
2026-09-13T17:32:29.716+07:00  INFO 42696 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "4 - rename product name to title"
2026-09-13T17:32:29.729+07:00  INFO 42696 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v4 (execution time 00:00.004s)
2026-09-13T17:32:30.430+07:00  INFO 42696 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
Bash
docker exec sb-a31-pg psql -U shop -d shop -c 'select id, title, sku from products order by id' -c 'select installed_rank, version, description, script, checksum, success from flyway_schema_history order by installed_rank'
Text
 id |        title        |  sku
----+---------------------+--------
  1 | Mechanical keyboard | KB-01
  2 | Wireless mouse      | MS-01
  3 | USB-C hub           | HUB-07
(3 rows)
 
 installed_rank | version |         description          |                script                |  checksum  | success
----------------+---------+------------------------------+--------------------------------------+------------+---------
              1 | 1       | create catalog               | V1__create_catalog.sql               |   18327164 | t
              2 | 2       | create orders                | V2__create_orders.sql                |  678017735 | t
              3 | 3       | widen product sku            | V3__widen_product_sku.sql            | 1537025089 | t
              4 | 4       | rename product name to title | V4__rename_product_name_to_title.sql |  715857858 | t
(4 rows)

Ba product giữ nguyên tên dưới tên column mới, validate đi qua, và thay đổi có một row của riêng nó.

data.sql vẫn chạy bên cạnh Flyway

Bài 25 nạp dữ liệu mẫu cho catalogue bằng data.sql. Cùng ý tưởng đó cho các table mới:

src/main/resources/data.sql
INSERT INTO categories (name) VALUES ('Keyboards'), ('Mice'), ('Accessories');

Trên H2 in-memory, với log DEBUG cho org.springframework.jdbc.datasource.init:

Text
2026-09-13T17:29:55.309+07:00  INFO 42243 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 2 migrations to schema "PUBLIC", now at version v2 (execution time 00:00.011s)
2026-09-13T17:29:55.317+07:00 DEBUG 42243 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executing SQL script from URL [jar:nested:/…/demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/data.sql]
2026-09-13T17:29:55.318+07:00 DEBUG 42243 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : 3 returned as update count for SQL: INSERT INTO categories (name) VALUES ('Keyboards'), ('Mice'), ('Accessories')
2026-09-13T17:29:55.319+07:00 DEBUG 42243 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executed SQL script from URL [jar:nested:/…/demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/data.sql] in 1 ms.
2026-09-13T17:29:55.350+07:00  INFO 42243 --- [demo] [           main] org.hibernate.orm.jpa                    : HHH008540: Processing PersistenceUnitInfo [name: default]

Flyway không tắt cơ chế SQL initialization. Boot chạy data.sql sau các migration và trước Hibernate, thêm ba row. Trên PostgreSQL thì file bị bỏ qua, vì spring.sql.init.modeembedded như bài 25 đã đo: lần chạy đầu tiên trên shop có đúng file này trong jar, và các category mà psql thêm sau đó không đụng unique constraint của cột tên. Cùng một project vì thế nạp dữ liệu cho H2 mà không nạp cho production, và không nơi nào ghi lại việc đó.

Hãy xoá data.sql và cho dữ liệu một migration. Dữ liệu mà môi trường nào cũng cần là một versioned migration bình thường trong db/migration. Dữ liệu mẫu cho development đặt ở một location thứ hai:

src/main/resources/db/dev-data/V2_1__sample_products.sql
INSERT INTO categories (name) VALUES ('Keyboards'), ('Mice'), ('Accessories');
 
INSERT INTO products (name, sku, price, stock, category_id) VALUES
    ('Mechanical keyboard', 'KB-01', 89.90, 25, (SELECT id FROM categories WHERE name = 'Keyboards')),
    ('Wireless mouse', 'MS-01', 24.50, 3, (SELECT id FROM categories WHERE name = 'Mice')),
    ('USB-C hub', 'HUB-07', 39.00, 10, (SELECT id FROM categories WHERE name = 'Accessories'));

Chỉ profile development thêm location đó:

src/main/resources/application-dev.properties
spring.flyway.locations=classpath:db/migration,classpath:db/dev-data

Khởi động trên H2 với giá trị đó truyền vào qua --spring.flyway.locations, hai thư mục tạo thành một chuỗi version duy nhất:

Text
2026-09-13T17:31:26.330+07:00  INFO 42539 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 3 migrations (execution time 00:00.004s)
...
2026-09-13T17:31:26.350+07:00  INFO 42539 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "PUBLIC" to version "1 - create catalog"
2026-09-13T17:31:26.362+07:00  INFO 42539 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "PUBLIC" to version "2 - create orders"
2026-09-13T17:31:26.366+07:00  INFO 42539 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "PUBLIC" to version "2.1 - sample products"
2026-09-13T17:31:26.370+07:00  INFO 42539 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 3 migrations to schema "PUBLIC", now at version v2.1 (execution time 00:00.011s)

Điều gì xảy ra khi một migration thất bại

PostgreSQL rollback toàn bộ migration

V5 thêm một column mô tả và một index để tìm theo title, nhưng câu lệnh thứ hai lại viết theo tên column cũ:

src/main/resources/db/migration/V5__add_product_description.sql
ALTER TABLE products ADD COLUMN description VARCHAR(1000);
 
CREATE INDEX idx_products_name ON products (name);
Text
2026-09-13T17:32:32.004+07:00  INFO 42709 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "5 - add product description"
2026-09-13T17:32:32.016+07:00 ERROR 42709 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migration of schema "public" to version "5 - add product description" failed! Changes successfully rolled back.
...
Caused by: org.flywaydb.core.internal.exception.FlywayMigrateException: Failed to execute script V5__add_product_description.sql
--------------------------------------------------------
SQL State  : 42703
Error Code : 0
Message    : ERROR: column "name" does not exist
Location   : db/migration/V5__add_product_description.sql (/…/demo/nested:/…/demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/db/migration/V5__add_product_description.sql)
Line       : 3
Statement  : Run Flyway with -X option to see the actual statement causing the problem

Câu lệnh đầu đã thành công trước khi câu thứ hai thất bại. psql sau đó:

Bash
docker exec sb-a31-pg psql -U shop -d shop -c '\d products'
Text
                                    Table "public.products"
   Column    |          Type          | Collation | Nullable |             Default
-------------+------------------------+-----------+----------+----------------------------------
 id          | bigint                 |           | not null | generated by default as identity
 title       | character varying(120) |           | not null |
 sku         | character varying(64)  |           | not null |
 price       | numeric(10,2)          |           | not null |
 stock       | integer                |           | not null |
 category_id | bigint                 |           | not null |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
    "idx_products_category_id" btree (category_id)
    "uk_products_sku" UNIQUE CONSTRAINT, btree (sku)
Foreign-key constraints:
    "fk_products_category" FOREIGN KEY (category_id) REFERENCES categories(id)
Referenced by:
    TABLE "order_lines" CONSTRAINT "fk_order_lines_product" FOREIGN KEY (product_id) REFERENCES products(id)
    TABLE "product_tags" CONSTRAINT "fk_product_tags_product" FOREIGN KEY (product_id) REFERENCES products(id)

Không có column description, và query trên history table trả về đúng bốn row như sau V4, không có gì cho version 5. PostgreSQL chạy DDL bên trong transaction, Flyway chạy cả file trong một transaction, nên rollback cuốn luôn câu ALTER TABLE; bản thân transaction là chủ đề của bài 30. Vì version 5 chưa từng chạy ở đâu, file của nó vẫn sửa được:

src/main/resources/db/migration/V5__add_product_description.sql
ALTER TABLE products ADD COLUMN description VARCHAR(1000);
 
CREATE INDEX idx_products_name ON products (name); 
CREATE INDEX idx_products_title ON products (title); 

V5 đã sửa chạy ở lần khởi động sau, log của lần đó nằm trong phần repeatable migration.

MySQL 8.4 giữ lại một nửa

MySQL commit từng câu DDL riêng lẻ. Hai câu lệnh y hệt chạy thành version 2 trên mysql:8.4, sau một V1 viết cho MySQL tạo products với id, titlesku, và build được thêm org.flywaydb:flyway-mysql cùng com.mysql:mysql-connector-j:

Text
2026-09-13T17:37:59.639+07:00  INFO 44431 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema `shop` to version "2 - add product description"
2026-09-13T17:37:59.657+07:00 ERROR 44431 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migration of schema `shop` to version "2 - add product description" failed! Please restore backups and roll back database and code!
...
SQL State  : 42000
Error Code : 1072
Message    : Key column 'name' doesn't exist in table
Bash
docker exec sb-a31-mysql mysql -t -ushop -psecret shop -e "describe products; select installed_rank, version, description, type, script, checksum, success from flyway_schema_history order by installed_rank;" 2>/dev/null
Text
+-------------+---------------+------+-----+---------+----------------+
| Field       | Type          | Null | Key | Default | Extra          |
+-------------+---------------+------+-----+---------+----------------+
| id          | bigint        | NO   | PRI | NULL    | auto_increment |
| title       | varchar(120)  | NO   |     | NULL    |                |
| sku         | varchar(64)   | NO   |     | NULL    |                |
| description | varchar(1000) | YES  |     | NULL    |                |
+-------------+---------------+------+-----+---------+----------------+
+----------------+---------+-------------------------+------+---------------------------------+-------------+---------+
| installed_rank | version | description             | type | script                          | checksum    | success |
+----------------+---------+-------------------------+------+---------------------------------+-------------+---------+
|              1 | 1       | create products         | SQL  | V1__create_products.sql         |  1839980680 |       1 |
|              2 | 2       | add product description | SQL  | V2__add_product_description.sql | -1426013997 |       0 |
+----------------+---------+-------------------------+------+---------------------------------+-------------+---------+

Column của câu lệnh đầu vẫn nằm lại, và version 2 có một row với success bằng 0. Lần khởi động tiếp theo từ chối đi tiếp:

Text
Caused by: org.flywaydb.core.api.exception.FlywayValidateException: Validate failed: Migrations have failed validation
Detected failed migration to version 2 (add product description).
Please remove any half-completed changes then run repair to fix the schema history.

Trên MySQL, mỗi migration một câu DDL giúp một lần thất bại không để lại nửa migration.

Khi nào dùng flyway repair là hợp lý

repair làm hai việc, và log của nó nêu tên cả hai: xoá row của các migration thất bại, và cập nhật checksum đã ghi cho khớp với file. Ở ví dụ MySQL trên, thông báo yêu cầu việc thứ nhất sau khi column bị thêm dở đã được drop bằng tay. Việc thứ hai hợp lý khi một file thay đổi mà không đổi những gì nó làm. Một dòng comment thêm vào V1 lúc review code là thay đổi như vậy:

src/main/resources/db/migration/V1__create_catalog.sql
-- Catalogue tables: categories, tags, products and the product_tags join table
CREATE TABLE categories (

Nếu có một bean FlywayMigrationStrategy, Boot gọi nó thay vì tự chạy migrate(). Hai bean như thế, mỗi bean sau một profile riêng, đảm nhận lần repair ở đây và lần thử clean ở phần sau:

src/main/java/com/example/demo/lab/FlywayLab.java
package com.example.demo.lab;
 
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
 
@Configuration(proxyBeanMethods = false)
class FlywayLab {
 
    @Bean
    @Profile("repair")
    FlywayMigrationStrategy repairThenMigrate() {
        return flyway -> {
            flyway.repair();
            flyway.migrate();
        };
    }
 
    @Bean
    @Profile("clean")
    FlywayMigrationStrategy cleanThenMigrate() {
        return flyway -> {
            flyway.clean();
            flyway.migrate();
        };
    }
}
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8131 --spring.profiles.active=postgres,repair --spring.main.web-application-type=none
Text
2026-09-13T17:34:44.115+07:00  INFO 43317 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Repair of failed migration in Schema History table "public"."flyway_schema_history" not necessary. No failed migration detected.
2026-09-13T17:34:44.127+07:00  INFO 43317 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Repairing Schema History table for version 1 (Description: create catalog, Type: SQL, Checksum: 115815086)  ...
2026-09-13T17:34:44.130+07:00  INFO 43317 --- [demo] [           main] o.f.core.internal.command.DbRepair       : Successfully repaired schema history table "public"."flyway_schema_history" (execution time 00:00.024s).
...
2026-09-13T17:34:44.181+07:00  INFO 43317 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 5
2026-09-13T17:34:44.182+07:00  INFO 43317 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Schema "public" is up to date. No migration necessary.
Bash
docker exec sb-a31-pg psql -U shop -d shop -c "select version, description, checksum from flyway_schema_history where version = '1'"
Text
 version |  description   | checksum
---------+----------------+-----------
 1       | create catalog | 115815086
(1 row)

Chỉ một dòng comment đã đổi checksum của V1 từ 18327164 thành 115815086, nên nếu không có repair nó cũng chặn khởi động giống lần sửa SKU. Khởi động một lần với profile này, rồi bỏ nó đi: nếu để nguyên, strategy sẽ lặng lẽ repair mọi thay đổi ở mọi lần khởi động, kể cả lần sửa SKU. Bên ngoài application, command-line tool của Flyway có lệnh repair tương tự; bài này không dùng nó.

Repeatable migration với prefix R__

File có tên bắt đầu bằng R__ không có version. Flyway chạy nó sau tất cả versioned migration đang chờ và chạy lại mỗi khi checksum của nó đổi, rất hợp với những object được thay nguyên khối, như một view:

src/main/resources/db/migration/R__product_catalog_view.sql
CREATE OR REPLACE VIEW product_catalog AS
SELECT p.id, p.sku, p.title, c.name AS category, p.price, p.stock
FROM products p
JOIN categories c ON c.id = p.category_id;

File này vào cùng build với V5 đã sửa và chạy ngay sau nó:

Text
2026-09-13T17:32:33.614+07:00  INFO 42742 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "5 - add product description"
2026-09-13T17:32:33.629+07:00  INFO 42742 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" with repeatable migration "product catalog view"
2026-09-13T17:32:33.637+07:00  INFO 42742 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 2 migrations to schema "public", now at version v5 (execution time 00:00.009s)

Sau đó view có thêm cờ còn hàng:

src/main/resources/db/migration/R__product_catalog_view.sql
CREATE OR REPLACE VIEW product_catalog AS
SELECT p.id, p.sku, p.title, c.name AS category, p.price, p.stock 
SELECT p.id, p.sku, p.title, c.name AS category, p.price, p.stock, p.stock > 0 AS in_stock 
FROM products p
JOIN categories c ON c.id = p.category_id;
Text
2026-09-13T17:32:35.992+07:00  INFO 42755 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 7 migrations (execution time 00:00.014s)
2026-09-13T17:32:36.020+07:00  INFO 42755 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 5
2026-09-13T17:32:36.025+07:00  INFO 42755 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" with repeatable migration "product catalog view"
2026-09-13T17:32:36.038+07:00  INFO 42755 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public" (execution time 00:00.004s)
Bash
docker exec sb-a31-pg psql -U shop -d shop -c 'select installed_rank, version, description, type, script, checksum, success from flyway_schema_history order by installed_rank' -c 'select * from product_catalog order by id'
Text
 installed_rank | version |         description          | type |                script                |  checksum  | success
----------------+---------+------------------------------+------+--------------------------------------+------------+---------
              1 | 1       | create catalog               | SQL  | V1__create_catalog.sql               |   18327164 | t
              2 | 2       | create orders                | SQL  | V2__create_orders.sql                |  678017735 | t
              3 | 3       | widen product sku            | SQL  | V3__widen_product_sku.sql            | 1537025089 | t
              4 | 4       | rename product name to title | SQL  | V4__rename_product_name_to_title.sql |  715857858 | t
              5 | 5       | add product description      | SQL  | V5__add_product_description.sql      |  809655632 | t
              6 |         | product catalog view         | SQL  | R__product_catalog_view.sql          | -439945004 | t
              7 |         | product catalog view         | SQL  | R__product_catalog_view.sql          | 1231863337 | t
(7 rows)
 
 id |  sku   |        title        |  category   | price | stock | in_stock
----+--------+---------------------+-------------+-------+-------+----------
  1 | KB-01  | Mechanical keyboard | Keyboards   | 89.90 |    25 | t
  2 | MS-01  | Wireless mouse      | Mice        | 24.50 |     3 | t
  3 | HUB-07 | USB-C hub           | Accessories | 39.00 |    10 | t
(3 rows)
  • Repeatable migration không có version, và mỗi lần chạy thêm một row: 6 và 7 là hai trạng thái của view, với checksum khác nhau.
  • File không đổi thì không chạy lại. Lần khởi động sau đó với profile repair, khi file view không bị động tới, kết thúc bằng Schema "public" is up to date. No migration necessary.
  • File phải an toàn khi chạy nhiều lần, vì thế nó dùng CREATE OR REPLACE VIEW.

Thư mục lúc này:

Tree
src/main/resources/db
├── dev-data
│   └── V2_1__sample_products.sql
└── migration
    ├── R__product_catalog_view.sql
    ├── V1__create_catalog.sql
    ├── V2__create_orders.sql
    ├── V3__widen_product_sku.sql
    ├── V4__rename_product_name_to_title.sql
    └── V5__add_product_description.sql

clean-disabled và out-of-order

clean drop mọi object trong các schema mà Flyway quản lý, gồm cả table, view và history table, theo mô tả của tài liệu Flyway về lệnh này. Boot 4.1.1 đặt spring.flyway.clean-disabled=true. Profile clean của FlywayLab gọi flyway.clean() trên shop:

Text
Caused by: org.flywaydb.core.api.FlywayException: Unable to execute clean as it has been disabled with the 'flyway.cleanDisabled' property.

Sau đó \dt vẫn liệt kê đủ bảy table và \dv vẫn còn view: lời gọi ném exception trước khi đụng vào bất cứ thứ gì. Hãy giữ nguyên giá trị mặc định cho mọi database có data quan trọng.

spring.flyway.out-of-order liên quan tới một version thấp hơn version đã chạy. Trong database naming, đang ở version 10, một file mới V3__late.sql chặn khởi động:

Text
Caused by: org.flywaydb.core.api.exception.FlywayValidateException: Validate failed: Migrations have failed validation
Detected resolved migration not applied to database: 3.
To ignore this migration, set -ignoreMigrationPatterns='*:ignored'. To allow executing this migration, set -outOfOrder=true.

Với --spring.flyway.out-of-order=true nó chạy, kèm một warning: outOfOrder mode is active. Migration of schema "public" may not be reproducible. Tình huống này xuất hiện khi hai branch cùng thêm migration; đặt cho migration đến sau số tiếp theo còn trống thường gọn hơn là bật thiết lập này.

Đưa một database có sẵn vào Flyway bằng baseline

Database legacy là bản sao của ddlauto tạo trước các lần đổi tên: sáu table do update dựng, ba product, không có history table. Bản build có Flyway khởi động trên nó:

Text
Caused by: org.flywaydb.core.api.FlywayException: Found non-empty schema(s) "public" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.

Flyway không chạy V1 chồng lên các table đã tồn tại. Baseline cho nó biết database đã tương ứng với version nào. legacy có đủ những gì V1 và V2 tạo, nên baseline là version 2:

src/main/resources/application-postgres.properties
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=2

Truyền qua command line cho lần chạy trên legacy:

Text
2026-09-13T17:34:46.935+07:00  INFO 43331 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Schema history table "public"."flyway_schema_history" does not exist yet
2026-09-13T17:34:46.936+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 6 migrations (execution time 00:00.009s)
2026-09-13T17:34:46.945+07:00  INFO 43331 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Creating Schema History table "public"."flyway_schema_history" with baseline ...
2026-09-13T17:34:46.959+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbBaseline     : Successfully baselined schema with version: 2
2026-09-13T17:34:46.983+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 2
2026-09-13T17:34:46.986+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "3 - widen product sku"
2026-09-13T17:34:46.995+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "4 - rename product name to title"
2026-09-13T17:34:47.001+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "5 - add product description"
2026-09-13T17:34:47.008+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" with repeatable migration "product catalog view"
2026-09-13T17:34:47.014+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 4 migrations to schema "public", now at version v5 (execution time 00:00.007s)
2026-09-13T17:34:47.683+07:00  INFO 43331 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
Bash
docker exec sb-a31-pg psql -U shop -d legacy -c 'select installed_rank, version, description, type, script, checksum, success from flyway_schema_history order by installed_rank' -c '\d products'
Text
 installed_rank | version |         description          |   type   |                script                |  checksum  | success
----------------+---------+------------------------------+----------+--------------------------------------+------------+---------
              1 | 2       | << Flyway Baseline >>        | BASELINE | << Flyway Baseline >>                |            | t
              2 | 3       | widen product sku            | SQL      | V3__widen_product_sku.sql            | 1537025089 | t
              3 | 4       | rename product name to title | SQL      | V4__rename_product_name_to_title.sql |  715857858 | t
              4 | 5       | add product description      | SQL      | V5__add_product_description.sql      |  809655632 | t
              5 |         | product catalog view         | SQL      | R__product_catalog_view.sql          | 1231863337 | t
(5 rows)
 
                                     Table "public.products"
   Column    |          Type           | Collation | Nullable |             Default
-------------+-------------------------+-----------+----------+----------------------------------
 id          | bigint                  |           | not null | generated by default as identity
 title       | character varying(120)  |           | not null |
 price       | numeric(10,2)           |           | not null |
 sku         | character varying(64)   |           | not null |
 stock       | integer                 |           | not null |
 category_id | bigint                  |           | not null |
 description | character varying(1000) |           |          |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
    "idx_products_title" btree (title)
    "ukfhmd06dsmj6k0n90swsh8ie9g" UNIQUE CONSTRAINT, btree (sku)
Foreign-key constraints:
    "fkog2rp4qthbtt2lfyhfo32lsw9" FOREIGN KEY (category_id) REFERENCES categories(id)
Referenced by:
    TABLE "product_tags" CONSTRAINT "fk5rk6s19k3risy7q7wqdr41uss" FOREIGN KEY (product_id) REFERENCES products(id)
    TABLE "order_lines" CONSTRAINT "fk5v1oeejtgtf2n3toppm3tkuhh" FOREIGN KEY (product_id) REFERENCES products(id)
  • Row đầu tiên là baseline, type BASELINE, version 2, không có checksum. V1 và V2 chưa từng chạy trên legacy; V3, V4, V5 và view thì có.
  • Phần còn lại của schema vẫn là thứ update đã dựng. Unique constraint là ukfhmd06dsmj6k0n90swsh8ie9g, foreign key là fkog2rp4qthbtt2lfyhfo32lsw9, và category_id không có index. Một database dựng từ V1 lại có uk_products_skuidx_products_category_id, nên một migration sau này gọi tới các tên đó sẽ không tìm thấy gì trên legacy. Hãy làm hai bên khớp nhau bằng một migration riêng trước khi có migration nào phụ thuộc vào tên constraint.
  • Bỏ baseline-on-migrate sau lần chạy đầu. Nếu để nguyên, Flyway sẽ baseline bất kỳ schema nào có dữ liệu mà không có history table thay vì từ chối, trong khi chính lời từ chối đó là phần hữu ích của lần thử đầu tiên. Đặt baseline-version là version cao nhất mà database đã có đủ thay đổi; mọi version cao hơn sẽ chạy.

Sinh migration đầu tiên từ entity

Tự viết V1 cho một model đã có sẵn thì chậm, và Hibernate có thể viết bản nháp. Các property schema-generation của Jakarta Persistence sinh ra một script thay vì thay đổi database; với Flyway và ddl-auto đều tắt, application ghi ra script cho các entity hiện tại:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8131 --spring.profiles.active=postgres --spring.flyway.enabled=false --spring.jpa.hibernate.ddl-auto=none --spring.jpa.properties.jakarta.persistence.schema-generation.scripts.action=create --spring.jpa.properties.jakarta.persistence.schema-generation.scripts.create-target=build/generated-schema/create.sql --spring.main.web-application-type=none
build/generated-schema/create.sql
create table categories (id bigint generated by default as identity, name varchar(60) not null unique, primary key (id));
create table order_lines (quantity integer not null, unit_price numeric(10,2) not null, id bigint generated by default as identity, order_id bigint not null, product_id bigint not null, primary key (id));
create table orders (id bigint generated by default as identity, primary key (id));
create table product_tags (product_id bigint not null, tag_id bigint not null, primary key (product_id, tag_id));
create table products (price numeric(10,2) not null, stock integer not null, category_id bigint not null, id bigint generated by default as identity, sku varchar(64) not null unique, title varchar(120) not null, primary key (id));
create table tags (id bigint generated by default as identity, name varchar(40) not null unique, primary key (id));
alter table if exists order_lines add constraint FK1smc0s578t2oih21yn9hw6usr foreign key (order_id) references orders;
alter table if exists order_lines add constraint FK5v1oeejtgtf2n3toppm3tkuhh foreign key (product_id) references products;
alter table if exists product_tags add constraint FKpur2885qb9ae6fiquu77tcv1o foreign key (tag_id) references tags;
alter table if exists product_tags add constraint FK5rk6s19k3risy7q7wqdr41uss foreign key (product_id) references products;
alter table if exists products add constraint FKog2rp4qthbtt2lfyhfo32lsw9 foreign key (category_id) references categories;
  • Hibernate tự tạo thư mục build/generated-schema, viết mỗi câu lệnh trên một dòng và kết thúc bằng ;. shop không bị đụng tới: \dt vẫn liệt kê bảy table và history table vẫn có bảy row.
  • Script là bản nháp, không phải V1. Foreign key mang tên sinh tự động, unique constraint không có tên, không column foreign key nào có index, và thứ tự column là của Hibernate. Hãy review, đặt tên cho constraint, thêm index, rồi mới lưu nó thành versioned migration đầu tiên.
  • Lần chạy thứ hai ghi nối vào file. Khi thêm --spring.jpa.properties.hibernate.format_sql=true, file không bị thay: một bản đã format của cùng các câu lệnh được nối sau bản đầu, và create.sql tăng lên 78 dòng. Hãy xoá file trước khi sinh lại.

Liquibase ngắn gọn

Liquibase là công cụ migration còn lại mà Spring Boot tự cấu hình. Một bản sao của project thay các dòng Flyway bằng các dòng sau:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-liquibase'
	testImplementation 'org.springframework.boot:spring-boot-starter-liquibase-test'
}

Jar chứa liquibase-core-5.0.3.jar, snakeyaml-2.6.jaropencsv-5.12.0.jar, và không cần module riêng cho PostgreSQL. Metadata của Boot đặt giá trị mặc định của spring.liquibase.change-logclasspath:/db/changelog/db.changelog-master.yaml, và Initializr tạo sẵn src/main/resources/db/changelog, để trống. Một changeset:

src/main/resources/db/changelog/db.changelog-master.yaml
databaseChangeLog:
  - changeSet:
      id: create-categories
      author: catalog-team
      changes:
        - createTable:
            tableName: categories
            columns:
              - column:
                  name: id
                  type: BIGINT
                  autoIncrement: true
                  constraints:
                    primaryKey: true
              - column:
                  name: name
                  type: VARCHAR(60)
                  constraints:
                    nullable: false
                    unique: true
                    uniqueConstraintName: uk_categories_name

Trên một database mới, liqui, rút gọn:

Text
2026-09-13T17:38:04.011+07:00  INFO 44447 --- [demo] [           main] liquibase.changelog                      : Creating database changelog table with name: public.databasechangelog
2026-09-13T17:38:04.072+07:00  INFO 44447 --- [demo] [           main] liquibase.lockservice                    : Successfully acquired change log lock
2026-09-13T17:38:04.080+07:00  INFO 44447 --- [demo] [           main] liquibase.ui                             : Running Changeset: db/changelog/db.changelog-master.yaml::create-categories::catalog-team
2026-09-13T17:38:04.086+07:00  INFO 44447 --- [demo] [           main] liquibase.changelog                      : Table categories created
2026-09-13T17:38:04.087+07:00  INFO 44447 --- [demo] [           main] liquibase.changelog                      : ChangeSet db/changelog/db.changelog-master.yaml::create-categories::catalog-team ran successfully in 7ms
2026-09-13T17:38:04.092+07:00  INFO 44447 --- [demo] [           main] liquibase.util                           : UPDATE SUMMARY
2026-09-13T17:38:04.092+07:00  INFO 44447 --- [demo] [           main] liquibase.util                           : Run:                          1
2026-09-13T17:38:04.092+07:00  INFO 44447 --- [demo] [           main] liquibase.util                           : Previously run:               0
...
2026-09-13T17:38:04.093+07:00  INFO 44447 --- [demo] [           main] liquibase.ui                             : Liquibase: Update has been successful. Rows affected: 0
2026-09-13T17:38:04.096+07:00  INFO 44447 --- [demo] [           main] liquibase.lockservice                    : Successfully released change log lock
2026-09-13T17:38:04.790+07:00  INFO 44447 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
Bash
docker exec sb-a31-pg psql -U shop -d liqui -c '\dt' -c '\d categories' -c 'select id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, liquibase from databasechangelog'
Text
                 List of tables
 Schema |         Name          | Type  | Owner
--------+-----------------------+-------+-------
 public | categories            | table | shop
 public | databasechangelog     | table | shop
 public | databasechangeloglock | table | shop
(3 rows)
 
                                Table "public.categories"
 Column |         Type          | Collation | Nullable |             Default
--------+-----------------------+-----------+----------+----------------------------------
 id     | bigint                |           | not null | generated by default as identity
 name   | character varying(60) |           | not null |
Indexes:
    "categories_pkey" PRIMARY KEY, btree (id)
    "uk_categories_name" UNIQUE CONSTRAINT, btree (name)
 
        id         |    author    |               filename                |        dateexecuted        | orderexecuted | exectype |               md5sum               |           description            | liquibase
-------------------+--------------+---------------------------------------+----------------------------+---------------+----------+------------------------------------+----------------------------------+-----------
 create-categories | catalog-team | db/changelog/db.changelog-master.yaml | 2026-09-13 17:38:04.087142 |             1 | EXECUTED | 9:46b21e15b2b647823a77b4c3c475729f | createTable tableName=categories | 5.0.3
(1 row)

Một changeset được xác định bằng id, author và file changelog chứa nó, cả ba đều nằm trong databasechangelog, còn md5sum là checksum của nó. databasechangeloglock giữ lock mà log cho thấy đã được lấy và nhả ra quanh lần update. Column BIGINT với autoIncrement trở thành generated by default as identity: Liquibase viết DDL cho đúng database nó gặp.

Flyway so với Liquibase

Flyway 12.4.0Liquibase 5.0.3
Starter trong Boot 4.1.1spring-boot-starter-flywayspring-boot-starter-liquibase
Hỗ trợ PostgreSQLmodule riêng, flyway-database-postgresqlcó sẵn trong liquibase-core
Location mặc địnhclasspath:db/migrationclasspath:/db/changelog/db.changelog-master.yaml
Đơn vị thay đổimột file, V3__widen_product_sku.sqlmột changeset trong changelog, create-categories của catalog-team
Thứ tựversion trong tên filevị trí trong changelog
Thứ bạn viếtSQL cho một databasecác change type như createTable, được chuyển thành DDL cho database mà nó gặp
Lịch sửflyway_schema_historydatabasechangelogdatabasechangeloglock
Checksum được ghi183271649:46b21e15b2b647823a77b4c3c475729f

Mỗi project chọn một công cụ. Series này tiếp tục với Flyway: migration của nó là SQL PostgreSQL thuần, và bài tiếp theo sẽ thêm một migration nữa.

Các giá trị ddl-auto so với versioned migration trên database production

Cách làmLúc khởi động, trên database đã có dataĐã thấy trong bài
nonekhông làm gì; schema phải đến từ nơi khácmặc định của Boot trên PostgreSQL, và trên H2 khi Flyway được bật
validateso mapping với các table và không thay đổi gìdừng ở missing column [title]; chấp nhận length = 64 so với VARCHAR(40)
updatethêm table và column, sửa kiểu của column; đổi tên thành một column mớimột column title rỗng, hoặc một lệnh ALTER thất bại chỉ ghi WARN; numeric(10,0) làm tròn 89.90 thành 90
createdrop các table được map rồi tạo lạimỗi lần restart bắt đầu với table rỗng (bài 26)
create-dropnhư create, và drop thêm lần nữa khi tắtmặc định của Boot trên H2 khi tắt Flyway
Versioned migration của Flywaychạy mỗi file đang chờ đúng một lần, theo thứ tự version, trong transaction riêng trên PostgreSQL, và ghi lạiV4 đổi tên column cùng data; V1 bị sửa chặn khởi động; V5 lỗi được rollback và không có row trong history

FAQ

Vì sao không nên dùng ddl-auto=update ở production?

Vì nó thay đổi schema đang chạy dựa trên những entity class tình cờ được deploy, không qua review và không để lại dấu vết. Trên PostgreSQL 18.6 với Hibernate 7.4.5, đổi tên một field tạo ra một column rỗng và để data nằm lại ở column cũ, rồi NOT NULL của column cũ chặn mọi câu INSERT; với nullable = false lệnh ALTER thất bại và chỉ có một WARN; còn bỏ scale = 2 làm tròn mọi giá. Database không ghi lại bất cứ điều gì trong số đó.

"Unsupported Database: PostgreSQL 18.6" trong Flyway nghĩa là gì?

Nghĩa là thiếu flyway-database-postgresql. Trong Flyway 12.4.0, flyway-core không chứa phần hỗ trợ PostgreSQL; module kia mới có. Thêm org.flywaydb:flyway-database-postgresql cạnh spring-boot-starter-flyway, như Spring Initializr làm khi chọn PostgreSQL.

Spring Boot tìm Flyway migration ở đâu?

classpath:db/migration, tức src/main/resources/db/migration trong project Gradle hay Maven. Trong Spring Boot 4.1.1, giá trị mặc định được đặt trong constructor của FlywayProperties, không nằm trong property metadata. spring.flyway.locations thay đổi nó và nhận nhiều location, được Flyway gộp thành một chuỗi version.

Sửa lỗi "Migration checksum mismatch" trong Flyway thế nào?

Trả migration về đúng như lúc đã chạy và chuyển thay đổi sang một versioned migration mới, như V3 đã làm với SKU ở đây. Chỉ dùng repair khi thay đổi không đổi những gì migration làm, chẳng hạn thêm một comment, hoặc để xoá row của migration thất bại trên database không có transactional DDL, và chạy nó một lần, ví dụ qua một bean FlywayMigrationStrategy đặt sau một profile.

data.sql có còn chạy khi bật Flyway không?

Có, trên database embedded. Với Spring Boot 4.1.1 và H2, data.sql chạy sau các migration của Flyway và trước khi Hibernate khởi động. Trên PostgreSQL, giá trị mặc định spring.sql.init.mode=embedded bỏ qua nó. Dữ liệu mà môi trường nào cũng cần thì đưa vào một versioned migration, còn dữ liệu cho development thì đặt ở một location thứ hai mà chỉ profile development thêm vào.

Thêm Flyway vào một database có sẵn thế nào?

Đặt spring.flyway.baseline-on-migrate=truespring.flyway.baseline-version bằng version cuối cùng mà database đã khớp, khởi động một lần, rồi bỏ thiết lập thứ nhất. Không có nó, Flyway 12.4.0 dừng với Found non-empty schema(s) "public" but no schema history table. Hãy chuẩn bị tinh thần rằng tên constraint và index sẽ khác với một database dựng từ các migration.

Kết luận

ddl-auto sinh DDL từ những class tình cờ được deploy. Trên một database PostgreSQL có data, update biến một field bị đổi tên thành một column rỗng hoặc một WARN, bỏ data lại sau một NOT NULL chặn mọi câu INSERT, và làm tròn giá khi scale biến mất, mà không ghi lại gì cả. Flyway biến mỗi thay đổi thành một file SQL đánh số, chạy đúng một lần trên mỗi database, theo thứ tự version, trước khi Hibernate khởi động. flyway_schema_history giữ checksum của từng file, nên một V1 bị sửa chặn khởi động cho tới khi thay đổi được chuyển sang V3; một V5 lỗi được rollback trọn vẹn trên PostgreSQL trong khi MySQL giữ lại một nửa; và validate từ chối một entity có column không đi kèm migration.

Trong Spring Boot 4.1.1, điều đó có nghĩa là spring-boot-starter-flyway cộng với flyway-database-postgresql, mà khi thiếu sẽ hiện ra dưới dạng Unsupported Database: PostgreSQL 18.6, migration nằm trong classpath:db/migration, ddl-autonone hoặc validate, và không dùng data.sql, file vẫn chạy cạnh Flyway trên H2. Một database có sẵn được đưa vào bằng một lần baseline, view thuộc về repeatable migration, clean luôn bị tắt, và script do Hibernate sinh ra là bản nháp cần review trước khi trở thành migration.

Bài tiếp theo xây trên các table này với auditing: @CreatedDate, @LastModifiedDate@CreatedBy ghi lại ai tạo, ai sửa một row và vào lúc nào, cùng một migration mới để thêm các column của chúng.

Bài viết liên quan

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Spring Boot Basics] Tài liệu API trong Spring Boot với springdoc-openapi và Swagger UI

springdoc-openapi trên Spring Boot 4.1.1: document OpenAPI 3.1 ở /v3/api-docs, Swagger UI và Try it out, những gì springdoc suy ra từ controller, DTO record và Bean Validation constraint, response nào của @RestControllerAdvice được thêm vào, @Tag, @Operation, @ApiResponse, @Parameter và @Schema trên record, bean OpenAPI và customizer toàn cục, GroupedOpenApi, property của springdoc và tắt tài liệu trong profile prod.

[Spring Boot Basics] Auto-configuration trong Spring Boot hoạt động ra sao: conditional, back-off và báo cáo --debug

Mổ xẻ và đo đạc cơ chế auto-configuration của Spring Boot 4.1.1: @EnableAutoConfiguration và AutoConfigurationImportSelector, các file META-INF/spring/…AutoConfiguration.imports mà Boot 4 tách ra nhiều module nhỏ, họ annotation @ConditionalOnClass / @ConditionalOnMissingBean cùng một Condition tự viết, màn demo back-off có số liệu trước và sau, và cách đọc báo cáo CONDITIONS EVALUATION REPORT từ --debug.

[Spring Boot Basics] IoC và Dependency Injection trong Spring: vì sao không còn tự new object

Ý tưởng mà cả framework dựa lên, demo trên Spring Boot 4.1.1: một object graph bốn class tự new ở mọi tầng cùng ba hậu quả kéo theo, phân biệt rạch ròi Inversion of Control với Dependency Injection, nối tay cùng graph đó trong main mà không có framework nào, rồi để Spring container nối và in identity của từng instance ra để chứng minh, kèm một test JUnit 5 với stub tự viết, một lần đổi implementation mà không đụng vào class đang dùng nó, và danh sách thành thật những cái giá phải trả.