Files
spring-boot-demo/db-migrations-flyway-liquibase/docs/08-concurrent-startup-and-locking.md
T
asmhatreandClaude Sonnet 5 3908331431 Add db-migrations-flyway-liquibase: Flyway vs Liquibase migrations, rollbacks and baselines on Spring Boot 4.1
Companion code for the Flyway vs Liquibase article: checksum validation, out-of-order
and repeatable migrations, baselining an existing schema, Flyway Community's undo/diff/deploy
stubs, concurrent-startup locking for both tools, Liquibase changeset identity and rollback
(auto-generated vs explicit), a verified Liquibase 5.0.3 filename-caching defect, the new
OSS license service, the FSL license change, and running both tools against one database.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q6XdRjtsp4862EM44T7i9a
2026-09-15 07:08:57 +00:00

58 lines
4.1 KiB
Markdown

# 8. Concurrent startup and locking
[← 7. Why there is no undo](07-why-there-is-no-undo.md) · [Index](../README.md) · Next: [9. Liquibase: anatomy of an update →](09-liquibase-anatomy-of-an-update.md)
A rolling deploy starts several instances of the same application at close to the same moment,
all pointed at the same database, all carrying the same migrations. If two of them both decide
"I need to run V1" at once, what stops the second from either duplicating work or corrupting the
history table?
[`FlywayConcurrentMigrateTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayConcurrentMigrateTest.java)
answers this empirically rather than by reading about it: it starts two Flyway instances on two
real threads, pointed at the same H2 file (opened with `AUTO_SERVER=TRUE` so genuinely separate
JDBC connections can share one file), both migrating a deliberately slow, 800ms Java-based
migration ([`V1__SlowMigration.java`](../src/test/java/com/ankurm/dbmigrations/flyway/V1__SlowMigration.java)).
```
instance A migrate() took 905ms
instance B migrate() took 911ms
wall-clock time for both, run concurrently: 915ms
```
```
flyway_schema_history:
installed_rank | version | description | success
-1 | NULL | ... | true
1 | 1 | SlowMigration| true
```
(from [`docs/output/08-flyway-concurrent-lock.txt`](output/08-flyway-concurrent-lock.txt))
The migration ran exactly once — the test asserts that directly by counting rows matching
`SlowMigration`. What's more interesting is the timing: both instances took **almost the same,
almost-full 800+ms**, and the wall clock for both together is close to *that one duration*, not
their sum. That shape is the real proof of what's happening underneath: Flyway takes a row-level
lock on its own schema history table before checking what needs to run. The winner holds it for
the whole migration; the loser blocks on that same lock for nearly the full window, then — once it
finally acquires it — finds the migration already recorded and returns almost immediately. No
external coordinator, no separate lock table: the schema history table *is* the lock.
<svg viewBox="0 0 700 170" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<text x="20" y="20">instance A</text>
<rect x="90" y="8" width="260" height="24" fill="#dcfce7" stroke="#16a34a"/>
<text x="220" y="25" text-anchor="middle" font-size="11">holds lock — runs migration (≈800ms)</text>
<text x="20" y="60">instance B</text>
<rect x="90" y="48" width="260" height="24" fill="#fee2e2" stroke="#dc2626"/>
<text x="220" y="65" text-anchor="middle" font-size="11">blocked, waiting for the same row lock</text>
<rect x="352" y="48" width="20" height="24" fill="#dcfce7" stroke="#16a34a"/>
<text x="450" y="65" font-size="11">lock acquired → nothing to do → returns</text>
<text x="20" y="110" font-size="12" fill="#475569">Both calls "took" roughly 800-900ms — the loser's time is almost entirely the wait,</text>
<text x="20" y="128" font-size="12" fill="#475569">not the (nonexistent, for it) work. Wall clock ≈ one migration's length, not the sum of both.</text>
</svg>
## Going deeper
- **A naive assertion here would compare wall-clock time against the sum of both individual durations**, expecting serialization to look like "one after the other, end to end". That's the wrong model for two threads submitted at the same instant with one blocking on the other's lock — the right check is that *both* individual durations are long, proving the loser genuinely waited rather than racing ahead. This module's test was rewritten once to fix exactly that reasoning error.
- [Flyway's locking strategy](https://documentation.red-gate.com/flyway/flyway-cli-and-api/concepts/migrations#concurrent-migration) (`rel="nofollow"`) documents the row-lock approach and which databases support it natively versus via a fallback.
- Liquibase solves the same problem with a dedicated, separate lock table rather than a row lock on the history table itself — chapter [11](11-liquibase-locking.md) measures how differently that behaves under contention.