Проблемы параллелизма при регистрации на курсы в порядке очереди подачи заявок

Проблемы параллелизма при регистрации на курсы в порядке очереди подачи заявок

-Управление параллелизмом и процесс реализации для решения проблем переполнения лимита вместимости-

1. Предпосылки: переполнение вместимости при регистрации на курсы по принципу «первым пришёл — первым обслужен»

Онлайн-лекции и образовательные платформы часто ограничивают вместимость отдельных курсов и используют систему регистрации по принципу «первым пришёл — первым обслужен». Например, если вместимость составляет 100 человек, регистрация должна быть доступна до 100-го заявителя, а последующие запросы должны отклоняться.

В обычных условиях это можно реализовать, запросив текущее количество записавшихся студентов и сохранив регистрацию, если это количество меньше вместимости. Однако проблемы возникают, когда несколько пользователей регистрируются практически одновременно. Если вместимость составляет 100 человек, а текущее количество — 99, два одновременных запроса могут оба прочитать значение 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 может предотвращать одновременное выполнение одного и того же кода в пределах одной JVM. Однако при масштабировании сервера до нескольких экземпляров каждый сервер использует отдельную JVM, поэтому synchronized не может управлять параллелизмом между серверами. Следовательно, этот подход не подходит для распределённых сред.

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;

Ключевой момент заключается в обработке проверки условия и изменения значения как одной операции базы данных. Если свободные места ещё есть, 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 условный UPDATE можно определить с помощью @Modifying и @Query.

@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 позволяет объединить проверку вместимости и увеличение количества записавшихся студентов в одну операцию. Ещё одно преимущество заключается в возможности использовать существующую функциональность базы данных без добавления отдельной системы распределённых блокировок. Для такой простой задачи увеличения количества, как текущая, это относительно лаконичное решение.

6. Повторные регистрации и целостность данных

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

ALTER TABLE enrollment
ADD CONSTRAINT uk_enrollment_student_course
UNIQUE (student_id, course_id);

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

7. Результаты реализации

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

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

8. Заключение

Проблема переполнения вместимости при регистрации на курсы по принципу «первым пришёл — первым обслужен» не так заметна при обычной обработке одиночных запросов, но сразу проявляется, когда запросы концентрируются в определённый момент. Невозможно безопасно обрабатывать несколько запросов, просто запрашивая текущее количество и проверяя, меньше ли оно вместимости.

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

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

dwmoon

Site footer