Lock 정리(낙관적 락과 비관적 락, 분산락, 데드락) 및 활용까지
낙관적 락(optimistic Lock)
- 충돌이 발생하지 않는다고 낙관적이라고 가정함.
- DB가 제공하는 락 기능이 아니라 어플리케이션에서 제공하는 버전관리 기능을 사용함.
- version 등의 구분칼럼을 사용하여 충돌방지
- 트랜잭션을 커밋하는 시점에 충돌을 알 수 잇다.
- 최근 업데이트 과정에서만 락을 점유하기 때문에 락 점유시간을 최소화하여 동시성을 높일 수 있다.
예)
1. A가 먼저 접근 후 바로 뒤이어 B가 접근한다.
2. A가 해당 ROW와 version 을 업데이트한다.
3.B가 커밋 시점에 해당 ROW를 업데이트하려고 version을 체크해보니
처음과 다른 경우 어플리케이션은 예외를 발생시키고 첫번째 A의 커밋만 적용하여 정합성을 유지한다.
4. 실패된 커밋을 어플리케이션에서 후처리한다.
👉 JPA에서 낙관적 락 사용하기
@Version 을 통해 낙관적 락을 사용할 수 있다.
낙관적 락이 발생하는 경우 ObjectOptimisticLockingFailureException 예외가 발생하고, 해당 에러를 catch 해서 어플리케이션에서 후처리해주면 된다.
비관적 락(perssimistic Lock)
- 충돌이 발생한다고 비관적으로 가정하는 방식
- Repeatable Read, Serializableable 정도의 격리성에서 가능하다.
- 내가자주쓰는 mysql 의 격리수준
- READ UNCOMMITTED(커밋되지 않은 읽기)
- READ COMMITTED(커밋된 읽기)
- REPEATABLE READ(반복 가능한 읽기)
- SERIALIZABLE(직렬화 가능)
순서대로 READ UNCOMMITTED의 격리 수준이 가장 낮고 SERIALIZABLE의 격리 수준이 가장 높다.
- 트랜잭션이 시작될 때 S LOCK 또는 X LOCK을 걸고 시작한다.
- S lock은 공유 락이라고도 하며, 트랜잭션이 데이터를 읽기만 하는 경우에 사용됩니다. 다른 트랜잭션도 동시에 같은 데이터를 읽을 수 있지만, 쓰기 작업은 차단됩니다. S lock이 설정된 데이터를 읽는 트랜잭션이 끝날 때까지 해당 데이터는 다른 트랜잭션에서는 읽기만 가능하고 수정이 불가능합니다. S lock은 읽기 일관성을 유지하는 데 도움이 됩니다.
- X lock은 배타 락이라고도 하며, 트랜잭션이 데이터를 수정하거나 쓰는 작업을 할 때 사용됩니다. X lock이 설정된 데이터를 수정하는 트랜잭션이 끝날 때까지 다른 어떤 트랜잭션도 해당 데이터를 읽거나 쓸 수 없습니다. X lock은 쓰기 작업의 원자성과 데이터 무결성을 보장하는 데 도움이 됩니다.
- DB 가 제공하는 락 사용한다
- 데이터 수정 즉시 트랜잭션 충돌을 알 수 있다.
- 교착 상태 문제가 자주 발생할 수 있다.
교착상태란 (DeadLock )?
프로세스나 스레드가 서로가 가지고 있는 자원을 기다리면서 진행이 멈추어 있는 상태를 말합니다. 간단하게 말하면, 두 개 이상의 프로세스가 서로가 가진 자원을 무한히 기다리며 진행이 더 이상 이루어지지 않는 상태입니다.
4가지의 필요조건이 동시에 발생할 때 교착상태가 이루어진다.
- 상호 배제 (Mutual Exclusion): 자원은 한 번에 하나의 프로세스만이 사용할 수 있어야 합니다. 다른 프로세스는 해당 자원의 사용이 끝날 때까지 대기해야 합니다.
- 점유와 대기 (Hold and Wait): 프로세스가 적어도 하나의 자원을 가지고 있으면서 다른 프로세스가 가진 자원을 기다리고 있어야 합니다.
- 비선점 (Non-Preemption): 프로세스가 자원을 스스로 반납하지 않는 한, 다른 프로세스에 의해 강제로 자원을 빼앗을 수 없습니다.
- 순환 대기 (Circular Wait): 각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있어야 합니다. 즉, 프로세스들이 자원을 순환 형태로 대기하고 있는 상태입니다.
성능의 측면
낙관적 락이 성능적으로 좋다.
왜냐하면, 충돌이 일어나지 않을 거라고 가정하지만, 비관적 락은 충돌이 발생할거라고 생각하고 바로 Lock 을 걸어버리기 때문이다.
그렇기 때문에 낙관적 락은 충돌이 많이 일어나지 않을 것이라고 예상되는 곳에 사용하면 좋은 성능을 기대할 수 있다.
롤백
비관적 락의 경우 트랜잭션만 롤백해주면 되지만,
낙관적 락의 경우에는 충돌이 발생하면 개발자가 수동으로 롤백처리를 하나하나 해줘야 한다는 단점이 있다.
분산락(Distributed Lock)
- 서버가 여러대인 상황에서 동일한 데이터에 대한 동기화를 보장하기 위해 사용한다.
TMI:실제로 kafka zookeeper나 redisson을 통해서 맛본적은 있다... 하지만 깊게는 공부해 보지 못했기에
실제로 redis를 프로젝트에 구성하고 클러스터까지 구성할 목적을 가지고 있기 때문에 한번 적용해볼 것이다.
- 서버들 간 동기화된 처리가 필요하고, 여러서버에 공통된 락을 적용해야 하기 때문에 redis를 이용하여
분산락을 이용한다.
-분산락 같은 경우 공통된 데이터 저장소(DB)를 이용해 자원이 사용중인지 확인하기 때문에 전체 서버에
동기화된 처리가 가능하다.
Redis 기반의 분산락 사용하기
1.Lettuce
스핀락(만약 다른 스레드가 Lock 을 소유하고 있다면 그 lock이 반환될 때까지 계속 확인하며 기다리는 것)을
사용하여 구현가능.
Lock 을 점유하는 시간이 짧을 경우 유용하지만 스레드가 Lock 을 오래 유지하는 경우 CPU에 부담을 준다.
sample code
void doProcess() {
String lockKey = "lock";
try {
while (!tryLock(lockKey)) { // ②
try {
Thread.sleep(50);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// (3) do process
} finally {
unlock(lockKey); // (4)
}
}
boolean tryLock(String key) {
return command.setnx(key, "1"); // ①
}
void unlock(String key) {
command.del(key);
}
출처 : 레디스와 분산 락(1/2) - 레디스를 활용한 분산 락과 안전하고 빠른 락의 구현
1. redis의 setnx 는 동일한 key 가 없을 경우에만 저장한다.
2.락을 획득 즉, redis 에 key를 저장할 때까지 while 반복문으로 계속 락 획득을 시도하고, 락을 획득한
경우에만 로직을 수행한다.
3.로직을 모두 수행한 후 redis에서 해당 키 삭제하는 걸로 락을 해제한다.
문제점.
첫번째, Lock 타임아웃 설정이 되지 않는다.
락을 획득하지 못한 경우에 무한루프를 돌게 된다. 그렇기때문에 일정시간이 지나면 락이 만료되도록 해줘야
하는데 setnx 명령어는 expire time을 설정할 수 없다고 한다..
두번째. Redis에 많은 부하가 발생한다.
스핀락은 지속적으로 락의 획득을 시도하기 때문에 Redis 에 많은 부담을 줄 수 밖에 없다.
TMI: 아니 그럼 왜 사용해... Lettuce는 왜 사용하는 거야... 단점 투성이... 한번 더 자세하게 알아볼 필요가있다..
2.Redisson
Redisson은 Lettuce 방식과는 다르게 스핀락 방식이 아니라 pub/sub 방식을 사용하기 때문에 Redis에 가는부하를 최대한
줄일 수 있다. 이방식은 kafka의 방식과 좀 유사하다고 생각한다.
여기서 pub / sub 란 ?
채널을 구독한 subscribe 에게 락이 해제될 때마다 메세지를 전송하는 방식이다.
구독자들에게 바로 메세지를 전달하기 때문에 메세지를 보관하지 않는다.
어느 채널을 통해서 메세지를 전달해준다고 생각하면 된다.
redis-cli -> subscribe test -> publish test "테스트 !" 로 redis에서 pub/sub방식을
테스트 해볼 수 있다.
Lock 획득 프로세스:
1. 대기없는 tryLock 을 통해 락 획득에 성공하면 true 를 반환한다.
이는 경합이 없을 때 아무런 오버헤드없이 락을 획득할 수 있도록 해준다.
2. pub/sub을 이용하여 메세지가 올 때 까지 대기하다가 락이 해제되었다는 메세지가 오면 대기를 풀고
다시 락 획득을 시도한다.
락 획득에 실패하면 다시 락 해제 메세지를 기다린다.
이 프로세스를 타임아웃시까지 반복한다.
3.타음아웃이 지나면 최종적으로 false 를 반환하고 락 획득에 실패했음을 알려준다.
대기가 풀릴 때 타임아웃 여부를 체크하므로 타임아웃이 발생하는 순간은 파라미터로 넘긴 타임아웃시간과
약간의 차이가 있을 수 있다.
특징:
1. Lock 에 타임아웃을 설정할 수 있다.
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException
waitTime : 락을 얻기 위한 대기 시간
leaseTime : 락이 만료되어 사라지는 시간 지정
② Lua 스크립트를 사용한다.
락 획득가능 여부, 획득, pubsub 알림은 atomic 해야한다.
Lua 스크립트 자체가 하나의 큰 명령으로 해석되기 때문에 atomic 하게 처리 된다.
MySQL 기반의 분산락 적용
Redis 뿐만 아니라 Mysql 에서도 분산락을 적용할 수 있다.
아래 예시는 Spring 에서 MYSQL을 적용하는 방법 예시
1. ExclusiveLock 테이블을 생성한다.
2.repository에 분산락 관련 코드를 설정한다.
@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);
3. ExclusiveLock.find 후 update 해준다.
find 할 때 비관적락이 걸리기 때문에 수선가 보장된다.
음... 정리를 하고 많은 생각을 해봤다.
어느 상황인지에 따라 달라지겠지만, 우리서비스는 현재 redis를 가동시키고 있는 중이고
mysql보다 캐시에서 처리해주는 것이 더 빠르지 않을까라는 생각이 들기떄문에
선택 : Redisson을 선택
redis에서도 방식이 여러가지인데 ? 어떤 것을 선택할까 ?
1. Lettuce 이용
Lettuce는 기본적으로 락 기능을 제공하고 있지 않기 때문에 락을 잡기 위해 setnx명령어를 이용해서 사용자가 직접 스핀 락 형태(Polling 방식)로 구현해야 함. 이로인해, 락을 잡지 못하면 끊임없이 루프를 돌며 재시도를 하게 되고 이는 레디스에 부하를 줄수 있다고 판단했음. 또한, setnx명령어는 expire time을 지정할 수 없기 때문에 락을 잡고 있는 서버가 죽었을 경우 락을 해제하지 못하는 이슈가 있음
(DeadLock 발생 가능성)참고로,
락을 얻는 과정은 락이 존재하는지를 확인하는 연산과 락이 존재하지 않으면 락을 획득하는 두 연산이 atomic하게 이루어져야 한다. 레디스에서는 이를 setnx 명령어로 지원한다.
2. Redisson 이용Redisson은 락을 잡는 방식이 스핀락 방식이 아니었고, expire time도 적용할 수 있었기 때문에 Redisson를 선택함.
참고로, Redisson은 pub/sub 방식을 사용하여 락이 해제될 때마다 subscribe하는 클라이언트들에게 "이제 락 획득을 해도 좋아!"라는 알림을 주는 구조이다.
Redis 는 또한 Signle Thread 로 되어 있어 최대한 하나의 자원에 여러 스레드가 동시에 접근하지 않도록
하고 있다. 또한 Redis의 자료구조는 Atomic 한 성질을 가지고 있어, Critical Section(프로세스가
동시에 접근하면 안되는 구역)에 대한 동기화를 제공한다.(Automic 한 Transaction이 원치 않는 READ/WRITE 동기화)
클러스터를 구성한다면 ? -> 서버가 여러 곳에 있다는 것은 서버의 데이터가 일치하지 않는다는 위험성을
내재하고있다. 하지만 Redis 는 앞서 말했듯이 Automic한 성질을 갖고 있어 데이터의 일관성을 보장한다.
Redisson sample Code
package com.example.stock.facade;
import com.example.stock.service.StockService;
import org.redisson.api.RLock;
import org.springframework.stereotype.Component;
import org.redisson.api.RedissonClient;
import java.util.concurrent.TimeUnit;
@Component
public class RedissonLockStockFacade {
private RedissonClient redissonClient;
private StockService stockService;
public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) {
this.redissonClient = redissonClient;
this.stockService = stockService;
}
public void decrease(Long key,Long quantity){
RLock lock = redissonClient.getLock(key.toString());
try{
boolean available=lock.tryLock(5,1, TimeUnit.SECONDS);
if(!available){
System.out.println("Lock 획득 실패");
return;
}
stockService.decrease(key,quantity);
}catch (InterruptedException e){
throw new RuntimeException(e);
}finally {
lock.unlock();
}
}
}
1. redissonClient.getLock(key.toString()) 을 통해 Redis의 락을 생성! -> 이 락은 key를 기반을 생성되며, 다른클라이언트가
동일한 key로 락을 요청하면 동일한 락을 획득하려고 시도하게 된다.
2. lock.tryLock(5,1,TimeUnit.SECONDS)를 통해 락을 획득하려고 시도한다. 해당 메서드는 락을 획득할 때까지 최대 5초간 대기하며
1초마다 락을 시도한다. 만약 5초 이내에 락을 획득하지 못한다 ? -> false 반환 이 경우 lock 획득 실패라는 메세지를 출력하고 메소드 종료.
3. 락을 획득하게되면 재고감소 로직 실행!
4. 재고 감소 후 lock.unlock() 을 호출하여 락을 해제시킴..
실제 TestCode
package com.example.stock.facade;
import com.example.stock.domain.Stock;
import com.example.stock.repository.StockRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class RedissonLockStockFacadeTest {
@Autowired
private RedissonLockStockFacade redissonLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before(){
Stock stock = new Stock(1L, 200L);
stockRepository.save(stock);
}
@AfterEach
public void after(){
stockRepository.deleteAll();
}
@Test
public void 동시에_32개의_요청() throws InterruptedException {
int threadCount=200;
//최대 스레드풀 32개
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for(int i=0;i<threadCount;i++){
executorService.submit(()->{
try {
redissonLockStockFacade.decrease(1L,1L);
}finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(0L,stock.getQuantity());
}
}
스레드풀 최대 제한을 32개로 설정하였고 스레드는 200개를 생성하였습니다.
재고감소로직을 작성해보니 테스트가 정상적으로 되었습니다.
마지으로
활용
Redis는 기본적으로 조회수와 같이 수많은 I/O를 반복해야하는 데이터를 캐싱처리하기 위해 사용된다(조회수는 반드시 Consistency를 만족할 필요가 없는 데이터이기도 하다). 그 외에도 API 캐싱, 좋아요 기능(한 명의 사용자는 하나의 댓글에 한 번만 좋아요 가능하다 -> Set 자료구조), 최근 검색어 목록(Sorted Set 자료구조), 세션 저장소(scale-out 용이함) 등이 있다.
*********추후********
Redis 클러스터를 구축하고
redis의 데이터를 동기화하는 작업까지 실시해볼 것!
redisson 코드참고:https://github.com/minwoo1999/Concurrency,
참고한 블로그: https://study-ihl.tistory.com/158,