-
이벤트 페이지 그리고 데드락.. 그리고 FK 로 인한 공유 잠금.. 그리고 비관적 잠금..Spring-Boot 2023. 7. 12. 18:00test
서비스에서 이벤트를 쿠폰으로 뿌리게 되면 엄청난 트래픽으로 인해 장애를 경험한적은 다들 한번쯤 있을것이다.
근데 나는 없다.
그래서 나는 혼자 이벤트를 만들고 혼자 참여하고 혼자 장애를 경험하고 혼자 고쳐보려 했다.
개발하는 어플리케이션은
정해진 쿠폰 수가 있고 쿠폰 재고가 끝날때까지 쿠폰을 받아갈수있는 이벤트이다.
Event 엔티티와 Coupon 엔티티를 작성한다.
@Entity @Table(name = "tb_event") @NoArgsConstructor @Getter @EqualsAndHashCode public class Event { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true) private Long code; private String name; //이벤트 이름 private Long count; //쿠폰 재고 수 @OneToMany(fetch = FetchType.LAZY, mappedBy = "event") private List<Coupon> coupons = new ArrayList<>(); public Event(Long code, String name, Long count) { this.code = code; this.name = name; this.count = count; } public void publishCoupon(Coupon coupon){ this.isPublishCoupon(); this.count = this.count - 1; this.coupons.add(coupon); coupon.publish(this); } public void isPublishCoupon(){ if(this.count <= 0) throw new IllegalStateException("not coupon count"); } } @Entity @Table(name = "tb_coupon") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Coupon { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true) private String code; //쿠폰 번호 @JoinColumn(name = "event_id") @ManyToOne(fetch = FetchType.LAZY) private Event event; public Coupon(String code) { this.code = code; } public void publish(Event event){ this.event = event; } }
@Repository public interface EventRepository extends JpaRepository<Event,Long> { Optional<Event> findFirstByCode(Long code); } @Repository public interface CouponRepository extends JpaRepository<Coupon, Long> { }
Event 생성과 Coupon 을 발급 받을수있는 코드를 작성하였다.
@Service @Transactional @RequiredArgsConstructor public class EventService { private final CouponRepository couponRepository; private final EventRepository eventRepository; public void event(Long code, String name, Long count){ Event event = new Event(code, name, count); eventRepository.save(event); } public String coupon(Long code) { Event event = eventRepository.findFirstByCode(code) .orElseThrow(() -> new IllegalArgumentException("not exist event code")); event.isPublishCoupon(); String couponCode = randomCode(); Coupon coupon = new Coupon(couponCode); event.publishCoupon(coupon); couponRepository.save(coupon); return couponCode; } private String randomCode(){ return "<random>"; } }
coupon을 발행받기위해 Event의 code를 가지고
Event를 찾은후에 남은 재고가있다면 재고수를 1 줄이고 coupon 을 생성하여 발급하는 코드이다.
위와같이 코드를 작성한후에 멀티스레드 환경에 노출되면 적합성이 깨져서 count 감소가 순차적으로 이루어지지 못하고
쿠폰이 잘못 발행될것이라 예상했다.
테스트 환경은
Jmeter 을 사용하여 Number of Threads 를 늘려서 테스트 해보기도 하고 아래와같은 테스트 코드를 돌려서 확인 했다.
@Test public void multPublish() throws InterruptedException { int count = 10; ExecutorService executorService = Executors.newFixedThreadPool(count); CountDownLatch countDownLatch = new CountDownLatch(count); for (int i = 0; i < count; i++) { executorService.execute(() -> { try { ResultActions perform = mvc.perform(get(couponTemplate) .contentType(MediaType.APPLICATION_JSON) ); perform.andExpect(status().isOk()); countDownLatch.countDown(); } catch (Exception e) { e.printStackTrace(); } }); } countDownLatch.await(); }
결과는 성공하지만 데이터는 잘못들어갈것이라는 예상과는 다르게 Deadlock 가 발생했다.
[pool-2-thread-1] SQL Error: 1213, SQLState: 40001 [pool-2-thread-1] (conn=196) Deadlock found when trying to get lock; try restarting transaction [pool-2-thread-1] HHH000010: On release of batch it still contained JDBC statements [pool-2-thread-1] connection: [ 5] | action: [ rollback] | time: [2 ms] [pool-2-thread-2] connection: [ 4] | action: [ commit] | time: [3 ms]
일단 데드락 문제를 해결하기위해 데이터베이스의 잠금 에 대해 알아야한다.
데이터 베이스 LOCK
데이터베이스의 일관성과 무결성을 유지하기위해 트랜잭션 처리의 순차성을 보장하는 방법이다
잠금은 상황에 따라 두가지로 나뉜다
- 공유 잠금(Shared Lock, S 락, 읽기 잠금)
- 배타 잠금(Exclusive Lock, X 락, 쓰기 잠금)
Shared Lock
데이터를 읽을때 사용되는 잠금 이며
하나의 ROW 를 여러 트랜잭션이 동시에 읽을 수 있도록 공유 잠금끼리의 동시 접근을 지원하지만
다른 트랜젝션이 읽고 있는 ROW 를 수정하거나 삭제 할수는 없도록 한다.
Exclusive Lock
데이터를 변경할때 사용되는 잠금 이며 트랜젝션이 완료될때 까지 유지된다
이 잠금이 해제 될때 까지는 다른 트랜젝션 이 데이터에 접근 할 수 없고
공유 잠금, 배타 잠금을 걸수 없다.
쉽게보면
- 읽는 건 모두가 같이 할 수 있다.
- 내가 읽고 있을 때 다른 사람이 수정할 수 없다.
- 내가 수정하고 있을 때 다른 사람이 읽을 수 없다.
일반적으로 DBMS 에는 데드락이 발생하면 트랜젝션중 하나를 강제로 중지시켜서
한 트랜젝션은 정상적으로 실행되게 하고 중지된 트랜젝션은 원래 상태로 되돌리는 데드락 탐지 기능을 제공한다.
다시 위의 서비스 로직과 데드락 로그를 보면
public String coupon(Long code) { //event 조회 Event event = eventRepository.findFirstByCode(code) event.isPublishCoupon(); String couponCode = randomCode(); Coupon coupon = new Coupon(couponCode); //event 수정 event.publishCoupon(coupon); //coupon 저장 couponRepository.save(coupon); return couponCode; }
T1 과 T2 는 각각 Event 를 조회하고 Coupon을 생성해서 추가하고
Event 를 업데이트하려고한다.
서로 Event 를 공유 잠금 하고있는 상태에서
서로 Event 를 업데이트 하려 하지만 배타 잠금은 공유잠금 이 있을때 사용할 수 없기에
서로의 트랜젝션이 끝나기만을 기다리는 데드락에 빠지게되었고
DBMS 의 데드락 탐지 기능 로 인해
[pool-2-thread-1] SQL Error: 1213, SQLState: 40001 [pool-2-thread-1] (conn=196) Deadlock found when trying to get lock; try restarting transaction [pool-2-thread-1] HHH000010: On release of batch it still contained JDBC statements [pool-2-thread-1] connection: [ 5] | action: [ rollback] | time: [2 ms] [pool-2-thread-2] connection: [ 4] | action: [ commit] | time: [3 ms]
thread-1 은 rollboack, thread-2 는 commit 를 한 모습이다.
하지만 여기서 궁굼한것은
나는 Shared Lock 하지도 않았는데 왜 걸림? 이다.
이유는 coupon 을 insert 를 하는 과정 때문에
event 가 Shared Lock 이 걸린 것인데
coupon은 FK 로 event의 id 를 가리키고 있기 때문에
FK 에 대한 참조 무결성을 지키기위해 event 에 Shared Lock 가 발생한다.
"그럼 coupon 이랑 event 랑 관계를 끊어버리면 데드락이 발생하지 않는다는건가?"
라고 생각한다면 그렇다. 하지만 아래의 synchronized 와 같은 결과가 나온다그럼 순차적으로 접근해서 coupon을 저장하도록
여러 Thread 가 하나의 공유 자원에 안전하게 접근 가능하게 지원하는
java의 synchronized 를 사용하면 어떻게 될까?
public synchronized String coupon(Long code){ Event event = eventRepository.findFirstByCode(code) .orElseThrow(() -> new IllegalArgumentException("not exist event code")); event.isPublishCoupon(); String couponCode = Random.code(); Coupon coupon = new Coupon(couponCode); event.publishCoupon(coupon); couponRepository.save(coupon); return couponCode; }
[pool-2-thread-1] connection: [ 4] | action: [ commit] | time: [10 ms] [pool-2-thread-2] connection: [11] | action: [ commit] | time: [8 ms]
정상적으로 두 작업 모두 commit 가 되었다
하지만 update 하는 과정에서 순서가 보장되지 않기때문에
Event 의 count 를 감소하는 과정에서
public void publishCoupon(Coupon coupon){ this.isPublishCoupon(); this.count = this.count - 1; // <- 여기 this.coupons.add(coupon); coupon.publish(this); }
데이터 적합성이 깨지게된다.
[pool-2-thread-1] connection: [ 4] | action: [statement] | time: [25 ms] select * from tb_event event0_ where event0_.code=1001 limit 1 [pool-2-thread-1] connection: [ 4] | action: [statement] | time: [5 ms] insert into tb_coupon(code, event_id)values('JfYniOAHUwRLaqfUJ7F3', 1) [pool-2-thread-4] connection: [11] | action: [statement] | time: [9 ms] select * from tb_event event0_ where event0_.code=1001 limit 1 [pool-2-thread-1] connection: [ 4] | action: [statement] | time: [3 ms] update tb_event set code=1001,count=9,name='시작 이벤트' where id=1 [pool-2-thread-1] connection: [ 4] | action: [ commit] | time: [10 ms] [pool-2-thread-4] connection: [11] | action: [statement] | time: [8 ms] insert into tb_coupon(code, event_id)values('tRbT4i48gVIUkCnxk20W', 1) [pool-2-thread-4] connection: [11] | action: [statement] | time: [7 ms] update tb_event set code=1001,count=9,name='시작 이벤트' where id=1 [pool-2-thread-4] connection: [11] | action: [ commit] | time: [8 ms]
10개의 쿠폰을 10개의 스레드가 요청하면서 발생한 로그의 일부이다.
thread-1 번이 event 를 조회하고 coupon 을 생성하고 coupon을 저장할때
thread-4 번이 event 를 조회.
thread-1 번이 event 의 count 를 -1 해서 9로 변경
thread-4 번이 coupon 을 생성하고 coupon을 저장하고 event 의 count 를 -1 해서 9로 변경
결국 올바른 값은 count 8 이지만 결과는 count 9 가 된다.
다른 방법으로 위의 문제를 해결해야한다
이러한 문제를 두번의 갱신 분실 문제라 한다.
두번의 갱신 분실 문제
기존의 게시글 제목으로 "hello" 라는 값이 있을때
"유저 1" 해당 게시글 정보를 불러온다
"유저 2" 해당 게실글 정보를 불러온다
"유저 1" 해당 게시글의 제목을 "world" 로 변경한다
"유저2" 해당 게시글의 제목을 "hello" 로 변경한다.
위와같은 동작을 하게되었을때 "유저2"의 트랜잭션만 데이터베이스에 반영되어
게시글의 제목은 여전히 "hello" 가 된다.
위와같은 상황에서의 처리방법은 아래와같다
- 마지막 커밋만 적용하기(위에서 동작한 방식)
- 최초 커밋만 인정하기(위에서 동작한 결과의 반대로 "유저1"을 적용한다)
- 내용 병합(두 변경사항을 병합하여 적용한다)
위의 방법중 "마지막 커밋만 적용하기" 가 아닌 다른 정책을 사용하려해도
트랜잭션의 격리 수준으로는 구현할수없다.
그렇기에 다른 정책은 직접 제공해야한다
비관적 락, 낙관적 락
Optimistic Lock
낙관적 잠금은 잠금 보다는 충돌 방지에 가까우며 충돌이 발생하지 않을것 이라고 보고 잠금을 거는 방법이다.
동시에 여러 요청이 발생할 가능성이 낮을 때 사용하며
만약 동시 수정이 일어난 경우에는 예외를 발생시켜버리는 방식이다.
그래서 잠금보다는 충돌감지에 가깝다.
이를 통해 격리수준 REPEATABLE READ 와 같이 dirty read, non-repeatable read를 방지한다.
JPA 에서는 Entity class 에 @Version 어노테이션을 사용해서 변수를 생성해주면 구현이 가능하다.
public class Event { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Version private Long version; }
이렇게 낙관적 잠금을 적용한 Entity 는
Select 시에 버전 값을 보유하고 업데이트 하기전에 버전 값을 다시 확인하며 동작한다.
만약 이때 버전 정보가 다르다면 OptimisticLockException 를 발생시키며
버전 정보가 같다면 정상 처리되며 버전 정보를 증가 시킨다.
예외 발생시 충돌된 Entity 를 제공하고있기에 편하게 처리가 가능하다.
public class OptimisticLockException extends PersistenceException { Object entity; }
Optimistic Lock Mode 로는
- OPTIMISTIC
- 낙관적 잠금이 수정시에 동작하는게 아닌 읽기 시에도 동작하도록 한다.
- OPTIMISTIC_FORCE_INCREMENT
- 낙관적 잠금을 사용하면서 @Version 의 버전 정보를 올리는 옵션
Pessimistic Lock
비관적 락 은 트랜잭션이 시작될때 Shared Lock 혹은 Exclusive Lock 를 거는 방식이다
즉 Shared Lock 를 걸게되면 다른 트랜잭션에서 write 를 하려해도 Shared Lock 를 통해 업데이트 할 수 없게 한다
동일한 데이터를 동시에 수정할 가능성이 높을때 사용한다.
이를 통해 격리수준 SERIALIZABLE 와 같이 dirty read, non-repeatable read, phantom read 를 방지한다.
Pessimistic Lock Model 로는
- PESSIMISTIC_READ
- Read시 Shared Lock 를 걸어 다른 트랜잭션에서 update, delete 하는 것을 방지
- PESSIMISTIC_WRITE
- Read 시 Exclusive Lock 를 걸어 다른 트랜잭션에서 read, update, delete 하는 것을 방지
- PESSIMISTIC_FORCE_INCREMENT
- PESSIMISTIC_WRITE 와 같이 동작하며 @Version 의 버전 정보를 올리는 옵션
그럼 이제 데드락 문제를 해결하기위해 비관락 사용한다
@Repository public interface EventRepository extends JpaRepository<Event,Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) Optional<Event> findFirstByCode(Long code); }
이제 다시
JMeter 로 돌려보면
- Number of Threads : 100
- Pamp-up period : 1
- Loop Count : 100
select event0_.id as id1_2_, event0_.code as code2_2_, event0_.count as count3_2_, event0_.name as name4_2_ from tb_event event0_ where event0_.code=1001 limit 1 for update
for update 로 잠금이 걸리면서 동작하는것을 볼수 있다.
여기서 사용한 코드는 아래에 있다
https://github.com/birariro/first_come_first_served
참고
https://techblog.woowahan.com/2663/
https://joont92.github.io/db/트랜잭션-격리-수준-isolation-level/
출처
https://twitter.com/painter_of_100/status/1389145281590038531?s=46&t=-TB14gqMYTa5OYIpX0lP9Q
'Spring-Boot' 카테고리의 다른 글
JpaRepository 가 아닌 Repository를 상속 받아 CQRS 로 분리하기 (0) 2023.11.06 API Controller 주변 청소하기 (0) 2023.07.25 Spring @Bean 과 @Component (1) 2023.04.13 Spring Boot 및 DB 모니터링 시스템 구축 (0) 2023.03.13 @RequestBody 동작 이해하기 (0) 2022.09.08