체결 처리가 가끔 실패했다. 재시도하면 되니 넘어가고 있었는데 로그를 보니 같은 오류였다.
Table of contents
Open Table of contents
증상 — 같은 오류로 실패하던 체결
로그에 남은 것은 이 줄이었다.
SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock
하루에 몇 건씩이라 재시도로 넘기고 있었는데 모아 보니 전부 40001 이었다.
간헐적이라는 말이 원인이 없다는 뜻은 아니고 Deadlock found 가 반복된다면 같은 자리에서 나고 있을 가능성이 크다.
원인 — 부딪힌 두 트랜잭션
교착이 나면 InnoDB 가 한쪽을 죽이고 마지막 것을 남긴다.
SHOW ENGINE INNODB STATUS\G
*** (1) TRANSACTION:
UPDATE balance SET amount = amount - 100 WHERE member_no = 812 AND coin = 'A'
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index PRIMARY of table `balance` trx id 88213 lock_mode X
*** (2) TRANSACTION:
UPDATE balance SET amount = amount - 3 WHERE member_no = 415 AND coin = 'B'
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index PRIMARY of table `balance` trx id 88214 lock_mode X
서로 다른 표가 아니라 balance 한 표의 다른 행에서 났다.
member_no = 812 와 member_no = 415 가 서로 상대의 행을 기다리고 있었다. 여러 표가 얽혀야 난다고 생각했는데 같은 표의 두 행만으로도 충분했다.
코드 쪽의 잠금 순서
체결은 파는 쪽과 사는 쪽의 잔액을 함께 고친다.
$this->balance->sub($order->memberNo, $order->coin, $qty);
$this->balance->add($match->memberNo, $match->coin, $qty);
sub 가 먼저 돌고 add 가 나중에 도는데 누가 주문이고 누가 상대인지는 요청마다 다르다.
요청 A: 812 잠그고 → 415 기다림
요청 B: 415 잠그고 → 812 기다림
두 요청이 정확히 반대 순서로 같은 두 행을 잡으면 서로를 기다리게 된다.
sub 와 add 를 그 순서로 부르는 것 자체는 어느 쪽에서 읽어도 이상한 데가 없다. 순서를 요청이 정하게 두었다는 것만이 문제였다.
조치 — 순서를 고정했다
잠그는 순서를 값에 상관없이 하나로 정했다.
$targets = [
[$order->memberNo, $order->coin, -$qty],
[$match->memberNo, $match->coin, +$qty],
];
usort($targets, fn($a, $b) => [$a[0], $a[1]] <=> [$b[0], $b[1]]);
foreach ($targets as [$memberNo, $coin, $delta]) {
$this->balance->move($memberNo, $coin, $delta);
}
usort 로 memberNo 와 coin 을 정렬하니 어느 요청이든 작은 쪽을 먼저 잠근다.
두 요청이 같은 두 행을 건드려도 순서가 같으면 하나가 기다릴 뿐이다. usort 한 줄이 하는 일이 그것이었다.
제약 — 순서만으로는 다 안 막힌다
같은 트랜잭션 안에서 다른 표도 고치고 있었다.
$this->balance->move(...);
$this->order->updateFilled(...);
$this->tradeLog->insert(...);
balance 와 orders 와 trade_log 사이에도 순서가 필요해서 코드에 적어 뒀다.
/* 잠금 순서: balance → orders → trade_log.
이 순서를 바꾸면 다른 트랜잭션과 교착이 난다. */
주석만으로는 지켜지지 않으므로 트랜잭션을 여는 자리를 한 곳으로 모으고 그 안에서만 이 순서를 밟게 했다.
그래도 lock_mode X 가 인덱스 범위에 걸려 부딪히는 경우가 남는다. 코드가 잡는 행만으로 잠금 범위가 정해지는 것이 아니라서 순서를 맞춰도 0이 되지는 않는다.
대응 — 선별 재시도와 계측
Deadlock 을 없애는 대신 나는 것을 전제하고 다루기로 했다.
for ($i = 0; $i < 3; $i++) {
try {
return $this->db->transaction(fn() => $this->settle($order, $match));
} catch (PDOException $e) {
if ($e->getCode() !== '40001') { throw $e; }
usleep(random_int(10, 50) * 1000);
}
}
throw new SettleFailed('교착으로 3회 실패');
40001 일 때만 다시 하고 다른 오류는 그대로 올린다.
모든 오류를 재시도하면 잘못된 요청까지 세 번씩 보내게 된다. 기다리는 시간을 무작위로 둔 것은 둘이 같은 간격으로 다시 부딪히지 않게 하려는 것이다.
재시도로 넘기면 늘어나는 것을 모르므로 settle.deadlock 으로 횟수를 셌다.
Metrics::inc('settle.deadlock');
재시도가 성공하면 그 실패는 밖에서 안 보이니 숫자가 없으면 늘어난 것도 모른다. 이 수가 늘면 잠금 범위가 넓어졌거나 유입이 바뀐 신호다.
트랜잭션 안에 외부 호출이 있는 자리도 있었다.
$this->db->beginTransaction();
$this->balance->move(...);
$this->notifier->send($order->memberNo, '체결'); // ← 외부 호출
$this->db->commit();
notifier->send 가 느려지면 그동안 잠금이 유지되므로 밖으로 뺐다.
$this->db->transaction(function () use (&$events) {
$this->balance->move(...);
$events[] = new Filled($order->no);
});
foreach ($events as $e) { $this->notifier->handle($e); }
커밋한 뒤에 보내니 알림이 실패해도 체결은 남는다. 잠금을 오래 쥐지 않는 것이 순서를 맞추는 것만큼 효과가 있었다.
정리
- 교착은 잠그는 순서가 서로 반대일 때 난다
- 여러 표가 얽히지 않아도 같은 표의 다른 행만으로 난다
- 엔진이 남긴 마지막 교착을 먼저 읽는다
- 순서를 요청이 정하게 두면 반대로 잡는 조합이 생긴다
- 잠금 순서를 값에 상관없이 하나로 고정한다
- 표 사이에도 순서를 정하고 트랜잭션 여는 자리를 한곳에 모은다
- 범위 잠금 때문에 순서를 맞춰도 0이 되지는 않는다
- 그 오류 코드만 재시도하고 다른 오류는 그대로 올린다
- 대기를 무작위로 줘야 같은 시점에 다시 안 부딪힌다
- 재시도가 삼키기 전에 횟수를 센다
- 트랜잭션 안에 외부 호출을 두지 않고 커밋 뒤에 보낸다