TL;DR

  • CockroachDB의 SELECT ... FOR UPDATE는 SERIALIZABLE 트랜잭션에서 정확성을 보장하는 잠금이 아니라, 경합이 심한 행의 동시 접근을 줄이는 최선 노력형 대기 수단임.
  • SERIALIZABLE 격리 수준은 충돌하는 트랜잭션 가운데 최대 하나만 커밋하도록 보장하며, 나머지는 처음부터 다시 실행됨.
  • 올바른 행을 트랜잭션 초기에 읽거나 쓰면 정확성을 위한 잠금 없이도 직렬화 가능성이 유지됨.
  • 경합이 심한 행을 잠금 없이 갱신하면 트랜잭션 전체를 재시도하는 비용이 커질 수 있어 FOR UPDATE가 성능 완화책으로 쓰임.
  • 잠금은 빠른 메모리 내 비복제 잠금이며, 리스 이전이나 범위 분할·병합 시 해제될 수 있어 단독으로 정확성을 보장하지 못함.

최선 노력형 배타적 잠금이라니?

  • 동료가 Kratos에서 서로 다른 요청이 같은 일회용 코드를 사용하지 못하도록 데이터베이스 행에 배타적 잠금을 구현했으며, 이 사례에서는 정확성이 핵심임.
  • PostgreSQL과 MySQL에서는 SELECT ... FOR UPDATE로 선택된 행을 잠그며, 일반 SELECT는 계속 가능하지만 잠금 방식의 읽기나 쓰기는 트랜잭션이 커밋 또는 롤백될 때까지 대기함.
  • CockroachDB도 같은 구문을 지원하지만, SERIALIZABLE 격리 수준에서는 SELECT ... FOR UPDATE와 SELECT ... FOR SHARE를 최선 노력형으로 취급하므로 정확성 보장에 의존해서는 안 됨.
  • 동시 접근의 의도된 순서가 유지되지 않을 수 있음. 예를 들어 테이블 T를 다루는 트랜잭션 B가 트랜잭션 A 뒤에서 기다려야 하더라도 실제로 기다리지 않을 수 있음.
  • 동료는 처음에 트랜잭션 격리 수준을 READ COMMITTED로 낮추려 했으며, 이 수준에서는 FOR UPDATE가 기대한 완전한 배타적 잠금으로 동작함. PostgreSQL의 기본값은 READ COMMITTED이고 CockroachDB의 기본값은 SERIALIZABLE임.
  • 하지만 낮은 격리 수준에서는 여러 동시성 이상 현상이 발생할 수 있으며, 특히 코드베이스에 서로 다른 격리 수준의 트랜잭션이 있고 같은 행에 접근하면 애플리케이션 로직의 정확성을 유지하기 어려워짐.
  • 간헐적으로만 작동하는 뮤텍스나 때때로만 방수되는 우비, 때때로만 제동되는 자동차처럼 보일 수 있지만, 여기서 핵심은 잠금이 SERIALIZABLE 트랜잭션 안에서 동작한다는 점임.
  • CockroachDB는 같은 행을 대상으로 하는 동시 트랜잭션의 읽기-쓰기 또는 쓰기-쓰기 충돌을 감지함.
  • 충돌이 발생하면 최대 하나의 트랜잭션만 커밋하고 나머지는 처음부터 재시작하도록 함.
  • 일부 경우에는 동시 쓰기가 대기하다가 상대 트랜잭션이 끝난 뒤 이어서 완료됨.
  • SQL 표준의 정의에 따르면 SERIALIZABLE 격리 수준에서 동시 실행한 트랜잭션은 직렬 실행과 같은 결과를 보장함. 직렬 실행은 각 트랜잭션이 완료된 뒤 다음 트랜잭션이 시작되는 순차 실행임.
  • 따라서 SERIALIZABLE 트랜잭션이 시작할 때 필요한 모든 행에 접근한다면, 예를 들어 더미 SELECT나 UPDATE my_table SET id = id ...를 수행한다면 잠금 자체는 필요하지 않음. 재시도되는 트랜잭션이 결국 성공하므로 정확성이 유지됨.

왜 존재하는가?

  • 콘서트 티켓 판매가 시작될 때 1만 건의 동시 요청이 같은 행을 갱신해 티켓을 사려는 상황에서는 잠금 없는 방식의 재시도 비용이 커질 수 있음.
  • 모든 트랜잭션이 같은 행을 먼저 읽고 결제 처리 등 비용이 큰 작업을 수행한 다음 티켓 판매 수를 갱신하면, 하나만 커밋되고 나머지는 트랜잭션 시작부터 재시도됨.
  • 이 방식은 최종적으로 올바른 판매 수를 보장하며 이중 증가나 증가 누락을 방지하지만, 전체 트랜잭션을 처음부터 다시 실행하는 비용이 발생함. CockroachDB는 경우에 따라 애플리케이션에 드러내지 않고 서버 측에서 자동 재시도할 수 있음.
  • 경합은 작업 자체가 비용이 큰 것이 아니더라도 모두에게 지연을 유발할 수 있음. 가능하다면 차례가 올 때까지 행 갱신을 기다리는 편이 나음.
  • CockroachDB의 SELECT ... FOR UPDATE는 바로 이 대기를 유도해 행 갱신의 경합을 줄이는 용도임.
  • 둘 이상의 트랜잭션이 동시에 시도하더라도 괜찮으며, 모든 트랜잭션이 한꺼번에 작업하는 상황을 줄이는 완화 전략임. 절반만 기다리더라도 성능상 큰 이점이 될 수 있음.
  • 최적화된 흐름에서는 트랜잭션 시작 부분에서 SELECT ... FOR UPDATE로 행에 접근해 최선 노력형 잠금을 얻고, 동시 트랜잭션이 그 지점에서 대기한 뒤 결제 처리를 진행하고 티켓 판매 수를 갱신함.

정확히 무엇이 최선 노력형인가?

  • CockroachDB 문서에 따르면 SELECT ... FOR UPDATE와 SELECT ... FOR SHARE는 빠른 메모리 내 비복제 잠금으로 구현됨.
  • 잠금이 걸린 범위에서 리스 이전이나 범위 분할·병합이 발생하면 잠금이 해제됨.
  • 따라서 이 잠금은 빠르고 단순하지만, 그 자체만으로 정확성을 보장하기에는 완전히 불충분함.
  • 잠금은 노드 충돌 같은 장애가 없어도 해제될 수 있음.

결론

  • SERIALIZABLE은 강력하지만 때로 비용이 큼.
  • 상황에 따라 쓰기 조건을 추가하거나, 경합이 심한 범위가 아닌 다른 범위에 쓰는 방식이 해법일 수 있음.
  • 다른 경우에는 작은 규모의 최선 노력형 대기만으로 충분할 수 있음.
  • 관련 세부 사항은 CockroachDB가 키 구간(key interval) 단위로 충돌을 관찰한다는 설명에 있음.