openlang

goroutine 20개에서 일일 한도는 초과되는데 잔액 가드는 잘 동작해요. 버그가 하나일까요, 두 개일까요?

Go + pgx pool + Postgres, 기본값인 Read Committed, 출금 한 건당 트랜잭션 하나예요. 잔액 1000, daily_limit 50, goroutine 20개가 각각 10씩 출금해요. 성공이 5번일 거라고 예상했는데 7, 8, 9번이 나와요. 오늘 날짜의 sum(amount)는 70~90이 되고요. MaxConns=1이면 정확히 5예요.

잔액 가드는 문제없어 보여요. 잔액이 15일 때는 하나만 성공하고 나머지는 UPDATE에서 그냥 0 rows가 나와요. 블록되지도 않고 에러도 안 나요.

INSERT INTO withdrawals (account_id, amount)
SELECT $1, $2
WHERE (SELECT coalesce(sum(amount), 0)
         FROM withdrawals
        WHERE account_id = $1 AND day = current_date) + $2
      <= (SELECT daily_limit FROM accounts WHERE id = $1)
UPDATE accounts
SET balance = balance - $2
WHERE id = $1 AND balance >= $2

Postgres가 UPDATE처럼 INSERT ... SELECT의 WHERE도 다시 확인하나요? 아니면 이 둘은 별개의 문제인가요? 로그에는 이상한 게 없고 -race도 깨끗해요.

제가 보고 있는 isolation 문서예요: https://www.postgresql.org/docs/current/transaction-iso.html

영어에서 번역원문 보기

이어지는 대화 3

서로의 근거를 따라
  1. 고유 링크

    제가 이해한 바로는 이 둘은 별개의 문제예요. Read Committed에서는 다른 트랜잭션이 방금 변경한 행을 만난 UPDATE가 그 트랜잭션이 끝나기를 기다린 다음, 새 행 버전을 기준으로 WHERE를 다시 평가해요. 그래서 잔액 검사가 제대로 동작하는 거예요. 반면 INSERT ... SELECT에는 기다릴 행이 없어요. 구문이 시작될 때 만들어진 스냅샷만 읽기 때문에, 스무 개가 전부 같은 합계를 보고 전부 통과할 수 있어요. SELECT ... FROM accounts WHERE id = $1 FOR UPDATE로 먼저 행 락을 잡거나, INSERT 전에 잔액 UPDATE를 실행하면 계정별로 직렬화될 거예요. https://www.postgresql.org/docs/current/explicit-locking.html 에 설명되어 있어요.

    영어에서 번역원문 보기
  2. 고유 링크

    제가 보는 것과 일치해요. 락을 먼저 잡는 방식에는 주의할 점이 하나 있어요. FOR UPDATE는 INSERT 앞에서 별도의 문장이어야 해요. 같은 문장 안의 CTE로 합치면 스냅샷은 이미 문장 시작 시점에 만들어진 뒤라서 합계가 여전히 오래된 값일 수 있어요. 별도 문장이면 락을 얻은 뒤에 INSERT가 새 스냅샷을 받고, 앞선 출금이 커밋한 내용을 볼 수 있어요.

    SELECT 1 FROM accounts WHERE id = $1 FOR UPDATE;
    -- then the INSERT ... SELECT as before

    락은 커밋이나 롤백 때까지 유지되니까 트랜잭션을 짧게 유지하세요. 락을 잡고 커밋하기 전에 네트워크 호출은 하지 마세요.

    잔액 UPDATE를 먼저 실행하는 방법도 되긴 하는데, 우연히 같은 행 락을 잡기 때문일 뿐이에요. 부수 효과라서, 저는 다음 사람이 볼 수 있게 락을 명시적으로 적어 두는 쪽이 좋아요.

    이걸로 MaxConns=1일 때 정확히 5가 나오는 것도 설명돼요. 연결이 하나면 풀 자체가 락이에요.

    Read Committed 섹션에 문장별 스냅샷이 명확하게 나와 있어요: https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED

    영어에서 번역원문 보기
  3. 고유 링크

    처음 질문하신 것에 답하자면, 아니요, Postgres는 UPDATE처럼 INSERT ... SELECT의 WHERE를 다시 확인하지 않아요. 따라가야 할 기존 행 버전이 없기 때문이에요. 재확인(찾아보시려면 EvalPlanQual이에요)은 문장이 다른 트랜잭션이 이미 바꾼 행을 수정하거나 락을 잡으려고 할 때만 일어나요. SELECT 서브쿼리는 그 문장의 스냅샷에서 읽을 뿐이라서 스무 개 모두 같은 합계를 보고 통과해요. 숫자가 7, 8, 9로 달라지는 이유도 그거예요. 앞선 커밋이 반영되기 전에 몇 개가 시작되느냐에 따라 달라지니까요.

    행 락 말고도 방법이 두 가지 더 있어요. 검사 대상이 락을 걸 단일 행이 없는 경우라면, 트랜잭션 시작에서 pg_advisory_xact_lock(account_id)를 쓰면 같은 역할을 하고 커밋 때 알아서 풀려요. 아니면 트랜잭션을 SERIALIZABLE로 실행하고 SQLSTATE 40001에서 재시도할 수도 있어요. 명시적인 락 없이도 올바르게 동작하지만, 재시도 루프를 직접 써야 하고, 한 계정에 goroutine 스무 개가 몰리면 재시도를 꽤 많이 하게 될 거예요.

    핫 계정 하나라면 저는 그래도 평범한 FOR UPDATE를 쓸 거예요.

    영어에서 번역원문 보기

같은 질문의 다른 갈래

  1. 에러 없이 끝난 rows.Next 루프

    database/sql로 만든 작은 도구가 목록을 반만 돌려줬어. 에러 없이. 코드는 이래. 컨텍스트에 데드라인이 있었어. 가져오는 도중에 데드라인이 지나서 rows.Next()가 false를 줬고, 루프가 끝났고, 함수는…

    답글 3최근 활동 sqlgo
  2. partial index와 WHERE 절의 파라미터

    수업 프로젝트로 Go로 작은 job runner를 만들고 있어요. 환경은 database/sql을 통해 쓰는 pgx v5, 로컬 Postgres 16이고, jobs 테이블에 행이 600만 개쯤 있어요. 거의 다 done이고…

    답글 10최근 활동 sqlgo
  3. 총합이 합계가 아니라 숫자가 그냥 붙어서 출력돼요

    일별 줄은 멀쩡한데(12500, 3400, 900원), TOTAL은 0125003400900 KRW로 찍혀요. 왜 0이 하나 더 붙죠? tsc는 통과하고, Postgres 16은 docker에서 돌려요. reduce는 늘 쓰…

    답글 10최근 활동 typescriptsql