Spring Transaction Self Call
Spring @Transactional 自我呼叫筆記
一開始為什麼會這樣寫
一開始這樣寫,其實很自然,原因通常有兩個:
- 想提供比較短、比較好叫的 method
- 想把完整版參數集中在同一支 method,其他版本只是轉呼叫
例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class RecordApprovalService {
@Transactional
public int bulkAgree(
String personSn,
String year,
String unit,
String status,
String actingUnitSn,
String actingDegree,
boolean agree
) {
// 真正的批次簽核邏輯
return 1;
}
public int bulkAgree(String personSn, String year, String unit, String status, boolean agree) {
return bulkAgree(personSn, year, unit, status, null, null, agree);
}
}
或是查詢 service 內部直接呼叫自己另一支 public method:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class RecordWorkspaceQueryService {
@Transactional(readOnly = true)
public List<TsAdminSignRow> getRecordsForAdmin(String personSn, String uyear) {
return repository.getRecordsForAdmin(personSn, uyear);
}
@Transactional(readOnly = true)
public List<String> getSignableUnits(String personSn, String uyear, String actingUnitSn, String actingDegree) {
return getRecordsForAdmin(personSn, uyear).stream()
.map(TsAdminSignRow::getUnitn)
.toList();
}
}
從 Java 語法來看,這些寫法都完全合法,也很常見;問題是 @Transactional 不是只看 Java 語法,而是要看 Spring 在執行時怎麼代理這個 bean。
為什麼會被報錯
Spring 的 @Transactional 一般是靠 proxy 生效。
也就是說,只有「從 bean 外面,經過 Spring proxy 呼叫進來」時,transaction advice 才會套上去。
正常會生效的情況
1
recordApprovalService.bulkAgree(...);
這種是別的 bean 呼叫 RecordApprovalService,會經過 Spring proxy,所以 @Transactional 會生效。
不會重新套用 transaction advice 的情況
1
2
3
public int bulkAgree(String personSn, String year, String unit, String status, boolean agree) {
return bulkAgree(personSn, year, unit, status, null, null, agree);
}
這裡是 同一個物件內部 直接呼叫另一支 method,本質上等於:
1
this.bulkAgree(...);
這不會經過 Spring proxy,所以:
- 不會重新進入 transaction advice
- 內層 method 上的
@Transactional不會在這次呼叫中額外發揮作用
IDEA 的警告不是說程式一定壞掉,而是提醒:
你以為這裡會套用
@Transactional,但實際上不會。
為什麼這件事麻煩
如果只是單純 overload 轉呼叫,程式有時候表面上還是能跑,但會有幾個問題:
可讀性會誤導
看到 method 上有@Transactional,很容易以為每次呼叫都一定會經過 transaction proxy。重構時容易失真
一開始也許沒差,但之後若有人調整 propagation、readOnly 或 rollback 規則,就可能以為有生效,其實沒有。IDEA 會一直警告
這不是單純「編輯器太囉唆」,而是真的在提醒設計有模糊地帶。
這次專案裡實際踩到的情況
當時拆完 service 後,雖然 RecordService 已經不見了,但以下這類寫法還在:
寫入 service 的簡化 overload
1
2
3
4
5
6
7
public int bulkAgree(String personSn, String year, String unit, String status, boolean agree) {
return bulkAgree(personSn, year, unit, status, null, null, agree);
}
public AdminRecordView updateRecordDecision(String personSn, String uyear, Long tsSn, boolean agree) {
return updateRecordDecision(personSn, uyear, tsSn, null, null, agree);
}
查詢 service 的簡化 overload
1
2
3
4
5
6
7
public DashboardSummary getDashboardSummary(String personSn, String uyear) {
return getDashboardSummary(personSn, uyear, null, null);
}
public List<String> getSignableUnits(String personSn, String uyear) {
return getSignableUnits(personSn, uyear, null, null);
}
查詢 service 內部再呼叫自己帶 transaction 的 method
1
2
3
4
5
6
7
@Transactional(readOnly = true)
public List<String> getSignableUnits(String personSn, String uyear, String actingUnitSn, String actingDegree) {
List<TsAdminSignRow> actionableRecords = getRecordsForAdmin(personSn, uyear).stream()
.filter(...)
.toList();
return dashboardQueryService.resolveActionableUnitNames(actionableRecords);
}
這些都會被 IDEA 視為 self-invocation 風險。
如何解決
這次採用的原則很直接:
- 不保留會在同 class 內轉呼叫
@Transactionalmethod 的簡化 overload - 呼叫端直接傳完整參數
- 同一個 service 內若只是要重用查詢,直接呼叫 repository 或抽 private helper,不呼叫自己標註
@Transactional的 public method
調整前
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class RecordApprovalService {
@Transactional
public int bulkAgree(
String personSn,
String year,
String unit,
String status,
String actingUnitSn,
String actingDegree,
boolean agree
) {
// ...
return 1;
}
public int bulkAgree(String personSn, String year, String unit, String status, boolean agree) {
return bulkAgree(personSn, year, unit, status, null, null, agree);
}
}
調整後
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class RecordApprovalService {
@Transactional
public int bulkAgree(
String personSn,
String year,
String unit,
String status,
String actingUnitSn,
String actingDegree,
boolean agree
) {
// ...
return 1;
}
}
呼叫端改成明確傳完整參數:
1
2
3
4
5
6
7
8
9
int updated = recordApprovalService.bulkAgree(
personSn,
year,
unit,
status,
actingUnitSn,
actingDegree,
body.getAgree()
);
查詢 service 的調整前
1
2
3
4
5
6
7
8
9
10
11
12
@Transactional(readOnly = true)
public List<TsAdminSignRow> getRecordsForAdmin(String personSn, String uyear) {
return repository.getRecordsForAdmin(personSn, uyear);
}
@Transactional(readOnly = true)
public List<String> getSignableUnits(String personSn, String uyear, String actingUnitSn, String actingDegree) {
return getRecordsForAdmin(personSn, uyear).stream()
.filter(...)
.map(TsAdminSignRow::getUnitn)
.toList();
}
查詢 service 的調整後
1
2
3
4
5
6
7
@Transactional(readOnly = true)
public List<String> getSignableUnits(String personSn, String uyear, String actingUnitSn, String actingDegree) {
return repository.getRecordsForAdmin(personSn, uyear).stream()
.filter(...)
.map(TsAdminSignRow::getUnitn)
.toList();
}
這樣就沒有同 class 內 public @Transactional method 互相呼叫的問題。
為什麼這種解法比較乾淨
這種解法的好處是:
@Transactional只標在真正的邏輯入口- 呼叫關係一眼就看得懂
- 不需要為了「少打幾個
null」保留會誤導人的 method - IDEA 警告會跟著消失,而不是只是在壓警告
更重要的是,這不是在迎合工具,而是在把 transaction 邊界講清楚。
如果真的想保留簡化呼叫方式,怎麼辦
如果真的很想保留簡化 API,也不是完全不行,但要注意:
- 不要讓簡化 method 再去呼叫同 class 上帶
@Transactional的 public method - 可以把真正邏輯抽成 private method,由唯一一支 public
@Transactionalmethod 進入 - 或者乾脆把不同責任拆到不同 bean,讓呼叫跨 bean 發生
不過這次在 TsNoticeApproval 的情境下,最單純的做法仍然是:
- 不保留這些簡化 overload
- 呼叫端直接傳完整參數
因為這個系統本來就已經有 actingUnitSn、actingDegree 這種工作台身分語意,硬做簡化版,反而容易把語意藏起來。
這次得到的結論
這次不是「拆了 service 就自然不會有 transaction 問題」,而是:
拆完 service 後,還要把同 class 內的 transaction 自我呼叫一起清掉,設計才算真的完成。
實務上可以記成這一句:
@Transactional要標在真正對外的進入點;同一個 bean 內不要再靠 public method 彼此轉呼叫。
只要看到這兩種寫法,就要提高警覺:
1
return this.someTransactionalMethod(...);
或
1
return someTransactionalMethod(...);
如果那支 method 是同一個 class 的 public @Transactional method,就很可能又會被 IDEA 警告,而且那個警告通常是合理的。