Timee Product Team Blog

タイミー開発者ブログ

バッチ処理の改善〜トランザクション範囲の最小化〜

後編(冪等性の設計導入)へ

はじめに

こんにちは。タイミーのバックエンドエンジニアの中野です。よくGopherくんに似てると言われます。

本記事では月次で実行している「締め」のバッチ処理に関する一連の技術的改善について掲載します。弊社のプロダクト「タイミー」は著しい事業成長に伴いデータ量が急増してきています。そこで今回はデータ量の急増を背景とした中長期的なバッチ処理の設計改善にどのように取り組んできたのかをご紹介したいと思います。バッチ処理に関する技術的改善の記事は前編・後編の2部構成をとっています。前編はバッチ処理におけるトランザクションの改善をテーマに、後編ではバッチ処理に冪等性の設計を導入したことをご紹介したいと思います。
今回は前編のトランザクションの改善をテーマにご紹介します。すでに本番稼働しているアプリケーションにおいてトランザクションの範囲が大きい場合にどのような問題が発生したのか、そしてどう解決していったのかを中心に取り上げます。

読み手としては、今後データ量の急激な増加が見込まれるプロダクト開発の中長期的な設計・運用を模索している方を想定しています。また読み手に該当しない場合でも、現在タイミーがどのような事業を推進しているのか興味を持ってもらえるように記載しましたので是非最後までお付き合いください。

バッチ処理について

ある程度の量のデータを一括で処理することを「バッチ処理」と呼び、通常は日時、週次、月次で定期的に実行されます。オンライン処理*1を稼働もしくは停止させたままバッチ処理を実行するかによって、バッチ処理は次の2種類に分けられます。

  • オンラインバッチ
    オンライン処理を継続して稼働させた状態で実行するバッチ処理です。オンライン処理と同じリソース(例えばデータベース)へアクセスが発生する場合はオンライン処理へ影響を与える可能性があります。

  • オフラインバッチ
    オンライン処理を停止させた状態で実行するバッチ処理です。例えば、夜間にメンテナンス期間を設けることでシステムのダウンタイムを許容し実行するバッチ処理が考えられます。

今回説明する締めのバッチ処理ではオンラインバッチを前提としています。

バッチ処理改善のきっかけ

弊社では毎月初に締めのバッチ処理を実行しています。具体的な処理内容の説明は以降のパートに譲りますが、月次で定期的に実行されるオンラインバッチを想定してください。約2年前の設計当初は問題なく稼働していましたが、プロダクトが成長するにつれてバッチの処理時間が長期化してきました。処理時間が長期化するにつれて、これまで見えてこなかった問題が起きるようになりました。その問題とはオンライン処理におけるLockWaitTimeoutエラーの発生です。実は改善前のバッチ処理では処理全体でトランザクションを貼り実行していたため、バッチ処理で作成されるレコードまたは外部キー制約において値が登録されている親テーブルのレコードにおいて占有・共有ロックが行われていました。レコードにロックがとられている場合、トランザクションがコミットされるまでの間に、他の処理からロックされているレコードに対しての読み書きに処理待ちが発生する場合があります。バッチ処理のロックがとられる時間が長期化した結果、オンライン処理でLockWaitTimeoutエラーが発生しユーザーに500エラーが返る事態が発生しました。

この問題をどのようなプロセスを経て改善していったのか、まず弊社の締めのバッチ処理に関する説明をした上でご紹介します。

締めのバッチ処理とは

弊社と私のチームが扱っている領域

締めのバッチ処理を弊社でどう改善してきたのか説明する前に、事前知識として弊社及び「締め」の概念に関して簡単にご紹介します。

弊社で開発しているプロダクト「タイミー」は「働きたい時間」と「働いてほしい時間」をマッチングするスキマバイトサービスです。私のチームでは図1グレー部分の企業・クライアントに対し求人を円滑に掲載するための機能を主に開発しています。

用語説明

クライアント:タイミー上に求人を掲載する主体。クライアントの管理画面ではワーカー管理や出退勤管理などの機能を提供している。

企業:クライアントがタイミーを利用した際に発生する請求を管理する主体。法人単位であることが多く、企業は複数のクライアントを持つ。企業管理画面では請求書や利用明細閲覧の機能を提供している。

各チームが扱っている領域の説明

「締め」の概念

タイミーで働いたワーカーの給料はタイミーが立替払いを行なっています。そのため毎月月初の特定の時点で先月分の給料およびタイミーのサービス利用料を確定させ企業に請求を行っています。この毎月月初で請求を確定させる行為を「締め」と呼びます。

ここではより詳細に「締め」でどのような処理を行なっているのかを説明します。ワーカーの稼働により報酬が確定した段階で「請求テーブル」にレコードを作成し管理しています。毎月月初の「締め」のバッチ処理により、「請求テーブル」のレコードを「確定請求テーブル」に複製することで企業への請求を確定させていきます。簡単に説明すると、締めのバッチ処理は月初時点の請求テーブルのスナップショットを撮る行為であると捉えてください。

また企業の利用明細画面では翌月の請求予定額及び月ごとに確定した請求額を閲覧する機能を提供しています。翌月の請求予定額は締め処理が行われていない「請求テーブル」のレコードを参照しており、確定した月の請求額は「確定請求テーブル」を参照しています。

請求関連テーブルと利用明細画面との依存関係

現状の課題認識

締めの処理対象である請求テーブルの特徴として、タイミーの事業成長に伴い急激にデータ量が増えてきたことが挙げられます。締めのバッチ処理が実装された約2年前から比較すると月次で処理すべきデータ量が20倍近くになっていることが分かります。*2

請求テーブルのデータ量推移

2年前のバッチ処理設計当初はデータ量が少なかったこともあり、バッチ処理全体にトランザクションを適用していました。利用明細画面の表示が締め処理により作成される確定請求テーブルに依存していたため(図「請求関連テーブルと利用明細画面との依存関係」を参照)、トランザクションの性質を利用することにより次の2つの状態を担保していました。

  • 確定請求レコードが全て作成される状態
    トランザクションが成功した場合に遷移する状態。
    この状態に遷移した時に翌月の請求予定額及び締めにより確定した月の請求金額の利用明細画面の表示が一斉に更新される

  • 確定請求レコードが1つも作成されていない状態
    処理開始前またはバッチ処理が失敗した場合に遷移する状態。
    バッチ処理が途中で失敗した場合にトランザクションロールバックにより処理開始前の状態に戻ります。翌月の請求予定額及び締めにより確定した月の請求額の利用明細画面の表示はバッチ処理実行前のままとなる。

このようにトランザクションにおける原子性の特性を利用することで、バッチ処理が失敗した場合でも処理途中までの確定請求レコードが中途半端に作成される状態は存在しません。そのため、確定請求テーブルに依存している利用明細画面の表示上の金額も上の2つの状態しか取りえないことになります。

これまでバッチ処理全体におけるトランザクション貼ることで上記のような恩恵を受けてきましたが、次のような課題も出てきました。

  • バッチ処理の影響によるオンライン処理のエラー発生
    タイミーは現在モノリシックなサービスのため、オンラインバッチで処理を実行するとオンライン処理と同じデータベースに対しトランザクション処理を行うことになります。トランザクションは、他のトランザクション処理の影響を受けないようにするためレコードまたはテーブルに対してロックをとることがあります。レコードに対してロックが取られた場合に他のトランザクションからのレコードに対する更新系の処理は停止し待たされる状況が発生します。一定時間ロック状態が継続しロックが解放されない場合にLockWaitTimeoutエラーが発生し処理が失敗します。このLockWaitTimeoutエラーが発生すると、ユーザーが意図した処理が実行されないばかりか、エラー発生までユーザーは処理が待たされていることを認知できないため意図しない誤操作を行いかねません。

  • バッチ処理失敗時の処理完了時間の遅延
    バッチ処理を運用していると様々な要因により処理が異常終了します。例えば、データ量の増加に伴いコンテナのメモリ量が不足し処理が失敗する問題や請求テーブルのデータに異常なデータが混在しているなどです。処理が失敗した場合にトランザクションロールバックにより締め開始前の状態に戻るため、処理が失敗した原因を特定後に再度締めの開始から処理を再開する必要がありました。結果的に締めが完了までの時間が長期化し企業への請求確定メールが遅延することも課題としてありました。

実施した施策

トランザクションの範囲をバッチ処理全体から最小限の範囲に限定しました。

では、最小限の範囲とはどう評価すべきでしょうか。トランザクションはアプリケーションから見て一貫した状態から次の一貫した状態へ遷移させる作用とみなすことができます。ここで言及した最小限の範囲とは、一貫した状態から次の一貫した状態へと遷移させるトランザクションのうち最小の処理を持つトランザクションの適用範囲を指します。

言葉で定義するとわかりにくいため、次の事例を考えます。

  1. 請求レコードに対応する確定請求レコードの作成
  2. 確定請求レコードを作成が完了した場合に処理済みの請求レコードを管理するための処理済みフラグを更新

上記1, 2の処理を締め対象月の請求レコードに対し逐次的に処理していきます。トランザクションの最小限の範囲を考えるにあたり次の3つのトランザクション(A, B, C)を例に考えます。

トランザクションA

まず初めに状態aから状態bへのトランザクションによる遷移を考えます。これは今まで締めのバッチ処理で扱ってきたパターンです。a, bの状態はアプリケーション上許容される状態のため一貫した状態であると言えそうです。ただし、締め処理全体の処理であるため最小の処理を持つトランザクションとは必ずしも言えなそうです。

状態a: 締め対象月の確定請求テーブルのレコードが1つも作成されていない状態
状態b: 締め対象月の確定請求テーブルのレコードが全て作成され請求レコードも全て処理済みに更新されている状態

トランザクションAによる状態遷移

トランザクションB

次に、状態cから状態dへのトランザクションによる遷移を考えます。c, dの状態は確定請求レコードの作成と請求レコードの処理済みフラグの更新が両方とも実行されており、アプリケーション上許容される状態です。そのためc, dは共に一貫した状態であると言えそうです。さらに最小の処理を持つトランザクションは存在するのでしょうか。

状態c: ある請求レコードに対応する確定請求レコードが作成されていない、かつ請求レコードが処理済みに更新されていない状態
状態d: ある請求レコードに対応した確定請求レコードが作成され、かつ請求レコードが処理済みに更新された状態

トランザクションBによる状態遷移

トランザクションC

最後に状態eから状態fへのトランザクションによる遷移を考えます。これがトランザクションによる最小の処理と言えそうです。しかし、前提として確定請求レコードの作成と処理済みのフラグ更新は両方とも処理が成功するか、もしくは両方とも処理が失敗すべきかの状態が担保される必要があり、確定請求レコードの作成のみが処理されている状態fはアプリケーション上許容されません。これは一貫した状態への遷移とは言えなそうです。

状態e: 請求レコードに対応する確定請求テーブルのレコードが作成されていない状態
状態f: 請求レコードに対応する確定請求テーブルのレコードが作成されるも請求レコードの処理済みのフラグが更新されていない状態

トランザクションCによる状態遷移

上の3つのケースから一貫した状態から次の一貫した状態への最小限の処理を持つトランザクション処理は、トランザクションBとなります。このようにして、締めのバッチ処理に対して最小限のトランザクション範囲を限定し変更していきました。

達成できたこと

これまで1度の締めのバッチ処理に対してLockWaitTimeoutエラーを20 ~ 30件近く観測していましたが、トランザクションの範囲を最小限に限定することで0件にまで抑制することができました。しかし、バッチ処理全体におけるトランザクションを廃止したためバッチ処理全体における原子性が担保されなくなりました。原子性が担保されなくなったことにより、請求テーブル及び確定請求テーブルに依存している利用明細画面の表示に問題が発生しました。どのような問題が発生し、どう解決していったのか後編で詳細をまとめていますので、是非後編もご覧ください。

まとめ

データ量の増加が見込まれるシステムでは、それに限らず一般的なシステム開発においてトランザクションの範囲は最小限に限定する必要があります。今回の事例のようにレコードに広範囲のロックがとられる可能性があり処理の遅延を招くことがあります。

後編(冪等性の設計導入)へ

*1:バッチ処理とは対照的にユーザーからのリクエストに応じて逐次処理を実行し即時的にレスポンスを返す処理をオンライン処理と呼びます。

*2:縦軸はバッチ処理が導入された当初(2020/4)の月次データ量を1としたときの倍率を表しています