Tampilan
Kenapa penting
Kode aplikasi punya Git, code review, dan riwayat versi. Skema database harus diperlakukan sama: setiap perubahan tabel, kolom, dan index adalah bagian dari rilis. Tanpa migrasi terversi, laptop developer, server staging, dan production pelan-pelan punya skema yang berbeda, dan tidak ada yang tahu perubahan mana yang sudah jalan di mana. Bug "di laptopku jalan" yang paling menyakitkan sering berasal dari sini. Flyway menyelesaikannya dengan file SQL bernomor yang dijalankan berurutan, tepat sekali, dan dicatat di database — sehingga satu git checkout sudah cukup untuk mereproduksi skema versi mana pun.
Konsep inti
Kenapa bukan ddl-auto
Di bab-bab sebelumnya Hibernate bisa membuat tabel sendiri lewat spring.jpa.hibernate.ddl-auto. Nilai create, create-drop, dan update nyaman untuk eksperimen, tetapi berbahaya di production:
| Nilai | Perilaku | Masalah di production |
|---|---|---|
create-drop | buat tabel saat start, hapus saat berhenti | data hilang setiap restart |
create | hapus lalu buat ulang tabel saat start | data hilang setiap deploy |
update | tambah tabel/kolom yang kurang | tidak pernah menghapus atau me-rename, tidak bisa backfill, tidak bisa di-review |
validate | hanya mencocokkan entity dengan skema | aman — tidak mengubah apa pun |
none | tidak melakukan apa-apa | aman, tapi tanpa pengaman |
update terlihat menggoda, tetapi hasilnya bergantung pada urutan deploy dan isi database saat itu, tidak ada file yang bisa di-review, dan rename kolom akan berakhir sebagai kolom baru yang kosong. Aturan kerjanya sederhana: Flyway yang mengubah skema, Hibernate hanya memvalidasi.
Menyiapkan Flyway di Spring Boot 4
Spring Boot 4 punya starter khusus Flyway. Untuk PostgreSQL, sejak Flyway 10 dukungan database dipisah ke modul tersendiri, jadi kamu butuh dua dependency:
xml
<!-- Potongan pom.xml — versi diatur oleh Spring Boot parent -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>Tanpa flyway-database-postgresql, aplikasi gagal start dengan pesan bahwa database PostgreSQL tidak didukung. Konfigurasinya:
yaml
# application.yml (potongan)
spring:
datasource:
url: jdbc:postgresql://localhost:5432/shop
username: shop
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # Hibernate hanya memeriksa, Flyway yang mengubah
flyway:
locations: classpath:db/migration # default, boleh tidak ditulisSaat aplikasi start, Spring Boot menjalankan Flyway sebelum EntityManagerFactory siap. Artinya migrasi selesai dulu, baru Hibernate memvalidasi skema hasilnya.
Menulis migrasi pertama
File migrasi diletakkan di src/main/resources/db/migration dengan pola V<versi>__<deskripsi>.sql: huruf V besar, nomor versi, dua garis bawah, lalu deskripsi dengan garis bawah sebagai spasi.
text
src/main/resources/db/migration/
├── V1__create_orders.sql
├── V2__index_orders_customer_id.sql
└── V3__add_order_number.sqlsql
-- V1__create_orders.sql
CREATE TABLE customers (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(200) NOT NULL
);
CREATE TABLE orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers (id),
status VARCHAR(20) NOT NULL,
total NUMERIC(19, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
version BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE order_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders (id),
sku VARCHAR(40) NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(19, 2) NOT NULL
);Kolom version dipakai @Version untuk optimistic locking dari bab transaksi. Versi dibandingkan sebagai angka, jadi V10 jalan sesudah V9. Banyak tim memakai stempel waktu (V20261023_1__...) agar dua branch tidak berebut nomor yang sama.
Tabel flyway_schema_history
Flyway mencatat setiap migrasi yang sudah dijalankan di tabel flyway_schema_history. Kamu bisa melihatnya:
sql
SELECT installed_rank, version, description, checksum, success
FROM flyway_schema_history
ORDER BY installed_rank;Setiap start, Flyway membandingkan file di classpath dengan catatan ini: migrasi yang belum tercatat dijalankan berurutan, yang sudah tercatat dicek checksum-nya. Di PostgreSQL, setiap migrasi berjalan dalam transaksi, jadi migrasi yang gagal di tengah tidak meninggalkan setengah perubahan — DDL PostgreSQL ikut di-rollback.
Jangan mengedit migrasi yang sudah jalan
Begitu sebuah migrasi sudah dijalankan di environment bersama (staging, production), file itu beku. Mengubah isinya membuat checksum berbeda, dan Flyway menolak start dengan error checksum mismatch saat validasi. Perbaikan selalu ditulis sebagai migrasi baru. Perintah flyway repair memang bisa menyamakan checksum, tetapi itu untuk membereskan kecelakaan, bukan alur kerja normal. Sebelum di-merge, mengedit migrasi di branch sendiri masih wajar.
Migrasi tanpa downtime: expand–contract
Saat rolling deploy, versi aplikasi lama dan baru berjalan bersamaan beberapa menit, padahal migrasi sudah jalan. Maka setiap migrasi harus kompatibel dengan kode lama. Misalkan pesanan butuh order_number yang wajib dan unik. Satu migrasi ADD COLUMN ... NOT NULL akan gagal (baris lama tidak punya nilai), dan aplikasi lama yang tidak tahu kolom itu akan ditolak saat insert. Polanya dipecah:
sql
-- V3__add_order_number.sql (expand: kolom nullable, aplikasi lama tidak terganggu)
ALTER TABLE orders ADD COLUMN order_number VARCHAR(30);Lalu deploy aplikasi versi baru yang mengisi order_number untuk setiap pesanan baru. Setelah semua instance memakai versi baru:
sql
-- V4__backfill_order_number.sql (isi data lama)
UPDATE orders
SET order_number = 'ORD-' || lpad(id::text, 8, '0')
WHERE order_number IS NULL;sql
-- V5__order_number_not_null.sql (contract: kunci aturannya)
ALTER TABLE orders ALTER COLUMN order_number SET NOT NULL;
ALTER TABLE orders ADD CONSTRAINT uq_orders_order_number UNIQUE (order_number);Rename dan hapus kolom mengikuti pola yang sama: tambah kolom baru, tulis ke keduanya, pindahkan data, alihkan pembacaan, baru hapus kolom lama di rilis berikutnya.
Tabel besar
UPDATE jutaan baris dalam satu transaksi menahan lock lama dan membengkakkan WAL. Untuk tabel besar, backfill dilakukan bertahap (misalnya per 10.000 id) lewat job terpisah, dan migrasi V5 baru dijalankan setelahnya. SET NOT NULL juga memindai seluruh tabel sambil memegang lock eksklusif — rencanakan di jam sepi.
Repeatable migration
File berawalan R__ (misalnya R__order_summary_view.sql) tidak punya versi. Flyway menjalankannya lagi setiap kali checksum isinya berubah, selalu sesudah semua migrasi terversi. Cocok untuk view atau function yang ditulis dengan CREATE OR REPLACE:
sql
-- R__order_summary_view.sql
CREATE OR REPLACE VIEW order_summary AS
SELECT o.id, o.order_number, c.email, o.status, o.total
FROM orders o
JOIN customers c ON c.id = o.customer_id;Liquibase sebagai alternatif
Liquibase memecahkan masalah yang sama dengan changelog (YAML, XML, atau SQL) dan changeset. Kelebihannya: perubahan bisa ditulis netral-database dan rollback bisa dideklarasikan per changeset. Flyway lebih sederhana — SQL biasa yang langsung kamu baca. Spring Boot mendukung keduanya; pilih satu per proyek dan jangan dicampur.
Contoh di dunia backend
Setelah V3 jalan, entity diperbarui agar cocok dengan skema. ddl-auto=validate memastikan keduanya tidak menyimpang: bila kamu menambah field tetapi lupa migrasinya, aplikasi gagal start di CI, bukan di production.
java
// Potongan — entity Order setelah migrasi V3
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_number", length = 30) // nullable dulu; setelah V5 tambahkan nullable = false
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id")
private Customer customer;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status;
@Column(nullable = false, precision = 19, scale = 2)
private BigDecimal total;
@Version
private long version;
protected Order() {
}
// konstruktor, getter, dan method bisnis
}java
// Potongan — service mengisi order_number untuk pesanan baru (aplikasi versi baru)
@Service
public class PlaceOrderService {
private final OrderRepository orders;
private final OrderNumberGenerator orderNumbers; // mis. berbasis sequence database
public PlaceOrderService(OrderRepository orders, OrderNumberGenerator orderNumbers) {
this.orders = orders;
this.orderNumbers = orderNumbers;
}
@Transactional
public Order place(Customer customer, BigDecimal total) {
Order order = new Order(customer, OrderStatus.NEW, total);
order.assignNumber(orderNumbers.next()); // nomor sudah ada SEBELUM insert
return orders.save(order);
}
}Perhatikan urutannya: nomor diisi sebelum save. Dengan GenerationType.IDENTITY, save langsung menjalankan INSERT; bila nomor baru diisi sesudahnya, insert awal berisi NULL — masih lolos di antara V3 dan V5, tetapi langsung ditolak begitu V5 memasang NOT NULL. Expand–contract hanya aman bila kode versi baru sudah memenuhi aturan akhir skema sejak hari pertama. Generator nomor juga harus menghasilkan format yang tidak bentrok dengan hasil backfill V4; constraint UNIQUE di V5 menjadi pengaman terakhirnya.
Pertanyaan interview
1. Kenapa tidak memakai ddl-auto=update di production?
update hanya menambah tabel dan kolom, tidak pernah me-rename, menghapus, atau mengisi data. Hasilnya bergantung pada keadaan database saat itu, tidak ada file yang bisa di-review, dan rename berakhir sebagai kolom baru yang kosong. Migrasi Flyway terversi, tercatat di Git, di-review, dan berjalan sama persis di setiap environment.
2. Apa yang terjadi bila migrasi yang sudah jalan diubah?
Checksum file tidak lagi cocok dengan yang tercatat di flyway_schema_history, sehingga validasi gagal dan aplikasi tidak start. Perbaikannya: kembalikan file seperti semula dan tulis migrasi baru. flyway repair hanya untuk kasus khusus, misalnya membersihkan catatan migrasi yang gagal.
3. Bagaimana menambah kolom wajib tanpa downtime?
Pakai expand–contract: tambah kolom nullable, deploy kode yang mengisi kolom untuk data baru, backfill data lama (bertahap bila tabel besar), lalu pasang NOT NULL di migrasi terpisah. Setiap langkah kompatibel dengan versi aplikasi yang sedang berjalan saat rolling deploy.
Jebakan umum
- Mengedit migrasi yang sudah jalan di environment bersama → checksum mismatch, aplikasi gagal start. Tulis migrasi baru.
- Lupa
flyway-database-postgresqldi Flyway 10+ → Flyway tidak mengenali PostgreSQL dan aplikasi gagal start. - Satu garis bawah di nama file (
V2_add_index.sql) → file tidak dikenali sebagai migrasi. - Membiarkan
ddl-auto=updateaktif bersama Flyway → dua pihak mengubah skema; pakaivalidate. - Menambah kolom
NOT NULLsekaligus di tabel berisi data → migrasi gagal atau aplikasi lama rusak; pakai expand–contract. - Menghapus kolom di rilis yang sama dengan kode yang berhenti memakainya → instance lama yang masih berjalan langsung error. Hapus di rilis berikutnya.
Ringkasan
- Skema production dikelola dengan migrasi terversi; Hibernate cukup
ddl-auto=validate. - Spring Boot 4 + PostgreSQL butuh
spring-boot-starter-flywaydanflyway-database-postgresql. - File
V<versi>__<deskripsi>.sqldidb/migrationdijalankan berurutan sekali dan dicatat diflyway_schema_historybeserta checksum-nya. - Migrasi yang sudah jalan tidak boleh diedit; perubahan selalu berupa migrasi baru.
- Perubahan tanpa downtime memakai expand–contract: nullable → isi → backfill →
NOT NULL. R__untuk objek yang bisa dibuat ulang seperti view; Liquibase adalah alternatif berbasis changelog.