테마
13화 · 안전 조치의 대가
5월 27일 월요일, 지난주에 내가 낸 처방이 주말에 장애로 돌아왔다. 오늘은 두 번 틀린다. 그리고 지난주에 비워 둔 칸 하나를 채운다.
오전 9시 05분
월요일 아침이면 늘 하듯 리비전 폴더부터 열었다.
Q1_rev03 Q2_rev01 Q3_rev01 Q4_rev03 N1_rev01다섯 조각은 이미 판이 있었다. 아직 판이 없는 조각이 하나 남아 있었다. 야간 정산 적재 배치, P1이다. 01화에서 "3번 고친다 (13·14·15화)"라고 적어 두기만 한 조각이다. 오늘 그 첫 판인 P1_rev00을 꺼냈다. 본문은 커서 FOR 루프 하나였는데, 그 가운데 한 줄이 눈에 띄었다.
sql
IF MOD(n, 1000) = 0 THEN COMMIT; END IF; -- 트랜잭션은 짧게 (2020.07 강도현)그 위에는 커밋 로그가 달려 있었다. 01화에서 한 번 본 로그였다.
2020-07-22 hd.kang "야간 배치 중 UNDO 부족 장애. 트랜잭션 분할."한 번에 커밋했다가 언두가 터져서 1,000건마다 끊었다는 얘기다. 이 조각은 쉬워 보였다. 12화에서 우리가 정산 보정 배치에 "트랜잭션을 짧게"라고 처방했으니, P1은 이미 그렇게 짜여 있는 셈이었다. P1은 모범생이네. 그렇게 생각하며 창을 닫으려는데 메신저가 울렸다. 오과장이었다.
오과장 09:02
주말 보정 배치가 토요일 새벽에 죽었어요. 에러가 이거예요.
ORA-01555: snapshot too old
지난주에 말한 대로 1,000건마다 커밋으로 바꿨는데… 바꾸고 나서 처음 돌린 날 죽었어요.그 줄을 두 번 읽었다. 1,000건마다 커밋하도록 바꾼 건 지난주에 내가 포스트모템에 적은 재발방지책이었다. 12화에서 정산 화면이 10분 동안 멈췄을 때 나는 "배치는 트랜잭션을 짧게: N건마다 COMMIT"이라고 썼고, 오과장은 "P1처럼 1,000건 단위로 하겠다"고 답했다. 적힌 그대로 했는데 죽은 것이다.
선배가 커피를 들고 왔다가 화면을 보더니 옆에 앉았다.
"처방 때문에 장애가 났네."
"제가 쓴 거예요. 그 칸."
"알아. 그래서 오늘은 그걸 재는 날이야." 선배가 말했다. "그 전에 하나 묻자. ORA-01555가 무슨 뜻인 것 같아?"
"스냅샷이 너무 오래됐다… 언두가 모자란 거 아니에요?" 나는 방금 본 커밋 로그를 가리켰다. "강도현 씨도 언두가 모자라서 트랜잭션을 쪼갰잖아요. 그 2020.07 커밋이요. 언두를 늘리면 되죠."
"재 봐." 선배가 말했다. "두 개를 재. 한 번에 커밋하는 배치하고, 1,000건마다 커밋하는 배치. 작은 언두에서."
"작은 언두요? 언두를 줄여요?"
"01555를 빨리 보려면 과거가 금방 사라지게 해야 해. 그러려면 언두를 작게 줄여야 하고." 선배가 손가락을 하나 세웠다. "근데 그건 인스턴스 설정이야. 테이블 하나 바꾸는 거랑은 달라. 규칙 하나만 지켜."
"뭘요?"
"바꾸기 전 값부터 적어. 그다음에 바꿔. 다 끝나면 되돌리고, 되돌아갔는지 확인해." 선배가 또박또박 말했다. "지금 언두는 UNDOTBS1에 retention 900이야. 스크립트 맨 앞에 이 값을 적어 두고, 맨 끝에서 이 값으로 돌려놓고, 마지막 줄에서 '정말 900이 됐나'를 다시 조회해. 그 조회 결과를 보기 전까진 끝난 게 아니야."
"귀찮은데요."
"연습 DB라서 봐주는 거야. 운영이었으면 언두를 줄이자는 말을 꺼낸 순간 내가 막았어." 선배가 말했다. "그리고 이건 너 좋으라고 하는 거야. 작게 줄여 놓고 원복을 깜빡하면, 다음 주에 다른 실습을 하다가 멀쩡한 쿼리가 01555로 죽어. 그럼 또 하루를 날리는 거지."
그래서 SYSDBA 스크립트 맨 끝에 원복 문장과 원복 확인 쿼리부터 적어 두고 시작했다.
오전 9시 40분 — 반대쪽의 두 장애
운영은 건드리지 않는다. 연습 DB에 정산 복사본을 뜨고 언두를 일부러 작게 줄였다. 그리고 보정 배치를 두 가지로 돌렸다. 하나는 맨 끝에서 한 번만 커밋하고, 다른 하나는 1,000건마다 커밋한다.
한 번만 커밋한 쪽이 먼저 죽었다.
[A] 한 번 COMMIT: 42232 행째에서 죽음 -> ORA-30036: unable to extend segment by 8 in undo tablespaceORA-30036. 언두를 더 늘릴 수 없다는 에러였다. 42,000행쯤에서 죽었다. 여기까지는 예상대로였다.
"이게 강도현 씨가 겪은 거예요. 언두 부족. 한 번에 다 하려니까 언두가 꽉 찬 거죠."
"맞아. 큰 트랜잭션은 자기가 쓴 언두를 끝까지 쥐고 있어. 커밋해야 놓거든. 10만 건을 한 트랜잭션에 넣으면 언두가 넘치는 거지." 선배가 고개를 끄덕였다. "그래서 강도현 씨가 쪼갠 거야. 그때는 옳았어. 그럼 1,000건마다 커밋한 건?"
[B] 1000 건 COMMIT: ... ORA-01555: snapshot too old: rollback segment ... too smallORA-01555. 같은 작은 언두인데, 이번엔 30036이 아니었다.
"어… 다른 에러네요. 1,000건마다 커밋했는데도 죽었어요. 언두를 늘리면…"
"늘려 봐. 되나."
언두를 조금 늘렸다. 30036은 사라졌다. 그런데 01555는 그대로였다. 언두를 늘려도 01555는 풀리지 않았다.
"……왜 안 풀려요? 언두가 부족한 거면 늘리면 풀려야 하는데."
"30036하고 01555는 반대쪽 장애야." 선배가 종이를 당겼다. "30036은 '지금 쓸 언두 공간이 없다'는 거야. 그러니 늘리면 돼. 01555는 '과거의 언두가 이미 사라졌다'는 거고. 늘려도 이미 사라진 건 안 돌아와." 선배가 둘을 나란히 적었다.
| ORA-30036 | ORA-01555 | |
|---|---|---|
| 모자란 것 | 지금 쓸 언두 공간 | 과거 시점의 언두 |
| 커밋을 덜 하면 | 터진다 | 멀어진다 |
| 커밋을 더 하면 | 멀어진다 | 터진다 |
"봐. 커밋을 자주 하면 30036은 멀어져. 언두를 덜 쥐니까. 네가 지난주에 낸 처방이 그거였어. 30036만 놓고 보면 맞는 처방이야. 그런데 언두를 덜 쥔다는 바로 그 점이 01555를 불러. 커밋하는 순간 네가 쓴 언두는 '이제 재사용해도 되는' 언두가 되거든."
멍해졌다. 안전하자고 한 조치가 나를 반대쪽 절벽으로 떠민 셈이었다.
"그럼 더 자주 커밋하면요? 매 건마다 하면 언두를 훨씬 덜 쥐니까…"
선배가 나를 봤다. "재 봐. 근데 이번엔 다른 것도 같이 봐."
오전 10시 20분 — 더 자주 하면 더 싸지나
이번엔 보정 배치 말고 적재 쪽인 P1을 열었다. P1은 강도현이 이미 1,000건마다 커밋하도록 짜 둔 야간 적재 배치다. 10만 건을 넣으면서 커밋 주기만 바꿔 네 번 돌리고, redo와 undo, 커밋 횟수를 쟀다.
NAME once every_1000 each_row
user commits 3 102 100002
redo size 30023508 30010680 57168116
undo change vector size 6809240 6813516 13620228
LGWR log file parallel write 50 116 651세 열을 한참 봤다.
한 번 커밋(once)과 1,000건 커밋이 거의 똑같았다. redo가 약 3,000만, undo가 약 680만이었다. 1,000건마다 끊는 비용은 거의 보이지 않았다.
그런데 매 건 커밋(each_row)은 redo가 5,700만으로 거의 두 배였다. undo도 1,360만으로 두 배였다. LGWR가 로그를 쓴 횟수는 50에서 651로 13배나 늘었다.
"매 건 커밋하면 두 배로 느려지는데요. 싸지기는커녕."
"그래. 커밋은 공짜가 아니야. 커밋할 때마다 작은 '커밋 레코드'를 redo에 쓰고, 로그를 디스크로 밀어내거든. 100번이면 100번 밀고, 10만 번이면 10만 번 밀어." 선배가 말했다. "그러니까 네 질문에 답하자면, 더 자주 커밋한다고 싸지지 않아. 오히려 비싸져. 그리고,"
선배는 매 건 커밋한 복사본으로 01555도 다시 재 보라고 했다. 더 자주 커밋하니 언두가 더 빨리 풀렸고, 긴 읽기는 더 빨리 01555로 죽었다.
"더 나빠졌네요. 양쪽 다."
"이제 알겠어? 커밋 주기를 조절해서 둘 다 피할 수는 없어. 드물게 하면 30036이고, 자주 하면 01555에 redo 낭비까지 따라와. 둘 사이 어딘가에 답이 있는 게 아니라 애초에 질문이 틀렸어." 선배가 일어섰다. "따라와. 보여 줄 게 있어."
⛳ 오늘은 여기까지(중단 지점 1) 여기까지가 오늘의 절반이다. 30036과 01555가 반대쪽 장애라는 것, 커밋을 자주 해도 싸지지 않고 01555만 가까워진다는 것을 쟀다. 이어서 읽으면 왜 그런지(redo와 undo의 정체)를 보고, 진짜 문제는 커밋 주기가 아니라는 것을 알게 된다.
오전 11시 10분 — 정정 전 사본함
선배가 향한 곳은 이번에도 총무팀이었다. 11화의 결재 서식함과 12화의 발번 대장을 지나니 데스크 옆에 캐비닛이 하나 더 있었다. 라벨에는 "정정 전 사본함"이라고 적혀 있었다.
"회사 장부 알지. 거래가 생기면 장부에 적어. 그런데 이미 적은 걸 고칠 때는 규칙이 있어." 총무팀 과장이 설명해 줬다. "고치기 전에 원래 뭐라고 적혀 있었는지 슬립 한 장에 베껴서 이 함에 넣어. 그러고 나서 장부를 고쳐."
"왜 베껴 둬요?"
"이유가 두 가지야. 하나, 고치다가 중간에 틀리면 그 슬립으로 되돌려. 둘, 내가 장부를 고치는 중에 다른 사람이 장부를 보러 오면 그 사람한테는 아직 고치는 중이니까 '고치기 전 값'을 보여 줘야 하거든. 그때 이 함에서 슬립을 꺼내 보여 줘."
나는 셋째 칸을 짚었다. 되돌리기, 그리고 읽는 사람에게 과거 보여 주기. 그게 언두였다. undo.
"그리고 완료 도장이 있어." 과장이 도장을 들어 보였다. "한 묶음을 다 고치면 여기 도장을 쾅 찍어. 그러면 그 묶음의 슬립은 이제 버려도 돼. 함이 작으니까 도장 찍힌 슬립 자리부터 새 슬립으로 덮어써."
완료 도장이 COMMIT이었다. 커밋하면 그 트랜잭션의 언두 슬립은 '재사용 가능' 상태가 된다.
선배가 끼어들었다. "자, 이제 아까 그 두 장애를 이 함으로 설명해 봐. 함은 작아. 너는 장부 10만 줄을 고치는 중이야."
잠깐 생각했다. "한 번에 다 고치고 도장을 맨 끝에 한 번만 찍으면… 고치는 동안 슬립 10만 장이 함에 쌓여요. 도장을 안 찍었으니까 버릴 수가 없어요. 함이 꽉 차요."
"그게 30036이야. 그럼 1,000줄마다 도장을 찍으면?"
"슬립이 금방금방 버려지니까 함은 안 차요. 그런데…" 나는 말을 멈췄다. "그 사이에 긴 감사가 들어와서 '오늘 아침 시작할 때의 장부'를 처음부터 끝까지 보고 있으면요. 내가 이미 고친 줄을 감사가 보려고 하면 '고치기 전 값' 슬립을 함에서 찾을 텐데…"
"찾을 텐데?"
"제가 도장을 찍어서 그 슬립은 벌써 다른 슬립으로 덮였어요. 감사는 '그 과거는 이제 없습니다'라는 말을 듣게 되고요."
"그게 01555야." 선배가 말했다. "네가 완료 도장을 자주 찍는 바람에, 긴 감사가 봐야 할 과거가 지워진 거야."
(01화 커피 캐리어 = 블록 · 02화 방문객 출입대장 = 인덱스 · 06화 4층 거래처 카드함 = 조인 · 07화 탕비실 픽업 선반 = 버퍼 캐시 · 08화 회식 정산서 = 조인 카디널리티 · 09화 출입대장 포스트잇 = 키 기반 페이징 · 10화 구내식당 식수표 = 히스토그램 · 11화 결재 서식함 = 라이브러리 캐시 · 12화 발번 대장 = enqueue)
오후 1시 00분 — 먼저 적는 일지
점심을 먹고 나니 걸리는 게 하나 있었다. "선배님, 그럼 커밋하면 고친 장부가 디스크에 저장되는 거예요?"
"재 봐. 복사본 하나 고치고 커밋한 다음, 그 블록이 디스크에 내려갔는지 봐."
복사본 M_CUS를 고치고 커밋한 뒤, V$BH로 그 블록들의 상태를 봤다.
DIRTY_AFTER_CTAS 4
DIRTY_AFTER_COMMIT 31커밋했는데도 dirty가 31이었다. 메모리에서는 바뀌었지만 디스크에는 아직 쓰지 않은 블록이 31개라는 뜻이다. 커밋 전이나 후나 그대로였다.
"07화에서 본 그 31이에요. 커밋 뒤에도 dirty라던."
"그래. 그때 '왜 안전한지는 13화에서'라고 했지. 그게 오늘이야." 선배가 다시 총무팀 비유로 돌아갔다. "완료 도장을 찍었는데 장부(데이터 블록)는 아직 책상 위에 있어. 디스크로 안 내려갔지. 그런데 왜 안전할까? 불이 나서 책상이 타도 복구가 되거든. 어떻게?"
"……일지요?"
"그래. 총무팀엔 함만 있는 게 아니야. 출납 일지가 따로 있어. 장부를 고치기 전에 무엇을 어떻게 바꿀지 일지에 먼저 한 줄 적어. 그다음에 장부를 고쳐. 이 순서가 핵심이야. 일지가 먼저, 장부는 나중."
그게 redo였다. 그리고 '일지 먼저'라는 원칙이 WAL(Write-Ahead Logging)이다.
"커밋이 하는 일은 일지의 그 줄까지 디스크에 확실히 밀어내고 '확정' 도장을 찍는 거야. 장부 블록은 안 밀어내. 그건 DBWn이라는 다른 사람이 나중에 한가할 때 내려보내. 그래서 커밋이 빠른 거야. 작은 일지 한 줄만 디스크에 쓰면 되니까. 큰 장부 블록을 통째로 쓸 필요가 없지." 선배가 말했다. "불이 나면? 디스크엔 일지가 있어. 일지를 다시 읽으면서 '이렇게 고쳤었지' 하고 장부를 다시 만들어. 그게 복구야."
"그럼 아침에 잰 건요?" 10만 번 커밋했는데 내 세션의 log file sync는 2번이었던 게 떠올랐다. "커밋마다 일지를 밀어낸다면서, 10만 번 밀어낸 흔적이 왜 2번밖에 없어요?"
"좋은 질문이야. PL/SQL 루프 안에서 커밋하면 오라클이 '다 썼나'를 매번 기다려 주지 않아. 일지를 쓰라고 시켜 놓고 다음 건으로 넘어가. 세션이 실제로 기다리는 건 루프가 끝날 무렵 몇 번뿐이야." 선배가 말했다. "그런데 클라이언트 프로그램이 한 건 넣고 커밋, 또 한 건 넣고 커밋하는 식이면 얘기가 달라. 커밋할 때마다 서버한테 '다 썼지? 응답 줘' 하고 왕복을 기다리거든. 그게 log file sync야. 그래서 같은 10만 번 커밋이라도 어디서 커밋하느냐에 따라 2번이 되기도 하고 10만 번이 되기도 해. 아까 COMMIT WRITE WAIT로 바꿨더니 100번으로 뛰었지? '기다려라'를 강제로 켠 거야."
나는 받아 적었다. 커밋에서 비싼 건 redo 양이 아니라 세션이 디스크를 기다리는 그 왕복이었다.
아까 잰 숫자도 다시 봤다. 한 행을 UPDATE했을 때의 값이다.
redo entries 5 undo change vector size 464 (바이트)"한 행을 고쳤는데 redo도 생기고 undo도 생겨요."
"고칠 때마다 둘 다 적어. 바꾼 내용(redo, 일지)하고 바꾸기 전 값(undo, 사본함 슬립). redo는 앞으로 가는 데(복구, 재현) 쓰고, undo는 뒤로 가는 데(롤백, 과거 읽기) 써. 변경 하나에 변경 벡터가 두 개 생기는 거야." 선배가 말했다. "이 변경 벡터들이 모여서 redo 레코드 하나가 되고, 그게 로그 버퍼를 거쳐 디스크로 가."
더 깊이: 변경 벡터 → redo 레코드 → 로그 파일, WAL과 Fast Commit, undo의 3층 →
2_파고들기.md §1~§3
오후 2시 10분 — 슬립은 어디에 붙어 있나
"12화에서 우리가 남긴 숙제 기억나?" 선배가 물었다. "트랜잭션 이름표. usn, slot, wrap. 그게 가리키는 '칸' 안에 뭐가 있는지는 13화에서 보자고 했지."
12화에서 블로킹 트리를 찍었을 때, 기다리던 세션이 기다린 건 테이블이 아니라 트랜잭션이었다. 그 트랜잭션의 이름표가 usn / slot / wrap이고, 이것은 언두 세그먼트 헤더의 칸(슬롯) 주소다.
"그 칸이 사본함 슬립의 첫 장이야. 언두는 3층으로 돼 있거든." 선배가 SYS 창을 열고, 트랜잭션이 행을 고치고 있는 블록을 하나 덤프했다.
Itl Xid Uba Flag Lck Scn/Fsc
0x01 0x0005.018.0000099a 0x00009bee.011d.0a C--- 0 scn 0x...639338
0x02 0x0004.016.000007f8 0x00009d18.0187.31 --U- 1 fsc 0x...639346"이게 블록 머리의 ITL이야. 05화에서 칸이 둘이라는 것만 보고 넘어간 거. 둘째 줄을 봐. Flag가 --U-지. U는 Uncommitted, 아직 커밋 안 된 트랜잭션이 이 칸을 쥐고 있다는 뜻이야. Lck 1은 이 칸이 이 블록에서 한 행을 잠갔다는 거고."
"Xid 0x0004.016.000007f8은…"
"usn은 4, slot은 0x16이니까 22, wrap은 0x7f8이니까 2040이야. 12화에서 본 그 트랜잭션 이름표지. ITL 칸이 그 트랜잭션을 가리키는 거야. 그리고 Uba는 undo block address야. 사본함에서 이 변경의 슬립이 어느 서랍에 있는지 가리키는 주소지."
선배가 커밋하고 다시 덤프했다. 그 칸의 Flag가 바뀌어 있었다.
0x02 0x0004.016.000007f8 ... C--- 0 scn 0x..."C---, Committed. 그리고 커밋 SCN이 적혔어. 아까 --U-였던 칸이 커밋되면서 '이 시각에 확정됐음'으로 바뀐 거야." 선배가 3층을 정리해 줬다. "맨 위가 언두 세그먼트 헤더의 트랜잭션 테이블 슬롯(usn/slot/wrap)이야. 트랜잭션 하나당 한 칸이지. 그 아래가 블록의 ITL 칸이고, 이 블록을 누가 잠갔는지 적혀 있어. 그 아래가 행의 lock byte인데, 이 행이 ITL 몇 번 칸에 묶였는지 적혀 있어. 세 층이 서로를 가리켜. 그래서 12화에서 10만 행을 잠가도 V$LOCK은 2줄이었던 거야. 락은 블록 안에 적히지, 락 관리자 메모리에 행마다 적히는 게 아니거든."
ITL Flag 읽는 법, 3층 구조, 블록 덤프 꺼내는 법 →
2_파고들기.md §3· 실습06
오후 3시 20분 — 읽었을 뿐인데 일지를 쓴다
"하나 더. 01화에서 이상한 거 하나 있었잖아. 설치 직후 첫 실행은 5,842인데 다시 돌리면 5,827." 선배가 말했다. "그게 지연 클린아웃일 거라고 누가 적어 놨던데. 재 봐."
그것부터 쟀다. T_ORD_BIG을 APPEND로 다시 적재하고, 통계 없이 첫 읽기와 둘째 읽기를 비교했다. 그런데 차이가 없었다. APPEND로 넣은 블록은 처음 읽을 때도 추가 비용이 붙지 않았다.
"어… 지연 클린아웃이 아닌데요? APPEND로 적재하면 블록이 깨끗해요. 첫 실행 5,842의 추가분은 블록 청소가 아니라 첫 파싱이랑 재귀 조회예요."
"좋아. 재 보니까 가설이 뒤집혔네. 그럼 지연 클린아웃은 언제 나는 건데?"
지연 클린아웃은 일부러 만들어야 했다. 한 트랜잭션으로 블록을 많이 고친 뒤, 커밋이 블록을 다 청소하기 전에 그 블록들이 캐시에서 밀려나야 한다. 그 상황을 만들어 놓고 커밋 뒤 첫 SELECT를 쟀다.
NAME first_read second_read
redo size 152888 2076
cleanouts only - consistent read gets 1694 0읽기만 했는데 첫 SELECT가 redo를 15만 바이트나 썼다. 블록도 1,694개를 청소했다. 둘째 SELECT는 거의 0이었다.
"읽었을 뿐인데 일지를 썼어요."
"그게 지연 블록 클린아웃이야. 커밋할 때 블록이 캐시에 있으면 그 자리에서 청소해(commit cleanout). 근데 블록이 너무 많아서 커밋이 다 청소하지 못하거나, 커밋 전에 블록이 캐시에서 밀려나면 '열린 ITL'을 단 채 디스크로 가. 다음에 그 블록을 읽는 사람이 '이 트랜잭션 커밋됐나?' 확인하고, 커밋됐으면 ITL에 커밋 SCN을 적어 줘. 그것도 블록을 고치는 거니까 redo가 생기지." 선배가 말했다. "그래서 밤에 큰 배치가 돌고 난 다음 날 아침에 아무것도 안 바꾼 첫 조회가 유난히 느린 일이 생겨. 읽으면서 청소하는 거야. 01555의 세 번째 원인이기도 해. 청소하려고 ITL을 봤는데 그 트랜잭션의 흔적을 되살릴 언두가 없으면 거기서도 01555가 나거든."
지연 클린아웃과 01555의 원인 3종 →
2_파고들기.md §4·§5
오후 4시 30분 — 그럼 뭐가 문제였나
자리로 돌아와 아침에 본 두 장애, 30036과 01555를 다시 봤다. 커밋을 드물게 해도 터지고 자주 해도 터진다. 선배에게 물었다.
"그럼 1,000건이 아니라 5,000건? 10,000건? 그 사이 어딘가로 맞추면…"
"또 주기 얘기네." 선배가 웃었다. "1,000건 커밋하고 한 번 커밋은 redo랑 undo가 거의 같았지? 끊는 비용은 거의 없었어. 그럼 1,000건 커밋은 왜 01555로 죽었을까? 끊어서가 아니야. 잘 봐 둬. 읽으면서, 고치면서, 커밋했기 때문이야."
선배가 보정 배치의 구조를 짚었다.
sql
-- 보정 배치 (오과장) — fetch across commit
FOR r IN (SELECT ... FROM 정산 ORDER BY ...) LOOP -- 긴 커서: 오늘 시작 시점을 본다
UPDATE 정산 SET ... WHERE ...; -- 그 정산을 고친다
IF MOD(n,1000)=0 THEN COMMIT; END IF; -- 그리고 커밋해서 과거를 풀어 준다
END LOOP;"이 커서는 오늘 아침 시작할 때의 정산을 처음부터 끝까지 읽어. 긴 감사야. 그런데 그 감사가 진행되는 동안 같은 배치가 정산을 고치고 커밋해. 감사가 봐야 할 과거를 감사 자신이 지우고 있는 거야. 이게 fetch across commit이야. 읽는 커서가 도는 동안 그 테이블을 고치면서 커밋하는 것."
연습 DB에서 그 상황을 재현했다. 긴 읽기 커서를 하나 열어 두고, 그동안 등록 화면들이 정산을 계속 고치고 커밋하게 했다. 긴 읽기는 200행쯤 읽다가 죽었다.
READER 보정 배치가 200 행째에서 죽음 -> ORA-01555: snapshot too old: rollback segment ... too small"자기 발등을 찍었어요. 읽으면서 고치고 커밋하니까 읽던 과거가 사라지는 거네요."
"그래. 그러니까 처방은 커밋 주기를 바꾸는 게 아니야. 읽기와 쓰기를 떼어 내는 거야." 선배가 방법 세 가지를 적었다.
- 읽고 → 닫고 → 쓴다: 고칠 대상을 먼저 다 모아 담고(짧은 읽기), 커서를 닫은 뒤 그 목록으로 고친다.
- 키 범위로 쪼갠다: 조각마다 따로 짧은 쿼리를 돌린다. 긴 커서를 열어 두지 않는다.
- 집합 연산 한 문장으로 쓴다: 애초에 루프를 없앤다.
UPDATE … / MERGE.
1번을 종이에 써 봤다.
sql
-- 읽고 (짧게) → 닫고 → 쓴다
SELECT 정산번호 BULK COLLECT INTO 목록 FROM 정산 WHERE ...; -- 여기서 읽기가 끝난다. 긴 커서가 없다
FORALL i IN 1..목록.COUNT
UPDATE 정산 SET ... WHERE 정산번호 = 목록(i); -- 과거를 들고 있을 커서가 없으니 01555 가 없다
COMMIT; -- (목록이 크면 범위로 쪼개 각 범위를 짧게)"읽는 커서가 중간에 사라졌어요. 고칠 때는 이미 목록만 들고 있고요."
"그래. 긴 커서가 열려 있지 않으면 지킬 과거도 없어. 01555가 설 자리가 없지." 선배가 고개를 끄덕였다. "이게 왜 되는지, 그리고 더 빠른 3번은 다음 주에 봐. '그럼 배치를 어떻게 빠르게 하나'는 14화에서 다룰 질문이야. 오늘은 왜 느려지고 왜 터지는지까지만."
오후 5시 30분 — 비워 둔 칸
선배가 12화 포스트모템을 열었다. 재발방지 2번 항목이었다.
2. 주간 보정 배치 — 트랜잭션을 짧게: 배치는 N 건마다 COMMIT 한다
담당: 오과장 기한: 5/27 검증: 블로킹 트리 1분 간격
리스크:리스크 칸이 비어 있었다.
"지난주에 1번 리스크 칸은 채웠잖아. 시퀀스 결번. 근데 2번은 비웠어." 선배가 그 칸을 짚었다. "왜 비워 뒀어?"
지난주의 내가 떠올랐다. 커서가 그 칸에서 깜빡이고 있었는데, "짧게 하는 게 무슨 리스크가 있지, 락을 덜 쥐는 것뿐인데" 하고 넘어갔다.
"짧게 하는 게 좋은 거라고만 생각했어요. 대가가 있을 거라고는 생각을 못 했어요."
"조치는 뭐든 시스템을 바꿔. 그리고 바꾸면 리스크가 생겨." 선배가 말했다. "그 빈칸이 이번 장애가 된 거야. 이제 채울 수 있지?"
나는 칸을 채웠다.
리스크:
- N 건마다 COMMIT 이 '읽으면서 고치며 커밋'(fetch across commit)이면 긴 커서가 ORA-01555 로 죽는다.
→ 커밋 주기가 아니라 '읽기와 쓰기 분리'로 푼다 (목록 선적재 · 키 범위 분할 · 집합 연산).
- 쪼갠 배치가 중간에 죽으면 '반쯤 반영' 이 남는다 (전부 아니면 전무가 깨진다).
→ 재시작 지점을 설계하고 멱등하게. 정합성 회귀 체크리스트 E·H 로 점검.
- 반대쪽: 한 번에 커밋하면 큰 트랜잭션이 언두를 다 쥐어 ORA-30036. 주기만으로는 둘 다 못 피한다.선배가 읽고 말했다. "이 세 줄이 오늘 번 거야. 이건 포스트모템에만 두지 말고 기준으로 만들자. 다음 사람이 배치를 짤 때 읽을 수 있게."
그래서 「커밋 주기 기준」을 만들었다. 집합 연산으로 쓸 수 있나, fetch across commit인가, 언두에 비해 큰가를 차례로 묻는 결정표다. 칸마다 30036과 01555를 양쪽 다 적었다.
⛳ 오늘은 여기까지(중단 지점 2) 12화에서 비워 둔 「리스크」 칸을 채웠고, 「커밋 주기 기준」을 만들었다. 여기까지만 해도 오늘 몫은 다 한 것이다. 이어지는 저녁 장면에서는 리포트를 쓰고, 반쯤 반영된 데이터를 정합성 관점에서 확인한다.
오후 6시 40분 — 반쯤 반영
리포트를 쓰기 전에 선배가 하나를 더 짚었다. "오과장님 배치가 토요일에 01555로 죽었을 때, 정산이 어떻게 됐을 것 같아?"
1,000건마다 커밋했으니 죽기 전까지 커밋한 건 남아 있을 것이다. 그것도 재 봤다. 같은 배치가 50,000행째에서 장애를 만나게 해 두고 커밋 주기만 바꿔 돌렸다.
[once] 반영 0 · 그대로 100000 ← 전부 되돌아감
[every 1000] 반영 50000 · 그대로 50000 ← 반쯤 반영"한 번 커밋하는 배치는 죽으면 전부 롤백이에요. 전부 아니면 전무죠. 1,000건 배치는 5만 건이 이미 고쳐진 채로 남아요. 반쯤 보정된 정산이에요."
"그게 쪼갠 커밋의 또 다른 대가야." 선배가 말했다. "토요일에 오과장님 배치가 죽었을 때, 지난주 정산 중 일부는 보정됐고 일부는 안 됐어. 그걸 모르고 월요일에 다시 돌리면? 이미 보정된 걸 또 보정해. 금액이 두 번 깎여."
"그래서 재시작 지점이 필요하군요."
"그래. 쪼갤 거면 어디까지 했는지 적어 두고, 다시 돌릴 때 멱등하거나 건너뛰게 짜야 해. 안 그러면 안전 조치 때문에 정합성 사고가 나." 선배가 08화 이야기를 꺼냈다. "08화부터 모든 리포트에 정합성 회귀 체크리스트를 붙였지. 오늘은 E(경계)와 H(영향)에 '반쯤 반영'을 꼭 넣어."
리포트는 T1과 T4를 섞어 썼다. 조각(P1과 보정 배치)의 측정은 T1으로, 토요일 장애는 T4로 정리했다. 10호였다. 커밋 주기 기준을 붙이고, 정합성 체크리스트에는 반쯤 반영을 적었다.
오후 7시 10분
퇴근하면서 오과장에게 답장을 보냈다.
나 19:05
배치를 1,000건 커밋으로 바꾼 게 원인이 맞아요. 다만 '커밋을 자주 해서'가 아니라,
읽으면서 고치며 커밋하는 구조라서예요. 커밋 주기를 바꾸는 걸로는 안 풀려요.
읽기와 쓰기를 떼어 내는 쪽으로 같이 고쳐요. 다음 주에 집합 연산으로 다시 쓰는 것도 볼게요.
그리고 토요일에 죽었을 때 지난주 정산이 반쯤 보정됐을 거예요. 다시 돌리기 전에 그것부터 확인해요.오과장 19:12
반쯤 보정된 거… 생각도 못 했네요. 그것부터 볼게요.리비전 폴더를 열었다. P1은 그대로였다. 오늘은 P1을 고치지 않았다. 대신 P1 옆에 메모를 붙였다. 1,000건 커밋은 지금은 안전하다. P1은 읽는 테이블을 고치지 않으니까. 전진은 측정이 뒷받침할 때(14화).
오늘 고친 건 SQL 한 줄이 아니라 지난주에 비워 둔 칸 하나였다.
오늘 남은 것
| 무엇 | 값 |
|---|---|
| 커밋 주기 (P1 적재 10만 건) | 한 번 ≈ 1,000건 (redo 약 3,000만 · undo 약 680만) · 매 건은 redo 약 1.9배 · undo 약 2배 · LGWR 약 13배 |
| ORA-30036 | 한 번 커밋 + 작은 언두 → 약 42,000행째. 큰 트랜잭션이 언두를 다 쥔다 (강도현 2020.07) |
| ORA-01555 | 긴 읽기 + 그 사이의 커밋 → 과거 언두가 사라짐. 원인은 커밋 주기가 아니라 fetch across commit |
| 반쯤 반영 | 한 번 커밋 = 0 / 쪼갠 커밋 = 중단 지점까지 (전부 아니면 전무가 깨진다) |
| COMMIT 뒤 dirty 31 | 커밋은 데이터 블록을 디스크에 쓰지 않는다. redo(WAL)가 디스크에 있어서 안전하다 (← 07화) |
| ITL Flag | --U- 활성 · C--- 커밋 · ---- 빈 칸. Xid = 트랜잭션 이름표(← 12화) · Uba = 언두 블록 |
| 지연 클린아웃 | 첫 읽기가 redo를 약 15만 바이트 쓴다. (01화의 5,842는 지연 클린아웃이 아니다. 재 보니 가설이 뒤집혔다) |
| 커밋 주기 기준 | 새 표준. 12화 포스트모템 1호의 빈 「리스크」 칸 회수 |
오늘 배운 한 문장
커밋 주기만으로는 두 장애를 다 피할 수 없다. 드물게 하면 ORA-30036, 자주 하면 ORA-01555다. 진짜 문제는 주기가 아니라 읽으면서 고치고 커밋하는 구조(fetch across commit)다. 읽기와 쓰기를 떼어 내야 한다.
다음으로 넘기는 것
- 그럼 배치를 어떻게 빠르게 하나 (집합 연산 · APPEND · 파티션 · 병렬) → 14화
- P1을 집합 연산으로 다시 쓰기, 05화 HWM과 04화 인덱스의 적재 비용 청구서 → 14화
- PL/SQL의 컨텍스트 스위칭 (루프 자체의 비용) → 15화
다음 →
3_실습과_기록.md에서 커밋 주기를 직접 재고, 작은 언두에서 30036과 01555를 직접 띄워 보고, 블록 덤프로 ITL을 읽고, 커밋 주기 기준을 쓴다. 개념이 막히면2_파고들기.md를 펼친다.