처리 패스는 상식적인 걸 했어. 후보 행마다, 아는 걸 쓰고, 네트워크를 부르고, 결과를 쓰고. 커밋은 끝에서 한 번. 행마다 커밋하면 fsync 가 늘고 배치가 빠르다는 건 다들 아니까.
단일 기록자 DB 에선 커밋 안 된 쓰기가 쓰기 잠금을 쥐어. 그래서 그 루프의 첫 업데이트가 잠금을 잡았고, 바로 다음 구문이 네트워크 호출이었지. 잠금이 라운드의 남은 모든 가져오기를 감싼 채 쥐어졌고, 한 패스에 대충 구 초로 측정됐어. 설정된 바쁨 타임아웃을 한참 넘은 거야.
증상은 무관한 기능에 나타났어
처리 패스의 무엇도 문제를 보고 안 했어. 실패는 완전히 딴 데서 드러났지. 라운드 중에 좋아요나 저장을 누른 독자가 DB 잠금 오류를 받았어. 그리고 클라이언트 이벤트 핸들러가 오류를 넓게 잡고 그냥 선반을 다시 불러왔으니까 탭이 그냥 사라졌어. 메시지도, 재시도도 없이, 버튼이 그냥 안 먹히는 것처럼 보였고.
세 가지 성질이 이걸 찾기 어렵게 만들었어. 라운드 중에만 나니까 간헐적이야. 책임 있는 코드랑 아무 상관 없는 기능에 나타나고. 그리고 오류가 아무도 안 볼 자리에서 한 층 떨어진 포괄 처리에 삼켜졌고.
행마다 커밋해
해법은 줄 하나를 루프 안으로 옮기는 거야. 각 행 업데이트가 자기 네트워크 호출 전에 커밋되니까 잠금이 초가 아니라 마이크로초 동안 쥐어져. 배치 직관은 이 모양의 작업엔 그냥 틀렸어. 최적화되던 비용은 fsync 몇 개였고, 치르던 비용은 프로세스의 모든 동시 기록자였거든.
DB 세부 없이 적을 값어치가 있는 일반 규칙. 잠금의 지속 시간은 네가 통제하는 계산으로 제한돼야지, 원격 시스템의 응답 시간으로 제한되면 안 돼. 획득이랑 해제 사이의 모든 await, 모든 요청, 모든 서브프로세스가 네 지연 예산을 남의 서버한테 넘기는 거야.
패스 말고 성질을 시험해
패스엔 테스트가 있었고 통과했어. 혼자 돌았으니까. 이걸 잡는 테스트는 동시성에 대한 성질을 단언해. 라운드가 도는 동안에도 평범한 쓰기가 성공한다. 그건 다른 종류의 테스트야. 패스랑 기록자가 동시에 떠 있어야 하니까. 그리고 이걸 찾을 수 있는 유일한 종류고.
I/O 를 감싼 잠금은 잠금이 아니라 일정 잡힌 장애야. 지속 시간을 원격 호스트가 정하니까 네 쓰기 가용성이 이제 남의 가동 시간의 성질이 돼. 그리고 실패는 하필 그 창에 쓰기를 하는 무관한 기능에 나타날 거고.
Code
쥐고 있던 잠금, 행마다 커밋, 그리고 동시성 성질 테스트·python
# BEFORE: batched commit. The first UPDATE takes the write lock and the
# next statement is a network call, so the lock is held across the
# WHOLE round. Measured ~1.12s per resolution x 8 per round = ~9s of
# held lock, past the busy timeout.
def run_shell_pass(con, candidates):
for row in candidates:
con.execute("UPDATE articles SET shell_checked_at=? WHERE id=?",
(utcnow(), row["id"])) # <- takes the lock
resolved = _unwrap(row["url"]) # <- ...across THIS
if resolved:
con.execute("UPDATE articles SET url=? WHERE id=?",
(resolved, row["id"]))
con.commit() # <- released here
# AFTER: commit per row. The lock is held for microseconds. The
# batching instinct was optimizing a handful of fsyncs and paying with
# every concurrent writer in the process.
def run_shell_pass(con, candidates):
for row in candidates:
con.execute("UPDATE articles SET shell_checked_at=? WHERE id=?",
(utcnow(), row["id"]))
con.commit() # <- before the network
resolved = _unwrap(row["url"])
if resolved:
con.execute("UPDATE articles SET url=? WHERE id=?",
(resolved, row["id"]))
con.commit()
# The test that finds it. Not 'does the pass work' -- it did. This
# asserts a PROPERTY: a writer landing mid-pass still succeeds.
def test_a_writer_survives_a_round(con, slow_network):
with running_in_background(run_shell_pass, con, candidates):
time.sleep(0.5) # land inside the round
record_event(con, "like", article_id=1) # must not raise
assert store.article_state(con, 1)["liked_at"]
네 코드베이스에서 트랜잭션이나 잠금을 찾아서, 각각에 대해 획득이랑 해제 사이의 모든 호출을 나열해. I/O 를 하는 걸 표시해. 요청, 큐 발행, 서브프로세스, 네트워크 마운트의 파일 읽기. 제일 나쁜 거에 대해 쥐고 있는 시간을 추정해서 DB 바쁨 타임아웃이랑 비교해봐.
Hint
이 패턴은 행을 업데이트하고, 밖을 부르고, 또 업데이트하는 루프에 숨어. 자연스러운 파이프라인으로 읽히고 아무도 BEGIN 을 안 썼으니까 트랜잭션 경계가 안 보이거든. 자동 커밋이 꺼져 있거나 ORM 세션이 열린 채인 게 애초에 트랜잭션이 존재하는 이유인 경우가 많아.
Progress
Progress is local-only — sign in to sync across devices.