{"id":"IL-0436","org":"freee","date":"2025-08-27","docs":[],"title":{"en":"A supremum lock left by ROLLBACK TO SAVEPOINT in a long transaction blocked inserts and filled Aurora MySQL connections","ko":"Aurora MySQL 에서 INSERT 가 supremum 락에 막혀 커넥션이 고갈되고 freee 웹 서비스 전체가 다운되었다"},"domain":["database","aws"],"impact":{"scope":"A whole freee web service was down: its web pages could not be opened","users":"unknown","region":"unknown"},"review":{"written_by":"session","reviewed_at":null,"reviewed_by":null},"lessons":{"en":"ROLLBACK TO SAVEPOINT rolls back rows but does not release their locks, so rolling back one inserted row can leave a supremum lock that blocks every INSERT into the table. ORM frameworks, including Active Record for nested transactions, use SAVEPOINT internally without developers noticing. Locks held by long transactions often go unnoticed when few workloads are blocked, and this incident showed their hidden risk.","ko":"supremum 락 하나로는 문제가 되지 않지만 롱 트랜잭션과 겹치면 서비스 정지로 이어진다. ORM 프레임워크는 중첩 트랜잭션을 만들려고 내부에서 SAVEPOINT 를 쓰는 일이 많아 개발자가 모르고 쓰게 된다. 롱 트랜잭션이 잡은 락은 영향 범위가 좁아 막히는 작업이 적으면 지나치기 쉽지만, 이번 일로 그 잠재 위험이 드러났다."},"related":[],"sources":[{"url":"https://developers.freee.co.jp/entry/db-failure-caused-by-supremum-lock","kind":"blog","archived":"https://web.archive.org/web/20260414213811/https://developers.freee.co.jp/entry/db-failure-caused-by-supremum-lock"}],"summary":{"en":"In MySQL's default REPEATABLE READ isolation, InnoDB takes gap locks between records as well as record locks. An INSERT first takes an insert intention lock on the gap it inserts into, which conflicts only with other gap locks; with an auto-increment primary key that gap on the primary index is the supremum, the virtual upper bound after the last record. A long transaction held an exclusive lock on the supremum for a long time, so every INSERT into the table waited, many threads piled up waiting for the lock to be released, the connections ran out and the service stopped.\n\nThe long transaction's only locking statement on that table was an INSERT; otherwise it ran SELECT and UPDATE on other tables and SAVEPOINT and ROLLBACK TO SAVEPOINT. freee's DBRE team reproduced the lock with the sequence begin, any read, SAVEPOINT, INSERT into the table, ROLLBACK TO SAVEPOINT: performance_schema.data_locks showed only a TABLE IX lock after the INSERT, and a RECORD X lock on the \"supremum pseudo-record\" after the rollback. After an INSERT the transaction holds an implicit exclusive lock on the new record, not shown in data_locks until a conflict occurs. When the rollback's undo removes the record, the lock is not released but is inherited by the next gap, which for a record at the end of the index is the supremum. The MySQL 8.0 code calls this inheritance explicitly through row_undo_ins_remove_clust_rec() -> btr_cur_pessimistic_delete() -> lock_update_delete() -> lock_rec_inherit_to_gap().\n\nRollback to a savepoint was being used inside freee without developers knowing it: Rails Active Record uses SAVEPOINT when ActiveRecord::Base.transaction is given requires_new: true, and #create_or_find_by and #find_or_create_by call ActiveRecord::Base.transaction(requires_new: true) internally. The supremum lock alone causes no trouble; together with a long transaction it took the whole web service down.","ko":"freee 의 한 서비스에서 Aurora MySQL 접속 실패가 잦아지다가 웹 페이지를 열 수 없는 상태로 번졌다. Writer 인스턴스의 CPU 와 메모리는 정상이었지만 BlockedTransactions 와 DatabaseConnections 가 급증해 커넥션이 상한에 닿았다. Database Insights 에서 특정 테이블의 INSERT 가 행 락 대기에 막힌 것을 찾았고, SHOW ENGINE INNODB STATUS 에서 롱 트랜잭션이 Primary Index 의 supremum 에 배타 락을 잡고 있는 것을 확인했다. supremum 은 마지막 레코드 뒤의 갭을 나타내는 가상의 상한값이다. auto_increment 기본 키를 쓰는 테이블의 INSERT 는 이 supremum 에 insert intention lock 을 잡아야 하므로, 모든 INSERT 가 막혔다. 많은 스레드가 이 락이 풀리기를 기다리며 쌓여 커넥션이 고갈되고 서비스가 멈췄다.\n\n문제의 롱 트랜잭션에서 그 테이블에 락을 잡는 쿼리는 INSERT 뿐이었고, 그 밖에는 다른 테이블의 SELECT 와 UPDATE, SAVEPOINT 와 ROLLBACK TO SAVEPOINT 가 있었다. DBRE 팀은 트랜잭션 시작, 아무 읽기 쿼리, SAVEPOINT, INSERT, ROLLBACK TO SAVEPOINT 순서로 supremum 락을 재현했다. INSERT 는 레코드를 넣은 뒤 그 레코드에 암묵적 배타 락을 잡는다. ROLLBACK 은 undo 로 레코드를 지우지만 락은 풀지 않고, 지운 레코드의 락을 다음 갭으로 넘긴다. 그래서 마지막에 추가한 레코드의 락은 supremum 으로 넘어갔다. MySQL 8.0 코드에서도 클러스터드 인덱스에 들어간 행을 undo 할 때 lock_rec_inherit_to_gap() 이 명시적으로 불렸다.\n\nRails 의 Active Record 에서 ActiveRecord::Base.transaction 에 requires_new: true 를 주면 내부에서 SAVEPOINT 를 쓰고, #create_or_find_by 와 #find_or_create_by 도 내부에서 이를 부른다. 사내에서도 이렇게 SAVEPOINT 롤백을 모르고 쓰는 곳이 여럿 있었다. supremum 락만으로는 문제가 되지 않았지만, 롱 트랜잭션과 겹치면서 행 하나의 롤백이 웹 서비스 전체를 다운시켰다."},"trigger":{"en":"A long transaction inserted a row into the table after a SAVEPOINT and then ran ROLLBACK TO SAVEPOINT, which left it holding an exclusive lock on the supremum of the table's primary index.","ko":"롱 트랜잭션 안에서 SAVEPOINT 를 만들고 대상 테이블에 INSERT 한 뒤 ROLLBACK TO SAVEPOINT 를 실행했고, 이 롤백이 그 테이블의 supremum 에 배타 락을 남겼다."},"severity":"unknown","symptoms":["lock-contention","service-unavailable"],"timeline":{"detected_after":null,"resolved_after":null},"date_kind":"published","detection":null,"components":["database"],"root_cause":"software-bug","remediation":[],"action_items":[{"text":{"en":"As a rule stop issuing SAVEPOINT, split a transaction when a partial rollback is needed, and set an application-side timeout where a nested transaction is unavoidable","ko":"원칙적으로 SAVEPOINT 를 쓰지 않는다. 부분 롤백이 필요하면 트랜잭션 자체를 나누고, 꼭 트랜잭션을 중첩해야 하면 애플리케이션 쪽에 타임아웃을 둔다."}},{"text":{"en":"Teach developers to consider READ COMMITTED per session for use cases that can accept phantom rows","ko":"트랜잭션 격리 수준은 세션 단위로도 정할 수 있다. 그래서 팬텀 행을 허용할 수 있는 경우에는 갭 락을 잡지 않는 READ COMMITTED 를 쓰는 것도 고려하도록 개발자를 교육하고 안내한다."}},{"text":{"en":"Monitor long transactions and keep working to remove them across the company","ko":"롱 트랜잭션을 감시하고, 회사 전체에서 롱 트랜잭션을 없애는 자발적인 활동을 이어 간다."}}],"contributing":["assumption-unverified","default-config-unchanged"],"recurrence_of":null,"symptoms_text":{"en":"Connections to Aurora MySQL failed again and again in one freee service until its web pages could not be opened at all. The database did not respond, the web application timed out and could not open new database connections, and the whole service was down.\n\nOn the Aurora MySQL writer instance, CPUUtilization and FreeableMemory showed nothing abnormal and the instance was not under heavy load. BlockedTransactions, the number of transactions waiting on locks, rose sharply, and DatabaseConnections rose sharply until it reached its limit.\n\nDatabase Insights showed that around the time of the failure INSERTs into one table were waiting on wait/synch/cond/innodb/row_lock_wait, so new rows could not be added to it. This looked odd, because the table's primary key is an AUTO_INCREMENT value and an INSERT of a new row would not normally be blocked by a gap lock. The SHOW ENGINE INNODB STATUS output showed a long transaction holding lock_mode X on the PRIMARY index record \"asc supremum;;\".","ko":"Aurora MySQL 에 접속 실패가 잦아지다가 결국 웹 페이지를 열 수 없게 되어 서비스 전체가 다운되었다. 웹 애플리케이션은 DB 가 응답하지 않아 타임아웃이 잇따랐고 새 DB 접속을 맺지 못했다.\n\nAurora MySQL Writer 인스턴스의 CPUUtilization 과 FreeableMemory 에는 이상이 없어 고부하 상태로 볼 수 없었다. 반면 락 때문에 기다리는 트랜잭션 수인 BlockedTransactions 가 급증했고, DatabaseConnections 가 급증해 상한에 닿았다.\n\nDatabase Insights 에서 SQL 문을 보니 장애 시각 앞뒤로 특정 테이블의 INSERT 가 wait/synch/cond/innodb/row_lock_wait 를 일으켜 레코드 추가가 막혀 있었다. 기본 키가 AUTO_INCREMENT 값이라, 새 레코드의 INSERT 를 막는 갭 락이 잡히는 일은 보통 생각하기 어려웠다.\n\nSHOW ENGINE INNODB STATUS 의 로그에는 Primary Index 의 supremum 에 배타 락을 잡은 롱 트랜잭션이 있었다. 그 락은 index PRIMARY of table `XXXX`.`XXXX` trx id XXXXXXXXXXXX lock_mode X 줄과 0: len 8; hex XXXXXXXXXXXXXXXX; asc supremum;; 줄로 찍혀 있었다."},"updated_at":"2026-11-14T12:34:08.588585Z","url":"https://benchlet.com/incidentlib/app/cases/IL-0436","org_slug":"freee"}