Skip lock을 아시나요? (feat: PostgreSQL)

2025. 5. 30. 02:10개발 지식

반응형

Context

테이블에 저장된 데이터를 주기적으로 트랜잭셔널하게 읽어서 처리할 때,

어떻게 하면 여러 인스턴스에서 데이터를 race condition을 방지하면서 데이터를 읽어서 처리할 수 있을까요?

 

 

예를 들어서 다음과 같은 상황이 있을 수 있습니다.

type A struct {
  publisher EventPublisher
}

func (a *A) DoSomething(ctx context.Context) {
   // 어떤 비즈니스 로직 수행

   a.publisher.publishEvent(ctx, &Event{}) // event 발행
}



비즈니스 로직에서 DoSomething메서드를 호출했을 때, 이 이벤트를 구독해서 Outbox 테이블에 기록하는 로직이 있다고 가정해봅시다.

 

type EventListener struct {
  outboxRepo OutboxRepository
}

func (e *EventListener) OnEvent(ctx context.Context, event Event) {
   e.outboxRepo.Save(ctx, event)
}

 

그럼 이때 Outbox 테이블에는 많은 이벤트 들이 쌓이게 될 것입니다.

 

그리고 이 Outbox의 이벤트를 읽는 poller가 이를 주기적으로 읽어서 데이터를 처리할 것입니다.

 

 

Problem

만약 poller 인스턴스가 하나라면 다음과 같이 코드를 작성할 수 있을 것입니다.

type poller struct {
   repo OutboxRepository
}


func (p *Poller) poll() {
  records, err := repo.Exec(
    "select * from outbox where processed_at is null limit 10"
  )
  
  //error handling and do something
}

 

이렇게 하면 poll을 호출하는 go루틴 혹은 스레드가 주기적으로 10개씩 읽어서 처리하겠지요.

 

하지만 이 인스턴스가 만약 2개 이상이라면 어떨까요?

 

인스턴스가 2개 이상이라면 같은 row에 대한 처리를 2개의 인스턴스에서 동시에 처리하게 되기 때문에 중복 처리가 발생하게 됩니다.

 

이를 어떻게 해결할 수 있을까요?

 

Solution

이때 MySQL/PostgreSQL에서는 Skip Lock으로 이를 해결할 수 있습니다.

 

skip lock은 MySQL 8.0.1 부터 지원했고, psql은 9.5 버전부터 skip lock을 지원했습니다.

 

skip lock이란 여러 프로세스가 같은 테이블의 같은 행을 경합 없이 안전하게 처리하도록 해주는 lock입니다.

그래서 처리 중인 행(락이 걸린 행)은 건너뛰고, 아직 락이 걸리지 않은 행만 가져올 수 있습니다.

 

 

그럼 위의 skip lock을 적용해서 다시 살펴봅시다.

type poller struct {
   repo OutboxRepository
}


func (p *Poller) poll() {
  repo.Exec("BEGIN") // start transaction
  
  records, err := repo.Exec(
    "select * from outbox where processed_at is null order by id limit 10 for update skip locked"
  )
  
  
  //error handling and do something
  
  // update records processed_at = now()
  repo.Exec("COMMIT");
}

 

 

먼저 트랜잭션을 시작하고, skip lock으로 lock을 걸어 첫 번째 인스턴스에서 10개의 레코드를 가져온다면,

두 번째 인스턴스에서는 lock이 걸리지 않은 남은 최대 10개의 레코드에 대해서 lock을 걸고 처리하게 될 것입니다.

 

그리고 processed_at을 현재 시각으로 업데이트하면 완성됩니다.

 

 

 

Addition

이렇게하면 대부분의 유즈케이스에서는 해결할 수 있지만 풀리지 않는 케이스가 있습니다.

 

바로 순서 보장입니다.

 

SKIP LOCK을 사용하면 서로 다른 인스턴스에서 다른 레코드에 대해 접근을 할 수 있지만,

다음과 같은 상황에서 문제가 있습니다.

 

-- 1) 트랜잭션 시작
BEGIN;

-- 2) 아직 처리되지 않고, 다른 세션이 잡지 않은 행만 최대 10개 가져오기
SELECT *
  FROM outbox
 WHERE processed is NULL
 ORDER BY id
 LIMIT 10
 FOR UPDATE SKIP LOCKED;

-- 3) 애플리케이션에서 결과를 꺼내 처리(processing)…  (ERROR)

-- 4) 처리 완료 표시
UPDATE outbox
   SET processed   = TRUE,
       processed_at = NOW()
 WHERE id IN (…가져온 id 목록…);

-- 5) 트랜잭션 커밋
COMMIT;

 

 

 

우리는 위와 같은 커리를 하게 될 것입니다.

그러다 select 를 한 시점에서 skip lock이 걸릴 것이고, 그 이후에 애플리케이션으로 데이터가 반환되겠죠.

 

만약 이 시점에 애플리케이션에 장애가 발생해서 쿼리가 롤백되게 된다면,

똑같은 쿼리를 실행하고 있는 다른 인스턴스는 lock이 걸린 데이터를 건너뛰고 데이터를 처리하게 될 것입니다.

 

그로 인해서 롤백된 데이터가 더 나중에 처리되는 문제가 발생할 수 있습니다.

 

그래서 순서가 중요한 경우에는 skip lock을 사용하면 안 됩니다.

 

 

반응형