-定員超過問題を解決するための同時実行制御と適用プロセス-
1. 導入の背景:先着順の受講申請で発生した定員超過問題
オンライン講義や教育プラットフォームでは、特定の講座の定員を制限し、先着順で受講申請を受け付ける機能がよく使われます。例えば定員が100人の場合、100人目の申請者までは受け付け、それ以降のリクエストは受付終了として処理する必要があります。
一般的な状況では、現在の受講者数を取得し、定員未満であれば申請を保存する方式で実装できます。しかし、複数のユーザーがほぼ同時に申請すると問題が発生します。定員が100人で現在99人の時点に、2つのリクエストが同時に99人という値を読み取ると、どちらも申請に成功し、最終的な人数が101人になる可能性があります。
これは、複数のトランザクションが同じデータを同時に読み取り、変更することで発生する同時実行の問題です。そのため、単純なCRUDロジックだけでは、定員というビジネスルールを安定して保証することは困難です。
2. 単純な実装の限界
最も簡単に実装できる方法は、現在の人数を取得して定員を確認した後、申請を保存することです。
@Transactional
public void enroll(Long courseId, Long studentId) {
Course course = courseRepository.findById(courseId)
.orElseThrow();
if (course.getCurrentCount() >= course.getCapacity()) {
throw new IllegalStateException("Course is full.");
}
course.increaseCount();
enrollmentRepository.save(new Enrollment(courseId, studentId));
}
このコードの問題は、複数のリクエストが同じcurrentCountを読み取り、同時に条件を通過できてしまう点です。@Transactionalを適用したからといって、トランザクション間の同時アクセスが自動的に遮断されるわけではありません。そのため、別途同時実行制御が必要です。
3. 同時実行の解決策の比較
3.1 synchronized
synchronizedは、1つのJVM内で同じコードが同時に実行されるのを防ぐことができます。しかし、サーバーを複数台に拡張すると、各サーバーが別々のJVMを使用するため、サーバー間の同時実行は制御できません。そのため、分散環境には適していません。
3.2 悲観的ロック
悲観的ロックは、データベースの行をロックした後、受講者数を確認して変更する方式です。JPAではPESSIMISTIC_WRITEを使用できます。
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Course c where c.id = :id")
Optional<Course> findByIdForUpdate(@Param("id") Long id);
複数のサーバーでも動作するというメリットがありますが、特定の講座にリクエストが集中すると、ロック待ちのリクエストが増加する可能性があります。
3.3 楽観的ロック
楽観的ロックは、バージョン情報を利用して、他のトランザクションによる変更の有無を確認する方式です。
@Version
private Long version;
競合が発生すると例外が発生するため、リトライなどの追加処理が必要です。先着順のように短時間で競合が集中する状況では、リトライポリシーまで考慮する必要があります。
4. 最終的な選択:条件付きアトミックUPDATE
今回の問題では、データベースの条件付きアトミックUPDATE方式を選択しました。現在の人数を取得してからアプリケーションで判断するのではなく、定員の条件をUPDATE文に含める方式です。
UPDATE course
SET current_count = current_count + 1
WHERE id = :courseId
AND current_count < capacity;
条件の確認と値の変更を、1つのデータベース処理として行うことがポイントです。定員に余裕があればUPDATEが成功し、定員に達した後のリクエストはWHERE条件を満たさないため、変更行数が0になります。
アプリケーションでは、変更行数を確認して成功したかどうかを判断できます。
@Transactional
public void enroll(Long courseId, Long studentId) {
int updated = courseRepository.increaseCountIfAvailable(courseId);
if (updated == 0) {
throw new IllegalStateException("Course is full.");
}
enrollmentRepository.save(
Enrollment.create(courseId, studentId)
);
}
Spring Data JPAでは、@Modifyingと@Queryを使用して条件付きUPDATEを定義できます。
@Modifying
@Query("update Course c " +
"set c.currentCount = c.currentCount + 1 " +
"where c.id = :courseId " +
"and c.currentCount < c.capacity")
int increaseCountIfAvailable(@Param("courseId") Long courseId);
5. この方式を選択した理由
最も重要な基準は、定員というビジネスルールをデータベースレベルで保証できるかどうかでした。synchronizedは複数サーバー環境に限界があり、楽観的ロックは競合が多い状況でリトライロジックが必要です。悲観的ロックは安定していますが、特定の講座にリクエストが集中するとロック競合が発生する可能性があります。
条件付きアトミックUPDATEは、定員の検証と人数の増加を1つの処理に結合できます。別途分散ロックシステムを追加せず、既存のデータベース機能を活用できる点もメリットです。今回の要件のような単純な数量増加の問題に対しては、比較的簡潔な解決策です。
6. 重複申請とデータ整合性
定員超過を防止できたとしても、同じ学生による重複申請は別途防止する必要があります。ボタンの素早い連打やネットワークによる再送信によって、同じリクエストが重複する可能性があるためです。そのため、学生と講座の組み合わせにユニーク制約を設定するのが安全です。
ALTER TABLE enrollment
ADD CONSTRAINT uk_enrollment_student_course
UNIQUE (student_id, course_id);
また、定員の増加と受講申請の保存は、1つのトランザクションとして処理する必要があります。2つの処理がともに成功するか、エラー発生時にはともにロールバックされるように構成すれば、定員だけが増加して申請が保存されない問題を防止できます。
7. 導入効果
同時実行制御を適用すると、イベント開始直後のようにリクエストが集中する状況でも、定員超過を防止できます。データベースのアトミックな処理を使用するため、複数のアプリケーションサーバーが動作する環境でも、同じ定員ルールを適用できます。
また、別途分散ロックシステムを追加する必要がないため、インフラの複雑さを大幅に増やさずに済むというメリットがあります。取得後に判断して保存するプロセスを減らせるため、コードもシンプルになります。ただし、外部決済やメッセージ発行などが含まれる場合は、別途トランザクションおよびイベント処理戦略を検討する必要があります。
8. まとめ
先着順の受講申請における定員超過問題は、通常の単一リクエストでは容易に明らかになりませんが、特定の瞬間にリクエストが集中するとすぐに現れる同時実行の問題です。単純に現在の人数を取得し、定員未満かどうかを確認する方式だけでは、複数のリクエストを安全に処理することは困難です。
今回の問題では、複数の方式を比較した上で、条件付きアトミックUPDATEを選択しました。定員の検証と人数の増加を1つのデータベース処理として行うことで、定員超過を防止し、複数サーバー環境でも一貫した結果を得られるためです。ここにユニーク制約とトランザクションを併用すれば、重複申請とデータ不整合も防止できます。
結局のところ、同時実行問題の核心は、どのデータが同時に変更される可能性があるのか、必ず守らなければならないビジネスルールは何か、そしてどの層でそれを保証すべきかを明確に定義することです。定員、在庫、クーポンの数量、座席のように、限られたリソースを同時に取得する機能では、このような観点が特に重要です。
dwmoon