使用している技術は以下の通り
- 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できることが確認できた。