MySQLで読み書き分離パターンを選択する方法

読み書き分離パターンの選択

読み書き分離を実装する場合、適切なパターンを選択することが重要です。一般的に使用される読み書き分離パターンには、単純な読み書き分離とロードバランサーを活用した読み書き分離の2種類があります。データソースを動的に切り替えることで、これらのパターンを実装できます。以下では、Spring Bootベースの読み書き分離 구현と、さまざまなパターンのコード例について詳しく説明します。

読み書き分離パターン

  1. 単純な読み書き分離:読み取り操作ではスレーブデータベースを書き込み操作ではマスターデータベースを使用します。
  2. ロードバランス型読み書き分離:読み取り操作時、複数のスレーブデータベースから1つを選択して負荷を分散します。

開発環境の準備

Spring BootとMySQLを使用し、1つのマスターデータベースと2つのスレーブデータベースで構成される環境を想定します。SpringのDataSourceRouterクラスを使用してデータソースの動的切り替えを実装し、AOP Aspectを使用して読み取り操作と書き込み操作を区別します。

プロジェクト依存関係

pom.xmlに必要な依存関係を追加します:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
</dependencies>

データソース設定

データソース設定クラスでマスターデータベースとスレーブデータベースを設定し、ルーティングデータソースを設定して読み書き分離を行います。

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableTransactionManagement
public class DatabaseConfig {

    @Bean(name = "masterDatabase")
    public DataSource masterDatabase() {
        return DataSourceBuilder.create()
                .url("jdbc:mysql://localhost:3306/db_primary")
                .username("admin")
                .password("secret")
                .driverClassName("com.mysql.cj.jdbc.Driver")
                .build();
    }

    @Bean(name = "replicaDatabaseOne")
    public DataSource replicaDatabaseOne() {
        return DataSourceBuilder.create()
                .url("jdbc:mysql://localhost:3306/db_replica_1")
                .username("admin")
                .password("secret")
                .driverClassName("com.mysql.cj.jdbc.Driver")
                .build();
    }

    @Bean(name = "replicaDatabaseTwo")
    public DataSource replicaDatabaseTwo() {
        return DataSourceBuilder.create()
                .url("jdbc:mysql://localhost:3306/db_replica_2")
                .username("admin")
                .password("secret")
                .driverClassName("com.mysql.cj.jdbc.Driver")
                .build();
    }

    @Bean
    public DataSource routerDataSource(
            @Qualifier("masterDatabase") DataSource masterDatabase,
            @Qualifier("replicaDatabaseOne") DataSource replicaDatabaseOne,
            @Qualifier("replicaDatabaseTwo") DataSource replicaDatabaseTwo) {
        
        AbstractRoutingDataSource router = new AbstractRoutingDataSource() {
            @Override
            protected Object determineCurrentLookupKey() {
                return DatabaseContext.getCurrentTarget();
            }
        };

        Map<Object, Object> targetDatabases = new HashMap<>();
        targetDatabases.put(DatabaseTarget.PRIMARY, masterDatabase);
        targetDatabases.put(DatabaseTarget.REPLICA_1, replicaDatabaseOne);
        targetDatabases.put(DatabaseTarget.REPLICA_2, replicaDatabaseTwo);

        router.setDefaultTargetDataSource(masterDatabase);
        router.setTargetDataSources(targetDatabases);
        return router;
    }

    @Bean
    public JdbcTemplate jdbcTemplate(DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }
}

データソースコンテキスト

現在のデータベースターゲット(マスタースレーブ)を保持するコンテキストクラスを定義します:

public class DatabaseContext {
    private static final ThreadLocal<DatabaseTarget> currentTarget = new ThreadLocal<>();

    public static void setCurrentTarget(DatabaseTarget target) {
        currentTarget.set(target);
    }

    public static DatabaseTarget getCurrentTarget() {
        return currentTarget.get();
    }

    public static void clear() {
        currentTarget.remove();
    }
}

public enum DatabaseTarget {
    PRIMARY,
    REPLICA_1,
    REPLICA_2
}

AOP Aspect実装

AOP Aspectを使用して読み書き分離を実装します。読み取り操作前はスレーブデータベース、書き込み操作前はマスターデータベースを設定します。

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

import java.util.concurrent.atomic.AtomicInteger;

@Aspect
@Component
public class RoutingAspect {

    private AtomicInteger roundRobin = new AtomicInteger(0);

    @Before("execution(* com.example.repository.*.search*(..)) || execution(* com.example.repository.*.fetch*(..))")
    public void configureReadDatabase() {
        int replicaIndex = roundRobin.incrementAndGet() % 2;
        if (replicaIndex == 0) {
            DatabaseContext.setCurrentTarget(DatabaseTarget.REPLICA_1);
        } else {
            DatabaseContext.setCurrentTarget(DatabaseTarget.REPLICA_2);
        }
    }

    @Before("execution(* com.example.repository.*.save*(..)) || execution(* com.example.repository.*.modify*(..)) || execution(* com.example.repository.*.remove*(..))")
    public void configureWriteDatabase() {
        DatabaseContext.setCurrentTarget(DatabaseTarget.PRIMARY);
    }
}

データベース操作サービス

具体的なデータベース操作サービスを実装します:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ProductRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public void createProduct(String productId, String productName, BigDecimal price) {
        String sql = "INSERT INTO products (id, name, price) VALUES (?, ?, ?)";
        jdbcTemplate.update(sql, productId, productName, price);
    }

    public List<Product> findByName(String productName) {
        String sql = "SELECT * FROM products WHERE name = ?";
        return jdbcTemplate.query(sql, new Object[]{productName}, (resultSet, rowNumber) ->
                new Product(
                    resultSet.getString("id"),
                    resultSet.getString("name"),
                    resultSet.getBigDecimal("price")
                ));
    }
}

読み書き分離のテスト

ProductRepositoryのメソッドを呼び出してテストします:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.util.List;

@Component
public class IntegrationTestRunner implements CommandLineRunner {

    @Autowired
    private ProductRepository productRepository;

    @Override
    public void run(String... arguments) throws Exception {
        productRepository.createProduct("prod001", "Sample Product", new BigDecimal("2500.00"));

        List<Product> products = productRepository.findByName("Sample Product");
        products.forEach(product -> System.out.println("Found: " + product.getName()));
    }
}

実装のポイント

以下の手法により、Spring Bootベースの読み書き分離を実装できます:

  1. データソース設定:マスターデータベースとスレーブデータベースを設定し、ルーティングデータソースを通じて読み書き分離を行います。
  2. コンテキスト管理:現在のデータベースターゲットを保持するコンテキストクラスを定義します。
  3. AOP Aspect:AOP Aspectを使用して、読み取り操作と書き込み操作の前にそれぞれ適切なデータベースターゲットを設定します。ラウンドロビン方式による負荷分散も実装可能です。
  4. リポジトリ実装:具体的なデータベース操作をJdbcTemplateを使用して実装します。

この実装により、データベースの読み書き分離と負荷分散を効果的に行え、パフォーマンスとスケーラビリティを向上させることができます。プロジェクトの要件に応じて、読み書き分離パターンを適切に選択してください。

タグ: MySQL spring-boot データベース 読み書き分離 AOP

7月5日 23:00 投稿