LoginSignup
86
80

More than 5 years have passed since last update.

SpringのDBコネクションの共有方法(DBトランザクション)を理解する

Last updated at Posted at 2017-02-12

今回は、SpringのDBコネクションの共有方法(=複数のSQLを同一トランザクション内で実行する方法)の仕組みについて説明します。
仕組みだけ説明をするとSpringに詳しくない人に対してハードルが高くなる!?気がするので・・・Spring提供のDBアクセス機能(JdbcTemplate)を実際に使いながら、SpringがDBコネクションをどうやって生成・共有するのか説明していきたいと思います。

動作検証バージョン

  • Spring Boot 1.5.1.RELEASE
  • Spring Framework 4.3.6.RELEASE

検証用のSpring Bootプロジェクトの作成

まず、検証用のSpring Bootプロジェクト作成しましょう(Dependencieにはjdbcとh2を指定)。ここではコマンドラインでプロジェクトを作成する例になっていますが、SPRING INITIALIZRのWeb UIやお使いのIDEの機能で生成しても(もちろん)OKです!!

$ curl -s https://start.spring.io/starter.tgz\
       -d name=spring-tx-demo\
       -d artifactId=spring-tx-demo\
       -d dependencies=jdbc,h2\
       -d baseDir=spring-tx-demo\
       | tar -xzvf -

プロジェクトを生成すると、以下のような構成のMavenプロジェクトが生成されます。

$ cd spring-tx-demo
$ tree
.
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── example
    │   │           └── SpringTxDemoApplication.java
    │   └── resources
    │       └── application.properties
    └── test
        └── java
            └── com
                └── example
                    └── SpringTxDemoApplicationTests.java

必要に応じてお使いのIDE上にインポートしてください!!

データベース(データソース)のセットアップ

今回はH2のインメモリDBを使用して検証します。

src/main/resources/schema.sql
CREATE TABLE account (
  id CHAR(10) PRIMARY KEY,
  name VARCHAR(255)
);

また、Spring Bootが内蔵しているDataSource(コネクションプール付きのDataSource)を使用するとトランザクション制御が正しく行われているか検証するのが難しいので、検証用にコネクションプールなしのDataSourceをBean定義します。

Note:

通常のアプリケションを開発する際は、コネクションプールを使ってくださいね!!(↓のようなBean定義は不要です)

src/main/java/com/example/SpringTxDemoApplication.java
package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.SimpleDriverDataSource;

import javax.sql.DataSource;
import java.sql.Driver;
import java.util.Properties;

@SpringBootApplication
public class SpringTxDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringTxDemoApplication.class, args);
    }

    @Configuration
    static class DataSourceConfiguration {
        @Bean
        public DataSource dataSource(DataSourceProperties properties) throws ClassNotFoundException {
            // ★★★ Springが提供しているコネクションをプールしない実装クラスを利用 ★★★
            SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
            dataSource.setDriverClass((Class<? extends Driver>) Class.forName(properties.determineDriverClassName()));
            dataSource.setUrl(properties.determineUrl());
            dataSource.setUsername(properties.determineUsername());
            dataSource.setPassword(properties.determinePassword());
            Properties connectionProperties = new Properties();
            connectionProperties.setProperty("autoCommit", "false"); // ★★★ 自動コミットをOFFにする ★★★
            dataSource.setConnectionProperties(connectionProperties);
            return dataSource;
        }
    }

}

また、自動コミットフラグは必ずfalse(自動コミットしない)に設定してください。自動コミットフラグをtrue(自動コミットする)にしてしまうと、実際だれがコミットしたのか判断できないので・・・。

JdbcTemplateを使用したDBアクセス

SpringTxDemoApplicationクラスにCommandLineRunnerインタフェースを実装し、runメソッドの中でJdbcTemplateを使用したDBアクセスを実装しましょう。

Note:

Springが提供するDBアクセス機能のログ(デバッグログ)を出力するようにしておくと、内部でどのような動きをしているか確認しやすくなります。

src/main/resources/application.properteis
logging.level.org.springframework.jdbc=debug
src/main/java/com/example/SpringTxDemoApplication.java
package com.example;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.SimpleDriverDataSource;

import javax.sql.DataSource;
import java.sql.Driver;
import java.util.Map;
import java.util.Properties;

@SpringBootApplication
public class SpringTxDemoApplication implements CommandLineRunner {

    public static void main(String[] args) {
        SpringApplication.run(SpringTxDemoApplication.class, args);
    }

    private final JdbcTemplate jdbcTemplate;

    public SpringTxDemoApplication(JdbcTemplate jdbcTemplate){
        this.jdbcTemplate = jdbcTemplate;
    }

    // ★★★ JdbcTemplateを使用したDBアクセス処理を実装する ★★★
    @Override
    public void run(String... args) throws Exception {

        jdbcTemplate.update("INSERT INTO city (name, state, country) VALUES ('San Francisco', 'CA', 'US')");

        Map<String, Object> city = jdbcTemplate.queryForMap("SELECT id, name, state, country FROM city WHERE id = 1");

        System.out.println(city);

    }

    @Configuration
    static class DataSourceConfiguration {
        @Bean
        public DataSource dataSource(DataSourceProperties properties) throws ClassNotFoundException {
            SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
            dataSource.setDriverClass((Class<? extends Driver>) Class.forName(properties.determineDriverClassName()));
            dataSource.setUrl(properties.determineUrl());
            dataSource.setUsername(properties.determineUsername());
            dataSource.setPassword(properties.determinePassword());
            Properties connectionProperties = new Properties();
            connectionProperties.setProperty("autoCommit", "false");
            dataSource.setConnectionProperties(connectionProperties);
            return dataSource;
        }
    }

}

では、このクラスをSpring Bootアプリとして実行すると、どういう結果になるでしょうか?
1件レコードをINSERTして、INSERTしたレコードを取得しているので・・・

コンソールには・・・

{ID=1, NAME=San Francisco, STATE=CA, COUNTRY=US}

と出力されることを期待すると思いますが・・・・

実際に実行してみると、INSERTしたレコードを取得する際に以下のエラーが発生します :cold_sweat:

./mvnw spring-boot:run
...
 .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v1.5.1.RELEASE)
...
2017-02-12 17:26:08.627 ERROR 46138 --- [           main] o.s.boot.SpringApplication               : Application startup failed

java.lang.IllegalStateException: Failed to execute CommandLineRunner
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:779) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:760) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at org.springframework.boot.SpringApplication.afterRefresh(SpringApplication.java:747) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:315) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1162) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1151) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at com.example.SpringTxDemoApplication.main(SpringTxDemoApplication.java:21) [classes/:na]
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_121]
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_121]
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_121]
    at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_121]
    at org.springframework.boot.maven.AbstractRunMojo$LaunchRunner.run(AbstractRunMojo.java:527) [spring-boot-maven-plugin-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at java.lang.Thread.run(Thread.java:745) [na:1.8.0_121]
Caused by: org.springframework.dao.EmptyResultDataAccessException: Incorrect result size: expected 1, actual 0
    at org.springframework.dao.support.DataAccessUtils.requiredSingleResult(DataAccessUtils.java:71) ~[spring-tx-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.jdbc.core.JdbcTemplate.queryForObject(JdbcTemplate.java:495) ~[spring-jdbc-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.jdbc.core.JdbcTemplate.queryForMap(JdbcTemplate.java:489) ~[spring-jdbc-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at com.example.SpringTxDemoApplication.run(SpringTxDemoApplication.java:35) [classes/:na]
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:776) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    ... 12 common frames omitted
...

原因例外をみると、「Caused by: org.springframework.dao.EmptyResultDataAccessException: Incorrect result size: expected 1, actual 0」となっているので、レコードがみつからなかったことが原因のようです。

JdbcTemplate利用時のConnectionの扱い

では、なぜ直前にINSERTしたはずのレコードがみつからなかったのでしょうか?
以下の図は、Springのトランザクション管理外でJdbcTemplateのメソッドを呼び出した場合に、どのようにConnectionを取得しているか示したものです。

spring-jdbc-non-tx.png

項番 説明
ControllerからSpringのトランザクション管理外のServiceクラスのメソッドを呼び出す。
ServiceからJdbcTemplateのメソッドを呼び出す。
JdbcTemplateは、自身に割り当てられているDataSourceからConnectionを取得する。その際、直接DataSourceからConnectionを取得するのではなく、DataSourceUtil#getConnection(DataSource)を介して取得する仕組みになっています。後で詳しく説明しますが、トランザクション管理下でのConnectionの共有を実現しているのがDataSourceUtilのメソッドになります。
DataSourceUtilから取得したConnectionのメソッドを使用してSQLの実行を依頼する。
Connection(Connectionから取得したStatement)は、データベースにSQLを実行する。

図中の吹き出しで説明しているように、JdbcTemplateのメソッド呼び出しごとに別のConnectionが使われるため、INSERT時に使用したConnectionがコミットされないと、後続のSELECT時に使うConnectionからはINSERTしたレコードをみつけることはできません。(ちなみに・・・これはConnectionの分離レベル=Isolation LevelがREAD COMMITTEDの時の動きなので、分離レベルによってはレコードをみつけることができる点を補足しておきます)

ためしに・・・自動コミットフラグをtrueにして再度実行してみてください。

@Bean
public DataSource dataSource(DataSourceProperties properties) throws ClassNotFoundException {
    SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
    // ...
    connectionProperties.setProperty("autoCommit", "true"); // ★★★ trueに変更 ★★★
    dataSource.setConnectionProperties(connectionProperties);
    return dataSource;
}
$ ./mvnw spring-boot:run
...
2017-02-12 18:15:25.097 DEBUG 46292 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing SQL update [INSERT INTO city (name, state, country) VALUES ('San Francisco', 'CA', 'US')]
2017-02-12 18:15:25.098 DEBUG 46292 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Fetching JDBC Connection from DataSource
2017-02-12 18:15:25.098 DEBUG 46292 --- [           main] o.s.j.datasource.SimpleDriverDataSource  : Creating new JDBC Driver Connection to [jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE]
2017-02-12 18:15:25.100 DEBUG 46292 --- [           main] o.s.jdbc.core.JdbcTemplate               : SQL update affected 1 rows
2017-02-12 18:15:25.103 DEBUG 46292 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Returning JDBC Connection to DataSource
2017-02-12 18:15:25.103 DEBUG 46292 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing SQL query [SELECT id, name, state, country FROM city WHERE id = 1]
2017-02-12 18:15:25.103 DEBUG 46292 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Fetching JDBC Connection from DataSource
2017-02-12 18:15:25.103 DEBUG 46292 --- [           main] o.s.j.datasource.SimpleDriverDataSource  : Creating new JDBC Driver Connection to [jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE]
2017-02-12 18:15:25.123 DEBUG 46292 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Returning JDBC Connection to DataSource
{ID=1, NAME=San Francisco, STATE=CA, COUNTRY=US}
2017-02-12 18:15:25.126  INFO 46292 --- [           main] com.example.SpringTxDemoApplication      : Started SpringTxDemoApplication in 1.636 seconds (JVM running for 5.305)
...

上記のようなログが出力され、SELECTに成功します。これは、自動コミットフラグをtrueにすることで、INSERT成功時にConnectionがコミットされたためです。

じゃ〜自動コミットフラグをtrueにすれば一件落着かというと・・・当然ながらそんなことはありません :sweat_smile:
説明する必要もないと思いますが・・・以下のように複数の更新操作を行う場合は、すべての更新操作が成功した場合にコミット、一部の更新操作が失敗した場合はロールバックする(=いわゆるトランザクション制御を行う)必要がでてきます。こういったケースでは、SQLの実行毎にコミットが行われてしまう「自動コミットフラグ=true」を使うわけにはいかないのです。

@Override
public void run(String... args) throws Exception {

    jdbcTemplate.update("INSERT INTO city (name, state, country) VALUES ('San Francisco', 'CA', 'US')");
    jdbcTemplate.update("INSERT INTO city (name, state, country) VALUES ('豊島区', '東京', 'JPN')");

    // ...  
}

トランザクション管理下でのJdbcTemplate利用時のConnectionの扱い

複数の更新操作を同一トランザクション内で行うためにはどうすればいいのか?というと・・・Springユーザならご存知のとおり、トランザクション管理下で実行したいクラスやメソッドに@Transactionalを付与するだけです。

Note:

Spring Bootを使う場合は特別な設定は不要ですが・・・非Spring Bootの場合は、

が必要になります。(本エントリーでは非Spring Bootの説明は割愛させて頂きます)

では、実際に検証アプリに@Transactionalを付与して実行してみましょう。(なお、自動コミットフラグをfalseに戻すことを忘れずに!!)

@Transactional // ★★★ メソッドに付与 ★★★
@Override
public void run(String... args) throws Exception {

    jdbcTemplate.update("INSERT INTO city (name, state, country) VALUES ('San Francisco', 'CA', 'US')");

    Map<String, Object> city = jdbcTemplate.queryForMap("SELECT id, name, state, country FROM city WHERE id = 1");

    System.out.println(city);

}

@Transactionalを付与した後にSpring Bootアプリを実行すると、SELECTも成功するようになりました :grin:

$ ./mvnw spring-boot:run
...
2017-02-12 18:42:46.689 DEBUG 46340 --- [           main] o.s.j.d.DataSourceTransactionManager     : Creating new transaction with name [com.example.SpringTxDemoApplication$$EnhancerBySpringCGLIB$$7f2a8641.run]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT; ''
2017-02-12 18:42:46.690 DEBUG 46340 --- [           main] o.s.j.datasource.SimpleDriverDataSource  : Creating new JDBC Driver Connection to [jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE]
2017-02-12 18:42:46.690 DEBUG 46340 --- [           main] o.s.j.d.DataSourceTransactionManager     : Acquired Connection [conn1: url=jdbc:h2:mem:testdb user=SA] for JDBC transaction
2017-02-12 18:42:46.697 DEBUG 46340 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing SQL update [INSERT INTO city (name, state, country) VALUES ('San Francisco', 'CA', 'US')]
2017-02-12 18:42:46.700 DEBUG 46340 --- [           main] o.s.jdbc.core.JdbcTemplate               : SQL update affected 1 rows
2017-02-12 18:42:46.703 DEBUG 46340 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing SQL query [SELECT id, name, state, country FROM city WHERE id = 1]
{ID=1, NAME=San Francisco, STATE=CA, COUNTRY=US}
2017-02-12 18:42:46.721 DEBUG 46340 --- [           main] o.s.j.d.DataSourceTransactionManager     : Initiating transaction commit
2017-02-12 18:42:46.722 DEBUG 46340 --- [           main] o.s.j.d.DataSourceTransactionManager     : Committing JDBC transaction on Connection [conn1: url=jdbc:h2:mem:testdb user=SA]
2017-02-12 18:42:46.722 DEBUG 46340 --- [           main] o.s.j.d.DataSourceTransactionManager     : Releasing JDBC Connection [conn1: url=jdbc:h2:mem:testdb user=SA] after transaction
2017-02-12 18:42:46.722 DEBUG 46340 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Returning JDBC Connection to DataSource
..

ログをみてみると・・・コネクションの生成(Creating new JDBC Driver Connection to [jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE])のログ出力が1回だけになり、DataSourceTransactionManagerのログ(トランザクション開始ログなど)が出力されるようになりました。ログを見る限りだと、複数の操作でコネクションが共有されているようです。

では、なぜコネクションが共有されるようになったのか?を紐解いていきましょう。
以下の図は、Springのトランザクション管理下でJdbcTemplateのメソッドを呼び出した場合に、どのようにConnectionを生成し利用しているかを示したものです。
ちょっと複雑ですが、ひとつひとつ見ていけばきっと理解できると思います!!(わたしの説明がわかりずらい・・・とかはあるかも:sweat_smile:

spring-jdbc-tx.png

項番 説明
ControllerからSpringのトランザクション管理下のServiceクラスのメソッドを呼び出す。トランザクション管理下のServiceクラスのメソッドを呼び出すと、実際にはProxyオブジェクトのメソッドが呼び出され、トランザクション制御を行うクラス(TransactionInterceptor)が呼び出される仕組みになっています。
TransactionInterceptorは、DataSourceTransactionManagerのメソッドを呼び出してトランザクションの開始を依頼する。
DataSourceTransactionManagerは、自身に割り当てられたDataSourceからConnection(autoCommitfalseとなる)を取得しConnectionHolder(トランザクション内で共有するConnectionを保持する領域)を生成する。生成したConnectionHolderは、TransactionSynchronizationManagerクラス上のスレッドローカルなMap型の変数に格納されます(Mapに格納する際のキーとしてDataSourceインスタンスが利用されます)。
TransactionInterceptorは、トランザクション開始後にServiceのメソッドを呼び出す。
ServiceからJdbcTemplateのメソッドを呼び出す。
JdbcTemplateは、DataSourceUtil#getConnection(DataSource)を介してトランザクションに割り当てられているConnectionを取得し、取得したConnectionのメソッドを使用してSQLの実行を依頼する。DataSourceUtilは、TransactionSynchronizationManagerクラス上のスレッドローカルなMap型の変数からConnectionHolderを取得する際のキーとして引数で受け取ったDataSourceインスタンスを指定します。そのため、JdbcTemplateDataSourceTransactionManagerに指定するDataSourceは、同一インスタンスのものを指定する必要があります。
Connection(Connectionから取得したStatement)は、データベースにSQLを実行する。

つまり・・・トランザクションの開始時にDataSourceからConnectionをひとつ取得し、以降の処理ではDataSourceUtil(TransactionSynchronizationManager)クラスのメソッドを介してConnectionを共有することで、複数の操作を同一トランザクション内で実行する仕組みを提供しているのです。(③と⑥がポイント

図には記載していませんが、Serviceクラスのメソッド呼び出しが終了すると、TransactionInterceptorからDataSourceTransactionManagercommitメソッド又はrollbackメソッドが呼び出され、トランザクションに割り当てられているConnectioncommitメソッド又はrollbackメソッドが呼び出される仕組みになっています。

JDBC以外のトランザクションは?

JDBC以外にも、JMS(Java Message Service)、JTA(Java Transaction API)、JPA(Java Persistence API)など様々なインフラストラクチャ用のPlatformTransactionManagerがSpringやSpringのサブプロジェクトから提供されています。複数の操作を同一トランザクション内で実行するための仕組みはそれぞれ異なりますが、JDBCと同様にトランザクション制御に必要なオブジェクトをTransactionSynchronizationManagerを介して共有する方式を採用しているケースが多いようです。(少なくてもJMSとJPAは同じ方式を採用しています)

Note:

ちなみに・・・わたしが仕事でよく使うMyBatis(MyBatis-Spring)のSqlSessionも、TransactionSynchronizationManagerを介して同一トランザクション内で同じSqlSessionが共有されるように制御されています。

さいごに

「まとめ」ることがなかったので、「さいごに」として・・・
「理解するシリーズ」を久々にかいてみました。今回はSpringのコア機能のひとつであるトランザクション管理の仕組みの一部を紹介しました。Springには様々な機能があるので、気が向いたら(書けるくらい自分が理解したら:grinning:)また投稿したいと思います。

By 「動いたからOK!じゃなくて・・・なぜ?どうやって?動いているのか紐解きたい人」

関連エントリー

以下は、トランザクション関連のエントリーです。

参考サイト

以下のサイトを参考にしました。

86
80
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
86
80