🦧

DB負荷改善の道のり:技術的負債解消から始まった挑戦

に公開

はじめに

みなさま、こんにちは!!!
サービス機能開発チームの尾形です。
今回は、弊社のシステム基盤チームと共同で今まさに取り組んでいるDB負荷改善プロジェクトについて紹介します。

DB負荷改善プロジェクトとは

弊社のインフラ構築、運用を担当しているシステム基盤チームから、システムの監視をしている中でDBサーバーへの負荷が高まっている時間があると報告を受けました。
このまま放置すれば、パフォーマンス低下やDBのクラッシュの恐れがあるため、DBの負荷を軽減するための改善プロジェクトが立ち上がりました。

改修方針の決定

負荷の原因について調査をすると、重いバッチ処理が動いている時間と、ユーザーのアクセスが集中している時間が重なっていることが判明しました。
そのため、システム基盤チームと協議し、アプリケーションのクエリをWriterインスタンスとReaderインスタンスに振り分けることで負荷を軽減する改修を行うこととしました。

謎の挙動

しかし、調査を進める中で参照系のクエリの中で更新処理が行われている箇所が見つかりました。
このままでは参照系のクエリをReaderインスタンスへ振り分けることができません。そのクエリを更に調査していくと、更新した日時の管理をしているLoginDateというカラムに対して更新処理が行われていることがわかりました。しかも、何故か時刻が18分59秒巻き戻っていました。。。

これらのカラムは、インサートや更新した日時の管理をしているカラムです。

LoginDateカラム
Before: 1754-01-01 09:00:00
After:  1754-01-01 08:41:01

show-sql=trueに設定してSQLログを確認したところ、確かに意図しないUPDATEが実行されています。

[http-nio-8080-exec-2][DEBUG][org.hibernate.SQL] update ***

原因発覚

原因は、LoginDateカラムの初期値を1754年に設定していたことにありました。1750年代というのはイギリスを含む一部の国々がグレゴリオ暦に移行した年代で、その影響でdatatime型の最小値が1753年になっているDBもあります。
具体的には、社内で実装された「ZonedDateTimeConverter#convertToEntityAttribute」というメソッドで、DBから取得した日時情報をシステムのデフォルトタイムゾーンに基づいた日時情報に変換した際に、更新されていました。

dbData.toLocalDateTime().atZone(ZoneId.systemDefault());

Springのタイムゾーンに関する計算や変換を行う時に参照しているIANA Time Zone Databaseを確認したところ、以下のようなタイムゾーン情報が確認できます。
https://github.com/eggert/tz/blob/2025a/asia#L2147-L2148

日本標準時が施行されたのは1888年1月1日で、それ以前は日本標準時(JST)が存在しておらず、タイムゾーンのデータが整備されていませんでした。
そのため1750年代の時刻は、日本の地方時 LMT(UTC+9時間18分59秒)を基にしており、現在のタイムゾーン JST(UTC+9時間)に変換しようとすると、18分59秒のズレが反映され、巻き戻りのような問題が発生していたのです。

対処方法

hibernate-java8のバージョンを5.0.12.Finalにアップデートすることで、hibernateが提供する日時型変換をサポートするZonedDateTime.ofInstantが優先されるようになります。

このメソッドはタイムゾーンの履歴データを考慮し、LMTからJSTへの変換を含む過去のタイムゾーン変更を適切に処理します。そうすることによりLMTからJSTへのタイムゾーン変更が考慮され、JSTではなく、LMTのオフセットが適用されるようになります。これによりズレが解消され、意図しない更新が実行されないようになりました。

ZonedDateTime.ofInstant(sqlTimestamp.toInstant(), ZoneId.systemDefault());

コードベースでReadレプリカへの振り分けを実装

ようやくReadレプリカへの振り分けを実装する段階になります。
当初振り分けは、アプリケーションロードバランサーで実装していましたが、レプリカラグの解消に時間がかかってしまうことから、代替案としてコードベースで振り分けを行うことにしました。

以下のサンプルコードで、@Transactional(readOnly = true)が付いた処理をReadレプリカに振り分けます。
このクラスは、@Transactional(readOnly = true)が付与されたメソッドが呼ばれた際に、自動的にレプリカのデータソース("reader")に接続し、それ以外の書き込みトランザクションではマスターデータソース("writer")に接続するように動作します。

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    public DataSource actualDataSource(
            @Qualifier("readWriteDataSource") DataSource readWriteDataSource,
            @Qualifier("readOnlyDataSource") DataSource readOnlyDataSource) {
        DataSourceRouting routingDataSource = new DataSourceRouting();
        Map<Object, Object> map = new HashMap<>();
        map.put("reader", readOnlyDataSource);
        map.put("writer", readWriteDataSource);
        routingDataSource.setTargetDataSources(map);
        routingDataSource.afterPropertiesSet();
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}

public class DataSourceRouting extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        Boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        return readOnly ? "reader" : "writer";
    }
}

まとめ

現在、本番リリースに向けた検証が着実に進行しており、どの程度まで負荷の解消が実現できるかについては依然として不確実な部分が多いものの、その結果に対しては大いに期待を寄せています!

インスタンスの振り分けを実現するために、まず技術的負債の解消に取り組みました。その過程でSpringの内部的な動きについても深く理解することができ、IANA Time Zone Databaseの知見やコードベースでのReadレプリカへの振り分け方法などの学びを得ることができました!!

WealthNavi Engineering Blog

Discussion