처음에는 낙관적 락, 비관적 락, 분산 락을 고려했다. 그러나 이 프로젝트는 서버가 분산되지 않았고, DB 또한 분산 DB가 아니기 때문에 분산 락은 배제했다.
대부분의 트랜잭션이 충돌이 발생하지 않을 것
이라고 낙관적으로 가정하는 방법이다.version
컬럼을 통해 동시성을 제어한다.충돌이 발생하면 개발자가 수동으로 롤백처리
를 해줘야한다.@Version
으로 추가한 버전 관리 필드는 JPA가 직접 관리하므로 임의로 수정해서는 안된다.벌크 연산
의 경우 버전을 무시하므로 버전 필드를 증가하는 부분을 추가
해야한다.update Recruitment p set p.applicant_count = p.applicant_count + 1, p.version = p.version + 1
낙관적 락의 version은 int, Integer, short, Short, long, Long, Timestamp
타입에서 적용할 수 있다. 또한 메서드, 필드에 적용할 수 있다.
public class Recruitment extends BaseTimeEntity {
public static final boolean IS_CLOSED_DEFAULT = false;
public static final int MAX_IMAGE_SIZE = 5;
@Id
@Column(name = "recruitment_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long recruitmentId;
@Version
private Long version;
@OneToMany(mappedBy = "recruitment", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
private List<Applicant> applicants = new ArrayList<>();
...
}
Recruitment 엔티티에 버전을 추가한다.
@Transactional
@DataIntegrityHandler(message = "이미 신청한 봉사입니다.", exceptionClass = ApplicantConflictException.class)
public void registerApplicantWithOptimisticLockV2(Long recruitmentId, Long volunteerId) {
Volunteer volunteer = getVolunteer(volunteerId);
while (true) {
try {
optimisticLockQuantityService.registerApplicantOptimistic(volunteer, recruitmentId);
break;
} catch (ObjectOptimisticLockingFailureException e) {
log.info("충돌이 발생했습니다. 재시도합니다.");
}
}
}
ApplicantService에 while문, try-catch문 안에 봉사 신청 로직을 추가하고 충돌 발생 여부를 로그로 확인한다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void registerApplicantOptimistic(Volunteer volunteer, Long recruitmentId) {
Recruitment recruitment = getRecruitment(recruitmentId);
Applicant applicant = new Applicant(recruitment, volunteer);
recruitment.increaseApplicantCount();
applicantRepository.save(applicant);
}
private Recruitment getRecruitment(Long recruitmentId) {
return recruitmentRepository.findOpti(recruitmentId)
.orElseThrow(() -> new RecruitmentNotFoundException("못 찾음"));
}
OptimisticLockQuantityService에 registerApplicantOptimistic을 구현해 트랜잭션을 분리한다.
@Transactional(propagation = Propagation.REQUIRES_NEW)은 트랜잭션이 존재하는 경우 해당 트랜잭션을 잠시 보류시키고, 신규 트랜잭션을 생성한다. (트랜잭션을 분리하지 않을 경우 영속성 컨텍스트를 공유하므로 충돌로 인해 재시도를 할 때 갱신된 버전의 recruitment 가 아닌 1차 캐시에 존재하는 recruitment 를 불러오게 되어 정상적으로 동작하지 않는다.)
@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@Query("select r from Recruitment r where r.recruitmentId = :id")
Optional<Recruitment> findOpti(@Param("id") Long recruitmentId);
RecruitmentRepository에 findOpti를 구현해 낙관적 락을 건다. OPTIMISTIC_FORCE_INCREMENT은 낙관적 락을 사용하면서 version을 강제로 증가하는 옵션이다.(따로 해주지 않아도 동작함)
@Test
@DisplayName("성공: 30명 정원, 30명 동시 신청")
void registerApplicantWhenRegisterWith30In30CapacityV2() throws InterruptedException {
//given
int capacity = 30;
List<Volunteer> volunteers = VolunteerFixture.volunteers(capacity);
Recruitment recruitment = RecruitmentFixture.recruitment(shelter, capacity);
volunteerRepository.saveAll(volunteers);
recruitmentRepository.save(recruitment);
int poolSize = 30;
ExecutorService executorService = Executors.newFixedThreadPool(poolSize);
CountDownLatch latch = new CountDownLatch(poolSize);
//when
for (int i = 0; i < poolSize; i++) {
int finalI = i;
executorService.submit(() -> {
try {
applicantService.registerApplicantWithOptimisticLockV2(shelter.getShelterId(),
volunteers.get(finalI).getVolunteerId());
} finally {
latch.countDown();
}
});
}
latch.await();
//then
Recruitment findRecruitment = entityManager.find(Recruitment.class,
recruitment.getRecruitmentId());
List<Applicant> findApplicants = getApplicants(recruitment);
assertThat(findRecruitment.getApplicantCount()).isEqualTo(capacity);
assertThat(findApplicants).hasSize(capacity);
}
“30명 정원, 30명 동시 신청”하는 테스트 코드를 작성해 실행했다.
Deadlock found when trying to get lock; try restarting transaction
데드락이 걸린다.
MySQL에 접속해서 show engine innodb status;
로 확인해봤다.
2023-12-06 23:34:02 0x16d96b000
*** (1) TRANSACTION:
TRANSACTION 3032073, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 424, OS thread handle 10909495296, query id 8778 localhost 127.0.0.1 root updating
update recruitment set applicant_count=1,content='recruitmentContent',capacity=30,deadline='2024-01-05 14:34:02.525494',end_time='2024-01-06 16:34:02.525494',is_closed=0,start_time='2024-01-06 14:34:02.525494',shelter_id=4,title='recruitmentTitle',updated_at='2023-12-06 14:34:02.672198',version=1 where recruitment_id=4 and version=0
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 10256 page no 4 n bits 72 index PRIMARY of table `anifriends`.`recruitment` trx id 3032073 lock mode S locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
...
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10256 page no 4 n bits 72 index PRIMARY of table `anifriends`.`recruitment` trx id 3032073 lock_mode X locks rec but not gap waiting
Record lock, heap no 5 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
...
*** (2) TRANSACTION:
TRANSACTION 3032096, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 426, OS thread handle 10911723520, query id 8805 localhost 127.0.0.1 root updating
update recruitment set applicant_count=1,content='recruitmentContent',capacity=30,deadline='2024-01-05 14:34:02.525494',end_time='2024-01-06 16:34:02.525494',is_closed=0,start_time='2024-01-06 14:34:02.525494',shelter_id=4,title='recruitmentTitle',updated_at='2023-12-06 14:34:02.67714',version=1 where recruitment_id=4 and version=0
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 10256 page no 4 n bits 72 index PRIMARY of table `anifriends`.`recruitment` trx id 3032096 lock mode S locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
lock mode S locks rec but not gap Record lock
lock_mode X locks rec but not gap waiting Record lock
S-lock
X-lock
MySQL 8.0 공식 문서에 답이 나와있다.
💡 If a `FOREIGN KEY` constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. `InnoDB` also sets these locks in the case where the constraint fails.s-lock
💡 [UPDATE ... WHERE ...] sets an exclusive next-key lock on every record the search encounters. However, only an index record lock is required for statements that lock rows using a unique index to search for a unique row.x-lock
핵심은 이 부분이다.
@Transactional
public void registerApplicantOptimistic(Volunteer volunteer, Long recruitmentId) {
Recruitment recruitment = getRecruitment(recruitmentId);
Applicant applicant = new Applicant(recruitment, volunteer);
recruitment.increaseApplicantCount();
applicantRepository.save(applicant);
}
private Recruitment getRecruitment(Long recruitmentId) {
return recruitmentRepository.findOpti(recruitmentId)
.orElseThrow(() -> new RecruitmentNotFoundException("못 찾음"));
}
트랜잭션 1과 트랜잭션 2가 동시에 실행된다고 가정한다.
applicantRepository.save(applicant)
에서 Recruitment에 s-lock
을 획득한다. applicantRepository.save(applicant)
를 실행해 Recruitment에 s-lock
을 획득한다.x-lock
을 획득하려고 시도했지만 트랜잭션 2에 s-lock
이 걸려있어 대기한다.x-lock
을 획득하려고 시도했지만 트랜잭션 1에 s-lock
이 걸려있어 대기한다.대부분의 트랜잭션이 충돌이 발생할 것
으로 비관적으로 가정하는 방법이다.select ~ for update
쿼리가 실행된다.@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select r from Recruitment r where r.recruitmentId = :recruitmentId")
Optional<Recruitment> findByIdPessimistic(@Param("recruitmentId") Long recruitmentId);
RecruitmentRepository에 비관적 락을 적용한다.
PESSIMISTIC_WRITE : 데이터베이스에 쓰기 락
public Applicant(
Recruitment recruitment,
Volunteer volunteer
) {
validateRecruitment(recruitment);
validateVolunteer(volunteer);
validateApplicantCount(recruitment);
recruitment.increaseApplicantCount();
this.recruitment = recruitment;
this.volunteer = volunteer;
recruitment.addApplicant(this);
volunteer.addApplicant(this);
}
비관적 락은 increaseApplicantCount()를 Applicant 생성자에 넣었다.
@Transactional
@DataIntegrityHandler(message = "이미 신청한 봉사입니다.", exceptionClass = ApplicantConflictException.class)
public void registerApplicant(Long recruitmentId, Long volunteerId) {
Recruitment recruitmentPessimistic = getRecruitmentPessimistic(recruitmentId);
Volunteer volunteer = getVolunteer(volunteerId);
Applicant applicant = new Applicant(recruitmentPessimistic, volunteer);
applicantRepository.save(applicant);
if (recruitmentPessimistic.isFullApplicants()) {
shelterNotificationRepository.save(
makeClosedRecruitmentNotification(recruitmentPessimistic));
}
}
private Recruitment getRecruitmentPessimistic(Long recruitmentId) {
return recruitmentRepository.findByIdPessimistic(recruitmentId)
.orElseThrow(() -> new RecruitmentNotFoundException("존재하지 않는 봉사 모집글입니다."));
}
OptimisticLockQuantityService의 getRecruitmentPessimistic()이 실행될 때 x-lock이 걸린 상태에서 기능이 수행되고 난 뒤에 Applicant의 생성자에 있는 increaseApplicantCount()가 실행된다.
@Test
@DisplayName("성공: 10명 정원, 30명 동시 신청")
void registerApplicantWhenRegisterWith30In10Capacity() throws InterruptedException {
//gvien
int capacity = 10;
List<Volunteer> volunteers = VolunteerFixture.volunteers(capacity);
Recruitment recruitment = RecruitmentFixture.recruitment(shelter, capacity);
volunteerRepository.saveAll(volunteers);
recruitmentRepository.save(recruitment);
int poolSize = 30;
ExecutorService executorService = Executors.newFixedThreadPool(poolSize);
CountDownLatch latch = new CountDownLatch(poolSize);
//when
for (int i = 0; i < poolSize; i++) {
int finalI = i;
executorService.submit(() -> {
try {
applicantService.registerApplicant(shelter.getShelterId(),
volunteers.get(finalI).getVolunteerId());
} finally {
latch.countDown();
}
});
}
latch.await();
//then
Recruitment findRecruitment = entityManager.find(Recruitment.class,
recruitment.getRecruitmentId());
List<Applicant> findApplicants = getApplicants(recruitment);
assertThat(findRecruitment.getApplicantCount()).isEqualTo(capacity);
assertThat(findApplicants).hasSize(capacity);
}
“10명 정원, 30명 동시 신청”하는 테스트 코드에서 registerApplicant() 함수로 바꿔 실행했다.
성공!
낙관적 락이 걸려 select ~ for update 쿼리를 볼 수 있다.
50명 정원, 60명 동시 신청인 경우 ngrinder로 테스트했다.
약 1.8초가 걸린다. (AWS EC2 t2.small, AWS RDS t1.micro)
유명인이 다녀간 보호소인 경우 봉사 신청이 몰리는 케이스를 있기 때문에 비관적 락을 채택했다. “트래픽이 적은 보호소의 봉사 신청은 낙관적 락을 이용하고 트래픽이 몰리는 보호소의 봉사 신청은 비관적 락으로 적용하면 되지 않을까?” 생각이 든다. 트래픽을 마주했을 때 팀원들과 고민해봐야겠다.
DB 커넥션 풀 디폴트 값이 10이기 때문에 타임아웃이 발생한다. 커넥션 풀을 늘려서 해결하면 된다.
spring:
datasource:
hikari:
maximum-pool-size: 80
적용한 프로젝트(브랜치 dev, test/optimistic-lock, test/optimistic-deadlock) : https://github.com/Anifriends/Anifriends-Backend
ExecutorService : https://mangkyu.tistory.com/259
CountDownLatch : https://imasoftwareengineer.tistory.com/100
트랜잭션 옵션 : https://velog.io/@stpn94/JPA에서-Transaction의-전파
s-lock, x-lock : https://jaeseongdev.github.io/development/2021/06/16/Lock의-종류-(Shared-Lock,-Exclusive-Lock,-Record-Lock,-Gap-Lock,-Next-key-Lock)/
https://www.letmecompile.com/mysql-innodb-lock-deadlock/
https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html