O0oO0Oo / netty-reservation-service

트랜잭션, 동시성을 공부하기 위한 토이 프로젝트입니다.
0 stars 0 forks source link

feat: concurrent transactions - using same transaction -> monolithic saga pattern #10

Closed O0oO0Oo closed 1 month ago

O0oO0Oo commented 3 months ago

이슈 개요

특정 엔티티에 트래픽이 몰린다면, 동일한 엔티티에 대한 수정은 동일한 트랜잭션에 참여하도록 한다.

이전 테스트에서 1000명의 유저가 1개의 엔티티에 대해 수정 요청을 했을때 대부분 다음과 같은 데드락이 걸렸다.

2024-06-10T09:29:57.591+09:00  WARN 223492 --- [reservation] [tLoopGroup-1-11] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1213, SQLState: 40001
...
2024-06-10T09:29:57.591+09:00 ERROR 223492 --- [reservation] [ntLoopGroup-1-8] o.h.engine.jdbc.spi.SqlExceptionHelper   : Deadlock found when trying to get lock; try restarting transaction
...
2024-06-10T09:29:58.501+09:00 ERROR 223492 --- [reservation] [tLoopGroup-1-14] c.s.r.n.http.handler.ExceptionHandler    : Exception, could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [update reservable_item set business_id=?,is_available=?,last_modified_time=?,max_quantity_per_user=?,name=?,price=?,quantity=?,reservable_time=? where item_id=?]; SQL [update reservable_item set business_id=?,is_available=?,last_modified_time=?,max_quantity_per_user=?,name=?,price=?,quantity=?,reservable_time=? where item_id=?]

이러한 문제를 해결하기 위해서는 낙관/ 비관 락등의 방법이 있지만 스프링의 트랜잭션을 이해해보고자 다음과 같은 내용으로 해결해보고자 합니다.

2024-06-10T15:35:48.979+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [com.server.reservation.reservableitem.service.ReservableItemService.registerBusinessReservableItem]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2024-06-10T15:35:48.980+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Opened new EntityManager [SessionImpl(1728798306<open>)] for JPA transaction
2024-06-10T15:35:48.989+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@5594684a]
2024-06-10T15:35:48.990+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(1728798306<open>)] for JPA transaction
2024-06-10T15:35:48.990+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction
2024-06-10T15:35:48.992+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(1728798306<open>)] for JPA transaction
2024-06-10T15:35:48.992+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction
Hibernate: select b1_0.business_id,b1_0.business_type,b1_0.name from business b1_0 where b1_0.business_id=?
2024-06-10T15:35:49.057+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(1728798306<open>)] for JPA transaction
2024-06-10T15:35:49.057+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction
Hibernate: insert into reservable_item (business_id,created_time,is_available,last_modified_time,max_quantity_per_user,name,price,quantity,reservable_time) values (?,?,?,?,?,?,?,?,?)
2024-06-10T15:35:49.113+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
2024-06-10T15:35:49.114+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(1728798306<open>)]
2024-06-10T15:35:49.133+09:00 DEBUG 215160 --- [reservation] [ntLoopGroup-1-0] o.s.orm.jpa.JpaTransactionManager        : Closing JPA EntityManager [SessionImpl(1728798306<open>)] after transaction

이번에 개발을 하면서 설정을 통해 트랜잭션 관련 로그를 보게 되었다, 위의 로그를 보면 트랜잭션이 생기고 나서 특정한 SessionImpl(1728798306) 같은 값이 생기고, 이 값으로 존재하는 트랜잭션 참여한다.

각 트랜잭션 간 영속성 컨텍스트를 공유하게 되므로, 같은 엔티티임을 식별할 수 있는 Primary Key n번을 수정하는 요청은 모두 같은 트랜잭션 안에서 실행되도록 해보자.

그렇다면 동일한 엔티티를 수정하기 위해 트랜잭션들끼리 데드락이 생기는 경우가 없어진다.

문제 발생

  1. 엔티티마다 길게 트랜잭션을 가지고 갈 경우 커넥션 풀이 모두 소진된다.
  2. 처음 계획했던 것을 구현하기 위해서는 Jpa 의 SessionImpl 클래스를 수정하여 사용해야한다. 안정적인 기존 JPA 기능을 사용하는 방법을 찾는것이 좋다.
  3. 2차 캐시와 Saga 패턴을 참고해서 각 엔티티별로 트랜잭션 경계를 더 작게 나눠보자 ApplicationEventPublisher 을 사용해서 해보려고 했지만, 장애가 발생했을때 복구에 필요한 로그와 같은 저장소가 필요하다. 따라서 아래 초안을 바탕으로 카프카를 도입해보려고한다. image

재현 단계

수정 전

  1. sessionImpl 값이나 트랜잭션을 식별 하고 참여하게하는 값 찾기
  2. pk 와 같은 레코드를 식별 가능한 값을 기준으로 요청 시 고유한 해시값을 만들기
  3. 기존 트랜잭션 참여하게 하거나 새로 만드는 기능
  4. 요청을 기다려야하는 특정 시간이 지나거나 물건이 다 팔린 특정 조건을 달성하면 flush 하도록 만들기

수정 후

예상 동작

수정 전

  1. 동일한 엔티티에 대해 수정 요청이 동시에 많이 들어온다.
  2. 동일한 엔티티에 대해 작업중인 트랜잭션이 있다면, 해당 트랜잭션에 참여한다.
  3. 동일한 엔티티에 대해 n초 동안 요청이 없거나, 엔티티의 특정 조건(재고가 없음)이 달성되면 flush 한다.

수정 후

  1. 동일한 엔티티에 대해 수정 요청이 동시에 많이 들어온다.
  2. 카프카로 이벤트를 발행한다.
  3. 동일한 id 는 동일한 스레드에서 처리되기 때문에, 데이터 이상이 발생하지 않는다.
  4. 오케스트레이션 사가 단계가 모두 끝나면 완료 응답을 리턴한다.

실제 동작

수정 후 동작과 같음

개발하면서 얻은 정보

디버깅을 통한 트랜잭션, 참여 트랜잭션 내부 상태

```java transactionStatus = {DefaultTransactionStatus@13061} transactionName = "com.server.reservation.business.service.BusinessService.findBusiness" transaction = {JpaTransactionManager$JpaTransactionObject@13069} entityManagerHolder = {EntityManagerHolder@13070} entityManager = {SessionImpl@13073} "SessionImpl(1224076122)" transactionActive = true savepointManager = null synchronizedWithTransaction = true rollbackOnly = false deadline = null referenceCount = 1 isVoid = false newEntityManagerHolder = false transactionData = null this$0 = {JpaTransactionManager@13071} connectionHolder = {ConnectionHolder@13072} previousIsolationLevel = null readOnly = false savepointAllowed = true newTransaction = false newSynchronization = false nested = false readOnly = true debug = true suspendedResources = null rollbackOnly = false completed = false savepoint = null hash = 1787736364 // 아래의 트랜잭션에 참여, newTransaction 이 false 로 되어있음 transactionStatus = {DefaultTransactionStatus@13083} transactionName = "com.server.reservation.reservationrecord.service.ReservationRecordService.registerReservationRecord" transaction = {JpaTransactionManager$JpaTransactionObject@17133} entityManagerHolder = {EntityManagerHolder@13070} entityManager = {SessionImpl@13073} "SessionImpl(1224076122)" transactionActive = true savepointManager = null synchronizedWithTransaction = true rollbackOnly = false deadline = null referenceCount = 2 isVoid = false newEntityManagerHolder = true transactionData = {HibernateJpaDialect$SessionTransactionData@17134} this$0 = {JpaTransactionManager@13071} connectionHolder = {ConnectionHolder@13072} previousIsolationLevel = null readOnly = false savepointAllowed = true newTransaction = true newSynchronization = true nested = false readOnly = false debug = true suspendedResources = null rollbackOnly = false completed = false savepoint = null hash = 1036729202 // 새로운 트랜잭션 entityManager = {SessionImpl@17121} "SessionImpl(1241292490)" ... persistenceContext = {StatefulPersistenceContext@17125} "PersistenceContext[entityKeys=[EntityKey[com.server.reservation.user.entity.User#8], EntityKey[com.server.reservation.business.entity.Business#2]], collectionKeys=[CollectionKey[com.server.reservation.user.entity.User.reservationRecords#8], CollectionKey[com.server.reservation.business.entity.Business.reservationRecords#2], CollectionKey[com.server.reservation.business.entity.Business.reservableItems#2]]]" session = {SessionImpl@17121} "SessionImpl(1241292490)" // persistence context 의 세션 ```