기존 서비스에 api 중에서 getOOO 수행 시, OOO가 없는 경우에는 insert를 하는 케이스가 존재하고 있다.
이 API의 로직을 확인해 보면 Mybatis를 이용해서 insert문에 아래와 같이 중복 처리를 해주었다.
INSERT INTO TABLE_NAME VALUES ()
ON DUPLICATE KEY UPDATE
해당 부분을 포함하는 API를 JPA로 전환하는 과정에서 위의 SQL은 사용할 수 없다는 것을 알게 되었고
우선 해당 부분은 고려하지 않고 전환을 하고 테스트를 진행해 보았다.
특정한 이유로 client 측에서 해당 api를 동시에 호출하게 되면
중복 insert가 발생하게 되었다.
ConstraintViolationException
o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1062, SQLState: 23000
org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [test.PRIMARY]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement
Caused by: java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'ed9' for key 'test.PRIMARY' at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:117)
중복으로 insert 되는 부분을 방지하기 위해서는 Lock이 필요하다고 생각이 들었고
Lock 관련 조사를 해보니, 낙관적 락, 비관적 락 이렇게 존재한다. (자세한 내역은 다른 포스팅에서 다룬다)
이 중에서 애플리케이션 로직에서는 특별히 무언가를 추가해주고 싶지 않아서
DB 레이어에서 락을 제공해 주는 비관적락을 사용하겠다고 생각을 하였다.
비관적락을 사용하여 select를 하는 순간에 select for update를 수행하여 Exclusive lock (X)을 획득하면
문제없이 api가 성공할 것이라고 생각을 하였지만.. deadLock이 발생하였다.
CannotAcquireLockException
o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1213, SQLState: 40001
org.springframework.dao.CannotAcquireLockException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.exception.LockAcquisitionException: could not execute statement
Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
deadLock이 발생하는 이유를 찾아보았을 때는 제 기준 너무 복잡하고 이해하기 어려웠다.
역시 인생은 생각한 데로 흘러가지 않는다.. ㅎㅎ
해당 내역에 대해서 알기 위해서는 mySQL에서 제공하는 Lock의 종류를 알고 있어야 한다. (자세한 내역은 다른 포스팅에서 다룬다)
이번 문제를 해결하기 위해 이해한 내역을 간단하게 요약하면
mySql의 default transaction isolation level은 repeatable read이고
mySql의 InnoDB 스토리지 엔진의 경우 select for update를 수행하면 gap lock, insert intention lock이 걸리게 된다.
중복으로 insert가 요청된다면 각 트랜젝션이 insert intention lock(IX)을 잡고 있어서
gap lock(X)을 얻기 위해서 각자가 lock을 풀 때까지 대기하여 deadLock이 발생하게 된다.
|
X |
IX |
S |
IS |
X |
Conflict |
Conflict |
Conflict |
Conflict |
IX |
Conflict |
Compatible |
Conflict |
Compatible |
S |
Conflict |
Conflict |
Compatible |
Compatible |
IS |
Conflict |
Compatible |
Complatible |
Compatible |
해결 방안
원인은 이해가 되었고, 이를 해결하기 위한 방안으로는
- 해당 로직을 수행하는 transaction의 isolation level을 read committed로 변경
- read committed의 경우 gap lock이 잡히지 않는다
- select for update를 해도.. lock이 잡히지 않아서 duplicate entry exception 발생..
- transaction 잡히는 구간을 최대한 짧게 유지 및 deadLock 발생 시 retry
- 기술적인 해결 방법은 아니지만.. 해결책이 될 수 있을 거 같다..
@Retryable(include = {CannotAcquireLockException.class, DataIntegrityViolationException.class})