Home 여러개의 Repository 또는 여러개의 Row 를 동시에 Lock 을 걸고자 할 때
Post
Cancel

여러개의 Repository 또는 여러개의 Row 를 동시에 Lock 을 걸고자 할 때

증상

여기에 상품의 후기를 기록하면 연관된 상품들의 좋아요 횟수가 +1 씩 증가하는 로직이 있습니다.

그리고, 동시성 이슈를 해결하고자, SELECT FOR UPDATE 를 사용하고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
@Repository
public interface StorePackagesRepository extends JpaRepository<StorePackages, Long>, StorePackagesRepositoryCustom {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select storePackages from StorePackages storePackages where id = :storePackageId")
StorePackages.findByIdForUpdate(@Param("storePackageId") Long storePackageId);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select storePackages from StorePackages storePackages where id in :storePackageIdSet")
    List<StorePackages> findByIdSetForUpdate(@Param("storePackageIdSet") Set<Long> storePackageIdSet);
}

// LockModeType.PESSIMISTIC_WRITE -> 배타적 잠금(Exclusive Lock)을 획득하고 데이터를 다른 트랜잭션에서 READ, UPDATE, DELETE 하는것을 방지 할 수 있습니다.

findByIdSetForUpdate 메소드를 호출할 때는 다음과 같은 방법을 사용하고 있습니다.

1
2
3
4
5
6
7
8
9
10
Set<Long> storePackageIdSet = productSet
							.stream().map(m -> m.getStorePackages().getId())
				  		.collect(Collectors.toSet());

List<StorePackages> storePackagesList = storePackagesRepository
																			.findByIdSetForUpdate(storePackageIdSet);

storePackagesList.forEach(
						m -> m.updateCombinationReviewCount(combinationReviews.getStatus())
);

그리고, 간헐적으로 Lock wait timeout exceeded(Lock 을 얻지 못해 타임아웃이 발생) 현상이 발생하고 있습니다.

이러한 현상이 특정 시간대에 잠깐 나타나고 사라지는 것으로 봐서는, 각각의 트랜잭션이 타이밍 문제로, 일부 ROW 에 락을 획득하였지만, 나머지 ROW 에 락을 획득하지 못해서 발생한 현상이라고 추측하고 있습니다.

또한, findByIdForUpdate 메소드와, findByIdSetForUpdate 메소드가 서로 다른 트랜잭션에

동시에 호출된다고 가정을 해보면, 서로 다른 트랜잭션간의 교착상태가 발생할 확률 또한 같이 증가한다는 점 등을

고려하여, 분산락이 필요한 시점이라고 판단하였습니다.

분산락이란?

분산 락은 서버가 여러 대인 상황에서 동일한 데이터(예를 들어 DB)에 대한 동기화를 보장하기 위해 사용되는데 이때 동기화를 목적으로 임계점에 Lock(이하 락)을 거는 것이 분산 락이라 할 수 있습니다.

분산 락을 구현하기 위해선 락을 표시하기 위해 물리적으로 떨어져 있거나 다른 프로세스와 공유할 수 있는 데이터 저장 공간이 필요하며, 동시에 락을 거는 경우를 막기 위해서 Atomic 한 연산이 필요합니다.

해결책

서로 다른 트랜잭션에서 리뷰 등록시 분산락 적용으로 교착상태를 예방함.

이 때, 분산락은 exclusive_lock 테이블에 특정 ROW 를 잠금하는 형태로 구현함.

예시 : Book 의 좋아여 횟수를 늘리는 과정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Slf4j
@Component
public class ExclusiveLockProperties {
    public static final String BOOK_LOCK_NAME = "BOOK";
}

@Transactional
public void onInit() {
		exclusiveLockRepository.save(ExclusiveLock.ofCreate(ExclusiveLockProperties.BOOK_LOCK_NAME));
}

@Transactional
public void increaseLikeCount(Long id) {
    // 명시적으로 BOOK 에 Exclusive Lock 을 시도하고, 이후 과정을 진행함.
    exclusiveLockRepository.findByCode(ExclusiveLockProperties.BOOK_LOCK_NAME);
    Book book = bookRepository.findById(id).orElseThrow(IllegalArgumentException::new);
    book.increaseLikeCount();
}

상세 구현과정

ExclusiveLock.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.app.model;

import lombok.Getter;
import javax.persistence.*;

@Entity
@Getter
@Table(indexes = @Index(name = "code_idx", columnList = "code"))
public class ExclusiveLock {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    Long id;

    @Column(unique = true, nullable = false)
    String code;

    public static ExclusiveLock ofCreate(String code) {
        ExclusiveLock instance = new ExclusiveLock();
        instance.code = code;
        return instance;
    }
}
/*
CREATE TABLE `exclusive_lock` (
    `id` bigint NOT NULL AUTO_INCREMENT,
    `code` varchar(255) NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `UK_aiv42q2m35mibh2t10d9t6kfo` (`code`),
    KEY `code_idx` (`code`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

> SHOW INDEXES FROM exclusive_lock;
--------------+----------+----------------------------+------------+-----------+---------+-----------+--------+------+----+----------+-------+-------------+-------+----------+
Table         |Non_unique|Key_name                    |Seq_in_index|Column_name|Collation|Cardinality|Sub_part|Packed|Null|Index_type|Comment|Index_comment|Visible|Expression|
--------------+----------+----------------------------+------------+-----------+---------+-----------+--------+------+----+----------+-------+-------------+-------+----------+
exclusive_lock|         0|PRIMARY                     |           1|id         |A        |          1|        |      |    |BTREE     |       |             |YES    |          |
exclusive_lock|         0|UK_aiv42q2m35mibh2t10d9t6kfo|           1|code       |A        |          1|        |      |    |BTREE     |       |             |YES    |          |
exclusive_lock|         1|code_idx                    |           1|code       |A        |          1|        |      |    |BTREE     |       |             |YES    |          |
*/

ExclusiveLockRepositoryCustom.java

1
2
3
4
5
package com.example.app.repository;

public interface ExclusiveLockRepositoryCustom {

}

ExclusiveLockRepositoryCustomImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.app.repository.impl;

import com.example.app.model.ExclusiveLock;
import com.example.app.repository.ExclusiveLockRepositoryCustom;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;

import javax.persistence.EntityManager;

public class ExclusiveLockRepositoryCustomImpl extends QuerydslRepositorySupport implements ExclusiveLockRepositoryCustom {

    public ExclusiveLockRepositoryCustomImpl(JPAQueryFactory jpaQueryFactory, EntityManager entityManager) {
        super(ExclusiveLock.class);
        this.jpaQueryFactory = jpaQueryFactory;
        this.entityManager = entityManager;
    }

    private final JPAQueryFactory jpaQueryFactory;
    private final EntityManager entityManager;
}

ExclusiveLockRepository.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.app.repository;

import com.example.app.model.ExclusiveLock;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.QueryHints;
import org.springframework.stereotype.Repository;

import javax.persistence.LockModeType;
import javax.persistence.QueryHint;
import java.util.Optional;

@Repository
public interface ExclusiveLockRepository extends JpaRepository<ExclusiveLock, Long>, ExclusiveLockRepositoryCustom {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "300000")})
    @Override
    Optional<ExclusiveLock> findById(Long id);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "300000")})
    Optional<ExclusiveLock> findByCode(String code);
}

ExclusiveLockProperties.java

1
2
3
4
5
6
7
8
9
10
package com.example.app.properties;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class ExclusiveLockProperties {
    public static final String BOOK_LOCK_NAME = "BOOK";
}

사용 예제

Book.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.example.app.model;

import lombok.Getter;
import lombok.ToString;

import javax.persistence.*;

@Table
@Entity
@Getter
@ToString
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    Long id;

    @Column
    String code;

    @Column
    String name;

    @Column
    Long likeCount;

    public void increaseLikeCount() {
        this.likeCount++;
    }

    public void decreaseLikeCount() {
        if (this.likeCount > 1L) {
            this.likeCount--;
        }
    }

    public static Book ofCreate(String code, String name, Long likeCount) {
        Book instance = new Book();
        instance.code = code;
        instance.name = name;
        instance.likeCount = likeCount;
        return instance;
    }
}

BookRepository.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.app.repository;

import com.example.app.model.Book;
import com.example.app.model.Box;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;

@Repository
public interface BookRepository extends JpaRepository<Book, Long>, BoxRepositoryCustom {
    List<Book> findByIdIn(Iterable<Long> ids);
}

BookRepositoryCustom.java

1
2
3
4
package com.example.app.repository;

public interface BookRepositoryCustom {
}

BookRepositoryCustomImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.app.repository.impl;

import com.example.app.model.Book;
import com.example.app.repository.BookRepositoryCustom;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;

import javax.persistence.EntityManager;

public class BookRepositoryCustomImpl extends QuerydslRepositorySupport implements BookRepositoryCustom {

    public BookRepositoryCustomImpl(JPAQueryFactory jpaQueryFactory, EntityManager entityManager) {
        super(Book.class);
        this.jpaQueryFactory = jpaQueryFactory;
        this.entityManager = entityManager;
    }

    private final JPAQueryFactory jpaQueryFactory;
    private final EntityManager entityManager;
}

BookService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.app.service;

import com.example.app.model.Book;
import com.example.app.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;
import java.util.List;

@Service
@RequiredArgsConstructor
public class BookService {
    private final BookRepository bookRepository;
    private final EntityManager  entityManager;

    @Transactional
    public Book save(Book book) {
        return bookRepository.save(book);
    }

    @Transactional
    public List<Book> saveAll(List<Book> books) {
        return bookRepository.saveAll(books);
    }

    @Transactional
    public Book findById(Long id) {
        return bookRepository.findById(id).get();
    }

    @Transactional
    public List<Book> findByIdIn(Iterable<Long> ids) {
        return bookRepository.findByIdIn(ids);
    }
}

BookLikeCountService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.example.app.service;

import com.example.app.model.Book;
import com.example.app.properties.ExclusiveLockProperties;
import com.example.app.repository.BookRepository;
import com.example.app.repository.ExclusiveLockRepository;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.concurrent.atomic.AtomicInteger;

@Service
@Getter
@RequiredArgsConstructor
public class BookLikeCountService {
    private final BookRepository bookRepository;
    private final ExclusiveLockRepository exclusiveLockRepository;
    private final AtomicInteger increaseLikeCallCount = new AtomicInteger(0);
    private final AtomicInteger increaseLikeV2CallCount = new AtomicInteger(0);

    @Transactional
    public void increaseLikeCount(Long id) {
        // 분산락이 적용되어 있지 않아서, 불안정하게 LikeCount가 증가하게됨.
        Book book = bookRepository.findById(id).orElseThrow(IllegalArgumentException::new);
        book.increaseLikeCount();
        increaseLikeCallCount.incrementAndGet();
    }

    @Transactional
    public void increaseLikeCountV2(Long id) {
        // 분산락이 적용되어 있어서, 안정적으로 LikeCount 가 증가하게 됨.
        exclusiveLockRepository.findByCode(ExclusiveLockProperties.BOOK_LOCK_NAME);
        Book book = bookRepository.findById(id).orElseThrow(IllegalArgumentException::new);
        book.increaseLikeCount();
        increaseLikeV2CallCount.incrementAndGet();
    }

    @Transactional
    public Long getLikeCount(Long id) {
        Book book = bookRepository.findById(id).orElseThrow(IllegalArgumentException::new);
        return book.getLikeCount();
    }
}

// incrementAndGet() : +1 증가시키고 변경된 값 리턴
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package com.example.app;

import com.example.app.model.Book;
import com.example.app.model.ExclusiveLock;
import com.example.app.properties.ExclusiveLockProperties;
import com.example.app.repository.BookRepository;
import com.example.app.repository.ExclusiveLockRepository;
import com.example.app.service.BookLikeCountService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.TestConstructor;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

@Slf4j
@ExtendWith(SpringExtension.class)
@SpringBootTest
@RequiredArgsConstructor
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class BookIncreaseLikeCountTests {
    private final ExclusiveLockRepository exclusiveLockRepository;
    private final BookRepository bookRepository;
    private final BookLikeCountService bookLikeCountService;
    private static final String code = "MY_LOCK";
    private static final int MAX_INCREASE_COUNT = 100;
    private static final int MAX_THREAD_COUNT = 10;

    @BeforeAll
    @Transactional
    @Rollback(false)
    void beforeAll() {
        exclusiveLockRepository.save(ExclusiveLock.ofCreate(code));
        exclusiveLockRepository.save(ExclusiveLock.ofCreate(ExclusiveLockProperties.BOOK_LOCK_NAME));
        bookRepository.save(Book.ofCreate("001", "testBook1", 0L));
        bookRepository.save(Book.ofCreate("002", "testBook2", 0L));
    }

    @Test
    @Order(1)
    @Rollback(value = false)
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    @DisplayName("Lock 이 보장되지 않는 환경에서 좋아요 100번 했을 때 카운트 테스트")
    public void increaseBookLikeCountTest() throws Exception {
        Long testBookId = 1L;
        ExecutorService executorService = Executors.newFixedThreadPool(MAX_THREAD_COUNT);

        for (int i = 0; i < MAX_INCREASE_COUNT; i++) {
            executorService.submit(() -> {
                bookLikeCountService.increaseLikeCount(testBookId);
            });
        }

        executorService.shutdown();
        executorService.awaitTermination(10L, TimeUnit.MINUTES);
        Long bookLikeCount = bookLikeCountService.getLikeCount(testBookId);
        int bookLikeCallCount = bookLikeCountService.getIncreaseLikeCallCount().get();
        
				// 분산락이 적용되어 있지 않기 때문에, 100번 좋아요를 호출하더라도,
				// 실제 좋아요 횟수는 100번 이하기 됩니다.
				Assertions.assertNotEquals(MAX_INCREASE_COUNT, bookLikeCount);
        log.info("book.likeCount : {}, book.likeIncreaseCallCount : {}", bookLikeCount, bookLikeCallCount);
    }

    @Test
    @Order(2)
    @Rollback(value = false)
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    @DisplayName("Lock 이 보장되는 환경에서 좋아요 100번 했을 때 카운트 테스트")
    public void increaseBookLikeCountV2Test() throws Exception {
        Long testBookId = 2L;
        ExecutorService executorService = Executors.newFixedThreadPool(MAX_THREAD_COUNT);

        for (int i = 0; i < MAX_INCREASE_COUNT; i++) {
            executorService.submit(() -> {
                bookLikeCountService.increaseLikeCountV2(testBookId);
            });
        }

        executorService.shutdown();
        executorService.awaitTermination(10L, TimeUnit.MINUTES);
        Long bookLikeCount = bookLikeCountService.getLikeCount(testBookId);
        int bookLikeCallCount = bookLikeCountService.getIncreaseLikeV2CallCount().get();
        
				// 분산락이 적용되어 있기 때문에, 100번 좋아요를 호출한 상황에서,
				// 실제 좋아요 횟수도 100이 됩니다.
				Assertions.assertEquals(MAX_INCREASE_COUNT, bookLikeCount);
        log.info("book.likeCount : {}, book.likeIncreaseCallCount : {}", bookLikeCount, bookLikeCallCount);
    }
}
This post is licensed under CC BY 4.0 by the author.