πŸš€ μš”μ•½

SUMMARY

InnoDB μ—”μ§„μ—μ„œ λΉ„μΈλ±μŠ€ μ»¬λŸΌμ„ WHERE 쑰건으둜 PESSIMISTIC_WRITEλ₯Ό μ‚¬μš©ν•˜λ©΄ λ ˆμ½”λ“œ 락(Record Lock)뿐만 μ•„λ‹ˆλΌ κ°­ 락(Gap Lock)이 ν•¨κ»˜ λ™μž‘ν•  수 있으며, μ΄λŠ” μ˜λ„μΉ˜ μ•Šμ€ λ°λ“œλ½μ„ μœ λ°œν•˜λŠ” 원인이 될 수 μžˆλ‹€.

  • κ³΅μ‹λ¬Έμ„œλŠ” REPEATABLE READ 격리 μˆ˜μ€€ μ΄μƒμ—μ„œ Gap Lock 이 λ°œμƒν•˜λŠ” 경우λ₯Ό μ„€λͺ…ν•˜μ§€λ§Œ READ_COMMITTED 와 READ_UNCOMMITTED 격리 μˆ˜μ€€μ—μ„œλ„ Gap Lock 이 λ°œμƒν–ˆλ‹€.
  • λΉ„κ΅μœ (Non-Unique) 인덱슀λ₯Ό WHERE 쑰건으둜 μ‚¬μš©ν•΄λ„ λ™μΌν•˜κ²Œ λ°λ“œλ½μ΄ λ°œμƒν•  것이라 μƒκ°ν–ˆμ§€λ§Œ μ˜μ™Έλ‘œ λ°λ“œλ½μ΄ λ°œμƒν•˜μ§€ μ•Šμ•˜λ‹€.
  • λ°λ“œλ½μ„ ν”Όν•˜κΈ° μœ„ν•΄μ„œλŠ” 상황에 따라 μ•„λž˜ 방법 등을 κ³ λ―Όν•΄λ³Ό 수 μžˆλ‹€.
    • λΉ„μœ λ‹ˆν¬ 인덱슀 쑰건을 WHERE PK IN (A, B)와 같이 κΈ°λ³Έ ν‚€(PK)λ₯Ό μ΄μš©ν•œ 쑰건으둜 λ³€κ²½
    • 비관적 락이 μ•„λ‹Œ 낙관적 락으둜 둜직 λ³€κ²½
    • νŠΈλžœμ μ…˜ 였λ₯˜ μ‹œ μž¬μ‹œλ„ 둜직 μΆ”κ°€

βš™οΈ ν™˜κ²½

  • MariaDB 10.8.3 (InnoDB)
  • Spring Boot 2.5.1
  • JDK 1.8

πŸ’¬ 이슈

사내 μ†”λ£¨μ…˜μ—μ„œ 비관적 락(PESSIMISTIC_WRITE)을 μ‚¬μš©ν•˜λŠ” λ‘œμ§μ— νŠΈλžœμž­μ…˜ κ°„ 경합이 λ°œμƒν•˜λ©° κ°„ν—μ μœΌλ‘œ λ°λ“œλ½μ΄ λ°œμƒν•˜λŠ” 문제λ₯Ό κ²ͺμ—ˆλ‹€. 문제 상황을 μž¬ν˜„ν•˜κΈ° μœ„ν•΄ μ•„λž˜μ™€ 같이 μ½”λ“œλ₯Ό κ΅¬μ„±ν•΄λ³΄μ•˜λ‹€.

μ—”ν‹°ν‹°

@Entity  
@Table(name = "target_table", catalog = "test")  
@Data  
public class TargetTable {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.AUTO)  
    @Column(name = "id")  
    private Integer id;  
  
    @Column(name = "col1")  
    private Integer col1;  
  
}

였λ₯˜ λ°œμƒ 지점

private List<TargetTable> findEntityListWithLock(int col1) {  
    return rdbService.getQueryFactory()  
            .selectFrom(QTargetTable.targetTable)  
            .where(QTargetTable.targetTable.col1.eq(col1))  
            .setLockMode(LockModeType.PESSIMISTIC_WRITE)  
            .fetch();  
}

였λ₯˜ λ©”μ‹œμ§€

Caused by: javax.persistence.OptimisticLockException: org.hibernate.exception.LockAcquisitionException: could not extract ResultSet

❓ 의문점

일반적인 κ²½ν•© 상황이라면, λ¨Όμ € 락을 νšλ“ν•œ νŠΈλžœμž­μ…˜μ΄ 끝날 λ•ŒκΉŒμ§€ innodb_lock_wait_timeout 섀정값인 50초 λ™μ•ˆ λŒ€κΈ°ν•œ ν›„ νƒ€μž„μ•„μ›ƒ 였λ₯˜κ°€ λ°œμƒν•΄μ•Ό ν•œλ‹€κ³  μ˜ˆμƒν–ˆλ‹€. ν•˜μ§€λ§Œ μ‹€μ œλ‘œλŠ” 락 νšλ“ μ‹œλ„ ν›„ 1μ΄ˆλ„ μ•ˆ λ˜μ–΄ λ°λ“œλ½ μ˜ˆμ™Έκ°€ λ°œμƒν–ˆλ‹€.

κ²Œλ‹€κ°€ μ˜ˆμ™Έ μ’…λ₯˜λ„ 락 νšλ“ μ‹€νŒ¨ μ‹œ μ˜ˆμƒν–ˆλ˜ PessimisticLockException이 μ•„λ‹ˆλΌ OptimisticLockExceptionμ΄μ–΄μ„œ μ˜μ•„ν–ˆλ‹€.

μ΄λŸ¬ν•œ 점듀 λ•Œλ¬Έμ— λ‹¨μˆœν•œ κ²½ν•© λ¬Έμ œκ°€ μ•„λ‹Œ, 둜직 μƒμ—μ„œ μ˜λ„μΉ˜ μ•Šμ€ λ‹€λ₯Έ 원인이 μžˆμ„ 것이라 νŒλ‹¨ν–ˆκ³ , 더 깊이 νŒŒκ³ λ“€μ–΄ 보기둜 ν–ˆλ‹€.

πŸ§— ν•΄κ²°

MariaDB 둜그 확인

μ„œλΉ„μŠ€ 였λ₯˜ λ‘œκ·Έλ§ŒμœΌλ‘œλŠ” μ •ν™•ν•œ 원인 νŒŒμ•…μ΄ μ–΄λ €μ›Œ, MariaDB의 λ°λ“œλ½ 둜그λ₯Ό 직접 ν™•μΈν•΄λ³΄κΈ°λ‘œ ν–ˆλ‹€. (확인 방법은 MariaDB λ°λ“œλ½ 둜그 확인 μ°Έκ³ )

2024-09-27 18:01:00 0x7f86936af700  
*** (1) TRANSACTION:  
TRANSACTION 28480029, ACTIVE 0 sec starting index read  
mysql tables in use 1, locked 1  
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)  
MariaDB thread id 901455, OS thread handle 140215975606016, query id 53815334 172.19.0.1 root Sending data  
select targettabl0_.id as id1_184_, targettabl0_.col1 as col2_184_ from test.target_table targettabl0_ where targettabl0_.col1=50 for update  
*** WAITING FOR THIS LOCK TO BE GRANTED:  
RECORD LOCKS space id 218104 page no 3 n bits 8 index PRIMARY of table `test`.`target_table` trx id 28480029 lock_mode X locks rec but not gap waiting  
...
*** (2) TRANSACTION:  
TRANSACTION 28480030, ACTIVE 0 sec fetching rows  
mysql tables in use 1, locked 1  
LOCK WAIT 3 lock struct(s), heap size 1128, 4 row lock(s)  
MariaDB thread id 901452, OS thread handle 140215980521216, query id 53815330 172.19.0.1 root Sending data  
select targettabl0_.id as id1_184_, targettabl0_.col1 as col2_184_ from test.target_table targettabl0_ where targettabl0_.col1=20 for update  
*** WAITING FOR THIS LOCK TO BE GRANTED:  
RECORD LOCKS space id 218104 page no 3 n bits 8 index PRIMARY of table `test`.`target_table` trx id 28480030 lock_mode X locks rec but not gap waiting  
...
*** WE ROLL BACK TRANSACTION (0)

둜그의 핡심은 두 νŠΈλžœμž­μ…˜μ΄ record lock은 νšλ“ν–ˆμ§€λ§Œ gap lock을 기닀리닀 ꡐ착 μƒνƒœμ— 빠쑌고(lock_mode X locks rec but not gap waiting), κ²°κ΅­ InnoDB 엔진이 λ‘˜ 쀑 ν•˜λ‚˜λ₯Ό 희생양(victim)으둜 선택해 λ‘€λ°±ν–ˆλ‹€λŠ” 것이닀.

NOTE

κ°­ 락(Gap Lock)μ΄λž€? κ°­ 락은 인덱슀 λ ˆμ½”λ“œ μ‚¬μ΄μ˜ 간격(Gap)을 μž κ·ΈλŠ” κΈ°λŠ₯이닀. 즉, μ‹€μ œ μ‘΄μž¬ν•˜λŠ” λ ˆμ½”λ“œλΏλ§Œ μ•„λ‹ˆλΌ, 쑰건에 ν•΄λ‹Ήν•˜μ§€λ§Œ μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ ˆμ½”λ“œμ˜ λ²”μœ„κΉŒμ§€ μž κ·Όλ‹€. 이둜 인해 λ‹€λ₯Έ νŠΈλžœμž­μ…˜μ΄ κ·Έ 간격 내에 μƒˆλ‘œμš΄ 데이터λ₯Ό μΆ”κ°€(INSERT)ν•˜λŠ” 것을 λ°©μ§€ν•˜μ—¬ Phantom Read ν˜„μƒμ„ λ§‰λŠ”λ‹€.

μ—¬κΈ°μ„œ μƒˆλ‘œμš΄ 의문이 생겼닀. 두 νŠΈλžœμž­μ…˜μ€ μ„œλ‘œ λ‹€λ₯Έ λ ˆμ½”λ“œλ₯Ό λŒ€μƒμœΌλ‘œ 락을 μ‹œλ„ν–ˆλŠ”λ° μ™œ ꡐ착 μƒνƒœκ°€ λ°œμƒν–ˆμœΌλ©°, μƒμ†Œν•œ Gap Lockμ΄λž€ λŒ€μ²΄ λ¬΄μ—‡μΌκΉŒ? 일반적으둜 FOR UPDATE μΏΌλ¦¬λŠ” λ ˆμ½”λ“œ(Row) λ‹¨μœ„λ‘œ 락을 νšλ“ν•˜λ―€λ‘œ, μ„œλ‘œ λ‹€λ₯Έ λ ˆμ½”λ“œλ₯Ό λŒ€μƒμœΌλ‘œ ν•  λ•ŒλŠ” 경합이 λ°œμƒν•˜μ§€ μ•Šμ•„μ•Ό ν•œλ‹€κ³  μƒκ°ν–ˆλ‹€. ν•˜μ§€λ§Œ μ—¬κΈ°μ—λŠ” ν•œ κ°€μ§€ μ€‘μš”ν•œ 쑰건이 μˆ¨μ–΄μžˆμ—ˆλ‹€.

였λ₯˜ μž¬μ—°

μ •ν™•ν•œ 원인을 νŒŒμ•…ν•˜κΈ° μœ„ν•΄ λ°λ“œλ½μ΄ λ°œμƒν•˜λŠ” 상황을 직접 μž¬ν˜„ν•΄λ³΄μ•˜λ‹€.

ν…Œμ΄λΈ” μ„ΈνŒ…

MariaDB [test]> CREATE TABLE target_table (
    -> id INT NOT NULL,
    -> col1 INT DEFAULT NULL,
    -> PRIMARY KEY (id)
    -> ) ENGINE=InnoDB;
 
MariaDB [test]> INSERT INTO target_table VALUES (1, 10), (2, 20), (3, 30), (4, 20), (5, 50), (6, 10), (7, 20), (8, 30), (9, 40), (10, 50);
 
MariaDB [test]> select * from target_table;
+----+------+
| id | col1 |
+----+------+
|  1 |   10 |
|  6 |   10 |
|  2 |   20 |
|  7 |   20 |
|  3 |   30 |
|  8 |   30 |
|  4 |   40 |
|  9 |   40 |
|  5 |   50 |
| 10 |   50 |
+----+------+

ν…ŒμŠ€νŠΈ μ½”λ“œ

@Slf4j  
@RequiredArgsConstructor  
@DisplayName("λ°λ“œλ½ ν…ŒμŠ€νŠΈ")  
@Transactional  
public class DeadLockTest extends IntegrationTest {  
  
    private final DeadLockTestService deadLockTestService;  
    final int NUM_THREADS = 5;  
  
    @Test  
    @DisplayName("μ„œλ‘œ λ‹€λ₯Έ μŠ€λ ˆλ“œ(νŠΈλžœμ μ…˜)κ°€ λ™μ‹œμ— 락 νšλ“ μ‹œλ„")  
    void test() {  
        ExecutorService executorService = new ThreadPoolExecutor(NUM_THREADS, NUM_THREADS, 0L, TimeUnit.MILLISECONDS,  
                new LinkedBlockingQueue<>());  
  
        List<Runnable> runnables = new ArrayList<>();  
  
        for (int i = 0; i < NUM_THREADS; i++) {  
            final int threadId = i + 1; // μŠ€λ ˆλ“œ 번호  
            Runnable runnable = () -> {  
                deadLockTestService.process(threadId);  
            };  
            runnables.add(runnable);  
        }  
        // μŠ€λ ˆλ“œ μž‘μ—… μ‹€ν–‰  
        runnables.forEach(executorService::execute);  
  
        // μŠ€λ ˆλ“œ ν’€ μ’…λ£Œ  
        executorService.shutdown();  
        try {  
            executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);  
        } catch (InterruptedException e) {  
            Thread.currentThread().interrupt();  
        }  
    }  
}

μ„œλΉ„μŠ€ 클래슀

@Service  
@Slf4j  
@RequiredArgsConstructor  
public class DeadLockTestService {  
    private final RdbService rdbService;  
  
    private List<TargetTable> findEntityListWithLock(int col1) {  
        return rdbService.getQueryFactory()  
                .selectFrom(QTargetTable.targetTable)  
                .where(QTargetTable.targetTable.col1.eq(col1))  
                .setLockMode(LockModeType.PESSIMISTIC_WRITE)
                .fetch();  
    }  
  
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void process(int threadId) {  
        // ν˜„μž¬ μ‹œκ°„  
        LocalTime now = LocalTime.now();  
        // λ‹€μŒ λΆ„μ˜ 00초 000λ°€λ¦¬μ΄ˆκΉŒμ§€ 남은 μ‹œκ°„ 계산  
        LocalTime nextMinute = now.plusMinutes(1).truncatedTo(ChronoUnit.MINUTES);  
        long millisUntilNextMinute = ChronoUnit.MILLIS.between(now, nextMinute);   
        try {  
            // λ‹€μŒ 00초 000λ°€λ¦¬μ΄ˆκΉŒμ§€ λŒ€κΈ°  
            Thread.sleep(millisUntilNextMinute);  
  
            log.info("비관적 락 μ‹œλ„ - 쑰건 : col1={}", threadId * 10);  
            List<TargetTable> list = findEntityListWithLock(threadId * 10);  
            log.info("비관적 락 νšλ“ μ™„λ£Œ - {}", list.size());  
            list = findEntityListWithLock(threadId * 10);  
  
            Thread.sleep(10000);  
        } catch (InterruptedException e) {  
            throw new RuntimeException(e);  
        } catch (OptimisticLockException e) {  
            e.printStackTrace();  
            log.info("μž‘μ•˜λ‹€!");  
        } catch (PessimisticLockException e) {  
            e.printStackTrace();  
            log.info("정상적인 경우");  
        }    }  
}

ν…ŒμŠ€νŠΈ μ§„ν–‰

μœ„ μ½”λ“œλ₯Ό 베이슀둜 λ‹€μŒκ³Ό 같은 μΌ€μ΄μŠ€λ‘œ ν…ŒμŠ€νŠΈλ₯Ό μ§„ν–‰ν–ˆκ³ , μ•„λž˜ κ²°κ³Όλ₯Ό μ–»μ—ˆλ‹€.

μΌ€μ΄μŠ€λ°λ“œλ½ λ°œμƒ μ—¬λΆ€
WHERE 쑰건에 PK λ₯Ό μ‚¬μš©ν•˜λŠ” κ²½μš°λ°œμƒ μ•ˆν•¨
κ²©λ¦¬μˆ˜μ€€μ„ REPEATABLE_READ λ‚˜ SERIALIZABLE 둜 μ„€μ •ν•˜λŠ” κ²½μš°λ°œμƒ μ•ˆν•¨
ν•œ νŠΈλžœμ μ…˜μ— FOR UPDATE 쿼리λ₯Ό 1λ²ˆμ”©λ§Œ ν˜ΈμΆœν•˜λŠ” κ²½μš°λ°œμƒ μ•ˆν•¨
λΉ„κ³ μœ (Non-unique)인덱슀둜 λ“±λ‘ν•œ μ»¬λŸΌμ„ WHERE 쑰건으둜 μ‚¬μš©ν•˜λŠ” κ²½μš°λ°œμƒ μ•ˆν•¨
setHint("javax.persistence.lock.timeout", 5000) 둜 μ„€μ •ν•˜λŠ” κ²½μš°λ¬΄κ΄€ν•˜κ²Œ λ°œμƒ

IMPORTANT

λΉ„κ³ μœ (Non-unique) 인덱슀λ₯Ό WHERE 쑰건으둜 μ‚¬μš©ν•΄λ„ λ™μΌν•˜κ²Œ λ°λ“œλ½μ΄ λ°œμƒν•  것이라 μƒκ°ν–ˆμ§€λ§Œ μ˜μ™Έλ‘œ λ°λ“œλ½μ΄ λ°œμƒν•˜μ§€ μ•Šμ•˜λ‹€. 이 뢀뢄에 λŒ€ν•΄μ„œλŠ” μΆ”κ°€μ μœΌλ‘œ νŒŒμ•…ν•΄λ³΄μ§€ λͺ»ν–ˆλ‹€.

λ°λ“œλ½ λ°œμƒ 쑰건 뢄석

μ—¬λŸ¬ ν…ŒμŠ€νŠΈλ₯Ό 톡해 λ°λ“œλ½μ΄ λ°œμƒν•˜λŠ” νŠΉμ • 쑰건을 μ’…ν•©ν•΄λ³Ό 수 μžˆμ—ˆλ‹€.

  • μ„œλ‘œ λ‹€λ₯Έ νŠΈλžœμ μ…˜μ΄ 거의 λ™μ‹œμ— 락을 νšλ“ν•˜λ € ν–ˆλ‹€.
  • 격리 λ ˆλ²¨μ€ READ_COMMITTED 와 READ_UNCOMMITTED 일 κ²½μš°μ—λ§Œ λ°œμƒν–ˆλ‹€.
  • μΈλ±μŠ€κ°€ μ•„λ‹Œ μ»¬λŸΌμ„ 쑰건으둜 μ‚¬μš©ν–ˆλ‹€.
  • 1개 νŠΈλžœμ μ…˜μ—μ„œ for update λ₯Ό 두 번 ν˜ΈμΆœν–ˆλ‹€.

μ’…ν•©ν•˜λ©΄ READ_COMMITTED λ˜λŠ” READ_UNCOMMITTED 격리 μˆ˜μ€€μ—μ„œ, μΈλ±μŠ€κ°€ μ—†λŠ” μ»¬λŸΌμ„ WHERE 쑰건으둜 μ‚¬μš©ν•˜μ—¬, ν•˜λ‚˜μ˜ νŠΈλžœμž­μ…˜μ—μ„œ FOR UPDATEλ₯Ό 두 번 이상 ν˜ΈμΆœν•˜λ©°, μ—¬λŸ¬ νŠΈλžœμž­μ…˜μ΄ 거의 λ™μ‹œμ— 락을 νšλ“ν•˜λ € ν•  λ•Œ λ°œμƒν–ˆλ‹€.

IMPORTANT

곡식 λ¬Έμ„œμ—μ„œλŠ” REPEATABLE READ 격리 μˆ˜μ€€ μ΄μƒμ—μ„œ Gap Lock이 λ°œμƒν•œλ‹€κ³  μ„€λͺ…ν•˜μ§€λ§Œ, μ§„ν–‰ν•œ ν…ŒμŠ€νŠΈμ—μ„œλŠ” READ_COMMITTED와 READ_UNCOMMITTED 격리 μˆ˜μ€€μ—μ„œλ„ Gap Lock으둜 μΈν•œ λ°λ“œλ½μ΄ λ°œμƒν–ˆλ‹€. MariaDB λ²„μ „μ˜ μ΄μŠˆλ‚˜ νŠΉμ • 상황에 따라 λ™μž‘μ΄ λ‹€λ₯Ό 수 μžˆλŠ”μ§€μ— λŒ€ν•΄μ„œλŠ” νŒŒμ•…ν•˜μ§€ λͺ»ν–ˆλ‹€.

ν•΄κ²° λ°©μ•ˆ

Gap locking is not needed for statements that lock rows using a unique index to search for a unique row. - MariaDB 곡식 λ¬Έμ„œ

원인이 Gap Lock에 μžˆλ‹€λŠ” 것을 νŒŒμ•…ν•œ ν›„, μ„œλΉ„μŠ€ λ‘œμ§μ„ μˆ˜μ •ν•˜κΈ°λ‘œ ν–ˆλ‹€. λ”°λΌμ„œ κΈ°μ‘΄ FOR UPDATE 쿼리의 WHERE μ ˆμ—μ„œ λΉ„ 인덱슀 컬럼 쑰건을 μ‚¬μš©ν•˜λŠ” λŒ€μ‹ , WHERE id IN (...)κ³Ό 같이 PK 기반으둜 λ ˆμ½”λ“œλ₯Ό μ‘°νšŒν•˜λ„λ‘ λ‘œμ§μ„ λ³€κ²½ν•˜μ—¬ 문제λ₯Ό ν•΄κ²°ν–ˆλ‹€.

μΆ”κ°€μ μœΌλ‘œ, λ°λ“œλ½μ΄ μ•„λ‹ˆλ”λΌλ„ λ°œμƒν•  수 μžˆλŠ” νƒ€μž„μ•„μ›ƒ(PessimisticLockException)에 λŒ€λΉ„ν•˜μ—¬ νŠΈλžœμž­μ…˜ 였λ₯˜ μ‹œ λΉ„μ¦ˆλ‹ˆμŠ€ λ‘œμ§μ„ μž¬μ‹œλ„ν•˜λŠ” λ‘œμ§μ„ λ”ν•œλ‹€λ©΄ λ”μš± μ•ˆμ •μ μΈ μ„œλΉ„μŠ€λ₯Ό λ§Œλ“€ 수 μžˆμ„ 것이라 μƒκ°ν•œλ‹€. μ΄λŠ” νŠΈλžœμž­μ…˜ μ™ΈλΆ€μ—μ„œ μ²˜λ¦¬ν•˜κ±°λ‚˜ λ³„λ„μ˜ μŠ€μΌ€μ€„λ§ 둜직으둜 κ΅¬ν˜„ν•˜λŠ” 것이 μ–΄λ–¨κΉŒ μ‹Άλ‹€.

πŸ”— μ°Έκ³