안녕하세요, 휴먼스케이프에서 주로 서버 개발을 하고 있는 James입니다.
문제 발생
서비스 운영을 하는 과정에서 가끔씩은(…) 에러가 발생하는데요, 그럴 때마다 Slack의 에러 모니터링 채널에 메시지가 올라옵니다.
어느 한 즐거운 월요일에 아래와 같은 메시지가 올라왔는데, 내용을 열어서 보니 다른 쿼리들과 transaction으로 묶여 있지 않은 단일 UPDATE
에서 deadlock이 발생했던 것이었습니다.
어떤 transaction과 deadlock이 생겼는지도 알려 줬으면 좋았겠지만, 안타깝게도 그 정보는 Sentry만 봐서는 알 수가 없었습니다…
문제 상황 검색
보통 deadlock은 좀 길고 복잡한 transaction에서 발생할 줄 알았는데, 그렇지 않아서 처음에는 굉장히 당황스러웠습니다. 그래서 `single update query deadlock`이라는 키워드로 검색을 해보니, 첫 검색 결과에서 약간의 실마리를 얻을 수 있었습니다.
t1이라는 테이블에 r1, r2라는 행이 있는 경우를 생각해 봅시다.
UPDATE 쿼리의 범위가 r1, r2이고, 다른 transaction에 r1과 r2를 각각 수정하는 쿼리가 포함된 경우에
- transaction (lock r1)
UPDATE
(lock r2, waiting r1)- transaction (waiting r2)
의 순서로 쿼리가 실행된다면 어떤 쪽도 더 이상 진행할 수 없는 deadlock이 발생할 수가 있습니다.
서비스 코드 검토
서비스 코드를 살펴보니, UPDATE 대상 테이블을 한 transaction 내에서 여러 번 수정하는 경우가 있었습니다. 그래서, 저는 transaction 내부에서 수정할 행들을 시작부터 한 번에 잠그면 해결할 수 있을 것이라 생각하고 방법을 찾아보았습니다.
Database lock modes
PostgreSQL 기준으로 설명을 드리면, 동시에 일어나는 데이터 액세스를 컨트롤하기 위해서는 적절한 lock을 걸어야 합니다. Lock은 크게 테이블 수준(table-level)에서 걸리는 것과 행 수준(row-level)에서 걸리는 것으로 나누어 볼 수가 있는데요, 이번 이슈의 경우 테이블 자체나 테이블의 모든 행을 삭제하는 등의 일이 없기 때문에 row-level lock 부분을 살펴보았습니다.
SELECT
쿼리에는 lock의 수준에 따라 구분되는 4가지 키워드(FOR UPDATE
, FOR NO KEY UPDATE
, FOR SHARE
, FOR KEY SHARE
) 를 사용할 수 있는데요, 하나씩 살펴보겠습니다.
FOR UPDATE
UPDATE
또는 DELETE
쿼리에 걸리는 것과 동일한 수준의 lock이 걸립니다.
다른 transaction은 이 lock이 해제되기 전까지 해당 행에 lock을 걸거나(SELECT FOR [UPDATE | NO KEY UPDATE | SHARE | KEY SHARE]
), 수정하거나, 삭제할 수 없고, 반대로 이 lock을 걸기 위해서는 다른 transaction의 이러한 작업들이 완료될 때까지 기다려야 합니다.
REPEATABLE READ
또는 SERIALIZABLE
수준의 transaction은 시작 이후 lock을 걸고자 하는 행이 바뀌었다면 에러를 발생시킵니다.
Unique index가 있어 foreign key로 사용될 수 있는 열들은 기본적으로 FOR UPDATE
수준의 lock이 걸립니다.
FOR NO KEY UPDATE
FOR UPDATE
와 비슷하게 동작하지만, 더 약한 수준입니다. 이 lock이 걸려 있는 행에 대해 SELECT FOR KEY SHARE
명령어는 즉시 실행됩니다.
FOR UPDATE
수준 lock이 걸리지 않는 행은 UPDATE
명령어 사용 시 이 수준으로 lock이 걸립니다.
FOR SHARE
FOR NO KEY UPDATE
와 비슷하게 동작하지만, 이는 개별 행에 대한 exclusive lock이 아닌 shared lock을 획득합니다.
Shared lock이 걸린 행은 다른 transaction에 의해 exclusive lock 획득(SELECT FOR [UPDATE | NO KEY UPDATE]
), 수정, 삭제가 불가능하지만, SELECT FOR [SHARE | KEY SHRARE]
의 실행을 막지는 않습니다.
FOR KEY SHARE
FOR SHARE
와 비슷하게 동작하지만, SELECT FOR NO KEY UPDATE
의 실행을 막지 않습니다. Key-shared lock은 다른 transaction의 DELETE
또는 key를 수정하는 UPDATE
를 막지만, 다른 동작들은 막지 않습니다.
요약하면, 아래와 같은 관계가 성립합니다.
해결책
다시 서비스 코드 이야기로 돌아가면, 문제가 될 것으로 추정되는 transaction들에 있는 두 번의 UPDATE
는 각각 다른 행의 다른 열을 업데이트해서 한 번으로 묶을 수가 없었습니다. 따라서, 저는 UPDATE
범위의 합집합에 SELECT FOR UPDATE
로 lock을 거는 쿼리를 transaction 시작 부분에 넣었습니다.
이렇게 되면 deadlock이 발생했던 단일 UPDATE
문을 transaction의 SELECT FOR UPDATE
쿼리 이전에 실행하려고 하면 transaction은 수정하려는 모든 행에 lock이 이미 걸려있어 끝날 때까지 대기하게 되고, 이후에 실행하려고 하면 SELECT FOR UPDATE
가 먼저 걸어놓은 lock으로 인해 transaction의 작업 범위에 새로운 lock을 걸지 못하고 대기하게 됩니다.
여담
지금 글을 쓰고 있는 시점에서 작업한 부분을 다시 천천히 살펴보니, key를 갱신하지 않는 UPDATE
들이 뭉쳐서 실행되어야 해서 SELECT FOR NO KEY UPDATE
수준의 lock이 데이터의 정합성을 지키는 데 최소한으로 필요해 보입니다.
제가 위 작업을 한 서비스에서는 Sequelize
라는 ORM을 사용하고 있는데, SELECT
쿼리에 FOR NO KEY UPDATE
수준의 lock을 걸고자 하면 아래와 같이 findAll
메소드에서 options.lock
속성의 값을 transaction.LOCK.NO_KEY_UPDATE
로 지정하면 됩니다.
const transaction = await models.sequelize.transaction();try {
const rows = await models[model].findAll({
where: { ... }, // where1 U where2
lock: transaction.LOCK.NO_KEY_UPDATE,
transaction,
}); // locked await models[model].update(
values1,
{
where: { ... }, // rows for where1 already has been locked
transaction,
}
); await models[model].update(
values2,
{
where: { ... }, // rows for where2 already has been locked
transaction,
}
); await transaction.commit(); // release lock
} catch (err) {
console.error(err);
await transaction.rollback();
}
정리
Deadlock이란, 여러 transaction이 다른 쪽이 끝나서 lock을 해제하기를 서로 기다리는 상황을 의미합니다. Oracle 문서에서는 이를 피하기 위한 간단한 몇 가지 가이드를 제공하고 있습니다.
Row-level lock과 READ_COMMITED 격리 수준(isolation level)을 사용하는 것으로 대부분의 deadlock을 피할 수 있다고 하고, 테이블 여러 개를 접근하는 경우 같은 순서로 접근할 것, index를 적절히 활용해 테이블 스캔을 하기보다는 적은 수의 lock을 획득하는 것을 권장하고 있습니다.
Transaction의 격리 수준이나 lock에 대한 더 자세한 내용은 여러 공식 문서에도 나와 있고, 이를 잘 정리해주신 Suhwan Jee 님의 아래 포스트를 참고하시면 좋을 것 같습니다.
Walk with us!
기술이 세상을 더 아름답게 할 수 있다고 믿으신다면, 휴먼스케이프와 함께 소중한 뜻을 펼칠 수 있습니다.
함께 걸어가며 성장하실 분, 언제든지 연락해주세요 :)