집계 결과를 갱신할 때 지우고 다시 넣고 있었다. 중간에 멈춘 날 화면이 통째로 비었다.
Table of contents
Open Table of contents
증상 — 지우고 넣고 있었다
갱신하는 자리는 두 줄이었다.
DELETE FROM stat_daily WHERE stat_date = '2018-09-27';
INSERT INTO stat_daily SELECT ... ;
DELETE 와 INSERT 두 줄이라 간단하고 다시 돌려도 결과가 같아 보였다.
문제는 DELETE 뒤 INSERT 전에 멈추면 아무것도 없다는 것이었다. stat_daily 의 갱신에 실패했는데 갱신하기 전 값까지 함께 잃은 것이다.
성공하는 경로만 보고 만들면 실패 경로가 그대로 방치된다. 실패했을 때 이전 상태가 유지되느냐가 이 종류 처리의 기준이었다.
조치 — 한 트랜잭션에 넣었다
먼저 두 쿼리를 한 트랜잭션으로 감쌌다.
$db->transaction(function () use ($date) {
$db->query("DELETE FROM stat_daily WHERE stat_date = ?", [$date]);
$db->query("INSERT INTO stat_daily SELECT ...", [$date]);
});
중간에 실패하면 ROLLBACK 되어 지운 것도 함께 되돌아간다.
이 한 줄로 소실 자체는 대부분 해결됐다. 그런데 집계 자체가 오래 걸려서 그동안 stat_daily 가 잠겨 있었고 화면에서 그 표를 읽는 것이 밀렸다.
설정 — 새로 만들고 바꿔치기
전체를 다시 만들 때는 방식을 아예 바꿨다.
CREATE TABLE stat_daily_new LIKE stat_daily;
INSERT INTO stat_daily_new SELECT ...;
RENAME TABLE stat_daily TO stat_daily_old, stat_daily_new TO stat_daily;
DROP TABLE stat_daily_old;
stat_daily_new 에 다 만들고 나서 이름만 바꾸는데 RENAME TABLE 은 순식간이다.
만드는 동안 옛 표가 그대로 서비스한다. 실패하면 새 표만 버리면 되고 읽는 쪽은 영향을 안 받는다.
RENAME TABLE 에 둘을 같이 쓴 것은 그 사이에 stat_daily 라는 이름이 없는 순간을 만들지 않기 위해서였다.
선택지 — 일부만 갱신하는 경우
하루치만 갱신할 때는 표를 통째로 바꿀 수 없었다.
INSERT INTO stat_daily (stat_date, ...)
VALUES (?, ...)
ON DUPLICATE KEY UPDATE order_count = VALUES(order_count), amount = VALUES(amount);
ON DUPLICATE KEY UPDATE 로 덮어쓰므로 실패해도 원래 값이 남는다.
유일 인덱스가 있어야 동작한다.
ALTER TABLE stat_daily ADD UNIQUE KEY uk_date (stat_date);
uk_date 로 어떤 값이 같으면 같은 행인지가 정의된다. 중간에 멈춰도 처리된 날짜까지는 갱신되고 나머지는 옛 값이 그대로 남는다.
주의 — 이상한 값을 거르는 검사
덮어쓰기 전에 새 값이 말이 되는지 봤다.
$new = $this->calculate($date);
$old = $this->repo->get($date);
if ($old && $new['order_count'] < $old['order_count'] * 0.5) {
log_error("집계 결과가 이전의 절반 미만입니다. 갱신하지 않습니다.");
notify("집계 확인 필요: {$date}");
return;
}
$this->repo->upsert($date, $new);
order_count 가 갑자기 절반 아래로 줄면 원본에 문제가 있을 수 있어 upsert 를 건너뛴다.
실제로 한 번 걸렸는데 원본 표의 일부가 아직 안 들어와 있었다. 그대로 덮었으면 옛 값도 함께 잃었을 것이다.
바꾸기 전 값도 남기게 했다.
INSERT INTO stat_daily_history (stat_date, order_count, amount, replaced_at)
SELECT stat_date, order_count, amount, NOW() FROM stat_daily WHERE stat_date = ?;
stat_daily_history 에 replaced_at 과 함께 바뀐 이력이 쌓인다.
지난주에 본 숫자와 다르다는 말이 나왔을 때 이것으로 확인했다. 원본이 나중에 정정돼서 집계가 바뀐 것이었고 이력이 있어 언제 무엇이 바뀌었는지 설명할 수 있었다.
검증 — 일부러 만든 중간 실패
바꾼 방식이 실제로 다른지 확인했다.
if (getenv('FAIL_AFTER_DELETE')) { throw new RuntimeException('시험용 실패'); }
FAIL_AFTER_DELETE 를 걸어 지운 직후에 실패하게 만들었다.
옛 방식에서는 그 자리에서 데이터가 없어졌고 바꾼 방식에서는 옛 값이 그대로 있었다. 이것을 안 해 봤으면 트랜잭션이 제대로 걸렸는지도 몰랐을 것이다.
정리
- 지우고 다시 넣는 방식은 중간에 멈추면 아무것도 없다
- 갱신 실패가 그대로 데이터 소실이 된다
- 성공 경로만 보고 만들면 실패 경로가 방치된다
- 한 트랜잭션에 넣으면 되돌아가지만 그동안 잠긴다
- 전체를 다시 만들 때는 새로 만들고 이름을 바꾼다
- 만드는 동안 옛 표가 서비스한다
- 일부만 갱신할 때는 지우지 말고 덮어쓴다
- 덮어쓰려면 유일 인덱스가 필요하다
- 새 값이 이상하면 덮지 않고 이전 값과 비교해 판단한다
- 바꾸기 전 값을 이력으로 남긴다
- 일부러 중간에 실패시켜 바꾼 방식이 다른지 확인한다