オンライン試験の答案消失を防ぐ技術の導入記

オンライン試験の答案消失を防ぐ技術の導入記

オンライン教育サイトを運営する中で、受講生の学習到達度を評価するため、定期的にオンライン試験を実施することになりました。準備した試験の同時受験者数は約100人でした。通常の単純な閲覧中心のWebトラフィックから考えると、それほど大規模ではないと思えるかもしれません。

しかし、オンライン試験というドメインの特性を詳しく検討する過程で、致命的なリスク要因を一つ発見しました。試験終了時刻ちょうどに、すべての受講生からの解答保存リクエストと提出リクエストが、わずか1秒という短い時間に同時に集中する点です。さらに、不安定な個人ネットワーク環境によるクライアントの再試行や、フロントエンドのタイマーによる自動提出リクエストが重なれば、たった一人でも受講生の作成した解答が欠落する可能性がありました。

実際の試験運営中に解答の消失事故が発生すれば、受講生の正当な努力が損なわれるだけでなく、教育プラットフォーム全体の信頼性にも大きな打撃を与えます。そのため私たちのチームは、実際に試験を公開する前に、解答が欠落する可能性のあるシナリオを定義し、それを確実に防ぐための3つの重要な技術を先行して導入しました。本記事では、試験終了時のリクエスト集中と同時実行性の問題を解決するために、どのような技術を選定・実装し、解答を欠落させることなく試験を安定して処理したのか、その過程を紹介します。

1. 事前検証で発見した解答欠落のリスクシナリオ

本格的に技術を導入する前に、一般的な同期型データベース保存構造で、試験終了直前に発生し得る問題を確認しました。その結果、実際の試験で十分に起こり得る、解答が欠落する具体的な経路を3つ特定しました。

1.1 終了時のリクエスト集中とパケットの順序逆転

受講生は通常、試験終了直前まで記述式の解答を推敲したり、選択式のマークを修正したりします。100人余りの受講生が終了直前の数秒間に一斉に解答を修正し、自動保存が実行されると、瞬間的に多数の書き込みリクエストがデータベースに集中します。このとき、Wi-Fiやモバイルホットスポットなどの不安定なネットワークを利用している受講生では、先に送信した修正リクエストより、後から送った最新の修正リクエストが先にサーバーへ到着する、パケットの順序逆転が起こる可能性があります。単純な上書き構造では、遅れて到着した古いリクエストが最新の解答を上書きしてしまい、更新の消失が発生します。

1.2 手動提出とタイマーによる自動提出の同時競合

試験終了の1秒前に受講生が「提出する」ボタンをクリックすると同時に、ブラウザーのタイマーが切れて自動提出APIを呼び出す状況です。さらに、ネットワーク遅延によるクライアントの再試行リクエストも重なると、同じ試験受験データに対して、数ミリ秒間隔で複数の提出・保存トランザクションが同時にアクセスします。このとき、状態制御が厳密でなければ、自動提出が完了した直後に遅れて実行された保存リクエストが確定済みの解答に手を加えたり、重複提出エラーが発生したりして、正常な締め切り処理に失敗するリスクがありました。

1.3 クライアントの時計のずれと締め切り判定の問題

フロントエンドのタイマーは、受講生のPCのシステム時計に従います。受講生の端末の時計がサーバーより遅れていたり、終了直前に送信したパケットがネットワーク遅延によって1秒遅れてサーバーに到着したりすると、サーバーの基準では締め切りを過ぎたリクエストとなります。サーバーがこれを単純に拒否すると、受講生は時間内に提出したにもかかわらず、最後の解答が欠落することになります。

2. 解答の消失を防ぐための主要技術と選定理由

上記の問題を根本から防ぐため、私たちは3つの技術を導入しました。トラフィックの緩衝とバージョン制御を担う、Apache Kafkaベースの自動保存パイプライン。同時実行の競合を防ぐ、提出状態マシンと再試行可能な提出API。そして時間判定の公平性を保証する、サーバー基準の締め切り時間検証です。

2.1 Kafkaベースの非同期自動保存とバージョンベースの更新

  • 導入技術: Apache Kafkaイベントストリーミングとクライアントのシーケンス番号を利用したDB条件付き更新

  • 選定理由: 試験中に発生する多数の自動保存リクエストや、試験終了直前に急増する書き込みトラフィックを、データベースが同期的に直接受け付けると、コネクションの遅延やロック競合が避けられません。そのため、一時的に集中する保存リクエストを失うことなく素早く受け付け、データベースが処理可能な速度で安全に反映するため、Kafkaを導入しました。

また、Kafkaを通じて非同期にデータを処理する場合や、ネットワークパケットの順序が逆転した場合でも、古いデータが最新データを上書きしないよう、バージョンベースの更新も併せて適用しました。クライアントが解答を送信する際には、単調増加するシーケンス番号(clientSeq)も一緒に送信し、Kafkaのパーティションキーに受験セッションID(attemptId)を指定して、同じ受講生のイベント順序を保証しました。さらに、データベースに最終反映する際は、現在保存されているシーケンスより大きいシーケンスを持つリクエストだけを反映するよう、条件付き更新クエリを構成しました。

package com.edu.exam.producer;

import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import java.time.Instant;

@Service
public class AnswerAutoSaveProducer {

    private static final String TOPIC_NAME = "exam-answer-autosave-events";
    private final KafkaTemplate<String, AnswerSaveEvent> kafkaTemplate;

    public AnswerAutoSaveProducer(KafkaTemplate<String, AnswerSaveEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    /**
     * Publishes auto-save requests to Kafka partitioned by attemptId
     * to guarantee ordering per examinee and absorb traffic spikes.
     */
    public void publishAutoSaveEvent(long attemptId, long questionId, String content, long clientSeq) {
        AnswerSaveEvent event = new AnswerSaveEvent(
                attemptId,
                questionId,
                content,
                clientSeq,
                Instant.now().toEpochMilli()
        );

        // Using attemptId as the partition key ensures sequential processing for the same user
        kafkaTemplate.send(TOPIC_NAME, String.valueOf(attemptId), event);
    }

    public record AnswerSaveEvent(
            long attemptId,
            long questionId,
            String answerContent,
            long clientSeq,
            long serverReceivedAtMs
    ) {}
}

Kafkaコンシューマーはトピックからイベントを読み込み、データベースに反映します。このとき、ネットワーク上ですでに順序が逆転して到着した古いパケットがあっても、以下の条件付きクエリがエラーを発生させずに安全に無視します。

package com.edu.exam.consumer;

import com.edu.exam.producer.AnswerAutoSaveProducer.AnswerSaveEvent;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

import java.sql.Timestamp;
import java.time.Instant;

@Component
public class AnswerAutoSaveConsumer {

    private final NamedParameterJdbcTemplate jdbcTemplate;

    public AnswerAutoSaveConsumer(NamedParameterJdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    /**
     * Consumes auto-save events and performs a version-checked upsert.
     * Prevents out-of-order network packets from overwriting newer answers.
     */
    @KafkaListener(topics = "exam-answer-autosave-events", groupId = "exam-autosave-group")
    public void consumeAndSaveAnswer(AnswerSaveEvent event) {
        String sql = """
            INSERT INTO exam_answer
                (attempt_id, question_id, answer_text, last_client_seq, updated_at)
            VALUES
                (:attemptId, :questionId, :answerText, :clientSeq, :updatedAt)
            ON DUPLICATE KEY UPDATE
                answer_text     = IF(:clientSeq > last_client_seq, :answerText, answer_text),
                updated_at      = IF(:clientSeq > last_client_seq, :updatedAt, updated_at),
                last_client_seq = IF(:clientSeq > last_client_seq, :clientSeq, last_client_seq)
            """;

        MapSqlParameterSource params = new MapSqlParameterSource()
                .addValue("attemptId", event.attemptId())
                .addValue("questionId", event.questionId())
                .addValue("answerText", event.answerContent())
                .addValue("clientSeq", event.clientSeq())
                .addValue("updatedAt", Timestamp.from(Instant.ofEpochMilli(event.serverReceivedAtMs())));

        jdbcTemplate.update(sql, params);
    }
}

この構成により、試験終了直前に保存リクエストが一度に集中しても、APIサーバーはKafkaにイベントをすばやく発行して応答を返すため、ボトルネックが解消されました。また、データベースのレベルで :clientSeq > last_client_seq の条件を検証するため、どのようなパケット順序の逆転が起きても、受講生が最後に作成した最新の解答だけが正確に保持されます。

2.2 サーバー基準の締め切り時間検証(Server Time Authority & Grace Period)

  • 導入技術: サーバー時刻を唯一の基準とする仕組みと、ネットワーク遅延を考慮した猶予時間(Grace Period)の検証

  • 選定理由: クライアントのシステム時刻にはいつでもずれが生じたり、改ざんされたりする可能性があるため、締め切り時間の判定基準は必ずサーバーにする必要があります。しかし、サーバー時刻だけを機械的に適用すると、試験終了直前に正常に送信されたリクエストが、ネットワークの伝送時間のためにわずかな差で拒否される問題が生じます。そのため、すべてのリクエストの到着時刻はサーバーの時計を基準に記録しつつ、公式の終了時刻から数秒間の猶予時間(Grace Period)を設け、ネットワーク遅延による欠落を防ぐように設計しました。

また、試験開始時にサーバーの現在時刻をクライアントに伝え、画面上のタイマーがサーバーの時計と同じ基準で動作するよう補正しました。

package com.edu.exam.service;

import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;

@Component
public class ExamTimeValidator {

    // Allow a 3-second grace period to absorb network latency at the exact moment of exam termination
    private static final Duration NETWORK_GRACE_PERIOD = Duration.ofSeconds(3);

    /**
     * Validates whether the incoming request arrived within the authoritative server deadline
     * including the allowed network grace period.
     */
    public boolean isWithinValidSubmissionWindow(Instant officialEndTime, Instant serverArrivedAt) {
        Instant validCutoffTime = officialEndTime.plus(NETWORK_GRACE_PERIOD);
        return !serverArrivedAt.isAfter(validCutoffTime);
    }
}

この検証機能により、受講生が終了直前の最後の瞬間まで作成した解答が、ネットワークを通じてサーバーに届くまでの物理的な遅延を安全に吸収し、正当な解答が破棄される事態を防ぎました。

2.3 提出状態マシンと再試行可能な冪等性提出API

  • 導入技術: 一方向の提出状態マシン(State Machine)と、状態を収束させる冪等性(Idempotency)提出API

  • 選定理由: 試験終了時には、ユーザーによる手動提出のクリック、タイマーによる自動提出リクエスト、ネットワークタイムアウトによるクライアントの再試行が同時に重なります。このとき、重複リクエストが届いたという理由でサーバーがエラーを返すと、受講生の画面に提出失敗のメッセージが表示され、大きな混乱が生じます。また、提出ボタンを押す直前の1〜2秒間に作成され、まだ自動保存されていない最後の解答が失われる可能性もあります。

これを解決するため、試験の状態を IN_PROGRESS -> SUBMITTING -> SUBMITTED(または AUTO_SUBMITTED) と一方向にのみ遷移する状態マシンとして定義し、2つの安全策を実装しました。

  1. 原子的な状態遷移(CAS)と冪等性のある応答: データベースの条件付き更新(WHERE status = 'IN_PROGRESS')により、同時に届いた手動提出と自動提出のうち、状態を変更する権限を得られるのは一方だけとしました。後から到着した重複リクエストや再試行リクエストについては、試験がすでに締め切り状態(SUBMITTEDまたはAUTO_SUBMITTED)であることを確認し、エラーではなく正常な提出完了応答を同じように返すことで、いつでも安全に再試行できるAPIを構築しました。

  2. 最終解答の同時送信(Piggybacking): 提出APIを呼び出す際、最後に作成した解答の変更分もリクエスト本文に含めて送信するようにしました。これにより、Kafkaコンシューマーの遅延に関係なく、提出トランザクション内で最後の解答を即座に反映して状態を締め切るよう実装しました。

package com.edu.exam.service;

import com.edu.exam.consumer.AnswerAutoSaveConsumer;
import com.edu.exam.producer.AnswerAutoSaveProducer.AnswerSaveEvent;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;

@Service
public class ExamSubmissionService {

    private final NamedParameterJdbcTemplate jdbcTemplate;
    private final ExamTimeValidator timeValidator;
    private final AnswerAutoSaveConsumer answerSaver;

    public ExamSubmissionService(
            NamedParameterJdbcTemplate jdbcTemplate,
            ExamTimeValidator timeValidator,
            AnswerAutoSaveConsumer answerSaver) {
        this.jdbcTemplate = jdbcTemplate;
        this.timeValidator = timeValidator;
        this.answerSaver = answerSaver;
    }

    /**
     * Processes final exam submission using a unidirectional state machine.
     * Guarantees idempotency when manual submit, auto submit, and client retries overlap.
     */
    @Transactional
    public SubmissionResult submitExam(long attemptId, boolean isAutoSubmit, List<AnswerSaveEvent> finalAnswers) {
        Instant serverNow = Instant.now();

        ExamAttemptRecord attempt = findAttemptById(attemptId);

        // 1. Idempotency check: If already finalized by a prior request, return success immediately
        if ("SUBMITTED".equals(attempt.status()) || "AUTO_SUBMITTED".equals(attempt.status())) {
            return new SubmissionResult(attemptId, attempt.status(), "ALREADY_COMPLETED_SUCCESS");
        }

        // 2. Atomic State Machine Transition: IN_PROGRESS -> SUBMITTING
        String casSql = """
            UPDATE exam_attempt
            SET status = 'SUBMITTING', updated_at = :now
            WHERE attempt_id = :attemptId AND status = 'IN_PROGRESS'
            """;

        int updatedRows = jdbcTemplate.update(casSql, new MapSqlParameterSource()
                .addValue("attemptId", attemptId)
                .addValue("now", Timestamp.from(serverNow)));
        if (updatedRows == 0) {
            // Another concurrent request is already processing or has just finished submission
            ExamAttemptRecord refreshed = findAttemptById(attemptId);
            if ("SUBMITTED".equals(refreshed.status()) || "AUTO_SUBMITTED".equals(refreshed.status())) {
                return new SubmissionResult(attemptId, refreshed.status(), "CONVERGED_SUCCESS");
            }
            throw new IllegalStateException("Submission is currently in progress. Please retry.");
        }

        // 3. Validate server deadline and flush final piggybacked answers using version check
        if (timeValidator.isWithinValidSubmissionWindow(attempt.officialEndTime(), serverNow) && finalAnswers != null) {
            for (AnswerSaveEvent answer : finalAnswers) {
                answerSaver.consumeAndSaveAnswer(new AnswerSaveEvent(
                        attemptId,
                        answer.questionId(),
                        answer.answerContent(),
                        answer.clientSeq(),
                        serverNow.toEpochMilli()
                ));
            }
        }

        // 4. Finalize state transition: SUBMITTING -> SUBMITTED or AUTO_SUBMITTED
        String targetStatus = isAutoSubmit ? "AUTO_SUBMITTED" : "SUBMITTED";
        String finalizeSql = """
            UPDATE exam_attempt
            SET status = :targetStatus, submitted_at = :now, updated_at = :now
            WHERE attempt_id = :attemptId AND status = 'SUBMITTING'
            """;

        jdbcTemplate.update(finalizeSql, new MapSqlParameterSource()
                .addValue("attemptId", attemptId)
                .addValue("targetStatus", targetStatus)
                .addValue("now", Timestamp.from(serverNow)));

        return new SubmissionResult(attemptId, targetStatus, "NEWLY_SUBMITTED_SUCCESS");
    }

    private ExamAttemptRecord findAttemptById(long attemptId) {
        String sql = "SELECT attempt_id, status, official_end_time FROM exam_attempt WHERE attempt_id = :id";
        return jdbcTemplate.queryForObject(sql, new MapSqlParameterSource("id", attemptId), (rs, rowNum) ->
                new ExamAttemptRecord(
                        rs.getLong("attempt_id"),
                        rs.getString("status"),
                        rs.getTimestamp("official_end_time").toInstant()
                )
        );
    }

    public record ExamAttemptRecord(long attemptId, String status, Instant officialEndTime) {}
    public record SubmissionResult(long attemptId, String finalStatus, String resultCode) {}
}

この設計では、提出が一度完了した後にKafkaキューに残っていた遅延中の自動保存イベントがコンシューマーで処理されても、提出時点で最も大きいシーケンス番号を持つ最終解答がすでにDBに反映されているため、データは安全に保護されます。また、PCの電源が切れるなどして提出リクエストをまったく送信できなかった受講生に備え、サーバースケジューラーが締め切りを過ぎたIN_PROGRESSセッションを定期的に見つけ、同じ状態マシンのロジックで安全に自動締め切り処理を行うよう補完しました。

3. 導入結果とまとめ

実際に試験を実施する前に、上記の3つの主要技術をシステムに適用してテストを行い、その後、100人余りの受講生を対象にオンライン試験を運営しました。

試験中から終了時刻ちょうどにかけて、受講生の解答修正リクエストと提出リクエストが同時に集中しましたが、Kafkaを利用した非同期自動保存パイプラインのおかげで、サーバーとデータベースはボトルネックや遅延を起こすことなく、すべてのリクエストを安定して処理できました。一部の受講生ではネットワーク環境が不安定で、パケットの順序が逆転して届く現象もありましたが、シーケンス番号ベースの条件付き更新が古いデータを正確に排除し、最後に作成された解答がそのまま保持されました。

特に、試験終了直前まで問題を解いて手動提出ボタンを押すのと同時にタイマーによる自動提出が作動した場合や、一時的なネットワーク切断によって再試行リクエストが発生した場合でも、一方向の提出状態マシンと冪等性APIは正常に機能しました。重複リクエストによるエラー画面を表示することなく、すべての受講生の提出処理が円滑に収束しました。また、サーバー基準の締め切り時間と猶予時間の検証により、終了直前に送信された最後の解答まで一つも欠落することなく、すべて正常に採点へ反映されました。

同時に数万人がアクセスするような大規模な試験ではありませんでしたが、試験終了時にトラフィックが集中し、複数のリクエストが競合するというオンライン試験特有の技術的課題は、規模にかかわらず同じように存在していました。問題が実際に発生してから対応するのではなく、起こり得る消失シナリオを事前に洗い出し、Kafkaベースのバージョン制御付き自動保存、サーバー時刻基準の検証、状態マシンベースの再試行可能な提出APIを先行して導入したことで、受講生に信頼できる安定した試験環境を提供できました。

dwmoon

Site footer