SSD에서 fsync 한 번이 1ms 걸린다고 하자. sync_binlog=1로 설정된 MySQL은 초당 몇 개의 트랜잭션을 커밋할 수 있을까. "fsync가 트랜잭션마다 한 번씩이니 초당 1000개"라고 답했다면, group commit을 빼먹은 계산이다. 실제 상한은 트랜잭션 수가 아니라 그룹의 수에서 나온다.
트랜잭션을 커밋했다고 클라이언트에게 응답하려면, 그 트랜잭션의 binlog 이벤트가 디스크에 실제로 내려갔다는 보장이 있어야 한다. 메모리에만 쓰고 응답부터 보내면 그 직후 서버가 죽었을 때 "커밋됐다고 들었는데 사라진" 트랜잭션이 생긴다. 이 보장을 만드는 연산이 fsync이고, sync_binlog=1은 "매 커밋마다 fsync한다"는 설정이다.
문제는 fsync가 느리다는 것이다. 디스크 배리어를 기다리는 연산이라, 트랜잭션마다 독립적으로 fsync를 호출하면 동시에 커밋을 시도하는 100개의 트랜잭션이 fsync 100번을 순차로 기다려야 한다. 여기서 자연스러운 결론이 나온다 — "그럼 sync_binlog=1인 이상 durability와 처리량은 맞바꿀 수밖에 없다." 이 결론이 절반만 맞다는 걸 group commit이 보여준다.
group commit의 아이디어는 "비슷한 시점에 도착한 트랜잭션들의 binlog write를 모아서 fsync는 묶음 전체에 한 번만 하자"는 것이다. sync_binlog=1이어도 이 묶음(그룹) 단위로 fsync가 발생하므로, 그룹에 3개가 모이면 fsync 1번으로 3개가 동시에 durable해진다. 줄어드는 건 fsync 횟수이지 durability 수준이 아니다 — 그룹에 속한 트랜잭션은 모두 그 fsync가 끝난 후에만 커밋 확인을 받는다.
다만 그룹 크기는 기본적으로 "그 순간의 동시성"에만 의존한다. 부하가 낮으면 트랜잭션이 시간축에서 떨어져 도착하고 그룹은 1개짜리로 쪼그라든다. binlog_group_commit_sync_delay(마이크로초, 기본값 0)는 이걸 인위적으로 넓히는 지연 윈도우다. 커밋 스레드가 fsync로 즉시 가지 않고 지정 시간만큼 대기하면서 그 사이 도착하는 트랜잭션을 그룹에 편입시킨다.
T1 커밋 요청 ──┐
T2 커밋 요청 ───┼─ [배치 윈도우: 최대 sync_delay μs] ─ fsync 1회 ─ T1,T2,T3 동시 durable
T3 커밋 요청 ──┘
T4 커밋 요청 ──────────────(다음 그룹, 별도 fsync)──────────────
여기에 안전밸브가 binlog_group_commit_sync_no_delay_count다. 지연 시간을 다 채우기 전에 이 개수만큼 모이면 기다리지 않고 바로 fsync로 넘어간다. 이미 그룹이 충분히 큰데 남은 지연을 다 소비하는 건 순손해이기 때문이다. sync_binlog=N(>1)(N그룹마다 한 번만 fsync — 크래시 시 최대 N그룹 유실 가능한, durability를 낮추는 설정)에서는 지연이 매 그룹이 아니라 N그룹마다 한 번 걸린다. sync_binlog는 "몇 그룹마다 fsync할지"를, sync_delay는 "그룹을 얼마나 불릴지"를 조절하는, 서로 직교하는 두 축이다.
fsync 1회에 1ms가 걸리고, 동시성이 충분해서 매 fsync 앞에 그룹이 평균 10개씩 모인다고 하자. sync_delay=0이면 최악의 경우처럼 그룹이 1개씩으로 쪼그라들어 상한은 초당 1000커밋. 그룹 크기가 10으로 유지되면 fsync 횟수는 초당 1000번 그대로지만 각 fsync가 10개를 실어 나르므로 상한은 초당 10000커밋으로 뛴다. sync_delay를 늘려 그룹 크기를 인위적으로 키우면 개별 트랜잭션의 지연은 늘지만 이 승수가 커진다 — fsync 횟수 자체는 못 줄여도, fsync 한 번이 처리하는 트랜잭션 수를 늘려서 처리량을 얻는 구조다.
역방향 실패 케이스도 있다. 동시 접속자가 1명뿐인 배치 스크립트에서 sync_delay=100000(100ms)을 걸면, 그룹은 여전히 1개짜리인데 매 커밋이 지연 윈도우를 다 채울 때까지 100ms씩 기다리기만 한다 — 그룹 크기를 키워줄 동시 트랜잭션이 애초에 없으니, 처리량 이득 없이 지연만 늘어난다. sync_delay는 동시성이 실제로 있을 때만 값을 낸다.
로컬에서 현재 설정과 fsync 횟수를 직접 확인할 수 있다.
SHOW VARIABLES LIKE 'binlog_group_commit_sync_delay';
SHOW VARIABLES LIKE 'binlog_group_commit_sync_no_delay_count';
SHOW VARIABLES LIKE 'sync_binlog';
# mysqld의 fsync 호출 횟수를 부하 구간 동안 집계
strace -f -c -e trace=fsync,fdatasync -p "$(pgrep -x mysqld)" &
STRACE_PID=$!
sleep 20 # 이 동안 sysbench 등으로 동시 커밋을 발생시킨다
kill -INT "$STRACE_PID"
동일 부하에서 sync_delay=0과 sync_delay=1000(1ms)을 비교하면, 커밋된 트랜잭션 수 대비 fsync 호출 횟수 비율이 후자에서 뚜렷하게 낮게 나온다 — 그룹이 커졌다는 뜻이다.
sync_binlog=1은 "트랜잭션마다 fsync"가 아니라 "그룹마다 fsync"를 뜻하고, 그 그룹의 크기를 결정하는 건 자연 동시성과 binlog_group_commit_sync_delay다. durability 수준(sync_binlog)과 그룹 크기(sync_delay)는 서로 다른 축이라, 하나를 안전하게 유지한 채로 다른 하나만 조절해 처리량을 얻을 수 있다.
binlog fsync 앞에 그룹을 모으고 나면, 그 순서대로 InnoDB에도 커밋을 넣어야 복제본이 소스와 같은 최종 상태에 도달한다 — 이 순서 보장은 binlog_order_commits가 담당하는 별개의 문제라 여기서는 다루지 않았다. crash 이후 InnoDB의 prepared 트랜잭션과 binlog에 실제로 적힌 트랜잭션을 대조하는 절차도 다음에 파볼 만하다. 페이지 단위에서 비슷한 durability-처리량 트레이드오프를 다룬 글로는 InnoDB 더블라이트 버퍼가 있다 — 거기서는 fsync 횟수가 아니라 torn page를 막기 위해 쓰기 자체를 두 번 하는 반대 방향의 선택을 본다.