목차
- 트랜잭션과 잠금의 의미
- MySQL에서의 트랜잭션
- MySQL 엔진에서의 잠금
- 글로벌 락
- 테이블 락
- 네임드 락
- 메타데이터 락
- InnoDB 스토리지 엔진에서의 잠금
- 레코드 락
- 갭 락
- 넥스트 키 락
- 자동 증가 락
- 인덱스와 잠금
- 트랜잭션의 격리수준
트랜잭션
은 작업의 완정성을 보장해주는 것입니다. 즉, 하나의 논리적인 작업 셋에 하나의 쿼리가 있든 두 개 이상의 쿼리가 있든 관계없이 논리적인 작업 셋 자체가 100% 적용(COMMIT)되거나 아무것도 적용되지 않아야 함을 보장해주는 것입니다.
잠금
은 여러 커넥션에서 동시에 동일한 자원을 요청할 경우, 순서대로 한 시점에는 하나의 커넥션만 변경할 수 있게 해주는 역할을 합니다.
격리 수준
이라는 것은 하나의 트랜잭션 내에서 또는 여러 트랜잭션 간의 작업내용을 어떻게 공유하고 차단할 것인지 결정하는 레벨을 의미합니다.
즉, 트랜잭션
은 데이터의 정합성을 보장하기 위한 기능이고, 잠금
은 동시성을 제어하기 위한 기능입니다.
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 엔진에서는 테이블 데이터 동기화를 위한 테이블 락
이외에도 테이블의 구조를 잠그는 메타데이터 락(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)
은 개별 테이블 단위로 설정되는 잠금이며, 명시적 또는 묵시적으로 특정 테이블의 락을 획득할 수 있습니다.
LOCK TABLES table_name [ READ | WRITE ]
명령 UNLOCK TABLES
명령으로 잠금을 반납해야 한다네임드 락(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)
은 데이터베이스 객체(테이블이나 뷰 등)의 이름이나 구조를 변경하는 경우에 획득하는 잠금입니다.
메타데이터 락
은 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 스토리지 엔진은 MySQL에서 제공하는 잠금과는 별개로 스토리지 엔진 내부에서 레코드 기반의 잠금 방식을 탑재하고 있습니다. InnoDB는 레코드 기반의 잠금 방식으로 인해서 MyISAM에 비해 뛰어난 동시성 처리를 제공합니다.
InnoDB 스토리지 엔진이 내부적으로 어떤 잠금들을 제공하는지 알아보겠습니다.
레코드 자체만을 잠그는 것을 레코드 락(Record lock, Record only lock)
이라고 합니다.
그런데 InnoDB의 레코드 락과 다른 상용 DBMS와의 중요한 차이점은 InnoDB 스토리지 엔진은 레코드 자체가 아니라, 인덱스의 레코드를 잠근다는 점입니다. 즉, 인덱스가 없는 테이블이라도 자동 생성된 클러스터링 인덱스(PK)를 이용해 잠금을 설정합니다.
뒤에서 레코드를 잠그는 것과 인덱스를 잠그는 것의 차이에 대해 기술합니다.
참고>
InnoDB에서 대부분보조 인덱스를 이용한 변경 작업
은 이어서 설명한 넥스트 키 락이나 갭 락을 사용하지만,PK 또는 유니크 인덱스에 의한 변경 작업
에서는 갭에 대해서는 잠그지 않고, 레코드 자체에 대해서만 락을 겁니다.
다른 DBMS와의 또 다른 차이점은 갭 락(Gap lock)
입니다. 갭 락
은 레코드 자체가 아니라, 레코드와 인접한 레코드 사이의 간격만을 잠그는 것을 의미합니다.
갭 락
의 역할은 레코드와 레코드 사이의 새로운 레코드가 생성되는 것을 제어합니다. 갭 락
은 이 자체로 쓰이기 보단, 뒤에 설명할 넥스트 키 락
의 일부로 자주 사용됩니다.
넥스트 키 락(Next key lock)
은 레코드 락
과 갭 락
을 합쳐 놓은 형태의 잠금입니다.
MySQL에서 자동 증가하는 숫자 값을 추출(채번)하기 위해 AUTO_INCREMENT
라는 컬럼 속성을 제공합니다.
만약 AUTO_INCREMENT
컬럼이 사용된 테이블에 여러 레코드가 INSERT 되는 경우에는, 중복되지 않고 저장되는 순서대로 증가하는 일련번호 값을 가져야 합니다.
InnoDB는 이를 위해 내부적으로 AUTO_INCREMENT 락
이라고 하는 테이블 수준의 잠금을 사용합니다.
다른 잠금과는 달리, AUTO_INCREMENT 락
은 트랜잭션과 관계없이 INSERT나 REPLACE 명령에서 AUTO_INCREMENT
값을 가져오는 순간만 락이 걸렸다가 즉시 해제됩니다.
AUTO_INCREMENT 락
은 테이블에 단 하나만 존재합니다. 단, AUTO_INCREMENT 락
은 매우 짧은 시간동안 걸렸다가 해제되는 잠금이라 큰 문제가 되진 않습니다.
innodb_autoinc_lock_mode=0
AUTO_INCREMENT 락
사용innodb_autoinc_lock_mode=1
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 READ | NON-REPEATABLE READ | PHANTOM READ | |
---|---|---|---|
READ UNCOMMITED | O | O | O |
READ COMMITED | X | O | O |
REPEATABLE READ | X | X | O |
SERIALIZABLE | X | X | X |
READ UNCOMMITED
격리수준은 말그대로 COMMIT
이나 ROLLBACK
여부에 상관없이 다른 트랜잭션에게 보여주는 것을 의미합니다.
위의 그림처럼 어떤 트랜잭션에서 처리한 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있는 현상을 더티 리드(DIRTY READ)
라고 하고, 더티 리드(DIRTY READ)
가 허용되는 격리 수준은 READ UNCOMMITED
입니다.
READ COMMITED
격리수준에서는 더티 리드
현상은 발생하지 않습니다. 즉 COMMIT이 완료된 데이터만 다른 트랜잭션에서 조회할 수 있기 때문입니다. COMMIT 되기 전이라면, 언두 로그
에 있는 데이터를 조회합니다.
하지만, READ COMMITED
격리수준에서도 NON-REPETABLE READ
라는 부정합 문제가 있습니다.
사용자 B가 하나의 트랜잭션 내에서 똑같은 SELECT 쿼리를 실행했을 때는 항상 같은 결과를 가져와야 한다는 "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 UPDATE
나 SELECT ... LOCK IN SHARE MODE
로 조회되는 레코드는 언두 영역의 변경 전 데이터를 가져오는 것이 아니라, 현재 레코드의 값을 가져오게 되는 것입니다.
SELECT ... FOR UPDATE
나SELECT ... LOCK IN SHARE MODE
는 뭘까?
SELECT ... FOR UPDATE
는 "나 수정하려고 읽은 거니까 다른 애들은 건드리지마~" 라는 뜻이며UPDATE
명령을 실행한 것처럼,SELECT
한 레코드들에 쓰기 락(베타적 락)을 걸어서, 다른 트랜잭션이 해당 레코드들의 락을 획득할 수 없도록 합니다.
그래서SELECT ... FOR UPDATE
를 실행하면,SELECT ... LOCK IN SHARE MODE
나UPDATE
와 같이 락이 필요한 경우의 명령들은 대기하게 됩니다. 하지만, 잠금 없는 일관된 읽기(단순 SELECT
)의 경우에는, 락을 무시하고 읽기가 가능합니다.
그리고SELECT ... LOCK IN SHARE MODE
는 위와는 달리, 읽기 락(공유 락)을 걸어서, 다른 커넥션이 레코드를 읽을 수 있지만 트랜잭션이 커밋될 때까지 수정할 수는 없습니다.
이러한 레코드들이 아직 커밋되지 않은 다른 트랜잭션에 의해 변경된 경우, 쿼리는 해당 트랜잭션이 끝날 때까지 기다린 다음 최신 값을 사용합니다.
- 잠금 없는 읽기에 대한 내용은 해당 포스팅을 참고해보자
- 공유 락과 베타 락에 대한 설명은 해당 포스팅을 참고해보자
- 조금 더 자세한 설명이 필요하다면, MySQL 공식문서를 참고해보자.
SERIALIZABLE
격리수준은 가장 단순한 격리수준이면서 동시에 가장 엄격한 격리수준입니다. 그만큼 동시 처리 성능도 다른 트랜잭션 격리 수준보다 떨어집니다.
InnoDB에서 순수한 SELECT 작업은 잠금 없이 실행되지만, SERIALIZABLE
격리수준이라면 읽기 작업도 공유 락(읽기 락)을 획득해야 실행이 가능합니다. 그래서 SERIALIZABLE
격리수준에선, SELECT 작업 동안 다른 트랜잭션은 레코드를 변경하지 못합니다.
그렇기 때문에 PHANTOM READ
문제도 발생하지 않습니다. 하지만 InnoDB 스토리지 엔진에서는 갭 락과 넥스트 키 락 덕분에 REPEATABLE READ
격리 수준에서도 이미 PHANTOM READ
문제가 발생하지 않아서, SERIALIZABLE
격리수준은 권장되지 않습니다.
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