Skip to content
isdnetworks
Go back

두 갱신이 서로를 기다렸다

체결 처리가 가끔 실패했다. 재시도하면 되니 넘어가고 있었는데 로그를 보니 같은 오류였다.

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 = 812member_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 기다림

두 요청이 정확히 반대 순서로 같은 두 행을 잡으면 서로를 기다리게 된다.

subadd 를 그 순서로 부르는 것 자체는 어느 쪽에서 읽어도 이상한 데가 없다. 순서를 요청이 정하게 두었다는 것만이 문제였다.

조치 — 순서를 고정했다

잠그는 순서를 값에 상관없이 하나로 정했다.

$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);
}

usortmemberNocoin 을 정렬하니 어느 요청이든 작은 쪽을 먼저 잠근다.

두 요청이 같은 두 행을 건드려도 순서가 같으면 하나가 기다릴 뿐이다. usort 한 줄이 하는 일이 그것이었다.

제약 — 순서만으로는 다 안 막힌다

같은 트랜잭션 안에서 다른 표도 고치고 있었다.

$this->balance->move(...);
$this->order->updateFilled(...);
$this->tradeLog->insert(...);

balanceorderstrade_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); }

커밋한 뒤에 보내니 알림이 실패해도 체결은 남는다. 잠금을 오래 쥐지 않는 것이 순서를 맞추는 것만큼 효과가 있었다.

정리


Share this post on:

Previous Post
전파가 환불에 매달려 있었다
Next Post
두 가지를 한 호출 하나