거래 정지를 걸면 그 종목의 미체결 주문을 전부 취소해야 했다. 정지 요청은 한 번인데 취소 대상은 수천 건이었다. 처음에는 그 요청 안에서 전부 처리하려고 했다.
Table of contents
Open Table of contents
원인 — 한 요청에서 다 하려던 문제
처음에는 suspend() 안에서 findOpen 을 돌며 하나씩 cancel 하고 있었다.
public function suspend(string $symbol): void {
$this->market->updateState($symbol, 'SUSPENDED');
foreach ($this->orders->findOpen($symbol) as $o) {
$this->orders->cancel($o->no);
}
}
미체결이 8,000건이라 요청이 시간 초과로 끊겼고 정지는 됐는데 주문은 반만 취소된 채로 남았다.
게다가 취소한 행마다 걸린 InnoDB 배타 잠금이 COMMIT 까지 유지돼서 다른 처리까지 함께 밀렸다. 요청 하나가 감당할 수 있는 크기를 이미 넘어선 것이었다. 상태 변경 자체는 한 번인데 그것이 만드는 일이 그만큼 많은 구조였다.
할 일을 만들어 두는 방식
그래서 updateState 와 createBulk 까지만 한 트랜잭션에 넣고 실제 취소는 워커가 하게 바꿨다.
$this->db->transaction(function () use ($symbol) {
$this->market->updateState($symbol, 'SUSPENDED');
$this->tasks->createBulk('CANCEL_OPEN_ORDER', $this->orders->openIds($symbol), [
'symbol' => $symbol,
'reason' => 'MARKET_SUSPENDED',
]);
});
그러면 요청은 금방 끝나고 취소는 뒤에서 차례로 진행된다.
job 이 행으로 남으니 중간에 끊겨도 어디까지 했는지가 그대로 남는다. 다시 시작하면 남은 것부터 이어서 처리한다. 요청 안에서 다 하는 것과 목록을 만들어 두는 것의 차이가 이 안정성이었다.
진행을 볼 수 있게 했다
전에는 취소가 끝났는지 알 방법이 없어서 화면을 계속 새로 고치고 있었다. task 가 행으로 남으니 state 로 GROUP BY 하면 진행률이 나온다.
SELECT state, COUNT(*) FROM task
WHERE type = 'CANCEL_OPEN_ORDER' AND ref = 'BTC' GROUP BY state;
DONE 6,204
PENDING 1,796
FAILED 0
화면에도 「거래 정지: 진행 중 (6,204 / 8,000)」으로 냈고 기다리는 사람이 그동안 다른 일을 할 수 있다. 진행을 보여 주는 것이 처리 속도를 바꾸지는 않지만 체감을 크게 바꿨다. 그리고 그 값이 그대로 완료를 판정하는 근거로도 쓰였다.
끝났다는 기준
무엇을 끝난 것으로 볼지도 정해야 했다.
public function isSuspendComplete(string $symbol): bool {
return $this->tasks->countPending('CANCEL_OPEN_ORDER', $symbol) === 0
&& $this->orders->countOpen($symbol) === 0;
}
countPending 이 0인 것과 countOpen 이 0인 것은 다른 상태라 둘을 다 봤다.
countPending 이 0인데 countOpen 이 남아 있으면 처리 중에 새 주문이 들어온 것이다. 그래서 만들어진 task 수를 함께 남겨서 0건이면 그 사실이 드러나게 했다. 0건인 것이 정상인지 조건이 잘못된 것인지를 그 값으로 갈랐다.
순서와 중복
상태를 먼저 바꾸고 할 일을 만드는 순서가 중요했다. 반대로 하면 그 사이에 들어온 새 주문이 취소 대상에서 그대로 빠진다.
먼저 막고 나서 이미 있는 것을 정리하는 순서여야 새는 것이 없다. 같은 정지를 두 번 걸어도 할 일이 두 배가 되지 않도록 이미 만들어진 것이 있으면 건너뛰게 했다. 실패한 것을 다룰 때도 진짜 실패와 그 사이에 이미 체결되어 취소할 필요가 없어진 것을 나눴다.
정리
- 한 번의 상태 변경이 여러 건의 작업을 만들 수 있다
- 그런 것을 한 요청 안에서 다 하지 않는다
- 상태
UPDATE와jobINSERT까지만 한 트랜잭션에 묶는다 job이 행으로 남으면 끊겨도 이어서 처리한다- 상태별로
GROUP BY해COUNT(*)하면 진행률이 나온다 - 할 일이 없는 것과 대상이 없었던 것은 다르다
- 상태를 먼저 바꿔야 그 뒤에 들어오는 것이 막힌다
- 진짜 실패와 할 필요 없어진 것을 나눈다