Spring環境における複数データベースのルーティング機構と実装パターン

分散型アーキテクチャにおけるデータベース接続のルーティング設計

単一のアプリケーションから複数のデータベースに接続する要件は、マルチテナントシステムやデータレイク連携において頻繁に発生します。Spring Frameworkは内部にAbstractRoutingDataSourceを提供しており、これを利用することで物理コネクションの取得タイミングでターゲットを動的に決定できます。以下に、設定方法から実行時制御、運用監視に至るまでの技術実装を示します。

1. 基本設定とプロファイル管理

接続情報を管理する場合、設定ファイルに明確なプレフィックスを割り当てて分離するのが標準的です。

spring:
  db-routing:
    main-store:
      jdbc-url: jdbc:mysql://host-a:3306/core_db
      user: admin
      pass: secure_pw
      driver: com.mysql.cj.jdbc.Driver
    archive-store:
      jdbc-url: jdbc:mysql://host-b:3306/history_db
      user: archiver
      pass: safe_key
      driver: com.mysql.cj.jdbc.Driver

対応するJava設定では、バインディング用のコンポーネントを活用してインスタンスを生成します。


@Configuration
@ConfigurationProperties(prefix = "spring.db-routing")
public class MultiDbProperties {
    private DbSettings mainStore = new DbSettings();
    private DbSettings archiveStore = new DbSettings();
    // getters & setters
}

public class DbSettings {
    private String jdbcUrl;
    private String user;
    private String pass;
    private String driver;
    // standard getters
}

@Configuration
public class DbConnectionFactory {
    
    @Bean("mainDb")
    public DataSource buildMainDataSource(MultiDbProperties props) {
        return DataSourceBuilder.create()
                .url(props.getMainStore().getJdbcUrl())
                .username(props.getMainStore().getUser())
                .password(props.getMainStore().getPass())
                .driverClassName(props.getMainStore().getDriver())
                .build();
    }

    @Bean("archiveDb")
    public DataSource buildArchiveDataSource(MultiDbProperties props) {
        return DataSourceBuilder.create()
                .url(props.getArchiveStore().getJdbcUrl())
                .username(props.getArchiveStore().getUser())
                .password(props.getArchiveStore().getPass())
                .driverClassName(props.getArchiveStore().getDriver())
                .build();
    }
}

2. 実行時ルーティングの仕組み

ルーティングの核心は、スレッド単位でコンテキストを保持し、コネクション取得時に適切な物理接続を返す設計です。


public class RoutingContextKeeper {
    private static final ThreadLocal<String> currentRoute = new ThreadLocal<>();

    public static void switchTo(String targetKey) { currentRoute.set(targetKey); }
    public static String peekRoute() { return currentRoute.get(); }
    public static void reset() { currentRoute.remove(); }
}

public class SmartRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return RoutingContextKeeper.peekRoute();
    }
}

登録された実DataSourceをラッパーに集約する設定は以下の通りです。


@Configuration
public class RoutingAssemblyConfig {
    
    @Bean
    @Primary
    public SmartRoutingDataSource assembleRouter(
            @Qualifier("mainDb") DataSource main,
            @Qualifier("archiveDb") DataSource archive) {
        
        var router = new SmartRoutingDataSource();
        var registry = new LinkedHashMap<>();
        registry.put("main", main);
        registry.put("archive", archive);
        
        router.setTargetDataSources(registry);
        router.setDefaultTargetDataSource(main);
        return router;
    }
}

3. アノテーション駆動の切り替え処理

メソッド単位でルーティングキーを指定するために、独自アノテーションとAOP介入を組み合わせます。


@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RouteTo {
    String target() default "main";
}

@Aspect
@Component
public class RouteInterceptor {
    
    @Around("@annotation(RouteTo) || @within(RouteTo)")
    public Object intercept(ProceedingJoinPoint joinPoint) throws Throwable {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        String key = Optional.ofNullable(method.getAnnotation(RouteTo.class))
                .map(RouteTo::target)
                .orElseGet(() -> Optional.ofNullable(joinPoint.getTarget().getClass().getAnnotation(RouteTo.class))
                        .map(RouteTo::target).orElse("main"));

        RoutingContextKeeper.switchTo(key);
        try {
            return joinPoint.proceed();
        } finally {
            RoutingContextKeeper.reset();
        }
    }
}

4. 内部動作とトランザクション境界

AbstractRoutingDataSourcegetConnection()呼び出し時に内部でdetermineTargetDataSource()が実行され、解決済みマップから物理DataSourceが取得されます。トランザクション管理を行う場合、トランザクション同期はルーティング決定後にバインドされるため、同一トランザクション内でキーが変更されないよう注意が必要です。


@Configuration
@EnableTransactionManagement
public class TxSetup {
    
    @Bean
    public PlatformTransactionManager txManager(SmartRoutingDataSource router) {
        return new DataSourceTransactionManager(router);
    }
}

5. 高度な運用パターン

読み書き分離の自動判別
トランザクション属性に基づいて自動でターゲットを振り分けるロジックです。


@Aspect
@Component
public class RWSplitter {
    
    @Before("@annotation(org.springframework.transaction.annotation.Transactional)")
    public void detectMode(JoinPoint jp) {
        Method m = ((MethodSignature) jp.getSignature()).getMethod();
        boolean isReadOnly = Optional.ofNullable(m.getAnnotation(Transactional.class))
                .map(Transactional::readOnly).orElse(false);
        RoutingContextKeeper.switchTo(isReadOnly ? "archive" : "main");
    }
}

ランタイムでの追加・削除
起動後のデータベース追加に対応するため、ルーティングマップの再構築を公開します。


@Service
public class RuntimeRegistry {
    private final SmartRoutingDataSource router;
    
    public RuntimeRegistry(SmartRoutingDataSource router) { this.router = router; }

    public void register(String id, DataSource ds) {
        var updated = new LinkedHashMap<>(router.getTargetDataSources());
        updated.put(id, ds);
        router.setTargetDataSources(updated);
        router.afterPropertiesSet(); // 内部キャッシュの再構築
    }
}

接続健全性チェック
バックグラウンドで定期的な疎通確認を行うスケジューリング処理です。


@Component
public class PingScheduler {
    private final Map<String, DataSource> pool;
    
    public PingScheduler(Map<String, DataSource> pool) { this.pool = pool; }

    @Scheduled(fixedDelay = 45000)
    public void verifyLiveness() {
        pool.forEach((id, src) -> {
            try (var c = src.getConnection(); var s = c.createStatement()) {
                s.execute("SELECT 1");
            } catch (SQLException e) {
                // 障害検知ロジックへフォールバック
            }
        });
    }
}

6. 最適化と観測性

接続プールのチューニングはスループットに直結します。HikariCPを用いた場合は、外部設定からバインドする形が推奨されます。


@Configuration
public class PoolTuning {
    
    @Bean
    @ConfigurationProperties(prefix = "spring.db-routing.main-store.hikari")
    public HikariConfig tuneMainPool() { return new HikariConfig(); }

    @Bean
    public DataSource managedMainPool(HikariConfig cfg) { return new HikariDataSource(cfg); }
}

メトリクス収集にはMicrometerを活用し、プールステータスを可視化します。


@Component
public class PoolObserver {
    public PoolObserver(MeterRegistry reg, Map<String, DataSource> allPools) {
        allPools.forEach((name, ds) -> {
            if (ds instanceof HikariDataSource hds) {
                Gauge.builder("db.pool.active", hds, HikariDataSource::getActiveConnections)
                     .tag("target", name).register(reg);
                Gauge.builder("db.pool.waiting", hds, HikariDataSource::getThreadsAwaitingConnection)
                     .tag("target", name).register(reg);
            }
        });
    }
}

エラーハンドリングはグローバル例外処理で一元化し、ルーティングエラーとSQL実行エラーを分類して返却します。


@ControllerAdvice
public class GlobalErrorHandler {
    
    @ExceptionHandler(DataSourceLookupFailureException.class)
    public ResponseEntity<Map<String, String>> handleRoutingFail(DataSourceLookupFailureException ex) {
        return ResponseEntity.status(503).body(Map.of("status", "UNAVAILABLE", "detail", ex.getMessage()));
    }
}

実装において留意すべき点は、トランザクションの開始後にルーティングキーを変更すると一貫性が損なわれるため、切り替えロジックは必ずトランザクション境界より前で完了させることです。接続プールの上限値は各物理DBの許容接続数を考慮して設定し、メトリクス連携による閾値アラートを導入することで、本番環境での安定性が確保されます。動的なプール再構築時はスレッドセーフな実装になっているか確認し、不要なコネクションリークを防ぐためにfinallyブロックでのコンテキストクリアは必須となります。

タグ: spring-boot multi-datasource dynamic-routing hikaricp aspect-oriented-programming

5月13日 04:26 投稿