증상
여기에 상품의 후기를 기록하면 연관된 상품들의 좋아요 횟수가 +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();
}
상세 구현과정
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;
}
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);
}
}