iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 3

Day 3|Flyway:把 schema 當程式碼管理

  • 分享至 

  • xImage
  •  

使用者先儲值到自己的帳戶,跟著別人開的團下單,等團購截止結算時,系統從他的餘額把該付的金額扣掉。

帳號資料放在 users

CREATE TABLE `users` (
  `user_id`    VARCHAR(20)  NOT NULL,
  `user_name`  VARCHAR(20)  NOT NULL,
  `password`   VARCHAR(256) NOT NULL,
  `role`       VARCHAR(10)  NOT NULL DEFAULT 'employee',
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

資料表transactions 就是這套系統的流水帳,儲值與扣款各佔一列,每一列都存下那次交易之後的結餘:

CREATE TABLE `transactions` (
  `transaction_id` VARCHAR(36)  NOT NULL COMMENT 'UUID',
  `user_id`        VARCHAR(20)  NOT NULL,
  `order_id`       VARCHAR(36)  DEFAULT NULL COMMENT '儲值時為 NULL',
  `amount`         INT          NOT NULL COMMENT '交易金額 (正數)',
  `closing_balance` INT         NOT NULL COMMENT '交易後餘額',
  `type`           TINYINT      NOT NULL COMMENT '1=DEBIT(扣款), 2=RECHARGE(儲值)',
  `created_at`     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `created_by`     VARCHAR(20)  NOT NULL COMMENT '操作者',
  PRIMARY KEY (`transaction_id`),
  KEY `idx_user_created` (`user_id`, `created_at` DESC),
  KEY `idx_order_id` (`order_id`),
  CONSTRAINT `fk_transactions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

users 裡沒有餘額這一欄。想知道某人現在有多少錢,就得去流水帳撈他最新那一筆;訂單結算時每位參與者要查一次。

這個時候才發現——餘額應該直接放在 users 上 !

於是有了這次的 schema 變更,而它帶著一條規則:

新的 users.balance 欄位,值不能從 0 開始。每個人的初始餘額,必須等於他在流水帳裡最新一筆的 closing_balance

誰負責讓每一台機器(環境)都長出這個欄位?

寫出這句 SQL 不難:

ALTER TABLE `users` ADD COLUMN `balance` BIGINT NOT NULL DEFAULT 0;

難的是它只改了你本機那個資料庫。同事的機器、CI(跑自動化測試的那台)、下週重灌的本機,都還沒有這個欄位。

MyBatis-Plus負責把查詢結果映射成 Java 物件,至於表怎麼來、欄位怎麼改,它一概不管;pom.xml 裡也沒有 JPA,所以沒有「依 entity 自動建表」這回事,這也意味著schema 得有人負責。

直覺做法:專案裡放一份 schema.sql

把完整的建表語句存成一份 schema.sql 放進專案。要改表就連進資料庫執行,再回頭把檔案也改成一致,檔案就是資料庫的說明書,需要一個新環境時,拿著它從頭跑一次就好。

但這條規則寫不進 schema.sql

回頭看開頭那條規則:balance 的初始值必須等於流水帳的最新結餘,要新增這個欄位,除了 ALTER,還得跑一句 UPDATE 去回填資料。然而 UPDATE 放不進 schema.sql——那份檔案描述的是「表長什麼樣」,假使今天是多人同時開發,會發生下面的窘境:

你的開發環境 schema.sql 同事的開發環境
balance 欄位
回填的 UPDATE 手動跑過 放不進去 沒有人跑過
balance 的值 每個人的實際結餘 全部都是 0

三邊的表結構一模一樣,資料卻不一樣,而且沒有任何錯誤訊息。

schema.sql 只記錄了現在的表長什麼樣子,但 schema 演進(也就是遷移migration)真正要問的是"做過什麼?"

Flyway:一支檔案 = 一次轉移的歷程記錄

Flyway 是一套資料庫版本控制工具,把每一次變更寫成一支帶版本號的 SQL 檔,放進 src/main/resources/db/migration,發出去之後就不再改。(檔案命名規則是"版本號 + 雙底線 + 這次要做什麼")

V1__init_schema.sql                    初始建表
V2__schema_updates.sql                 加欄位、改型別,並回填既有資料
V3__fix_seed_passwords.sql             只改資料,一行結構都沒動
V4__add_categories.sql                 新增一張表
V5__add_category_version.sql           既有的表加一個欄位
V6__add_rbac.sql                       新增權限相關的表
V7__register_admin_rbac_resources.sql  灌一批權限資料

啟動時,Flyway 會去看一張它自己建的表 flyway_schema_history,裡面記著這個資料庫已經跑過哪幾支,然後只執行還沒跑過的。這時同事從git拉下來重啟,就會自動跟到同一版。

在 V2__schema_updates.sql 裡是這樣的:

ALTER TABLE `users` ADD COLUMN `balance` BIGINT NOT NULL DEFAULT 0 AFTER `role`;

-- 從每位使用者最新一筆流水帳的結餘回填
UPDATE `users` u
  JOIN ( /* 取每人 created_at 最大的那筆 transactions */ ) latest
    ON u.user_id = latest.user_id
SET u.balance = latest.closing_balance;

V2__schema_updates.sql 還把 orders.statusTINYINT 換成 VARCHAR,並把既有的數字值轉成字串,在改結構之後也順便把舊資料搬過去。

兩句話必須在同一支檔案裡,如果分開放,中間任何一步失敗,就會留下「欄位有了、值還沒回填」的資料庫。

已發生的轉移不能改寫

Flyway 會幫每支檔案算一個 checksum(內容的指紋),寫進 flyway_schema_history。已執行過的檔案事後被改,指紋對不起來,啟動直接中止,Flyway 不會重跑已經執行過的版本。


上一篇
Day 2|一個請求的完整旅程
下一篇
Day 4|分類名稱軟刪除之後,名字還能重新使用?
系列文
做一個團購後端,順便搞懂那些事8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言