상품 복제 작업이 도는 동안 카테고리 편집 화면이 멈춰서 잠금 경합으로 보였다. 어느 코드가 categories 를 잠그는지부터 찾아야 했다.
Table of contents
Open Table of contents
배경 — 갱신 코드가 0건인데 멈추는 화면
멈추는 화면이 건드리는 테이블을 확인하고 grep -rn "categories" | grep -i "update\|insert" 로 갱신하는 코드를 전부 검색했지만 문제의 작업 쪽에서는 한 건도 나오지 않았다. 그 작업은 그 테이블을 고치는 코드를 갖고 있지 않았다.
그런데 실제로는 그 작업이 도는 동안에만 화면이 멈추므로 시간 상관관계는 분명했다. 코드에는 없고 현상에는 있는 상태에서 조사가 막혔다.
주의 — 시간 상관관계를 인과로 단정한 보고
앞선 장애 보고서에는 그 작업이 카테고리 테이블을 갱신하면서 배타 잠금을 걸었다고 원인이 적혀 있었다. 그러나 코드 어디에도 그 갱신이 없으므로 그 문장은 관측이 아니라 추론이었다.
그것을 멈추니 풀렸다는 관측에서 그것이 갱신해서 잠갔다는 인과로 한 단계를 건너뛴 서술이었다. 시간 상관관계는 무엇이 관여했는지는 말해 주지만 어떤 방식으로 관여했는지는 말해 주지 않는다.
원인 — 제약조건이 거는 암묵적 잠금
검토를 받으면서 외래키 제약이 거는 잠금이 아니냐는 지적을 받고 SHOW CREATE TABLE 로 관계를 확인했다. product_categories 가 categories 를 참조하는 FOREIGN KEY 가 실제로 걸려 있었다.
MySQL 매뉴얼대로 외래키 제약이 있으면 제약 검사가 필요한 INSERT 가 검사 대상 부모 행에 공유 레코드 잠금을 걸고 그것이 커밋까지 유지된다. 삽입 한 번이 부모 행을 잠그는 것인데 그 코드에는 카테고리를 갱신하는 문장이 한 줄도 없다.
검증 — 잠금을 걸고 나서 하는 긴 작업
작업 구조를 보니 DB::transaction 안에서 product_categories 를 먼저 넣고 그다음에 product_options 수십 개를 복제하고 있었다. 잠금을 먼저 잡은 뒤에 오래 걸리는 일을 하는 순서였다. 고치는 길은 두 가지가 있었는데 옵션 복제를 트랜잭션 밖으로 빼는 것과 순서만 바꿔 카테고리 연결을 마지막에 넣는 것이다. 뒤쪽을 골랐다. 이유는 트랜잭션을 쪼개면 옵션이 반만 복제된 상품이 남을 수 있어서다. 대신 순서 변경이 다른 참조 무결성 검사 순서에 영향을 주는지 한 번 더 검증해야 하는 것을 감수했다.
그동안 다른 쪽에서 그 카테고리를 편집하려 하면 X 잠금을 요청하는데 S 와 X 는 함께 잡히지 않으므로 대기한다. 많이 쓰이는 카테고리일수록 그 충돌이 자주 일어난다.
조치 — 진단 순서와 결함의 개수
이후로 잠금 경합을 볼 때 갱신 코드를 먼저 찾고 안 나오면 information_schema.KEY_COLUMN_USAGE 로 그 테이블을 참조하는 외래키를 확인하고 마지막으로 트랜잭션 범위를 보는 순서로 진행한다. 두 번째 단계를 빼면 이런 종류의 원인은 검색으로 끝까지 안 나온다.
파고 보니 독립된 결함이 셋이었는데 잠금 뒤의 긴 작업이라는 구조 문제와 그 안의 무거운 조회라는 성능 문제와 실제로 안 바뀌는 값이 변경으로 인식되는 표면 문제였다. 앞선 보고서는 세 번째만 원인으로 지목하고 있었고 표면에서 멈추면 구조가 안 보인다.
정리
- 제약조건이 거는 잠금은 코드 검색으로 안 잡힌다
- 연결 테이블에 삽입하면 참조되는 부모 행이 잠긴다
- 그 잠금은 커밋까지 유지된다
- 시간 상관관계는 관여 여부만 말하고 방식은 말하지 않는다
- 잠금을 먼저 잡고 긴 작업을 하면 그 시간만큼 대기가 생긴다
- 진단은 갱신 코드와 외래키 관계와 트랜잭션 범위 순으로 본다
- 결함이 여럿일 수 있고 표면에서 멈추면 구조가 안 보인다
- 검토를 받으면 알고는 있던 지식이 이 상황과 연결된다