Concurrency Issues in First-Come, First-Served Course Registration

Concurrency Issues in First-Come, First-Served Course Registration

-Concurrency Control and Implementation Process for Solving Capacity Limit Overbooking Problems-

1. Background: Capacity Overbooking in First-Come, First-Served Course Registration

Online lectures and educational platforms often limit the capacity of specific courses and use a first-come, first-served registration system. For example, if the capacity is 100, registrations should be accepted through the 100th applicant, and subsequent requests should be closed.

Under normal circumstances, this can be implemented by querying the current number of enrolled students and saving the registration if the number is below the capacity. However, problems arise when multiple users register at nearly the same time. If the capacity is 100 and the current number is 99, two simultaneous requests may both read 99 and successfully register, resulting in a final total of 101 students.

This is a concurrency problem that occurs when multiple transactions simultaneously read and modify the same data. Therefore, it is difficult to reliably guarantee a business rule such as a capacity limit with simple CRUD logic alone.

2. Limitations of a Simple Implementation

The easiest implementation is to query the current number of students, check the capacity, and then save the registration.

@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));
}

The problem with this code is that multiple requests can read the same currentCount and pass the condition simultaneously. Applying @Transactional does not automatically block concurrent access between transactions. Therefore, separate concurrency control is required.

3. Comparison of Concurrency Solutions

3.1 synchronized

synchronized can prevent simultaneous execution of the same code within a single JVM. However, when the server is scaled to multiple instances, each server uses a separate JVM, so it cannot control concurrency between servers. Therefore, it is not suitable for distributed environments.

3.2 Pessimistic Lock

A pessimistic lock locks a database row, then checks and updates the number of enrolled students. In JPA, PESSIMISTIC_WRITE can be used.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Course c where c.id = :id")
Optional<Course> findByIdForUpdate(@Param("id") Long id);

Although it has the advantage of working across multiple servers, the number of requests waiting for the lock may increase when requests are concentrated on a specific course.

3.3 Optimistic Lock

An optimistic lock uses version information to check whether another transaction has modified the data.

@Version
private Long version;

Because an exception occurs in the event of a conflict, additional handling such as retries is required. In situations where conflicts are concentrated within a short period, as with first-come, first-served registration, the retry policy must also be considered.

4. Final Choice: Conditional Atomic UPDATE

For this problem, we chose the database's conditional atomic UPDATE approach. Instead of querying the current number and making the decision in the application, this approach includes the capacity condition in the UPDATE statement.

UPDATE course
SET current_count = current_count + 1
WHERE id = :courseId
  AND current_count < capacity;

The key is to process the condition check and value change as a single database operation. If capacity remains, the UPDATE succeeds. Once the capacity has been reached, subsequent requests do not satisfy the WHERE condition, and the number of affected rows becomes 0.

The application can determine whether the operation succeeded by checking the number of affected rows.

@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)
    );
}

In Spring Data JPA, a conditional UPDATE can be defined using @Modifying and @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. Why This Approach Was Chosen

The most important criterion was whether the business rule of a capacity limit could be guaranteed at the database level. synchronized has limitations in a multi-server environment, while optimistic locking requires retry logic when conflicts are frequent. Pessimistic locking is reliable, but lock contention may occur when requests are concentrated on a specific course.

A conditional atomic UPDATE can combine capacity validation and the increase in the number of enrolled students into a single operation. Another advantage is that it can use existing database functionality without adding a separate distributed lock system. For a simple quantity-increase problem such as the current requirement, it is a relatively concise solution.

6. Duplicate Registrations and Data Integrity

Even if capacity overbooking is prevented, duplicate registrations by the same student must be prevented separately. The same request may be duplicated due to rapid button clicks or network retransmission. Therefore, it is safest to place a unique constraint on the combination of the student and the course.

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

In addition, the capacity increase and registration record insertion must be processed in a single transaction. Configuring the two operations to either succeed together or roll back together when an error occurs prevents the problem of the capacity increasing without the registration being saved.

7. Effects of Implementation

Applying concurrency control can prevent capacity overbooking even when requests are concentrated immediately after an event begins. Because it uses atomic database operations, the same capacity rule can be applied in environments where multiple application servers are running.

Another advantage is that infrastructure complexity does not increase significantly because no separate distributed lock system is required. The code also becomes simpler because the process of querying, making a decision, and saving can be reduced. However, if external payments or message publishing are involved, separate transaction and event-processing strategies should be considered.

8. Conclusion

The capacity overbooking problem in first-come, first-served course registration is not easily visible during normal single-request processing, but it appears immediately when requests become concentrated at a particular moment. It is difficult to safely process multiple requests simply by querying the current number and checking whether it is below the capacity.

For this problem, we compared several approaches and selected a conditional atomic UPDATE. This is because it prevents capacity overbooking and produces consistent results across multiple-server environments by processing capacity validation and the increase in the number of enrolled students as a single database operation. Applying a unique constraint and transaction together can also prevent duplicate registrations and data inconsistencies.

Ultimately, the key to concurrency problems is to clearly define which data can be modified simultaneously, which business rules must be followed, and at which layer those rules should be guaranteed. This perspective is especially important for functions that simultaneously acquire limited resources such as capacity, inventory, coupon quantities, and seats.

dwmoon

Site footer