Posts MySQL - LOCK 살펴보기 (1) — 락 기초와 잠금 과정
Post
Cancel

MySQL - LOCK 살펴보기 (1) — 락 기초와 잠금 과정

락을 잘 모르면 비효율적인 쿼리를 작성하거나, 데드락을 유발하거나, 불필요한 성능저하 등을 일으킬 수 있다고 생각한다. 또한 디버깅시 실마리를 못잡을 수도 있다. 이런 일을 최대한 겪지 않도록 MySQL에서는 어떤 락을 제공하는지 살펴보자.

이 글은 두 편으로 나눈다. (1편) 락이 무엇이고, 어떤 인덱스 구조 위에서, 어떤 과정으로 잡히는지. (2편) MySQL - LOCK 살펴보기 (2) — 락의 타입과 종류 에서 개별 락(S/X, 레코드·갭·넥스트키 등)을 하나씩.

Lock이란?


여러 트랜잭션이 같은 데이터에 동시에 접근할 때 서로를 방해해 데이터가 깨지는 걸 막는 동시 접근 제어 수단이다.

  • 동시에 실행되는 트랜잭션들이 아무 통제 없이 같은 행을 읽고 쓰면 lost update, 팬텀 같은 이상 현상이 생긴다.
  • 락은 특정 순간에 어떤 트랜잭션이 어떤 데이터를 읽고 쓸 수 있는지를 통제해 이를 막는다.

읽기는 MVCC, 쓰기·잠금 읽기는 락

  • InnoDB에서 일반 SELECT는 (READ COMMITTED / REPEATABLE READ 기준) 락을 걸지 않는다.
  • 언두 로그로 스냅샷 시점의 버전을 읽는 MVCC로 일관성을 확보하기 때문이다.
  • 따라서 락이 실제로 필요한 건 쓰기(INSERT/UPDATE/DELETE)와 잠금 읽기(SELECT ... FOR UPDATE, FOR SHARE) 다.
문제해결 수단
일반 SELECT의 일관된 읽기MVCC (락 없음)
같은 행 동시 수정 (lost update)레코드 락
범위 안에 새 행이 끼어듦 (팬텀)갭 락 / 넥스트키 락
  • 레코드 락만으로는 이미 존재하는 행만 지킬 수 있고, 아직 없는 행이 나중에 생기는 건 막지 못한다.
  • REPEATABLE READ에서 팬텀을 막기 위해 갭 락이 추가로 필요한 이유이자, READ COMMITTED가 갭 락을 쓰지 않는 이유(애초에 팬텀을 허용하는 격리 수준)이기도 하다.

※ SERIALIZABLE은 예외

  • SERIALIZABLE에서는 일반 SELECT도 락을 건다.
  • autocommit이 꺼져 있으면 InnoDB가 평범한 SELECT를 암묵적으로 SELECT ... FOR SHARE로 바꿔, 읽은 행과 그 범위에 S 넥스트키 락을 건다.
  • MVCC 스냅샷 없이 락만으로 일관성을 보장하는 셈이다. (autocommit이 켜진 단일 SELECT는 예외로 락 없는 consistent read로 처리된다)

각 락이 구체적으로 무엇을 어떻게 막는지는 2편에서 다룬다.

인덱스 구조


락은 인덱스를 스캔하며 잡힌다. “무엇이 잠기나”를 이해하려면 먼저 InnoDB가 데이터를 어떤 구조로 저장하고, “스캔한다”는 게 물리적으로 무슨 일인지 알아야 한다.

앞으로 이 테이블을 예시로 쓴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE t (
  id   INT PRIMARY KEY,
  name VARCHAR(10),
  age  INT,
  KEY idx_age (age)       -- age에 세컨더리 인덱스
);

-- 삽입한 데이터
 id | name | age
----+------+-----
 10 | kim  | 25
 20 | lee  | 30
 30 | park | 25
 40 | choi | 40
 50 | jung | 30

모든 테이블은 클러스터드 인덱스다

  • InnoDB는 테이블 자체를 PK 기준 B+트리(클러스터드 인덱스) 로 저장한다.
  • 내부 노드는 “어느 자식으로 내려갈지” 길잡이(키)만 갖고, 실제 행 데이터는 리프 노드에 통째로 들어 있다.
  • 리프끼리는 좌→우 포인터로 이어져 있어 범위 스캔이 가로로 흐른다.
1
2
3
4
5
6
7
8
9
10
11
12
13
                       ┌───────────────────┐
                       │  루트(내부) 노드     │   자식 페이지의 최소 키만 보관
                       │   [ 10 | 40 ]     │   (실제 행 데이터는 없음)
                       └──┬──────────┬──────┘
             ┌────────────┘          └────────────┐
             ▼                                     ▼
      ┌────────────────────────┐          ┌────────────────────────┐
 ────▶│ 리프 페이지 P1            │────next──▶ 리프 페이지 P2            │────▶
      │  10 │ kim  │ 25         │          │  40 │ choi │ 40        │
      │  20 │ lee  │ 30         │          │  50 │ jung │ 30        │
      │  30 │ park │ 25         │          │                        │
      └────────────────────────┘          └────────────────────────┘
       리프에 행 전체(name·age 포함)가 있다   리프끼리 연결돼 범위 스캔이 가로로 흐른다
  • PK가 없으면 NOT NULL 유니크 인덱스를, 그것도 없으면 InnoDB가 내부적으로 숨은 row_id를 만들어 클러스터드 인덱스로 쓴다.
  • 위 그림은 구조를 보이려 리프를 둘로 나눴지만, 행이 몇 개뿐이면 실제로는 16KB 리프 한 페이지에 다 들어간다.

세컨더리 인덱스 리프 = 인덱스 컬럼 + PK

  • idx_age의 리프에는 행 전체가 아니라 age 값과 PK(id) 만 들어 있고, age 기준으로 정렬된다.
1
2
3
4
5
 idx_age 리프 (age 순 정렬,  값 = age + PK)
 ────▶ 25→10 ─▶ 25→30 ─▶ 30→20 ─▶ 30→50 ─▶ 40→40 ─▶
        │                                    │
        └── name 같은 다른 컬럼이 필요하면 PK(10, 30…)로 클러스터드 인덱스를 다시 탄다
            (SELECT가 age·id만 읽으면 여기서 끝 = 커버링 인덱스)
  • 어느 쪽이든 행을 찾고 수정하는 일은 항상 인덱스를 통해 이뤄진다.
  • 그래서 락도 “행”이 아니라 인덱스 레코드에 걸린다.
  • WHERE age = 30idx_age 위 레코드에, WHERE id = 20은 클러스터드 인덱스 위 레코드에 잠금이 걸린다.

“행”은 어디 있고, 락은 어느 칸에 걸리나

InnoDB에는 행을 따로 담아두는 별도 저장소가 없다. 행의 실제 저장 위치가 클러스터드 인덱스 리프 레코드 그 자체다.

  • “인덱스 레코드”란 리프 안의 엔트리 한 칸으로, 레코드 헤더(heap_no·next_record) + 키 + 값 구조다.
  • 클러스터드 리프 레코드는 여기에 나머지 컬럼 전부와 InnoDB 숨은 컬럼(DB_TRX_ID·DB_ROLL_PTR)까지 담아 사실상 행 전체가 된다.
  • 레코드는 페이지에 키 순서로 저장되는 게 아니라, 삽입된 순서대로 쌓이고, 키 정렬은 각 레코드 헤더의 next_record(다음 레코드로의 오프셋) 포인터로만 표현된다.
  • 페이지 양 끝에는 가상 경계 레코드 infimum·supremum이 고정돼 있어, 체인은 항상 infimum → 최소키 → … → 최대키 → supremum으로 이어진다.
  • 이렇게 체인으로 늘어선 레코드가 한 페이지에 여러 개 쌓여 있고, 락은 그중 조건에 걸린 한 칸에만 걸린다.

UPDATE t SET name='x' WHERE id = 20을 예로 보면:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 UPDATE t SET name='x' WHERE id = 20      ← PK(클러스터드 인덱스)로 접근

 클러스터드 인덱스 리프 P1   (리프 레코드 = 행 전체,  … = DB_TRX_ID·DB_ROLL_PTR)

  [infimum]                              ← 페이지 시작 경계(가상 레코드)
      │ next_record
      ▼
  ┌──────┬────┬──────┬─────┬───┐
  │ 헤더  │ 10 │ kim  │ 25  │ … │
  └──────┴────┴──────┴─────┴───┘
      │ next_record
      ▼
  ┌──────┬────┬──────┬─────┬───┐
  │ 헤더  │ 20 │ lee  │ 30  │ … │  ◀━━ 🔒 X 락은 이 레코드 한 칸에만
  └──────┴────┴──────┴─────┴───┘
      │ next_record
      ▼
  ┌──────┬────┬──────┬─────┬───┐
  │ 헤더  │ 30 │ park │ 25  │ … │
  └──────┴────┴──────┴─────┴───┘
      │ next_record
      ▼
  [supremum]                             ← 페이지 끝 경계(가상 레코드)
1
2
3
 idx_age 리프 (age 순, 값 = age+PK) — 이 쿼리는 여기를 안 탐 → 안 잠김
  [infimum] ─▶ 25→10 ─▶ 25→30 ─▶ 30→20 ─▶ 30→50 ─▶ 40→40 ─▶ [supremum]
                                  └ 같은 행(id=20)의 '다른 칸'. 지금은 잠기지 않는다
  • 같은 논리적 행 id=20이라도 클러스터드 레코드와 세컨더리 레코드는 서로 다른 칸이라, 둘 다 잠기는 게 아니라 쿼리가 실제로 탄 인덱스 위의 칸만 잠긴다.
  • WHERE age=30으로 접근했다면 그때는 idx_age30→20 레코드가 잠겼을 것이다.
  • 스캔한다 = 이 next_record 체인을 한 칸씩 밟는 것이고, 락은 밟은 레코드에 하나씩 붙는다.
  • 범위가 어디서 끝나는지 알려면 조건을 벗어나는 첫 레코드까지 한 번 밟아야 하므로, 잠기는 범위는 접근 경로에 따라 달라진다.
  • 페이지 내부의 더 자세한 구조(Page Directory, File Header/Trailer 등)와 인덱스 세부 동작은 별도 문서로 정리한다. 여기서는 락이 next_record로 이어진 인덱스 레코드에 걸린다는 점만 붙잡으면 된다.
  • 갭이 정확히 무엇이고 INSERT가 왜 특정 링크에서 막히는지는 2편의 Gap Lock에서 이어서 다룬다.

쿼리 수행 과정과 락을 잡는 단계


락은 쿼리 시작 시 한꺼번에 잡히는 게 아니라, 스토리지 엔진이 인덱스를 스캔하며 레코드를 만나는 순간마다 하나씩 잡힌다.

앞의 인덱스 구조에서 본 “next_record 체인을 밟으며 스캔한다”가 락과 어떻게 연결되는지, 쿼리 한 건이 처리되는 흐름을 따라가며 보자.

MySQL의 두 계층 — 서버 레이어와 스토리지 엔진

MySQL은 서버 레이어스토리지 엔진으로 나뉜다. 락을 거는 주체와 WHERE 조건을 판정하는 주체가 서로 다른 계층이고, 이 분리가 뒤에서 볼 잠금 동작의 원인이 된다.

서버 레이어(server layer)

  • 어떤 스토리지 엔진을 쓰든 공통인 상위층. 커넥션·인증, 파서, 옵티마이저, 실행기(executor), 내장 함수·LIKE·표현식 평가가 여기 있다.
  • 데이터를 직접 갖고 있진 않고, 실행기가 “행 하나 줘”라고 엔진을 호출한 뒤 올라온 행에 남은 WHERE 조건을 최종 판정한다.

스토리지 엔진(storage engine, 여기선 InnoDB)

  • 실제 데이터·인덱스를 저장하고 읽는 하위층. 인덱스를 타고 레코드를 읽으며 읽는 레코드에 락을 건다.
  • 트랜잭션·MVCC·버퍼풀도 이 계층 담당이다.
  • 둘 사이는 핸들러 API로 연결된다.
    • 실행기가 “인덱스로 첫 행 읽어”, “다음 행” 하고 부르면 엔진이 레코드를 하나씩 돌려주는 구조다.

MySQL 서버 아키텍처 계층 — 커넥터, 서버 프로세스(SQL 인터페이스·파서·옵티마이저·캐시), 스토리지 엔진(InnoDB 등), 파일 시스템

이 글과 직접 관련된 층은 가운데 MySQL Server Process(파서·옵티마이저·실행기 = 서버 레이어)와 그 아래 Storage Engines(InnoDB = 스토리지 엔진)다. 실행기가 핸들러 API로 InnoDB를 호출해 레코드를 받고, 락은 아래층 InnoDB가, WHERE 최종 판정은 위층 서버 레이어가 맡는다.

위 그림은 널리 쓰이는 클래식 다이어그램이라 MySQL 8.0+ 기준으로 두 군데가 낡았다. NoSQL Interface(InnoDB memcached 플러그인)는 8.0.22에서 deprecated → 8.3.0에서 제거됐고, Caches & Buffers 안의 쿼리 캐시는 8.0에서 삭제됐다. 나머지 구조는 8.0에서도 유효하다.

데이터는 엔진에 다 있는데 왜 서버 레이어가 따로 있나

  • 엔진이 제공하는 건 SQL이 아니라 행 단위 읽기/쓰기 API(“이 인덱스에서 키 X 찾아줘”, “다음 행 줘”)뿐이다. JOIN·GROUP BY·LIKE '%x%' 같은 개념은 이 API에 없다. 엔진은 컬럼을 바이트로 돌려줄 뿐, SQL 표현식으로 해석하진 않는다.
  • 그래서 한 테이블/한 행을 넘어서는 일은 서버 레이어 몫이다. 여러 테이블을 엮는 JOIN(심지어 InnoDB↔MEMORY처럼 엔진이 달라도), 여러 행을 모으는 집계·정렬, 표현식·함수 평가, 인덱스 선택 같은 옵티마이저 판단이 여기 있다.
  • 이렇게 나눈 이유는 스토리지 엔진이 플러그인이라서다. SQL 파싱·최적화·조인을 엔진마다 구현하면 중복이 크니, SQL 레벨 작업을 공용 상위층에 한 번만 빼두고 엔진엔 단순 저장·조회 API만 요구한다.
  • 앞서 본 ICP는 이 원칙의 예외다. 라운드트립이 아까우니 엔진이 인덱스로 값싸게 판단 가능한 조건만 골라 아래로 내려보낼 뿐, 임의의 SQL은 여전히 서버 레이어로 올라온다.

쿼리 한 건이 락을 잡기까지

두 계층이 실제로 어떻게 주고받는지, 예시 테이블 t에 쿼리 하나를 던져 따라가 보자. (REPEATABLE READ)

1
SELECT * FROM t WHERE id BETWEEN 20 AND 40 AND name LIKE '%a%' FOR UPDATE;
  • id BETWEEN 20 AND 40은 PK(클러스터드 인덱스)로 범위를 좁힐 수 있다 → 옵티마이저가 여기까지를 엔진에 맡긴다.
  • name LIKE '%a%'는 앞이 %라 인덱스로 못 거른다 → 서버 레이어가 행을 올려받아 직접 판정해야 한다.
1
2
3
4
5
6
7
8
9
 옵티마이저: "PRIMARY로 id 20~40 구간을 스캔" 결정 (잠금 범위가 여기서 사실상 확정)

 실행기 ─"20~40 첫 행 줘"─▶ InnoDB : id=20(lee)  읽음 → 🔒 락 ─올림─▶ 실행기
                                                          name '%a%'? lee ✗ → 버림 (락은 남음)
 실행기 ─"다음 행"────────▶ InnoDB : id=30(park) 읽음 → 🔒 락 ─올림─▶ 실행기
                                                          name '%a%'? park ✓ → 결과로
 실행기 ─"다음 행"────────▶ InnoDB : id=40(choi) 읽음 → 🔒 락 ─올림─▶ 실행기
                                                          name '%a%'? choi ✗ → 버림 (락은 남음)
 → 40까지 읽으면 구간 끝. 최종 결과는 park(30) 한 행뿐

performance_schema.data_locks로 실제 잡힌 락을 확인하면(위 트랜잭션을 열어둔 채 조회):

INDEX_NAMELOCK_MODELOCK_DATA 
PRIMARYX,REC_NOT_GAP20lee — 조건 불일치인데도 잠김
PRIMARYX30park — 결과 행
PRIMARYX40choi — 조건 불일치인데도 잠김

(이 외에 테이블에 IX 의도 락이 하나 잡힌다.)

  • 결과로는 park 한 행만 나왔지만, 엔진이 스캔하며 읽은 20·40도 잠겨 있다.
  • 서버 레이어가 name LIKE '%a%'를 판정하려면 그 행을 일단 엔진이 읽어 올려야 하고, InnoDB는 읽는 순간 락부터 걸기 때문이다.
    • “결과에 없는 행인데 왜 잠겨 있지?”의 정체가 이것이다.
  • READ COMMITTED였다면 조건에서 탈락한 20·40의 락은 판정 직후 풀린다. REPEATABLE READ라서 트랜잭션 끝까지 남는다.
  • 20은 레코드만(REC_NOT_GAP) 잠기고 30·40은 갭까지 포함한 넥스트키(X)로 잠기는지는 2편의 락 타입에서 다룬다.
    • 여기서 볼 것은 읽힌 세 레코드가 전부 잠긴다는 점이다.

이 흐름에서 일반화할 수 있는 원칙은 넷이다.

  • 잠금 범위는 최적화 단계에서 정해진다.
    • 옵티마이저가 어떤 인덱스로 어디부터 어디까지 스캔할지 결정하는 순간, 잠글 레코드와 갭의 범위가 사실상 확정된다.
    • 조건에 맞는 인덱스가 없으면 스캔 범위 = 테이블 전체가 되어 잠금도 그만큼 커진다.
  • 락은 스캔하면서 점진적으로 획득된다.
    • 쿼리 시작 시 전부 잡는 게 아니라, 엔진이 레코드를 하나 읽을 때마다 그 레코드에 락을 건다.
    • 그래서 같은 쿼리라도 인덱스를 얼마나 잘 타느냐에 따라 실제로 걸리는 락 수가 달라진다.
  • 엔진이 읽은 행은 조건에 안 맞아도 일단 잠긴다.
    • InnoDB는 서버 레이어의 WHERE 평가보다 먼저 레코드를 잠근다.
    • REPEATABLE READ에서는 조건에 안 맞는 행의 락도 트랜잭션 끝까지 유지되고, READ COMMITTED에서만 평가 후 즉시 해제된다. (인덱스 컨디션 푸시다운으로 조건을 엔진에 내려보내면 애초에 안 읽는 행이 줄어 락도 준다)
  • 락은 트랜잭션이 끝나야 풀린다.
    • 개별 문장이 끝날 때가 아니라 COMMIT/ROLLBACK 시점에 해제된다.
    • InnoDB는 실행 중에 락을 획득만 하고, 트랜잭션 종료 때 한꺼번에 해제하는 엄격한 2단계 잠금(strict 2PL) 을 따른다.
    • 트랜잭션을 짧게 유지해야 하는 이유가 이것이다.

즉, “이 쿼리가 무슨 락을 거는가”는 SQL 구문만 봐서는 알 수 없고, 옵티마이저가 어떤 인덱스를 어떻게 타는지까지 봐야 한다. EXPLAIN으로 실행 계획을, performance_schema.data_locks로 실제 잡힌 락을 확인하는 습관이 필요하다.

스캔 도중 락을 못 잡으면

이미 다른 트랜잭션이 충돌하는 락을 쥐고 있으면, 스캔은 그 레코드에서 멈춰서 기다린다. 실패로 즉시 끝나는 게 아니라 블로킹된다.

락을 바로 못 잡은 트랜잭션은 세 갈래로 갈린다.

  1. 대기 (blocking) — 기본 동작. 앞 트랜잭션이 COMMIT/ROLLBACK으로 락을 풀면 그때 이어서 진행한다. 이 대기 관계는 performance_schema.data_locks(해당 락의 LOCK_STATUS = WAITING)와 data_lock_waits(누가 누구를 기다리는지)로 확인한다.

  2. 락 대기 타임아웃 — 대기가 innodb_lock_wait_timeout(기본 50초)을 넘기면 에러가 난다.

1
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
  • 기본값(innodb_rollback_on_timeout = OFF)에서는 현재 문장만 롤백되고 트랜잭션은 열린 채 남는다.
  • 이미 잡아둔 락도 그대로다. 그래서 애플리케이션이 재시도하거나 직접 ROLLBACK으로 정리해야 한다.
  1. 데드락 — 서로가 서로의 락을 기다려 순환이 생기면, InnoDB의 데드락 감지(innodb_deadlock_detect, 기본 ON)가 타임아웃을 기다리지 않고 즉시 한쪽을 희생자로 골라 롤백시킨다.
1
ERROR 1213 (HY000): Deadlock found when trying to get lock; try restarting transaction
  • 희생자는 보통 되돌릴 양이 가장 적은(수정한 행이 적은) 트랜잭션이 선택되며, 이 경우엔 문장이 아니라 트랜잭션 전체가 롤백된다.

대기 자체를 원치 않으면 잠금 읽기에 옵션을 붙일 수 있다 (MySQL 8.0+).

  • SELECT ... FOR UPDATE NOWAIT — 잠긴 행을 만나면 대기 없이 즉시 에러(3572)를 반환한다.
  • SELECT ... FOR UPDATE SKIP LOCKED — 잠긴 행은 건너뛰고 잠기지 않은 행만 반환한다. 여러 워커가 큐에서 서로 다른 행을 집어가게 할 때 쓰는 방식이다.

LIMIT 1이면 한 행만 잠길까

아니다. LIMIT결과로 돌려주는 행 수를 제한할 뿐, 잠금은 그 한 건을 찾기까지 스캔한 범위에 걸린다.

LIMIT 1은 조건에 맞는 행을 하나 찾으면 거기서 스캔을 멈추게 한다. 그래서 테이블 전체를 잠그진 않지만, 멈추기 전까지 읽은 행은 다 잠근다. 그 개수는 인덱스에 따라 갈린다.

  • 인덱스를 잘 타면 → 적게 잠근다. 아래 쿼리는 status 인덱스로 첫 매칭 행에 바로 도달해 그 행(+RR이면 앞 갭)만 잠그고 멈춘다.
1
2
-- status 컬럼에 인덱스 있음
SELECT * FROM t WHERE status = 'A' ORDER BY id LIMIT 1 FOR UPDATE;
  • 인덱스를 못 타면 → 스캔한 만큼 다 잠근다. 아래 쿼리는 처음부터 한 행씩 읽으며 non_idx = 5를 찾는데, 그 값이 100번째 행에 있으면 1~100번째 행이 전부 잠긴다.
  • 조건에 안 맞는 99개까지 포함해서다. (앞서 본 “읽는 순간 잠기고, 조건 평가는 그 다음”이 그대로 적용)
1
2
-- non_idx 컬럼에 인덱스 없음
SELECT * FROM t WHERE non_idx = 5 LIMIT 1 FOR UPDATE;
  • 격리 수준에 따라 잔여 락이 다르다. REPEATABLE READ는 스캔 중 안 맞은 99개 행과 갭까지 트랜잭션 끝까지 유지하고, READ COMMITTED는 조건 평가 후 즉시 풀어 결국 매칭된 1개만 남긴다. (단 RC도 스캔 도중엔 걸었다 푸는 것이라 그 순간의 경합은 있다)

정리하면, LIMIT 1이 보장하는 건 “결과 1행”이지 “잠금 1행”이 아니다. 잠금 수 = 매칭을 찾기까지 스캔한 행 수이고, 이건 인덱스가 결정한다.

안 맞는 행까지 잠그는 걸 줄이려면 — ICP

앞 예시의 20·40처럼 조건에서 탈락할 행까지 잠기는 건, name LIKE '%a%'같이 인덱스로 못 거르는 조건이 서버 레이어에서야 판정되기 때문이다. 인덱스 컨디션 푸시다운(ICP) 은 이 낭비를 줄이는 최적화다.

  • 세컨더리 인덱스로 스캔할 때, 조건 중 그 인덱스 컬럼만으로 판단 가능한 것은 서버까지 올리지 않고 InnoDB에 내려보내(push down) 인덱스 단계에서 미리 걸러낸다.
  • 안 맞는 행은 아예 읽지도 잠그지도 않으므로 락이 줄어든다. (EXPLAINExtraUsing index condition으로 표시된다)
  • 다만 LIKE '%...%'처럼 인덱스로는 못 거르는 조건은 여전히 서버 레이어까지 올라가, 위에서 본 “읽고 → 잠그고 → 버리는” 흐름이 그대로 남는다.

“읽는 순간 잠긴다”와 “경계 레코드는 갭만 잠긴다”는 모순이 아니다

앞의 “엔진이 읽은 행은 조건에 안 맞아도 일단 잠긴다”와, 2편에서 볼 “범위를 벗어난 경계 레코드는 레코드는 안 잠기고 앞 갭만 잠긴다”는 얼핏 반대로 들린다. 둘은 불일치를 어디서 판정하느냐가 다를 뿐이다.

불일치 판정 위치InnoDB 동작
인덱스 레벨 (인덱스 키 값만 보고 범위 밖/불일치임을 앎)BETWEEN 10 AND 18에서 만난 id=20, 등값 miss id=15레코드 안 잠금, 앞 갭만 (X,GAP)
서버 레벨 (인덱스로는 판단 불가, 행을 올려 서버가 걸러냄)memo LIKE '%urgent%', 인덱스 없는 컬럼 조건레코드 먼저 잠그고 → 서버가 버림 (RR은 유지, RC는 해제)
  • “읽는 순간 잠긴다”는 서버 레벨 케이스다.
    • 엔진이 인덱스만으론 매칭 여부를 몰라 일단 레코드 락을 걸고 서버로 올려 판단하게 하므로, 버려진 행에도 락이 남는다(RR).
  • “경계 레코드는 갭만”은 인덱스 레벨 케이스다.
    • id=20이 범위 밖이라는 걸 인덱스 키 값만 보고 아니까, 레코드 부분은 빼고 팬텀 방지용 갭만 잠근다.
  • 지배 규칙은 “접근한 레코드가 조건에 매칭되면 레코드 부분을 잡고, 매칭 안 되면(그걸 인덱스에서 알 수 있는 한) 레코드 부분을 빼고 갭만 잡는다” 이다.

참고 자료

This post is licensed under CC BY 4.0 by the author.

HTTPS 요청이 HTTP로 리다이렉트 되는 현상 2

MySQL - LOCK 살펴보기 (2) — 락의 타입과 종류