
系列:30 天打造企業級 PLM|面向:後端
log 印出執行成功,簽核名單卻沒多出半個人。本系列最經典的 bug,凶手不在演算法、不在資料,在一個註解參數:@Transactional(propagation = ...)。今天用同一顆引擎裡三個不同的交易傳播決策,講清楚為什麼 SET_FIELD 用 REQUIRES_NEW、IMPORT_APPROVER 用 REQUIRED、AFTER_COMMIT 路徑又非 REQUIRES_NEW 不可。每一段都有對應的血淚。
在以前 Oracle Agile PLM 中,自動化擴充(Java PX / Event Action)是直接跑在 WebLogic JTA 分散式交易環境中的。
在舊 J2EE / EJB 架構下,交易邊界常演變成災難:
RollbackOnly:只要某個非核心的 Java PX 拋出未處理的例外,WebLogic 容器就會自動把整個 JTA 交易標記為 RollbackOnly。結果外層剛簽核完的表單、剛放行的資料在 Commit 時全部被迫回滾,整張單據卡死。Mini-PLM 依託 Spring 的宣告式交易機制,將交易控制拆解為三組精準的傳播決策:

/**
* 每個 trigger 各自一個獨立 transaction:避免一個 trigger 失敗讓 sibling 全部 rollback。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public TriggerResult execute(ConfigTrigger trigger, TriggerContext context) {
同一個觸發點可能掛五條 SET_FIELD 規則,第三條炸了,前兩條已改的欄位值該不該留?該留,這是 WARN_CONTINUE 的語意。所以每條規則自己一個交易,成敗獨立。
這就是「自動加簽永遠找不到人」的案發現場。實際程式碼的 javadoc 把事故報告寫得清清楚楚:
/**
* 必須加入呼叫端(signOff / autoChangeStep)的同一個 transaction。
*
* IMPORT_APPROVER 在 BEFORE_ADD_APPROVERS 觸發,需讀取「當前關卡剛完成、
* 尚未 commit」的簽核 Action(例如第一關 signoff 後匯入到第二關)。
* 若改用 REQUIRES_NEW,新交易在 READ COMMITTED 隔離下看不到外層未 commit
* 的資料,會誤判為「resolved no candidate」。改用 REQUIRED 後,共用同一
* persistence context,Hibernate 查詢前 auto-flush,即可讀到剛完成的簽核記錄。
*/
@Transactional(propagation = Propagation.REQUIRED, noRollbackFor = Exception.class)
翻成白話:「前一關簽核者的主管」這種規則,要讀的是這一秒剛寫入、還在交易裡的簽核紀錄。REQUIRES_NEW 開新交易,在 READ COMMITTED 隔離級別下看不到外層未 commit 的資料,於是規則永遠解析出零個候選人。log 寫 SUCCESS(規則正常跑完,只是找不到人),名單沒人(真的沒解析到)。每一行程式都對,組合起來全錯。
更陰的是它測不出來。測試環境單步操作,上一關早已 commit,怎麼測都正常;只有生產環境「簽核瞬間連動加簽」的連續路徑會踩中。交易時序 bug 的共同特徵:重現條件藏在交易邊界裡,不在操作步驟裡。
再看第二個參數 noRollbackFor = Exception.class,這是 REQUIRED 的連帶代價。共用交易後,executor 拋例外會把外層交易標記 rollback-only,就算引擎 catch 住不 rethrow(WARN_CONTINUE),外層 signOff 在 commit 時仍會炸 UnexpectedRollbackException。關掉自動 rollback,例外照樣往上傳給失敗策略裁決,但不污染外層交易;BLOCK 策略則由 handleFailure 重新拋出,正確阻擋。傳播模式與 rollback 規則是一組決策,拆開想必出事。
AFTER_COMMIT 觸發點透過 @TransactionalEventListener(AFTER_COMMIT) 回呼執行,這裡藏著全系列最詭異的一個坑(TriggerEngine.executeAfterCommit 實碼 javadoc):
/**
* 必須 REQUIRES_NEW:本方法由 @TransactionalEventListener(AFTER_COMMIT) 的
* afterCommit 回呼呼叫,此時外層交易已 commit 但交易資源仍綁在執行緒上;
* 若以預設 REQUIRED 加入該「已完成」交易,executor 內的 repository.save()
* 永遠不會再被 flush/commit,造成 IMPORT_APPROVER 等寫入被靜默丟棄
* (log 因 REQUIRES_NEW 獨立交易照常寫入,形成「有觸發紀錄但沒加人」的假象)。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public TriggerResult executeAfterCommit(TriggerContext context) {
afterCommit 回呼的執行緒上還綁著那個已經 commit 完的交易。此時用 REQUIRED 加入它,所有 save 都寫進一個永遠不會再 flush 的 persistence context:靜默丟棄,不報錯。而執行 log 是獨立交易寫的,照常落地。結果就是最折磨人的偵錯現場,log 說做了,資料說沒做,兩邊都是真的。
同一個 catch 區塊還留了一條教訓:早期版本 silent catch 吃掉了 LazyInitialization 等執行期例外,變成「沒 action 也沒 log」。現在一律 log.error 帶完整 context。AFTER_COMMIT 路徑保證不拋出,但保證不拋出不等於保證不出聲。
回頭路(Day 9)讓同一關可能被重複進入,trigger 也就可能重複加人。防線兩道:交易內的 dedupe guard,key 含 stepId,同交易同關只跑一次;跨週期的殘留排除用 workflowRunNo 過濾而非 finishFlag。用 finishFlag 的話,上一輪已完成的 Action 會被當成「這輪已經加過人」,新一輪反而漏加。又一個欄位語意選錯、行為整個歪掉的例子。
同一顆引擎、三種傳播策略,各自對應一個交易事實:REQUIRES_NEW 隔離 sibling 失敗,REQUIRED 看得見未 commit 的現在(配 noRollbackFor 保外層),afterCommit 回呼裡必須開新交易否則寫入蒸發。交易傳播沒有預設安全值,每個 @Transactional 都是一個要被證明的決策,而證明只能靠真實資料庫的整合測試。H2 與單步操作都會騙你。
明日 Day 13:回到資料本身——版本生命週期與改版機制,immutable revision 的實作。