3P by GN⁺ | ★ favorite | 댓글 1개
  • MySQL 8.0.34의 기본 격리 수준인 Repeatable Read는 단일 정상 노드에서도 ANSI SQL 및 Adya의 PL-2.99 기대와 맞지 않는 트랜잭션 일관성 위반을 보임
  • Elle의 list-append 검사기, targeted workload, LazyFS를 조합해 MySQL 8.0.34, MariaDB 10.11.3, binlog 복제 클러스터, AWS RDS MySQL Multi-AZ DB Cluster를 함께 검증함
  • Kleppmann의 2014년 Hermitage 결과처럼 G2-item, G-single, lost update가 재현됐고, 내부 일관성 위반과 non-repeatable read, Monotonic Atomic View 위반도 관찰됨
  • 단일 MySQL의 Read Uncommitted, Read Committed, Serializable은 각각 PL-1, PL-2, PL-3에 부합해 보였지만, AWS RDS MySQL 클러스터는 Serializable에서도 G2-item과 G-single을 보임
  • ANSI 또는 PL-2.99 수준의 Repeatable Read가 필요하다면 MySQL Repeatable Read만 믿기 어렵고, Serializable이나 SELECT... FOR UPDATE 같은 명시적 잠금이 필요함

평가 대상과 범위

  • MySQL은 널리 쓰이는 관계형 데이터베이스이며, 이 분석에서 “MySQL”은 기본 스토리지 엔진인 InnoDB를 사용하는 MySQL을 뜻함
  • 초점은 단일 서버 MySQL이지만, binlog 복제를 쓰는 단일 쓰기 primary와 읽기 전용 secondary 클러스터도 함께 다룸
  • 테스트 대상은 다음과 같음
    • MySQL 8.0.34
    • MariaDB 10.11.3
    • Debian Bookworm
    • AWS RDS Cluster의 “Multi-AZ DB Cluster” 프로필
  • 작업은 보상 없이 독립적으로 수행됐고, Jepsen ethics policy에 따라 진행됨

SQL 격리 수준과 Repeatable Read의 기준

  • ANSI SQL은 Read Uncommitted, Read Committed, Repeatable Read, Serializable을 P1 dirty read, P2 non-repeatable read, P3 phantom 가능 여부로 정의함
  • 1995년 Berenson 등은 A Critique of ANSI SQL Isolation Levels에서 ANSI 정의의 모호성과 불완전성을 비판함
    • P1, P2, P3는 해석 여지가 있음
    • P0 dirty write 같은 중요한 현상이 빠져 있음
    • P3는 predicate에 영향을 주는 insert만 금지하고 update나 delete는 다루지 않음
  • Atul Adya의 1999년 논문은 트랜잭션 간 의존성 그래프를 기반으로 구현 독립적인 격리 수준을 정의함
    • PL-1은 G0 write cycle을 금지함
    • PL-2는 G0와 G1을 금지함
    • PL-2.99는 G0, G1, G2-item을 금지하며 Repeatable Read에 대응함
    • PL-3은 G0, G1, G2를 금지하며 Serializable에 대응함
  • Jepsen은 일반적으로 Adya의 형식을 사용해 트랜잭션 기록과 이상 현상을 판별함

MySQL 문서와 Repeatable Read의 충돌

  • MySQL 문서는 InnoDB가 SQL:1992 표준의 네 격리 수준을 모두 제공한다고 설명함
  • 기본 격리 수준인 Repeatable Read는 같은 트랜잭션 안의 consistent read가 첫 읽기에서 설정된 스냅샷을 읽는다고 설명함
  • consistent read 문서도 첫 읽기 시점의 timepoint에 따라 데이터베이스를 본다고 설명함
  • 하지만 같은 문서의 주석은 스냅샷이 SELECT에는 적용되지만 DML 문장에는 반드시 적용되지 않으며, 다른 트랜잭션이 commit한 row를 DELETEUPDATE가 건드릴 수 있다고 말함
  • 이 주석은 ANSI SQL과 MySQL 참조 매뉴얼이 SELECT도 DML로 본다는 점과 충돌하고, Repeatable Read에서 쓰기가 읽을 수 없던 row에 영향을 줄 수 있다는 혼란을 만듦

테스트 설계

  • MySQL용 테스트 스위트Jepsen testing library 0.3.4 기반으로 작성됨
  • 클라이언트는 mysql-connector-j JDBC 어댑터를 사용함
  • 테스트에는 process pause, crash, network partition, fsync되지 않은 디스크 쓰기 손실 같은 fault injection이 포함됨
  • 다만 이번 분석의 거의 모든 발견은 정상 상태의 단일 MySQL 노드에서 발생함
  • Elle list-append workload

    • 핵심 workload는 Elle의 list-append checker를 사용함
    • Elle는 트랜잭션 사이의 write-write, write-read, read-write 의존성을 추론하고, 의존성 그래프의 cycle로 특정 격리 수준 위반을 입증함
    • list-append workload는 primary key로 식별되는 여러 list에 대해 read와 append로 구성된 랜덤 트랜잭션을 수행함
    • list는 쉼표로 구분된 값을 담는 text 필드로 인코딩하고, append는 SQL CONCAT으로 처리함
    • 최근 개선으로 Elle는 다음을 더 잘 탐지함
      • 읽히지 않은 append element에 대한 ww/rw 의존성 추론
      • P4 lost update 명시 탐지
      • real-time edge와 process edge를 포함한 복잡한 cycle 탐색
  • Targeted workload

    • Non-repeatable read workload는 people 테이블의 한 row를 대상으로 함
    • 한 계열의 트랜잭션은 name만 update하고, 다른 계열은 name을 읽고 gender를 update한 뒤 다시 name을 읽음
    • 두 읽기 사이에 name이 바뀌면 Repeatable Read 위반임
    • Monotonic Atomic View workload는 두 row의 value를 사용함
    • writer는 row 0의 value를 증가시킨 뒤 row 1을 증가시킴
    • reader는 row 0을 읽고 row 1의 noop을 update한 뒤 row 1과 row 0을 읽음
    • 한 트랜잭션의 일부 효과를 봤다면 모든 효과를 봐야 함
  • LazyFS

    • LazyFS는 fsync되지 않은 쓰기 손실을 시뮬레이션하는 FUSE 파일시스템임
    • MySQL process를 죽이고 LazyFS 캐시를 버린 뒤 MySQL을 재시작하는 방식으로 테스트함
    • 이 보고서는 LazyFS가 포함된 첫 공개 Jepsen 보고서임

MySQL Repeatable Read에서 발견된 이상 현상

  • G2-item

    • Adya의 PL-2.99 Repeatable Read는 predicate를 포함하지 않는 write-write, write-read, read-write 의존성 cycle인 G2-item을 금지함
    • MySQL Repeatable Read는 단일 정상 노드에서도 G2-item을 반복적으로 허용함
    • Kleppmann이 2014년 Hermitage에서 보고한 동작이 MySQL 8.0.34에서도 계속 발생함
    • 예시 테스트는 40초 동안 214개 cycle을 보였음
    • 이 동작은 PL-2.99 Repeatable Read에서는 금지되지만, ANSI SQL의 P2 정의가 같은 row를 두 번 읽는 경우만 다루기 때문에 ANSI 정의상 해석 여지가 남음
  • G-single과 read skew

    • MySQL Repeatable Read는 G-single도 보임
    • G-single은 write-write, write-read, read-write edge로 구성되지만 read-write edge가 서로 인접하지 않는 cycle임
    • Kleppmann이 2014년 보고한 read skew가 MySQL 8.0.34에서도 확인됨
    • 60초 append 테스트에서는 초당 약 140트랜잭션에서 G-single 244건과 G2-item 305건이 나타남
    • append 테스트는 predicate 연산을 사용하지 않으므로 모두 Repeatable Read 위반으로 분류됨
  • Lost update

    • P4 lost update는 두 트랜잭션이 같은 key의 같은 version을 읽고 둘 다 update하는 G-single의 특수 사례임
    • Snapshot Isolation과 PL-2.99 Repeatable Read는 lost update를 금지함
    • MySQL Repeatable Read는 단일 정상 노드에서도 lost update를 반복적으로 허용함
    • 한 테스트에서 9,048개 성공 트랜잭션 중 새 checker는 198개의 lost update 사례에 관여한 446개 트랜잭션을 찾음
    • 이 중 cycle로 드러난 사례는 47개뿐임
    • 값을 읽은 뒤 쓰는 패턴은 MySQL Repeatable Read에서 안전하지 않음
    • 객체를 읽어 메모리에서 수정한 뒤 다시 저장하는 표준 ORM 패턴에서는 commit된 변경이 조용히 사라질 수 있음
    • 사용자는 명시적 잠금을 직접 사용해야 함
  • Non-repeatable read와 내부 일관성 위반

    • MySQL Repeatable Read는 단일 정상 노드에서도 내부 일관성 위반을 보임
    • 같은 테스트 run에서 9,048개 commit 트랜잭션 중 126개가 내부 일관성 오류를 보임
    • 예시에서는 한 트랜잭션이 key를 nil로 읽고 값 하나를 append한 뒤 같은 key를 다시 읽었을 때, 다른 세 값이 추가된 상태를 관찰함
    • 또 다른 예시에서는 key 1096을 [1 2 3]으로 읽고 7을 append한 뒤 다시 읽었을 때 [1 2 3 4 5 6 7]을 관찰함
    • targeted workload에서는 한 Repeatable Read 트랜잭션 안에서 name"pebble"로 읽고, gender"femme"로 update한 뒤, 같은 name을 다시 읽자 "moss"가 나옴
    • 이런 동작은 ANSI SQL의 non-repeatable read 정의와 MySQL 문서의 “첫 읽기에서 설정된 스냅샷” 설명에 어긋남
  • Monotonic Atomic View 위반

    • Monotonic Atomic View는 한 트랜잭션의 어떤 효과를 본 트랜잭션이 그 트랜잭션의 모든 효과를 봐야 한다는 성질임
    • MySQL Repeatable Read는 정상 단일 노드에서도 이를 반복적으로 위반함
    • workload에서 writer는 row 0을 증가시킨 뒤 row 1을 증가시킴
    • reader는 row 0에서 이전 값 0을 본 뒤, row 1에서 writer의 증가분 1을 보고, 다시 row 0에서 여전히 0을 봄
    • 이는 row 1의 효과는 봤지만 row 0의 효과는 보지 못한 비단조 읽기이며, 일반적인 스냅샷 동작과 맞지 않음

AWS RDS MySQL Serializable의 이상 현상

  • AWS RDS MySQL 클러스터는 “Serializable” 격리 수준에서도 Serializability를 반복적으로 위반함
  • 기본 권장 production 프로필의 RDS MySQL 클러스터에서 append 테스트는 G2-item과 G-single anomaly를 보임
  • 관찰된 anomaly는 한 트랜잭션의 효과를 본 트랜잭션의 앞선 의존성을 다른 트랜잭션이 놓치는 형태였음
  • 이 anomaly는 G-single이자 G2-item으로 분류되며, Snapshot Isolation, Repeatable Read, Serializability를 모두 위반함
  • replica_preserve_commit_order 관련 설정이 의심 요인으로 남아 있음
    • MySQL 8.0.27 이상은 replica_preserve_commit_order=ON이 기본값임
    • RDS 기본 파라미터는 여전히 replica_preserve_commit_order=OFF에 해당하는 설정을 선택함
    • RDS parameter group에서는 이 설정의 예전 이름인 slave_preserve_commit_order를 사용함
    • 로컬 테스트 클러스터에 이 설정을 적용하면 유사한 G-single과 G2-item이 관찰됨

정상적으로 보인 부분과 LazyFS 결과

  • MySQL 8.0.34의 Read Uncommitted, Read Committed, Serializable은 각각 PL-1, PL-2, PL-3을 만족하는 것으로 보임
  • 이 결과는 단일 노드와 binlog 복제를 사용하는 작은 read-only replica 클러스터 모두에서 관찰됨
  • process pause, crash, network partition에서도 해당 결과가 유지됨
  • LazyFS fault injection은 MySQL 기본 설정에서 문제를 찾지 못함
  • innodb_flush_log_at_trx_commit=1 기본값에서는 process crash와 fsync되지 않은 데이터 손실 이후에도 commit된 트랜잭션 손실이 나타나지 않음
  • innodb_flush_log_at_trx_commit=0으로 바꾸면 MySQL이 몇 초마다 한 번만 fsync했고, 데이터 손실이 관찰됨

MySQL Repeatable Read의 실제 성격

  • MySQL Repeatable Read는 PL-2.99 Repeatable Read를 만족하지 않음
    • G2-item과 write skew를 보임
  • Snapshot Isolation도 만족하지 않음
    • G-single, read skew, lost update를 보임
  • cursor stability도 만족하지 않음
    • lost update가 발생함
  • Read Atomic, Causal Consistency, Consistent View, Prefix Consistency, Parallel Snapshot Isolation도 배제됨
    • 내부 일관성 위반이 관찰됨
  • MySQL Repeatable Read는 Read Committed보다는 다소 강해 보임
    • G0 dirty write, G1a aborted read, G1b intermediate read, G1c cyclic information flow는 관찰되지 않음
    • 일부 읽기의 repeatability가 Read Committed보다 강한 성질을 제공함
  • 다만 MySQL Repeatable Read가 정확히 어떤 consistency model인지 명확하지 않고, 공식적인 성질 정의도 없음

문서와 커뮤니티 이해의 불일치

  • MySQL 커뮤니티에서는 Repeatable Read 동작이 충분히 이해되지 않은 상태임
  • 여러 글은 MySQL Repeatable Read가 lost update를 막는다고 믿지만, 다른 글들은 막지 못한다고 보고 명시적 잠금 사용을 권함
  • 여러 인터넷 자료는 MySQL Repeatable Read가 실제로 repeatable하다고 말하지만, Jepsen의 테스트는 그렇지 않은 사례를 보임
  • MySQL과 MariaDB 문서도 Repeatable Read가 같은 트랜잭션 안에서 같은 스냅샷을 읽는다고 설명함
  • MySQL consistent read 문서의 한 문장은 이 설명과 충돌하는 동작을 암시하지만, 해당 내용은 문서 안에 묻혀 있음

권고 사항

  • MySQL이 현재 동작을 유지한다면 “Repeatable Read”가 실제로 어떤 consistency model을 제공하는지 명확히 문서화해야 함
  • 다른 선택지는 현재 동작을 버그로 다루고 수정하는 것임
  • MySQL과 다른 벤더가 PL-2.99 Repeatable Read 제공을 약속한다면 Jepsen은 이를 환영한다고 밝힘
  • PL-2.99 또는 ANSI Repeatable Read가 필요한 사용자는 MySQL Repeatable Read에 주의해야 함
  • 실무 대안은 다음과 같음
    • MySQL의 Serializable 격리 수준 사용
    • READ COMMITTED에서 SELECT ... FOR UPDATE 같은 잠금 기법으로 읽기 강화

RDS 사용자를 위한 권고

  • AWS RDS MySQL 클러스터는 “Serializable”에서 read skew와 G2-item을 보임
  • Serializability에 의존하는 사용자는 RDS parameter group에서 slave_preserve_commit_orderON으로 설정해야 함
  • AWS는 기본값을 바꾸거나, RDS MySQL의 known limitations 문서에 허용되는 Serializability 위반을 명확히 설명해야 한다는 제안이 나옴

향후 작업과 표준화 요청

  • MySQL binlog replication은 취약해 보였음
    • 로컬 Jepsen 테스트에서 replication이 멈추는 여러 상황이 관찰됨
    • AWS RDS MySQL replication은 몇 분의 테스트만으로 완전히 깨질 수 있었고, primary에서 성공한 CREATE DATABASE가 secondary에 나타나지 않는 상황이 1시간 동안 회복되지 않음
  • secondary를 primary로 승격하거나 ring, star 같은 복제 topology는 탐색하지 않음
  • predicate safety를 평가하기 위한 더 일반적인 predicate test 연구가 진행 중임
  • ANSI SQL 격리 수준 정의는 Berenson 등이 모호성과 불완전성을 지적한 지 28년이 지나고 7번의 ANSI·ISO 개정이 있었지만 바뀌지 않음
  • ISO/IEC 9075-2가 내부 anomaly, lost update, dirty write 같은 현상을 명확히 다룰 수 있도록 더 형식적이고 이식 가능한 격리 수준 정의가 필요함

댓글과 토론

Hacker News 의견들
  • repeatable read는 구현이 완벽해도 나쁜 아이디어라고 오래전부터 봐왔음
    데이터베이스 안에서 올바르게 동작하더라도, 복잡한 쿼리에서는 추론이 너무 까다로움
    말이 되는 격리 수준은 read committedserializable 둘뿐이라고 봄
    놀랄 일이 없게 끝까지 serializable로 가거나, 트랜잭션 안에서 일관된 뷰가 필요하면 읽기 전에 행을 잠가야 한다는 점이 명확한 read committed로 가야 함
    read committed는 일반적인 멀티스레드 코드와 메모리 관리에 가까워서 엔지니어들이 직관을 갖기 쉽고, serializable은 워낙 엄격해서 의외의 실수를 만들기 어렵다 봄
    그 중간은 무인지대이고, read committed보다 덜 일관적인 것은 더 이상 제대로 된 데이터베이스라고 보기 어렵다

    • read committed를 사람들이 잘 추론한다고 보진 않음
      애플리케이션이 커질수록 어디서 잠금이 잡히고 데이터가 접근되는지 모든 경우를 이해하기가 매우 어려워짐
      읽기/쓰기 트랜잭션에는 serializable만이 제정신인 격리 모델이고, 읽기 전용 트랜잭션에는 특정 시점의 데이터베이스 스냅샷을 다루는 snapshot isolation이 좋은 모델이라고 봄
      Spanner가 제공하는 모드도 사실상 이 둘뿐임: https://cloud.google.com/spanner/docs/transactions
    • read uncommitted는 집계 통계에는 괜찮지만, 그 정도라면 데이터를 ClickHouse로 흘려보내는 편이 더 나음
    • 읽기 전용 스냅샷 쿼리는 실제 시스템에서 매우 유용함
    • repeatable read가 실제로 제대로 동작한다면 굳이 행을 잠글 필요가 없을 것임
  • FOSSDEM 2024에 SQL 데이터베이스의 격리 수준과 MVCC를 비교한 발표가 있음
    Oracle, MySQL, SQL Server, PostgreSQL, YugabyteDB를 다룸
    https://fosdem.org/2024/schedule/event/fosdem-2024-3600-isol...

    • 발표자가 YugabyteDB에서 일하는 개발자 애드버킷인데, 이것이 Kyle의 작업과 어떻게 연결되는지 궁금함
  • append(a)가 주어진 테이블의 실제 SQL 연산으로는 어떻게 매핑되는지 궁금함
    TEXT 필드를 리스트처럼 쓰는 건가?
    MySQL repeatable read 모드에서 단일 행을 선택하는 단일 SELECT가 불가능한 결과를 반환한 적도 있음
    SELECT min(value), max(value) FROM table WHERE id = 1; 형태였고, id는 기본 키였는데 minmax가 서로 다른 값으로 나왔음

  • 글과 AWS RDS를 다뤄준 점은 좋았지만, AWS Aurora MySQL에도 초점이 있었는지 궁금함
    모르는 사람을 위해 말하면 AWS는 MySQL이나 PostgreSQL인 척하는 프로토콜 호환 데이터베이스 플랫폼을 만들었음
    Aurora MySQL이 RDS나 MariaDB와 같은 “특징”을 갖는지 보면 흥미로울 것 같음

    • Aurora는 완전히 다른 DB 엔진이라 동시성 이슈도 다르므로 여기서는 다루지 않았을 것임
      그래도 매우 흥미로운 대상이고, Aurora가 훨씬 새로운 데이터베이스인 만큼 오래된 MySQL보다 아직 발견되지 않은 미묘한 문제가 있을 것 같다는 직감이 듦
    • MySQL Aurora를 꽤 많이 쓰고 있고, 우리 용도에서는 사용량은 매우 높지만 쿼리 패턴이 단순해서 큰 차이는 잘 안 보임
      다만 큰 짜증거리 하나는 있음
      Plaid 엔지니어들이 차이를 잘 정리한 글을 썼음: https://plaid.com/blog/exploring-performance-differences-bet...
      내게 가장 큰 차이는 Aurora 클러스터가 공유 스토리지를 쓰기 때문에 격리 모델이 약간 다르다는 점임
      read committed는 클러스터 전체 파라미터를 설정해야만 가능하고, read uncommitted는 내가 보기엔 불가능함
  • 매우 흥미로운 글임
    그렇게 많은 일관성 이상 현상을 보이는 기반 위에서도 얼마나 많은 “실제로 동작하는 시스템”이 만들어질 수 있는지 잘 보여줌

    • 대부분의 시스템은 실질적으로 망가져 있고, 인간적인 보정으로 우회하며 굴러감
  • 5분 만에 건드렸더니 RDS 복제가 멈췄고, 실패한 헬스 체크 알림도 없었다는 부분은 좀 걱정됨

    • 세부 사항이 중요하고 스크린캐스트만으로는 거의 문제 해결이 불가능하지만, 내 경험상 AWS는 대체로 CloudWatch Metrics를 꽤 넉넉하게 제공함
      다만 사용자가 150개가 넘는 지표를 뒤지고 문서를 읽어 중요한 것을 찾아야 하는 부담을 떠넘기는 편임
      또한 <https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_...>에서는 복제 상태를 보여주는 콘솔 표 셀이 있다고 하지만, 콘솔에서는 종종 해당 열 표시를 사용자가 직접 켜야 해서 좋지 않음
      AWS가 말하는 “공동 책임 모델”에 상당히 많이 기대고 있음
    • 어떤 AWS 헬스 체크도 다운 상황의 1차 알림으로 믿으면 안 된다고 장담할 수 있음
      호스트나 컨테이너 내부에서 전부 직접 해야 함
      AWS/Rackspace 지원은 “AWS 서비스 안에서 동작하는 것은 우리가 관리하지 않으니 고객 문제”라고 말할 뿐임
  • 2022년에 Jepsen이 Porto 대학 INESC TEC에 LazyFS 개발을 의뢰했다는 부분이 좋음
    fsync되지 않은 쓰기 손실을 시뮬레이션하는 FUSE 파일시스템이라니, 기술 수준을 앞으로 밀어붙이는 훌륭한 예임

  • SELECT ... FOR UPDATE가 이런 문제들의 답처럼 보임
    업데이트할 행을 잠그면 갑자기 모든 것이 광고한 대로 동작하지 않나?

    • 일반적으로 행을 잠그는 연산은 repeatable read와 상관없이 값을 존재하게 “고정”하는 경향이 있음
      어떤 레코드를 다른 레코드의 데이터에 따라 업데이트하려면, 그 다른 레코드와 아마 업데이트할 레코드에도 잠금 읽기를 해야 함
      단일 SQL 쿼리로 다른 레코드에 기반해 레코드를 업데이트하면 MySQL이 어차피 둘 다 잠가줌
      여러 대상에 기반해 무언가를 업데이트해야 한다면, 내 경험상 교착 상태가 매우 잘 생김
      대신 잠금용 레코드 같은 것을 잠근 다음, 원하는 데이터에 repeatable read를 수행하고 업데이트하는 편이 낫다
      repeatable read의 시점은 일관 읽기를 수행할 때까지 정해지지 않음
      SELECT ... FOR UPDATE는 일관 읽기가 아니므로, 일반 SQL 업데이트로 수십·수백 행을 잠그지 않으면서도 동시성 상황에서 잘 동작함
    • 성능이 완전히 박살나도 괜찮다면 맞음
  • 내 경험상 대부분의 개발자는 애초에 격리 수준을 고려하지 않고 기본값을 그대로 씀
    경쟁 조건이 생기면 “어 이상하네” 하고 넘어감

    • 반박하고 싶지만, MongoDB의 성공적인 초기 시절이 그 말을 잘 증명함
    • 그래서 기본 격리 수준이 serializable이어야 한다고 말한 것임
      [1] https://news.ycombinator.com/item?id=38696421
    • 격리 문제는 추론하기 너무 어려워서, serializable 일관성보다 낮은 대부분의 것은 결국 여러 방식으로 발목을 잡게 됨
      그래서 대부분의 개발자는 격리 수준을 직접 고민하지 않는 편이 낫고, MySQL과 일부 데이터베이스는 평균적인 개발자에게 보장 수준을 너무 적게 제공한다고 봄
    • 내 경험상 거의 어떤 개발자도 일관성 자체를 고려하지 않음