mariadb를 사용한다.
@Transactional
public CreateRegistrationResponse create(ClaimsDto claims, CreateRegistrationRequest request) {
if(!validateRegistarionPeriod(LocalDateTime.now())){
throw RegistrationBusinessException.notRegistrationPeriod();
}
Lecture lecture = lectureRepository.findByIdWithLock(request.getLectureId()).orElseThrow(LectureBusinessException::lectureNotFound);
Student student = studentRepository.findById(Long.parseLong(claims.getUsername())).orElseThrow(StudentBusinessException::studentNotFound);
// 중복 신청여부 확인
if(registrationRepository.isAlreadyRegistered(lecture.getId(), student.getId())){
throw RegistrationBusinessException.alreadyRegistration();
}
// 강의가 여전히 신청가능한지 확인
if(!lecture.hasCapacity()){
throw RegistrationBusinessException.lectureAlreadyFull();
}
// registration 생성 -> 이때 lecture entity의 increaseStudent를 Registration의 생성자 내에서 호출
Registration registration = new Registration(student, lecture);
registration = registrationRepository.save(registration);
return CreateRegistrationResponse.from(registration);
}
이렇게 수강신청을 한다
public interface LectureRepository extends JpaRepository<Lecture, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select l from Lecture l where l.id = :id")
Optional<Lecture> findByIdWithLock(@Param("id") Long id);
}
동시성 제어를 위해서 PESSIMISTIC_WRITE lock을 취득한다.
@Import(TestcontainersConfiguration.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
public class RegistrationConcurrencyTest {
@Autowired
private TestRestTemplate template;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private RegistrationRepository registrationRepository;
@Autowired
private LectureRepository lectureRepository;
@Autowired
private StudentRepository studentRepository;
@Autowired
private RegistrationPeriodRepository periodRepository;
@Autowired
private AuthenticationService authenticationService;
@Autowired
private JwtUtils jwtUtils;
@LocalServerPort
private int port;
private String lectureName = "동시성강의";
private Integer capacity = 30;
private Integer studentCount = 100;
private Long lectureId;
@BeforeEach
void setup(){
// 수강신청할 강의 생성
Lecture lecture = new Lecture(lectureName, capacity);
lecture = lectureRepository.save(lecture);
lectureId = lecture.getId();
// 수강신청 기한 생성
LocalDateTime start = LocalDateTime.now().minusDays(1);
LocalDateTime end = start.plusDays(7);
periodRepository.save(new RegistrationPeriod(start, end));
}
@Test
void test() throws Exception {
List<String> tokens = new ArrayList<>();
for(int i=0;i<studentCount;i++){
LoginRequest loginRequest = new LoginRequest("testusername" + i, "password");
tokens.add(authenticationService.signup(loginRequest).getAccessToken());
}
ExecutorService executorService = Executors.newFixedThreadPool(studentCount);
CountDownLatch readyLatch = new CountDownLatch(studentCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(studentCount);
AtomicInteger error = new AtomicInteger();
AtomicInteger fail = new AtomicInteger();
AtomicInteger success = new AtomicInteger();
Map<String, Object> requestBody = Map.of("lectureId", lectureId);
for (final String token : tokens){
executorService.submit(() -> {
try {
HttpHeaders header = new HttpHeaders();
header.setContentType(MediaType.APPLICATION_JSON);
header.setBearerAuth(token);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, header);
readyLatch.countDown();;
readyLatch.await();
startLatch.await();
ResponseEntity<CreateRegistrationResponse> response = template.postForEntity("/registrations", request, CreateRegistrationResponse.class);
if (response.getStatusCode().isSameCodeAs(HttpStatus.CREATED)){
success.incrementAndGet();
}else if (response.getStatusCode().isSameCodeAs(HttpStatus.CONFLICT)){
fail.incrementAndGet();
}else {
error.incrementAndGet();
}
}catch (Exception ex){
fail.incrementAndGet();
}finally {
endLatch.countDown();
}
});
}
readyLatch.await();
startLatch.countDown();
endLatch.await();
assertEquals(capacity, success.get());
assertEquals(studentCount - capacity, fail.get());
assertEquals(0, error.get());
}
}
이런 테스트코드를 시도한다.
org.springframework.dao.CannotAcquireLockException: JDBC exception executing SQL [select l1_0.lecture_id,l1_0.capacity,l1_0.name,l1_0.student_count from lectures l1_0 where l1_0.lecture_id=? for update] [(conn=9) Record has changed since last read in table 'lectures'] [n/a]; SQL [n/a]
(중략)
Caused by: org.hibernate.exception.LockAcquisitionException: JDBC exception executing SQL [select l1_0.lecture_id,l1_0.capacity,l1_0.name,l1_0.student_count from lectures l1_0 where l1_0.lecture_id=? for update] [(conn=9) Record has changed since last read in table 'lectures']
(중략)
Caused by: java.sql.SQLException: (conn=9) Record has changed since last read in table 'lectures'
at org.mariadb.jdbc.export.ExceptionFactory.createException(ExceptionFactory.java:313) ~[mariadb-java-client-3.5.6.jar:na]
(중략)
필요:30
실제 :11
이렇게 실패한다.
CannotAcquireLockException는 말 그대로 lock을 획득하지 못했다는 뜻이다
중요한 건 왜 획득하지 못했냐는 것이다.
mariadb의 lock 획득 타임아웃은 50초이기 때문이다.
그리고 lock 획득 타임아웃을 초과하면 다른 메시지가 나온다
[HY000][1205] (conn=85) Lock wait timeout exceeded; try restarting transaction
Record has changed since last read in table
이것은 무슨 문제일까.
우선 문제를 재현해보자
2개의 커넥션을 만들고
차례대로 명령어를 실행하면 된다.
이때 명령어의 실행순서가 중요한데,
-- 1
START TRANSACTION;
-- 2
select * from registration_periods;
-- 3
select * from lectures l where l.lecture_id = 9999 for update;
-- 4
update lectures set student_count = 1 where lecture_id = 9999;
-- 5
commit;
| A | B | 비고 |
|---|---|---|
| 1: START TRANSACTION; | ||
| 2: select * from registration_periods; | 스냅샷 생성 | |
| 1: START TRANSACTION; | ||
| 2: select * from registration_periods; | ||
| 3: select * from lectures l where l.lecture_id = 9999 for update; | ||
| 4:update lectures set student_count = 1 where lecture_id = 9999; | ||
| 5: commit | ||
| 3: select * from lectures l where l.lecture_id = 9999 for update; | Record has changed since last read in table 발생 |
왜 이런 문제가 발생할까?
그것은 lock의 문제가 아니라 트랜잭션의 작동방식에 의한 것이다.
REPEATABLE READ는 트랜잭션 격리 수준 중 하나로, mariadb의 기본 격리수준이다.
동일한 트랜잭션에서 모든 select의 결과는 첫번째 select 때 생성된 스냅샷을 읽는 다는 뜻이다.
이를 위해서 mariadb에서는 MVCC를 사용한다.
MVCC는 첫번째 select 쿼리가 실행된 시점에서 해당 트랜잭션이 읽을 수 있게 snapshot을 생성하고 이 snapshot을 해당 트랜잭션의 select 문에서 읽게끔 한다.
"Locking reads inside InnoDB read the latest committed version, ignoring what should actually be visible to the transaction"
Isolation level violation testing and debugging in MariaDB
그런데 이게 왜 이번 케이스에서 문제가 되는 걸까?
mariadb의 최신버전에서의 작동방식이 달라졌기 때문이다. innodb_snapshot_isolation 옵션이 그 원인이다.
nonlocking select는 snapshot을 읽지만, locking read는 트랜잭션 내부 스냅샷이 아닌 최신버전의 데이터를 취득하려한다.
innodb_snapshot_isolation는 이 최신 데이터를 취득한 뒤 트랜잭션 내부 스냅샷의 값과 비교한 뒤 두 데이터가 동일하지 않는 모순을 해결하기 위해서 ER_CHECKREAD 오류를 발생시키고 트랜잭션을 롤백시킨다.
innodb_snapshot_isolation를 끄면 된다.
하지만 db 설계자들이 일관성 있는 데이터베이스를 위해 추가한 기능을 끄는 것은 좋은 선택이라 생각되지 않는다.
다시 서비스 레벨로 돌아가자면
@Transactional
public CreateRegistrationResponse create(ClaimsDto claims, CreateRegistrationRequest request) {
if(!validateRegistarionPeriod(LocalDateTime.now())){
// 여기서 select를 사용해서 문제발생
throw RegistrationBusinessException.notRegistrationPeriod();
}
Lecture lecture = lectureRepository.findByIdWithLock(request.getLectureId()).orElseThrow(LectureBusinessException::lectureNotFound);
Student student = studentRepository.findById(Long.parseLong(claims.getUsername())).orElseThrow(StudentBusinessException::studentNotFound);
// 중복 신청여부 확인
if(registrationRepository.isAlreadyRegistered(lecture.getId(), student.getId())){
throw RegistrationBusinessException.alreadyRegistration();
}
// 강의가 여전히 신청가능한지 확인
if(!lecture.hasCapacity()){
throw RegistrationBusinessException.lectureAlreadyFull();
}
// registration 생성 -> 이때 lecture entity의 increaseStudent를 Registration의 생성자 내에서 호출
Registration registration = new Registration(student, lecture);
registration = registrationRepository.save(registration);
return CreateRegistrationResponse.from(registration);
}
수강신청 기간확인 때문에 시작된 select 때문에 snapshot이 생성됨을 확인할 수 있다.
2가지 해결법이 있는데, lock 획득 후 수강신청기간을 확인하는 것과, 수강신청기간 조회를 아예 다른 트랜잭션으로 빼는 것이다.
후자를 선택했다.
수강신청 로직이 exclusive lock을 획득하기 때문에 단순 강좌 조회 등의 다른 요청 처리에 있어서 부정적인 영향을 줄 것이라고 생각했기 때문이다. 성능상 영향을 줄 수 있는 lock 처리하기 이전에 먼저 수강신청기간인지 검사하고 적절한 경우에만 lock을 획득하는 것이 옳다고 생각했다.
@RequiredArgsConstructor
@RestController
@RequestMapping("/registrations")
public class RegistrationController {
private final RegistrationService registrationService;
@PostMapping
public ResponseEntity<CreateRegistrationResponse> createRegistration(@RequestBody CreateRegistrationRequest request, @TokenClaim ClaimsDto claims){
if(!registrationService.validateRegistarionPeriod(LocalDateTime.now())){
throw RegistrationBusinessException.notRegistrationPeriod();
}
CreateRegistrationResponse response = registrationService.create(claims, request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
package com.rejs.registration.domain.registration.service;
import com.rejs.registration.domain.entity.Lecture;
import com.rejs.registration.domain.entity.Registration;
import com.rejs.registration.domain.entity.RegistrationPeriod;
import com.rejs.registration.domain.entity.Student;
import com.rejs.registration.domain.lecture.exception.LectureBusinessException;
import com.rejs.registration.domain.lecture.repository.LectureRepository;
import com.rejs.registration.domain.registration.dto.reqeust.CreateRegistrationRequest;
import com.rejs.registration.domain.registration.dto.response.CreateRegistrationResponse;
import com.rejs.registration.domain.registration.exception.RegistrationBusinessException;
import com.rejs.registration.domain.registration.repository.RegistrationPeriodRepository;
import com.rejs.registration.domain.registration.repository.RegistrationRepository;
import com.rejs.registration.domain.student.exception.StudentBusinessException;
import com.rejs.registration.domain.student.repository.StudentRepository;
import com.rejs.registration.global.exception.GlobalException;
import com.rejs.registration.global.exception.NotFoundException;
import com.rejs.token_starter.token.ClaimsDto;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class RegistrationService {
private final RegistrationRepository registrationRepository;
private final RegistrationPeriodRepository periodRepository;
private final StudentRepository studentRepository;
private final LectureRepository lectureRepository;
@Transactional
public CreateRegistrationResponse create(ClaimsDto claims, CreateRegistrationRequest request) {
Lecture lecture = lectureRepository.findByIdWithLock(request.getLectureId()).orElseThrow(LectureBusinessException::lectureNotFound);
Student student = studentRepository.findById(Long.parseLong(claims.getUsername())).orElseThrow(StudentBusinessException::studentNotFound);
// 중복 신청여부 확인
if(registrationRepository.isAlreadyRegistered(lecture.getId(), student.getId())){
throw RegistrationBusinessException.alreadyRegistration();
}
// 강의가 여전히 신청가능한지 확인
if(!lecture.hasCapacity()){
throw RegistrationBusinessException.lectureAlreadyFull();
}
// registration 생성 -> 이때 lecture entity의 increaseStudent를 Registration의 생성자 내에서 호출
Registration registration = new Registration(student, lecture);
registration = registrationRepository.save(registration);
return CreateRegistrationResponse.from(registration);
}
public boolean validateRegistarionPeriod(LocalDateTime now){
List<RegistrationPeriod> periods = periodRepository.findByPeroid(now);
// 나중에 1학년만 가능 등등 옵션 추가가능할 수 있도록
return !periods.isEmpty();
}
}
변경한 이후 테스트를 통과하는 데 성공했다.