[Database] 트랜잭션과 잠금

땡글이·2023년 5월 3일
1
post-thumbnail

목차

  • 트랜잭션과 잠금의 의미
  • MySQL에서의 트랜잭션
  • MySQL 엔진에서의 잠금
    • 글로벌 락
    • 테이블 락
    • 네임드 락
    • 메타데이터 락
  • InnoDB 스토리지 엔진에서의 잠금
    • 레코드 락
    • 갭 락
    • 넥스트 키 락
    • 자동 증가 락
  • 인덱스와 잠금
  • 트랜잭션의 격리수준

트랜잭션과 잠금의 의미

트랜잭션은 작업의 완정성을 보장해주는 것입니다. 즉, 하나의 논리적인 작업 셋에 하나의 쿼리가 있든 두 개 이상의 쿼리가 있든 관계없이 논리적인 작업 셋 자체가 100% 적용(COMMIT)되거나 아무것도 적용되지 않아야 함을 보장해주는 것입니다.

잠금은 여러 커넥션에서 동시에 동일한 자원을 요청할 경우, 순서대로 한 시점에는 하나의 커넥션만 변경할 수 있게 해주는 역할을 합니다.

격리 수준이라는 것은 하나의 트랜잭션 내에서 또는 여러 트랜잭션 간의 작업내용을 어떻게 공유하고 차단할 것인지 결정하는 레벨을 의미합니다.

즉, 트랜잭션은 데이터의 정합성을 보장하기 위한 기능이고, 잠금은 동시성을 제어하기 위한 기능입니다.

MySQL에서의 트랜잭션

MySQL에서 InnoDB는 트랜잭션을 지원하지만, MyISAM은 지원하지 않습니다. 예시로 확인하겠습니다.

-- // table_myisam과 table_innodb 모두 3 이라는 id 가지는 로우 있음.

mysql> INSERT INTO table_myisam values (1), (2), (3);
mysql> INSERT INTO table_innodb values (1), (2), (3);

결과는 MyISAM에는 id가 1, 2, 3 인 로우가 존재하고,InnoDB에는 id가 3인 로우만 존재합니다.

왜? InnoDB는 3 번째 데이터를 INSERT할 때, PK 중복 오류로 쿼리가 실패하면서, 트랜잭션이 실패하며 롤백됩니다.
하지만, MyISAM은 1,2번째 데이터는 이미 INSERT되었고, 마지막 데이터(3)를 넣는 과정에서만 오류가 발생하면서 종료되고 롤백되지 않습니다.

이런 문제를 부분 업데이트(Partial Update) 현상이라고 합니다. 이런 부분 업데이트(Partial Update) 현상은 데이터의 정합성을 맞추는 데에 상당히 어려운 문제를 만들어냅니다.

즉, 부분 업데이트(Partial Update) 현상이 발생하면, 실패한 쿼리로 인해 남은 레코드를 다시 삭제하는 재처리 작업이 필요할 수 있습니다.

주의사항!

트랜잭션 또한 DBMS의 커넥션과 동일하게 꼭 필요한 최소의 코드에만 적용하는 것이 좋습니다. 이는 프로그램 코드에서 트랜잭션의 범위를 최소화해야한다는 의미입니다.
트랜잭션의 활성상태가 길어지는 것은 데이터베이스 커녁센을 물고 있게되고 다른 커넥션의 성능을 저하시킬 수 있습니다.
또한 트랜잭션 내에 다른 서버와의 통신과정이 들어가 있다면, 다른 서버에서 문제가 생겼을 때에 트랜잭션까지 영향을 받게 됩니다. 이 부분은 많은 사람들이 실수할 수 있는 부분이니 주의합시다!
스프링 기준으로, 트랜잭션을 분할하고 범위를 최소화하려면, 클래스를 따로 만들어 메서드를 호출해주는 것이 좋습니다. 해당 링크를 참고해주세요.


MySQL 엔진 레벨의 잠금

MySQL에서 사용되는 잠금은 MySQL 엔진 레벨의 잠금스토리지 엔진 레벨의 잠금으로 나뉩니다.

  • MySQL 엔진 레벨의 잠금은 모든 스토리지 엔진에 영향을 미침
  • 스토리지 엔진 레벨의 잠금은 스트로지 엔진 간 상호 작용을 미치지 않습니다.

여기서는 MySQL 엔진 레벨의 잠금에 대해 알아보겠습니다. MySQL 엔진에서는 테이블 데이터 동기화를 위한 테이블 락 이외에도 테이블의 구조를 잠그는 메타데이터 락(Metadata Lock) 그리고 사용자의 필요에 맞게 사용할 수 있는 네임드 락(Named Lock) 이라는 잠금 기능도 제공합니다.

MySQL 엔진 레벨의 잠금에 있는 여러 잠금에 대해 알아보겠습니다.

글로벌 락

한 커넥션에서 글로벌 락을 획득하면, 다른 커넥션에서는 SELECT를 제외한 DDL 이나 DML 명령들은 글로벌 락이 해제될 때까지 대기해야 합니다.

글로벌 락이 영향을 미치는 범위는 MySQL 서버 전체이며, 작업 대상 테이블이나 데이터베이스가 다르더라도 동일하게 영향을 미칩니다.

근데 반대로 생각해보면, 글로벌 락을 거는 FLUSH TABLES WITH READ LOCK 명령은 모든 테이블의 락이 걸려있지 않을 때 락을 걸 수 있습니다.

즉, 테이블이나 레코드에 쓰기 잠금을 거는 SQL이 실행됐다면 이 글로벌 락을 얻는 명령은 먼저 실행된 SQL과 트랜잭션이 완료될 때까지 기다려야 합니다.

하지만 MySQL 8.0 부터는 트랜잭션을 지원하는 `InnoDB 스토리지 엔진`의 사용이 일반화되면서, 일관된 데이터 상태를 위해 모든 데이터 변경 작업을 멈출 필요가 없어졌습니다., `조금 더 가벼운 글로벌 락`의 필요성이 대두되었습니다. MySQL 8.0 부터는 Xtrabackup 이나 Enterprise Backup과 같은 백업 툴들의 안정적인 실행을 위해 `백업 락`이 도입되었습니다. 

### 백업 락
`백업 락`은 일반적인 테이블의 데이터 변경은 허용합니다. 일반적인 MySQL 서버의 구성은 소스 서버(Source Server)과 레플리카 서버(Replica server)로 구성되는데, 주로 백업은 레플리카 서버에서 실행됩니다.

테이블 락 (Table Lock)

테이블 락(Table Lock)은 개별 테이블 단위로 설정되는 잠금이며, 명시적 또는 묵시적으로 특정 테이블의 락을 획득할 수 있습니다.

  • 명시적으로 테이블 락 획득
    • LOCK TABLES table_name [ READ | WRITE ] 명령
    • 명시적으로 획득한 테이블 락은 UNLOCK TABLES 명령으로 잠금을 반납해야 한다
    • 명시적인 테이블 락도 특별한 상황이 아니면, 애플리케이션에서 사용할 필요가 거의 없다.
  • 묵시적으로 테이블 락 획득
    • 쿼리가 실행되는 동안 자동으로 획득했다가 쿼리가 완료된 후 해제
    • InnoDB는 레코드 기반의 잠금을 제공하기에, 단순 데이터 변경 쿼리로 인해 테이블 락이 설정되진 않음.
    • DDL 명령의 경우에만 테이블 락이 설정됨

네임드 락(Named Lock)

네임드 락(Named Lock)은 GET_LOCK() 함수를 이용해 임의의 문자열에 대해 잠금을 설정할 수 있습니다.

이 잠금의 특징은 대상이 테이블이나 레코드 또는 AUTO_INCREMENT 와 같은 데이터베이스 객체가 아니라는 것입니다.

네임드 락은 단순히 사용자가 지정한 문자열(String)에 대해 획득하고 반납(해제)하는 잠금입니다. 자주 사용되는 락은 아닙니다.

--// "mylock"이라는 문자열에 대해 잠금 획득
--// 2초 이후 자동 잠금 해제
mysql> SELECT GET_LOCK('mylock', 2);

--// "mylock" 잠금이 설정됐는지 확인
mysql> SELECT IS_FREE_LOCK('mylock');

--// "mylock"이라는 문자열에 대해 잠금 반납
mysql> SELECT RELEASE_LOCK('mylock');

네임드 락의 경우, 많은 레코드에 대해서 복잡한 요건으로 레코드를 변경하는 트랜잭션에 유용하게 사용할 수 있습니다.

배치 프로그램처럼 한꺼번에 많은 레코드를 변경하는 쿼리는 자주 데드락의 원인이 됩니다. 이러한 경우에 동일 데이터를 변경하거나 참조하는 프로그램끼리 분류해서 네임드 락을 걸고 쿼리를 실행하면 아주 간단히 해결할 수 있습니다.

메타데이터 락(Metadata Lock)

메타데이터 락(Metadata Lock)은 데이터베이스 객체(테이블이나 뷰 등)의 이름이나 구조를 변경하는 경우에 획득하는 잠금입니다.

메타데이터 락RENAME TABLE table_a TO table_b와 같이 테이블의 이름을 변경하는 경우 자동으로 획득하는 잠금입니다.

mysql> RENAME TABLE rank TO rank_backup, rank_new TO rank

위의 쿼리는 두 개의 RENAME 작업이 한꺼번에 실행되면, "Table not found 'rank'"와 같은 출력문을 발생시키지 않고 적용됩니다.

mysql> RENAME TABLE rank TO rank_backup
mysql> RENAME TABLE rank_new TO rank

하지만, 두 개의 쿼리로 나눠서 실행하면 잠깐동안 'rank' 테이블이 없는 상황이 생겨 "Table not found 'rank'"와 같은 문장이 출력되면서 오류가 발생합니다. 그래서 메타데이터 잠금과 InnoDB의 트랜잭션을 동시에 사용해서 이런 문제를 해결하는 방법도 있습니다.

mysql> START TRANSACTION;
mysql> RENAME TABLE rank TO rank_backup
mysql> RENAME TABLE rank_new TO rank
mysql> COMMIT;

InnoDB 스토리지 엔진 레벨의 잠금

InnoDB 스토리지 엔진은 MySQL에서 제공하는 잠금과는 별개로 스토리지 엔진 내부에서 레코드 기반의 잠금 방식을 탑재하고 있습니다. InnoDB는 레코드 기반의 잠금 방식으로 인해서 MyISAM에 비해 뛰어난 동시성 처리를 제공합니다.

InnoDB 스토리지 엔진이 내부적으로 어떤 잠금들을 제공하는지 알아보겠습니다.

레코드 락(Record lock)

레코드 자체만을 잠그는 것을 레코드 락(Record lock, Record only lock) 이라고 합니다.

그런데 InnoDB의 레코드 락과 다른 상용 DBMS와의 중요한 차이점은 InnoDB 스토리지 엔진은 레코드 자체가 아니라, 인덱스의 레코드를 잠근다는 점입니다. 즉, 인덱스가 없는 테이블이라도 자동 생성된 클러스터링 인덱스(PK)를 이용해 잠금을 설정합니다.

뒤에서 레코드를 잠그는 것과 인덱스를 잠그는 것의 차이에 대해 기술합니다.

참고>
InnoDB에서 대부분 보조 인덱스를 이용한 변경 작업은 이어서 설명한 넥스트 키 락이나 갭 락을 사용하지만, PK 또는 유니크 인덱스에 의한 변경 작업에서는 갭에 대해서는 잠그지 않고, 레코드 자체에 대해서만 락을 겁니다.

갭 락(Gap lock)

다른 DBMS와의 또 다른 차이점은 갭 락(Gap lock)입니다. 갭 락은 레코드 자체가 아니라, 레코드와 인접한 레코드 사이의 간격만을 잠그는 것을 의미합니다.

갭 락의 역할은 레코드와 레코드 사이의 새로운 레코드가 생성되는 것을 제어합니다. 갭 락은 이 자체로 쓰이기 보단, 뒤에 설명할 넥스트 키 락의 일부로 자주 사용됩니다.

넥스트 키 락 (Next key lock)

넥스트 키 락(Next key lock)레코드 락갭 락을 합쳐 놓은 형태의 잠금입니다.

AUTO_INCREMENT 락

MySQL에서 자동 증가하는 숫자 값을 추출(채번)하기 위해 AUTO_INCREMENT 라는 컬럼 속성을 제공합니다.

만약 AUTO_INCREMENT 컬럼이 사용된 테이블에 여러 레코드가 INSERT 되는 경우에는, 중복되지 않고 저장되는 순서대로 증가하는 일련번호 값을 가져야 합니다.

InnoDB는 이를 위해 내부적으로 AUTO_INCREMENT 락이라고 하는 테이블 수준의 잠금을 사용합니다.

다른 잠금과는 달리, AUTO_INCREMENT 락은 트랜잭션과 관계없이 INSERT나 REPLACE 명령에서 AUTO_INCREMENT 값을 가져오는 순간만 락이 걸렸다가 즉시 해제됩니다.

AUTO_INCREMENT 락은 테이블에 단 하나만 존재합니다. 단, AUTO_INCREMENT 락은 매우 짧은 시간동안 걸렸다가 해제되는 잠금이라 큰 문제가 되진 않습니다.

  • innodb_autoinc_lock_mode=0
    • 모든 INSERT 문장은 AUTO_INCREMENT 락 사용
  • innodb_autoinc_lock_mode=1
    • INSERT 되는 레코드 건수를 정확히 예측할 수 있을 때에는 AUTO_INCREMENT 락을 사용하지 않고, 훨씬 가볍고 빠른 래치(뮤텍스)를 이용해 처리
    • 연속모드(Consecutive mode)라고 함.
  • innodb_autoinc_lock_mode=2
    • 항상 AUTO_INCREMENT 락을 사용하지 않고, 래치(뮤텍스)를 이용해 처리
    • 인터리빙 모드(Interleaved mode) 라고 함.

자동증가 값이 한 번 증가하면, 절대 줄어들지 않는 이유가 AUTO_INCREMENT 락 을 최소화하기 위함입니다. 설령 INSERT 쿼리가 실패했더라도 한 번 증가된 AUTO_INCREMENT 값은 다시 줄어들지 않고 그대로 남습니다.


인덱스와 잠금

위에서 언급했듯이, InnoDB의 잠금은 레코드를 잠그는 것이 아니라, 인덱스를 잠그는 것이라고 했습니다.

즉, 변경해야할 레코드를 찾기 위해 검색한 인덱스의 레코드를 모두 락을 걸어야합니다. 아래의 예시를 보면 알 수 있습니다.

-- // employees 테이블의 first_name 컬럼에 인덱스가 붙어있다고 가정

mysql> SELECT COUNT(*) from employees WHERE first_name='Georgi';
+-----+
|  253|
+-----+

mysql> SELECT COUNT(*) from employees WHERE first_name='Georgi' AND last_name='Klassen';
+-----+
|    1|
+-----+

mysql> UPDATE employees SET hire_date=NOW() WHERE first_name='Georgi' AND last_name='Klassen';

UPDATE 명령이 실행되면 1건의 레코드가 업데이트됩니다. 하지만 1건의 업데이트를 위해 몇 개의 레코드에 락이 걸릴까요?

first_name 컬럼에는 인덱스가 있지만, last_name 컬럼에는 인덱스가 없어서 253건의 레코드가 모두 잠깁니다.

즉, UPDATE 작업을 위해 적절히 인덱스가 준비되어 있지 않다면, 각 클라이언트 간의 동시성이 떨어져서, 한 세션에서 UPDATE 하는 와중에 다른 클라이언트는 그 테이블을 업데이트하지 못하고, 기다려야 하는 상황이 발생합니다.

만약 employees 테이블에 PK를 제외하고 아무 인덱스도 없다면?

employees 테이블의 모든 레코드를 잠그게 됩니다.
이것이 MySQL의 InnoDB에서 인덱스 설계가 중요한 이유 중 하나입니다.


트랜잭션의 격리수준

트랜잭션의 격리 수준(isolation level)이란, 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것입니다.

총 4개의 격리 수준이 있는데, 뛰로 갈수록 각 트랜잭션 간의 데이터 격리(고립) 정도가 높아지며, 동시 처리 성능도 떨어지는 것이 일반적입니다. 하지만, 격리 수준이 높아져도 SERIALIZABLE이 아니라면 크게 성능의 차이가 없습니다.

데이터베이스의 격리수준을 이야기하면, 꼭 나오는 3가지의 부정합 문제점이 있습니다. 격리수준에 따라 발생할 수도 있고 발생하지 않을수도 있습니다. 자세한 내용은 각각 파트에서 설명하겠습니다.

DIRTY READNON-REPEATABLE READPHANTOM READ
READ UNCOMMITEDOOO
READ COMMITEDXOO
REPEATABLE READXXO
SERIALIZABLEXXX

READ UNCOMMITED

READ UNCOMMITED 격리수준은 말그대로 COMMIT 이나 ROLLBACK 여부에 상관없이 다른 트랜잭션에게 보여주는 것을 의미합니다.

위의 그림처럼 어떤 트랜잭션에서 처리한 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있는 현상을 더티 리드(DIRTY READ)라고 하고, 더티 리드(DIRTY READ)가 허용되는 격리 수준은 READ UNCOMMITED 입니다.

READ COMMITED

READ COMMITED 격리수준에서는 더티 리드 현상은 발생하지 않습니다. 즉 COMMIT이 완료된 데이터만 다른 트랜잭션에서 조회할 수 있기 때문입니다. COMMIT 되기 전이라면, 언두 로그에 있는 데이터를 조회합니다.

하지만, READ COMMITED 격리수준에서도 NON-REPETABLE READ 라는 부정합 문제가 있습니다.

사용자 B가 하나의 트랜잭션 내에서 똑같은 SELECT 쿼리를 실행했을 때는 항상 같은 결과를 가져와야 한다는 "REPEATABLE READ" 정합성에 어긋납니다.

이런 부정합 현상은 상황에 따라 크게 문제되지 않을 수도 있지만, 하나의 트랜잭션에서 동일 데이터를 여러 번 읽고 변경하는 작업이 금전적인 처리와 연결되면 문제가 될 수 있습니다.

REPEATABLE READ

REPEATABLE READ 격리수준은 InnoDB 스토리지 엔진에서 기본으로 사용되는 격리 수준입니다.

REPEATABLE READ 격리수준은 MVCC를 이용해 언두 영역에 백업된 이전 데이터를 이용해 동일 트랜잭션 내에서는 동일한 결과를 보여줄 수 있게 보장합니다. 사실 READ COMMITED 격리수준도 마찬가지로 MVCC를 이용해 COMMIT 되기 이전의 데이터를 보여줍니다.

하지만 REPEATABLE READ 격리수준과 READ COMMITED 격리수준의 차이는 언두 영역에 백업된 레코드의 여러 버전 가운데 몇 번째 이전 버전까지 찾아들어가야 하느냐에 있습니다.

모든 InnoDB의 트랜잭션은 고유한 트랜잭션 번호(순차적으로 증가함)을 가지며, 언두 영역에 백업된 모든 레코드에는 변경을 발생시킨 트랜잭션의 번호가 포장돼있습니다.

그리고 언두 영역의 백업된 데이터는 InnoDB 스토리지 엔진이 불필요하다고 판단하는 시점에 주기적으로 삭제합니다. REPEATABLE READ 격리수준에서는 MVCC를 보장하기 위해 실행 중인 트랜잭션 가운데 가장 오래된 트랜잭션 번호보다 앞선 언두 영역의 데이터는 삭제할 수 없습니다.

  • READ COMMITED 격리수준은 그것까지 고려하지 않고 COMMIT되면 삭제

아래의 그림처럼, 사용자B의 10번 트랜잭션 안에서 실행되는 모든 select 쿼리는 트랜잭션 번호가 10보다 작은 트랜잭션 번호에서 변경한 것만 조회합니다.

하지만, 하나의 트랜잭션이 계속 활성화되어 있다면, 언두 영역에 백업된 레코드가 너무 많아져 MySQL 서버의 처리 성능이 떨어질 수도 있으니 조심해야 합니다.

그리고 REPEATABLE READ 격리수준도 아래 그림처럼 발생하는 부정합 문제가 있습니다. 다른 트랜잭션에서 수행한 변경 작업에 의해 레코드가 보였다 안보였다하는 현상을 PHANTOM READ(또는 PHANTOM ROW)라고 합니다.

SELECT ... FOR UPDATE 쿼리는 SELECT 하는 레코드에 쓰기 잠금을 걸어야 하는데, 언두 레코드에는 잠금을 걸 수 없습니다. 그래서 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE 로 조회되는 레코드는 언두 영역의 변경 전 데이터를 가져오는 것이 아니라, 현재 레코드의 값을 가져오게 되는 것입니다.

SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE 는 뭘까?

SELECT ... FOR UPDATE는 "나 수정하려고 읽은 거니까 다른 애들은 건드리지마~" 라는 뜻이며 UPDATE 명령을 실행한 것처럼, SELECT 한 레코드들에 쓰기 락(베타적 락)을 걸어서, 다른 트랜잭션이 해당 레코드들의 락을 획득할 수 없도록 합니다.
그래서 SELECT ... FOR UPDATE를 실행하면, SELECT ... LOCK IN SHARE MODEUPDATE 와 같이 락이 필요한 경우의 명령들은 대기하게 됩니다. 하지만, 잠금 없는 일관된 읽기(단순 SELECT)의 경우에는, 락을 무시하고 읽기가 가능합니다.
그리고 SELECT ... LOCK IN SHARE MODE는 위와는 달리, 읽기 락(공유 락)을 걸어서, 다른 커넥션이 레코드를 읽을 수 있지만 트랜잭션이 커밋될 때까지 수정할 수는 없습니다.
이러한 레코드들이 아직 커밋되지 않은 다른 트랜잭션에 의해 변경된 경우, 쿼리는 해당 트랜잭션이 끝날 때까지 기다린 다음 최신 값을 사용합니다.

SERIALIZABLE

SERIALIZABLE 격리수준은 가장 단순한 격리수준이면서 동시에 가장 엄격한 격리수준입니다. 그만큼 동시 처리 성능도 다른 트랜잭션 격리 수준보다 떨어집니다.

InnoDB에서 순수한 SELECT 작업은 잠금 없이 실행되지만, SERIALIZABLE 격리수준이라면 읽기 작업도 공유 락(읽기 락)을 획득해야 실행이 가능합니다. 그래서 SERIALIZABLE 격리수준에선, SELECT 작업 동안 다른 트랜잭션은 레코드를 변경하지 못합니다.

그렇기 때문에 PHANTOM READ 문제도 발생하지 않습니다. 하지만 InnoDB 스토리지 엔진에서는 갭 락과 넥스트 키 락 덕분에 REPEATABLE READ 격리 수준에서도 이미 PHANTOM READ 문제가 발생하지 않아서, SERIALIZABLE 격리수준은 권장되지 않습니다.


Reference

Real MySQL 8.0.1
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html
https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl.html
https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html
https://whitepro.tistory.com/581
https://zzang9ha.tistory.com/381

profile
꾸벅 🙇‍♂️ 매일매일 한발씩 나아가자잇!

0개의 댓글