Представляем технологию для предотвращения потери ответов на онлайн-экзаменах

Представляем технологию для предотвращения потери ответов на онлайн-экзаменах

Работая над образовательным сайтом, мы начали регулярно проводить онлайн-экзамены, чтобы оценивать учебные достижения студентов. Мы рассчитывали, что в подготовленных нами экзаменах одновременно будут участвовать около 100 человек. С точки зрения обычного веб-трафика, который в основном состоит из простых просмотров страниц, такой масштаб может показаться не особенно большим.

Однако, внимательно изучив особенности онлайн-экзаменов, мы выявили один критический риск: в момент окончания экзамена запросы всех студентов на сохранение ответов и отправку работы могли поступить одновременно в течение короткого промежутка — всего одной секунды. Если бы это совпало с повторными попытками клиентов из-за нестабильного подключения к сети и автоматическими запросами на отправку от таймеров на стороне фронтенда, возник бы риск потери ответов даже у одного студента.

Если во время настоящего экзамена ответы будут потеряны, это не только обесценит добросовестные усилия студентов, но и серьезно подорвет доверие ко всей образовательной платформе. Поэтому, прежде чем открыть экзамен для студентов, наша команда определила возможные сценарии потери ответов и заблаговременно внедрила три ключевые технологии, которые надежно предотвращают такие ситуации. В этой статье мы расскажем, какие технологии мы выбрали и внедрили, чтобы справиться с резким ростом числа запросов и проблемами параллельной обработки в конце экзамена, а также как мы обеспечили надежное проведение экзамена без потери ответов.

1. Сценарии потери ответов, выявленные в ходе предварительных проверок

Перед внедрением технологий мы изучили проблемы, которые могут возникнуть непосредственно перед окончанием экзамена при использовании типичной архитектуры синхронного хранения данных в базе. В результате мы выявили три конкретных сценария потери ответов, которые вполне могут реализоваться во время настоящего экзамена.

1.1. Всплеск запросов в конце экзамена и изменение порядка доставки пакетов

Обычно в последние мгновения перед окончанием экзамена студенты дорабатывают развернутые ответы или меняют варианты в тестах с выбором ответа. Если около 100 студентов одновременно будут редактировать ответы в последние секунды, а в это время сработает автосохранение, в базу данных может внезапно поступить множество запросов на запись. В нестабильных сетях, например при подключении через Wi-Fi или мобильную точку доступа, пакеты могут приходить не по порядку: запрос с более поздними изменениями может достичь сервера раньше предыдущего. В простой архитектуре с перезаписью данных поздно поступивший старый запрос может перезаписать последние ответы, что приведет к потере обновления.

1.2. Гонка между ручной отправкой и автоматической отправкой по таймеру

Один из возможных сценариев: студент нажимает кнопку «Отправить» за секунду до окончания экзамена, и в тот же момент истекает время на таймере браузера, который вызывает API автоматической отправки. Если к этому добавляются повторные попытки клиента из-за задержки сети, несколько транзакций отправки и сохранения могут обращаться к данным одной и той же попытки экзамена одновременно, с разницей всего в несколько миллисекунд. Без строгого управления состоянием отложенный запрос на сохранение может изменить уже зафиксированные ответы сразу после завершения автоматической отправки. Также может возникнуть ошибка из-за повторной отправки, поставив под угрозу штатное завершение экзамена.

1.3. Расхождение часов клиента и проверка дедлайна

Таймер на фронтенде ориентируется на системные часы компьютера студента. Если часы устройства отстают от серверных или пакет, отправленный непосредственно перед дедлайном, добирается до сервера на секунду позже из-за задержки сети, сервер сочтет, что запрос поступил после срока. Если сервер просто отклонит его, студент может потерять последние ответы, хотя отправил их вовремя.

2. Ключевые технологии для предотвращения потери ответов и причины их выбора

Чтобы принципиально устранить выявленные выше проблемы, мы внедрили три технологии: конвейер автосохранения на основе Kafka для сглаживания всплесков трафика и управления версиями; конечный автомат состояний отправки и API отправки с поддержкой повторных попыток для предотвращения конфликтов при параллельной обработке; а также проверку дедлайна на сервере для справедливого определения времени поступления работ.

2.1. Асинхронное автосохранение на основе Kafka и обновления с учетом версий

  • Внедренная технология: Apache Kafka для потоковой передачи событий и условные обновления базы данных с использованием порядковых номеров клиента

  • Почему мы выбрали этот подход: Если база данных будет синхронно обрабатывать многочисленные запросы автосохранения во время экзамена и всплеск записей непосредственно перед его окончанием, задержки при установлении соединений и конкуренция за блокировки неизбежны. Поэтому мы внедрили Kafka, чтобы быстро принимать всплески запросов на сохранение, не теряя их, а затем безопасно применять данные с той скоростью, с которой справляется база данных.

Мы также реализовали обновления с учетом версий, чтобы старые данные не перезаписывали новые — даже при асинхронной обработке через Kafka или нарушении порядка поступления сетевых пакетов. Отправляя ответы, клиент передает и монотонно возрастающий порядковый номер (clientSeq), а в качестве ключа раздела Kafka мы используем идентификатор сессии попытки (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. Проверка дедлайна на сервере (серверное время как источник истины и льготный период)

  • Внедренная технология: Единый источник серверного времени и проверка с учетом льготного периода на случай сетевых задержек

  • Почему мы выбрали этот подход: Поскольку системные часы клиента могут быть неточными или в любой момент подвергнуться манипуляциям, именно сервер должен определять, истек ли срок. Однако механическое применение только серверного времени может привести к тому, что запрос, отправленный непосредственно перед окончанием экзамена, будет отклонен с небольшим опозданием из-за времени передачи по сети. Поэтому мы фиксируем время поступления каждого запроса по серверным часам, но после официального окончания экзамена предусматриваем льготный период в несколько секунд, чтобы избежать потерь из-за задержки сетив нашей системе.

Также в начале экзамена мы передаем клиенту текущее время сервера, синхронизируя отображаемый на экране таймер с тем же источником времени.

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) и идемпотентный API отправки, обеспечивающий согласованность состояния (Idempotency)

  • Почему мы выбрали этот подход: В момент окончания экзамена одновременно могут произойти нажатие студентом кнопки ручной отправки, автоматический запрос на отправку от таймера и повторные попытки клиента из-за сетевых тайм-аутов. Если сервер вернет ошибку только потому, что получил повторный запрос, студент увидит сообщение о сбое отправки, что вызовет серьезное замешательство. Кроме того, ответы, введенные за последние 1–2 секунды перед нажатием кнопки «Отправить», могут потеряться, если автосохранение еще не успело их сохранить.

Чтобы решить эту проблему, мы описали состояние экзамена как однонаправленный конечный автомат, допускающий только переходы IN_PROGRESS -> SUBMITTING -> SUBMITTED (или AUTO_SUBMITTED), и реализовали два механизма защиты.

  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 могут оставаться отложенные события автосохранения, которые потребитель обработает позднее, но к моменту отправки в базу данных уже записан ответ с наибольшим порядковым номером, поэтому данные защищены. Мы также предусмотрели защиту для студентов, которые вообще не могут отправить запрос на сдачу, например если их компьютер внезапно отключится: планировщик на сервере периодически находит сессии IN_PROGRESS с истекшим сроком и безопасно закрывает их автоматически, используя ту же логику конечного автомата.

3. Результаты и выводы

Перед проведением настоящего экзамена мы внедрили в систему описанные выше три ключевые технологии, протестировали их, а затем провели онлайн-экзамен примерно для 100 студентов.

Во время экзамена и точно в момент его окончания запросы студентов на редактирование ответов и отправку работ поступили одновременно. Благодаря асинхронному конвейеру автосохранения на основе Kafka сервер и база данных надежно обработали каждый запрос без узких мест и задержек. Некоторые студенты со нестабильным сетевым подключением столкнулись с нарушением порядка доставки пакетов, но условные обновления на основе порядковых номеров корректно отфильтровали старые данные и сохранили последние введенные ответы без изменений.

Особенно хорошо показали себя однонаправленный конечный автомат состояний отправки и идемпотентный API: они работали как задумано, даже когда студенты нажимали кнопку ручной отправки одновременно с автоматической отправкой по таймеру или повторяли запросы после кратковременного обрыва сети. Процесс отправки у каждого студента завершился штатно, без ошибок из-за повторных запросов. Проверка дедлайна на сервере с учетом льготного периода также обеспечила включение в оценивание всех последних ответов, отправленных непосредственно перед окончанием экзамена, — ни один ответ не был потерян.

Хотя это был не масштабный экзамен с десятками тысяч одновременных пользователей, технические проблемы, характерные для онлайн-экзаменов, — концентрация трафика в конце и конкуренция между многочисленными запросами — не зависят от масштаба. Вместо того чтобы реагировать на уже возникшие проблемы, мы заранее определили возможные сценарии потери данных и заблаговременно внедрили автосохранение с управлением версиями на основе Kafka, проверку по серверному времени и API отправки с поддержкой повторных попыток на основе конечного автомата состояний. Это позволило нам обеспечить студентам надежную и стабильную среду для сдачи экзамена.

dwmoon

Site footer