글 작성자: Key Ryung

개요

Scale-Out을 위한 사전 작업을 진행하기 전에 구매 API를 정상화할 필요성이 있었습니다.

Pinpoint를 통해 확인한 CannotAquireLockException

 

이전 #1[구매주문] 에서 다뤘듯이 같은 품목 옵션을 많은 사람들이 동시에 구매하는 경우 DeadLock이 발생하며 CannotAquireLockException이 발생했습니다. 그리고 쿼리 최적화에서 인덱스가 영향을 줄 수 있을지 몰랐다는 부분과 연관됩니다. 정확히는 제약 조건이 어떤 영향을 줄지 몰랐다는 점입니다.

[#2] 쿼리 최적화 내용 중 일부

그럼 어떻게 DeadLock의 원인과 해결했는지에 대한 설명을 진행해보겠습니다.

 

어디서 DeadLock이 발생할까?

Pinpoint를 사용하여 StackTrace를 볼 수 있어 문제의 실마리는 쉽게 찾을 수 있었습니다. MySQL에서 DeadLock이 발견되어 Exception이 발생하게 됩니다.

MySQL에서 DeadLock이 발견되어 Exception 발생하는 StackTrace

재고보다 더 많이 구매하게 되면 서비스적인 측면에서도 비용이 발생하고 재고 검증을 안하고 주문이 성공했지만 후에 실제 재고가 부족하여 주문이 취소된다면 사용자 관점에서는 서비스에 대한 인식이 안 좋아질 수 있으므로 재고 검증 과정을 넣었습니다.

구매 주문과정과 대응되는 SQL

재고 검증부터 재고 차감까지 하나의 Transaction으로 묶여 동작하도록 @Transactional 어노테이션을 사용했습니다. 구매 주문 과정과 대응되는 SQL은 MySQL에 로깅된 쿼리를 그대로 옮겨왔습니다. 쿼리 로그를 확인한 직후에 예상되는 DeadLock 발생 지점은 2가지로 생각되었습니다.

 

  • Purchase Order와 Purchase Order Item을 삽입할 때 
  • Product의 재고를 차감할 때

 

사용하는 MySQL InnoDB Storage Engine은 Record 기반의 Lock으로 동작하고 이 Lock은 Index에 걸리게 되므로 쿼리 최적화에서 생성한 Index로 인해 문제가 발생할 수도 있겠다는 막연한 가정을 했었습니다. 추가적인 실마리를 더 획득하기 위해 Local에 같은 환경을 구성한 뒤에 2개의 Transaction을 차례로 실행해 나갔습니다.

 

// (1)
START TRANSACTION;

// (2)
SELECT * FROM product_options WHERE id = 89000;

// (3)
INSERT INTO purchase_orders (buyer_id, purchase_order_status, created_at, updated_at) VALUES (2, 'ACCEPTED', current_timestamp(), current_timestamp());

// (4)
INSERT INTO purchase_order_items (purchase_order_id, product_option_id, purchase_order_amount, created_at, updated_at)
VALUES (2, 89000, 10, current_timestamp(), current_timestamp());

// (5)
UPDATE product_options SET product_id = 29667, product_size='free', product_count = 50 WHERE id = 89000;

// (6)
COMMIT;

 

테스트 환경과 완전히 동일하게 테스트하기 위해서는 동시에 쿼리를 진행하면 좋았겠지만 결국 Transaction의 Atomic, Isolated한 특성을 Database가 보장해주므로 2개의 Transaction의 쿼리를 번갈아 실행하더라도 똑같은 현상이 일어날 것이라 예상했습니다.

 

혹시 예상되는 부분이 있으신가요? 

 

DeadLock은 하나의 Transaction이 (4) 실행 뒤, 다른 하나의 Transaction이 (5)를 실행하며 발생했습니다.

 

왜 DeadLock이 발생할까?

더 쉽게 설명 하기 위해 모식도를 정리했습니다.

DeadLock 발생과정 모식도

Transaction을 각각 Ta, Tb로 표기하겠습니다. Ta가 Shared-Lock을 구매 주문 물품을 등록하며 획득합니다. Tb 또한 Shared-Lock을 구매 주문 물품을 등록하여 획득합니다. Shared-Lock은 서로 호환 가능하므로 서로의 Lock이 해제되지 않아도 쿼리가 수행됩니다. 다음으로 재고 차감하는 부분에서 Update 쿼리 사용 시에는 Exclusive Lock이 필요합니다. Exclusive Lock은 Shrared-Lock, Exclusive-Lock 모두 충돌하므로 다른 Lock들이 모두 해제된 상태에서 획득될 수 있습니다.

Lock 간의 호환여부 표

Tb는 따라서 Ta의 S-Lock이 해제되길 기다리고 있고 Ta는 다시 X-Lock을 획득하기 위하여 기다리게 됩니다.

 

이 모든 DeadLock의 시작은 Foreign Key 제약 조건으로부터 시작합니다.

If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. InnoDB also sets these locks in the case where the constraint fails.

FOREIGN KEY 제약 조건은 테이블에 정의되며, 제약 조건을 확인해야 하는 어떤 삽입, 수정, 삭제는 제약 조건을 확인하기 위해 해당 Record에 Shared-Lock 수준 잠금을 설정합니다. InnoDB의 경우 제약 조건이 실패하는 경우에도 이러한 잠금을 설정합니다. 

https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html

 

MySQL :: MySQL 8.0 Reference Manual :: 15.7.3 Locks Set by Different SQL Statements in InnoDB

15.7.3 Locks Set by Different SQL Statements in InnoDB A locking read, an UPDATE, or a DELETE generally set record locks on every index record that is scanned in the processing of an SQL statement. It does not matter whether there are WHERE conditions in

dev.mysql.com

참조 무결성을 위해, 즉 데이터의 정합성을 위해 외래 키 제약 조건을 무심코 사용함으로써 DeadLock을 발생시켰습니다.

 

어떻게 DeadLock을 해결할까?

DeadLock의 발생 조건 중 하나라도 끊어낸다면 Dead Lock을 해결할 수 있습니다. 저는 이 중에서 점유 대기(Hold-And-Wait)와 순환 대기(Circular Waiting)를 끊어내는 방식으로 DeadLock 해결을 진행했습니다. 2개의 Dead Lock 발생 조건을 끊어내는 방법에 2가지 선택지가 있었습니다.

 

  • S-Lock을 없애기 위해 외래키 제약 조건 삭제
  • Product Option에 Intention Lock(SELECT... FOR UPDATE) 적용

 

외래 키 제약 조건을 삭제하면 S-Lock이 사라지고 X-Lock만 필요하므로 점유 대기, 순환 대기 조건이 충족하지 못합니다. 혹은 Intention Lock을 Product Option에 적용하면 한 번에 하나의 Transaction만 해당 물품을 구매 주문을 진행할 수 있으므로 또한 위 조건을 충족하지 못합니다. 2가지의 해결방안 중 저는 외래 키 제약 조건 삭제를 통해 문제를 해결했습니다.

 

이유는 현재도 목표 성능에 다다르지 못한 상태에서 Intention Lock을 적용한다면 TPS 성능이 더 안 좋아질 것이라 예상한 부분과 추후 확장을 위해 데이터 베이스의 다중화를 고려했을 때 외래 키 제약 조건으로 확장이 어려워질 것이라 판단했습니다.

 

다시 Test

DeadLock에 대한 해결책을 적용하고 실험을 다시 진행했습니다.

왼쪽 - 다품목 구매 주문, 오른쪽 - 단품목 구매 주문

  DeadLock 개선 전 TPS DeadLock 개선 후 TPS
다품목 구매 주문 441.4 1175.4
단품목 구매 주문 393 435.6

다품목 구매 주문은 2.66배 성능이 향상 되었고 단품목 구매 주문 1.11배 성능이 향상되었습니다. 외래 키 제약 조건만 삭제했을 뿐인데 왜 성능이 향상되었을지와 다품목에 비해 단품목은 왜 성능 향상 폭이 적었는지 고민했습니다.

 

고민 끝에 내린 결론은 DeadLock의 원인을 알아보며 외래 키로 인해 제약 조건을 검사하기 위해 S-Lock이 걸리는 것과 연관지어 생각해보면 `S-Lock 없이 X-Lock만 수행되므로 성능적인 이득을 얻을 수 있었다`입니다.

 

다품목과 단품목 구매 주문에서 차이가 나는 이유는 다품목의 경우 랜덤 하게 구매하는 과정에서 X-Lock을 대기할 경우가 적으므로 S-Lock이 없어짐으로써 생기는 성능적인 이득이 온전히 반영되지만 단품목의 경우 S-Lock이 사라지더라도 같은 물품에 대해서 X-Lock을 대기해야 되므로 성능 향상 폭이 적다고 생각했습니다.

 

[Pinpoint] 왼쪽 - 다품목 구매주문, 오른쪽 - 단품목 구매주문

DeadLock으로 실패한 요청 없이 수행되는 것을 확인할 수 있습니다. Prometheus를 통해 MySQL 서버 모니터링한 결과를 비교해보면 UPDATE 쿼리로 인해 Busy I/O Wait가 발생하는 것을 볼 수 있고 다품목 구매의 경우 Lock이 겹칠 확률이 낮기 때문에 더 많은 쿼리를 수행하고 단품목 구매는 상대적으로 적은 쿼리를 수행하고 있다는 것을 확인할 수 있었습니다.

 

Conclusion

외래키 제약 조건을 통해 데이터 무결성을 보장하려고 했지만 오히려 DeadLock이 발생하는 것을 보며 적용한 제약 조건과 인덱스에 대해 다시 한번 살펴볼 수 있는 계기가 되었습니다.

 

다음 포스팅에서는 드디어 Scale-Out을 위한 사전 준비를 시작할 예정입니다.

 

해당 프로젝트는 네이버 클라우드를 활용하여 진행했습니다.