使用している技術は以下の通り
- Java: 21
- Spring Boot: 3.2.6
- MyBatis: 3.5.14
- MySQL: 8.0
@Transactionalをつける対象
Serviceのクラスかメソッドか
@Transactional
は一般的にはService
につけるべきとなっている。
さらにクラスにつけるかメソッドにつけるかで方法が分かれるが、DB接続自体がないサービスメソッドやSELECT
しか発行しないサービスメソッドもあることから、メソッドにつける方を採用したい。
またクラスにつけると、特定のメソッドだけどうしてもautocommit
にしたいケースで困ることになる。
Serviceのメソッドにつけなくてもいい場合
MySQLだとautocommit
モードで接続することが普通だが、サービスのメソッド内の処理が以下の場合、autocommit
であることを併せて考えると@Transactional
をつけてBEGIN
, COMMIT
をわざわざ発行する必要がない。
- サービスのメソッド内で
SELECT
しか発行しない - サービスのメソッド内の一番最後で1件だけ更新SQLを発行する
1については、SELECT
文の発行数次第では@Transactional(readOnly = true)
を指定したいと思うかもしれない。確かに、読取り専用トランザクションにすることで、トランザクションの transaction ID (TRX_ID フィールド) の設定に関連するオーバーヘッドを回避できる。しかし、https://dev.mysql.com/doc/refman/8.0/ja/innodb-performance-ro-txn.htmlによると、トランザクションが START TRANSACTION READ ONLY
ステートメントで開始される以外でも、「autocommit
設定がオンになっているため、トランザクションが 1 つのステートメントであることが保証され、そのトランザクションを構成している 1 つのステートメントが「非ロック」の SELECT
ステートメントである場合。 つまり、FOR UPDATE
または LOCK IN SHARED MODE
句を使用しない SELECT
」も読取り専用トランザクションになってくれる。そのためわざわざ@Transactional(readOnly = true)
を指定する必要がない。
2については、更新SQLの後に処理がないからSQLが成功した後に例外が発生することがなく、そのままautocommit
に任せてしまって良い。
不要な場合でも更新SQLを1つでも含んでいる場合はつける
サービスのメソッド内の一番最後で1件だけ更新SQLを発行する場合は、@Transactional
をつけなくてもいいと言ったが、その後の改修で条件が変わった場合の設定漏れを考慮すると、あえてつけておいた方がいいと考える。
TERASOLUNAでも設定漏れによるバグを防ぐ事を目的として、必要最低限より広い範囲に@Transactional
をつけている。
トランザクション境界の設定が必須なのは更新処理を含む業務ロジックのみだが、設定漏れによるバグを防ぐ事を目的として、クラスレベルにアノテーションを付与することを推奨している。
もちろん必要な箇所(更新処理を行うメソッド)のみに、
@Transactional
アノテーションを定義する方法を採用してもよい。
まとめ: 更新SQLを含むサービスのメソッドに@Transactionalをつける
まとめとしては、更新SQLを含むサービスのメソッドに@Transactionalをつけるということになる。
性能面、実装面、バグ可能性のバランスをとると、このような結論になると思う。
@Transactionalのつけ忘れを検知する
更新SQLを含むサービスのメソッドに@Transactionalをつけると決めた後は、つけ忘れを検知する必要がある。
レビュー観点のドキュメントにトランザクション制御について記載するなどの方法もあるが、AOPを使って機械的に検知する方法を考えたい。
AOPを使った検知の仕組み
AOPを使った検知の処理フローとして主要な部分は以下のようになる。
- MyBatisの
@Insert
,@Update
,@Delete
のアノテーションがついたメソッドをAOPで拾う TransactionSynchronizationManager.isActualTransactionActive()
を使用して、現在トランザクションがアクティブであるかどうかをチェックする- アクティブでなければ例外を発生させる
主要なフロー以外では、あえてautocommit
にしたいサービスのメソッド用に@AutoCommit
というアノテーションを用意して検知対象から除外することを考える。
またAOP自体やTransactionSynchronizationManager.isActualTransactionActive()
が性能面で悪影響を与えるため、検知はローカル環境に限定するなども考える。特に@AutoCommit
を除外する仕組みではBeanをリクエストスコープにして実現していることもあり、本番では稼働させたくない。
実装
Controller
動作確認を簡単にするために、4つのAPIエンドポイントを用意する。
SELECT
のみ発行@Transactional
を付与したサービスを呼ぶ@Transactional
を付与していないサービスを呼び、エラーになる@AutoCommit
を付与したサービスを呼ぶ
実装面では特筆すべき点はなし。
@RestController
@RequiredArgsConstructor
public class AController {
private final AService aService;
@GetMapping("/get")
List<AModel> get() {
return aService.get();
}
@PostMapping("/post")
void post() {
aService.post();
}
@PostMapping("/post-without-transaction")
void postWithoutTransaction() {
aService.postWithoutTransaction();
}
@PostMapping("/post-without-transaction-but-autocommit-declared")
void postWithoutTransactionButAutoCommitDeclared() {
aService.postWithoutTransactionButAutoCommitDeclared();
}
}
Service
Controllerで4つエンドポイントを用意したので、それに対応するサービスのメソッドを4つ用意する。
Mapperを呼び出しているだけで、特筆すべき点はなし。
@Service
@RequiredArgsConstructor
public class AService {
private final AMapper aMapper;
public List<AModel> get() {
return aMapper.selectAll();
}
@Transactional
public void post() {
aMapper.insert(new AModel(null, UUID.randomUUID().toString()));
}
public void postWithoutTransaction() {
aMapper.insert(new AModel(null, UUID.randomUUID().toString()));
}
@AutoCommit
public void postWithoutTransactionButAutoCommitDeclared() {
aMapper.insert(new AModel(null, UUID.randomUUID().toString()));
}
}
Mapper
MapperはシンプルなSQLを発行しているだけで、特筆すべき点はなし。
@Mapper
public interface AMapper {
@Select("select * from a")
List<AModel> selectAll();
@Insert("""
insert into a
set
id = #{id},
name = #{name}
""")
int insert(AModel aModel);
}
データを格納するオブジェクトもシンプル。
@AllArgsConstructor
@NoArgsConstructor
@Data
public class AModel {
private Integer id;
private String name;
}
AOP
このクラスが本題となる。
@Component
@RequestScope
@Aspect
@Profile("local")
public class TransactionConsider {
boolean isAutoCommit;
@Pointcut("""
@annotation(org.apache.ibatis.annotations.Insert)
|| @annotation(org.apache.ibatis.annotations.Update)
|| @annotation(org.apache.ibatis.annotations.Delete)
""")
public void isCUD() {
}
@Pointcut("@annotation(com.example.transactionconsider.aop.TransactionConsider.AutoCommit)")
public void isAutoCommit() {
}
@Around("isAutoCommit()")
public void aroundIsAutoCommit(ProceedingJoinPoint pjp) throws Throwable {
isAutoCommit = true;
pjp.proceed();
isAutoCommit = false;
}
@Before("isCUD()")
public void validate() {
if (isAutoCommit) {
return;
}
boolean isTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
if (!isTransactionActive) {
throw new IllegalStateException("Please consider database transactions.");
}
}
public @interface AutoCommit {
}
}
isCUD()
というメソッドを定義して、それに@Pointcut
と@annotation
指定で、MyBatisの@Insert
, @Update
, @Delete
の処理タイミングを拾えるようにしている。
定義したメソッドを@Before
に設定することで、更新系SQLのメソッドが実行される前に、TransactionSynchronizationManager.isActualTransactionActive()
でトランザクションがアクティブかどうか判定できるようにしている。
さらにAutoCommit
というアノテーションを定義し、それに対しても同じように@Pointcut
と@Around
を組み合わせて使うことで、@AutoCommit
がついたサービスのメソッドの実行中は検知しないようにしている。ただしboolean isAutoCommit
をインスタンス変数にしていることから、SpringのBeanのデフォルトスコープであるシングルトンと相性が悪くスレッドセーフにならない。そのため@RequestScope
を設定している。
@Profile("local")
では、このBeanが実行される環境をローカル環境に限定し、本番に影響を与えないようにしている。application.propertiesにspring.profiles.active=local
を指定して実行すると検知機能が動くが、別の値を指定して実行すると動かないことがわかる。
curlによる確認
curlで動作確認する。
$ curl localhost:8080/get
# JSON配列が返ってくる。
# SELECTでは@Transactionalが不要なことが確認できた。
$ curl -XPOST localhost:8080/post
# エラーは発生しない。
# @Transactionalがついているから問題なくINSERTできることが確認できた。
$ curl -XPOST localhost:8080/post-without-transaction
# エラー発生。
# @Transactionalがついていないと、AOPによって例外が投げられた。
$ curl -XPOST localhost:8080/post-without-transaction-but-autocommit-declared
# エラーは発生しない。
# @Transactionalがついていなくても、@AutoCommitがついていれば問題なくINSERTできることが確認できた。